Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
//! 迁移收据:指纹 + 幂等,防重复迁移 / 半迁移(HMCL `MigrationReceipt` 思想)。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8——
//! 收据里存源文件 `sourceSHA256`,这样「用户删掉新文件、但旧文件没变」时不会重复迁移,旧文件变了才重放。
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::atomic;
use super::error::PlatformError;
use super::error::PlatformResult;
use super::hash;
/// 本实现能读懂的收据版本。
pub const RECEIPT_SCHEMA_VERSION: u32 = 1;
/// 收据类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptKind {
/// 数据目录迁移。
DataDirMove,
/// 遗留配置格式迁移。
LegacyConfig,
}
impl ReceiptKind {
/// 稳定名。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::DataDirMove => "data_dir_move",
Self::LegacyConfig => "legacy_config",
}
}
}
/// 一次已完成的迁移记录。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationReceipt {
/// 格式版本。
pub schema_version: u32,
/// 收据类型。
pub kind: ReceiptKind,
/// 源指纹(普通配置迁移 = 源文件内容 SHA-256;目录迁移 = 源/目标路径签名)。
pub source_fingerprint: String,
/// 源路径。
pub source: PathBuf,
/// 目标路径。
pub target: PathBuf,
/// 完成时间(毫秒 since UNIX_EPOCH)。
pub applied_at_ms: u64,
}
impl MigrationReceipt {
/// 构造数据目录迁移收据。
#[must_use]
pub fn data_dir_move(source: PathBuf, target: PathBuf, applied_at_ms: u64) -> Self {
let fingerprint = fingerprint_data_dir_move(&source, &target);
Self {
schema_version: RECEIPT_SCHEMA_VERSION,
kind: ReceiptKind::DataDirMove,
source_fingerprint: fingerprint,
source,
target,
applied_at_ms,
}
}
/// 构造遗留配置迁移收据。
#[must_use]
pub fn legacy_config(
source: PathBuf,
target: PathBuf,
source_fingerprint: impl Into<String>,
applied_at_ms: u64,
) -> Self {
Self {
schema_version: RECEIPT_SCHEMA_VERSION,
kind: ReceiptKind::LegacyConfig,
source_fingerprint: source_fingerprint.into(),
source,
target,
applied_at_ms,
}
}
}
/// 是否该重放这次迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplayDecision {
/// 收据在且源指纹相同 → 已经迁过,跳过(防重放)。
SkipSameSource,
/// 收据在但源指纹变了 → 允许重放。
ReplayChangedSource,
/// 没有收据 → 正常迁移。
ReplayNoReceipt,
}
impl ReplayDecision {
/// 是否需要执行迁移。
#[must_use]
pub const fn should_replay(&self) -> bool {
!matches!(self, Self::SkipSameSource)
}
/// 稳定名(日志 / 审计)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::SkipSameSource => "skip_same_source",
Self::ReplayChangedSource => "replay_changed_source",
Self::ReplayNoReceipt => "replay_no_receipt",
}
}
}
/// 根据已有收据与当前源指纹决定是否重放。
#[must_use]
pub fn decide(existing: Option<&MigrationReceipt>, fingerprint: &str) -> ReplayDecision {
match existing {
None => ReplayDecision::ReplayNoReceipt,
Some(receipt) if receipt.source_fingerprint == fingerprint => {
ReplayDecision::SkipSameSource
}
Some(_) => ReplayDecision::ReplayChangedSource,
}
}
/// 读收据;不存在返回 `None`。
///
/// # Errors
/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError::ReceiptCorrupt`] / [`PlatformError::Io`]。
pub fn read(path: &Path) -> PlatformResult<Option<MigrationReceipt>> {
let Some(bytes) = atomic::read_optional(path)? else {
return Ok(None);
};
let receipt: MigrationReceipt =
serde_json::from_slice(&bytes).map_err(|err| PlatformError::ReceiptCorrupt {
path: path.to_path_buf(),
reason: err.to_string(),
})?;
if receipt.schema_version > RECEIPT_SCHEMA_VERSION {
return Err(PlatformError::ReceiptCorrupt {
path: path.to_path_buf(),
reason: format!(
"收据版本 {} 高于本实现支持的 {RECEIPT_SCHEMA_VERSION}",
receipt.schema_version
),
});
}
Ok(Some(receipt))
}
/// 原子写收据。
///
/// # Errors
/// 序列化或写盘失败时返回 [`PlatformError`]。
pub fn write(path: &Path, receipt: &MigrationReceipt) -> PlatformResult<()> {
let bytes =
serde_json::to_vec_pretty(receipt).map_err(|err| PlatformError::ReceiptCorrupt {
path: path.to_path_buf(),
reason: format!("无法序列化收据: {err}"),
})?;
atomic::write_atomic(path, &bytes)
}
/// 内容指纹(SHA-256 十六进制)。
#[must_use]
pub fn fingerprint_bytes(bytes: &[u8]) -> String {
hash::sha256_hex(bytes)
}
/// 文件内容指纹;文件不存在返回 `None`。
///
/// # Errors
/// 读失败时返回 [`PlatformError::Io`]。
pub fn fingerprint_file(path: &Path) -> PlatformResult<Option<String>> {
match hash::sha256_hex_file(path) {
Ok(hex) => Ok(Some(hex)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(PlatformError::Io {
path: path.to_path_buf(),
source: err,
}),
}
}
/// 目录迁移的源指纹:源/目标路径签名。
///
/// 目录内容的哈希代价太高且天然易变(缓存/日志一直在动),因此目录迁移的抗重放主力是
/// [`super::redirect`] 指针 + 「目标非空则拒绝」,收据负责留痕与「同源同目标不重做」。
#[must_use]
pub fn fingerprint_data_dir_move(source: &Path, target: &Path) -> String {
let signature = format!(
"{}|{}",
super::paths::to_portable_string(source),
super::paths::to_portable_string(target)
);
fingerprint_bytes(signature.as_bytes())
}