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

+235
View File
@@ -0,0 +1,235 @@
//! 断点续传的**保守判定**。
//!
//! 承 analyse/03 §5.4 与设计文档 §4 最后一行:「拒绝『能续传但会坏』场景」是 HMCL 做对的事。
//! 我们把它写成纯函数,判定条件全部显式:
//!
//! 1. 策略不是 `Never`;
//! 2. 中间文件存在且长度 > 0;
//! 3. 有上一次的元数据(URL、摘要、总长、校验器);
//! 4. URL 与期望摘要都没变;
//! 5. 总长已知且 `0 < 已下 < 总长`;
//! 6. 有 ETag 或 Last-Modified 作为校验器(否则服务器换了内容我们也发现不了)。
//!
//! 任何一条不满足就**从头下**——重下 100MB 也比交付一个坏文件便宜。
//! 另外单独处理 416(`Requested Range Not Satisfiable`):已下满则直接进入校验,
//! 否则丢弃中间文件重来。
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::atomic::write_atomic;
use super::types::{HashAlgo, ResumePolicy};
/// 上一次下载留下的续传元数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResumeMeta {
/// 当时请求的 URL(镜像可能换掉,URL 变了就不续传)。
pub url: String,
/// 响应 `ETag`。
pub etag: Option<String>,
/// 响应 `Last-Modified`。
pub last_modified: Option<String>,
/// 响应告知的总长度。
pub total: Option<u64>,
/// 摘要算法。
pub algo: HashAlgo,
/// 期望摘要。
pub hash: String,
/// 保存时间(unix 毫秒)。
pub saved_at_ms: u64,
}
impl ResumeMeta {
/// 从磁盘读(不存在/损坏 → `None`)。
#[must_use]
pub fn load(path: &Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// 原子写。
pub fn save(&self, path: &Path) -> io::Result<()> {
let json = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
write_atomic(path, &json)
}
/// 删除(校验通过或放弃续传时)。
pub fn remove(path: &Path) {
let _ = std::fs::remove_file(path);
}
/// 本记录可用的校验器。
#[must_use]
pub fn validator(&self) -> Validator {
Validator {
etag: self.etag.clone(),
last_modified: self.last_modified.clone(),
}
}
}
/// 续传用的校验器(`If-Range` 头的取值来源)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Validator {
/// 强校验器。
pub etag: Option<String>,
/// 弱校验器。
pub last_modified: Option<String>,
}
impl Validator {
/// `If-Range` 头的值(ETag 优先,其次 Last-Modified)。
#[must_use]
pub fn if_range_value(&self) -> Option<&str> {
self.etag.as_deref().or(self.last_modified.as_deref())
}
}
/// 不复续传的原因(日志与诊断页用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartReason {
/// 策略要求从不续传。
PolicyNever,
/// 没有中间文件。
NoPart,
/// 中间文件是 0 字节。
EmptyPart,
/// 没有元数据(无法证明中间文件是同一次下载的产物)。
NoMeta,
/// URL 变了(换源后偏移不可信)。
UrlChanged,
/// 期望摘要变了(目标文件已经不是同一个东西)。
HashChanged,
/// 总长未知(无法判断是否已下满)。
TotalUnknown,
/// 已下字节超过总长(中间文件被污染)。
Oversize,
/// 没有 ETag/Last-Modified,无法证明远端内容没变。
NoValidator,
/// 服务器 416 且未下满。
RangeUnsatisfiable,
}
impl RestartReason {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PolicyNever => "policy_never",
Self::NoPart => "no_part",
Self::EmptyPart => "empty_part",
Self::NoMeta => "no_meta",
Self::UrlChanged => "url_changed",
Self::HashChanged => "hash_changed",
Self::TotalUnknown => "total_unknown",
Self::Oversize => "oversize",
Self::NoValidator => "no_validator",
Self::RangeUnsatisfiable => "range_unsatisfiable",
}
}
}
/// 续传决策。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResumeDecision {
/// 可以续传:从 `offset` 起发 `Range` + `If-Range`。
Resume {
/// 已下字节数。
offset: u64,
/// 校验器。
validator: Validator,
},
/// 从头下载。
Restart {
/// 原因。
reason: RestartReason,
},
/// 中间文件已经是完整长度:跳过传输,直接校验。
Complete {
/// 文件长度。
size: u64,
},
}
impl ResumeDecision {
/// 是否要丢掉中间文件(`Complete` 不能丢)。
#[must_use]
pub const fn should_drop_part(&self) -> bool {
matches!(self, Self::Restart { .. })
}
}
/// 判定是否续传(纯函数)。
#[must_use]
pub fn decide(
policy: ResumePolicy,
part_len: u64,
meta: Option<&ResumeMeta>,
url: &str,
integrity: Option<(HashAlgo, &str)>,
expected_size: Option<u64>,
) -> ResumeDecision {
if policy == ResumePolicy::Never {
return ResumeDecision::Restart {
reason: RestartReason::PolicyNever,
};
}
if part_len == 0 {
return ResumeDecision::Restart {
reason: RestartReason::EmptyPart,
};
}
let Some(meta) = meta else {
return ResumeDecision::Restart {
reason: RestartReason::NoMeta,
};
};
if meta.url != url {
return ResumeDecision::Restart {
reason: RestartReason::UrlChanged,
};
}
if let Some((algo, hash)) = integrity {
if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
return ResumeDecision::Restart {
reason: RestartReason::HashChanged,
};
}
}
let Some(total) = meta.total.or(expected_size) else {
return ResumeDecision::Restart {
reason: RestartReason::TotalUnknown,
};
};
if part_len > total {
return ResumeDecision::Restart {
reason: RestartReason::Oversize,
};
}
if part_len == total {
return ResumeDecision::Complete { size: total };
}
let validator = meta.validator();
if validator.if_range_value().is_none() {
return ResumeDecision::Restart {
reason: RestartReason::NoValidator,
};
}
ResumeDecision::Resume {
offset: part_len,
validator,
}
}
/// 收到 416 后的决策。
#[must_use]
pub fn on_range_unsatisfiable(part_len: u64, total: Option<u64>) -> ResumeDecision {
match total {
Some(total) if part_len == total => ResumeDecision::Complete { size: total },
_ => ResumeDecision::Restart {
reason: RestartReason::RangeUnsatisfiable,
},
}
}