feat: track official versions and import resources

Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
This commit is contained in:
2026-07-15 19:53:10 +08:00
parent 4192d7ee4c
commit 90307a3243
30 changed files with 1269 additions and 157 deletions
+499 -3
View File
@@ -37,11 +37,14 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
/// Current official bootstrap cache schema version.
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
/// Current official version-state schema version.
pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1;
const OFFICIAL_CURRENT_LINK: &str = "current";
const OFFICIAL_VERSIONS_DIR: &str = "versions";
const OFFICIAL_STAGING_DIR: &str = ".staging";
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json";
/// Server-info input for an official update run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -125,6 +128,11 @@ impl OfficialUpdateConfig {
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")
@@ -181,6 +189,82 @@ pub struct OfficialUpdateSnapshot {
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(
@@ -391,6 +475,8 @@ pub struct OfficialUpdateReport {
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.
@@ -754,6 +840,10 @@ impl OfficialUpdateService {
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);
@@ -1035,6 +1125,7 @@ impl OfficialUpdateService {
output_root: config.output_root.clone(),
active_resource_root: active_resource_root.clone(),
current_path: publish_layout.current_path.clone(),
version_state_path: version_state_path.clone(),
published_version_path: None,
staging_path: None,
snapshot_path: snapshot_path.clone(),
@@ -1079,6 +1170,27 @@ impl OfficialUpdateService {
"audit",
verification_progress_message(&report.verification_summary),
));
if !config.dry_run {
let current_record = version_record_for_snapshot(
&current_update_snapshot,
VersionRecordInput {
id: version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot)),
resource_root: active_resource_root.clone(),
snapshot_path: snapshot_path.clone(),
staging_path: None,
version_path: Some(active_resource_root.clone()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
);
complete_version_state(
&version_state_path,
current_record,
&active_resource_root,
&snapshot_path,
)?;
}
progress(OfficialUpdateProgress::new(
"finish",
"资源已是最新;无需下载",
@@ -1129,11 +1241,22 @@ impl OfficialUpdateService {
publish_layout
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
.map_err(anyhow::Error::msg)?;
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
let in_progress_record = prepare_in_progress_version_state(
&version_state_path,
&current_update_snapshot,
&publish_plan.id,
&active_resource_root,
previous_snapshot.as_ref(),
&publish_plan.staging_path,
&staging_snapshot_path,
)?;
let mut version_state_guard =
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
let staging_fetcher = OfficialResourcePullService::with_curl_command(
&publish_plan.staging_path,
&config.curl_command,
);
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();
@@ -1150,7 +1273,10 @@ impl OfficialUpdateService {
},
&mut should_cancel,
)
.map_err(anyhow::Error::msg)?;
.map_err(|error| anyhow::anyhow!(error))
.inspect_err(|error| {
let _ = version_state_guard.fail(&error.to_string());
})?;
progress(OfficialUpdateProgress::new(
"download",
format!(
@@ -1217,7 +1343,15 @@ impl OfficialUpdateService {
report.local_manifest_verified_count = final_audit.verified_count();
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
report.verification_summary = final_verification_summary;
report.snapshot_written = Some(final_snapshot_path);
report.snapshot_written = Some(final_snapshot_path.clone());
let completed_record = version_state_guard.record()?.clone();
complete_version_state(
&version_state_path,
completed_record,
&published_version_path,
&final_snapshot_path,
)?;
version_state_guard.commit();
progress(OfficialUpdateProgress::new(
"finish",
@@ -1505,6 +1639,11 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
&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(())
}
@@ -1725,6 +1864,30 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow:
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)?
@@ -1763,6 +1926,227 @@ fn default_bootstrap_cache_version() -> u32 {
OFFICIAL_BOOTSTRAP_CACHE_VERSION
}
fn default_version_state_version() -> u32 {
OFFICIAL_VERSION_STATE_VERSION
}
#[derive(Debug)]
struct VersionRecordInput {
id: String,
resource_root: PathBuf,
snapshot_path: PathBuf,
staging_path: Option<PathBuf>,
version_path: Option<PathBuf>,
started_unix_seconds: Option<u64>,
completed_unix_seconds: Option<u64>,
}
fn version_record_for_snapshot(
snapshot: &OfficialUpdateSnapshot,
input: VersionRecordInput,
) -> OfficialVersionRecord {
OfficialVersionRecord {
id: input.id,
app_version: snapshot.app_version.clone(),
bundle_version: snapshot.bundle_version.clone(),
addressables_root: snapshot.addressables_root.clone(),
resource_root: input.resource_root,
snapshot_path: input.snapshot_path,
staging_path: input.staging_path,
version_path: input.version_path,
started_unix_seconds: input.started_unix_seconds,
completed_unix_seconds: input.completed_unix_seconds,
}
}
fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> {
let Some(mut state) = read_version_state(path)? else {
return Ok(());
};
let Some(version) = state.in_progress_version.take() else {
return Ok(());
};
state.failed_versions.push(OfficialFailedVersionRecord {
version,
error: "上一次官方资源同步在完成发布前中断".to_string(),
failed_unix_seconds: unix_seconds_now(),
});
trim_failed_versions(&mut state.failed_versions);
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
fn prepare_in_progress_version_state(
path: &Path,
snapshot: &OfficialUpdateSnapshot,
publish_id: &str,
active_resource_root: &Path,
previous_snapshot: Option<&OfficialUpdateSnapshot>,
staging_path: &Path,
snapshot_path: &Path,
) -> anyhow::Result<OfficialVersionRecord> {
let mut state = read_version_state(path)?.unwrap_or_default();
let fallback_current = previous_snapshot.map(|snapshot| {
version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: version_id_from_path(active_resource_root)
.unwrap_or_else(|| fallback_version_id(snapshot)),
resource_root: active_resource_root.to_path_buf(),
snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root),
staging_path: None,
version_path: Some(active_resource_root.to_path_buf()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
)
});
if state.current_completed_version.is_none() {
state.current_completed_version = fallback_current;
}
let now = unix_seconds_now();
let record = version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: publish_id.to_string(),
resource_root: staging_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: Some(staging_path.to_path_buf()),
version_path: None,
started_unix_seconds: Some(now),
completed_unix_seconds: None,
},
);
state.in_progress_version = Some(record.clone());
state.updated_unix_seconds = now;
write_version_state(path, &state)?;
Ok(record)
}
fn complete_version_state(
path: &Path,
record: OfficialVersionRecord,
published_path: &Path,
snapshot_path: &Path,
) -> anyhow::Result<()> {
let mut state = read_version_state(path)?.unwrap_or_default();
let previous = state
.current_completed_version
.take()
.filter(|previous| previous.id != record.id);
if previous.is_some() {
state.previous_available_version = previous;
}
state.current_completed_version = Some(OfficialVersionRecord {
resource_root: published_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: None,
version_path: Some(published_path.to_path_buf()),
completed_unix_seconds: Some(unix_seconds_now()),
..record
});
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;
state.failed_versions.push(OfficialFailedVersionRecord {
version: record,
error: error.to_string(),
failed_unix_seconds: 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 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,
@@ -2268,6 +2652,118 @@ mod tests {
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
}
#[test]
fn persists_version_state_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
let current = version_record_for_snapshot(
&snapshot,
VersionRecordInput {
id: "1.70.0-bundle-current".to_string(),
resource_root: temp.path().join("versions/current"),
snapshot_path: temp
.path()
.join("versions/current/official-sync-snapshot.json"),
staging_path: None,
version_path: Some(temp.path().join("versions/current")),
started_unix_seconds: None,
completed_unix_seconds: Some(10),
},
);
let failed = OfficialFailedVersionRecord {
version: current.clone(),
error: "fixture failure".to_string(),
failed_unix_seconds: 11,
};
let state = OfficialVersionState {
current_completed_version: Some(current),
failed_versions: vec![failed],
updated_unix_seconds: 12,
..OfficialVersionState::default()
};
write_version_state(&path, &state).unwrap();
assert_eq!(read_version_state(&path).unwrap(), Some(state));
}
#[test]
fn version_state_tracks_in_progress_success_and_failure() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let active = temp.path().join("versions/previous");
let staging = temp.path().join(".staging/current");
let staging_snapshot = staging.join("official-sync-snapshot.json");
let published = temp.path().join("versions/current");
let previous_snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("previous")],
None,
);
let current_snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("current")],
Some(&fixture_bootstrap()),
);
let in_progress = prepare_in_progress_version_state(
&path,
&current_snapshot,
"current",
&active,
Some(&previous_snapshot),
&staging,
&staging_snapshot,
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"previous"
);
complete_version_state(
&path,
in_progress.clone(),
&published,
&published.join("official-sync-snapshot.json"),
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"current"
);
assert!(state.in_progress_version.is_none());
assert_eq!(
state.previous_available_version.as_ref().unwrap().id,
"previous"
);
let failed = prepare_in_progress_version_state(
&path,
&current_snapshot,
"broken",
&published,
state
.current_completed_version
.as_ref()
.map(|_| &current_snapshot),
&temp.path().join(".staging/broken"),
&temp
.path()
.join(".staging/broken/official-sync-snapshot.json"),
)
.unwrap();
fail_version_state(&path, failed, "download 403").unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert!(state.in_progress_version.is_none());
assert_eq!(state.failed_versions.len(), 1);
assert_eq!(state.failed_versions[0].version.id, "broken");
assert!(state.failed_versions[0].error.contains("403"));
}
#[test]
fn update_rejects_dangerous_output_root_before_network_work() {
let config = OfficialUpdateConfig {