mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:14:55 +08:00
5423 lines
207 KiB
Rust
5423 lines
207 KiB
Rust
//! 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_download::OFFICIAL_CAS_REUSE_REFERENCES_FILE;
|
||
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,
|
||
/// Zip command used when publishing localized bundles nested in ZIP archives.
|
||
pub zip_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"),
|
||
zip_command: PathBuf::from("zip"),
|
||
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)?;
|
||
if active_root != self.root {
|
||
let cas_references = staging_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
|
||
if path_exists_no_follow(&cas_references)? {
|
||
ensure_safe_file_target(staging_root, &cas_references, "staging CAS 引用清单")?;
|
||
fs::remove_file(&cas_references).map_err(|error| {
|
||
format!(
|
||
"清理 active release CAS ownership 清单失败 {}:{error}",
|
||
cas_references.display()
|
||
)
|
||
})?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
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(¤t_snapshot)
|
||
),
|
||
));
|
||
let marker_collection = collect_endpoint_markers(
|
||
&fetcher,
|
||
¤t_snapshot,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
let endpoint_markers = marker_collection.markers;
|
||
let current_update_snapshot = OfficialUpdateSnapshot::new(
|
||
current_snapshot.clone(),
|
||
endpoint_markers,
|
||
bootstrap.as_ref(),
|
||
);
|
||
check_shutdown_requested(&mut should_cancel)?;
|
||
progress(OfficialUpdateProgress::new(
|
||
"snapshot",
|
||
format!("读取上次快照 {}", snapshot_path.display()),
|
||
));
|
||
let previous_snapshot = read_snapshot(&snapshot_path)?;
|
||
let previous_base_snapshot = previous_snapshot
|
||
.as_ref()
|
||
.map(OfficialUpdateSnapshot::base_snapshot);
|
||
let sync_plan =
|
||
build_official_sync_plan(current_snapshot.clone(), previous_base_snapshot.as_ref());
|
||
let extended_delta =
|
||
diff_extended_snapshot(¤t_update_snapshot, previous_snapshot.as_ref());
|
||
let changed_urls = changed_endpoint_urls(&sync_plan.delta);
|
||
if !marker_collection.unavailable_endpoints.is_empty() {
|
||
progress(OfficialUpdateProgress::new(
|
||
"upstream",
|
||
format!(
|
||
"官方启动器/server-info 已更新,但 {} 个远端标记尚未开放;保留当前资源并等待下次检查",
|
||
marker_collection.unavailable_endpoints.len()
|
||
),
|
||
));
|
||
let launcher_bootstrap_artifact_path = if !config.dry_run && bootstrap.is_some() {
|
||
progress(OfficialUpdateProgress::new(
|
||
"launcher-bootstrap",
|
||
format!(
|
||
"写入待开放官方启动器 bootstrap {}",
|
||
config
|
||
.output_root
|
||
.join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE)
|
||
.display()
|
||
),
|
||
));
|
||
write_launcher_bootstrap_artifact_for_snapshot(
|
||
config,
|
||
&config.output_root,
|
||
¤t_update_snapshot,
|
||
bootstrap.as_ref(),
|
||
OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources,
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
|
||
)?
|
||
} else {
|
||
None
|
||
};
|
||
let mut report = waiting_for_official_resources_report(
|
||
config,
|
||
platforms,
|
||
&publish_layout,
|
||
&active_resource_root,
|
||
&snapshot_path,
|
||
&version_state_path,
|
||
¤t_snapshot,
|
||
¤t_update_snapshot,
|
||
previous_snapshot.is_some(),
|
||
format!("{:?}", sync_plan.decision),
|
||
sync_plan.delta.is_initial,
|
||
changed_urls,
|
||
extended_delta,
|
||
bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit),
|
||
Some(bootstrap_cache_path.clone()),
|
||
marker_collection.unavailable_endpoints,
|
||
);
|
||
report.launcher_bootstrap_artifact_path = launcher_bootstrap_artifact_path;
|
||
return Ok(report);
|
||
}
|
||
let remote_should_download =
|
||
config.force || sync_plan.should_download() || extended_delta.has_changes();
|
||
progress(OfficialUpdateProgress::new(
|
||
"decision",
|
||
format!(
|
||
"远端决策={:?} 强制刷新={} 远端需要下载={}",
|
||
sync_plan.decision, config.force, remote_should_download
|
||
),
|
||
));
|
||
progress(OfficialUpdateProgress::new(
|
||
"plan",
|
||
"根据最新种子目录构建官方拉取计划",
|
||
));
|
||
let pull_plan = match build_pull_plan(
|
||
&server_info,
|
||
&connection_group,
|
||
&app_version,
|
||
platforms,
|
||
&fetcher,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
) {
|
||
Ok(plan) => plan,
|
||
Err(error) => {
|
||
if let Some(unavailable) = error.downcast_ref::<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,
|
||
¤t_update_snapshot,
|
||
bootstrap.as_ref(),
|
||
OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources,
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
|
||
)?
|
||
} else {
|
||
None
|
||
};
|
||
let mut report = waiting_for_official_resources_report(
|
||
config,
|
||
platforms,
|
||
&publish_layout,
|
||
&active_resource_root,
|
||
&snapshot_path,
|
||
&version_state_path,
|
||
¤t_snapshot,
|
||
¤t_update_snapshot,
|
||
previous_snapshot.is_some(),
|
||
format!("{:?}", sync_plan.decision),
|
||
sync_plan.delta.is_initial,
|
||
changed_urls,
|
||
extended_delta,
|
||
bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit),
|
||
Some(bootstrap_cache_path.clone()),
|
||
vec![unavailable.endpoint.clone()],
|
||
);
|
||
report.launcher_bootstrap_artifact_path = launcher_bootstrap_artifact_path;
|
||
return Ok(report);
|
||
}
|
||
return Err(error);
|
||
}
|
||
};
|
||
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
|
||
progress(OfficialUpdateProgress::new(
|
||
"plan",
|
||
format!("拉取计划包含 {url_count} 个官方 URL"),
|
||
));
|
||
let local_state = fetcher
|
||
.local_resource_state(&pull_plan)
|
||
.map_err(anyhow::Error::msg)?;
|
||
let has_local_resources = local_state.has_any_resources();
|
||
progress(OfficialUpdateProgress::new(
|
||
"local-state",
|
||
format!(
|
||
"manifest 条目={} 已存在文件={} 是否已有本地资源={}",
|
||
local_state.manifest_entry_count,
|
||
local_state.existing_file_count,
|
||
has_local_resources
|
||
),
|
||
));
|
||
let should_audit_local = config.audit_local && has_local_resources;
|
||
let local_audit = if should_audit_local {
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
format!(
|
||
"审计本地下载 manifest {}",
|
||
fetcher.download_manifest_path().display()
|
||
),
|
||
));
|
||
Some(
|
||
fetcher
|
||
.audit_local_manifest(&pull_plan)
|
||
.map_err(anyhow::Error::msg)?,
|
||
)
|
||
} else if config.audit_local {
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
"未发现本地资源;首次全量拉取前跳过本地审计",
|
||
));
|
||
None
|
||
} else {
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
"本地 manifest 审计已关闭",
|
||
));
|
||
None
|
||
};
|
||
let local_manifest_verified_count = local_audit
|
||
.as_ref()
|
||
.map(|audit| audit.verified_count())
|
||
.unwrap_or(0);
|
||
let local_manifest_repair_needed_count = local_audit
|
||
.as_ref()
|
||
.map(|audit| audit.repair_needed_count())
|
||
.unwrap_or(0);
|
||
let local_manifest_blake3_verified_count = local_audit
|
||
.as_ref()
|
||
.map(|audit| audit.manifest_blake3_verified_count())
|
||
.unwrap_or(0);
|
||
let local_zip_structure_verified_count = local_audit
|
||
.as_ref()
|
||
.map(|audit| audit.zip_structure_verified_count())
|
||
.unwrap_or(0);
|
||
let repair_needed = config.repair
|
||
&& local_audit
|
||
.as_ref()
|
||
.map(|audit| !audit.is_clean())
|
||
.unwrap_or(false);
|
||
let initial_pull_needed = !has_local_resources;
|
||
let publish_required = !has_current_pointer;
|
||
let should_download =
|
||
remote_should_download || repair_needed || initial_pull_needed || publish_required;
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
format!(
|
||
"本地 manifest 已校验={} 需要修复={}",
|
||
local_manifest_verified_count, local_manifest_repair_needed_count
|
||
),
|
||
));
|
||
progress(OfficialUpdateProgress::new(
|
||
"decision",
|
||
format!(
|
||
"最终决策 需要下载={} 需要修复={} 首次拉取={}",
|
||
should_download, repair_needed, initial_pull_needed
|
||
),
|
||
));
|
||
if publish_required {
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
"尚未存在 current 原子发布指针;本轮会发布 versioned 目录并切换 current",
|
||
));
|
||
}
|
||
check_shutdown_requested(&mut should_cancel)?;
|
||
let active_release_id = version_id_from_path(&active_resource_root)
|
||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
||
let mut report = OfficialUpdateReport {
|
||
update_status: if should_download {
|
||
OfficialUpdateStatus::WouldDownload
|
||
} else {
|
||
OfficialUpdateStatus::UpToDate
|
||
},
|
||
status_code: if should_download {
|
||
ReleaseFlowStatusCode::OfficialUpdateAvailable
|
||
} else {
|
||
ReleaseFlowStatusCode::OfficialUpToDate
|
||
},
|
||
connection_group: current_snapshot.connection_group_name.clone(),
|
||
app_version: current_snapshot.app_version.clone(),
|
||
bundle_version: current_snapshot.bundle_version.clone(),
|
||
addressables_root: current_snapshot.addressables_root.clone(),
|
||
platforms: platforms.to_vec(),
|
||
output_root: config.output_root.clone(),
|
||
localized_output_root: config.localized_output_root.clone(),
|
||
localized_release_status: localized_info.status,
|
||
localized_current_path: localized_info.current_path,
|
||
localized_published_version_path: localized_info.published_version_path,
|
||
active_resource_root: active_resource_root.clone(),
|
||
current_path: publish_layout.current_path.clone(),
|
||
version_state_path: version_state_path.clone(),
|
||
published_version_path: None,
|
||
staging_path: None,
|
||
snapshot_path: snapshot_path.clone(),
|
||
previous_snapshot_present: previous_snapshot.is_some(),
|
||
decision: format!("{:?}", sync_plan.decision),
|
||
force: config.force,
|
||
audit_local: config.audit_local,
|
||
repair: config.repair,
|
||
should_download,
|
||
waiting_for_official_resources: false,
|
||
unavailable_endpoints: Vec::new(),
|
||
is_initial: sync_plan.delta.is_initial,
|
||
changed_endpoint_urls: changed_urls,
|
||
extended_delta,
|
||
addressables_marker_checked_count: current_update_snapshot
|
||
.addressables_marker_checked_count(),
|
||
unverified_marker_count: current_update_snapshot.unverified_marker_count(),
|
||
bootstrap_cache_hit: bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit),
|
||
bootstrap_cache_path: bootstrap.as_ref().map(|_| bootstrap_cache_path.clone()),
|
||
local_manifest_verified_count,
|
||
local_manifest_repair_needed_count,
|
||
dry_run: config.dry_run,
|
||
download_url_count: None,
|
||
download_urls: Vec::new(),
|
||
resource_count: None,
|
||
downloaded_count: 0,
|
||
resumed_count: 0,
|
||
skipped_count: 0,
|
||
release_reused_count: 0,
|
||
cas_reused_count: 0,
|
||
reused_bytes: 0,
|
||
reuse_warnings: Vec::new(),
|
||
final_bytes: 0,
|
||
transferred_bytes: 0,
|
||
official_seed_hash_verified_count: 0,
|
||
verification_summary: OfficialVerificationSummary::new(
|
||
local_manifest_blake3_verified_count,
|
||
local_manifest_repair_needed_count,
|
||
0,
|
||
local_zip_structure_verified_count,
|
||
),
|
||
download_manifest: fetcher.download_manifest_path(),
|
||
resource_change_set_path: None,
|
||
crowdin_handoff_path: None,
|
||
resource_change_summary: None,
|
||
parse_cache_path: None,
|
||
parse_summary: None,
|
||
textunit_task_queue_path: None,
|
||
crowdin_textunit_queue_path: None,
|
||
textunit_task_summary: None,
|
||
translation_handoff_path: None,
|
||
snapshot_written: None,
|
||
launcher_bootstrap_artifact_path: None,
|
||
repository_import_enabled: config.import_repository,
|
||
repository_import_cas_root: config
|
||
.import_repository
|
||
.then(|| config.effective_import_cas_root()),
|
||
repository_import_path: config
|
||
.import_repository
|
||
.then(|| config.effective_import_resource_repository_path()),
|
||
repository_import_summary: None,
|
||
};
|
||
|
||
if !should_download {
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
verification_progress_message(&report.verification_summary),
|
||
));
|
||
if !config.dry_run {
|
||
let current_record = version_record_for_snapshot(
|
||
¤t_update_snapshot,
|
||
VersionRecordInput {
|
||
id: version_id_from_path(&active_resource_root)
|
||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot)),
|
||
resource_root: active_resource_root.clone(),
|
||
snapshot_path: snapshot_path.clone(),
|
||
staging_path: None,
|
||
version_path: Some(active_resource_root.clone()),
|
||
started_unix_seconds: None,
|
||
completed_unix_seconds: Some(unix_seconds_now()),
|
||
},
|
||
);
|
||
complete_version_state(
|
||
&version_state_path,
|
||
current_record,
|
||
&active_resource_root,
|
||
&snapshot_path,
|
||
)?;
|
||
let active_launcher_bootstrap_path =
|
||
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
||
if bootstrap.is_some()
|
||
&& path_exists_no_follow(&active_launcher_bootstrap_path)
|
||
.map_err(anyhow::Error::msg)?
|
||
{
|
||
report.launcher_bootstrap_artifact_path = Some(active_launcher_bootstrap_path);
|
||
} else if bootstrap.is_some() {
|
||
progress(OfficialUpdateProgress::new(
|
||
"launcher-bootstrap",
|
||
format!(
|
||
"写入当前官方启动器 bootstrap {}",
|
||
active_launcher_bootstrap_path.display()
|
||
),
|
||
));
|
||
report.launcher_bootstrap_artifact_path =
|
||
write_launcher_bootstrap_artifact_for_snapshot(
|
||
config,
|
||
&active_resource_root,
|
||
¤t_update_snapshot,
|
||
bootstrap.as_ref(),
|
||
OfficialLauncherBootstrapArtifactStatus::Published,
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_FILE,
|
||
)?;
|
||
}
|
||
run_post_sync_parse_cache_if_needed(
|
||
config,
|
||
&active_resource_root,
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
run_post_sync_textunit_queue_if_needed(
|
||
&active_resource_root,
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
run_post_sync_repository_import(
|
||
config,
|
||
&active_resource_root,
|
||
Some(active_release_id.as_str()),
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
}
|
||
progress(OfficialUpdateProgress::new(
|
||
"finish",
|
||
"资源已是最新;无需下载",
|
||
));
|
||
return Ok(report);
|
||
}
|
||
|
||
if config.dry_run {
|
||
if config.plan {
|
||
let urls = pull_plan.all_urls().map_err(anyhow::Error::msg)?;
|
||
report.download_url_count = Some(urls.len());
|
||
report.download_urls = urls;
|
||
progress(OfficialUpdateProgress::new(
|
||
"dry-run",
|
||
format!(
|
||
"试运行已生成 {} 个 URL",
|
||
report.download_url_count.unwrap_or(0)
|
||
),
|
||
));
|
||
} else {
|
||
progress(OfficialUpdateProgress::new(
|
||
"dry-run",
|
||
"试运行检测到需要下载;未启用完整 URL 计划输出",
|
||
));
|
||
}
|
||
report.update_status = OfficialUpdateStatus::WouldDownload;
|
||
progress(OfficialUpdateProgress::new(
|
||
"finish",
|
||
"试运行完成,未写入状态",
|
||
));
|
||
return Ok(report);
|
||
}
|
||
check_shutdown_requested(&mut should_cancel)?;
|
||
|
||
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
|
||
let recovered_staging = recoverable_failed_staging(
|
||
&version_state_path,
|
||
¤t_update_snapshot,
|
||
&publish_layout,
|
||
)
|
||
.map_err(anyhow::Error::msg)?;
|
||
if let Some(record) = recovered_staging.as_ref() {
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!(
|
||
"发现可复用的未完成 staging:id={} path={}",
|
||
record.id,
|
||
record
|
||
.staging_path
|
||
.as_ref()
|
||
.unwrap_or(&record.resource_root)
|
||
.display()
|
||
),
|
||
));
|
||
}
|
||
let publish_plan =
|
||
publish_layout.plan(¤t_update_snapshot, recovered_staging.as_ref());
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!(
|
||
"准备 staging={} versioned={}",
|
||
publish_plan.staging_path.display(),
|
||
publish_plan.version_path.display()
|
||
),
|
||
));
|
||
publish_layout
|
||
.prepare_staging(&publish_plan)
|
||
.map_err(anyhow::Error::msg)?;
|
||
if publish_plan.reuse_existing_staging {
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
"复用未完成 staging;跳过 active release seed,避免覆盖已下载文件",
|
||
));
|
||
} else {
|
||
publish_layout
|
||
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
|
||
.map_err(anyhow::Error::msg)?;
|
||
}
|
||
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
|
||
let in_progress_record = prepare_in_progress_version_state(
|
||
&version_state_path,
|
||
¤t_update_snapshot,
|
||
&publish_plan.id,
|
||
&active_resource_root,
|
||
previous_snapshot.as_ref(),
|
||
&publish_plan.staging_path,
|
||
&staging_snapshot_path,
|
||
)?;
|
||
let mut version_state_guard =
|
||
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
|
||
let staging_fetcher = OfficialResourcePullService::with_curl_command(
|
||
&publish_plan.staging_path,
|
||
&config.curl_command,
|
||
)
|
||
.with_proxy_config(config.curl_proxy.clone())
|
||
.with_release_reuse_roots(
|
||
publish_layout
|
||
.release_reuse_roots(&active_resource_root)
|
||
.map_err(anyhow::Error::msg)?,
|
||
)
|
||
.with_cas_reuse_root(config.effective_import_cas_root())
|
||
.with_max_concurrency(config.download_concurrency);
|
||
let pruned_stale_resource_count = staging_fetcher
|
||
.prune_stale_manifest_entries(&pull_plan)
|
||
.map_err(anyhow::Error::msg)?;
|
||
if pruned_stale_resource_count > 0 {
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!(
|
||
"已清理新 manifest 删除的旧官方资源:{} 项",
|
||
pruned_stale_resource_count
|
||
),
|
||
));
|
||
}
|
||
report.staging_path = Some(publish_plan.staging_path.clone());
|
||
report.snapshot_path = staging_snapshot_path.clone();
|
||
report.download_manifest = staging_fetcher.download_manifest_path();
|
||
|
||
progress(OfficialUpdateProgress::new(
|
||
"download",
|
||
format!("下载或复用 {download_url_count} 个官方 URL"),
|
||
));
|
||
let pull_report = staging_fetcher
|
||
.pull_with_progress_and_cancellation(
|
||
&pull_plan,
|
||
|event| {
|
||
progress(progress_from_pull_event(event));
|
||
},
|
||
&mut should_cancel,
|
||
)
|
||
// 用 Error::new 保留 DownloadError 类型(含错误码),供上层 downcast 归类。
|
||
.map_err(anyhow::Error::new)
|
||
.inspect_err(|error| {
|
||
let _ = version_state_guard.fail(&error.to_string());
|
||
})?;
|
||
progress(OfficialUpdateProgress::new(
|
||
"download",
|
||
format!(
|
||
"下载阶段完成:已下载={} 已续传={} 当前 manifest 复用={} 历史 release 复用={} CAS 复用={} 本轮传输字节={}",
|
||
pull_report.downloaded_count(),
|
||
pull_report.resumed_count(),
|
||
pull_report.skipped_count(),
|
||
pull_report.release_reused_count(),
|
||
pull_report.cas_reused_count(),
|
||
pull_report.transferred_bytes()
|
||
),
|
||
));
|
||
if !pull_report.reuse_warnings.is_empty() {
|
||
progress(OfficialUpdateProgress::new(
|
||
"download",
|
||
format!(
|
||
"复用候选诊断:{} 项候选未通过校验,已按顺序回退",
|
||
pull_report.reuse_warnings.len()
|
||
),
|
||
));
|
||
}
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
"执行最终本地 manifest 审计",
|
||
));
|
||
check_shutdown_requested(&mut should_cancel)?;
|
||
let final_audit = staging_fetcher
|
||
.audit_local_manifest(&pull_plan)
|
||
.map_err(anyhow::Error::msg)?;
|
||
let final_verification_summary = OfficialVerificationSummary::new(
|
||
final_audit.manifest_blake3_verified_count(),
|
||
final_audit.repair_needed_count(),
|
||
pull_report.official_hash_verified_count(),
|
||
final_audit.zip_structure_verified_count(),
|
||
);
|
||
progress(OfficialUpdateProgress::new(
|
||
"audit",
|
||
verification_progress_message(&final_verification_summary),
|
||
));
|
||
progress(OfficialUpdateProgress::new(
|
||
"snapshot",
|
||
format!("写入快照 {}", staging_snapshot_path.display()),
|
||
));
|
||
if config.snapshot_path.is_none() {
|
||
write_snapshot(&staging_snapshot_path, ¤t_update_snapshot)?;
|
||
}
|
||
let staging_launcher_bootstrap_artifact_path = if bootstrap.is_some() {
|
||
progress(OfficialUpdateProgress::new(
|
||
"launcher-bootstrap",
|
||
format!(
|
||
"写入官方启动器 bootstrap {}",
|
||
publish_plan
|
||
.staging_path
|
||
.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE)
|
||
.display()
|
||
),
|
||
));
|
||
write_launcher_bootstrap_artifact_for_snapshot(
|
||
config,
|
||
&publish_plan.staging_path,
|
||
¤t_update_snapshot,
|
||
bootstrap.as_ref(),
|
||
OfficialLauncherBootstrapArtifactStatus::Published,
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_FILE,
|
||
)?
|
||
} else {
|
||
None
|
||
};
|
||
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!(
|
||
"校验完成,发布 versioned 目录并切换 current -> {}",
|
||
publish_plan.version_path.display()
|
||
),
|
||
));
|
||
let published_version_path = publish_layout
|
||
.publish(&publish_plan)
|
||
.map_err(anyhow::Error::msg)?;
|
||
// current symlink 已切换,资源发布成功。先取出完成版本记录并立即结束版本状态
|
||
// 事务,使后续 snapshot / version-state 写入失败不会把这个已发布版本经
|
||
// VersionStateGuard::Drop 误记为 failed,而是降级为可下轮重试的警告。
|
||
let completed_record = version_state_guard.record()?.clone();
|
||
let completed_release_id = completed_record.id.clone();
|
||
version_state_guard.commit();
|
||
|
||
let final_snapshot_path = snapshot_path_for(config, &published_version_path);
|
||
let snapshot_written = if config.snapshot_path.is_some() {
|
||
match write_snapshot(&final_snapshot_path, ¤t_update_snapshot) {
|
||
Ok(()) => true,
|
||
Err(error) => {
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!("资源已发布,但写入最终 snapshot 失败(下轮可重试):{error}"),
|
||
));
|
||
false
|
||
}
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
|
||
report.update_status = OfficialUpdateStatus::Downloaded;
|
||
report.status_code = report.update_status.flow_status_code();
|
||
report.active_resource_root = published_version_path.clone();
|
||
report.published_version_path = Some(published_version_path.clone());
|
||
report.snapshot_path = final_snapshot_path.clone();
|
||
report.download_manifest = published_version_path.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE);
|
||
report.resource_count = Some(pull_report.items.len());
|
||
report.downloaded_count = pull_report.downloaded_count();
|
||
report.resumed_count = pull_report.resumed_count();
|
||
report.skipped_count = pull_report.skipped_count();
|
||
report.release_reused_count = pull_report.release_reused_count();
|
||
report.cas_reused_count = pull_report.cas_reused_count();
|
||
report.reused_bytes = pull_report.reused_bytes();
|
||
report.reuse_warnings = pull_report.reuse_warnings.clone();
|
||
report.final_bytes = pull_report.total_bytes();
|
||
report.transferred_bytes = pull_report.transferred_bytes();
|
||
report.official_seed_hash_verified_count = pull_report.official_hash_verified_count();
|
||
report.local_manifest_verified_count = final_audit.verified_count();
|
||
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
|
||
report.verification_summary = final_verification_summary;
|
||
report.snapshot_written = snapshot_written.then(|| final_snapshot_path.clone());
|
||
report.launcher_bootstrap_artifact_path = staging_launcher_bootstrap_artifact_path
|
||
.map(|_| published_version_path.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE));
|
||
apply_localized_release_info(&mut report, config, Some(completed_release_id.as_str()));
|
||
if let Err(error) = complete_version_state(
|
||
&version_state_path,
|
||
completed_record,
|
||
&published_version_path,
|
||
&final_snapshot_path,
|
||
) {
|
||
progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!("资源已发布,但写入版本状态失败(下轮可重试):{error}"),
|
||
));
|
||
}
|
||
run_post_sync_resource_handoff(
|
||
Some(&active_resource_root),
|
||
&published_version_path,
|
||
completed_release_id.as_str(),
|
||
version_id_from_path(&active_resource_root),
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
run_post_sync_parse_cache(
|
||
config,
|
||
&published_version_path,
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
run_post_sync_textunit_queue_if_needed(
|
||
&published_version_path,
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
run_post_sync_repository_import(
|
||
config,
|
||
&published_version_path,
|
||
Some(completed_release_id.as_str()),
|
||
&mut report,
|
||
&mut progress,
|
||
&mut should_cancel,
|
||
)?;
|
||
|
||
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
|
||
match read_version_state(&version_state_path) {
|
||
Ok(Some(state)) => match gc_orphan_staging_with_cas_root(
|
||
&config.output_root,
|
||
&state,
|
||
&config.effective_import_cas_root(),
|
||
) {
|
||
Ok(removed) if !removed.is_empty() => progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!("已清理 {} 个孤儿 staging 目录", removed.len()),
|
||
)),
|
||
Ok(_) => {}
|
||
Err(error) => progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!("清理孤儿 staging 目录失败(忽略):{error}"),
|
||
)),
|
||
},
|
||
Ok(None) => {}
|
||
Err(error) => progress(OfficialUpdateProgress::new(
|
||
"publish",
|
||
format!("读取版本状态用于 staging 清理失败(忽略):{error}"),
|
||
)),
|
||
}
|
||
|
||
progress(OfficialUpdateProgress::new(
|
||
"finish",
|
||
format!(
|
||
"同步完成:资源数={} 官方 hash 已校验={}",
|
||
report.resource_count.unwrap_or(0),
|
||
report.official_seed_hash_verified_count
|
||
),
|
||
));
|
||
Ok(report)
|
||
}
|
||
}
|
||
|
||
fn run_post_sync_resource_handoff(
|
||
previous_resource_root: Option<&Path>,
|
||
current_resource_root: &Path,
|
||
official_release_id: &str,
|
||
previous_release_id: Option<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(¤t_path, &version_path) {
|
||
return not_localized_release_info(current_path);
|
||
}
|
||
|
||
LocalizedReleaseInfo {
|
||
status: LocalizedReleaseStatus::Localized,
|
||
current_path,
|
||
published_version_path: Some(version_path),
|
||
}
|
||
}
|
||
|
||
fn not_localized_release_info(current_path: PathBuf) -> LocalizedReleaseInfo {
|
||
LocalizedReleaseInfo {
|
||
status: LocalizedReleaseStatus::NotLocalized,
|
||
current_path,
|
||
published_version_path: None,
|
||
}
|
||
}
|
||
|
||
fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool {
|
||
let Ok(target) = fs::read_link(current_path) else {
|
||
return false;
|
||
};
|
||
let resolved = if target.is_absolute() {
|
||
target
|
||
} else {
|
||
current_path
|
||
.parent()
|
||
.map(|parent| parent.join(&target))
|
||
.unwrap_or(target)
|
||
};
|
||
resolved == version_path
|
||
}
|
||
|
||
/// Computes the extended snapshot delta.
|
||
pub fn diff_extended_snapshot(
|
||
current: &OfficialUpdateSnapshot,
|
||
previous: Option<&OfficialUpdateSnapshot>,
|
||
) -> ExtendedSnapshotDelta {
|
||
let Some(previous) = previous else {
|
||
return ExtendedSnapshotDelta {
|
||
endpoint_markers_changed: true,
|
||
launcher_metadata_changed: current.launcher_metadata.is_some(),
|
||
game_main_config_changed: current.game_main_config_bootstrap.is_some(),
|
||
};
|
||
};
|
||
|
||
ExtendedSnapshotDelta {
|
||
endpoint_markers_changed: current.endpoint_markers != previous.endpoint_markers,
|
||
launcher_metadata_changed: current.launcher_metadata != previous.launcher_metadata,
|
||
game_main_config_changed: current.game_main_config_bootstrap
|
||
!= previous.game_main_config_bootstrap,
|
||
}
|
||
}
|
||
|
||
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
||
if !(MIN_DOWNLOAD_CONCURRENCY..=MAX_DOWNLOAD_CONCURRENCY).contains(&config.download_concurrency)
|
||
{
|
||
return Err(format!(
|
||
"下载并发数必须在 {MIN_DOWNLOAD_CONCURRENCY}..={MAX_DOWNLOAD_CONCURRENCY} 范围内"
|
||
));
|
||
}
|
||
validate_output_root(&config.output_root)?;
|
||
validate_output_root(&config.localized_output_root)?;
|
||
validate_separate_output_roots(&config.output_root, &config.localized_output_root)?;
|
||
ensure_safe_directory_path(&config.output_root, "资源输出目录")?;
|
||
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
|
||
if config.import_repository {
|
||
validate_repository_import_paths(config)?;
|
||
}
|
||
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
|
||
ensure_path_within_root(&config.output_root, snapshot_path)?;
|
||
ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?;
|
||
}
|
||
ensure_safe_file_target(
|
||
&config.output_root,
|
||
&config.bootstrap_cache_path(),
|
||
"官方启动缓存",
|
||
)?;
|
||
ensure_safe_file_target(
|
||
&config.output_root,
|
||
&config.version_state_path(),
|
||
"官方版本状态",
|
||
)?;
|
||
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_repository_import_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
||
let cas_root = config.effective_import_cas_root();
|
||
validate_output_root(&cas_root)?;
|
||
ensure_safe_directory_path(&cas_root, "官方资源 CAS 导入目录")?;
|
||
let official = lexical_absolute(&config.output_root)?;
|
||
let localized = lexical_absolute(&config.localized_output_root)?;
|
||
let cas = lexical_absolute(&cas_root)?;
|
||
if cas == official {
|
||
return Err(format!(
|
||
"官方资源 CAS 导入目录不能直接等于官方资源发布根:{}",
|
||
cas.display()
|
||
));
|
||
}
|
||
if cas == localized || cas.starts_with(&localized) {
|
||
return Err(format!(
|
||
"官方资源 CAS 导入目录不能位于汉化输出目录内:CAS={} 汉化={}",
|
||
cas.display(),
|
||
localized.display()
|
||
));
|
||
}
|
||
|
||
let repository_path = config.effective_import_resource_repository_path();
|
||
let repository_parent = repository_path
|
||
.parent()
|
||
.ok_or_else(|| format!("资源索引数据库缺少父目录:{}", repository_path.display()))?;
|
||
ensure_safe_directory_path(repository_parent, "资源索引数据库目录")?;
|
||
ensure_safe_file_target(repository_parent, &repository_path, "资源索引数据库")?;
|
||
let repository = lexical_absolute(&repository_path)?;
|
||
if repository.starts_with(&localized) {
|
||
return Err(format!(
|
||
"资源索引数据库不能位于汉化输出目录内:数据库={} 汉化={}",
|
||
repository.display(),
|
||
localized.display()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_separate_output_roots(
|
||
output_root: &Path,
|
||
localized_output_root: &Path,
|
||
) -> Result<(), String> {
|
||
let official = lexical_absolute(output_root)?;
|
||
let localized = lexical_absolute(localized_output_root)?;
|
||
if official == localized {
|
||
return Err(format!(
|
||
"官方资源目录和汉化输出目录不能相同:{}",
|
||
official.display()
|
||
));
|
||
}
|
||
if localized.starts_with(&official) || official.starts_with(&localized) {
|
||
return Err(format!(
|
||
"官方资源目录和汉化输出目录不能互相嵌套:官方={} 汉化={}",
|
||
official.display(),
|
||
localized.display()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
|
||
config
|
||
.snapshot_path
|
||
.clone()
|
||
.unwrap_or_else(|| resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE))
|
||
}
|
||
|
||
fn path_exists_no_follow(path: &Path) -> Result<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"
|
||
| ".cas-owner-scope"
|
||
) {
|
||
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 is_release_local_mutable_state(&source_path) {
|
||
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
||
format!(
|
||
"复制官方 release mutable state 失败 {} -> {}:{copy_error}",
|
||
source_path.display(),
|
||
destination_path.display()
|
||
)
|
||
})?;
|
||
} else 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(())
|
||
}
|
||
|
||
fn is_release_local_mutable_state(path: &Path) -> bool {
|
||
matches!(
|
||
path.file_name().and_then(|name| name.to_str()),
|
||
Some(
|
||
OFFICIAL_DOWNLOAD_MANIFEST_FILE
|
||
| OFFICIAL_SYNC_SNAPSHOT_FILE
|
||
| "official-parse-cache.json"
|
||
| "official-textunit-index.json"
|
||
| "official-textunit-tasks.json"
|
||
| "crowdin-textunit-queue.json"
|
||
| "official-resource-changes.json"
|
||
| "crowdin-translation-handoff.json"
|
||
| "translation-tasks.sqlite"
|
||
| "translation-tasks.sqlite-wal"
|
||
| "translation-tasks.sqlite-shm"
|
||
| "translation-handoff.json"
|
||
| OFFICIAL_LAUNCHER_BOOTSTRAP_FILE
|
||
| OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE
|
||
| "official-cas-reuse-references.json"
|
||
)
|
||
)
|
||
}
|
||
|
||
#[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 不跟随 symlink:symlink(即使指向目录)is_dir() 为 false,会被跳过。
|
||
if !entry.file_type()?.is_dir() {
|
||
continue;
|
||
}
|
||
let path = entry.path();
|
||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||
continue;
|
||
};
|
||
if referenced.contains(&name) {
|
||
continue;
|
||
}
|
||
release_cas_reuse_references(&path, cas_root).map_err(anyhow::Error::msg)?;
|
||
fs::remove_dir_all(&path).map_err(|error| {
|
||
anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display())
|
||
})?;
|
||
removed.push(path);
|
||
}
|
||
Ok(removed)
|
||
}
|
||
|
||
fn snapshot_path_for_state_path(
|
||
_snapshot: &OfficialUpdateSnapshot,
|
||
resource_root: &Path,
|
||
) -> PathBuf {
|
||
resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE)
|
||
}
|
||
|
||
fn version_id_from_path(path: &Path) -> Option<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)]
|
||
pub(crate) struct OfficialUpdateLock {
|
||
path: PathBuf,
|
||
}
|
||
|
||
impl OfficialUpdateLock {
|
||
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
|
||
Self::acquire_output_root(&config.output_root)
|
||
}
|
||
|
||
pub(crate) fn acquire_output_root(output_root: &Path) -> anyhow::Result<Self> {
|
||
validate_output_root(output_root).map_err(anyhow::Error::msg)?;
|
||
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
|
||
fs::create_dir_all(output_root)?;
|
||
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
|
||
let path = output_root.join(".official-sync.lock");
|
||
ensure_safe_file_target(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()
|
||
))
|
||
}
|
||
}
|
||
|
||
pub(crate) fn acquire_official_output_lock(
|
||
output_root: &Path,
|
||
) -> anyhow::Result<OfficialUpdateLock> {
|
||
OfficialUpdateLock::acquire_output_root(output_root)
|
||
}
|
||
|
||
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::*;
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn staging_copy_does_not_hard_link_mutable_translation_state() {
|
||
use std::os::unix::fs::MetadataExt;
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let source = temp.path().join("old");
|
||
let destination = temp.path().join("new");
|
||
fs::create_dir_all(&source).unwrap();
|
||
let mutable_files = [
|
||
OFFICIAL_DOWNLOAD_MANIFEST_FILE,
|
||
OFFICIAL_SYNC_SNAPSHOT_FILE,
|
||
"official-parse-cache.json",
|
||
"official-textunit-index.json",
|
||
"official-textunit-tasks.json",
|
||
"crowdin-textunit-queue.json",
|
||
"official-resource-changes.json",
|
||
"crowdin-translation-handoff.json",
|
||
"translation-tasks.sqlite",
|
||
"translation-tasks.sqlite-wal",
|
||
"translation-tasks.sqlite-shm",
|
||
"translation-handoff.json",
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_FILE,
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
|
||
"official-cas-reuse-references.json",
|
||
];
|
||
for (index, name) in mutable_files.iter().enumerate() {
|
||
fs::write(source.join(name), format!("old-{index}")).unwrap();
|
||
}
|
||
fs::write(source.join("immutable.bundle"), b"payload").unwrap();
|
||
|
||
copy_tree_no_symlink(&source, &destination, false).unwrap();
|
||
|
||
for (index, name) in mutable_files.iter().enumerate() {
|
||
assert_ne!(
|
||
fs::metadata(source.join(name)).unwrap().ino(),
|
||
fs::metadata(destination.join(name)).unwrap().ino(),
|
||
"mutable state unexpectedly hard-linked: {name}"
|
||
);
|
||
fs::write(destination.join(name), format!("new-{index}")).unwrap();
|
||
assert_eq!(
|
||
fs::read(source.join(name)).unwrap(),
|
||
format!("old-{index}").as_bytes(),
|
||
"historical mutable state changed: {name}"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
fs::metadata(source.join("immutable.bundle")).unwrap().ino(),
|
||
fs::metadata(destination.join("immutable.bundle"))
|
||
.unwrap()
|
||
.ino()
|
||
);
|
||
}
|
||
|
||
#[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(¤t, Some(&previous));
|
||
assert!(delta.endpoint_markers_changed);
|
||
assert!(delta.has_changes());
|
||
assert!(!delta.launcher_metadata_changed);
|
||
assert!(!delta.game_main_config_changed);
|
||
}
|
||
|
||
#[test]
|
||
fn persists_bootstrap_cache_json_round_trip() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join("official-bootstrap-cache.json");
|
||
let bootstrap = fixture_bootstrap();
|
||
let cache = OfficialBootstrapCache {
|
||
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
|
||
launcher_metadata: bootstrap.launcher_metadata,
|
||
game_main_config: bootstrap.game_main_config,
|
||
};
|
||
|
||
write_bootstrap_cache(&path, &cache).unwrap();
|
||
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
|
||
}
|
||
|
||
#[test]
|
||
fn bootstrap_cache_misses_when_launcher_manifest_digest_changes() {
|
||
let bootstrap = fixture_bootstrap();
|
||
let cache = OfficialBootstrapCache {
|
||
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
|
||
launcher_metadata: bootstrap.launcher_metadata.clone(),
|
||
game_main_config: bootstrap.game_main_config.clone(),
|
||
};
|
||
let mut changed_metadata = bootstrap.launcher_metadata.clone();
|
||
changed_metadata.manifest_files_blake3 = Some("different-manifest-digest".to_string());
|
||
|
||
assert!(cached_game_main_config_for_metadata(&cache, &changed_metadata).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn launcher_manifest_file_digest_changes_with_manifest_content() {
|
||
let files = vec![YostarJpLauncherManifestFile {
|
||
path: "/BlueArchive_Data/resources.assets".to_string(),
|
||
size: "123".to_string(),
|
||
hash: "hash-a".to_string(),
|
||
vc: Some("vc".to_string()),
|
||
}];
|
||
let mut changed = files.clone();
|
||
changed[0].hash = "hash-b".to_string();
|
||
|
||
assert_ne!(
|
||
launcher_manifest_files_blake3(&files),
|
||
launcher_manifest_files_blake3(&changed)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn persists_launcher_bootstrap_artifact_json_round_trip() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join("official-launcher-bootstrap.json");
|
||
let bootstrap = fixture_bootstrap();
|
||
let snapshot = OfficialUpdateSnapshot::new(
|
||
fixture_base_snapshot(),
|
||
vec![fixture_marker("1234")],
|
||
Some(&bootstrap),
|
||
);
|
||
let artifact = launcher_bootstrap_artifact(
|
||
&snapshot,
|
||
&bootstrap,
|
||
OfficialLauncherBootstrapArtifactStatus::Published,
|
||
);
|
||
|
||
write_launcher_bootstrap_artifact(&path, &artifact).unwrap();
|
||
let read = read_launcher_bootstrap_artifact(&path).unwrap().unwrap();
|
||
|
||
assert_eq!(read, artifact);
|
||
assert_eq!(
|
||
read.launcher_bootstrap
|
||
.selected_game_main_config_source
|
||
.relative_path,
|
||
"BAJP_1.70.0.zip"
|
||
);
|
||
assert_eq!(
|
||
read.launcher_bootstrap.remote_manifest.files[0].parsed_size,
|
||
Some(123)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn writes_pending_launcher_bootstrap_artifact_under_output_root() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().to_path_buf(),
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
let bootstrap = fixture_bootstrap();
|
||
let snapshot = OfficialUpdateSnapshot::new(
|
||
fixture_base_snapshot(),
|
||
vec![fixture_marker("1234")],
|
||
Some(&bootstrap),
|
||
);
|
||
|
||
let path = write_launcher_bootstrap_artifact_for_snapshot(
|
||
&config,
|
||
temp.path(),
|
||
&snapshot,
|
||
Some(&bootstrap),
|
||
OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources,
|
||
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
|
||
)
|
||
.unwrap()
|
||
.unwrap();
|
||
let artifact = read_launcher_bootstrap_artifact(&path).unwrap().unwrap();
|
||
|
||
assert_eq!(
|
||
path,
|
||
temp.path().join(OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE)
|
||
);
|
||
assert_eq!(
|
||
artifact.status,
|
||
OfficialLauncherBootstrapArtifactStatus::WaitingForOfficialResources
|
||
);
|
||
assert_eq!(artifact.context.app_version, "1.70.0");
|
||
}
|
||
|
||
#[test]
|
||
fn persists_version_state_json_round_trip() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join("official-version-state.json");
|
||
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||
let current = version_record_for_snapshot(
|
||
&snapshot,
|
||
VersionRecordInput {
|
||
id: "1.70.0-bundle-current".to_string(),
|
||
resource_root: temp.path().join("versions/current"),
|
||
snapshot_path: temp
|
||
.path()
|
||
.join("versions/current/official-sync-snapshot.json"),
|
||
staging_path: None,
|
||
version_path: Some(temp.path().join("versions/current")),
|
||
started_unix_seconds: None,
|
||
completed_unix_seconds: Some(10),
|
||
},
|
||
);
|
||
let failed = OfficialFailedVersionRecord {
|
||
version: current.clone(),
|
||
error: "fixture failure".to_string(),
|
||
failed_unix_seconds: 11,
|
||
};
|
||
let state = OfficialVersionState {
|
||
current_completed_version: Some(current),
|
||
failed_versions: vec![failed],
|
||
updated_unix_seconds: 12,
|
||
..OfficialVersionState::default()
|
||
};
|
||
|
||
write_version_state(&path, &state).unwrap();
|
||
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
||
}
|
||
|
||
#[test]
|
||
fn version_state_tracks_in_progress_success_and_failure() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join("official-version-state.json");
|
||
let active = temp.path().join("versions/previous");
|
||
let staging = temp.path().join(".staging/current");
|
||
let staging_snapshot = staging.join("official-sync-snapshot.json");
|
||
let published = temp.path().join("versions/current");
|
||
let previous_snapshot = OfficialUpdateSnapshot::new(
|
||
fixture_base_snapshot(),
|
||
vec![fixture_marker("previous")],
|
||
None,
|
||
);
|
||
let current_snapshot = OfficialUpdateSnapshot::new(
|
||
fixture_base_snapshot(),
|
||
vec![fixture_marker("current")],
|
||
Some(&fixture_bootstrap()),
|
||
);
|
||
|
||
let in_progress = prepare_in_progress_version_state(
|
||
&path,
|
||
¤t_snapshot,
|
||
"current",
|
||
&active,
|
||
Some(&previous_snapshot),
|
||
&staging,
|
||
&staging_snapshot,
|
||
)
|
||
.unwrap();
|
||
let state = read_version_state(&path).unwrap().unwrap();
|
||
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
|
||
assert_eq!(
|
||
state.current_completed_version.as_ref().unwrap().id,
|
||
"previous"
|
||
);
|
||
|
||
complete_version_state(
|
||
&path,
|
||
in_progress.clone(),
|
||
&published,
|
||
&published.join("official-sync-snapshot.json"),
|
||
)
|
||
.unwrap();
|
||
let state = read_version_state(&path).unwrap().unwrap();
|
||
assert_eq!(
|
||
state.current_completed_version.as_ref().unwrap().id,
|
||
"current"
|
||
);
|
||
assert!(state.in_progress_version.is_none());
|
||
assert_eq!(
|
||
state.previous_available_version.as_ref().unwrap().id,
|
||
"previous"
|
||
);
|
||
|
||
let failed = prepare_in_progress_version_state(
|
||
&path,
|
||
¤t_snapshot,
|
||
"broken",
|
||
&published,
|
||
state
|
||
.current_completed_version
|
||
.as_ref()
|
||
.map(|_| ¤t_snapshot),
|
||
&temp.path().join(".staging/broken"),
|
||
&temp
|
||
.path()
|
||
.join(".staging/broken/official-sync-snapshot.json"),
|
||
)
|
||
.unwrap();
|
||
fail_version_state(&path, failed, "download 403").unwrap();
|
||
let state = read_version_state(&path).unwrap().unwrap();
|
||
assert!(state.in_progress_version.is_none());
|
||
assert_eq!(state.failed_versions.len(), 1);
|
||
assert_eq!(state.failed_versions[0].version.id, "broken");
|
||
assert!(state.failed_versions[0].error.contains("403"));
|
||
}
|
||
|
||
fn version_record_with_id(id: &str) -> OfficialVersionRecord {
|
||
OfficialVersionRecord {
|
||
id: id.to_string(),
|
||
app_version: "1.0.0".to_string(),
|
||
bundle_version: None,
|
||
addressables_root: "root".to_string(),
|
||
resource_root: PathBuf::from("/tmp/x"),
|
||
snapshot_path: PathBuf::from("/tmp/x/snap.json"),
|
||
staging_path: None,
|
||
version_path: None,
|
||
started_unix_seconds: None,
|
||
completed_unix_seconds: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn gc_orphan_staging_removes_only_unreferenced_dirs() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let root = temp.path();
|
||
let staging = root.join(OFFICIAL_STAGING_DIR);
|
||
for id in ["keep-inprogress", "keep-failed", "orphan-a", "orphan-b"] {
|
||
fs::create_dir_all(staging.join(id)).unwrap();
|
||
fs::write(staging.join(id).join("marker"), b"x").unwrap();
|
||
}
|
||
// staging 根下的普通文件不应被误删。
|
||
fs::write(staging.join("stray-file"), b"x").unwrap();
|
||
|
||
let state = OfficialVersionState {
|
||
in_progress_version: Some(version_record_with_id("keep-inprogress")),
|
||
failed_versions: vec![OfficialFailedVersionRecord {
|
||
version: version_record_with_id("keep-failed"),
|
||
error: "boom".to_string(),
|
||
failed_unix_seconds: 1,
|
||
}],
|
||
..OfficialVersionState::default()
|
||
};
|
||
|
||
let removed = gc_orphan_staging(root, &state).unwrap();
|
||
let mut removed_names: Vec<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, ¤t_snapshot, &layout)
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_rejects_dangerous_output_root_before_network_work() {
|
||
let config = OfficialUpdateConfig {
|
||
output_root: PathBuf::from("/"),
|
||
dry_run: true,
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
|
||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||
assert!(error.to_string().contains("危险路径"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_rejects_same_official_and_localized_output_root() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let root = temp.path().join("resources");
|
||
let config = OfficialUpdateConfig {
|
||
output_root: root.clone(),
|
||
localized_output_root: root,
|
||
dry_run: true,
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
|
||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||
assert!(error.to_string().contains("不能相同"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_rejects_nested_official_and_localized_output_roots() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().join("resources"),
|
||
localized_output_root: temp.path().join("resources/localized"),
|
||
dry_run: true,
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
|
||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||
assert!(error.to_string().contains("不能互相嵌套"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_rejects_snapshot_path_escape() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().join("resources"),
|
||
snapshot_path: Some(temp.path().join("outside-snapshot.json")),
|
||
dry_run: true,
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
|
||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||
assert!(error.to_string().contains("路径逃逸"));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn write_snapshot_rejects_symlink_target() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let outside = temp.path().join("outside.json");
|
||
let link = temp.path().join("snapshot.json");
|
||
fs::write(&outside, b"outside").unwrap();
|
||
symlink(&outside, &link).unwrap();
|
||
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||
|
||
let error = write_snapshot(&link, &snapshot).unwrap_err();
|
||
assert!(error.to_string().contains("symlink"));
|
||
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
||
}
|
||
|
||
#[test]
|
||
fn bootstrap_cache_hit_requires_same_metadata_and_version() {
|
||
let bootstrap = fixture_bootstrap();
|
||
let cache = OfficialBootstrapCache {
|
||
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION,
|
||
launcher_metadata: bootstrap.launcher_metadata.clone(),
|
||
game_main_config: bootstrap.game_main_config.clone(),
|
||
};
|
||
|
||
assert_eq!(
|
||
cached_game_main_config_for_metadata(&cache, &bootstrap.launcher_metadata),
|
||
Some(bootstrap.game_main_config.clone())
|
||
);
|
||
|
||
let mut changed_metadata = bootstrap.launcher_metadata.clone();
|
||
changed_metadata.game_latest_version = "1.71.0".to_string();
|
||
assert_eq!(
|
||
cached_game_main_config_for_metadata(&cache, &changed_metadata),
|
||
None
|
||
);
|
||
|
||
let stale_version_cache = OfficialBootstrapCache {
|
||
cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION + 1,
|
||
..cache
|
||
};
|
||
assert_eq!(
|
||
cached_game_main_config_for_metadata(
|
||
&stale_version_cache,
|
||
&bootstrap.launcher_metadata
|
||
),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn non_dry_run_lock_rejects_concurrent_writer() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().to_path_buf(),
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
|
||
let first = OfficialUpdateLock::acquire(&config).unwrap();
|
||
let second = OfficialUpdateLock::acquire(&config).unwrap_err();
|
||
assert!(second.to_string().contains("锁定"));
|
||
drop(first);
|
||
assert!(OfficialUpdateLock::acquire(&config).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn non_dry_run_lock_removes_stale_pid_file() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().to_path_buf(),
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
fs::create_dir_all(&config.output_root).unwrap();
|
||
fs::write(config.lock_path(), "999999999").unwrap();
|
||
|
||
let lock = OfficialUpdateLock::acquire(&config).unwrap();
|
||
assert!(config.lock_path().exists());
|
||
drop(lock);
|
||
assert!(!config.lock_path().exists());
|
||
}
|
||
|
||
#[test]
|
||
fn non_dry_run_lock_removes_corrupt_pid_file() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().to_path_buf(),
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
fs::create_dir_all(&config.output_root).unwrap();
|
||
fs::write(config.lock_path(), "not-a-pid").unwrap();
|
||
|
||
let lock = OfficialUpdateLock::acquire(&config).unwrap();
|
||
assert_eq!(
|
||
fs::read_to_string(config.lock_path()).unwrap(),
|
||
std::process::id().to_string()
|
||
);
|
||
drop(lock);
|
||
assert!(!config.lock_path().exists());
|
||
}
|
||
|
||
#[test]
|
||
fn update_run_can_be_cancelled_before_network_work() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let config = OfficialUpdateConfig {
|
||
output_root: temp.path().to_path_buf(),
|
||
dry_run: true,
|
||
..OfficialUpdateConfig::default()
|
||
};
|
||
|
||
let mut checks = 0;
|
||
let error = OfficialUpdateService::new()
|
||
.run_with_progress_and_cancellation(
|
||
&config,
|
||
|_| {},
|
||
|| {
|
||
checks += 1;
|
||
true
|
||
},
|
||
)
|
||
.unwrap_err();
|
||
|
||
assert!(error.to_string().contains("停止请求"));
|
||
assert_eq!(checks, 1);
|
||
}
|
||
}
|