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

+305
View File
@@ -0,0 +1,305 @@
//! 传输细节:单连接(含续传)+ 多连接分块,产出「已校验的完整中间文件」。
//!
//! 本文件只管「一个候选源的一次尝试」——换源、退避、限流在 `engine.rs`。
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use super::atomic::PartFile;
use super::cache::now_ms;
use super::chunked;
use super::engine::{AttemptOk, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::http;
use super::progress::{ProgressEvent, ProgressThrottle};
use super::resume::{self, RestartReason, ResumeDecision, ResumeMeta};
use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo};
use super::verify;
/// 续传起点(无续传时全为默认值)。
struct ResumeCut {
/// 已下字节数。
offset: u64,
/// 续传起点(事件字段)。
resumed_from: u64,
/// `If-Range` 的取值。
if_range: Option<String>,
}
impl ResumeCut {
/// 从头下载。
const fn none() -> Self {
Self {
offset: 0,
resumed_from: 0,
if_range: None,
}
}
}
impl DownloadEngine {
/// 一次尝试:续传判定 → 分块 or 单连接 → 流式校验。
pub(crate) async fn attempt(
&self,
id: DownloadId,
req: &DownloadRequest,
candidate: &Candidate,
part: &mut PartFile,
) -> Result<AttemptOk, DownloadError> {
let integrity = req.integrity.as_ref();
let meta_path = part.meta_path();
let previous = ResumeMeta::load(&meta_path);
// 「没有中间文件」和「有但 0 字节」是两种不同的原因,分开报出来便于诊断。
let decision = if !part.exists() {
ResumeDecision::Restart {
reason: RestartReason::NoPart,
}
} else if part.is_empty() {
ResumeDecision::Restart {
reason: RestartReason::EmptyPart,
}
} else {
resume::decide(
req.resume,
part.len(),
previous.as_ref(),
&candidate.url,
integrity.map(|item| (item.algo, item.hex.as_str())),
req.expected_size,
)
};
let mut cut = ResumeCut::none();
match decision {
ResumeDecision::Complete { size } => {
self.verify_part(part, integrity, id)?;
return Ok(AttemptOk {
bytes: size,
resumed_from: size,
});
}
ResumeDecision::Resume { offset, validator } => {
if let Some(value) = validator.if_range_value() {
cut = ResumeCut {
offset,
resumed_from: offset,
if_range: Some(value.to_string()),
};
}
}
ResumeDecision::Restart { .. } => {}
}
if cut.offset == 0 && self.cfg.chunked.applies_to(req.kind, req.expected_size) {
match self
.attempt_chunked(id, req, candidate, part, req.expected_size)
.await
{
Ok(ok) => return Ok(ok),
// Range 不支持/切不出足够段:静默退化为单连接(不算源失败)。
Err(err) if err.kind == DownloadErrorKind::RangeUnsupported => {}
Err(err) => return Err(err),
}
}
self.attempt_single(id, req, candidate, part, cut).await
}
async fn attempt_single(
&self,
id: DownloadId,
req: &DownloadRequest,
candidate: &Candidate,
part: &mut PartFile,
cut: ResumeCut,
) -> Result<AttemptOk, DownloadError> {
let integrity = req.integrity.as_ref();
let mut offset = cut.offset;
let mut resumed_from = cut.resumed_from;
let mut request = self.http.get(&candidate.url);
if offset > 0 {
match cut.if_range.as_deref() {
Some(value) => {
request = request
.header(reqwest::header::RANGE, format!("bytes={offset}-"))
.header(reqwest::header::IF_RANGE, value);
}
// 没有校验器就不能保证远端内容没变,直接从头下。
None => {
offset = 0;
resumed_from = 0;
}
}
}
let response = request.send().await.map_err(|err| {
http::classify_reqwest(&err, &candidate.url).with_source(candidate.source.as_str())
})?;
let status = response.status();
if status.as_u16() == 416 {
return match resume::on_range_unsatisfiable(part.len(), req.expected_size) {
ResumeDecision::Complete { size } => {
self.verify_part(part, integrity, id)?;
Ok(AttemptOk {
bytes: size,
resumed_from: size,
})
}
_ => {
part.discard();
Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
"416:丢弃中间文件重下",
)
.with_source(candidate.source.as_str()))
}
};
}
if !status.is_success() {
return Err(http::classify_status(status.as_u16(), &candidate.url)
.with_source(candidate.source.as_str()));
}
// 2xx 不等于「拿到归档」:镜像门户对任意路径都回 200 + HTML(华为云实测)。
// 归到 NotFound(换源,不算源坏不进冷却),否则假阳性源会一直挡在队首。
if let Some(reason) =
http::html_mirror_reason(response.headers(), req.expected_size, offset)
{
return Err(DownloadError::new(DownloadErrorKind::NotFound, reason)
.with_source(candidate.source.as_str()));
}
// 服务器无视了 Range(200 而非 206)→ 从头写。
if offset > 0 && status.as_u16() != 206 {
offset = 0;
resumed_from = 0;
}
let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE)
.and_then(chunked::parse_content_range);
let etag = http::etag(response.headers());
let last_modified = http::last_modified(response.headers());
let content_length = http::content_length(response.headers());
let mut total = req.expected_size;
if let Some(content_range) = content_range {
if content_range.start != offset {
// 服务器给的起点和我们以为的不一样:偏移不可信,从头来。
offset = 0;
resumed_from = 0;
}
total = content_range.total.or(total);
}
total = total.or_else(|| content_length.map(|len| len + offset));
// 记下续传元数据(跨进程续传的凭据)。
let meta = ResumeMeta {
url: candidate.url.clone(),
etag,
last_modified,
total,
algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
saved_at_ms: now_ms(),
};
let _ = meta.save(&part.meta_path());
let std_file = if offset > 0 {
part.open_append()
} else {
part.open_truncate()
}
.map_err(|err| io_err("打开中间文件失败", &err))?;
let mut file = tokio::fs::File::from_std(std_file);
let mut hasher = integrity.map(|item| verify::Hasher::new(item.algo));
if offset > 0 {
if let Some(hasher) = hasher.as_mut() {
verify::update_hasher_from_file(hasher, part.path(), offset)
.map_err(|err| io_err("读取续传前缀失败", &err))?;
}
}
let mut throttle = ProgressThrottle::new(self.cfg.progress_throttle);
let mut written = offset;
let mut stream = response.bytes_stream();
loop {
let next = tokio::time::timeout(
self.cfg.read_timeout,
futures_util::StreamExt::next(&mut stream),
)
.await
.map_err(|_| {
DownloadError::new(
DownloadErrorKind::Timeout,
format!("读取超时({})", candidate.url),
)
.with_source(candidate.source.as_str())
})?;
let Some(chunk) = next else { break };
let chunk = chunk.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
if let Some(hasher) = hasher.as_mut() {
hasher.update(&chunk);
}
file.write_all(&chunk)
.await
.map_err(|err| io_err("写入中间文件失败", &err))?;
written += chunk.len() as u64;
if throttle.should_emit(Instant::now(), false) {
self.emit(ProgressEvent::Progress {
id,
downloaded: written,
total_bytes: total,
});
}
}
file.flush()
.await
.map_err(|err| io_err("刷新中间文件失败", &err))?;
// Windows 上打开中的文件不能被 rename,必须先关。
drop(file);
if let Some(total) = total {
if written != total {
return Err(DownloadError::new(
DownloadErrorKind::Network,
format!("长度不符:期望 {total} 实得 {written}({})", candidate.url),
)
.with_source(candidate.source.as_str()));
}
}
if let (Some(integrity), Some(hasher)) = (integrity, hasher) {
self.emit(ProgressEvent::Verifying {
id,
algo: integrity.algo,
expected_prefix: integrity.short_prefix().to_string(),
});
let actual = hasher.finish();
if !verify::matches(&integrity.hex, &actual) {
part.discard();
return Err(DownloadError::new(
DownloadErrorKind::Integrity,
format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
)
.with_source(candidate.source.as_str()));
}
}
self.emit(ProgressEvent::Progress {
id,
downloaded: written,
total_bytes: total,
});
Ok(AttemptOk {
bytes: written,
resumed_from,
})
}
}
fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
}