Files
Unlucky-Launcher/crates/core/src/download/transfer.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

306 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 传输细节:单连接(含续传)+ 多连接分块,产出「已校验的完整中间文件」。
//!
//! 本文件只管「一个候选源的一次尝试」——换源、退避、限流在 `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}"))
}