Files
BlueArchiveToolkit/infrastructure/src/official_update.rs
T
nyaKazuha bd1e1a06f2
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
feat(resource): 增加历史 release 与 CAS 复用
Closes #47
2026-09-02 01:26:53 +08:00

5313 lines
202 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Official JP resource update orchestration.
//!
//! This module is the production-facing form of the official update flow. It
//! keeps the one-shot semantics required by external schedulers and Go CLI
//! callers: discover current official state, compare remote markers, audit the
//! local manifest, repair/download when needed, then return a structured report.
use crate::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<OfficialServerInfoSource>,
/// Optional explicit connection group.
pub connection_group: Option<String>,
/// Optional explicit app version.
pub app_version: Option<String>,
/// Official launcher version used for signed metadata discovery.
pub launcher_version: String,
/// Whether to discover app version, server-info URL, and connection group
/// from official metadata.
pub auto_discover: bool,
/// Platforms to update. Defaults to verified official platforms.
pub platforms: Option<Vec<PatchPlatform>>,
/// Output root for resources and state files.
pub output_root: PathBuf,
/// Output root reserved for localized resources generated from official data.
pub localized_output_root: PathBuf,
/// Optional explicit sync snapshot path.
pub snapshot_path: Option<PathBuf>,
/// Curl command used by the current infrastructure downloader.
pub curl_command: PathBuf,
/// 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<PathBuf>,
/// Optional SQLite resource index path for official release imports.
/// Defaults under `output_root`.
pub import_resource_repository_path: Option<PathBuf>,
}
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<String>,
/// Selected official Addressables root URL.
pub addressables_root: String,
/// Seed endpoints observed for the selected platform set.
pub endpoints: Vec<YostarJpResourceEndpoint>,
/// Small remote marker contents fetched for change detection.
#[serde(default)]
pub endpoint_markers: Vec<OfficialEndpointMarkerSnapshot>,
/// Launcher metadata summary used for auto-discovery, when available.
#[serde(default)]
pub launcher_metadata: Option<LauncherMetadataSnapshot>,
/// Parsed GameMainConfig summary used for auto-discovery, when available.
#[serde(default)]
pub game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
}
/// Persistent version state for an official resource output root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVersionState {
/// State schema version.
#[serde(default = "default_version_state_version")]
pub state_version: u32,
/// Last successfully published version.
#[serde(default)]
pub current_completed_version: Option<OfficialVersionRecord>,
/// Version currently being downloaded into staging.
#[serde(default)]
pub in_progress_version: Option<OfficialVersionRecord>,
/// Previously usable version before the latest successful publish.
#[serde(default)]
pub previous_available_version: Option<OfficialVersionRecord>,
/// Recent versions that failed after entering staging.
#[serde(default)]
pub failed_versions: Vec<OfficialFailedVersionRecord>,
/// Last time this state file was updated.
pub updated_unix_seconds: u64,
}
impl Default for OfficialVersionState {
fn default() -> Self {
Self {
state_version: OFFICIAL_VERSION_STATE_VERSION,
current_completed_version: None,
in_progress_version: None,
previous_available_version: None,
failed_versions: Vec::new(),
updated_unix_seconds: unix_seconds_now(),
}
}
}
/// One version tracked by `official-version-state.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVersionRecord {
/// Stable publish ID under `versions/<id>`.
pub id: String,
/// Selected app version.
pub app_version: String,
/// Selected bundle version, when present.
pub bundle_version: Option<String>,
/// Selected Addressables root.
pub addressables_root: String,
/// Resource root for this version. For in-progress versions this is a
/// staging path; for completed versions it is a versioned path.
pub resource_root: PathBuf,
/// Snapshot path associated with the resource root.
pub snapshot_path: PathBuf,
/// Staging path, when the version is still being downloaded.
#[serde(default)]
pub staging_path: Option<PathBuf>,
/// Versioned path, when known.
#[serde(default)]
pub version_path: Option<PathBuf>,
/// Start time for a staged pull.
#[serde(default)]
pub started_unix_seconds: Option<u64>,
/// Completion time for a published version.
#[serde(default)]
pub completed_unix_seconds: Option<u64>,
}
/// Failed version entry tracked after a staged pull or publish error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialFailedVersionRecord {
/// Version that failed.
pub version: OfficialVersionRecord,
/// Human-readable failure reason.
pub error: String,
/// Failure time.
pub failed_unix_seconds: u64,
}
impl OfficialUpdateSnapshot {
/// Creates an update snapshot from a base sync snapshot and extended data.
pub fn new(
base: YostarJpSyncSnapshot,
endpoint_markers: Vec<OfficialEndpointMarkerSnapshot>,
bootstrap: Option<&ResolvedBootstrap>,
) -> Self {
Self {
snapshot_version: OFFICIAL_UPDATE_SNAPSHOT_VERSION,
connection_group_name: base.connection_group_name,
app_version: base.app_version,
bundle_version: base.bundle_version,
addressables_root: base.addressables_root,
endpoints: base.endpoints,
endpoint_markers,
launcher_metadata: bootstrap.map(|bootstrap| bootstrap.launcher_metadata.clone()),
game_main_config_bootstrap: bootstrap
.map(|bootstrap| bootstrap.game_main_config.clone()),
}
}
/// Returns the legacy base snapshot used by the existing sync planner.
pub fn base_snapshot(&self) -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: self.connection_group_name.clone(),
app_version: self.app_version.clone(),
bundle_version: self.bundle_version.clone(),
addressables_root: self.addressables_root.clone(),
endpoints: self.endpoints.clone(),
}
}
/// Returns the number of Addressables catalog markers checked.
pub fn addressables_marker_checked_count(&self) -> usize {
self.endpoint_markers
.iter()
.filter(|marker| marker.role == OfficialEndpointMarkerRole::AddressablesCatalogMarker)
.count()
}
/// Returns marker count not used as strong content validation.
pub fn unverified_marker_count(&self) -> usize {
self.addressables_marker_checked_count()
}
}
/// Captured value of one small remote marker endpoint.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialEndpointMarkerSnapshot {
/// Endpoint kind.
pub kind: YostarJpResourceEndpointKind,
/// Platform for platform-specific endpoints.
pub platform: Option<PatchPlatform>,
/// Official marker URL.
pub url: String,
/// How this marker should be interpreted.
pub role: OfficialEndpointMarkerRole,
/// Trimmed marker content.
pub value: String,
}
/// 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<PatchPlatform>,
/// 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<u16>,
/// 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<String>,
/// Game executable name.
pub game_start_exe_name: Option<String>,
/// Game executable arguments.
pub game_start_params: Vec<String>,
/// Remote launcher manifest URL.
pub manifest_url: String,
/// Remote launcher manifest source path.
pub manifest_source: Option<String>,
/// Number of files in the remote launcher manifest.
pub manifest_file_count: usize,
/// BLAKE3 digest over the remote launcher manifest file list.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_files_blake3: Option<String>,
}
/// Summary of the decrypted GameMainConfig used by auto-discovery.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameMainConfigSnapshot {
/// Official server-info URL from GameMainConfig.
pub server_info_data_url: Option<String>,
/// Default connection group from GameMainConfig.
pub default_connection_group: Option<String>,
}
/// 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<String>,
/// 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<u64>,
/// Official launcher manifest hash field.
pub hash: String,
/// Official per-file integrity hash.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vc: Option<String>,
}
/// 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<String>,
/// 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<OfficialLauncherManifestFileSnapshot>,
}
/// 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<String>,
/// Declared file size from the manifest, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_size: Option<u64>,
/// Official launcher manifest `hash` field, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub official_hash: Option<String>,
/// Official launcher manifest per-file `vc`, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vc: Option<String>,
}
/// 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<String>,
/// Selected Addressables root.
pub addressables_root: String,
/// Platform set used by the run.
pub platforms: Vec<PatchPlatform>,
/// Managed publish root containing `current`, `versions`, and staging.
pub output_root: PathBuf,
/// 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<PathBuf>,
/// Active resource root used for local audit before this run.
pub active_resource_root: PathBuf,
/// Atomic `current` pointer path.
pub current_path: PathBuf,
/// Persistent version-state path.
pub version_state_path: PathBuf,
/// Versioned release directory after a successful publish.
pub published_version_path: Option<PathBuf>,
/// Staging directory used during download before publish.
pub staging_path: Option<PathBuf>,
/// Sync snapshot path.
pub snapshot_path: PathBuf,
/// Whether a previous snapshot existed.
pub previous_snapshot_present: bool,
/// Legacy sync decision label.
pub decision: String,
/// Whether force was enabled.
pub force: bool,
/// Whether local audit was enabled.
pub audit_local: bool,
/// Whether local repair was enabled.
pub repair: bool,
/// Whether a download was required.
pub should_download: bool,
/// Whether 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<OfficialUnavailableEndpoint>,
/// Whether this was the first observed snapshot.
pub is_initial: bool,
/// Changed endpoint URLs from legacy diff.
pub changed_endpoint_urls: Vec<String>,
/// Extended marker/metadata delta.
pub extended_delta: ExtendedSnapshotDelta,
/// Count of Addressables marker files checked.
pub addressables_marker_checked_count: usize,
/// Count of marker files not used for strong content validation.
pub unverified_marker_count: usize,
/// Whether bootstrap cache was hit.
pub bootstrap_cache_hit: Option<bool>,
/// Bootstrap cache path.
pub bootstrap_cache_path: Option<PathBuf>,
/// Local manifest verified file count.
pub local_manifest_verified_count: usize,
/// Local manifest repair-needed file count.
pub local_manifest_repair_needed_count: usize,
/// Dry-run flag.
pub dry_run: bool,
/// Download URL count when plan output was requested.
pub download_url_count: Option<usize>,
/// Download URLs when plan output was requested.
pub download_urls: Vec<String>,
/// Pull item count after a real download.
pub resource_count: Option<usize>,
/// Number of resources downloaded from scratch.
pub downloaded_count: usize,
/// Number of resources resumed.
pub resumed_count: usize,
/// Number of resources skipped using the local manifest.
pub skipped_count: usize,
/// 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<OfficialResourceReuseWarning>,
/// 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<PathBuf>,
/// Crowdin translation handoff path reserved for added/modified resources.
pub crowdin_handoff_path: Option<PathBuf>,
/// Summary of resource changes between the previous complete release and
/// the current release.
pub resource_change_summary: Option<OfficialResourceChangeSummary>,
/// Parse-cache path written or refreshed after successful verification.
pub parse_cache_path: Option<PathBuf>,
/// Post-sync parse-cache summary.
pub parse_summary: Option<OfficialParseSummary>,
/// Incremental TextUnit task queue path derived from change set + parse cache.
pub textunit_task_queue_path: Option<PathBuf>,
/// Crowdin offline TextUnit queue path derived from queued TextUnit tasks.
pub crowdin_textunit_queue_path: Option<PathBuf>,
/// Incremental TextUnit task queue summary.
pub textunit_task_summary: Option<OfficialTextUnitTaskSummary>,
/// Versioned translation worker handoff view.
pub translation_handoff_path: Option<PathBuf>,
/// Snapshot path written after success.
pub snapshot_written: Option<PathBuf>,
/// Launcher bootstrap artifact written for this run.
pub launcher_bootstrap_artifact_path: Option<PathBuf>,
/// 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<PathBuf>,
/// SQLite ResourceRepository path used by the optional importer.
pub repository_import_path: Option<PathBuf>,
/// Optional import summary after a verified official release was published
/// or confirmed up-to-date.
pub repository_import_summary: Option<OfficialReleaseImportReport>,
}
/// 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<usize>,
/// Total URL count for the current download plan.
pub download_total: Option<usize>,
/// Current official URL for download progress.
pub download_url: Option<String>,
/// Stable pull status label when a URL finished.
pub download_status: Option<String>,
/// Final local byte count for the URL when known.
pub download_bytes: Option<u64>,
/// Bytes transferred in this run for the URL when known.
pub download_transferred_bytes: Option<u64>,
/// Stable failure kind when a URL failed.
pub download_failure_kind: Option<String>,
/// HTTP status parsed from curl stderr when a URL failed.
pub download_failure_http_status: Option<u16>,
/// Whether the final failure was retryable.
pub download_failure_retryable: Option<bool>,
/// Number of transfer attempts executed before failure.
pub download_failure_attempts: Option<usize>,
/// Whether the URL was recorded in the quarantine manifest.
pub download_quarantined: Option<bool>,
/// Local size/BLAKE3/ZIP verification for a completed URL.
pub download_verification: Option<OfficialResourceVerification>,
/// Official `.hash` sidecar verification when a seed catalog pair passes.
pub official_hash_verification: Option<OfficialResourceHashVerification>,
}
impl OfficialUpdateProgress {
/// Creates a progress event.
pub fn new(stage: &'static str, message: impl Into<String>) -> 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<PathBuf, String> {
if let Some(current_target) = self.current_target()? {
return Ok(current_target);
}
// Legacy fallback for directories produced before atomic publishing.
// The next non-dry-run update will seed staging from this tree and
// publish it under `versions/<id>` before switching `current`.
Ok(self.root.clone())
}
fn current_target(&self) -> Result<Option<PathBuf>, String> {
let metadata = match fs::symlink_metadata(&self.current_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"读取 current 指针失败 {}{error}",
self.current_path.display()
))
}
};
if !metadata.file_type().is_symlink() {
return Err(format!(
"current 必须是指向 versioned 目录的 symlink{}",
self.current_path.display()
));
}
let target = fs::read_link(&self.current_path).map_err(|error| {
format!(
"读取 current symlink 目标失败 {}{error}",
self.current_path.display()
)
})?;
let target = if target.is_absolute() {
target
} else {
self.root.join(target)
};
ensure_path_within_root(&self.root, &target)?;
ensure_safe_directory_path(&target, "current versioned 目录")?;
Ok(Some(target))
}
fn has_current_pointer(&self) -> Result<bool, String> {
Ok(self.current_target()?.is_some())
}
fn plan(
&self,
snapshot: &OfficialUpdateSnapshot,
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<bool, String> {
path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE))
}
fn release_reuse_roots(&self, active_root: &Path) -> Result<Vec<PathBuf>, 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::<Vec<_>>();
versions.sort();
versions.reverse();
roots.extend(versions);
roots.dedup();
Ok(roots)
}
fn publish(&self, plan: &OfficialPublishPlan) -> Result<PathBuf, String> {
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
fs::create_dir_all(&self.versions_dir).map_err(|error| {
format!(
"创建官方资源 versions 根目录失败 {}{error}",
self.versions_dir.display()
)
})?;
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
if path_exists_no_follow(&plan.version_path)? {
return Err(format!(
"versioned 目录已存在,拒绝覆盖:{}",
plan.version_path.display()
));
}
fs::rename(&plan.staging_path, &plan.version_path).map_err(|error| {
format!(
"发布官方资源版本目录失败 {} -> {}{error}",
plan.staging_path.display(),
plan.version_path.display()
)
})?;
switch_current_symlink(&self.root, &self.current_path, &plan.id)?;
Ok(plan.version_path.clone())
}
}
/// Official update runner.
#[derive(Debug, Clone, Default)]
pub struct OfficialUpdateService;
impl OfficialUpdateService {
/// Creates an official update runner.
pub fn new() -> Self {
Self
}
/// Executes one official update run.
pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> {
self.run_with_progress(config, |_| {})
}
/// Executes one official update run and emits human-readable progress
/// events. Structured reports are still returned separately.
pub fn run_with_progress(
&self,
config: &OfficialUpdateConfig,
mut progress: impl FnMut(OfficialUpdateProgress),
) -> anyhow::Result<OfficialUpdateReport> {
self.run_with_progress_and_cancellation(config, &mut progress, || false)
}
/// Executes one official update run, emits progress events, and aborts
/// between safe steps when `should_cancel` returns true.
pub fn run_with_progress_and_cancellation(
&self,
config: &OfficialUpdateConfig,
mut progress: impl FnMut(OfficialUpdateProgress),
mut should_cancel: impl FnMut() -> bool,
) -> anyhow::Result<OfficialUpdateReport> {
progress(OfficialUpdateProgress::new(
"start",
format!(
"开始官方资源同步:资源目录={} 试运行={} 自动发现={}",
config.output_root.display(),
config.dry_run,
config.auto_discover
),
));
check_shutdown_requested(&mut should_cancel)?;
validate_update_paths(config).map_err(anyhow::Error::msg)?;
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(&current_snapshot)
),
));
let marker_collection = collect_endpoint_markers(
&fetcher,
&current_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(&current_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,
&current_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,
&current_snapshot,
&current_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::<OfficialResourceUnavailable>() {
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,
&current_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,
&current_snapshot,
&current_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(&current_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(
&current_update_snapshot,
VersionRecordInput {
id: version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot)),
resource_root: active_resource_root.clone(),
snapshot_path: snapshot_path.clone(),
staging_path: None,
version_path: Some(active_resource_root.clone()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
);
complete_version_state(
&version_state_path,
current_record,
&active_resource_root,
&snapshot_path,
)?;
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,
&current_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,
&current_update_snapshot,
&publish_layout,
)
.map_err(anyhow::Error::msg)?;
if let Some(record) = recovered_staging.as_ref() {
progress(OfficialUpdateProgress::new(
"publish",
format!(
"发现可复用的未完成 stagingid={} path={}",
record.id,
record
.staging_path
.as_ref()
.unwrap_or(&record.resource_root)
.display()
),
));
}
let publish_plan =
publish_layout.plan(&current_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,
&current_update_snapshot,
&publish_plan.id,
&active_resource_root,
previous_snapshot.as_ref(),
&publish_plan.staging_path,
&staging_snapshot_path,
)?;
let mut version_state_guard =
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
let staging_fetcher = OfficialResourcePullService::with_curl_command(
&publish_plan.staging_path,
&config.curl_command,
)
.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, &current_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,
&current_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, &current_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<String>,
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<OfficialReleaseImportReport> {
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<OfficialResourcePullPlan> {
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<Vec<u8>> {
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<String>,
extended_delta: ExtendedSnapshotDelta,
bootstrap_cache_hit: Option<bool>,
bootstrap_cache_path: Option<PathBuf>,
unavailable_endpoints: Vec<OfficialUnavailableEndpoint>,
) -> 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<EndpointMarkerCollection> {
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<OfficialEndpointMarkerSnapshot>,
unavailable_endpoints: Vec<OfficialUnavailableEndpoint>,
}
#[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<u16> {
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<u16> {
let digits = value
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>();
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<OfficialEndpointMarkerRole> {
match kind {
YostarJpResourceEndpointKind::TableCatalogHash
| YostarJpResourceEndpointKind::BundlePackingInfoHash
| YostarJpResourceEndpointKind::MediaCatalogHash => {
Some(OfficialEndpointMarkerRole::OfficialSeedHash)
}
YostarJpResourceEndpointKind::AddressablesCatalogHash => {
Some(OfficialEndpointMarkerRole::AddressablesCatalogMarker)
}
_ => None,
}
}
fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
snapshot
.endpoints
.iter()
.filter(|endpoint| marker_role(endpoint.kind).is_some())
.count()
}
fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
match source {
OfficialServerInfoSource::LocalPath(path) => format!("本地路径 {}", path.display()),
OfficialServerInfoSource::OfficialFile(file_name) => {
format!("官方文件 {file_name}")
}
OfficialServerInfoSource::OfficialUrl(url) => format!("官方 URL {url}"),
}
}
fn platforms_label(platforms: &[PatchPlatform]) -> String {
platforms
.iter()
.map(|platform| platform.as_str())
.collect::<Vec<_>>()
.join(",")
}
fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
fn platform_suffix(platform: Option<PatchPlatform>) -> String {
platform
.map(|platform| format!(" ({})", platform.as_str()))
.unwrap_or_default()
}
fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpdateProgress {
match event.kind {
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!(
"下载进度:已完成 {}/{} ({:.1}%);单文件开始 URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
event.url
),
)
.with_download_progress(&event),
OfficialResourcePullProgressKind::Finished => {
let status = event.status.map(localized_pull_status).unwrap_or("未知");
OfficialUpdateProgress::new(
"download",
format!(
"下载进度:已完成 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
status,
event.bytes.unwrap_or(0),
event.transferred_bytes.unwrap_or(0),
event.url
),
)
.with_download_progress(&event)
}
OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new(
"download",
format!(
"下载中断:已完成 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
event.failure_kind.as_deref().unwrap_or("unknown"),
event
.failure_http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
event
.failure_retryable
.map(|retryable| retryable.to_string())
.unwrap_or_else(|| "unknown".to_string()),
event
.failure_attempts
.map(|attempts| attempts.to_string())
.unwrap_or_else(|| "0".to_string()),
event.quarantined,
event.url
),
)
.with_download_progress(&event),
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<PathBuf>,
}
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(&current_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<bool, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(format!("路径不能是 symlink{}", path.display()))
}
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!("检查路径失败 {}{error}", path.display())),
}
}
fn copy_tree_no_symlink(
source: &Path,
destination: &Path,
skip_publish_management_entries: bool,
) -> Result<(), String> {
ensure_safe_directory_path(source, "官方资源发布源目录")?;
ensure_safe_directory_path(destination, "官方资源 staging 目录")?;
fs::create_dir_all(destination).map_err(|error| {
format!(
"创建官方资源 staging 子目录失败 {}{error}",
destination.display()
)
})?;
for entry in fs::read_dir(source)
.map_err(|error| format!("读取官方资源发布源目录失败 {}{error}", source.display()))?
{
let entry = entry.map_err(|error| {
format!("读取官方资源发布源目录项失败 {}{error}", source.display())
})?;
let source_path = entry.path();
let file_name = entry.file_name();
if skip_publish_management_entries {
if let Some(name) = file_name.to_str() {
if matches!(
name,
OFFICIAL_STAGING_DIR
| OFFICIAL_VERSIONS_DIR
| OFFICIAL_CURRENT_LINK
| ".official-sync.lock"
) {
continue;
}
}
}
if destination.starts_with(&source_path) {
continue;
}
let destination_path = destination.join(file_name);
let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
format!(
"读取官方资源发布源元数据失败 {}{error}",
source_path.display()
)
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"官方资源发布源不能包含 symlink:{}",
source_path.display()
));
}
if metadata.is_dir() {
copy_tree_no_symlink(&source_path, &destination_path, false)?;
} else if metadata.is_file() {
if let Some(name) = source_path.file_name().and_then(|value| value.to_str()) {
if name.ends_with(".part") || name.ends_with(".tmp") {
continue;
}
}
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
format!(
"复制官方资源到 staging 失败 {} -> {}{copy_error}",
source_path.display(),
destination_path.display()
)
})?;
}
} else {
return Err(format!(
"官方资源发布源包含非普通文件:{}",
source_path.display()
));
}
}
Ok(())
}
#[cfg(unix)]
fn switch_current_symlink(
root: &Path,
current_path: &Path,
publish_id: &str,
) -> Result<(), String> {
use std::os::unix::fs::symlink;
let temporary = root.join(format!(".current.{}.tmp", std::process::id()));
if path_exists_no_follow(&temporary)? {
fs::remove_file(&temporary).map_err(|error| {
format!("清理临时 current 指针失败 {}{error}", temporary.display())
})?;
}
let relative_target = Path::new(OFFICIAL_VERSIONS_DIR).join(publish_id);
symlink(&relative_target, &temporary).map_err(|error| {
format!(
"创建临时 current 指针失败 {} -> {}{error}",
temporary.display(),
relative_target.display()
)
})?;
if let Ok(metadata) = fs::symlink_metadata(current_path) {
if !metadata.file_type().is_symlink() {
let _ = fs::remove_file(&temporary);
return Err(format!(
"current 已存在但不是 symlink{}",
current_path.display()
));
}
}
fs::rename(&temporary, current_path).map_err(|error| {
format!(
"切换 current 指针失败 {} -> {}{error}",
temporary.display(),
current_path.display()
)
})
}
#[cfg(not(unix))]
fn switch_current_symlink(
_root: &Path,
_current_path: &Path,
_publish_id: &str,
) -> Result<(), String> {
Err("原子 current symlink 发布目前只支持 Unix/Linux 平台".to_string())
}
fn publish_id(snapshot: &OfficialUpdateSnapshot) -> String {
let bundle = snapshot
.bundle_version
.as_deref()
.map(sanitize_publish_segment)
.unwrap_or_else(|| "no-bundle".to_string());
format!(
"{}-{}-{}-{}-{}",
sanitize_publish_segment(&snapshot.app_version),
bundle,
snapshot.addressables_marker_checked_count(),
unix_seconds_now(),
std::process::id()
)
}
fn sanitize_publish_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
{
'_'
} else {
ch
}
})
.collect::<String>();
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized
}
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Reads an official update snapshot from disk, accepting legacy v1 snapshots.
pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
match serde_json::from_slice::<OfficialUpdateSnapshot>(&data) {
Ok(snapshot) => Ok(Some(snapshot)),
Err(update_error) => {
let legacy =
serde_json::from_slice::<YostarJpSyncSnapshot>(&data).map_err(|legacy_error| {
anyhow::anyhow!(
"无法解析官方更新快照:v2 解析失败 ({update_error})legacy v1 解析也失败 ({legacy_error})"
)
})?;
Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None)))
}
}
}
/// Writes an official update snapshot to disk.
pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(snapshot)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方更新快照").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Reads a versioned official launcher bootstrap artifact from disk.
pub fn read_launcher_bootstrap_artifact(
path: &Path,
) -> anyhow::Result<Option<OfficialLauncherBootstrapArtifact>> {
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<Option<OfficialVersionState>> {
let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let state: OfficialVersionState = serde_json::from_slice(&data)?;
if state.state_version != OFFICIAL_VERSION_STATE_VERSION {
return Err(anyhow::anyhow!(
"不支持的官方版本状态 schema{},当前版本={}",
state.state_version,
OFFICIAL_VERSION_STATE_VERSION
));
}
Ok(Some(state))
}
/// Writes the persistent official version state atomically.
pub fn write_version_state(path: &Path, state: &OfficialVersionState) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(state)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方版本状态").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Reads an official bootstrap cache from disk.
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
Ok(Some(serde_json::from_slice(&data)?))
}
/// Writes an official bootstrap cache to disk.
pub fn write_bootstrap_cache(path: &Path, cache: &OfficialBootstrapCache) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(cache)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方启动缓存").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Returns cached GameMainConfig when cache version and metadata match.
pub fn cached_game_main_config_for_metadata(
cache: &OfficialBootstrapCache,
launcher_metadata: &LauncherMetadataSnapshot,
) -> Option<GameMainConfigSnapshot> {
if cache.cache_version == OFFICIAL_BOOTSTRAP_CACHE_VERSION
&& cache.launcher_metadata == *launcher_metadata
{
Some(cache.game_main_config.clone())
} else {
None
}
}
fn default_update_snapshot_version() -> u32 {
OFFICIAL_UPDATE_SNAPSHOT_VERSION
}
fn default_bootstrap_cache_version() -> u32 {
OFFICIAL_BOOTSTRAP_CACHE_VERSION
}
fn default_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<PathBuf>,
version_path: Option<PathBuf>,
started_unix_seconds: Option<u64>,
completed_unix_seconds: Option<u64>,
}
fn version_record_for_snapshot(
snapshot: &OfficialUpdateSnapshot,
input: VersionRecordInput,
) -> OfficialVersionRecord {
OfficialVersionRecord {
id: input.id,
app_version: snapshot.app_version.clone(),
bundle_version: snapshot.bundle_version.clone(),
addressables_root: snapshot.addressables_root.clone(),
resource_root: input.resource_root,
snapshot_path: input.snapshot_path,
staging_path: input.staging_path,
version_path: input.version_path,
started_unix_seconds: input.started_unix_seconds,
completed_unix_seconds: input.completed_unix_seconds,
}
}
fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> {
let Some(mut state) = read_version_state(path)? else {
return Ok(());
};
let Some(version) = state.in_progress_version.take() else {
return Ok(());
};
upsert_failed_version(
&mut state.failed_versions,
version,
"上一次官方资源同步在完成发布前中断",
unix_seconds_now(),
);
trim_failed_versions(&mut state.failed_versions);
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
fn recoverable_failed_staging(
version_state_path: &Path,
snapshot: &OfficialUpdateSnapshot,
layout: &OfficialPublishLayout,
) -> Result<Option<OfficialVersionRecord>, 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<PathBuf> {
// 任何“无法安全复用”的情况都返回 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<OfficialVersionRecord> {
let mut state = read_version_state(path)?.unwrap_or_default();
let fallback_current = previous_snapshot.map(|snapshot| {
version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: version_id_from_path(active_resource_root)
.unwrap_or_else(|| fallback_version_id(snapshot)),
resource_root: active_resource_root.to_path_buf(),
snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root),
staging_path: None,
version_path: Some(active_resource_root.to_path_buf()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
)
});
if state.current_completed_version.is_none() {
state.current_completed_version = fallback_current;
}
let now = unix_seconds_now();
let record = version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: publish_id.to_string(),
resource_root: staging_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: Some(staging_path.to_path_buf()),
version_path: None,
started_unix_seconds: Some(now),
completed_unix_seconds: None,
},
);
remove_matching_version_failures(&mut state.failed_versions, &record);
state.in_progress_version = Some(record.clone());
state.updated_unix_seconds = now;
write_version_state(path, &state)?;
Ok(record)
}
fn 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<OfficialVersionRecord>,
}
impl VersionStateGuard {
fn new(path: PathBuf, record: OfficialVersionRecord) -> Self {
Self {
path,
record: Some(record),
}
}
fn record(&self) -> anyhow::Result<&OfficialVersionRecord> {
self.record
.as_ref()
.ok_or_else(|| anyhow::anyhow!("官方版本状态事务已结束"))
}
fn fail(&mut self, error: &str) -> anyhow::Result<()> {
let Some(record) = self.record.take() else {
return Ok(());
};
fail_version_state(&self.path, record, error)
}
fn commit(&mut self) {
self.record = None;
}
}
impl Drop for VersionStateGuard {
fn drop(&mut self) {
let Some(record) = self.record.take() else {
return;
};
let _ = fail_version_state(
&self.path,
record,
"官方资源同步未完成;已将该版本记录为失败,保留当前可用版本",
);
}
}
fn trim_failed_versions(failed_versions: &mut Vec<OfficialFailedVersionRecord>) {
const MAX_FAILED_VERSIONS: usize = 8;
if failed_versions.len() > MAX_FAILED_VERSIONS {
let drop_count = failed_versions.len() - MAX_FAILED_VERSIONS;
failed_versions.drain(0..drop_count);
}
}
fn upsert_failed_version(
failed_versions: &mut Vec<OfficialFailedVersionRecord>,
record: OfficialVersionRecord,
error: &str,
failed_unix_seconds: u64,
) {
if let Some(index) = failed_versions
.iter()
.position(|failed| failed_version_matches(&failed.version, &record))
{
failed_versions.remove(index);
}
failed_versions.push(OfficialFailedVersionRecord {
version: record,
error: error.to_string(),
failed_unix_seconds,
});
}
fn remove_matching_version_failures(
failed_versions: &mut Vec<OfficialFailedVersionRecord>,
version: &OfficialVersionRecord,
) {
failed_versions.retain(|failed| !failed_version_matches(&failed.version, version));
}
fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
left.app_version == right.app_version
&& left.bundle_version == right.bundle_version
&& left.addressables_root == right.addressables_root
}
/// 清理 `<output_root>/.staging` 下未被版本状态引用的孤儿目录。
///
/// 只保留 `in_progress_version` 与 `failed_versions` 引用的 `<id>` 目录(后者供失败
/// 后复用),删除其余目录——即跨版本失败、或被 trim/去重挤出记录后残留的 staging,
/// 避免长期 daemon 场景下多 GB 孤儿目录累积。返回被删除的目录路径。
pub fn gc_orphan_staging(
output_root: &Path,
state: &OfficialVersionState,
) -> anyhow::Result<Vec<PathBuf>> {
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<Vec<PathBuf>> {
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<Vec<PathBuf>> {
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 不跟随 symlinksymlink(即使指向目录)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<String> {
path.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn fallback_version_id(snapshot: &OfficialUpdateSnapshot) -> String {
format!(
"{}-{}",
sanitize_publish_segment(&snapshot.app_version),
snapshot
.bundle_version
.as_deref()
.map(sanitize_publish_segment)
.unwrap_or_else(|| "no-bundle".to_string())
)
}
fn launcher_metadata_from_parts(
launcher_version: &str,
game_config: &YostarJpLauncherGameConfig,
manifest_url: &YostarJpLauncherManifestUrl,
manifest: &YostarJpLauncherRemoteManifest,
) -> LauncherMetadataSnapshot {
LauncherMetadataSnapshot {
launcher_version: launcher_version.to_string(),
game_latest_version: game_config.game_latest_version.clone(),
game_latest_file_path: game_config.game_latest_file_path.clone(),
game_lowest_version: game_config.game_lowest_version.clone(),
game_start_exe_name: game_config.game_start_exe_name.clone(),
game_start_params: game_config.game_start_params.clone(),
manifest_url: manifest_url.url.clone(),
manifest_source: manifest.source.clone().filter(|value| !value.is_empty()),
manifest_file_count: manifest.files.len(),
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::<u64>().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<Option<PathBuf>> {
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<ResolvedBootstrap> {
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<SeedCatalogs> {
let mut catalogs = SeedCatalogs::default();
for endpoint in &discovery.endpoints {
if !matches!(
endpoint.kind,
YostarJpResourceEndpointKind::TableCatalog
| YostarJpResourceEndpointKind::BundlePackingInfo
| YostarJpResourceEndpointKind::MediaCatalog
) {
continue;
}
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"catalog",
format!(
"拉取种子目录 {}{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = 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<YostarJpPlatformDownloadInventory> {
let table_catalog = catalogs
.table_catalog
.as_deref()
.ok_or_else(|| anyhow::anyhow!("官方发现结果缺少 TableCatalog.bytes"))?;
let mut platform_catalogs = Vec::new();
for platform in platforms {
let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| {
anyhow::anyhow!(
"官方发现结果缺少 {} BundlePackingInfo.bytes",
platform.as_str()
)
})?;
let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| {
anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str())
})?;
platform_catalogs.push(PlatformCatalogInput {
platform: *platform,
bundle_packing_info,
media_catalog,
});
}
Ok(YostarJpBackend.parse_inventory(table_catalog, &platform_catalogs))
}
#[derive(Debug, Default)]
struct SeedCatalogs {
table_catalog: Option<Vec<u8>>,
bundle_packing_infos: HashMap<PatchPlatform, Vec<u8>>,
media_catalogs: HashMap<PatchPlatform, Vec<u8>>,
}
impl SeedCatalogs {
fn insert(
&mut self,
endpoint: &YostarJpResourceEndpoint,
bytes: Vec<u8>,
) -> anyhow::Result<()> {
match endpoint.kind {
YostarJpResourceEndpointKind::TableCatalog => {
self.table_catalog = Some(bytes);
}
YostarJpResourceEndpointKind::BundlePackingInfo => {
let platform = required_platform(endpoint)?;
self.bundle_packing_infos.insert(platform, bytes);
}
YostarJpResourceEndpointKind::MediaCatalog => {
let platform = required_platform(endpoint)?;
self.media_catalogs.insert(platform, bytes);
}
_ => {}
}
Ok(())
}
}
fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<PatchPlatform> {
endpoint.platform.ok_or_else(|| {
anyhow::anyhow!(
"发现 endpoint {:?} 缺少平台:{}",
endpoint.kind,
endpoint.url
)
})
}
#[derive(Debug)]
struct OfficialUpdateLock {
path: PathBuf,
}
impl OfficialUpdateLock {
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
fs::create_dir_all(&config.output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
let path = config.lock_path();
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
.map_err(anyhow::Error::msg)?;
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(crate::path_security::PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
let pid = std::process::id().to_string();
file.write_all(pid.as_bytes())?;
return Ok(Self { path });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 && remove_stale_lock(&path)? {
continue;
}
return Err(anyhow::anyhow!(
"官方资源目录已被锁定 (locked){}{}",
path.display(),
describe_lock_owner(&path)
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"获取官方资源目录状态锁失败 {}{error}",
path.display()
));
}
}
}
Err(anyhow::anyhow!(
"获取官方资源目录状态锁失败 {}",
path.display()
))
}
}
impl Drop for OfficialUpdateLock {
fn drop(&mut self) {
let expected = std::process::id().to_string();
if fs::symlink_metadata(&self.path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if fs::read_to_string(&self.path)
.map(|contents| contents.trim() == expected)
.unwrap_or(false)
{
let _ = fs::remove_file(&self.path);
}
}
}
fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() {
fs::remove_file(path)?;
return Ok(true);
}
let contents = fs::read_to_string(path).unwrap_or_default();
let Some(pid) = parse_lock_pid(&contents) else {
fs::remove_file(path)?;
return Ok(true);
};
if process_exists(pid) {
return Ok(false);
}
fs::remove_file(path)?;
Ok(true)
}
fn describe_lock_owner(path: &Path) -> String {
if fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return "锁文件是 symlink,可执行 clean-stable 清理".to_string();
}
let contents = fs::read_to_string(path).unwrap_or_default();
match parse_lock_pid(&contents) {
Some(pid) if process_exists(pid) => format!("owner_pid={pid} 仍在运行"),
Some(pid) => format!("owner_pid={pid} 已失效,可重新执行或 clean-stable 清理"),
None => "锁文件内容不是有效 PID,可执行 clean-stable 清理".to_string(),
}
}
fn parse_lock_pid(contents: &str) -> Option<u32> {
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
#[cfg(not(unix))]
fn process_exists(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[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::<DownloadError>()
.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::<DownloadError>()
.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::<DownloadError>()
.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(&current, Some(&previous));
assert!(delta.endpoint_markers_changed);
assert!(delta.has_changes());
assert!(!delta.launcher_metadata_changed);
assert!(!delta.game_main_config_changed);
}
#[test]
fn persists_bootstrap_cache_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-bootstrap-cache.json");
let bootstrap = fixture_bootstrap();
let cache = OfficialBootstrapCache {
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
launcher_metadata: bootstrap.launcher_metadata,
game_main_config: bootstrap.game_main_config,
};
write_bootstrap_cache(&path, &cache).unwrap();
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
}
#[test]
fn 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,
&current_snapshot,
"current",
&active,
Some(&previous_snapshot),
&staging,
&staging_snapshot,
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"previous"
);
complete_version_state(
&path,
in_progress.clone(),
&published,
&published.join("official-sync-snapshot.json"),
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"current"
);
assert!(state.in_progress_version.is_none());
assert_eq!(
state.previous_available_version.as_ref().unwrap().id,
"previous"
);
let failed = prepare_in_progress_version_state(
&path,
&current_snapshot,
"broken",
&published,
state
.current_completed_version
.as_ref()
.map(|_| &current_snapshot),
&temp.path().join(".staging/broken"),
&temp
.path()
.join(".staging/broken/official-sync-snapshot.json"),
)
.unwrap();
fail_version_state(&path, failed, "download 403").unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert!(state.in_progress_version.is_none());
assert_eq!(state.failed_versions.len(), 1);
assert_eq!(state.failed_versions[0].version.id, "broken");
assert!(state.failed_versions[0].error.contains("403"));
}
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<String> = 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, &current_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);
}
}