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