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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user