mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 06:56:44 +08:00
feat: track official versions and import resources
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||
use bat_infrastructure::{
|
||||
lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root,
|
||||
validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
||||
OfficialVerificationSummary, PRIVATE_FILE_MODE,
|
||||
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state,
|
||||
validate_output_root, validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionState, PRIVATE_FILE_MODE,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
@@ -608,17 +608,7 @@ fn record_daemon_status(
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = update_daemon_status(
|
||||
state_dir,
|
||||
update.state,
|
||||
update.last_update_status,
|
||||
update.last_error,
|
||||
update.next_retry_seconds,
|
||||
update.last_success_unix_seconds,
|
||||
update.next_check_unix_seconds,
|
||||
update.pending_scheduled_force,
|
||||
update.next_forced_refresh_at,
|
||||
) {
|
||||
if let Err(error) = update_daemon_status(state_dir, update) {
|
||||
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
|
||||
}
|
||||
}
|
||||
@@ -731,10 +721,7 @@ fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Ok(PidLockState::Corrupt);
|
||||
}
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let Some(pid) = parse_pid_value(&contents) else {
|
||||
return Ok(PidLockState::Corrupt);
|
||||
};
|
||||
@@ -1322,6 +1309,8 @@ struct DaemonStatusReport {
|
||||
current_stage: Option<String>,
|
||||
current_message: Option<String>,
|
||||
download_progress: Option<DaemonDownloadProgress>,
|
||||
version_state_path: Option<PathBuf>,
|
||||
version_state: Option<OfficialVersionState>,
|
||||
pending_scheduled_force: Option<bool>,
|
||||
next_forced_refresh_unix_seconds: Option<u64>,
|
||||
command: Option<Vec<String>>,
|
||||
@@ -1600,6 +1589,14 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
|
||||
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
|
||||
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
|
||||
let version_state_path = status_file.as_ref().map(|status| {
|
||||
status
|
||||
.resource_output_root
|
||||
.join("official-version-state.json")
|
||||
});
|
||||
let version_state = version_state_path
|
||||
.as_ref()
|
||||
.and_then(|path| read_version_state(path).ok().flatten());
|
||||
|
||||
Ok(DaemonStatusReport {
|
||||
status: if running { "running" } else { "stopped" },
|
||||
@@ -1659,6 +1656,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
download_progress: status_file
|
||||
.as_ref()
|
||||
.and_then(|status| status.download_progress.clone()),
|
||||
version_state_path,
|
||||
version_state,
|
||||
pending_scheduled_force: status_file
|
||||
.as_ref()
|
||||
.map(|status| status.pending_scheduled_force),
|
||||
@@ -1896,6 +1895,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
||||
print_json_field(value, "current_stage", "当前阶段");
|
||||
print_json_field(value, "current_message", "当前消息");
|
||||
print_json_field(value, "download_progress", "下载进度");
|
||||
print_json_field(value, "version_state_path", "版本状态路径");
|
||||
print_json_field(value, "version_state", "版本状态");
|
||||
print_json_field(value, "resource_output_root", "资源目录");
|
||||
print_json_field(value, "state_dir", "状态目录");
|
||||
print_json_field(value, "socket_path", "socket");
|
||||
@@ -2102,6 +2103,7 @@ impl HumanReport for OfficialUpdateReport {
|
||||
print_path_field("输出根目录", &self.output_root);
|
||||
print_path_field("active release", &self.active_resource_root);
|
||||
print_path_field("current", &self.current_path);
|
||||
print_path_field("version state", &self.version_state_path);
|
||||
print_optional_path_field("staging", self.staging_path.as_ref());
|
||||
print_optional_path_field("published", self.published_version_path.as_ref());
|
||||
print_path_field("snapshot", &self.snapshot_path);
|
||||
@@ -2174,7 +2176,32 @@ impl HumanReport for DaemonStatusReport {
|
||||
if let Some(progress) = self.download_progress.as_ref() {
|
||||
print_field("下载进度", format_daemon_download_progress(progress));
|
||||
}
|
||||
if let Some(version_state) = self.version_state.as_ref() {
|
||||
print_optional_field(
|
||||
"当前完成版本",
|
||||
version_state
|
||||
.current_completed_version
|
||||
.as_ref()
|
||||
.map(|version| version.id.as_str()),
|
||||
);
|
||||
print_optional_field(
|
||||
"正在拉取版本",
|
||||
version_state
|
||||
.in_progress_version
|
||||
.as_ref()
|
||||
.map(|version| version.id.as_str()),
|
||||
);
|
||||
print_optional_field(
|
||||
"上一个可用版本",
|
||||
version_state
|
||||
.previous_available_version
|
||||
.as_ref()
|
||||
.map(|version| version.id.as_str()),
|
||||
);
|
||||
print_field("失败版本数", version_state.failed_versions.len());
|
||||
}
|
||||
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
|
||||
print_optional_path_field("版本状态", self.version_state_path.as_ref());
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
print_path_field("socket", &self.socket_path);
|
||||
print_optional_path_field("日志", self.log_path.as_ref());
|
||||
@@ -2499,29 +2526,26 @@ struct DoctorReport {
|
||||
}
|
||||
|
||||
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
let mut checks = Vec::new();
|
||||
checks.push(path_check(
|
||||
"state_dir",
|
||||
&options.state_dir,
|
||||
"后台状态目录可用",
|
||||
));
|
||||
checks.push(path_check(
|
||||
"output_root",
|
||||
&options.config.output_root,
|
||||
"资源输出目录可用",
|
||||
));
|
||||
checks.push(safety_check(
|
||||
"output_root_safety",
|
||||
validate_output_root(&options.config.output_root),
|
||||
"资源输出目录安全边界通过",
|
||||
));
|
||||
checks.push(safety_check(
|
||||
"state_dir_safety",
|
||||
validate_runtime_state_dir(&options.state_dir),
|
||||
"后台状态目录安全边界通过",
|
||||
));
|
||||
checks.push(command_check("curl", &options.config.curl_command));
|
||||
checks.push(command_check("unzip", &options.config.unzip_command));
|
||||
let mut checks = vec![
|
||||
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
|
||||
path_check(
|
||||
"output_root",
|
||||
&options.config.output_root,
|
||||
"资源输出目录可用",
|
||||
),
|
||||
safety_check(
|
||||
"output_root_safety",
|
||||
validate_output_root(&options.config.output_root),
|
||||
"资源输出目录安全边界通过",
|
||||
),
|
||||
safety_check(
|
||||
"state_dir_safety",
|
||||
validate_runtime_state_dir(&options.state_dir),
|
||||
"后台状态目录安全边界通过",
|
||||
),
|
||||
command_check("curl", &options.config.curl_command),
|
||||
command_check("unzip", &options.config.unzip_command),
|
||||
];
|
||||
|
||||
let pid_path = daemon_pid_path(&options.state_dir);
|
||||
let daemon_running = read_pid_file(&pid_path)
|
||||
@@ -2864,17 +2888,7 @@ fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_daemon_status(
|
||||
state_dir: &Path,
|
||||
state: &str,
|
||||
last_update_status: Option<String>,
|
||||
last_error: Option<String>,
|
||||
next_retry_seconds: Option<u64>,
|
||||
last_success_unix_seconds: Option<u64>,
|
||||
next_check_unix_seconds: Option<u64>,
|
||||
pending_scheduled_force: bool,
|
||||
next_forced_refresh_at: SystemTime,
|
||||
) -> anyhow::Result<()> {
|
||||
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
|
||||
version: DAEMON_STATUS_VERSION,
|
||||
@@ -2899,22 +2913,23 @@ fn update_daemon_status(
|
||||
command: env::args().collect(),
|
||||
});
|
||||
status.pid = std::process::id();
|
||||
status.state = state.to_string();
|
||||
status.state = update.state.to_string();
|
||||
status.updated_unix_seconds = unix_seconds_now();
|
||||
status.last_update_status = last_update_status;
|
||||
status.last_error = last_error;
|
||||
status.next_retry_seconds = next_retry_seconds;
|
||||
if last_success_unix_seconds.is_some() {
|
||||
status.last_success_unix_seconds = last_success_unix_seconds;
|
||||
status.last_update_status = update.last_update_status;
|
||||
status.last_error = update.last_error;
|
||||
status.next_retry_seconds = update.next_retry_seconds;
|
||||
if update.last_success_unix_seconds.is_some() {
|
||||
status.last_success_unix_seconds = update.last_success_unix_seconds;
|
||||
}
|
||||
status.next_check_unix_seconds = next_check_unix_seconds;
|
||||
if state != "running" {
|
||||
status.next_check_unix_seconds = update.next_check_unix_seconds;
|
||||
if update.state != "running" {
|
||||
status.current_stage = None;
|
||||
status.current_message = None;
|
||||
status.download_progress = None;
|
||||
}
|
||||
status.pending_scheduled_force = pending_scheduled_force;
|
||||
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
|
||||
status.pending_scheduled_force = update.pending_scheduled_force;
|
||||
status.next_forced_refresh_unix_seconds =
|
||||
system_time_to_unix_seconds(update.next_forced_refresh_at);
|
||||
write_daemon_status_file(&status_path, &status)
|
||||
}
|
||||
|
||||
@@ -3130,11 +3145,11 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||
|
||||
fn format_duration_arg(duration: Duration) -> String {
|
||||
let millis = duration.as_millis();
|
||||
if millis % 3_600_000 == 0 {
|
||||
if millis.is_multiple_of(3_600_000) {
|
||||
format!("{}h", millis / 3_600_000)
|
||||
} else if millis % 60_000 == 0 {
|
||||
} else if millis.is_multiple_of(60_000) {
|
||||
format!("{}m", millis / 60_000)
|
||||
} else if millis % 1_000 == 0 {
|
||||
} else if millis.is_multiple_of(1_000) {
|
||||
format!("{}s", millis / 1_000)
|
||||
} else {
|
||||
format!("{millis}ms")
|
||||
@@ -3825,7 +3840,7 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
|
||||
"verify 只验证本地资源,不能和 --force 同时使用"
|
||||
));
|
||||
}
|
||||
if options.config.repair == false && options.config.audit_local == false {
|
||||
if !options.config.repair && !options.config.audit_local {
|
||||
return Err(anyhow::anyhow!(
|
||||
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
|
||||
));
|
||||
@@ -3854,7 +3869,7 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()>
|
||||
}
|
||||
|
||||
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
|
||||
config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip")
|
||||
config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip")
|
||||
}
|
||||
|
||||
fn next_option_value(
|
||||
@@ -4503,14 +4518,16 @@ mod tests {
|
||||
let next_check = unix_seconds_after(Duration::from_secs(30));
|
||||
update_daemon_status(
|
||||
&state_dir,
|
||||
"sleeping",
|
||||
Some("downloaded".to_string()),
|
||||
None,
|
||||
Some(30),
|
||||
Some(123456),
|
||||
Some(next_check),
|
||||
false,
|
||||
SystemTime::now() + Duration::from_secs(3600),
|
||||
DaemonStatusUpdate {
|
||||
state: "sleeping",
|
||||
last_update_status: Some("downloaded".to_string()),
|
||||
last_error: None,
|
||||
next_retry_seconds: Some(30),
|
||||
last_success_unix_seconds: Some(123456),
|
||||
next_check_unix_seconds: Some(next_check),
|
||||
pending_scheduled_force: false,
|
||||
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let report = build_daemon_status_report(&state_dir).unwrap();
|
||||
|
||||
+235
-28
@@ -4,6 +4,7 @@ use bat_adapters::manifest::GenericManifest;
|
||||
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
|
||||
use bat_core::domain::{Resource, ResourceType};
|
||||
use bat_core::repositories::{CasRepository, ResourceRepository};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 待导入的 AssetBundle 数据。
|
||||
@@ -32,12 +33,47 @@ pub struct ImportedResource {
|
||||
pub id: String,
|
||||
/// Manifest 中的资源路径。
|
||||
pub source_path: String,
|
||||
/// Manifest 或路径推断出的资源类型。
|
||||
pub resource_type: ResourceType,
|
||||
/// 导入链路使用的稳定分类标签。
|
||||
pub category: ResourceImportCategory,
|
||||
/// CAS 返回的对象 ID。
|
||||
pub object_id: String,
|
||||
/// 写入 CAS 的字节数。
|
||||
pub bytes: u64,
|
||||
/// UnityFS 解析摘要。
|
||||
pub unityfs: UnityFsImportSummary,
|
||||
/// UnityFS 解析摘要,仅 AssetBundle 资源会填充。
|
||||
pub unityfs: Option<UnityFsImportSummary>,
|
||||
}
|
||||
|
||||
/// Stable resource category produced by the import pipeline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ResourceImportCategory {
|
||||
/// UnityFS AssetBundle resource.
|
||||
AssetBundle,
|
||||
/// Unity TextAsset or text-like payload.
|
||||
TextAsset,
|
||||
/// Table bundle/database resource.
|
||||
Table,
|
||||
/// Media payload such as audio, video, texture, or media archive.
|
||||
Media,
|
||||
/// Manifest/catalog sidecar.
|
||||
Manifest,
|
||||
/// Resource that does not match a known category yet.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ResourceImportCategory {
|
||||
/// Returns a stable lowercase label for reports and golden tests.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AssetBundle => "asset_bundle",
|
||||
Self::TextAsset => "text_asset",
|
||||
Self::Table => "table",
|
||||
Self::Media => "media",
|
||||
Self::Manifest => "manifest",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入时解析到的 UnityFS 基础结构摘要。
|
||||
@@ -58,8 +94,10 @@ pub struct UnityFsImportSummary {
|
||||
pub struct ResourceImportReport {
|
||||
/// 成功导入的资源。
|
||||
pub imported: Vec<ImportedResource>,
|
||||
/// 被跳过的非 AssetBundle 资源路径。
|
||||
/// 被跳过的资源路径。
|
||||
pub skipped: Vec<String>,
|
||||
/// 按稳定分类标签统计的成功导入数量。
|
||||
pub category_counts: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
|
||||
@@ -88,10 +126,12 @@ impl<'a> ResourceImportService<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入 manifest 中的 AssetBundle 资源。
|
||||
/// 导入 manifest 中可用的资源数据。
|
||||
///
|
||||
/// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
|
||||
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
|
||||
/// AssetBundle 条目必须能在 `bundles` 中按完整路径或文件名找到对应
|
||||
/// 数据并成功解析 UnityFS,否则返回错误。TextAsset/Table/Media 等
|
||||
/// 非 AssetBundle 条目在有数据时写入 CAS 和 `ResourceRepository`;缺少
|
||||
/// 数据时记录到 `skipped`,用于渐进式导入官方下载结果。
|
||||
pub async fn import_manifest_bundles(
|
||||
&self,
|
||||
manifest: &GenericManifest,
|
||||
@@ -100,18 +140,23 @@ impl<'a> ResourceImportService<'a> {
|
||||
let mut report = ResourceImportReport::default();
|
||||
|
||||
for entry in &manifest.resources {
|
||||
if entry.resource_type != ResourceType::AssetBundle {
|
||||
let category = classify_import_category(entry.resource_type, &entry.path);
|
||||
let Some(bundle) = find_bundle(bundles, &entry.path) else {
|
||||
if entry.resource_type == ResourceType::AssetBundle {
|
||||
return Err(bat_core::Error::InvalidArgument(format!(
|
||||
"Missing bundle data for manifest resource: {}",
|
||||
entry.path
|
||||
)));
|
||||
}
|
||||
report.skipped.push(entry.path.clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
|
||||
bat_core::Error::InvalidArgument(format!(
|
||||
"Missing bundle data for manifest resource: {}",
|
||||
entry.path
|
||||
))
|
||||
})?;
|
||||
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
|
||||
let unityfs = if entry.resource_type == ResourceType::AssetBundle {
|
||||
Some(self.parse_unityfs_summary(&entry.path, bundle).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let object_id = self.cas.store(&bundle.data).await?;
|
||||
let mut stored_entry = entry.clone();
|
||||
@@ -128,10 +173,16 @@ impl<'a> ResourceImportService<'a> {
|
||||
report.imported.push(ImportedResource {
|
||||
id,
|
||||
source_path: entry.path.clone(),
|
||||
resource_type: entry.resource_type,
|
||||
category,
|
||||
object_id,
|
||||
bytes: bundle.data.len() as u64,
|
||||
unityfs,
|
||||
});
|
||||
*report
|
||||
.category_counts
|
||||
.entry(category.as_str().to_string())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
@@ -196,6 +247,43 @@ fn file_name(path: &str) -> Option<&str> {
|
||||
Path::new(path).file_name()?.to_str()
|
||||
}
|
||||
|
||||
fn classify_import_category(resource_type: ResourceType, path: &str) -> ResourceImportCategory {
|
||||
match resource_type {
|
||||
ResourceType::AssetBundle => ResourceImportCategory::AssetBundle,
|
||||
ResourceType::TextAsset => ResourceImportCategory::TextAsset,
|
||||
ResourceType::TableBundle => ResourceImportCategory::Table,
|
||||
ResourceType::Media => ResourceImportCategory::Media,
|
||||
ResourceType::Manifest => ResourceImportCategory::Manifest,
|
||||
ResourceType::Other => classify_import_category_from_path(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_import_category_from_path(path: &str) -> ResourceImportCategory {
|
||||
let normalized = path.replace('\\', "/").to_ascii_lowercase();
|
||||
if normalized.contains("tablebundles/") {
|
||||
ResourceImportCategory::Table
|
||||
} else if normalized.contains("mediaresources/")
|
||||
|| normalized.contains("mediaresources-")
|
||||
|| matches!(
|
||||
normalized.rsplit('.').next(),
|
||||
Some("mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb")
|
||||
)
|
||||
{
|
||||
ResourceImportCategory::Media
|
||||
} else if normalized.contains("textassets/")
|
||||
|| matches!(
|
||||
normalized.rsplit('.').next(),
|
||||
Some("txt" | "csv" | "xml" | "yaml" | "yml")
|
||||
)
|
||||
{
|
||||
ResourceImportCategory::TextAsset
|
||||
} else if normalized.contains("catalog") || normalized.ends_with(".hash") {
|
||||
ResourceImportCategory::Manifest
|
||||
} else {
|
||||
ResourceImportCategory::Other
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -287,6 +375,30 @@ mod tests {
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "TextAssets/dialogue.csv".to_string(),
|
||||
hash: "synthetic-text-hash".to_string(),
|
||||
size: 1,
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: Some("dialogue".to_string()),
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "TableBundles/ExcelDB.db".to_string(),
|
||||
hash: "synthetic-table-hash".to_string(),
|
||||
size: 1,
|
||||
resource_type: ResourceType::TableBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "MediaResources-Windows/voice/title.acb".to_string(),
|
||||
hash: "synthetic-media-hash".to_string(),
|
||||
size: 1,
|
||||
resource_type: ResourceType::Media,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
],
|
||||
metadata: ManifestMetadata {
|
||||
locator_id: None,
|
||||
@@ -306,32 +418,78 @@ mod tests {
|
||||
let report = service
|
||||
.import_manifest_bundles(
|
||||
&synthetic_manifest(),
|
||||
&[BundleSource::new(
|
||||
"minimal.bundle",
|
||||
synthetic_minimal_unityfs_bundle(),
|
||||
)],
|
||||
&[
|
||||
BundleSource::new("minimal.bundle", synthetic_minimal_unityfs_bundle()),
|
||||
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
|
||||
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
|
||||
BundleSource::new("title.acb", b"media-fixture".to_vec()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.imported.len(), 1);
|
||||
assert_eq!(report.imported.len(), 4);
|
||||
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
|
||||
assert!(cas.exists(&report.imported[0].object_id).await);
|
||||
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
|
||||
assert_eq!(report.imported[0].unityfs.block_count, 1);
|
||||
assert_eq!(report.imported[0].unityfs.directory_count, 1);
|
||||
assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle);
|
||||
assert_eq!(
|
||||
report.imported[0].unityfs.directories,
|
||||
vec!["SYNTHETIC-CAB".to_string()]
|
||||
report.imported[0].category,
|
||||
ResourceImportCategory::AssetBundle
|
||||
);
|
||||
let unityfs = report.imported[0].unityfs.as_ref().unwrap();
|
||||
assert_eq!(unityfs.unity_version, "2021.3.56f2");
|
||||
assert_eq!(unityfs.block_count, 1);
|
||||
assert_eq!(unityfs.directory_count, 1);
|
||||
assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]);
|
||||
assert_eq!(
|
||||
report.imported[1].category,
|
||||
ResourceImportCategory::TextAsset
|
||||
);
|
||||
assert_eq!(report.imported[2].category, ResourceImportCategory::Table);
|
||||
assert_eq!(report.imported[3].category, ResourceImportCategory::Media);
|
||||
assert_eq!(report.category_counts.get("asset_bundle"), Some(&1));
|
||||
assert_eq!(report.category_counts.get("text_asset"), Some(&1));
|
||||
assert_eq!(report.category_counts.get("table"), Some(&1));
|
||||
assert_eq!(report.category_counts.get("media"), Some(&1));
|
||||
|
||||
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
|
||||
assert_eq!(indexed.len(), 1);
|
||||
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
|
||||
assert_eq!(indexed.len(), 4);
|
||||
let indexed_bundle = indexed
|
||||
.iter()
|
||||
.find(|resource| resource.entry.path == "synthetic/minimal.bundle")
|
||||
.unwrap();
|
||||
assert_eq!(indexed_bundle.entry.hash, report.imported[0].object_id);
|
||||
assert_eq!(
|
||||
indexed[0].entry.size,
|
||||
indexed_bundle.entry.size,
|
||||
synthetic_minimal_unityfs_bundle().len() as u64
|
||||
);
|
||||
assert_eq!(
|
||||
indexed
|
||||
.iter()
|
||||
.find(|resource| resource.entry.path.ends_with("title.acb"))
|
||||
.unwrap()
|
||||
.entry
|
||||
.resource_type,
|
||||
ResourceType::Media
|
||||
);
|
||||
assert_eq!(
|
||||
indexed
|
||||
.iter()
|
||||
.find(|resource| resource.entry.path.ends_with("ExcelDB.db"))
|
||||
.unwrap()
|
||||
.entry
|
||||
.resource_type,
|
||||
ResourceType::TableBundle
|
||||
);
|
||||
assert_eq!(
|
||||
indexed
|
||||
.iter()
|
||||
.find(|resource| resource.entry.path.ends_with("dialogue.csv"))
|
||||
.unwrap()
|
||||
.entry
|
||||
.resource_type,
|
||||
ResourceType::TextAsset
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -370,4 +528,53 @@ mod tests {
|
||||
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
|
||||
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn indexes_non_bundle_resources_without_unityfs_summary() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
|
||||
let resources = InMemoryResourceRepository::new();
|
||||
let service = ResourceImportService::new(&cas, &resources);
|
||||
let mut manifest = synthetic_manifest();
|
||||
manifest
|
||||
.resources
|
||||
.retain(|entry| entry.resource_type != ResourceType::AssetBundle);
|
||||
|
||||
let report = service
|
||||
.import_manifest_bundles(
|
||||
&manifest,
|
||||
&[
|
||||
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
|
||||
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
|
||||
BundleSource::new("title.acb", b"media-fixture".to_vec()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.imported.len(), 3);
|
||||
assert!(report.imported.iter().all(|item| item.unityfs.is_none()));
|
||||
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
|
||||
assert_eq!(
|
||||
resources
|
||||
.count(ResourceQuery::by_type(ResourceType::TextAsset))
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
resources
|
||||
.count(ResourceQuery::by_type(ResourceType::TableBundle))
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
resources
|
||||
.count(ResourceQuery::by_type(ResourceType::Media))
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ pub mod resources;
|
||||
mod zip_validation;
|
||||
|
||||
pub use cas::FileSystemCasRepository;
|
||||
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
|
||||
pub use import::{
|
||||
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
||||
ResourceImportService,
|
||||
};
|
||||
pub use official_download::{
|
||||
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
||||
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
|
||||
@@ -48,11 +51,13 @@ pub use official_sync::{
|
||||
};
|
||||
pub use official_update::{
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
|
||||
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
|
||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
|
||||
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, ResolvedBootstrap,
|
||||
read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state,
|
||||
ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
||||
ResolvedBootstrap,
|
||||
};
|
||||
pub use path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
||||
|
||||
@@ -2787,6 +2787,59 @@ exit 22
|
||||
assert!(service.read_download_manifest().unwrap().entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_http_failure_regression_fixtures_are_enforced() {
|
||||
for fixture in [
|
||||
include_str!("../tests/fixtures/official_regression/http_403.json"),
|
||||
include_str!("../tests/fixtures/official_regression/http_404.json"),
|
||||
] {
|
||||
let fixture: serde_json::Value = serde_json::from_str(fixture).unwrap();
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
let bin_dir = TempDir::new().unwrap();
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
let status = fixture["http_status"].as_u64().unwrap() as u16;
|
||||
write_fake_http_error_curl(&curl_path, status);
|
||||
|
||||
let service =
|
||||
OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
|
||||
.with_retry_attempts(3);
|
||||
let url = fixture["url"].as_str().unwrap();
|
||||
let error = service.pull(&one_url_plan(url)).unwrap_err();
|
||||
let expected_kind = fixture["failure_kind"].as_str().unwrap();
|
||||
let expected_retryable = fixture["retryable"].as_bool().unwrap();
|
||||
let expected_attempts = fixture["expected_attempts"].as_u64().unwrap();
|
||||
|
||||
assert!(error.contains(expected_kind));
|
||||
assert!(error.contains(&format!("retryable={expected_retryable}")));
|
||||
assert_eq!(
|
||||
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
|
||||
expected_attempts.to_string()
|
||||
);
|
||||
let quarantine = service.read_download_quarantine().unwrap();
|
||||
let entry = quarantine.entries.get(url).unwrap();
|
||||
assert_eq!(entry.failure_kind.as_deref(), Some(expected_kind));
|
||||
assert_eq!(entry.retryable, Some(expected_retryable));
|
||||
assert_eq!(entry.attempts, expected_attempts as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_hash_mismatch_regression_fixture_is_enforced() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../tests/fixtures/official_regression/hash_mismatch_catalog.json"
|
||||
))
|
||||
.unwrap();
|
||||
let error = verify_official_seed_catalog_hash(
|
||||
fixture["data_url"].as_str().unwrap(),
|
||||
fixture["data"].as_str().unwrap().as_bytes(),
|
||||
fixture["hash_url"].as_str().unwrap(),
|
||||
fixture["official_hash"].as_str().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains(fixture["expected_error_contains"].as_str().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_http_5xx_before_quarantine() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -37,11 +37,14 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
|
||||
/// Current official bootstrap cache schema version.
|
||||
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
|
||||
/// Current official version-state schema version.
|
||||
pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1;
|
||||
const OFFICIAL_CURRENT_LINK: &str = "current";
|
||||
const OFFICIAL_VERSIONS_DIR: &str = "versions";
|
||||
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
||||
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
||||
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
|
||||
const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json";
|
||||
|
||||
/// Server-info input for an official update run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -125,6 +128,11 @@ impl OfficialUpdateConfig {
|
||||
self.output_root.join("official-bootstrap-cache.json")
|
||||
}
|
||||
|
||||
/// Returns the persistent version-state path for this config.
|
||||
pub fn version_state_path(&self) -> PathBuf {
|
||||
self.output_root.join(OFFICIAL_VERSION_STATE_FILE)
|
||||
}
|
||||
|
||||
/// Returns the lock path used for non-dry-run executions.
|
||||
pub fn lock_path(&self) -> PathBuf {
|
||||
self.output_root.join(".official-sync.lock")
|
||||
@@ -181,6 +189,82 @@ pub struct OfficialUpdateSnapshot {
|
||||
pub game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
|
||||
}
|
||||
|
||||
/// Persistent version state for an official resource output root.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialVersionState {
|
||||
/// State schema version.
|
||||
#[serde(default = "default_version_state_version")]
|
||||
pub state_version: u32,
|
||||
/// Last successfully published version.
|
||||
#[serde(default)]
|
||||
pub current_completed_version: Option<OfficialVersionRecord>,
|
||||
/// Version currently being downloaded into staging.
|
||||
#[serde(default)]
|
||||
pub in_progress_version: Option<OfficialVersionRecord>,
|
||||
/// Previously usable version before the latest successful publish.
|
||||
#[serde(default)]
|
||||
pub previous_available_version: Option<OfficialVersionRecord>,
|
||||
/// Recent versions that failed after entering staging.
|
||||
#[serde(default)]
|
||||
pub failed_versions: Vec<OfficialFailedVersionRecord>,
|
||||
/// Last time this state file was updated.
|
||||
pub updated_unix_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for OfficialVersionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state_version: OFFICIAL_VERSION_STATE_VERSION,
|
||||
current_completed_version: None,
|
||||
in_progress_version: None,
|
||||
previous_available_version: None,
|
||||
failed_versions: Vec::new(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One version tracked by `official-version-state.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialVersionRecord {
|
||||
/// Stable publish ID under `versions/<id>`.
|
||||
pub id: String,
|
||||
/// Selected app version.
|
||||
pub app_version: String,
|
||||
/// Selected bundle version, when present.
|
||||
pub bundle_version: Option<String>,
|
||||
/// Selected Addressables root.
|
||||
pub addressables_root: String,
|
||||
/// Resource root for this version. For in-progress versions this is a
|
||||
/// staging path; for completed versions it is a versioned path.
|
||||
pub resource_root: PathBuf,
|
||||
/// Snapshot path associated with the resource root.
|
||||
pub snapshot_path: PathBuf,
|
||||
/// Staging path, when the version is still being downloaded.
|
||||
#[serde(default)]
|
||||
pub staging_path: Option<PathBuf>,
|
||||
/// Versioned path, when known.
|
||||
#[serde(default)]
|
||||
pub version_path: Option<PathBuf>,
|
||||
/// Start time for a staged pull.
|
||||
#[serde(default)]
|
||||
pub started_unix_seconds: Option<u64>,
|
||||
/// Completion time for a published version.
|
||||
#[serde(default)]
|
||||
pub completed_unix_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// Failed version entry tracked after a staged pull or publish error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialFailedVersionRecord {
|
||||
/// Version that failed.
|
||||
pub version: OfficialVersionRecord,
|
||||
/// Human-readable failure reason.
|
||||
pub error: String,
|
||||
/// Failure time.
|
||||
pub failed_unix_seconds: u64,
|
||||
}
|
||||
|
||||
impl OfficialUpdateSnapshot {
|
||||
/// Creates an update snapshot from a base sync snapshot and extended data.
|
||||
pub fn new(
|
||||
@@ -391,6 +475,8 @@ pub struct OfficialUpdateReport {
|
||||
pub active_resource_root: PathBuf,
|
||||
/// Atomic `current` pointer path.
|
||||
pub current_path: PathBuf,
|
||||
/// Persistent version-state path.
|
||||
pub version_state_path: PathBuf,
|
||||
/// Versioned release directory after a successful publish.
|
||||
pub published_version_path: Option<PathBuf>,
|
||||
/// Staging directory used during download before publish.
|
||||
@@ -754,6 +840,10 @@ impl OfficialUpdateService {
|
||||
progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
|
||||
Some(lock)
|
||||
};
|
||||
let version_state_path = config.version_state_path();
|
||||
if !config.dry_run {
|
||||
recover_interrupted_version_state(&version_state_path)?;
|
||||
}
|
||||
check_shutdown_requested(&mut should_cancel)?;
|
||||
|
||||
let publish_layout = OfficialPublishLayout::new(&config.output_root);
|
||||
@@ -1035,6 +1125,7 @@ impl OfficialUpdateService {
|
||||
output_root: config.output_root.clone(),
|
||||
active_resource_root: active_resource_root.clone(),
|
||||
current_path: publish_layout.current_path.clone(),
|
||||
version_state_path: version_state_path.clone(),
|
||||
published_version_path: None,
|
||||
staging_path: None,
|
||||
snapshot_path: snapshot_path.clone(),
|
||||
@@ -1079,6 +1170,27 @@ impl OfficialUpdateService {
|
||||
"audit",
|
||||
verification_progress_message(&report.verification_summary),
|
||||
));
|
||||
if !config.dry_run {
|
||||
let current_record = version_record_for_snapshot(
|
||||
¤t_update_snapshot,
|
||||
VersionRecordInput {
|
||||
id: version_id_from_path(&active_resource_root)
|
||||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot)),
|
||||
resource_root: active_resource_root.clone(),
|
||||
snapshot_path: snapshot_path.clone(),
|
||||
staging_path: None,
|
||||
version_path: Some(active_resource_root.clone()),
|
||||
started_unix_seconds: None,
|
||||
completed_unix_seconds: Some(unix_seconds_now()),
|
||||
},
|
||||
);
|
||||
complete_version_state(
|
||||
&version_state_path,
|
||||
current_record,
|
||||
&active_resource_root,
|
||||
&snapshot_path,
|
||||
)?;
|
||||
}
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"finish",
|
||||
"资源已是最新;无需下载",
|
||||
@@ -1129,11 +1241,22 @@ impl OfficialUpdateService {
|
||||
publish_layout
|
||||
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
|
||||
let in_progress_record = prepare_in_progress_version_state(
|
||||
&version_state_path,
|
||||
¤t_update_snapshot,
|
||||
&publish_plan.id,
|
||||
&active_resource_root,
|
||||
previous_snapshot.as_ref(),
|
||||
&publish_plan.staging_path,
|
||||
&staging_snapshot_path,
|
||||
)?;
|
||||
let mut version_state_guard =
|
||||
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
|
||||
let staging_fetcher = OfficialResourcePullService::with_curl_command(
|
||||
&publish_plan.staging_path,
|
||||
&config.curl_command,
|
||||
);
|
||||
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
|
||||
report.staging_path = Some(publish_plan.staging_path.clone());
|
||||
report.snapshot_path = staging_snapshot_path.clone();
|
||||
report.download_manifest = staging_fetcher.download_manifest_path();
|
||||
@@ -1150,7 +1273,10 @@ impl OfficialUpdateService {
|
||||
},
|
||||
&mut should_cancel,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
.map_err(|error| anyhow::anyhow!(error))
|
||||
.inspect_err(|error| {
|
||||
let _ = version_state_guard.fail(&error.to_string());
|
||||
})?;
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"download",
|
||||
format!(
|
||||
@@ -1217,7 +1343,15 @@ impl OfficialUpdateService {
|
||||
report.local_manifest_verified_count = final_audit.verified_count();
|
||||
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
|
||||
report.verification_summary = final_verification_summary;
|
||||
report.snapshot_written = Some(final_snapshot_path);
|
||||
report.snapshot_written = Some(final_snapshot_path.clone());
|
||||
let completed_record = version_state_guard.record()?.clone();
|
||||
complete_version_state(
|
||||
&version_state_path,
|
||||
completed_record,
|
||||
&published_version_path,
|
||||
&final_snapshot_path,
|
||||
)?;
|
||||
version_state_guard.commit();
|
||||
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"finish",
|
||||
@@ -1505,6 +1639,11 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
||||
&config.bootstrap_cache_path(),
|
||||
"官方启动缓存",
|
||||
)?;
|
||||
ensure_safe_file_target(
|
||||
&config.output_root,
|
||||
&config.version_state_path(),
|
||||
"官方版本状态",
|
||||
)?;
|
||||
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1725,6 +1864,30 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the persistent official version state.
|
||||
pub fn read_version_state(path: &Path) -> anyhow::Result<Option<OfficialVersionState>> {
|
||||
let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let state: OfficialVersionState = serde_json::from_slice(&data)?;
|
||||
if state.state_version != OFFICIAL_VERSION_STATE_VERSION {
|
||||
return Err(anyhow::anyhow!(
|
||||
"不支持的官方版本状态 schema:{},当前版本={}",
|
||||
state.state_version,
|
||||
OFFICIAL_VERSION_STATE_VERSION
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
/// Writes the persistent official version state atomically.
|
||||
pub fn write_version_state(path: &Path, state: &OfficialVersionState) -> anyhow::Result<()> {
|
||||
let data = serde_json::to_vec_pretty(state)?;
|
||||
write_file_atomic(path, &data, STATE_FILE_MODE, "官方版本状态").map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an official bootstrap cache from disk.
|
||||
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
|
||||
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
|
||||
@@ -1763,6 +1926,227 @@ fn default_bootstrap_cache_version() -> u32 {
|
||||
OFFICIAL_BOOTSTRAP_CACHE_VERSION
|
||||
}
|
||||
|
||||
fn default_version_state_version() -> u32 {
|
||||
OFFICIAL_VERSION_STATE_VERSION
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct VersionRecordInput {
|
||||
id: String,
|
||||
resource_root: PathBuf,
|
||||
snapshot_path: PathBuf,
|
||||
staging_path: Option<PathBuf>,
|
||||
version_path: Option<PathBuf>,
|
||||
started_unix_seconds: Option<u64>,
|
||||
completed_unix_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
fn version_record_for_snapshot(
|
||||
snapshot: &OfficialUpdateSnapshot,
|
||||
input: VersionRecordInput,
|
||||
) -> OfficialVersionRecord {
|
||||
OfficialVersionRecord {
|
||||
id: input.id,
|
||||
app_version: snapshot.app_version.clone(),
|
||||
bundle_version: snapshot.bundle_version.clone(),
|
||||
addressables_root: snapshot.addressables_root.clone(),
|
||||
resource_root: input.resource_root,
|
||||
snapshot_path: input.snapshot_path,
|
||||
staging_path: input.staging_path,
|
||||
version_path: input.version_path,
|
||||
started_unix_seconds: input.started_unix_seconds,
|
||||
completed_unix_seconds: input.completed_unix_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> {
|
||||
let Some(mut state) = read_version_state(path)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(version) = state.in_progress_version.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
state.failed_versions.push(OfficialFailedVersionRecord {
|
||||
version,
|
||||
error: "上一次官方资源同步在完成发布前中断".to_string(),
|
||||
failed_unix_seconds: unix_seconds_now(),
|
||||
});
|
||||
trim_failed_versions(&mut state.failed_versions);
|
||||
state.updated_unix_seconds = unix_seconds_now();
|
||||
write_version_state(path, &state)
|
||||
}
|
||||
|
||||
fn prepare_in_progress_version_state(
|
||||
path: &Path,
|
||||
snapshot: &OfficialUpdateSnapshot,
|
||||
publish_id: &str,
|
||||
active_resource_root: &Path,
|
||||
previous_snapshot: Option<&OfficialUpdateSnapshot>,
|
||||
staging_path: &Path,
|
||||
snapshot_path: &Path,
|
||||
) -> anyhow::Result<OfficialVersionRecord> {
|
||||
let mut state = read_version_state(path)?.unwrap_or_default();
|
||||
let fallback_current = previous_snapshot.map(|snapshot| {
|
||||
version_record_for_snapshot(
|
||||
snapshot,
|
||||
VersionRecordInput {
|
||||
id: version_id_from_path(active_resource_root)
|
||||
.unwrap_or_else(|| fallback_version_id(snapshot)),
|
||||
resource_root: active_resource_root.to_path_buf(),
|
||||
snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root),
|
||||
staging_path: None,
|
||||
version_path: Some(active_resource_root.to_path_buf()),
|
||||
started_unix_seconds: None,
|
||||
completed_unix_seconds: Some(unix_seconds_now()),
|
||||
},
|
||||
)
|
||||
});
|
||||
if state.current_completed_version.is_none() {
|
||||
state.current_completed_version = fallback_current;
|
||||
}
|
||||
|
||||
let now = unix_seconds_now();
|
||||
let record = version_record_for_snapshot(
|
||||
snapshot,
|
||||
VersionRecordInput {
|
||||
id: publish_id.to_string(),
|
||||
resource_root: staging_path.to_path_buf(),
|
||||
snapshot_path: snapshot_path.to_path_buf(),
|
||||
staging_path: Some(staging_path.to_path_buf()),
|
||||
version_path: None,
|
||||
started_unix_seconds: Some(now),
|
||||
completed_unix_seconds: None,
|
||||
},
|
||||
);
|
||||
state.in_progress_version = Some(record.clone());
|
||||
state.updated_unix_seconds = now;
|
||||
write_version_state(path, &state)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn complete_version_state(
|
||||
path: &Path,
|
||||
record: OfficialVersionRecord,
|
||||
published_path: &Path,
|
||||
snapshot_path: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut state = read_version_state(path)?.unwrap_or_default();
|
||||
let previous = state
|
||||
.current_completed_version
|
||||
.take()
|
||||
.filter(|previous| previous.id != record.id);
|
||||
if previous.is_some() {
|
||||
state.previous_available_version = previous;
|
||||
}
|
||||
state.current_completed_version = Some(OfficialVersionRecord {
|
||||
resource_root: published_path.to_path_buf(),
|
||||
snapshot_path: snapshot_path.to_path_buf(),
|
||||
staging_path: None,
|
||||
version_path: Some(published_path.to_path_buf()),
|
||||
completed_unix_seconds: Some(unix_seconds_now()),
|
||||
..record
|
||||
});
|
||||
state.in_progress_version = None;
|
||||
state.updated_unix_seconds = unix_seconds_now();
|
||||
write_version_state(path, &state)
|
||||
}
|
||||
|
||||
fn fail_version_state(
|
||||
path: &Path,
|
||||
record: OfficialVersionRecord,
|
||||
error: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut state = read_version_state(path)?.unwrap_or_default();
|
||||
state.in_progress_version = None;
|
||||
state.failed_versions.push(OfficialFailedVersionRecord {
|
||||
version: record,
|
||||
error: error.to_string(),
|
||||
failed_unix_seconds: unix_seconds_now(),
|
||||
});
|
||||
trim_failed_versions(&mut state.failed_versions);
|
||||
state.updated_unix_seconds = unix_seconds_now();
|
||||
write_version_state(path, &state)
|
||||
}
|
||||
|
||||
struct VersionStateGuard {
|
||||
path: PathBuf,
|
||||
record: Option<OfficialVersionRecord>,
|
||||
}
|
||||
|
||||
impl VersionStateGuard {
|
||||
fn new(path: PathBuf, record: OfficialVersionRecord) -> Self {
|
||||
Self {
|
||||
path,
|
||||
record: Some(record),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&self) -> anyhow::Result<&OfficialVersionRecord> {
|
||||
self.record
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("官方版本状态事务已结束"))
|
||||
}
|
||||
|
||||
fn fail(&mut self, error: &str) -> anyhow::Result<()> {
|
||||
let Some(record) = self.record.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
fail_version_state(&self.path, record, error)
|
||||
}
|
||||
|
||||
fn commit(&mut self) {
|
||||
self.record = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VersionStateGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(record) = self.record.take() else {
|
||||
return;
|
||||
};
|
||||
let _ = fail_version_state(
|
||||
&self.path,
|
||||
record,
|
||||
"官方资源同步未完成;已将该版本记录为失败,保留当前可用版本",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_failed_versions(failed_versions: &mut Vec<OfficialFailedVersionRecord>) {
|
||||
const MAX_FAILED_VERSIONS: usize = 8;
|
||||
if failed_versions.len() > MAX_FAILED_VERSIONS {
|
||||
let drop_count = failed_versions.len() - MAX_FAILED_VERSIONS;
|
||||
failed_versions.drain(0..drop_count);
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_path_for_state_path(
|
||||
_snapshot: &OfficialUpdateSnapshot,
|
||||
resource_root: &Path,
|
||||
) -> PathBuf {
|
||||
resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE)
|
||||
}
|
||||
|
||||
fn version_id_from_path(path: &Path) -> Option<String> {
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn fallback_version_id(snapshot: &OfficialUpdateSnapshot) -> String {
|
||||
format!(
|
||||
"{}-{}",
|
||||
sanitize_publish_segment(&snapshot.app_version),
|
||||
snapshot
|
||||
.bundle_version
|
||||
.as_deref()
|
||||
.map(sanitize_publish_segment)
|
||||
.unwrap_or_else(|| "no-bundle".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn launcher_metadata_from_parts(
|
||||
launcher_version: &str,
|
||||
game_config: &YostarJpLauncherGameConfig,
|
||||
@@ -2268,6 +2652,118 @@ mod tests {
|
||||
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_version_state_json_round_trip() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join("official-version-state.json");
|
||||
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||||
let current = version_record_for_snapshot(
|
||||
&snapshot,
|
||||
VersionRecordInput {
|
||||
id: "1.70.0-bundle-current".to_string(),
|
||||
resource_root: temp.path().join("versions/current"),
|
||||
snapshot_path: temp
|
||||
.path()
|
||||
.join("versions/current/official-sync-snapshot.json"),
|
||||
staging_path: None,
|
||||
version_path: Some(temp.path().join("versions/current")),
|
||||
started_unix_seconds: None,
|
||||
completed_unix_seconds: Some(10),
|
||||
},
|
||||
);
|
||||
let failed = OfficialFailedVersionRecord {
|
||||
version: current.clone(),
|
||||
error: "fixture failure".to_string(),
|
||||
failed_unix_seconds: 11,
|
||||
};
|
||||
let state = OfficialVersionState {
|
||||
current_completed_version: Some(current),
|
||||
failed_versions: vec![failed],
|
||||
updated_unix_seconds: 12,
|
||||
..OfficialVersionState::default()
|
||||
};
|
||||
|
||||
write_version_state(&path, &state).unwrap();
|
||||
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_state_tracks_in_progress_success_and_failure() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join("official-version-state.json");
|
||||
let active = temp.path().join("versions/previous");
|
||||
let staging = temp.path().join(".staging/current");
|
||||
let staging_snapshot = staging.join("official-sync-snapshot.json");
|
||||
let published = temp.path().join("versions/current");
|
||||
let previous_snapshot = OfficialUpdateSnapshot::new(
|
||||
fixture_base_snapshot(),
|
||||
vec![fixture_marker("previous")],
|
||||
None,
|
||||
);
|
||||
let current_snapshot = OfficialUpdateSnapshot::new(
|
||||
fixture_base_snapshot(),
|
||||
vec![fixture_marker("current")],
|
||||
Some(&fixture_bootstrap()),
|
||||
);
|
||||
|
||||
let in_progress = prepare_in_progress_version_state(
|
||||
&path,
|
||||
¤t_snapshot,
|
||||
"current",
|
||||
&active,
|
||||
Some(&previous_snapshot),
|
||||
&staging,
|
||||
&staging_snapshot,
|
||||
)
|
||||
.unwrap();
|
||||
let state = read_version_state(&path).unwrap().unwrap();
|
||||
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
|
||||
assert_eq!(
|
||||
state.current_completed_version.as_ref().unwrap().id,
|
||||
"previous"
|
||||
);
|
||||
|
||||
complete_version_state(
|
||||
&path,
|
||||
in_progress.clone(),
|
||||
&published,
|
||||
&published.join("official-sync-snapshot.json"),
|
||||
)
|
||||
.unwrap();
|
||||
let state = read_version_state(&path).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
state.current_completed_version.as_ref().unwrap().id,
|
||||
"current"
|
||||
);
|
||||
assert!(state.in_progress_version.is_none());
|
||||
assert_eq!(
|
||||
state.previous_available_version.as_ref().unwrap().id,
|
||||
"previous"
|
||||
);
|
||||
|
||||
let failed = prepare_in_progress_version_state(
|
||||
&path,
|
||||
¤t_snapshot,
|
||||
"broken",
|
||||
&published,
|
||||
state
|
||||
.current_completed_version
|
||||
.as_ref()
|
||||
.map(|_| ¤t_snapshot),
|
||||
&temp.path().join(".staging/broken"),
|
||||
&temp
|
||||
.path()
|
||||
.join(".staging/broken/official-sync-snapshot.json"),
|
||||
)
|
||||
.unwrap();
|
||||
fail_version_state(&path, failed, "download 403").unwrap();
|
||||
let state = read_version_state(&path).unwrap().unwrap();
|
||||
assert!(state.in_progress_version.is_none());
|
||||
assert_eq!(state.failed_versions.len(), 1);
|
||||
assert_eq!(state.failed_versions[0].version.id, "broken");
|
||||
assert!(state.failed_versions[0].error.contains("403"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_dangerous_output_root_before_network_work() {
|
||||
let config = OfficialUpdateConfig {
|
||||
|
||||
@@ -187,6 +187,8 @@ impl SqliteResourceRepository {
|
||||
ResourceType::AssetBundle => "AssetBundle",
|
||||
ResourceType::Manifest => "Manifest",
|
||||
ResourceType::TableBundle => "TableBundle",
|
||||
ResourceType::TextAsset => "TextAsset",
|
||||
ResourceType::Media => "Media",
|
||||
ResourceType::Other => "Other",
|
||||
}
|
||||
}
|
||||
@@ -196,6 +198,8 @@ impl SqliteResourceRepository {
|
||||
"AssetBundle" => Ok(ResourceType::AssetBundle),
|
||||
"Manifest" => Ok(ResourceType::Manifest),
|
||||
"TableBundle" => Ok(ResourceType::TableBundle),
|
||||
"TextAsset" => Ok(ResourceType::TextAsset),
|
||||
"Media" => Ok(ResourceType::Media),
|
||||
"Other" => Ok(ResourceType::Other),
|
||||
other => Err(bat_core::Error::Serialization(format!(
|
||||
"Unknown resource type: {}",
|
||||
|
||||
Reference in New Issue
Block a user