Files
BlueArchiveToolkit/infrastructure/src/official_update.rs
T

3029 lines
111 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Official JP resource update orchestration.
//!
//! This module is the production-facing form of the official update flow. It
//! keeps the one-shot semantics required by external schedulers and Go CLI
//! callers: discover current official state, compare remote markers, audit the
//! local manifest, repair/download when needed, then return a structured report.
use crate::path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
};
use crate::{
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullService, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Current official update snapshot schema version.
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)]
pub enum OfficialServerInfoSource {
/// Read a local, already-audited official server-info JSON file.
LocalPath(PathBuf),
/// Fetch a server-info file by official file name.
OfficialFile(String),
/// Fetch a full official server-info URL.
OfficialUrl(String),
}
/// Configuration for one official update execution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialUpdateConfig {
/// Optional explicit server-info source.
pub server_info_source: Option<OfficialServerInfoSource>,
/// Optional explicit connection group.
pub connection_group: Option<String>,
/// Optional explicit app version.
pub app_version: Option<String>,
/// Official launcher version used for signed metadata discovery.
pub launcher_version: String,
/// Whether to discover app version, server-info URL, and connection group
/// from official metadata.
pub auto_discover: bool,
/// Platforms to update. Defaults to verified official platforms.
pub platforms: Option<Vec<PatchPlatform>>,
/// Output root for resources and state files.
pub output_root: PathBuf,
/// Optional explicit sync snapshot path.
pub snapshot_path: Option<PathBuf>,
/// Curl command used by the current infrastructure downloader.
pub curl_command: PathBuf,
/// Unzip command used when a metadata change requires GameMainConfig parsing.
pub unzip_command: PathBuf,
/// Dry run reports decisions and optional plan URLs without writing sync state.
pub dry_run: bool,
/// Include full download URLs when dry-running.
pub plan: bool,
/// Force download even when remote and local state look clean.
pub force: bool,
/// Audit the local download manifest before deciding up-to-date.
pub audit_local: bool,
/// Repair local files when the local manifest audit fails.
pub repair: bool,
}
impl Default for OfficialUpdateConfig {
fn default() -> Self {
Self {
server_info_source: None,
connection_group: None,
app_version: None,
launcher_version: "1.7.2".to_string(),
auto_discover: false,
platforms: None,
output_root: PathBuf::from("./bat-resources"),
snapshot_path: None,
curl_command: PathBuf::from("curl"),
unzip_command: PathBuf::from("unzip"),
dry_run: false,
plan: false,
force: false,
audit_local: true,
repair: true,
}
}
}
impl OfficialUpdateConfig {
/// Returns the effective sync snapshot path for this config.
pub fn effective_snapshot_path(&self) -> PathBuf {
self.snapshot_path
.clone()
.unwrap_or_else(|| self.output_root.join("official-sync-snapshot.json"))
}
/// Returns the bootstrap cache path for this config.
pub fn bootstrap_cache_path(&self) -> PathBuf {
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")
}
}
/// Status of one official update execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OfficialUpdateStatus {
/// Remote and local resources are already clean.
UpToDate,
/// Dry-run detected that a download would occur.
WouldDownload,
/// Resources were downloaded or repaired.
Downloaded,
}
impl OfficialUpdateStatus {
/// Returns a stable string label for CLI and JSON callers.
pub fn as_str(self) -> &'static str {
match self {
Self::UpToDate => "up_to_date",
Self::WouldDownload => "would_download",
Self::Downloaded => "downloaded",
}
}
}
/// Snapshot of the official JP update state observed at a point in time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialUpdateSnapshot {
/// Snapshot schema version.
#[serde(default = "default_update_snapshot_version")]
pub snapshot_version: u32,
/// Selected server-info connection group.
pub connection_group_name: String,
/// App version used for server-info override selection.
pub app_version: String,
/// Bundle version from server-info, when present.
pub bundle_version: Option<String>,
/// Selected official Addressables root URL.
pub addressables_root: String,
/// Seed endpoints observed for the selected platform set.
pub endpoints: Vec<YostarJpResourceEndpoint>,
/// Small remote marker contents fetched for change detection.
#[serde(default)]
pub endpoint_markers: Vec<OfficialEndpointMarkerSnapshot>,
/// Launcher metadata summary used for auto-discovery, when available.
#[serde(default)]
pub launcher_metadata: Option<LauncherMetadataSnapshot>,
/// Parsed GameMainConfig summary used for auto-discovery, when available.
#[serde(default)]
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(
base: YostarJpSyncSnapshot,
endpoint_markers: Vec<OfficialEndpointMarkerSnapshot>,
bootstrap: Option<&ResolvedBootstrap>,
) -> Self {
Self {
snapshot_version: OFFICIAL_UPDATE_SNAPSHOT_VERSION,
connection_group_name: base.connection_group_name,
app_version: base.app_version,
bundle_version: base.bundle_version,
addressables_root: base.addressables_root,
endpoints: base.endpoints,
endpoint_markers,
launcher_metadata: bootstrap.map(|bootstrap| bootstrap.launcher_metadata.clone()),
game_main_config_bootstrap: bootstrap
.map(|bootstrap| bootstrap.game_main_config.clone()),
}
}
/// Returns the legacy base snapshot used by the existing sync planner.
pub fn base_snapshot(&self) -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: self.connection_group_name.clone(),
app_version: self.app_version.clone(),
bundle_version: self.bundle_version.clone(),
addressables_root: self.addressables_root.clone(),
endpoints: self.endpoints.clone(),
}
}
/// Returns the number of Addressables catalog markers checked.
pub fn addressables_marker_checked_count(&self) -> usize {
self.endpoint_markers
.iter()
.filter(|marker| marker.role == OfficialEndpointMarkerRole::AddressablesCatalogMarker)
.count()
}
/// Returns marker count not used as strong content validation.
pub fn unverified_marker_count(&self) -> usize {
self.addressables_marker_checked_count()
}
}
/// Captured value of one small remote marker endpoint.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialEndpointMarkerSnapshot {
/// Endpoint kind.
pub kind: YostarJpResourceEndpointKind,
/// Platform for platform-specific endpoints.
pub platform: Option<PatchPlatform>,
/// Official marker URL.
pub url: String,
/// How this marker should be interpreted.
pub role: OfficialEndpointMarkerRole,
/// Trimmed marker content.
pub value: String,
}
/// Role of a fetched marker endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OfficialEndpointMarkerRole {
/// Official seed `.hash`, interpreted as xxHash32 decimal by download verification.
OfficialSeedHash,
/// Unity Addressables/SBP Hash128 marker, not a strong content validation.
AddressablesCatalogMarker,
}
/// Summary of launcher metadata used by auto-discovery.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LauncherMetadataSnapshot {
/// Launcher version used for signed requests.
pub launcher_version: String,
/// Latest game version reported by the launcher API.
pub game_latest_version: String,
/// Latest game file path reported by the launcher API.
pub game_latest_file_path: String,
/// Lowest game version accepted by the launcher API.
pub game_lowest_version: Option<String>,
/// Game executable name.
pub game_start_exe_name: Option<String>,
/// Game executable arguments.
pub game_start_params: Vec<String>,
/// Remote launcher manifest URL.
pub manifest_url: String,
/// Remote launcher manifest source path.
pub manifest_source: Option<String>,
/// Number of files in the remote launcher manifest.
pub manifest_file_count: usize,
}
/// Summary of the decrypted GameMainConfig used by auto-discovery.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameMainConfigSnapshot {
/// Official server-info URL from GameMainConfig.
pub server_info_data_url: Option<String>,
/// Default connection group from GameMainConfig.
pub default_connection_group: Option<String>,
}
/// Cached GameMainConfig summary keyed by launcher metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialBootstrapCache {
/// Cache schema version.
#[serde(default = "default_bootstrap_cache_version")]
pub cache_version: u32,
/// Launcher metadata that produced this GameMainConfig summary.
pub launcher_metadata: LauncherMetadataSnapshot,
/// Cached GameMainConfig summary.
pub game_main_config: GameMainConfigSnapshot,
}
/// Resolved bootstrap data for one run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedBootstrap {
/// Launcher metadata summary.
pub launcher_metadata: LauncherMetadataSnapshot,
/// GameMainConfig summary.
pub game_main_config: GameMainConfigSnapshot,
/// Whether GameMainConfig came from the local cache.
pub cache_hit: bool,
}
/// Extended snapshot delta not represented by the legacy sync planner.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtendedSnapshotDelta {
/// Whether fetched endpoint marker contents changed.
pub endpoint_markers_changed: bool,
/// Whether launcher metadata summary changed.
pub launcher_metadata_changed: bool,
/// Whether GameMainConfig summary changed.
pub game_main_config_changed: bool,
}
impl ExtendedSnapshotDelta {
/// Returns true when any extended snapshot field changed.
pub fn has_changes(self) -> bool {
self.endpoint_markers_changed
|| self.launcher_metadata_changed
|| self.game_main_config_changed
}
}
/// Structured verification summary for CLI and JSON reports.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVerificationSummary {
/// Number of resources verified by the local download manifest's path,
/// byte size, and BLAKE3 digest.
pub local_manifest_blake3_verified_count: usize,
/// Number of resources that still need local repair after manifest audit.
pub local_manifest_repair_needed_count: usize,
/// Human-facing description of the local manifest validation boundary.
pub local_manifest_blake3_scope: String,
/// Number of official seed `.bytes`/`.hash` pairs verified by the official
/// decimal xxHash32 rule.
pub official_hash_verified_count: usize,
/// Human-facing description of the official hash validation boundary.
pub official_hash_scope: String,
/// Number of ZIP files that passed structural validation.
pub zip_structure_verified_count: usize,
/// Human-facing description of the ZIP structure validation boundary.
pub zip_structure_scope: String,
}
impl OfficialVerificationSummary {
/// Creates a verification summary from validation counters.
pub fn new(
local_manifest_blake3_verified_count: usize,
local_manifest_repair_needed_count: usize,
official_hash_verified_count: usize,
zip_structure_verified_count: usize,
) -> Self {
Self {
local_manifest_blake3_verified_count,
local_manifest_repair_needed_count,
local_manifest_blake3_scope:
"本地 download manifest 的 URL/path、size 和 BLAKE3 复用校验".to_string(),
official_hash_verified_count,
official_hash_scope: "官方 seed .bytes/.hash 对,按官方十进制 xxHash32(seed=0) 强校验"
.to_string(),
zip_structure_verified_count,
zip_structure_scope:
"所有 .zip 文件的 EOCD、central directory、local header 和边界结构校验".to_string(),
}
}
}
/// Structured report returned by an official update run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialUpdateReport {
/// Final update status.
pub update_status: OfficialUpdateStatus,
/// Selected connection group.
pub connection_group: String,
/// Selected app version.
pub app_version: String,
/// Selected bundle version.
pub bundle_version: Option<String>,
/// Selected Addressables root.
pub addressables_root: String,
/// Platform set used by the run.
pub platforms: Vec<PatchPlatform>,
/// Managed publish root containing `current`, `versions`, and staging.
pub output_root: PathBuf,
/// Active resource root used for local audit before this run.
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.
pub staging_path: Option<PathBuf>,
/// Sync snapshot path.
pub snapshot_path: PathBuf,
/// Whether a previous snapshot existed.
pub previous_snapshot_present: bool,
/// Legacy sync decision label.
pub decision: String,
/// Whether force was enabled.
pub force: bool,
/// Whether local audit was enabled.
pub audit_local: bool,
/// Whether local repair was enabled.
pub repair: bool,
/// Whether a download was required.
pub should_download: bool,
/// Whether this was the first observed snapshot.
pub is_initial: bool,
/// Changed endpoint URLs from legacy diff.
pub changed_endpoint_urls: Vec<String>,
/// Extended marker/metadata delta.
pub extended_delta: ExtendedSnapshotDelta,
/// Count of Addressables marker files checked.
pub addressables_marker_checked_count: usize,
/// Count of marker files not used for strong content validation.
pub unverified_marker_count: usize,
/// Whether bootstrap cache was hit.
pub bootstrap_cache_hit: Option<bool>,
/// Bootstrap cache path.
pub bootstrap_cache_path: Option<PathBuf>,
/// Local manifest verified file count.
pub local_manifest_verified_count: usize,
/// Local manifest repair-needed file count.
pub local_manifest_repair_needed_count: usize,
/// Dry-run flag.
pub dry_run: bool,
/// Download URL count when plan output was requested.
pub download_url_count: Option<usize>,
/// Download URLs when plan output was requested.
pub download_urls: Vec<String>,
/// Pull item count after a real download.
pub resource_count: Option<usize>,
/// Number of resources downloaded from scratch.
pub downloaded_count: usize,
/// Number of resources resumed.
pub resumed_count: usize,
/// Number of resources skipped using the local manifest.
pub skipped_count: usize,
/// Final local byte count in the pull report.
pub final_bytes: u64,
/// Bytes transferred in this run.
pub transferred_bytes: u64,
/// Number of official seed hashes verified.
pub official_seed_hash_verified_count: usize,
/// Verification summary with explicit boundaries for each validation type.
pub verification_summary: OfficialVerificationSummary,
/// Download manifest path.
pub download_manifest: PathBuf,
/// Snapshot path written after success.
pub snapshot_written: Option<PathBuf>,
}
/// Human-readable progress emitted while one official update run is executing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialUpdateProgress {
/// Stable progress stage label.
pub stage: &'static str,
/// Human-readable status line.
pub message: String,
/// One-based download index when the event represents URL download work.
pub download_index: Option<usize>,
/// Total URL count for the current download plan.
pub download_total: Option<usize>,
/// Current official URL for download progress.
pub download_url: Option<String>,
/// Stable pull status label when a URL finished.
pub download_status: Option<String>,
/// Final local byte count for the URL when known.
pub download_bytes: Option<u64>,
/// Bytes transferred in this run for the URL when known.
pub download_transferred_bytes: Option<u64>,
/// Stable failure kind when a URL failed.
pub download_failure_kind: Option<String>,
/// HTTP status parsed from curl stderr when a URL failed.
pub download_failure_http_status: Option<u16>,
/// Whether the final failure was retryable.
pub download_failure_retryable: Option<bool>,
/// Number of transfer attempts executed before failure.
pub download_failure_attempts: Option<usize>,
/// Whether the URL was recorded in the quarantine manifest.
pub download_quarantined: Option<bool>,
}
impl OfficialUpdateProgress {
/// Creates a progress event.
pub fn new(stage: &'static str, message: impl Into<String>) -> Self {
Self {
stage,
message: message.into(),
download_index: None,
download_total: None,
download_url: None,
download_status: None,
download_bytes: None,
download_transferred_bytes: None,
download_failure_kind: None,
download_failure_http_status: None,
download_failure_retryable: None,
download_failure_attempts: None,
download_quarantined: None,
}
}
fn with_download_progress(mut self, event: &OfficialResourcePullProgress) -> Self {
self.download_index = Some(event.index);
self.download_total = Some(event.total);
self.download_url = Some(event.url.clone());
self.download_status = event
.status
.map(|status| status.as_str().to_string())
.or_else(|| {
(event.kind == OfficialResourcePullProgressKind::Failed)
.then(|| "failed".to_string())
});
self.download_bytes = event.bytes;
self.download_transferred_bytes = event.transferred_bytes;
self.download_failure_kind = event.failure_kind.clone();
self.download_failure_http_status = event.failure_http_status;
self.download_failure_retryable = event.failure_retryable;
self.download_failure_attempts = event.failure_attempts;
self.download_quarantined =
(event.kind == OfficialResourcePullProgressKind::Failed).then_some(event.quarantined);
self
}
}
#[derive(Debug, Clone)]
struct OfficialPublishLayout {
root: PathBuf,
current_path: PathBuf,
versions_dir: PathBuf,
staging_dir: PathBuf,
}
#[derive(Debug, Clone)]
struct OfficialPublishPlan {
id: String,
staging_path: PathBuf,
version_path: PathBuf,
}
impl OfficialPublishLayout {
fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
current_path: root.join(OFFICIAL_CURRENT_LINK),
versions_dir: root.join(OFFICIAL_VERSIONS_DIR),
staging_dir: root.join(OFFICIAL_STAGING_DIR),
}
}
fn active_resource_root(&self) -> Result<PathBuf, String> {
if let Some(current_target) = self.current_target()? {
return Ok(current_target);
}
// Legacy fallback for directories produced before atomic publishing.
// The next non-dry-run update will seed staging from this tree and
// publish it under `versions/<id>` before switching `current`.
Ok(self.root.clone())
}
fn current_target(&self) -> Result<Option<PathBuf>, String> {
let metadata = match fs::symlink_metadata(&self.current_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"读取 current 指针失败 {}{error}",
self.current_path.display()
))
}
};
if !metadata.file_type().is_symlink() {
return Err(format!(
"current 必须是指向 versioned 目录的 symlink{}",
self.current_path.display()
));
}
let target = fs::read_link(&self.current_path).map_err(|error| {
format!(
"读取 current symlink 目标失败 {}{error}",
self.current_path.display()
)
})?;
let target = if target.is_absolute() {
target
} else {
self.root.join(target)
};
ensure_path_within_root(&self.root, &target)?;
ensure_safe_directory_path(&target, "current versioned 目录")?;
Ok(Some(target))
}
fn has_current_pointer(&self) -> Result<bool, String> {
Ok(self.current_target()?.is_some())
}
fn plan(&self, snapshot: &OfficialUpdateSnapshot) -> OfficialPublishPlan {
let id = publish_id(snapshot);
OfficialPublishPlan {
staging_path: self.staging_dir.join(&id),
version_path: self.versions_dir.join(&id),
id,
}
}
fn prepare_staging(&self, plan: &OfficialPublishPlan) -> Result<(), String> {
validate_output_root(&self.root)?;
ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?;
fs::create_dir_all(&self.staging_dir).map_err(|error| {
format!(
"创建官方资源 staging 根目录失败 {}{error}",
self.staging_dir.display()
)
})?;
ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?;
if path_exists_no_follow(&plan.staging_path)? {
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
fs::remove_dir_all(&plan.staging_path).map_err(|error| {
format!(
"清理官方资源 staging 目录失败 {}{error}",
plan.staging_path.display()
)
})?;
}
if path_exists_no_follow(&plan.version_path)? {
return Err(format!(
"versioned 目录已存在,拒绝覆盖:{}",
plan.version_path.display()
));
}
fs::create_dir_all(&plan.staging_path).map_err(|error| {
format!(
"创建官方资源 staging 目录失败 {}{error}",
plan.staging_path.display()
)
})?;
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")
}
fn seed_staging_from_active(
&self,
active_root: &Path,
staging_root: &Path,
) -> Result<(), String> {
if active_root == self.root && !self.legacy_manifest_exists()? {
return Ok(());
}
if !path_exists_no_follow(active_root)? {
return Ok(());
}
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
}
fn legacy_manifest_exists(&self) -> Result<bool, String> {
path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE))
}
fn publish(&self, plan: &OfficialPublishPlan) -> Result<PathBuf, String> {
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
fs::create_dir_all(&self.versions_dir).map_err(|error| {
format!(
"创建官方资源 versions 根目录失败 {}{error}",
self.versions_dir.display()
)
})?;
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
if path_exists_no_follow(&plan.version_path)? {
return Err(format!(
"versioned 目录已存在,拒绝覆盖:{}",
plan.version_path.display()
));
}
fs::rename(&plan.staging_path, &plan.version_path).map_err(|error| {
format!(
"发布官方资源版本目录失败 {} -> {}{error}",
plan.staging_path.display(),
plan.version_path.display()
)
})?;
switch_current_symlink(&self.root, &self.current_path, &plan.id)?;
Ok(plan.version_path.clone())
}
}
/// Official update runner.
#[derive(Debug, Clone, Default)]
pub struct OfficialUpdateService;
impl OfficialUpdateService {
/// Creates an official update runner.
pub fn new() -> Self {
Self
}
/// Executes one official update run.
pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> {
self.run_with_progress(config, |_| {})
}
/// Executes one official update run and emits human-readable progress
/// events. Structured reports are still returned separately.
pub fn run_with_progress(
&self,
config: &OfficialUpdateConfig,
mut progress: impl FnMut(OfficialUpdateProgress),
) -> anyhow::Result<OfficialUpdateReport> {
self.run_with_progress_and_cancellation(config, &mut progress, || false)
}
/// Executes one official update run, emits progress events, and aborts
/// between safe steps when `should_cancel` returns true.
pub fn run_with_progress_and_cancellation(
&self,
config: &OfficialUpdateConfig,
mut progress: impl FnMut(OfficialUpdateProgress),
mut should_cancel: impl FnMut() -> bool,
) -> anyhow::Result<OfficialUpdateReport> {
progress(OfficialUpdateProgress::new(
"start",
format!(
"开始官方资源同步:资源目录={} 试运行={} 自动发现={}",
config.output_root.display(),
config.dry_run,
config.auto_discover
),
));
check_shutdown_requested(&mut should_cancel)?;
validate_update_paths(config).map_err(anyhow::Error::msg)?;
let _lock = if config.dry_run {
progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁"));
None
} else {
progress(OfficialUpdateProgress::new(
"lock",
format!("获取资源目录状态锁 {}", config.lock_path().display()),
));
let lock = OfficialUpdateLock::acquire(config)?;
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);
let active_resource_root = publish_layout
.active_resource_root()
.map_err(anyhow::Error::msg)?;
let has_current_pointer = publish_layout
.has_current_pointer()
.map_err(anyhow::Error::msg)?;
let default_platforms = default_official_platforms();
let platforms = config
.platforms
.as_deref()
.unwrap_or(default_platforms.as_slice());
let fetcher = OfficialResourcePullService::with_curl_command(
&active_resource_root,
&config.curl_command,
);
let snapshot_path = snapshot_path_for(config, &active_resource_root);
let bootstrap_cache_path = config.bootstrap_cache_path();
let bootstrap = if config.auto_discover {
progress(OfficialUpdateProgress::new(
"bootstrap",
format!(
"启用自动发现;启动器版本={} 缓存={}",
config.launcher_version,
bootstrap_cache_path.display()
),
));
Some(resolve_bootstrap(
&config.launcher_version,
&config.curl_command,
&config.unzip_command,
&bootstrap_cache_path,
!config.dry_run,
&mut progress,
&mut should_cancel,
)?)
} else {
progress(OfficialUpdateProgress::new(
"bootstrap",
"未启用自动发现;使用显式元数据输入",
));
None
};
check_shutdown_requested(&mut should_cancel)?;
let app_version = config
.app_version
.clone()
.or_else(|| {
bootstrap
.as_ref()
.map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone())
})
.ok_or_else(|| {
anyhow::anyhow!("缺少应用版本;请显式传入 --app-version 或启用 --auto-discover")
})?;
let connection_group = config
.connection_group
.clone()
.or_else(|| {
bootstrap.as_ref().and_then(|bootstrap| {
bootstrap.game_main_config.default_connection_group.clone()
})
})
.ok_or_else(|| {
anyhow::anyhow!("缺少连接组;请显式传入 --connection-group 或启用 --auto-discover")
})?;
progress(OfficialUpdateProgress::new(
"metadata",
format!(
"使用应用版本={} 连接组={} 平台={}",
app_version,
connection_group,
platforms_label(platforms)
),
));
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
progress(OfficialUpdateProgress::new(
"server-info",
format!("从 {} 读取服务器信息", server_info_source_label(source)),
));
load_server_info(source, &fetcher)?
} else {
let bootstrap = bootstrap.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"缺少服务器信息来源;请显式传入 --server-info-* 或启用 --auto-discover"
)
})?;
let url = bootstrap
.game_main_config
.server_info_data_url
.as_deref()
.ok_or_else(|| anyhow::anyhow!("官方 GameMainConfig 中没有 ServerInfoDataUrl"))?;
progress(OfficialUpdateProgress::new(
"server-info",
format!("拉取自动发现的服务器信息 {url}"),
));
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
};
progress(OfficialUpdateProgress::new(
"server-info",
format!("解析服务器信息,字节数={}", server_info_bytes.len()),
));
check_shutdown_requested(&mut should_cancel)?;
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
let current_snapshot = server_info
.sync_snapshot(&connection_group, &app_version, platforms)
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"discovery",
format!(
"解析到 Addressables 根={} endpoint 数={}",
current_snapshot.addressables_root,
current_snapshot.endpoints.len()
),
));
progress(OfficialUpdateProgress::new(
"markers",
format!(
"检查 {} 个远端标记 endpoint",
marker_endpoint_count(&current_snapshot)
),
));
let endpoint_markers = collect_endpoint_markers(
&fetcher,
&current_snapshot,
&mut progress,
&mut should_cancel,
)?;
let current_update_snapshot = OfficialUpdateSnapshot::new(
current_snapshot.clone(),
endpoint_markers,
bootstrap.as_ref(),
);
check_shutdown_requested(&mut should_cancel)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("读取上次快照 {}", snapshot_path.display()),
));
let previous_snapshot = read_snapshot(&snapshot_path)?;
let previous_base_snapshot = previous_snapshot
.as_ref()
.map(OfficialUpdateSnapshot::base_snapshot);
let sync_plan =
build_official_sync_plan(current_snapshot.clone(), previous_base_snapshot.as_ref());
let extended_delta =
diff_extended_snapshot(&current_update_snapshot, previous_snapshot.as_ref());
let changed_urls = changed_endpoint_urls(&sync_plan.delta);
let remote_should_download =
config.force || sync_plan.should_download() || extended_delta.has_changes();
progress(OfficialUpdateProgress::new(
"decision",
format!(
"远端决策={:?} 强制刷新={} 远端需要下载={}",
sync_plan.decision, config.force, remote_should_download
),
));
progress(OfficialUpdateProgress::new(
"plan",
"根据最新种子目录构建官方拉取计划",
));
let pull_plan = build_pull_plan(
&server_info,
&connection_group,
&app_version,
platforms,
&fetcher,
&mut progress,
&mut should_cancel,
)?;
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"plan",
format!("拉取计划包含 {url_count} 个官方 URL"),
));
let local_state = fetcher
.local_resource_state(&pull_plan)
.map_err(anyhow::Error::msg)?;
let has_local_resources = local_state.has_any_resources();
progress(OfficialUpdateProgress::new(
"local-state",
format!(
"manifest 条目={} 已存在文件={} 是否已有本地资源={}",
local_state.manifest_entry_count,
local_state.existing_file_count,
has_local_resources
),
));
let should_audit_local = config.audit_local && has_local_resources;
let local_audit = if should_audit_local {
progress(OfficialUpdateProgress::new(
"audit",
format!(
"审计本地下载 manifest {}",
fetcher.download_manifest_path().display()
),
));
Some(
fetcher
.audit_local_manifest(&pull_plan)
.map_err(anyhow::Error::msg)?,
)
} else if config.audit_local {
progress(OfficialUpdateProgress::new(
"audit",
"未发现本地资源;首次全量拉取前跳过本地审计",
));
None
} else {
progress(OfficialUpdateProgress::new(
"audit",
"本地 manifest 审计已关闭",
));
None
};
let local_manifest_verified_count = local_audit
.as_ref()
.map(|audit| audit.verified_count())
.unwrap_or(0);
let local_manifest_repair_needed_count = local_audit
.as_ref()
.map(|audit| audit.repair_needed_count())
.unwrap_or(0);
let local_manifest_blake3_verified_count = local_audit
.as_ref()
.map(|audit| audit.manifest_blake3_verified_count())
.unwrap_or(0);
let local_zip_structure_verified_count = local_audit
.as_ref()
.map(|audit| audit.zip_structure_verified_count())
.unwrap_or(0);
let repair_needed = config.repair
&& local_audit
.as_ref()
.map(|audit| !audit.is_clean())
.unwrap_or(false);
let initial_pull_needed = !has_local_resources;
let publish_required = !has_current_pointer;
let should_download =
remote_should_download || repair_needed || initial_pull_needed || publish_required;
progress(OfficialUpdateProgress::new(
"audit",
format!(
"本地 manifest 已校验={} 需要修复={}",
local_manifest_verified_count, local_manifest_repair_needed_count
),
));
progress(OfficialUpdateProgress::new(
"decision",
format!(
"最终决策 需要下载={} 需要修复={} 首次拉取={}",
should_download, repair_needed, initial_pull_needed
),
));
if publish_required {
progress(OfficialUpdateProgress::new(
"publish",
"尚未存在 current 原子发布指针;本轮会发布 versioned 目录并切换 current",
));
}
check_shutdown_requested(&mut should_cancel)?;
let mut report = OfficialUpdateReport {
update_status: if should_download {
OfficialUpdateStatus::WouldDownload
} else {
OfficialUpdateStatus::UpToDate
},
connection_group: current_snapshot.connection_group_name.clone(),
app_version: current_snapshot.app_version.clone(),
bundle_version: current_snapshot.bundle_version.clone(),
addressables_root: current_snapshot.addressables_root.clone(),
platforms: platforms.to_vec(),
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(),
previous_snapshot_present: previous_snapshot.is_some(),
decision: format!("{:?}", sync_plan.decision),
force: config.force,
audit_local: config.audit_local,
repair: config.repair,
should_download,
is_initial: sync_plan.delta.is_initial,
changed_endpoint_urls: changed_urls,
extended_delta,
addressables_marker_checked_count: current_update_snapshot
.addressables_marker_checked_count(),
unverified_marker_count: current_update_snapshot.unverified_marker_count(),
bootstrap_cache_hit: bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit),
bootstrap_cache_path: bootstrap.as_ref().map(|_| bootstrap_cache_path.clone()),
local_manifest_verified_count,
local_manifest_repair_needed_count,
dry_run: config.dry_run,
download_url_count: None,
download_urls: Vec::new(),
resource_count: None,
downloaded_count: 0,
resumed_count: 0,
skipped_count: 0,
final_bytes: 0,
transferred_bytes: 0,
official_seed_hash_verified_count: 0,
verification_summary: OfficialVerificationSummary::new(
local_manifest_blake3_verified_count,
local_manifest_repair_needed_count,
0,
local_zip_structure_verified_count,
),
download_manifest: fetcher.download_manifest_path(),
snapshot_written: None,
};
if !should_download {
progress(OfficialUpdateProgress::new(
"audit",
verification_progress_message(&report.verification_summary),
));
if !config.dry_run {
let current_record = version_record_for_snapshot(
&current_update_snapshot,
VersionRecordInput {
id: version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_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",
"资源已是最新;无需下载",
));
return Ok(report);
}
if config.dry_run {
if config.plan {
let urls = pull_plan.all_urls().map_err(anyhow::Error::msg)?;
report.download_url_count = Some(urls.len());
report.download_urls = urls;
progress(OfficialUpdateProgress::new(
"dry-run",
format!(
"试运行已生成 {} 个 URL",
report.download_url_count.unwrap_or(0)
),
));
} else {
progress(OfficialUpdateProgress::new(
"dry-run",
"试运行检测到需要下载;未启用完整 URL 计划输出",
));
}
report.update_status = OfficialUpdateStatus::WouldDownload;
progress(OfficialUpdateProgress::new(
"finish",
"试运行完成,未写入状态",
));
return Ok(report);
}
check_shutdown_requested(&mut should_cancel)?;
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
let publish_plan = publish_layout.plan(&current_update_snapshot);
progress(OfficialUpdateProgress::new(
"publish",
format!(
"准备 staging={} versioned={}",
publish_plan.staging_path.display(),
publish_plan.version_path.display()
),
));
publish_layout
.prepare_staging(&publish_plan)
.map_err(anyhow::Error::msg)?;
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,
&current_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,
);
report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path();
progress(OfficialUpdateProgress::new(
"download",
format!("下载或复用 {download_url_count} 个官方 URL"),
));
let pull_report = staging_fetcher
.pull_with_progress_and_cancellation(
&pull_plan,
|event| {
progress(progress_from_pull_event(event));
},
&mut should_cancel,
)
.map_err(|error| anyhow::anyhow!(error))
.inspect_err(|error| {
let _ = version_state_guard.fail(&error.to_string());
})?;
progress(OfficialUpdateProgress::new(
"download",
format!(
"下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}",
pull_report.downloaded_count(),
pull_report.resumed_count(),
pull_report.skipped_count(),
pull_report.transferred_bytes()
),
));
progress(OfficialUpdateProgress::new(
"audit",
"执行最终本地 manifest 审计",
));
check_shutdown_requested(&mut should_cancel)?;
let final_audit = staging_fetcher
.audit_local_manifest(&pull_plan)
.map_err(anyhow::Error::msg)?;
let final_verification_summary = OfficialVerificationSummary::new(
final_audit.manifest_blake3_verified_count(),
final_audit.repair_needed_count(),
pull_report.official_hash_verified_count(),
final_audit.zip_structure_verified_count(),
);
progress(OfficialUpdateProgress::new(
"audit",
verification_progress_message(&final_verification_summary),
));
progress(OfficialUpdateProgress::new(
"snapshot",
format!("写入快照 {}", staging_snapshot_path.display()),
));
if config.snapshot_path.is_none() {
write_snapshot(&staging_snapshot_path, &current_update_snapshot)?;
}
progress(OfficialUpdateProgress::new(
"publish",
format!(
"校验完成,发布 versioned 目录并切换 current -> {}",
publish_plan.version_path.display()
),
));
let published_version_path = publish_layout
.publish(&publish_plan)
.map_err(anyhow::Error::msg)?;
let final_snapshot_path = snapshot_path_for(config, &published_version_path);
if config.snapshot_path.is_some() {
write_snapshot(&final_snapshot_path, &current_update_snapshot)?;
}
report.update_status = OfficialUpdateStatus::Downloaded;
report.active_resource_root = published_version_path.clone();
report.published_version_path = Some(published_version_path.clone());
report.snapshot_path = final_snapshot_path.clone();
report.download_manifest = published_version_path.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE);
report.resource_count = Some(pull_report.items.len());
report.downloaded_count = pull_report.downloaded_count();
report.resumed_count = pull_report.resumed_count();
report.skipped_count = pull_report.skipped_count();
report.final_bytes = pull_report.total_bytes();
report.transferred_bytes = pull_report.transferred_bytes();
report.official_seed_hash_verified_count = pull_report.official_hash_verified_count();
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.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",
format!(
"同步完成:资源数={} 官方 hash 已校验={}",
report.resource_count.unwrap_or(0),
report.official_seed_hash_verified_count
),
));
Ok(report)
}
}
fn build_pull_plan(
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
fetcher: &OfficialResourcePullService,
progress: &mut dyn FnMut(OfficialUpdateProgress),
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<OfficialResourcePullPlan> {
check_shutdown_requested(should_cancel)?;
let discovery = server_info
.discovery_plan(connection_group, app_version, platforms)
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"plan",
format!(
"发现计划已解析:连接组={} 应用版本={} endpoint 数={}",
discovery.connection_group_name,
discovery.app_version,
discovery.endpoints.len()
),
));
check_shutdown_requested(should_cancel)?;
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery, progress, should_cancel)?;
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"inventory",
"正在把种子目录解析为下载清单",
));
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
Ok(build_official_pull_plan_for_platform_inventory(
discovery, inventory, platforms,
))
}
fn load_server_info(
source: &OfficialServerInfoSource,
fetcher: &OfficialResourcePullService,
) -> anyhow::Result<Vec<u8>> {
match source {
OfficialServerInfoSource::LocalPath(path) => Ok(fs::read(path)?),
OfficialServerInfoSource::OfficialFile(file_name) => {
let url = server_info_url(file_name).map_err(anyhow::Error::msg)?;
fetcher.fetch_bytes(&url).map_err(anyhow::Error::msg)
}
OfficialServerInfoSource::OfficialUrl(url) => {
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)
}
}
}
fn collect_endpoint_markers(
fetcher: &OfficialResourcePullService,
snapshot: &YostarJpSyncSnapshot,
progress: &mut dyn FnMut(OfficialUpdateProgress),
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> {
let mut markers = Vec::new();
for endpoint in &snapshot.endpoints {
let Some(role) = marker_role(endpoint.kind) else {
continue;
};
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"marker",
format!(
"拉取 {} 标记{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = fetcher
.fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?;
let value = String::from_utf8_lossy(&bytes).trim().to_string();
markers.push(OfficialEndpointMarkerSnapshot {
kind: endpoint.kind,
platform: endpoint.platform,
url: endpoint.url.clone(),
role,
value,
});
}
check_shutdown_requested(should_cancel)?;
Ok(markers)
}
fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> {
if should_cancel() {
return Err(anyhow::anyhow!("官方资源更新已被停止请求中断"));
}
Ok(())
}
fn marker_role(kind: YostarJpResourceEndpointKind) -> Option<OfficialEndpointMarkerRole> {
match kind {
YostarJpResourceEndpointKind::TableCatalogHash
| YostarJpResourceEndpointKind::BundlePackingInfoHash
| YostarJpResourceEndpointKind::MediaCatalogHash => {
Some(OfficialEndpointMarkerRole::OfficialSeedHash)
}
YostarJpResourceEndpointKind::AddressablesCatalogHash => {
Some(OfficialEndpointMarkerRole::AddressablesCatalogMarker)
}
_ => None,
}
}
fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
snapshot
.endpoints
.iter()
.filter(|endpoint| marker_role(endpoint.kind).is_some())
.count()
}
fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
match source {
OfficialServerInfoSource::LocalPath(path) => format!("本地路径 {}", path.display()),
OfficialServerInfoSource::OfficialFile(file_name) => {
format!("官方文件 {file_name}")
}
OfficialServerInfoSource::OfficialUrl(url) => format!("官方 URL {url}"),
}
}
fn platforms_label(platforms: &[PatchPlatform]) -> String {
platforms
.iter()
.map(|platform| platform.as_str())
.collect::<Vec<_>>()
.join(",")
}
fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
fn platform_suffix(platform: Option<PatchPlatform>) -> String {
platform
.map(|platform| format!(" ({})", platform.as_str()))
.unwrap_or_default()
}
fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpdateProgress {
match event.kind {
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!(
"下载进度:总体 {}/{} ({:.1}%);单文件开始 URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
event.url
),
)
.with_download_progress(&event),
OfficialResourcePullProgressKind::Finished => {
let status = event.status.map(localized_pull_status).unwrap_or("未知");
OfficialUpdateProgress::new(
"download",
format!(
"下载进度:总体 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
status,
event.bytes.unwrap_or(0),
event.transferred_bytes.unwrap_or(0),
event.url
),
)
.with_download_progress(&event)
}
OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new(
"download",
format!(
"下载中断:总体 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
event.failure_kind.as_deref().unwrap_or("unknown"),
event
.failure_http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
event
.failure_retryable
.map(|retryable| retryable.to_string())
.unwrap_or_else(|| "unknown".to_string()),
event
.failure_attempts
.map(|attempts| attempts.to_string())
.unwrap_or_else(|| "0".to_string()),
event.quarantined,
event.url
),
)
.with_download_progress(&event),
}
}
fn download_progress_percent(index: usize, total: usize) -> f64 {
if total == 0 {
100.0
} else {
(index as f64 / total as f64) * 100.0
}
}
fn verification_progress_message(summary: &OfficialVerificationSummary) -> String {
format!(
"校验结果:官方 .hash={};本地 BLAKE3 通过={};本地需修复={}ZIP 结构通过={}",
summary.official_hash_verified_count,
summary.local_manifest_blake3_verified_count,
summary.local_manifest_repair_needed_count,
summary.zip_structure_verified_count
)
}
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,
previous: Option<&OfficialUpdateSnapshot>,
) -> ExtendedSnapshotDelta {
let Some(previous) = previous else {
return ExtendedSnapshotDelta {
endpoint_markers_changed: true,
launcher_metadata_changed: current.launcher_metadata.is_some(),
game_main_config_changed: current.game_main_config_bootstrap.is_some(),
};
};
ExtendedSnapshotDelta {
endpoint_markers_changed: current.endpoint_markers != previous.endpoint_markers,
launcher_metadata_changed: current.launcher_metadata != previous.launcher_metadata,
game_main_config_changed: current.game_main_config_bootstrap
!= previous.game_main_config_bootstrap,
}
}
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
validate_output_root(&config.output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")?;
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
ensure_path_within_root(&config.output_root, snapshot_path)?;
ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?;
}
ensure_safe_file_target(
&config.output_root,
&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(())
}
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
config
.snapshot_path
.clone()
.unwrap_or_else(|| resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE))
}
fn path_exists_no_follow(path: &Path) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(format!("路径不能是 symlink{}", path.display()))
}
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!("检查路径失败 {}{error}", path.display())),
}
}
fn copy_tree_no_symlink(
source: &Path,
destination: &Path,
skip_publish_management_entries: bool,
) -> Result<(), String> {
ensure_safe_directory_path(source, "官方资源发布源目录")?;
ensure_safe_directory_path(destination, "官方资源 staging 目录")?;
fs::create_dir_all(destination).map_err(|error| {
format!(
"创建官方资源 staging 子目录失败 {}{error}",
destination.display()
)
})?;
for entry in fs::read_dir(source)
.map_err(|error| format!("读取官方资源发布源目录失败 {}{error}", source.display()))?
{
let entry = entry.map_err(|error| {
format!("读取官方资源发布源目录项失败 {}{error}", source.display())
})?;
let source_path = entry.path();
let file_name = entry.file_name();
if skip_publish_management_entries {
if let Some(name) = file_name.to_str() {
if matches!(
name,
OFFICIAL_STAGING_DIR
| OFFICIAL_VERSIONS_DIR
| OFFICIAL_CURRENT_LINK
| ".official-sync.lock"
) {
continue;
}
}
}
if destination.starts_with(&source_path) {
continue;
}
let destination_path = destination.join(file_name);
let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
format!(
"读取官方资源发布源元数据失败 {}{error}",
source_path.display()
)
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"官方资源发布源不能包含 symlink:{}",
source_path.display()
));
}
if metadata.is_dir() {
copy_tree_no_symlink(&source_path, &destination_path, false)?;
} else if metadata.is_file() {
if let Some(name) = source_path.file_name().and_then(|value| value.to_str()) {
if name.ends_with(".part") || name.ends_with(".tmp") {
continue;
}
}
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
format!(
"复制官方资源到 staging 失败 {} -> {}{copy_error}",
source_path.display(),
destination_path.display()
)
})?;
}
} else {
return Err(format!(
"官方资源发布源包含非普通文件:{}",
source_path.display()
));
}
}
Ok(())
}
#[cfg(unix)]
fn switch_current_symlink(
root: &Path,
current_path: &Path,
publish_id: &str,
) -> Result<(), String> {
use std::os::unix::fs::symlink;
let temporary = root.join(format!(".current.{}.tmp", std::process::id()));
if path_exists_no_follow(&temporary)? {
fs::remove_file(&temporary).map_err(|error| {
format!("清理临时 current 指针失败 {}{error}", temporary.display())
})?;
}
let relative_target = Path::new(OFFICIAL_VERSIONS_DIR).join(publish_id);
symlink(&relative_target, &temporary).map_err(|error| {
format!(
"创建临时 current 指针失败 {} -> {}{error}",
temporary.display(),
relative_target.display()
)
})?;
if let Ok(metadata) = fs::symlink_metadata(current_path) {
if !metadata.file_type().is_symlink() {
let _ = fs::remove_file(&temporary);
return Err(format!(
"current 已存在但不是 symlink{}",
current_path.display()
));
}
}
fs::rename(&temporary, current_path).map_err(|error| {
format!(
"切换 current 指针失败 {} -> {}{error}",
temporary.display(),
current_path.display()
)
})
}
#[cfg(not(unix))]
fn switch_current_symlink(
_root: &Path,
_current_path: &Path,
_publish_id: &str,
) -> Result<(), String> {
Err("原子 current symlink 发布目前只支持 Unix/Linux 平台".to_string())
}
fn publish_id(snapshot: &OfficialUpdateSnapshot) -> String {
let bundle = snapshot
.bundle_version
.as_deref()
.map(sanitize_publish_segment)
.unwrap_or_else(|| "no-bundle".to_string());
format!(
"{}-{}-{}-{}-{}",
sanitize_publish_segment(&snapshot.app_version),
bundle,
snapshot.addressables_marker_checked_count(),
unix_seconds_now(),
std::process::id()
)
}
fn sanitize_publish_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
{
'_'
} else {
ch
}
})
.collect::<String>();
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized
}
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Reads an official update snapshot from disk, accepting legacy v1 snapshots.
pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
match serde_json::from_slice::<OfficialUpdateSnapshot>(&data) {
Ok(snapshot) => Ok(Some(snapshot)),
Err(update_error) => {
let legacy =
serde_json::from_slice::<YostarJpSyncSnapshot>(&data).map_err(|legacy_error| {
anyhow::anyhow!(
"无法解析官方更新快照:v2 解析失败 ({update_error})legacy v1 解析也失败 ({legacy_error})"
)
})?;
Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None)))
}
}
}
/// Writes an official update snapshot to disk.
pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(snapshot)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方更新快照").map_err(anyhow::Error::msg)?;
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)?
else {
return Ok(None);
};
Ok(Some(serde_json::from_slice(&data)?))
}
/// Writes an official bootstrap cache to disk.
pub fn write_bootstrap_cache(path: &Path, cache: &OfficialBootstrapCache) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(cache)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方启动缓存").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Returns cached GameMainConfig when cache version and metadata match.
pub fn cached_game_main_config_for_metadata(
cache: &OfficialBootstrapCache,
launcher_metadata: &LauncherMetadataSnapshot,
) -> Option<GameMainConfigSnapshot> {
if cache.cache_version == OFFICIAL_BOOTSTRAP_CACHE_VERSION
&& cache.launcher_metadata == *launcher_metadata
{
Some(cache.game_main_config.clone())
} else {
None
}
}
fn default_update_snapshot_version() -> u32 {
OFFICIAL_UPDATE_SNAPSHOT_VERSION
}
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(());
};
upsert_failed_version(
&mut state.failed_versions,
version,
"上一次官方资源同步在完成发布前中断",
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,
},
);
remove_matching_version_failures(&mut state.failed_versions, &record);
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
});
if let Some(current) = state.current_completed_version.as_ref() {
remove_matching_version_failures(&mut state.failed_versions, current);
}
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;
upsert_failed_version(
&mut state.failed_versions,
record,
error,
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 upsert_failed_version(
failed_versions: &mut Vec<OfficialFailedVersionRecord>,
record: OfficialVersionRecord,
error: &str,
failed_unix_seconds: u64,
) {
if let Some(index) = failed_versions
.iter()
.position(|failed| failed_version_matches(&failed.version, &record))
{
failed_versions.remove(index);
}
failed_versions.push(OfficialFailedVersionRecord {
version: record,
error: error.to_string(),
failed_unix_seconds,
});
}
fn remove_matching_version_failures(
failed_versions: &mut Vec<OfficialFailedVersionRecord>,
version: &OfficialVersionRecord,
) {
failed_versions.retain(|failed| !failed_version_matches(&failed.version, version));
}
fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
left.app_version == right.app_version
&& left.bundle_version == right.bundle_version
&& left.addressables_root == right.addressables_root
}
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,
manifest_url: &YostarJpLauncherManifestUrl,
manifest: &YostarJpLauncherRemoteManifest,
) -> LauncherMetadataSnapshot {
LauncherMetadataSnapshot {
launcher_version: launcher_version.to_string(),
game_latest_version: game_config.game_latest_version.clone(),
game_latest_file_path: game_config.game_latest_file_path.clone(),
game_lowest_version: game_config.game_lowest_version.clone(),
game_start_exe_name: game_config.game_start_exe_name.clone(),
game_start_params: game_config.game_start_params.clone(),
manifest_url: manifest_url.url.clone(),
manifest_source: manifest.source.clone().filter(|value| !value.is_empty()),
manifest_file_count: manifest.files.len(),
}
}
fn game_main_config_snapshot(config: &YostarJpGameMainConfig) -> GameMainConfigSnapshot {
GameMainConfigSnapshot {
server_info_data_url: config.server_info_data_url.clone(),
default_connection_group: config.default_connection_group.clone(),
}
}
fn resolve_bootstrap(
launcher_version: &str,
curl_command: &Path,
unzip_command: &Path,
cache_path: &Path,
write_cache: bool,
progress: &mut dyn FnMut(OfficialUpdateProgress),
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<ResolvedBootstrap> {
check_shutdown_requested(should_cancel)?;
let launcher = OfficialLauncherBootstrapService::with_curl_command(
launcher_version,
curl_command.to_string_lossy().to_string(),
);
progress(OfficialUpdateProgress::new(
"launcher",
"正在拉取官方启动器游戏配置和远端 manifest",
));
let (game_config, manifest_url, manifest) = launcher
.fetch_latest_remote_manifest()
.map_err(anyhow::Error::msg)?;
check_shutdown_requested(should_cancel)?;
let launcher_metadata =
launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest);
progress(OfficialUpdateProgress::new(
"launcher",
format!(
"启动器元数据已解析:最新版本={} manifest 文件数={}",
launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count
),
));
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("检查启动缓存 {}", cache_path.display()),
));
if let Some(cache) = read_bootstrap_cache(cache_path)? {
if let Some(game_main_config) =
cached_game_main_config_for_metadata(&cache, &launcher_metadata)
{
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"命中缓存;复用已解析的 GameMainConfig",
));
return Ok(ResolvedBootstrap {
launcher_metadata,
game_main_config,
cache_hit: true,
});
}
}
progress(OfficialUpdateProgress::new(
"game-main-config",
"缓存未命中;拉取 resources.assets 并解析 GameMainConfig",
));
check_shutdown_requested(should_cancel)?;
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
launcher_version,
curl_command.to_path_buf(),
unzip_command.to_path_buf(),
);
let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::msg)?;
check_shutdown_requested(should_cancel)?;
let launcher_metadata = LauncherMetadataSnapshot {
launcher_version: launcher_version.to_string(),
game_latest_version: bootstrap.game_config.game_latest_version.clone(),
game_latest_file_path: bootstrap.game_config.game_latest_file_path.clone(),
game_lowest_version: bootstrap.game_config.game_lowest_version.clone(),
game_start_exe_name: bootstrap.game_config.game_start_exe_name.clone(),
game_start_params: bootstrap.game_config.game_start_params.clone(),
manifest_url: bootstrap.manifest_url.clone(),
manifest_source: bootstrap.manifest_source.clone(),
manifest_file_count: bootstrap.manifest_file_count,
};
let game_main_config = game_main_config_snapshot(&bootstrap.game_main_config);
if write_cache {
write_bootstrap_cache(
cache_path,
&OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: launcher_metadata.clone(),
game_main_config: game_main_config.clone(),
},
)?;
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("启动缓存已写入 {}", cache_path.display()),
));
} else {
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"试运行:不写入启动缓存",
));
}
Ok(ResolvedBootstrap {
launcher_metadata,
game_main_config,
cache_hit: false,
})
}
fn fetch_seed_catalogs(
fetcher: &OfficialResourcePullService,
discovery: &YostarJpResourceDiscoveryPlan,
progress: &mut dyn FnMut(OfficialUpdateProgress),
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<SeedCatalogs> {
let mut catalogs = SeedCatalogs::default();
for endpoint in &discovery.endpoints {
if !matches!(
endpoint.kind,
YostarJpResourceEndpointKind::TableCatalog
| YostarJpResourceEndpointKind::BundlePackingInfo
| YostarJpResourceEndpointKind::MediaCatalog
) {
continue;
}
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"catalog",
format!(
"拉取种子目录 {}{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = fetcher
.fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?;
catalogs.insert(endpoint, bytes)?;
}
check_shutdown_requested(should_cancel)?;
Ok(catalogs)
}
fn build_inventory_from_seed_catalogs(
catalogs: &SeedCatalogs,
platforms: &[PatchPlatform],
) -> anyhow::Result<YostarJpPlatformDownloadInventory> {
let table_catalog = catalogs
.table_catalog
.as_deref()
.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!(
"官方发现结果缺少 {} BundlePackingInfo.bytes",
platform.as_str()
)
})?;
let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| {
anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str())
})?;
platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes(
*platform,
bundle_packing_info,
media_catalog,
));
}
Ok(YostarJpPlatformDownloadInventory::from_catalog_bytes(
table_catalog,
platform_catalogs,
))
}
#[derive(Debug, Default)]
struct SeedCatalogs {
table_catalog: Option<Vec<u8>>,
bundle_packing_infos: HashMap<PatchPlatform, Vec<u8>>,
media_catalogs: HashMap<PatchPlatform, Vec<u8>>,
}
impl SeedCatalogs {
fn insert(
&mut self,
endpoint: &YostarJpResourceEndpoint,
bytes: Vec<u8>,
) -> anyhow::Result<()> {
match endpoint.kind {
YostarJpResourceEndpointKind::TableCatalog => {
self.table_catalog = Some(bytes);
}
YostarJpResourceEndpointKind::BundlePackingInfo => {
let platform = required_platform(endpoint)?;
self.bundle_packing_infos.insert(platform, bytes);
}
YostarJpResourceEndpointKind::MediaCatalog => {
let platform = required_platform(endpoint)?;
self.media_catalogs.insert(platform, bytes);
}
_ => {}
}
Ok(())
}
}
fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<PatchPlatform> {
endpoint.platform.ok_or_else(|| {
anyhow::anyhow!(
"发现 endpoint {:?} 缺少平台:{}",
endpoint.kind,
endpoint.url
)
})
}
#[derive(Debug)]
struct OfficialUpdateLock {
path: PathBuf,
}
impl OfficialUpdateLock {
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
fs::create_dir_all(&config.output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
let path = config.lock_path();
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
.map_err(anyhow::Error::msg)?;
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(crate::path_security::PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
let pid = std::process::id().to_string();
file.write_all(pid.as_bytes())?;
return Ok(Self { path });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 && remove_stale_lock(&path)? {
continue;
}
return Err(anyhow::anyhow!(
"官方资源目录已被锁定 (locked){}{}",
path.display(),
describe_lock_owner(&path)
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"获取官方资源目录状态锁失败 {}{error}",
path.display()
));
}
}
}
Err(anyhow::anyhow!(
"获取官方资源目录状态锁失败 {}",
path.display()
))
}
}
impl Drop for OfficialUpdateLock {
fn drop(&mut self) {
let expected = std::process::id().to_string();
if fs::symlink_metadata(&self.path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if fs::read_to_string(&self.path)
.map(|contents| contents.trim() == expected)
.unwrap_or(false)
{
let _ = fs::remove_file(&self.path);
}
}
}
fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() {
fs::remove_file(path)?;
return Ok(true);
}
let contents = fs::read_to_string(path).unwrap_or_default();
let Some(pid) = parse_lock_pid(&contents) else {
fs::remove_file(path)?;
return Ok(true);
};
if process_exists(pid) {
return Ok(false);
}
fs::remove_file(path)?;
Ok(true)
}
fn describe_lock_owner(path: &Path) -> String {
if fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return "锁文件是 symlink,可执行 clean-stable 清理".to_string();
}
let contents = fs::read_to_string(path).unwrap_or_default();
match parse_lock_pid(&contents) {
Some(pid) if process_exists(pid) => format!("owner_pid={pid} 仍在运行"),
Some(pid) => format!("owner_pid={pid} 已失效,可重新执行或 clean-stable 清理"),
None => "锁文件内容不是有效 PID,可执行 clean-stable 清理".to_string(),
}
}
fn parse_lock_pid(contents: &str) -> Option<u32> {
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
#[cfg(not(unix))]
fn process_exists(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_base_snapshot() -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("bundle".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/root".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.hash".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::AddressablesCatalogHash,
platform: Some(PatchPlatform::Windows),
url: "https://prod-clientpatch.bluearchiveyostar.com/root/Windows_PatchPack/catalog_StandaloneWindows64.hash".to_string(),
},
],
}
}
fn fixture_marker(value: &str) -> OfficialEndpointMarkerSnapshot {
OfficialEndpointMarkerSnapshot {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url:
"https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.hash"
.to_string(),
role: OfficialEndpointMarkerRole::OfficialSeedHash,
value: value.to_string(),
}
}
fn fixture_bootstrap() -> ResolvedBootstrap {
ResolvedBootstrap {
launcher_metadata: LauncherMetadataSnapshot {
launcher_version: "1.7.2".to_string(),
game_latest_version: "1.70.0".to_string(),
game_latest_file_path: "BAJP_1.70.0.zip".to_string(),
game_lowest_version: Some("1.69.0".to_string()),
game_start_exe_name: Some("BlueArchive".to_string()),
game_start_params: vec!["--prod".to_string()],
manifest_url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json".to_string(),
manifest_source: Some("BAJP_1.70.0.zip".to_string()),
manifest_file_count: 42,
},
game_main_config: GameMainConfigSnapshot {
server_info_data_url: Some(
"https://yostar-serverinfo.bluearchiveyostar.com/prod.json".to_string(),
),
default_connection_group: Some("Prod-Audit".to_string()),
},
cache_hit: false,
}
}
#[test]
fn persists_snapshot_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("snapshot.json");
let bootstrap = fixture_bootstrap();
let snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("1234")],
Some(&bootstrap),
);
write_snapshot(&path, &snapshot).unwrap();
assert_eq!(read_snapshot(&path).unwrap(), Some(snapshot));
}
#[test]
fn reads_legacy_snapshot_json() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("snapshot.json");
let legacy = fixture_base_snapshot();
fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap();
let snapshot = read_snapshot(&path).unwrap().unwrap();
assert_eq!(snapshot.base_snapshot(), legacy);
assert!(snapshot.endpoint_markers.is_empty());
assert_eq!(snapshot.snapshot_version, OFFICIAL_UPDATE_SNAPSHOT_VERSION);
}
#[test]
fn marker_content_change_requires_download() {
let previous = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("1111")],
Some(&fixture_bootstrap()),
);
let current = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("2222")],
Some(&fixture_bootstrap()),
);
let delta = diff_extended_snapshot(&current, Some(&previous));
assert!(delta.endpoint_markers_changed);
assert!(delta.has_changes());
assert!(!delta.launcher_metadata_changed);
assert!(!delta.game_main_config_changed);
}
#[test]
fn persists_bootstrap_cache_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-bootstrap-cache.json");
let bootstrap = fixture_bootstrap();
let cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: bootstrap.launcher_metadata,
game_main_config: bootstrap.game_main_config,
};
write_bootstrap_cache(&path, &cache).unwrap();
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,
&current_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,
&current_snapshot,
"broken",
&published,
state
.current_completed_version
.as_ref()
.map(|_| &current_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 version_state_deduplicates_repeated_failures_and_clears_after_success() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("current")],
Some(&fixture_bootstrap()),
);
let active = temp.path().join("versions/active");
for id in ["broken-1", "broken-2"] {
let staging = temp.path().join(".staging").join(id);
let failed = prepare_in_progress_version_state(
&path,
&snapshot,
id,
&active,
None,
&staging,
&staging.join("official-sync-snapshot.json"),
)
.unwrap();
fail_version_state(&path, failed, "download 403").unwrap();
}
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(state.failed_versions.len(), 1);
assert_eq!(state.failed_versions[0].version.id, "broken-2");
assert_eq!(state.failed_versions[0].error, "download 403");
let staging = temp.path().join(".staging/broken-3");
let failed = prepare_in_progress_version_state(
&path,
&snapshot,
"broken-3",
&active,
None,
&staging,
&staging.join("official-sync-snapshot.json"),
)
.unwrap();
fail_version_state(&path, failed, "download 404").unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(state.failed_versions.len(), 1);
assert_eq!(state.failed_versions[0].version.id, "broken-3");
assert_eq!(state.failed_versions[0].error, "download 404");
let staging = temp.path().join(".staging/fixed");
let fixed = prepare_in_progress_version_state(
&path,
&snapshot,
"fixed",
&active,
None,
&staging,
&staging.join("official-sync-snapshot.json"),
)
.unwrap();
let published = temp.path().join("versions/fixed");
complete_version_state(
&path,
fixed,
&published,
&published.join("official-sync-snapshot.json"),
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert!(state.failed_versions.is_empty());
}
#[test]
fn update_rejects_dangerous_output_root_before_network_work() {
let config = OfficialUpdateConfig {
output_root: PathBuf::from("/"),
dry_run: true,
..OfficialUpdateConfig::default()
};
let error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(error.to_string().contains("危险路径"));
}
#[test]
fn update_rejects_snapshot_path_escape() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().join("resources"),
snapshot_path: Some(temp.path().join("outside-snapshot.json")),
dry_run: true,
..OfficialUpdateConfig::default()
};
let error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(error.to_string().contains("路径逃逸"));
}
#[cfg(unix)]
#[test]
fn write_snapshot_rejects_symlink_target() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let outside = temp.path().join("outside.json");
let link = temp.path().join("snapshot.json");
fs::write(&outside, b"outside").unwrap();
symlink(&outside, &link).unwrap();
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
let error = write_snapshot(&link, &snapshot).unwrap_err();
assert!(error.to_string().contains("symlink"));
assert_eq!(fs::read(&outside).unwrap(), b"outside");
}
#[test]
fn bootstrap_cache_hit_requires_same_metadata_and_version() {
let bootstrap = fixture_bootstrap();
let cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: bootstrap.launcher_metadata.clone(),
game_main_config: bootstrap.game_main_config.clone(),
};
assert_eq!(
cached_game_main_config_for_metadata(&cache, &bootstrap.launcher_metadata),
Some(bootstrap.game_main_config.clone())
);
let mut changed_metadata = bootstrap.launcher_metadata.clone();
changed_metadata.game_latest_version = "1.71.0".to_string();
assert_eq!(
cached_game_main_config_for_metadata(&cache, &changed_metadata),
None
);
let stale_version_cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION + 1,
..cache
};
assert_eq!(
cached_game_main_config_for_metadata(
&stale_version_cache,
&bootstrap.launcher_metadata
),
None
);
}
#[test]
fn non_dry_run_lock_rejects_concurrent_writer() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().to_path_buf(),
..OfficialUpdateConfig::default()
};
let first = OfficialUpdateLock::acquire(&config).unwrap();
let second = OfficialUpdateLock::acquire(&config).unwrap_err();
assert!(second.to_string().contains("锁定"));
drop(first);
assert!(OfficialUpdateLock::acquire(&config).is_ok());
}
#[test]
fn non_dry_run_lock_removes_stale_pid_file() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().to_path_buf(),
..OfficialUpdateConfig::default()
};
fs::create_dir_all(&config.output_root).unwrap();
fs::write(config.lock_path(), "999999999").unwrap();
let lock = OfficialUpdateLock::acquire(&config).unwrap();
assert!(config.lock_path().exists());
drop(lock);
assert!(!config.lock_path().exists());
}
#[test]
fn non_dry_run_lock_removes_corrupt_pid_file() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().to_path_buf(),
..OfficialUpdateConfig::default()
};
fs::create_dir_all(&config.output_root).unwrap();
fs::write(config.lock_path(), "not-a-pid").unwrap();
let lock = OfficialUpdateLock::acquire(&config).unwrap();
assert_eq!(
fs::read_to_string(config.lock_path()).unwrap(),
std::process::id().to_string()
);
drop(lock);
assert!(!config.lock_path().exists());
}
#[test]
fn update_run_can_be_cancelled_before_network_work() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().to_path_buf(),
dry_run: true,
..OfficialUpdateConfig::default()
};
let mut checks = 0;
let error = OfficialUpdateService::new()
.run_with_progress_and_cancellation(
&config,
|_| {},
|| {
checks += 1;
true
},
)
.unwrap_err();
assert!(error.to_string().contains("停止请求"));
assert_eq!(checks, 1);
}
}