//! 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::curl_transfer::{resolve_curl_proxy, CurlProxyConfig}; use crate::localized_patch::{ read_localized_version_state, LOCALIZED_CURRENT_LINK, LOCALIZED_VERSIONS_DIR, }; use crate::official_changes::{ write_official_resource_change_handoff, OfficialResourceChangeHandoffReport, OfficialResourceChangeSummary, }; use crate::official_game_main_config::{ resolve_game_main_config_source, OfficialGameMainConfigSelectedSource, OfficialGameMainConfigSourceKind, }; use crate::official_repository::{ OfficialReleaseImportConfig, OfficialReleaseImportReport, OfficialReleaseImportService, }; use crate::official_textunit_queue::{ is_crowdin_textunit_queue_current, is_textunit_task_queue_current, read_textunit_task_queue_at, write_official_textunit_queues, OfficialTextUnitQueueReport, OfficialTextUnitTaskSummary, }; use crate::path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute, read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE, }; use crate::release_flow::ReleaseFlowStatusCode; use crate::translation_tasks::{ build_translation_handoff, sync_translation_task_repository_at, write_translation_handoff_at, }; use crate::{ build_official_pull_plan_for_platform_inventory, build_official_sync_plan, changed_endpoint_urls, default_official_platforms, release_cas_reuse_references, DownloadError, OfficialGameMainConfigBootstrapService, OfficialLauncherBootstrapService, OfficialResourceHashVerification, OfficialResourcePullPlan, OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullService, OfficialResourceReuseWarning, OfficialResourceVerification, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, }; use crate::{ read_parse_cache_at, OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary, }; use crate::{FileSystemCasRepository, SqliteResourceRepository}; use crate::{DEFAULT_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY}; use bat_adapters::official::game_main_config::YostarJpGameMainConfig; use bat_adapters::official::launcher::YostarJpLauncherManifestFile; use bat_adapters::official::yostar_jp::{ PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot, }; use bat_adapters::official::{ InventoryParser, OfficialResourceBackend, PlatformCatalogInput, YostarJpBackend, YostarJpPlatformDownloadInventory, }; use bat_core::ErrorCode; 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 launcher bootstrap artifact schema version. pub const OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_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_LAUNCHER_BOOTSTRAP_FILE: &str = "official-launcher-bootstrap.json"; const OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE: &str = "official-launcher-bootstrap.pending.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, /// Optional explicit connection group. pub connection_group: Option, /// Optional explicit app version. pub app_version: Option, /// 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>, /// Output root for resources and state files. pub output_root: PathBuf, /// Output root reserved for localized resources generated from official data. pub localized_output_root: PathBuf, /// Optional explicit sync snapshot path. pub snapshot_path: Option, /// Curl command used by the current infrastructure downloader. pub curl_command: PathBuf, /// Proxy selection used by all official `curl` transfers. pub curl_proxy: CurlProxyConfig, /// Maximum number of resource downloads executed concurrently. /// /// `8` is the default; values are accepted only in `1..=256`. pub download_concurrency: usize, /// 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, /// Import a verified official release into CAS + ResourceRepository. pub import_repository: bool, /// Optional CAS root for official release imports. Defaults under /// `output_root` so official bytes and derived index stay isolated. pub import_cas_root: Option, /// Optional SQLite resource index path for official release imports. /// Defaults under `output_root`. pub import_resource_repository_path: Option, } 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"), localized_output_root: PathBuf::from("./bat-localized"), snapshot_path: None, curl_command: PathBuf::from("curl"), curl_proxy: CurlProxyConfig::default(), download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY, unzip_command: PathBuf::from("unzip"), dry_run: false, plan: false, force: false, audit_local: true, repair: true, import_repository: false, import_cas_root: None, import_resource_repository_path: None, } } } 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") } /// Returns the CAS root used by the optional official release importer. pub fn effective_import_cas_root(&self) -> PathBuf { self.import_cas_root .clone() .unwrap_or_else(|| self.output_root.join(".cas")) } /// Returns the SQLite resource index path used by the optional official /// release importer. pub fn effective_import_resource_repository_path(&self) -> PathBuf { self.import_resource_repository_path .clone() .unwrap_or_else(|| self.output_root.join("resources.sqlite")) } } /// 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, /// Official launcher/server-info is ahead of the client-patch CDN; keep the /// existing release and check again later. WaitingForOfficialResources, /// 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::WaitingForOfficialResources => "waiting_for_official_resources", Self::Downloaded => "downloaded", } } /// Returns the stable cross-module flow status code. pub const fn flow_status_code(self) -> ReleaseFlowStatusCode { match self { Self::UpToDate => ReleaseFlowStatusCode::OfficialUpToDate, Self::WouldDownload => ReleaseFlowStatusCode::OfficialUpdateAvailable, Self::WaitingForOfficialResources => ReleaseFlowStatusCode::OfficialWaitingForResources, Self::Downloaded => ReleaseFlowStatusCode::OfficialPublished, } } } /// Publication state for localized resources associated with an official release. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LocalizedReleaseStatus { /// Official resources are published, but localized resources are not. NotLocalized, /// Official resources and localized resources are both published. Localized, } impl LocalizedReleaseStatus { /// Returns a stable string label for CLI and JSON callers. pub fn as_str(self) -> &'static str { match self { Self::NotLocalized => "not_localized", Self::Localized => "localized", } } /// Returns the stable status code for an official release that is known /// to match the currently selected localized state. pub const fn flow_status_code(self) -> ReleaseFlowStatusCode { match self { Self::NotLocalized => ReleaseFlowStatusCode::LocalizedPending, Self::Localized => ReleaseFlowStatusCode::LocalizedPublished, } } } /// 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, /// Selected official Addressables root URL. pub addressables_root: String, /// Seed endpoints observed for the selected platform set. pub endpoints: Vec, /// Small remote marker contents fetched for change detection. #[serde(default)] pub endpoint_markers: Vec, /// Launcher metadata summary used for auto-discovery, when available. #[serde(default)] pub launcher_metadata: Option, /// Parsed GameMainConfig summary used for auto-discovery, when available. #[serde(default)] pub game_main_config_bootstrap: Option, } /// 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, /// Version currently being downloaded into staging. #[serde(default)] pub in_progress_version: Option, /// Previously usable version before the latest successful publish. #[serde(default)] pub previous_available_version: Option, /// Recent versions that failed after entering staging. #[serde(default)] pub failed_versions: Vec, /// 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/`. pub id: String, /// Selected app version. pub app_version: String, /// Selected bundle version, when present. pub bundle_version: Option, /// 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, /// Versioned path, when known. #[serde(default)] pub version_path: Option, /// Start time for a staged pull. #[serde(default)] pub started_unix_seconds: Option, /// Completion time for a published version. #[serde(default)] pub completed_unix_seconds: Option, } /// 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, 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, /// Official marker URL. pub url: String, /// How this marker should be interpreted. pub role: OfficialEndpointMarkerRole, /// Trimmed marker content. pub value: String, } /// Official endpoint that was advertised by launcher/server-info but was not /// yet readable from the client-patch CDN. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialUnavailableEndpoint { /// Endpoint kind. pub kind: YostarJpResourceEndpointKind, /// Platform for platform-specific endpoints. pub platform: Option, /// Official URL that was probed. pub url: String, /// Stable error-code kind from the failed fetch. pub error_kind: String, /// HTTP status when curl observed one. pub http_status: Option, /// Human-readable failure detail. pub error: String, } impl OfficialUnavailableEndpoint { fn from_fetch_error(endpoint: &YostarJpResourceEndpoint, error: &DownloadError) -> Self { Self { kind: endpoint.kind, platform: endpoint.platform, url: endpoint.url.clone(), error_kind: error.code().kind().to_string(), http_status: http_status_from_error_message(&error.to_string()), error: error.to_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, /// Game executable name. pub game_start_exe_name: Option, /// Game executable arguments. pub game_start_params: Vec, /// Remote launcher manifest URL. pub manifest_url: String, /// Remote launcher manifest source path. pub manifest_source: Option, /// Number of files in the remote launcher manifest. pub manifest_file_count: usize, /// BLAKE3 digest over the remote launcher manifest file list. #[serde(default, skip_serializing_if = "Option::is_none")] pub manifest_files_blake3: Option, } /// 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, /// Default connection group from GameMainConfig. pub default_connection_group: Option, } /// Versioned artifact status for official launcher bootstrap data. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OfficialLauncherBootstrapArtifactStatus { /// The artifact belongs to a fully published official resource release. Published, /// The launcher/server-info chain advanced, but required game resources are /// not yet readable from the official client-patch CDN. WaitingForOfficialResources, } /// Resource-release context attached to one launcher bootstrap artifact. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherBootstrapContext { /// Selected connection group. pub connection_group_name: String, /// Selected app version. pub app_version: String, /// Bundle version from server-info, when present. pub bundle_version: Option, /// Selected official Addressables root URL. pub addressables_root: String, } /// Official launcher CDN roots observed for this bootstrap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherCdnConfigSnapshot { /// Primary official launcher package CDN root. pub primary_cdn: String, /// Backup official launcher package CDN root. pub back_up_cdn: String, } /// One file entry from the official launcher remote manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherManifestFileSnapshot { /// Manifest path relative to the game root, preserving official spelling. pub path: String, /// Official size field as received. pub size: String, /// Parsed size, when the official size field is valid decimal. #[serde(default, skip_serializing_if = "Option::is_none")] pub parsed_size: Option, /// Official launcher manifest hash field. pub hash: String, /// Official per-file integrity hash. #[serde(default, skip_serializing_if = "Option::is_none")] pub vc: Option, } /// Remote launcher manifest captured for audit and downstream bootstrap use. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherRemoteManifestSnapshot { /// Remote manifest URL returned by the official launcher API. pub url: String, /// Remote manifest source path. pub source: Option, /// Number of files declared by the remote manifest. pub file_count: usize, /// Stable BLAKE3 digest over the ordered manifest file list. pub files_blake3: String, /// Ordered file entries from the remote launcher manifest. pub files: Vec, } /// Kind of source selected for `GameMainConfig` extraction. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OfficialLauncherGameMainConfigSourceKind { /// Older launcher manifests point to a single game ZIP archive. Archive, /// Current launcher manifests expose a directory plus per-file entries. ManifestFile, } /// Exact official launcher artifact used to obtain `GameMainConfig`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherGameMainConfigSourceSnapshot { /// Source kind. pub kind: OfficialLauncherGameMainConfigSourceKind, /// Official URL fetched for this source. pub url: String, /// Relative path under the official launcher package CDN root. pub relative_path: String, /// Original manifest file path when the source is a manifest file entry. #[serde(default, skip_serializing_if = "Option::is_none")] pub manifest_path: Option, /// Declared file size from the manifest, when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub declared_size: Option, /// Official launcher manifest `hash` field, when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub official_hash: Option, /// Official launcher manifest per-file `vc`, when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub vc: Option, } /// Launcher bootstrap data resolved during one official update run. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherBootstrapData { /// Launcher metadata summary also stored in `official-sync-snapshot.json`. pub launcher_metadata: LauncherMetadataSnapshot, /// Decrypted `GameMainConfig` summary. pub game_main_config: GameMainConfigSnapshot, /// Official launcher CDN roots. pub cdn_config: OfficialLauncherCdnConfigSnapshot, /// Remote launcher manifest file list. pub remote_manifest: OfficialLauncherRemoteManifestSnapshot, /// Exact source selected for `GameMainConfig`. pub selected_game_main_config_source: OfficialLauncherGameMainConfigSourceSnapshot, } /// Versioned official launcher bootstrap artifact written next to a release. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialLauncherBootstrapArtifact { /// Artifact schema version. #[serde(default = "default_launcher_bootstrap_artifact_version")] pub artifact_version: u32, /// Whether this artifact belongs to a published release or a pending /// maintenance-period observation. pub status: OfficialLauncherBootstrapArtifactStatus, /// Write time for this artifact. pub generated_unix_seconds: u64, /// Official resource context this launcher data resolved to. pub context: OfficialLauncherBootstrapContext, /// Captured launcher bootstrap data. pub launcher_bootstrap: OfficialLauncherBootstrapData, } /// 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, /// Versionable launcher bootstrap data. pub launcher_bootstrap: OfficialLauncherBootstrapData, /// 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, /// Stable cross-module flow status code. pub status_code: ReleaseFlowStatusCode, /// Selected connection group. pub connection_group: String, /// Selected app version. pub app_version: String, /// Selected bundle version. pub bundle_version: Option, /// Selected Addressables root. pub addressables_root: String, /// Platform set used by the run. pub platforms: Vec, /// Managed publish root containing `current`, `versions`, and staging. pub output_root: PathBuf, /// Separate root reserved for localized resources. pub localized_output_root: PathBuf, /// Localized publication state for this official resource version. pub localized_release_status: LocalizedReleaseStatus, /// Future localized `current` pointer path under `localized_output_root`. pub localized_current_path: PathBuf, /// Versioned localized release directory when localized resources are published. pub localized_published_version_path: Option, /// 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, /// Staging directory used during download before publish. pub staging_path: Option, /// 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 launcher/server-info advertised a newer resource root whose /// required client-patch endpoints are not yet readable. #[serde(default)] pub waiting_for_official_resources: bool, /// Unavailable official seed/marker endpoints observed before staging. #[serde(default)] pub unavailable_endpoints: Vec, /// Whether this was the first observed snapshot. pub is_initial: bool, /// Changed endpoint URLs from legacy diff. pub changed_endpoint_urls: Vec, /// 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, /// Bootstrap cache path. pub bootstrap_cache_path: Option, /// 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, /// Download URLs when plan output was requested. pub download_urls: Vec, /// Pull item count after a real download. pub resource_count: Option, /// 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, /// Number of resources reused from immutable historical releases. #[serde(default)] pub release_reused_count: usize, /// Number of resources reused from CAS. #[serde(default)] pub cas_reused_count: usize, /// Bytes materialized without network transfer. #[serde(default)] pub reused_bytes: u64, /// Diagnostics recorded while invalid reuse candidates fell back. #[serde(default)] pub reuse_warnings: Vec, /// 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, /// Resource-change-set path written after a verified release publish. pub resource_change_set_path: Option, /// Crowdin translation handoff path reserved for added/modified resources. pub crowdin_handoff_path: Option, /// Summary of resource changes between the previous complete release and /// the current release. pub resource_change_summary: Option, /// Parse-cache path written or refreshed after successful verification. pub parse_cache_path: Option, /// Post-sync parse-cache summary. pub parse_summary: Option, /// Incremental TextUnit task queue path derived from change set + parse cache. pub textunit_task_queue_path: Option, /// Crowdin offline TextUnit queue path derived from queued TextUnit tasks. pub crowdin_textunit_queue_path: Option, /// Incremental TextUnit task queue summary. pub textunit_task_summary: Option, /// Versioned translation worker handoff view. pub translation_handoff_path: Option, /// Snapshot path written after success. pub snapshot_written: Option, /// Launcher bootstrap artifact written for this run. pub launcher_bootstrap_artifact_path: Option, /// Whether the optional CAS + ResourceRepository import was enabled. pub repository_import_enabled: bool, /// CAS root used by the optional importer. pub repository_import_cas_root: Option, /// SQLite ResourceRepository path used by the optional importer. pub repository_import_path: Option, /// Optional import summary after a verified official release was published /// or confirmed up-to-date. pub repository_import_summary: Option, } /// 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, /// Stable cross-module flow status code derived from `stage`. pub status_code: ReleaseFlowStatusCode, /// Human-readable status line. pub message: String, /// One-based download index when the event represents URL download work. pub download_index: Option, /// Total URL count for the current download plan. pub download_total: Option, /// Current official URL for download progress. pub download_url: Option, /// Stable pull status label when a URL finished. pub download_status: Option, /// Final local byte count for the URL when known. pub download_bytes: Option, /// Bytes transferred in this run for the URL when known. pub download_transferred_bytes: Option, /// Stable failure kind when a URL failed. pub download_failure_kind: Option, /// HTTP status parsed from curl stderr when a URL failed. pub download_failure_http_status: Option, /// Whether the final failure was retryable. pub download_failure_retryable: Option, /// Number of transfer attempts executed before failure. pub download_failure_attempts: Option, /// Whether the URL was recorded in the quarantine manifest. pub download_quarantined: Option, /// Local size/BLAKE3/ZIP verification for a completed URL. pub download_verification: Option, /// Official `.hash` sidecar verification when a seed catalog pair passes. pub official_hash_verification: Option, } impl OfficialUpdateProgress { /// Creates a progress event. pub fn new(stage: &'static str, message: impl Into) -> Self { Self { stage, status_code: ReleaseFlowStatusCode::from_progress_stage(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, download_verification: None, official_hash_verification: 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.download_verification = event.verification.clone(); self.official_hash_verification = event.official_hash.clone(); 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, reuse_existing_staging: bool, } 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 { 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/` before switching `current`. Ok(self.root.clone()) } fn current_target(&self) -> Result, 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 { Ok(self.current_target()?.is_some()) } fn plan( &self, snapshot: &OfficialUpdateSnapshot, recovered_staging: Option<&OfficialVersionRecord>, ) -> OfficialPublishPlan { if let Some(record) = recovered_staging { let staging_path = record .staging_path .clone() .unwrap_or_else(|| record.resource_root.clone()); return OfficialPublishPlan { id: record.id.clone(), staging_path, version_path: self.versions_dir.join(&record.id), reuse_existing_staging: true, }; } let id = publish_id(snapshot); OfficialPublishPlan { staging_path: self.staging_dir.join(&id), version_path: self.versions_dir.join(&id), id, reuse_existing_staging: false, } } 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 目录")?; if !plan.reuse_existing_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() )); } if plan.reuse_existing_staging { return Ok(()); } 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 { path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE)) } fn release_reuse_roots(&self, active_root: &Path) -> Result, String> { let mut roots = Vec::new(); if path_exists_no_follow(active_root)? { roots.push(active_root.to_path_buf()); } if !path_exists_no_follow(&self.versions_dir)? { return Ok(roots); } ensure_safe_directory_path(&self.versions_dir, "官方资源历史 release 根目录")?; let mut versions = fs::read_dir(&self.versions_dir) .map_err(|error| { format!( "读取官方资源历史 release 根目录失败 {}:{error}", self.versions_dir.display() ) })? .filter_map(|entry| entry.ok().map(|entry| entry.path())) .filter(|path| { fs::symlink_metadata(path) .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink()) .unwrap_or(false) }) .collect::>(); versions.sort(); versions.reverse(); roots.extend(versions); roots.dedup(); Ok(roots) } fn publish(&self, plan: &OfficialPublishPlan) -> Result { 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 { 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 { 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 { 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)?; progress(OfficialUpdateProgress::new( "proxy", resolve_curl_proxy(&config.curl_proxy).human_summary(), )); 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, ) .with_proxy_config(config.curl_proxy.clone()) .with_max_concurrency(config.download_concurrency); 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, &BootstrapTools { curl_command: &config.curl_command, curl_proxy: &config.curl_proxy, unzip_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::Error::new(DownloadError::new( ErrorCode::MISSING_APP_VERSION, "缺少应用版本;请显式传入 --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::Error::new(DownloadError::new( ErrorCode::MISSING_CONNECTION_GROUP, "缺少连接组;请显式传入 --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::Error::new(DownloadError::new( ErrorCode::MISSING_SERVER_INFO_SOURCE, "缺少服务器信息来源;请显式传入 --server-info-* 或启用 --auto-discover", )) })?; let url = bootstrap .game_main_config .server_info_data_url .as_deref() .ok_or_else(|| { // 官方 GameMainConfig 缺少必需字段:launcher 链内容缺陷。 anyhow::Error::new(DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, "官方 GameMainConfig 中没有 ServerInfoDataUrl", )) })?; progress(OfficialUpdateProgress::new( "server-info", format!("拉取自动发现的服务器信息 {url}"), )); fetcher.fetch_bytes(url).map_err(anyhow::Error::new)? }; 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(¤t_snapshot) ), )); let marker_collection = collect_endpoint_markers( &fetcher, ¤t_snapshot, &mut progress, &mut should_cancel, )?; let endpoint_markers = marker_collection.markers; 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(¤t_update_snapshot, previous_snapshot.as_ref()); let changed_urls = changed_endpoint_urls(&sync_plan.delta); if !marker_collection.unavailable_endpoints.is_empty() { progress(OfficialUpdateProgress::new( "upstream", format!( "官方启动器/server-info 已更新,但 {} 个远端标记尚未开放;保留当前资源并等待下次检查", marker_collection.unavailable_endpoints.len() ), )); let launcher_bootstrap_artifact_path = if !config.dry_run && bootstrap.is_some() { progress(OfficialUpdateProgress::new( "launcher-bootstrap", format!( "写入待开放官方启动器 bootstrap {}", config .output_root .join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE) .display() ), )); write_launcher_bootstrap_artifact_for_snapshot( config, &config.output_root, ¤t_update_snapshot, bootstrap.as_ref(), OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources, OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE, )? } else { None }; let mut report = waiting_for_official_resources_report( config, platforms, &publish_layout, &active_resource_root, &snapshot_path, &version_state_path, ¤t_snapshot, ¤t_update_snapshot, previous_snapshot.is_some(), format!("{:?}", sync_plan.decision), sync_plan.delta.is_initial, changed_urls, extended_delta, bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit), Some(bootstrap_cache_path.clone()), marker_collection.unavailable_endpoints, ); report.launcher_bootstrap_artifact_path = launcher_bootstrap_artifact_path; return Ok(report); } 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 = match build_pull_plan( &server_info, &connection_group, &app_version, platforms, &fetcher, &mut progress, &mut should_cancel, ) { Ok(plan) => plan, Err(error) => { if let Some(unavailable) = error.downcast_ref::() { progress(OfficialUpdateProgress::new( "upstream", format!( "官方启动器/server-info 已更新,但必需资源尚未开放;保留当前资源并等待下次检查:{}", unavailable.endpoint.url ), )); let launcher_bootstrap_artifact_path = if !config.dry_run && bootstrap.is_some() { progress(OfficialUpdateProgress::new( "launcher-bootstrap", format!( "写入待开放官方启动器 bootstrap {}", config .output_root .join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE) .display() ), )); write_launcher_bootstrap_artifact_for_snapshot( config, &config.output_root, ¤t_update_snapshot, bootstrap.as_ref(), OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources, OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE, )? } else { None }; let mut report = waiting_for_official_resources_report( config, platforms, &publish_layout, &active_resource_root, &snapshot_path, &version_state_path, ¤t_snapshot, ¤t_update_snapshot, previous_snapshot.is_some(), format!("{:?}", sync_plan.decision), sync_plan.delta.is_initial, changed_urls, extended_delta, bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit), Some(bootstrap_cache_path.clone()), vec![unavailable.endpoint.clone()], ); report.launcher_bootstrap_artifact_path = launcher_bootstrap_artifact_path; return Ok(report); } return Err(error); } }; 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 active_release_id = version_id_from_path(&active_resource_root) .unwrap_or_else(|| fallback_version_id(¤t_update_snapshot)); let localized_info = localized_release_info_for(config, Some(active_release_id.as_str())); let mut report = OfficialUpdateReport { update_status: if should_download { OfficialUpdateStatus::WouldDownload } else { OfficialUpdateStatus::UpToDate }, status_code: if should_download { ReleaseFlowStatusCode::OfficialUpdateAvailable } else { ReleaseFlowStatusCode::OfficialUpToDate }, 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(), localized_output_root: config.localized_output_root.clone(), localized_release_status: localized_info.status, localized_current_path: localized_info.current_path, localized_published_version_path: localized_info.published_version_path, 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, waiting_for_official_resources: false, unavailable_endpoints: Vec::new(), 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, release_reused_count: 0, cas_reused_count: 0, reused_bytes: 0, reuse_warnings: Vec::new(), 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(), resource_change_set_path: None, crowdin_handoff_path: None, resource_change_summary: None, parse_cache_path: None, parse_summary: None, textunit_task_queue_path: None, crowdin_textunit_queue_path: None, textunit_task_summary: None, translation_handoff_path: None, snapshot_written: None, launcher_bootstrap_artifact_path: None, repository_import_enabled: config.import_repository, repository_import_cas_root: config .import_repository .then(|| config.effective_import_cas_root()), repository_import_path: config .import_repository .then(|| config.effective_import_resource_repository_path()), repository_import_summary: 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( ¤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, )?; let active_launcher_bootstrap_path = active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE); if bootstrap.is_some() && path_exists_no_follow(&active_launcher_bootstrap_path) .map_err(anyhow::Error::msg)? { report.launcher_bootstrap_artifact_path = Some(active_launcher_bootstrap_path); } else if bootstrap.is_some() { progress(OfficialUpdateProgress::new( "launcher-bootstrap", format!( "写入当前官方启动器 bootstrap {}", active_launcher_bootstrap_path.display() ), )); report.launcher_bootstrap_artifact_path = write_launcher_bootstrap_artifact_for_snapshot( config, &active_resource_root, ¤t_update_snapshot, bootstrap.as_ref(), OfficialLauncherBootstrapArtifactStatus::Published, OFFICIAL_LAUNCHER_BOOTSTRAP_FILE, )?; } run_post_sync_parse_cache_if_needed( config, &active_resource_root, &mut report, &mut progress, &mut should_cancel, )?; run_post_sync_textunit_queue_if_needed( &active_resource_root, &mut report, &mut progress, &mut should_cancel, )?; run_post_sync_repository_import( config, &active_resource_root, Some(active_release_id.as_str()), &mut report, &mut progress, &mut should_cancel, )?; } 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 recovered_staging = recoverable_failed_staging( &version_state_path, ¤t_update_snapshot, &publish_layout, ) .map_err(anyhow::Error::msg)?; if let Some(record) = recovered_staging.as_ref() { progress(OfficialUpdateProgress::new( "publish", format!( "发现可复用的未完成 staging:id={} path={}", record.id, record .staging_path .as_ref() .unwrap_or(&record.resource_root) .display() ), )); } let publish_plan = publish_layout.plan(¤t_update_snapshot, recovered_staging.as_ref()); 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)?; if publish_plan.reuse_existing_staging { progress(OfficialUpdateProgress::new( "publish", "复用未完成 staging;跳过 active release seed,避免覆盖已下载文件", )); } else { 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, ) .with_proxy_config(config.curl_proxy.clone()) .with_release_reuse_roots( publish_layout .release_reuse_roots(&active_resource_root) .map_err(anyhow::Error::msg)?, ) .with_cas_reuse_root(config.effective_import_cas_root()) .with_max_concurrency(config.download_concurrency); let pruned_stale_resource_count = staging_fetcher .prune_stale_manifest_entries(&pull_plan) .map_err(anyhow::Error::msg)?; if pruned_stale_resource_count > 0 { progress(OfficialUpdateProgress::new( "publish", format!( "已清理新 manifest 删除的旧官方资源:{} 项", pruned_stale_resource_count ), )); } 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, ) // 用 Error::new 保留 DownloadError 类型(含错误码),供上层 downcast 归类。 .map_err(anyhow::Error::new) .inspect_err(|error| { let _ = version_state_guard.fail(&error.to_string()); })?; progress(OfficialUpdateProgress::new( "download", format!( "下载阶段完成:已下载={} 已续传={} 当前 manifest 复用={} 历史 release 复用={} CAS 复用={} 本轮传输字节={}", pull_report.downloaded_count(), pull_report.resumed_count(), pull_report.skipped_count(), pull_report.release_reused_count(), pull_report.cas_reused_count(), pull_report.transferred_bytes() ), )); if !pull_report.reuse_warnings.is_empty() { progress(OfficialUpdateProgress::new( "download", format!( "复用候选诊断:{} 项候选未通过校验,已按顺序回退", pull_report.reuse_warnings.len() ), )); } 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, ¤t_update_snapshot)?; } let staging_launcher_bootstrap_artifact_path = if bootstrap.is_some() { progress(OfficialUpdateProgress::new( "launcher-bootstrap", format!( "写入官方启动器 bootstrap {}", publish_plan .staging_path .join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE) .display() ), )); write_launcher_bootstrap_artifact_for_snapshot( config, &publish_plan.staging_path, ¤t_update_snapshot, bootstrap.as_ref(), OfficialLauncherBootstrapArtifactStatus::Published, OFFICIAL_LAUNCHER_BOOTSTRAP_FILE, )? } else { None }; 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)?; // current symlink 已切换,资源发布成功。先取出完成版本记录并立即结束版本状态 // 事务,使后续 snapshot / version-state 写入失败不会把这个已发布版本经 // VersionStateGuard::Drop 误记为 failed,而是降级为可下轮重试的警告。 let completed_record = version_state_guard.record()?.clone(); let completed_release_id = completed_record.id.clone(); version_state_guard.commit(); let final_snapshot_path = snapshot_path_for(config, &published_version_path); let snapshot_written = if config.snapshot_path.is_some() { match write_snapshot(&final_snapshot_path, ¤t_update_snapshot) { Ok(()) => true, Err(error) => { progress(OfficialUpdateProgress::new( "publish", format!("资源已发布,但写入最终 snapshot 失败(下轮可重试):{error}"), )); false } } } else { false }; report.update_status = OfficialUpdateStatus::Downloaded; report.status_code = report.update_status.flow_status_code(); 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.release_reused_count = pull_report.release_reused_count(); report.cas_reused_count = pull_report.cas_reused_count(); report.reused_bytes = pull_report.reused_bytes(); report.reuse_warnings = pull_report.reuse_warnings.clone(); 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 = snapshot_written.then(|| final_snapshot_path.clone()); report.launcher_bootstrap_artifact_path = staging_launcher_bootstrap_artifact_path .map(|_| published_version_path.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE)); apply_localized_release_info(&mut report, config, Some(completed_release_id.as_str())); if let Err(error) = complete_version_state( &version_state_path, completed_record, &published_version_path, &final_snapshot_path, ) { progress(OfficialUpdateProgress::new( "publish", format!("资源已发布,但写入版本状态失败(下轮可重试):{error}"), )); } run_post_sync_resource_handoff( Some(&active_resource_root), &published_version_path, completed_release_id.as_str(), version_id_from_path(&active_resource_root), &mut report, &mut progress, &mut should_cancel, )?; run_post_sync_parse_cache( config, &published_version_path, &mut report, &mut progress, &mut should_cancel, )?; run_post_sync_textunit_queue_if_needed( &published_version_path, &mut report, &mut progress, &mut should_cancel, )?; run_post_sync_repository_import( config, &published_version_path, Some(completed_release_id.as_str()), &mut report, &mut progress, &mut should_cancel, )?; // 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。 match read_version_state(&version_state_path) { Ok(Some(state)) => match gc_orphan_staging_with_cas_root( &config.output_root, &state, &config.effective_import_cas_root(), ) { Ok(removed) if !removed.is_empty() => progress(OfficialUpdateProgress::new( "publish", format!("已清理 {} 个孤儿 staging 目录", removed.len()), )), Ok(_) => {} Err(error) => progress(OfficialUpdateProgress::new( "publish", format!("清理孤儿 staging 目录失败(忽略):{error}"), )), }, Ok(None) => {} Err(error) => progress(OfficialUpdateProgress::new( "publish", format!("读取版本状态用于 staging 清理失败(忽略):{error}"), )), } progress(OfficialUpdateProgress::new( "finish", format!( "同步完成:资源数={} 官方 hash 已校验={}", report.resource_count.unwrap_or(0), report.official_seed_hash_verified_count ), )); Ok(report) } } fn run_post_sync_resource_handoff( previous_resource_root: Option<&Path>, current_resource_root: &Path, official_release_id: &str, previous_release_id: Option, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result<()> { check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "changes", format!( "生成官方资源变更集:previous={} current={}", previous_resource_root .map(|path| path.display().to_string()) .unwrap_or_else(|| "none".to_string()), current_resource_root.display() ), )); let handoff_report = write_official_resource_change_handoff( previous_resource_root, current_resource_root, official_release_id, previous_release_id, ) .map_err(anyhow::Error::msg)?; apply_resource_change_handoff_report(report, handoff_report); if let Some(summary) = report.resource_change_summary.as_ref() { progress(OfficialUpdateProgress::new( "changes", format!( "资源变更集完成:新增={} 变更={} 删除={} 解析候选={} Crowdin候选={}", summary.added_count, summary.modified_count, summary.removed_count, summary.parse_candidate_count, summary.translation_candidate_count ), )); } Ok(()) } fn apply_resource_change_handoff_report( report: &mut OfficialUpdateReport, handoff_report: OfficialResourceChangeHandoffReport, ) { report.resource_change_set_path = Some(handoff_report.change_set_path); report.crowdin_handoff_path = Some(handoff_report.crowdin_handoff_path); report.resource_change_summary = Some(handoff_report.summary); } fn run_post_sync_parse_cache_if_needed( config: &OfficialUpdateConfig, resource_root: &Path, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result<()> { check_shutdown_requested(should_cancel)?; if let Some(cache) = read_parse_cache_at(resource_root).map_err(anyhow::Error::msg)? { let cache_path = resource_root.join(crate::OFFICIAL_PARSE_CACHE_FILE); progress(OfficialUpdateProgress::new( "parse", format!( "官方资源未变更,复用解析缓存 {}:条目={} TextUnit={}", cache_path.display(), cache.summary.cache_entry_count, cache.summary.text_unit_count ), )); report.parse_cache_path = Some(cache_path); report.parse_summary = Some(cache.summary); return Ok(()); } run_post_sync_parse_cache(config, resource_root, report, progress, should_cancel) } fn run_post_sync_parse_cache( config: &OfficialUpdateConfig, resource_root: &Path, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result<()> { check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "parse", format!("更新官方资源解析缓存 {}", resource_root.display()), )); let parse_config = OfficialParseConfig::new(resource_root, &config.unzip_command); match OfficialParseCacheService::new().run(&parse_config) { Ok(parse_report) => { progress(OfficialUpdateProgress::new( "parse", format!( "解析缓存完成:条目={} 已解析={} 复用={} 不支持={} 失败={} TextAsset={} TextUnit={} 诊断={}", parse_report.summary.cache_entry_count, parse_report.summary.parsed_bundle_count, parse_report.summary.skipped_unchanged_count, parse_report.summary.unsupported_count, parse_report.summary.failed_count, parse_report.summary.text_asset_count, parse_report.summary.text_unit_count, parse_report.summary.text_unit_error_count ), )); report.parse_cache_path = Some(parse_report.cache_path); report.parse_summary = Some(parse_report.summary); } Err(error) => { progress(OfficialUpdateProgress::new( "parse", format!("解析缓存更新失败(不影响已校验官方资源):{error}"), )); } } Ok(()) } fn run_post_sync_textunit_queue_if_needed( resource_root: &Path, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result<()> { check_shutdown_requested(should_cancel)?; if let Some(queue) = read_textunit_task_queue_at(resource_root).map_err(anyhow::Error::msg)? { let task_queue_path = resource_root.join(crate::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); let crowdin_queue_path = resource_root.join(crate::CROWDIN_TEXTUNIT_QUEUE_FILE); if is_textunit_task_queue_current(resource_root, &queue).map_err(anyhow::Error::msg)? && is_crowdin_textunit_queue_current(resource_root, &queue) .map_err(anyhow::Error::msg)? { sync_translation_task_repository(resource_root, &queue)?; progress(OfficialUpdateProgress::new( "textunit", format!( "复用增量 TextUnit 队列 {}:任务={} TextUnit={}", task_queue_path.display(), queue.summary.queued_task_count, queue.summary.text_unit_count ), )); report.textunit_task_queue_path = Some(task_queue_path); report.crowdin_textunit_queue_path = Some(crowdin_queue_path); report.textunit_task_summary = Some(queue.summary.clone()); write_translation_handoff_for_release(resource_root, &queue, report, progress)?; return Ok(()); } progress(OfficialUpdateProgress::new( "textunit", format!( "TextUnit 队列输入已变化或 Crowdin 队列缺失,重新生成 {}", resource_root.display() ), )); } run_post_sync_textunit_queue(resource_root, report, progress, should_cancel) } fn sync_translation_task_repository( resource_root: &Path, queue: &crate::official_textunit_queue::OfficialTextUnitTaskQueue, ) -> anyhow::Result<()> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; runtime .block_on(sync_translation_task_repository_at(resource_root, queue)) .map_err(|error| anyhow::anyhow!("{error}"))?; Ok(()) } fn run_post_sync_textunit_queue( resource_root: &Path, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result<()> { check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "textunit", format!( "生成增量 TextUnit / Crowdin 离线队列 {}", resource_root.display() ), )); match write_official_textunit_queues(resource_root) { Ok(queue_report) => { progress(OfficialUpdateProgress::new( "textunit", format!( "增量 TextUnit 队列完成:资源候选={} 任务={} TextUnit={} 跳过无解析={} 无文本={} 解析失败={} 不支持={}", queue_report.summary.resource_candidate_count, queue_report.summary.queued_task_count, queue_report.summary.text_unit_count, queue_report.summary.skipped_no_parse_entry_count, queue_report.summary.skipped_no_text_unit_count, queue_report.summary.skipped_parse_failed_count, queue_report.summary.skipped_unsupported_count ), )); apply_textunit_queue_report(report, queue_report); if let Some(queue) = read_textunit_task_queue_at(resource_root).map_err(anyhow::Error::msg)? { write_translation_handoff_for_release(resource_root, &queue, report, progress)?; } } Err(error) => { progress(OfficialUpdateProgress::new( "textunit", format!("增量 TextUnit 队列生成失败(不影响已校验官方资源):{error}"), )); } } Ok(()) } fn apply_textunit_queue_report( report: &mut OfficialUpdateReport, queue_report: OfficialTextUnitQueueReport, ) { report.textunit_task_queue_path = Some(queue_report.textunit_task_queue_path); report.crowdin_textunit_queue_path = Some(queue_report.crowdin_textunit_queue_path); report.textunit_task_summary = Some(queue_report.summary); } fn write_translation_handoff_for_release( resource_root: &Path, queue: &crate::official_textunit_queue::OfficialTextUnitTaskQueue, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), ) -> anyhow::Result<()> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; let tasks = runtime.block_on(async { let repository = crate::translation_tasks::SqliteTranslationTaskRepository::new( crate::translation_tasks::SqliteTranslationTaskRepository::repository_path( resource_root, ), ) .await .map_err(|error| anyhow::anyhow!("{error}"))?; repository .sync_queue(queue) .await .map_err(|error| anyhow::anyhow!("{error}"))?; repository .list(&crate::official_textunit_queue::OfficialTextUnitTaskQuery::default()) .await .map_err(|error| anyhow::anyhow!("{error}")) })?; let handoff = build_translation_handoff(queue, &tasks); write_translation_handoff_at(resource_root, &handoff).map_err(anyhow::Error::msg)?; let path = resource_root.join(crate::translation_tasks::TRANSLATION_HANDOFF_FILE); report.translation_handoff_path = Some(path.clone()); progress(OfficialUpdateProgress::new( "textunit", format!( "翻译 handoff 已发布:任务={} provider_run={} {}", handoff.units.len(), handoff.provider_runs.len(), path.display() ), )); Ok(()) } fn run_post_sync_repository_import( config: &OfficialUpdateConfig, resource_root: &Path, official_release_id: Option<&str>, report: &mut OfficialUpdateReport, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result<()> { check_shutdown_requested(should_cancel)?; if !config.import_repository { progress(OfficialUpdateProgress::new( "repository", "CAS + ResourceRepository 导入未启用", )); return Ok(()); } validate_repository_import_paths(config).map_err(anyhow::Error::msg)?; let cas_root = config.effective_import_cas_root(); let repository_path = config.effective_import_resource_repository_path(); report.repository_import_enabled = true; report.repository_import_cas_root = Some(cas_root.clone()); report.repository_import_path = Some(repository_path.clone()); progress(OfficialUpdateProgress::new( "repository", format!( "导入官方 release 到 CAS={} ResourceRepository={}", cas_root.display(), repository_path.display() ), )); let import_report = import_official_release_to_repository( resource_root, official_release_id, &cas_root, &repository_path, )?; progress(OfficialUpdateProgress::new( "repository", format!( "Repository 导入完成:manifest={} imported={} unchanged={} metadata_updated={} AssetBundle={} TextAsset={} Table={} Media={}", import_report.manifest_entry_count, import_report.imported_count, import_report.unchanged_count, import_report.metadata_updated_count, import_report.asset_bundle_count, import_report.text_asset_count, import_report.table_count, import_report.media_count ), )); report.repository_import_summary = Some(import_report); Ok(()) } fn import_official_release_to_repository( resource_root: &Path, official_release_id: Option<&str>, cas_root: &Path, repository_path: &Path, ) -> anyhow::Result { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; runtime.block_on(async { let cas = FileSystemCasRepository::new(cas_root); cas.init() .await .map_err(|error| anyhow::anyhow!("{error}"))?; let resources = SqliteResourceRepository::new(repository_path) .await .map_err(|error| anyhow::anyhow!("{error}"))?; let mut import_config = OfficialReleaseImportConfig::new(resource_root); if let Some(release_id) = official_release_id { import_config = import_config.with_official_release_id(release_id); } OfficialReleaseImportService::new(&cas, &resources) .import_release(&import_config) .await .map_err(|error| anyhow::anyhow!("{error}")) }) } 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 { check_shutdown_requested(should_cancel)?; let backend = YostarJpBackend; let discovery = backend .discovery_plan(server_info, 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> { match source { OfficialServerInfoSource::LocalPath(path) => Ok(fs::read(path)?), OfficialServerInfoSource::OfficialFile(file_name) => { let url = YostarJpBackend .server_info_url(file_name) .map_err(anyhow::Error::msg)?; fetcher.fetch_bytes(&url).map_err(anyhow::Error::new) } OfficialServerInfoSource::OfficialUrl(url) => { fetcher.fetch_bytes(url).map_err(anyhow::Error::new) } } } #[allow(clippy::too_many_arguments)] fn waiting_for_official_resources_report( config: &OfficialUpdateConfig, platforms: &[PatchPlatform], publish_layout: &OfficialPublishLayout, active_resource_root: &Path, snapshot_path: &Path, version_state_path: &Path, base_snapshot: &YostarJpSyncSnapshot, update_snapshot: &OfficialUpdateSnapshot, previous_snapshot_present: bool, decision: String, is_initial: bool, changed_endpoint_urls: Vec, extended_delta: ExtendedSnapshotDelta, bootstrap_cache_hit: Option, bootstrap_cache_path: Option, unavailable_endpoints: Vec, ) -> OfficialUpdateReport { let active_release_id = version_id_from_path(active_resource_root) .unwrap_or_else(|| fallback_version_id(update_snapshot)); let localized_info = localized_release_info_for(config, Some(active_release_id.as_str())); OfficialUpdateReport { update_status: OfficialUpdateStatus::WaitingForOfficialResources, status_code: ReleaseFlowStatusCode::OfficialWaitingForResources, connection_group: base_snapshot.connection_group_name.clone(), app_version: base_snapshot.app_version.clone(), bundle_version: base_snapshot.bundle_version.clone(), addressables_root: base_snapshot.addressables_root.clone(), platforms: platforms.to_vec(), output_root: config.output_root.clone(), localized_output_root: config.localized_output_root.clone(), localized_release_status: localized_info.status, localized_current_path: localized_info.current_path, localized_published_version_path: localized_info.published_version_path, active_resource_root: active_resource_root.to_path_buf(), current_path: publish_layout.current_path.clone(), version_state_path: version_state_path.to_path_buf(), published_version_path: None, staging_path: None, snapshot_path: snapshot_path.to_path_buf(), previous_snapshot_present, decision, force: config.force, audit_local: config.audit_local, repair: config.repair, should_download: false, waiting_for_official_resources: true, unavailable_endpoints, is_initial, changed_endpoint_urls, extended_delta, addressables_marker_checked_count: update_snapshot.addressables_marker_checked_count(), unverified_marker_count: update_snapshot.unverified_marker_count(), bootstrap_cache_hit, bootstrap_cache_path, local_manifest_verified_count: 0, local_manifest_repair_needed_count: 0, 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, release_reused_count: 0, cas_reused_count: 0, reused_bytes: 0, reuse_warnings: Vec::new(), final_bytes: 0, transferred_bytes: 0, official_seed_hash_verified_count: 0, verification_summary: OfficialVerificationSummary::new(0, 0, 0, 0), download_manifest: active_resource_root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), resource_change_set_path: None, crowdin_handoff_path: None, resource_change_summary: None, parse_cache_path: None, parse_summary: None, textunit_task_queue_path: None, crowdin_textunit_queue_path: None, textunit_task_summary: None, translation_handoff_path: None, snapshot_written: None, launcher_bootstrap_artifact_path: None, repository_import_enabled: config.import_repository, repository_import_cas_root: config .import_repository .then(|| config.effective_import_cas_root()), repository_import_path: config .import_repository .then(|| config.effective_import_resource_repository_path()), repository_import_summary: None, } } fn collect_endpoint_markers( fetcher: &OfficialResourcePullService, snapshot: &YostarJpSyncSnapshot, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result { let mut markers = Vec::new(); let mut unavailable_endpoints = 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 = match fetcher.fetch_bytes(&endpoint.url) { Ok(bytes) => bytes, Err(error) if is_official_resource_not_ready(&error) => { let unavailable = OfficialUnavailableEndpoint::from_fetch_error(endpoint, &error); progress(OfficialUpdateProgress::new( "marker", format!( "{} 标记{} 当前不可用({} HTTP={}),等待官方资源端开放:{}", endpoint_kind_label(endpoint.kind), platform_suffix(endpoint.platform), unavailable.error_kind, unavailable .http_status .map(|status| status.to_string()) .unwrap_or_else(|| "none".to_string()), endpoint.url ), )); unavailable_endpoints.push(unavailable); continue; } Err(error) => return Err(anyhow::Error::new(error)), }; 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(EndpointMarkerCollection { markers, unavailable_endpoints, }) } #[derive(Debug, Clone, Default)] struct EndpointMarkerCollection { markers: Vec, unavailable_endpoints: Vec, } #[derive(Debug, Clone)] struct OfficialResourceUnavailable { endpoint: OfficialUnavailableEndpoint, } impl OfficialResourceUnavailable { fn new(endpoint: &YostarJpResourceEndpoint, error: &DownloadError) -> Self { Self { endpoint: OfficialUnavailableEndpoint::from_fetch_error(endpoint, error), } } } impl std::fmt::Display for OfficialResourceUnavailable { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( formatter, "官方资源端尚未开放:kind={}{} url={} error_kind={} http={} error={}", endpoint_kind_label(self.endpoint.kind), platform_suffix(self.endpoint.platform), self.endpoint.url, self.endpoint.error_kind, self.endpoint .http_status .map(|status| status.to_string()) .unwrap_or_else(|| "none".to_string()), self.endpoint.error ) } } impl std::error::Error for OfficialResourceUnavailable {} fn is_official_resource_not_ready(error: &DownloadError) -> bool { matches!( error.code(), ErrorCode::HTTP_FORBIDDEN | ErrorCode::HTTP_NOT_FOUND | ErrorCode::HTTP_CLIENT_ERROR ) } fn http_status_from_error_message(message: &str) -> Option { if let Some(rest) = message.split("http_status=").nth(1) { return parse_leading_u16(rest); } if let Some(rest) = message.split("returned error: ").nth(1) { return parse_leading_u16(rest); } None } fn parse_leading_u16(value: &str) -> Option { let digits = value .chars() .take_while(|ch| ch.is_ascii_digit()) .collect::(); if digits.is_empty() { return None; } digits.parse().ok() } 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 { 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::>() .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) -> 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), OfficialResourcePullProgressKind::Verification => { let hash = event.official_hash.as_ref(); OfficialUpdateProgress::new( "verify", format!( "官方 hash 校验通过:算法={} 期望={} 实际={} 数据 URL={} hash URL={}", hash.map(|value| value.algorithm.as_str()).unwrap_or("unknown"), hash.map(|value| value.expected.as_str()).unwrap_or("unknown"), hash.map(|value| value.actual.as_str()).unwrap_or("unknown"), hash.map(|value| value.data_url.as_str()).unwrap_or(event.url.as_str()), hash.map(|value| value.hash_url.as_str()).unwrap_or("unknown") ), ) .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::ReleaseReused => "已复用历史 release", crate::OfficialResourcePullStatus::CasReused => "已复用 CAS", crate::OfficialResourcePullStatus::Resumed => "已续传", crate::OfficialResourcePullStatus::Downloaded => "已下载", } } #[derive(Debug, Clone)] struct LocalizedReleaseInfo { status: LocalizedReleaseStatus, current_path: PathBuf, published_version_path: Option, } fn apply_localized_release_info( report: &mut OfficialUpdateReport, config: &OfficialUpdateConfig, official_release_id: Option<&str>, ) { let info = localized_release_info_for(config, official_release_id); report.localized_release_status = info.status; report.localized_current_path = info.current_path; report.localized_published_version_path = info.published_version_path; } fn localized_release_info_for( config: &OfficialUpdateConfig, official_release_id: Option<&str>, ) -> LocalizedReleaseInfo { let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); let state = match read_localized_version_state(&config.localized_output_root) { Ok(Some(state)) => state, Ok(None) | Err(_) => return not_localized_release_info(current_path), }; if state.status != LocalizedReleaseStatus::Localized.as_str() { return not_localized_release_info(current_path); } if official_release_id.is_some_and(|id| state.official_release_id != id) { return not_localized_release_info(current_path); } let Some(release_id) = state.current_release_id.as_deref() else { return not_localized_release_info(current_path); }; let version_path = config .localized_output_root .join(LOCALIZED_VERSIONS_DIR) .join(release_id); if !version_path.is_dir() || !localized_current_points_to(¤t_path, &version_path) { return not_localized_release_info(current_path); } LocalizedReleaseInfo { status: LocalizedReleaseStatus::Localized, current_path, published_version_path: Some(version_path), } } fn not_localized_release_info(current_path: PathBuf) -> LocalizedReleaseInfo { LocalizedReleaseInfo { status: LocalizedReleaseStatus::NotLocalized, current_path, published_version_path: None, } } fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool { let Ok(target) = fs::read_link(current_path) else { return false; }; let resolved = if target.is_absolute() { target } else { current_path .parent() .map(|parent| parent.join(&target)) .unwrap_or(target) }; resolved == version_path } /// 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> { if !(MIN_DOWNLOAD_CONCURRENCY..=MAX_DOWNLOAD_CONCURRENCY).contains(&config.download_concurrency) { return Err(format!( "下载并发数必须在 {MIN_DOWNLOAD_CONCURRENCY}..={MAX_DOWNLOAD_CONCURRENCY} 范围内" )); } validate_output_root(&config.output_root)?; validate_output_root(&config.localized_output_root)?; validate_separate_output_roots(&config.output_root, &config.localized_output_root)?; ensure_safe_directory_path(&config.output_root, "资源输出目录")?; ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?; if config.import_repository { validate_repository_import_paths(config)?; } 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 validate_repository_import_paths(config: &OfficialUpdateConfig) -> Result<(), String> { let cas_root = config.effective_import_cas_root(); validate_output_root(&cas_root)?; ensure_safe_directory_path(&cas_root, "官方资源 CAS 导入目录")?; let official = lexical_absolute(&config.output_root)?; let localized = lexical_absolute(&config.localized_output_root)?; let cas = lexical_absolute(&cas_root)?; if cas == official { return Err(format!( "官方资源 CAS 导入目录不能直接等于官方资源发布根:{}", cas.display() )); } if cas == localized || cas.starts_with(&localized) { return Err(format!( "官方资源 CAS 导入目录不能位于汉化输出目录内:CAS={} 汉化={}", cas.display(), localized.display() )); } let repository_path = config.effective_import_resource_repository_path(); let repository_parent = repository_path .parent() .ok_or_else(|| format!("资源索引数据库缺少父目录:{}", repository_path.display()))?; ensure_safe_directory_path(repository_parent, "资源索引数据库目录")?; ensure_safe_file_target(repository_parent, &repository_path, "资源索引数据库")?; let repository = lexical_absolute(&repository_path)?; if repository.starts_with(&localized) { return Err(format!( "资源索引数据库不能位于汉化输出目录内:数据库={} 汉化={}", repository.display(), localized.display() )); } Ok(()) } fn validate_separate_output_roots( output_root: &Path, localized_output_root: &Path, ) -> Result<(), String> { let official = lexical_absolute(output_root)?; let localized = lexical_absolute(localized_output_root)?; if official == localized { return Err(format!( "官方资源目录和汉化输出目录不能相同:{}", official.display() )); } if localized.starts_with(&official) || official.starts_with(&localized) { return Err(format!( "官方资源目录和汉化输出目录不能互相嵌套:官方={} 汉化={}", official.display(), localized.display() )); } 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 { 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::(); 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> { let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)? else { return Ok(None); }; match serde_json::from_slice::(&data) { Ok(snapshot) => Ok(Some(snapshot)), Err(update_error) => { let legacy = serde_json::from_slice::(&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 a versioned official launcher bootstrap artifact from disk. pub fn read_launcher_bootstrap_artifact( path: &Path, ) -> anyhow::Result> { let Some(data) = read_file_no_symlink(path, "官方启动器 bootstrap 产物").map_err(anyhow::Error::msg)? else { return Ok(None); }; let artifact: OfficialLauncherBootstrapArtifact = serde_json::from_slice(&data)?; if artifact.artifact_version != OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION { return Err(anyhow::anyhow!( "不支持的官方启动器 bootstrap schema:{},当前版本={}", artifact.artifact_version, OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION )); } Ok(Some(artifact)) } /// Writes a versioned official launcher bootstrap artifact atomically. pub fn write_launcher_bootstrap_artifact( path: &Path, artifact: &OfficialLauncherBootstrapArtifact, ) -> anyhow::Result<()> { let data = serde_json::to_vec_pretty(artifact)?; write_file_atomic(path, &data, STATE_FILE_MODE, "官方启动器 bootstrap 产物") .map_err(anyhow::Error::msg)?; Ok(()) } /// Reads the persistent official version state. pub fn read_version_state(path: &Path) -> anyhow::Result> { 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> { 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 { 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_launcher_bootstrap_artifact_version() -> u32 { OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_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, version_path: Option, started_unix_seconds: Option, completed_unix_seconds: Option, } 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 recoverable_failed_staging( version_state_path: &Path, snapshot: &OfficialUpdateSnapshot, layout: &OfficialPublishLayout, ) -> Result, String> { let Some(state) = read_version_state(version_state_path).map_err(|error| error.to_string())? else { return Ok(None); }; for failed in state.failed_versions.iter().rev() { let record = &failed.version; if !version_record_matches_snapshot(record, snapshot) { continue; } let Some(staging_path) = recoverable_staging_path(record, layout) else { continue; }; let mut recovered = record.clone(); recovered.resource_root = staging_path.clone(); recovered.staging_path = Some(staging_path); recovered.version_path = None; recovered.completed_unix_seconds = None; return Ok(Some(recovered)); } Ok(None) } fn recoverable_staging_path( record: &OfficialVersionRecord, layout: &OfficialPublishLayout, ) -> Option { // 任何“无法安全复用”的情况都返回 None(走全量重下),而非 Err 中止整轮同步: // 失败记录残留时,若这里返回 Err,每轮都会撞到同一异常而永久失败、无法自愈。 // 全量重下是始终安全的 fail-closed 回退(复用文件仍逐一校验,不会发布损坏内容)。 let staging_path = record .staging_path .as_ref() .unwrap_or(&record.resource_root); if ensure_path_within_root(&layout.staging_dir, staging_path).is_err() { return None; } if version_id_from_path(staging_path).as_deref() != Some(record.id.as_str()) { return None; } // staging 被外部替换成文件/symlink,或路径组件含 symlink,或读元数据出错:均不复用。 let metadata = fs::symlink_metadata(staging_path).ok()?; if metadata.file_type().is_symlink() || !metadata.is_dir() { return None; } if ensure_safe_directory_path(staging_path, "可恢复 staging 目录").is_err() { return None; } // 目标版本已发布,或无法确认是否已发布:都不复用该 staging。 let version_path = layout.versions_dir.join(&record.id); if ensure_path_within_root(&layout.versions_dir, &version_path).is_err() { return None; } if path_exists_no_follow(&version_path).unwrap_or(true) { return None; } Some(staging_path.to_path_buf()) } 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 { 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 version_record_matches_snapshot( record: &OfficialVersionRecord, snapshot: &OfficialUpdateSnapshot, ) -> bool { record.app_version == snapshot.app_version && record.bundle_version == snapshot.bundle_version && record.addressables_root == snapshot.addressables_root } 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, } 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) { 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, 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, 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 } /// 清理 `/.staging` 下未被版本状态引用的孤儿目录。 /// /// 只保留 `in_progress_version` 与 `failed_versions` 引用的 `` 目录(后者供失败 /// 后复用),删除其余目录——即跨版本失败、或被 trim/去重挤出记录后残留的 staging, /// 避免长期 daemon 场景下多 GB 孤儿目录累积。返回被删除的目录路径。 pub fn gc_orphan_staging( output_root: &Path, state: &OfficialVersionState, ) -> anyhow::Result> { gc_orphan_staging_with_cas_root(output_root, state, &output_root.join(".cas")) } /// Cleans orphan staging directories and releases their recorded CAS refs. pub fn gc_orphan_staging_with_cas_root( output_root: &Path, state: &OfficialVersionState, cas_root: &Path, ) -> anyhow::Result> { gc_orphan_staging_dirs(&output_root.join(OFFICIAL_STAGING_DIR), state, cas_root) } fn gc_orphan_staging_dirs( staging_dir: &Path, state: &OfficialVersionState, cas_root: &Path, ) -> anyhow::Result> { let read_dir = match fs::read_dir(staging_dir) { Ok(read_dir) => read_dir, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => { return Err(anyhow::anyhow!( "读取官方资源 staging 根目录失败 {}:{error}", staging_dir.display() )) } }; let mut referenced: Vec<&str> = Vec::new(); if let Some(in_progress) = state.in_progress_version.as_ref() { referenced.push(in_progress.id.as_str()); } for failed in &state.failed_versions { referenced.push(failed.version.id.as_str()); } let mut removed = Vec::new(); for entry in read_dir { let entry = entry?; // file_type 不跟随 symlink:symlink(即使指向目录)is_dir() 为 false,会被跳过。 if !entry.file_type()?.is_dir() { continue; } let path = entry.path(); let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; if referenced.contains(&name) { continue; } release_cas_reuse_references(&path, cas_root).map_err(anyhow::Error::msg)?; fs::remove_dir_all(&path).map_err(|error| { anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display()) })?; removed.push(path); } Ok(removed) } 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 { 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(), manifest_files_blake3: Some(launcher_manifest_files_blake3(&manifest.files)), } } 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 launcher_manifest_files_blake3(files: &[YostarJpLauncherManifestFile]) -> String { let mut hasher = blake3::Hasher::new(); for file in files { hasher.update(file.path.as_bytes()); hasher.update(&[0]); hasher.update(file.size.as_bytes()); hasher.update(&[0]); hasher.update(file.hash.as_bytes()); hasher.update(&[0]); if let Some(vc) = file.vc.as_deref() { hasher.update(b"vc"); hasher.update(vc.as_bytes()); } hasher.update(&[0xff]); } hasher.finalize().to_hex().to_string() } fn launcher_manifest_file_snapshot( file: &YostarJpLauncherManifestFile, ) -> OfficialLauncherManifestFileSnapshot { OfficialLauncherManifestFileSnapshot { path: file.path.clone(), size: file.size.clone(), parsed_size: file.size.parse::().ok(), hash: file.hash.clone(), vc: file.vc.clone(), } } fn launcher_game_main_config_source_snapshot( source: &OfficialGameMainConfigSelectedSource, ) -> OfficialLauncherGameMainConfigSourceSnapshot { OfficialLauncherGameMainConfigSourceSnapshot { kind: match source.kind { OfficialGameMainConfigSourceKind::Archive => { OfficialLauncherGameMainConfigSourceKind::Archive } OfficialGameMainConfigSourceKind::ManifestFile => { OfficialLauncherGameMainConfigSourceKind::ManifestFile } }, url: source.url.clone(), relative_path: source.relative_path.clone(), manifest_path: source.manifest_path.clone(), declared_size: source.declared_size, official_hash: source.official_hash.clone(), vc: source.vc.clone(), } } fn launcher_bootstrap_data_from_parts( launcher_metadata: LauncherMetadataSnapshot, game_main_config: GameMainConfigSnapshot, cdn_config: &YostarJpLauncherCdnConfig, manifest_url: &str, manifest: &YostarJpLauncherRemoteManifest, selected_source: &OfficialGameMainConfigSelectedSource, ) -> OfficialLauncherBootstrapData { OfficialLauncherBootstrapData { launcher_metadata, game_main_config, cdn_config: OfficialLauncherCdnConfigSnapshot { primary_cdn: cdn_config.primary_cdn.clone(), back_up_cdn: cdn_config.back_up_cdn.clone(), }, remote_manifest: OfficialLauncherRemoteManifestSnapshot { url: manifest_url.to_string(), source: manifest.source.clone(), file_count: manifest.files.len(), files_blake3: launcher_manifest_files_blake3(&manifest.files), files: manifest .files .iter() .map(launcher_manifest_file_snapshot) .collect(), }, selected_game_main_config_source: launcher_game_main_config_source_snapshot( selected_source, ), } } fn launcher_bootstrap_context( snapshot: &OfficialUpdateSnapshot, ) -> OfficialLauncherBootstrapContext { OfficialLauncherBootstrapContext { connection_group_name: snapshot.connection_group_name.clone(), app_version: snapshot.app_version.clone(), bundle_version: snapshot.bundle_version.clone(), addressables_root: snapshot.addressables_root.clone(), } } fn launcher_bootstrap_artifact( snapshot: &OfficialUpdateSnapshot, bootstrap: &ResolvedBootstrap, status: OfficialLauncherBootstrapArtifactStatus, ) -> OfficialLauncherBootstrapArtifact { OfficialLauncherBootstrapArtifact { artifact_version: OFFICIAL_LAUNCHER_BOOTSTRAP_ARTIFACT_VERSION, status, generated_unix_seconds: unix_seconds_now(), context: launcher_bootstrap_context(snapshot), launcher_bootstrap: bootstrap.launcher_bootstrap.clone(), } } fn write_launcher_bootstrap_artifact_for_snapshot( config: &OfficialUpdateConfig, root: &Path, snapshot: &OfficialUpdateSnapshot, bootstrap: Option<&ResolvedBootstrap>, status: OfficialLauncherBootstrapArtifactStatus, file_name: &str, ) -> anyhow::Result> { let Some(bootstrap) = bootstrap else { return Ok(None); }; let path = root.join(file_name); ensure_safe_file_target(&config.output_root, &path, "官方启动器 bootstrap 产物") .map_err(anyhow::Error::msg)?; let artifact = launcher_bootstrap_artifact(snapshot, bootstrap, status); write_launcher_bootstrap_artifact(&path, &artifact)?; Ok(Some(path)) } /// 官方引导链路使用的外部命令与代理配置。 struct BootstrapTools<'a> { curl_command: &'a Path, curl_proxy: &'a CurlProxyConfig, unzip_command: &'a Path, } fn resolve_bootstrap( launcher_version: &str, tools: &BootstrapTools<'_>, cache_path: &Path, write_cache: bool, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result { check_shutdown_requested(should_cancel)?; let launcher = OfficialLauncherBootstrapService::with_curl_command( launcher_version, tools.curl_command.to_string_lossy().to_string(), ) .with_proxy_config(tools.curl_proxy.clone()); progress(OfficialUpdateProgress::new( "launcher", "正在拉取官方启动器游戏配置和远端 manifest", )); let (game_config, manifest_url, manifest) = launcher .fetch_latest_remote_manifest() .map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; let cdn_config = launcher.fetch_cdn_config().map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; let launcher_metadata = launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest); let manifest_source = launcher_metadata .manifest_source .as_deref() .ok_or_else(|| { anyhow::Error::new(DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, "官方启动器远端 manifest 缺少 source", )) })?; let selected_source = resolve_game_main_config_source(&game_config, &manifest, manifest_source, &cdn_config) .map_err(anyhow::Error::new)?; progress(OfficialUpdateProgress::new( "launcher", format!( "启动器元数据已解析:最新版本={} manifest 文件数={} CDN={}", launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count, cdn_config.primary_cdn ), )); 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", )); let launcher_bootstrap = launcher_bootstrap_data_from_parts( launcher_metadata.clone(), game_main_config.clone(), &cdn_config, &manifest_url.url, &manifest, &selected_source, ); return Ok(ResolvedBootstrap { launcher_metadata, game_main_config, launcher_bootstrap, cache_hit: true, }); } } progress(OfficialUpdateProgress::new( "game-main-config", "缓存未命中;拉取 resources.assets 并解析 GameMainConfig", )); check_shutdown_requested(should_cancel)?; let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands( launcher_version, tools.curl_command.to_path_buf(), tools.unzip_command.to_path_buf(), ) .with_proxy_config(tools.curl_proxy.clone()); let bootstrap = bootstrapper .fetch_bootstrap_from_parts( game_config.clone(), cdn_config.clone(), manifest_url.clone(), manifest.clone(), ) .map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; let game_main_config = game_main_config_snapshot(&bootstrap.game_main_config); let launcher_bootstrap = launcher_bootstrap_data_from_parts( launcher_metadata.clone(), game_main_config.clone(), &bootstrap.cdn_config, &bootstrap.manifest_url, &bootstrap.remote_manifest, &bootstrap.selected_source, ); 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, launcher_bootstrap, cache_hit: false, }) } fn fetch_seed_catalogs( fetcher: &OfficialResourcePullService, discovery: &YostarJpResourceDiscoveryPlan, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result { 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 = match fetcher.fetch_bytes(&endpoint.url) { Ok(bytes) => bytes, Err(error) if is_official_resource_not_ready(&error) => { return Err(anyhow::Error::new(OfficialResourceUnavailable::new( endpoint, &error, ))); } Err(error) => return Err(anyhow::Error::new(error)), }; catalogs.insert(endpoint, bytes)?; } check_shutdown_requested(should_cancel)?; Ok(catalogs) } fn build_inventory_from_seed_catalogs( catalogs: &SeedCatalogs, platforms: &[PatchPlatform], ) -> anyhow::Result { 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(PlatformCatalogInput { platform: *platform, bundle_packing_info, media_catalog, }); } Ok(YostarJpBackend.parse_inventory(table_catalog, &platform_catalogs)) } #[derive(Debug, Default)] struct SeedCatalogs { table_catalog: Option>, bundle_packing_infos: HashMap>, media_catalogs: HashMap>, } impl SeedCatalogs { fn insert( &mut self, endpoint: &YostarJpResourceEndpoint, bytes: Vec, ) -> 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 { 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 { 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 { 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 { contents.trim().parse::().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::*; #[test] fn missing_app_version_carries_input_error_code() { // 未启用 auto-discover 且未传 app-version:配置校验失败应携带 // 输入域错误码(100001),供任务层 downcast 归类。 let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().join("out"), dry_run: true, plan: true, ..OfficialUpdateConfig::default() }; let error = OfficialUpdateService::new().run(&config).unwrap_err(); let coded = error .downcast_ref::() .expect("配置校验错误应为类型化 DownloadError"); assert_eq!(coded.code(), ErrorCode::MISSING_APP_VERSION); } #[test] fn missing_connection_group_carries_input_error_code() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().join("out"), app_version: Some("1.70.0".to_string()), dry_run: true, plan: true, ..OfficialUpdateConfig::default() }; let error = OfficialUpdateService::new().run(&config).unwrap_err(); let coded = error .downcast_ref::() .expect("配置校验错误应为类型化 DownloadError"); assert_eq!(coded.code(), ErrorCode::MISSING_CONNECTION_GROUP); } #[test] fn missing_server_info_source_carries_input_error_code() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().join("out"), app_version: Some("1.70.0".to_string()), connection_group: Some("Prod-Audit".to_string()), dry_run: true, plan: true, ..OfficialUpdateConfig::default() }; let error = OfficialUpdateService::new().run(&config).unwrap_err(); let coded = error .downcast_ref::() .expect("配置校验错误应为类型化 DownloadError"); assert_eq!(coded.code(), ErrorCode::MISSING_SERVER_INFO_SOURCE); } 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, manifest_files_blake3: Some("manifest-digest".to_string()), }, 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()), }, launcher_bootstrap: OfficialLauncherBootstrapData { 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, manifest_files_blake3: Some("manifest-digest".to_string()), }, 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()), }, cdn_config: OfficialLauncherCdnConfigSnapshot { primary_cdn: "https://launcher-pkg-ba-jp.yo-star.com".to_string(), back_up_cdn: "https://launcher-pkg-ba-jp-bk.yo-star.com".to_string(), }, remote_manifest: OfficialLauncherRemoteManifestSnapshot { url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json".to_string(), source: Some("BAJP_1.70.0.zip".to_string()), file_count: 1, files_blake3: "manifest-digest".to_string(), files: vec![OfficialLauncherManifestFileSnapshot { path: "/BlueArchive_Data/resources.assets".to_string(), size: "123".to_string(), parsed_size: Some(123), hash: "official-hash".to_string(), vc: Some("vc".to_string()), }], }, selected_game_main_config_source: OfficialLauncherGameMainConfigSourceSnapshot { kind: OfficialLauncherGameMainConfigSourceKind::Archive, url: "https://launcher-pkg-ba-jp.yo-star.com/BAJP_1.70.0.zip".to_string(), relative_path: "BAJP_1.70.0.zip".to_string(), manifest_path: None, declared_size: None, official_hash: None, vc: None, }, }, 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(¤t, 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 bootstrap_cache_misses_when_launcher_manifest_digest_changes() { 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(), }; let mut changed_metadata = bootstrap.launcher_metadata.clone(); changed_metadata.manifest_files_blake3 = Some("different-manifest-digest".to_string()); assert!(cached_game_main_config_for_metadata(&cache, &changed_metadata).is_none()); } #[test] fn launcher_manifest_file_digest_changes_with_manifest_content() { let files = vec![YostarJpLauncherManifestFile { path: "/BlueArchive_Data/resources.assets".to_string(), size: "123".to_string(), hash: "hash-a".to_string(), vc: Some("vc".to_string()), }]; let mut changed = files.clone(); changed[0].hash = "hash-b".to_string(); assert_ne!( launcher_manifest_files_blake3(&files), launcher_manifest_files_blake3(&changed) ); } #[test] fn persists_launcher_bootstrap_artifact_json_round_trip() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("official-launcher-bootstrap.json"); let bootstrap = fixture_bootstrap(); let snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("1234")], Some(&bootstrap), ); let artifact = launcher_bootstrap_artifact( &snapshot, &bootstrap, OfficialLauncherBootstrapArtifactStatus::Published, ); write_launcher_bootstrap_artifact(&path, &artifact).unwrap(); let read = read_launcher_bootstrap_artifact(&path).unwrap().unwrap(); assert_eq!(read, artifact); assert_eq!( read.launcher_bootstrap .selected_game_main_config_source .relative_path, "BAJP_1.70.0.zip" ); assert_eq!( read.launcher_bootstrap.remote_manifest.files[0].parsed_size, Some(123) ); } #[test] fn writes_pending_launcher_bootstrap_artifact_under_output_root() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().to_path_buf(), ..OfficialUpdateConfig::default() }; let bootstrap = fixture_bootstrap(); let snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("1234")], Some(&bootstrap), ); let path = write_launcher_bootstrap_artifact_for_snapshot( &config, temp.path(), &snapshot, Some(&bootstrap), OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources, OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE, ) .unwrap() .unwrap(); let artifact = read_launcher_bootstrap_artifact(&path).unwrap().unwrap(); assert_eq!( path, temp.path().join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE) ); assert_eq!( artifact.status, OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources ); assert_eq!(artifact.context.app_version, "1.70.0"); } #[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")); } fn version_record_with_id(id: &str) -> OfficialVersionRecord { OfficialVersionRecord { id: id.to_string(), app_version: "1.0.0".to_string(), bundle_version: None, addressables_root: "root".to_string(), resource_root: PathBuf::from("/tmp/x"), snapshot_path: PathBuf::from("/tmp/x/snap.json"), staging_path: None, version_path: None, started_unix_seconds: None, completed_unix_seconds: None, } } #[test] fn gc_orphan_staging_removes_only_unreferenced_dirs() { let temp = tempfile::TempDir::new().unwrap(); let root = temp.path(); let staging = root.join(OFFICIAL_STAGING_DIR); for id in ["keep-inprogress", "keep-failed", "orphan-a", "orphan-b"] { fs::create_dir_all(staging.join(id)).unwrap(); fs::write(staging.join(id).join("marker"), b"x").unwrap(); } // staging 根下的普通文件不应被误删。 fs::write(staging.join("stray-file"), b"x").unwrap(); let state = OfficialVersionState { in_progress_version: Some(version_record_with_id("keep-inprogress")), failed_versions: vec![OfficialFailedVersionRecord { version: version_record_with_id("keep-failed"), error: "boom".to_string(), failed_unix_seconds: 1, }], ..OfficialVersionState::default() }; let removed = gc_orphan_staging(root, &state).unwrap(); let mut removed_names: Vec = removed .iter() .filter_map(|path| path.file_name().and_then(|name| name.to_str())) .map(str::to_string) .collect(); removed_names.sort(); assert_eq!(removed_names, vec!["orphan-a", "orphan-b"]); assert!(staging.join("keep-inprogress").exists()); assert!(staging.join("keep-failed").exists()); assert!(!staging.join("orphan-a").exists()); assert!(!staging.join("orphan-b").exists()); assert!(staging.join("stray-file").exists()); } #[test] fn gc_orphan_staging_missing_dir_is_noop() { let temp = tempfile::TempDir::new().unwrap(); let removed = gc_orphan_staging(temp.path(), &OfficialVersionState::default()).unwrap(); assert!(removed.is_empty()); } #[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 recoverable_failed_staging_preserves_matching_staging() { let temp = tempfile::TempDir::new().unwrap(); let layout = OfficialPublishLayout::new(temp.path()); let snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("current")], Some(&fixture_bootstrap()), ); let staging = temp.path().join(".staging/reuse-id"); fs::create_dir_all(&staging).unwrap(); fs::write(staging.join("kept.bin"), b"already-downloaded").unwrap(); let record = version_record_for_snapshot( &snapshot, VersionRecordInput { id: "reuse-id".to_string(), resource_root: staging.clone(), snapshot_path: staging.join(OFFICIAL_SYNC_SNAPSHOT_FILE), staging_path: Some(staging.clone()), version_path: None, started_unix_seconds: Some(10), completed_unix_seconds: None, }, ); let state = OfficialVersionState { failed_versions: vec![OfficialFailedVersionRecord { version: record, error: "download interrupted".to_string(), failed_unix_seconds: 11, }], updated_unix_seconds: 11, ..OfficialVersionState::default() }; let state_path = temp.path().join(OFFICIAL_VERSION_STATE_FILE); write_version_state(&state_path, &state).unwrap(); let recovered = recoverable_failed_staging(&state_path, &snapshot, &layout) .unwrap() .unwrap(); let plan = layout.plan(&snapshot, Some(&recovered)); assert!(plan.reuse_existing_staging); layout.prepare_staging(&plan).unwrap(); assert_eq!( fs::read(staging.join("kept.bin")).unwrap(), b"already-downloaded" ); assert_eq!(plan.staging_path, staging); assert_eq!(plan.version_path, temp.path().join("versions/reuse-id")); } #[test] fn recoverable_failed_staging_skips_when_staging_is_not_a_directory() { let temp = tempfile::TempDir::new().unwrap(); let layout = OfficialPublishLayout::new(temp.path()); let snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("current")], Some(&fixture_bootstrap()), ); // staging 被外部替换成普通文件而非目录。 let staging = temp.path().join(".staging/reuse-id"); fs::create_dir_all(staging.parent().unwrap()).unwrap(); fs::write(&staging, b"not a directory").unwrap(); let record = version_record_for_snapshot( &snapshot, VersionRecordInput { id: "reuse-id".to_string(), resource_root: staging.clone(), snapshot_path: staging.join(OFFICIAL_SYNC_SNAPSHOT_FILE), staging_path: Some(staging.clone()), version_path: None, started_unix_seconds: Some(10), completed_unix_seconds: None, }, ); let state = OfficialVersionState { failed_versions: vec![OfficialFailedVersionRecord { version: record, error: "download interrupted".to_string(), failed_unix_seconds: 11, }], updated_unix_seconds: 11, ..OfficialVersionState::default() }; let state_path = temp.path().join(OFFICIAL_VERSION_STATE_FILE); write_version_state(&state_path, &state).unwrap(); // 不复用该 staging,返回 None(走全量重下)而非 Err 中止整轮同步。 let recovered = recoverable_failed_staging(&state_path, &snapshot, &layout).unwrap(); assert!(recovered.is_none()); } #[test] fn recoverable_failed_staging_ignores_mismatched_snapshot() { let temp = tempfile::TempDir::new().unwrap(); let layout = OfficialPublishLayout::new(temp.path()); let previous_snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("previous")], Some(&fixture_bootstrap()), ); let mut current_base = fixture_base_snapshot(); current_base.addressables_root = "https://prod-clientpatch.bluearchiveyostar.com/other".to_string(); let current_snapshot = OfficialUpdateSnapshot::new( current_base, vec![fixture_marker("current")], Some(&fixture_bootstrap()), ); let staging = temp.path().join(".staging/old-id"); fs::create_dir_all(&staging).unwrap(); let record = version_record_for_snapshot( &previous_snapshot, VersionRecordInput { id: "old-id".to_string(), resource_root: staging, snapshot_path: temp .path() .join(".staging/old-id") .join(OFFICIAL_SYNC_SNAPSHOT_FILE), staging_path: Some(temp.path().join(".staging/old-id")), version_path: None, started_unix_seconds: Some(10), completed_unix_seconds: None, }, ); let state = OfficialVersionState { failed_versions: vec![OfficialFailedVersionRecord { version: record, error: "download interrupted".to_string(), failed_unix_seconds: 11, }], updated_unix_seconds: 11, ..OfficialVersionState::default() }; let state_path = temp.path().join(OFFICIAL_VERSION_STATE_FILE); write_version_state(&state_path, &state).unwrap(); assert!( recoverable_failed_staging(&state_path, ¤t_snapshot, &layout) .unwrap() .is_none() ); } #[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_same_official_and_localized_output_root() { let temp = tempfile::TempDir::new().unwrap(); let root = temp.path().join("resources"); let config = OfficialUpdateConfig { output_root: root.clone(), localized_output_root: root, dry_run: true, ..OfficialUpdateConfig::default() }; let error = OfficialUpdateService::new().run(&config).unwrap_err(); assert!(error.to_string().contains("不能相同")); } #[test] fn update_rejects_nested_official_and_localized_output_roots() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().join("resources"), localized_output_root: temp.path().join("resources/localized"), 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); } }