fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门

P1-1(p1-1items 的根因):
- download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与
  `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有
  Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」
  取最可诊断的那个(**不从 suppressed 文本猜状态码**)。
- download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。
- online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是
  NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。
- import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开;
  `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。
- import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN,
  Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此
  只会在真断网时出现。

P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用
`core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`;
`HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`,
`InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。

P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回
`content_install_request`,详情方法只留自己那一行。

P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的
项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到
结果清掉。

顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过;
两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的
ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。

用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条
(AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、
单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」
(共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
This commit is contained in:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 81278f55af
commit b394316df4
30 files changed
+658 -172

No files matched your search

+10 -7
View File
@@ -166,14 +166,10 @@ impl App {
.is_some_and(|results| results.curseforge)
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
///
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
///
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
@@ -190,6 +186,13 @@ impl App {
})
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
///
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
fn content_install_request(&self) -> Option<ContentInstallRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
@@ -236,23 +236,26 @@ fn versions_come_from_the_detail_channel_of_the_selected_project() {
assert!(model.available_versions.is_empty());
assert!(model.versions_note.is_some());
state.content.apply_detail(ContentDetail {
project: "p1".to_string(),
title: "Sodium".to_string(),
description: "fast".to_string(),
body: String::new(),
versions: vec![ContentVersionView {
id: "v1".to_string(),
name: "Sodium 0.6.0".to_string(),
number: "mc1.20.1-0.6.0".to_string(),
kind: "release".to_string(),
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
published: "2026-02-01T00:00:00Z".to_string(),
file_name: Some("sodium.jar".to_string()),
file_size: Some(2048),
}],
});
state.content.apply_detail(
"p1",
ContentDetail {
project: "p1".to_string(),
title: "Sodium".to_string(),
description: "fast".to_string(),
body: String::new(),
versions: vec![ContentVersionView {
id: "v1".to_string(),
name: "Sodium 0.6.0".to_string(),
number: "mc1.20.1-0.6.0".to_string(),
kind: "release".to_string(),
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
published: "2026-02-01T00:00:00Z".to_string(),
file_name: Some("sodium.jar".to_string()),
file_size: Some(2048),
}],
},
);
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert_eq!(model.available_versions.len(), 1);
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
@@ -98,6 +98,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
bytes_total: 0,
speed_bps: 0,
eta_secs: 0,
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
failure_kind: None,
message: progress.current.clone().unwrap_or_default(),
}
}
@@ -295,6 +295,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
speed_bps: if failed { 0 } else { 1_258_291 },
eta_secs: if failed { 0 } else { 75 },
message: String::new(),
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
failure_kind: None,
},
catalog,
)
+3 -1
View File
@@ -82,7 +82,9 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan)
}
}
Err(error) => {
if error.kind() == FetchErrorKind::Network {
// 只有**真正的「没联网」**才提前收工:404(这条不在 Modrinth 上,或托管在
// CF CDN)与 5xx 都只影响这一条,后面那些仍然可能补得出来。
if error.kind() == FetchErrorKind::Offline {
state.offline = true;
}
state.unfilled += 1;
+4 -3
View File
@@ -176,9 +176,10 @@ impl From<FetchError> for ImportError {
let kind = match error.kind() {
FetchErrorKind::Integrity => ImportErrorKind::Integrity,
FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io),
FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => {
ImportErrorKind::Fetch
}
FetchErrorKind::NotFound
| FetchErrorKind::Network
| FetchErrorKind::Offline
| FetchErrorKind::Unsupported => ImportErrorKind::Fetch,
};
Self {
kind,
+16 -6
View File
@@ -44,8 +44,11 @@ use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind};
pub enum FetchErrorKind {
/// 源上确实没有(404 / 文件被删)。
NotFound,
/// 网络侧失败(超时、连不上、所有候选源都失败)。
/// 网络侧失败(超时、连不上、站点 5xx 退避后仍失败)。
Network,
/// **没联网**:连不上任何候选源(与 `Network` 分开,是因为「补全」见到它要立刻停,
/// 而见到 404 / 5xx 不该停 —— 下一条可能补得出来)。
Offline,
/// 本地 IO(写盘失败、磁盘满)。
Io,
/// 内容与声明的摘要不符。
@@ -61,6 +64,7 @@ impl FetchErrorKind {
match self {
Self::NotFound => "fetch_not_found",
Self::Network => "fetch_network",
Self::Offline => "fetch_offline",
Self::Io => "fetch_io",
Self::Integrity => "fetch_integrity",
Self::Unsupported => "fetch_unsupported",
@@ -70,7 +74,10 @@ impl FetchErrorKind {
/// 是否属于"某个文件搞不到"(可容忍,实例照装)。
#[must_use]
pub const fn is_tolerable(self) -> bool {
matches!(self, Self::NotFound | Self::Network | Self::Unsupported)
matches!(
self,
Self::NotFound | Self::Network | Self::Offline | Self::Unsupported
)
}
}
@@ -126,14 +133,15 @@ impl std::error::Error for FetchError {}
impl From<DownloadError> for FetchError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
// 用**叶子成因**(`effective_kind`):候选源循环结束时只有一条 `AllSourcesFailed`,
// 直接按它判定会把「这个文件不存在」也算成「网络失败」。
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
DownloadErrorKind::Io => FetchErrorKind::Io,
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
DownloadErrorKind::Timeout | DownloadErrorKind::Network => FetchErrorKind::Offline,
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
};
@@ -148,7 +156,9 @@ impl From<OnlineError> for FetchError {
OnlineErrorKind::Integrity | OnlineErrorKind::Malformed => FetchErrorKind::Integrity,
OnlineErrorKind::Io => FetchErrorKind::Io,
OnlineErrorKind::Unsupported => FetchErrorKind::Unsupported,
OnlineErrorKind::NotConnected | OnlineErrorKind::Server => FetchErrorKind::Network,
// 「没联网」要能被「补全立刻停」认出来;站点 5xx 只是这一条补不出。
OnlineErrorKind::NotConnected => FetchErrorKind::Offline,
OnlineErrorKind::Server => FetchErrorKind::Network,
};
Self::new(kind, error.message().to_string())
}
+28 -16
View File
@@ -122,8 +122,17 @@ pub async fn download_verified(
if url.trim().is_empty() {
return Err(OnlineError::unsupported("没有可用下载地址"));
}
let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?;
let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?;
// 两档摘要**各自**过形状检查:坏掉的那一档丢掉,用另一档校验(mrpack 的权威摘要是
// sha512,CLI/整合包都可能给出一个人写坏的 sha1——为此整条不装是过分惩罚)。
// 但如果**声明过**摘要却一档都用不上,就必须失败:那是「没有任何校验」的伪装。
let declared = sha1.is_some() || sha512.is_some();
let sha1 = usable_hash(sha1, HashAlgorithm::Sha1);
let sha512 = usable_hash(sha512, HashAlgorithm::Sha512);
if declared && sha1.is_none() && sha512.is_none() {
return Err(OnlineError::unsupported(
"声明的 sha1/sha512 都不是合法摘要,无法校验(拒绝在「没有校验」的情况下落盘)",
));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
OnlineError::new(
@@ -165,7 +174,14 @@ pub async fn download_verified(
continue;
}
}
let digest = digest_file(dest)?;
let digest = match digest_file(dest) {
Ok(digest) => digest,
Err(error) => {
// 与其它失败路径一致:读不出来就不许把文件留在盘上。
remove_quiet(dest);
return Err(error);
}
};
if let Some(expected) = sha1 {
if !equals_hex(expected, &digest.sha1) {
last = Some(OnlineError::integrity(format!(
@@ -239,19 +255,15 @@ pub fn digest_file(path: &Path) -> Result<Digest, OnlineError> {
/// 摘要形状合法吗(长度 + 十六进制)。
///
/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`equals_hex`]。
fn check_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Result<Option<&str>, OnlineError> {
match raw.map(str::trim) {
None => Ok(None),
Some("") => Ok(None),
Some(hex) if algorithm.accepts(hex) => Ok(Some(hex)),
Some(hex) => Err(OnlineError::unsupported(format!(
"声明的 {} 不是合法摘要:{:?}(要求 {} 位十六进制)",
algorithm.as_str(),
hex,
algorithm.hex_len()
))),
}
/// 这一档摘要能用吗:形状合法就原样返回(不复制、不归一,大小写不敏感比较交给
/// [`equals_hex`]),形状不合法就当「没给」。
///
/// 为什么丢掉而不是报错:`sha1` 与 `sha512` 是同一个文件的两档校验,**任一**都能证明内容;
/// 一档写坏不该让整条装不上(r1 顺手项)。两档都不可用时由 `download_verified` 统一拒绝。
fn usable_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Option<&str> {
raw.map(str::trim)
.filter(|hex| !hex.is_empty())
.filter(|hex| algorithm.accepts(hex))
}
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
@@ -0,0 +1,89 @@
//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。
//!
//! 这一族的价值全在「经过真的 `From<DownloadError>`」:直接给 `OnlineError` 造一个
//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射,
//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。
use crate::addon::online::{OnlineError, OnlineErrorKind};
use crate::download::{DownloadError, DownloadErrorKind};
#[test]
fn an_all_sources_failed_with_a_404_leaf_is_not_found() {
// 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。
let error = DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
"所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...",
)
.with_suppressed(vec!["official: HTTP 404".to_string()])
.with_leaf(DownloadErrorKind::NotFound);
let online: OnlineError = error.into();
assert_eq!(
online.kind(),
OnlineErrorKind::NotFound,
"项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」"
);
assert!(!online.is_offline());
}
#[test]
fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() {
let online: OnlineError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into();
assert_eq!(online.kind(), OnlineErrorKind::NotConnected);
assert!(online.is_offline());
}
#[test]
fn leaf_kinds_map_one_to_one() {
let cases = [
(DownloadErrorKind::NotFound, OnlineErrorKind::NotFound),
(DownloadErrorKind::ServerError, OnlineErrorKind::Server),
(DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Network, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Integrity, OnlineErrorKind::Integrity),
(DownloadErrorKind::Io, OnlineErrorKind::Io),
(
DownloadErrorKind::InvalidRequest,
OnlineErrorKind::Unsupported,
),
(
DownloadErrorKind::RangeUnsupported,
OnlineErrorKind::Malformed,
),
];
for (leaf, expected) in cases {
let error =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf);
let online: OnlineError = error.into();
assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str());
}
}
#[test]
fn a_single_source_failure_uses_its_own_kind() {
// 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。
let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into();
assert_eq!(online.kind(), OnlineErrorKind::NotFound);
}
#[test]
fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() {
// 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。
use crate::addon::import::FetchErrorKind;
let not_found: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound)
.into();
assert_eq!(not_found.kind(), FetchErrorKind::NotFound);
assert!(not_found.kind().is_tolerable());
let offline: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::Network)
.into();
assert_eq!(offline.kind(), FetchErrorKind::Offline);
assert!(
offline.kind().is_tolerable(),
"没联网仍然是「这条文件搞不到」,实例照装"
);
}
@@ -155,6 +155,26 @@ fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
assert!(!dest.exists());
}
#[test]
fn a_malformed_sha1_is_dropped_and_the_sha512_still_guards_the_file() {
// r1 顺手项:sha1 形状写坏不该让整条装不上——sha512 才是 mrpack 的权威摘要。
let root = TempRoot::new("online-bad-sha1-good-sha512");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (_, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some("not-a-sha1-at-all"),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("sha512 合法就该能校验并装下");
assert_eq!(done.digest.sha512, sha512);
assert!(dest.exists());
}
#[test]
fn an_empty_url_is_unsupported() {
let root = TempRoot::new("online-no-url");
@@ -7,6 +7,7 @@
//! M162 报告的「没验的部分」里。
mod api;
mod classify;
mod complete;
mod download;
mod fixtures;
+28 -9
View File
@@ -139,21 +139,40 @@ impl std::error::Error for OnlineError {}
/// `DownloadEngine` 的错误 → 本模块的错误分类。
///
/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络);
/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。
/// # 为什么读 `effective_kind()` 而不是 `kind`
///
/// 引擎的候选源循环结束时只留下一条 [`DownloadErrorKind::AllSourcesFailed`],404 / 429 / 5xx /
/// 超时这些**叶子**成因会被折叠掉(`crates/core/src/download/bytes.rs` 的收尾)。
/// 如果直接按 `kind` 判定,`NotFound` / `Server` / `Integrity` 三档在本模块的生产路径上
/// **永远不可达**:项目被删、id 拼错、被限速都会画成「未联网」(r1 P1-1 后果 A)。
///
/// 引擎现在会把汇总出来的叶子 kind 挂在 `DownloadError::leaf` 上,这里按叶子映射;
/// 没有叶子(单源直失败 / 手工构造)就按自身 kind。**不从 `suppressed` 文本里猜状态码。**
///
/// # 分类口径
///
/// | 引擎侧 | 本模块 | 上层该怎么说 |
/// |--------|--------|--------------|
/// | `NotFound`(404/410/403,全源都是) | [`OnlineErrorKind::NotFound`] | 「查无此项 / 换个关键词」 |
/// | `ServerError`(5xx / 429) | [`OnlineErrorKind::Server`] | 「站点那边出错了」 |
/// | `Network` / `Timeout` | [`OnlineErrorKind::NotConnected`] | 「未联网,检查网络」 |
/// | `AllSourcesFailed`(没有叶子) | [`OnlineErrorKind::NotConnected`] | 同上(保守) |
/// | `Integrity` | [`OnlineErrorKind::Integrity`] | 「内容不对」 |
impl From<DownloadError> for OnlineError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => OnlineErrorKind::NotFound,
DownloadErrorKind::ServerError => OnlineErrorKind::Server,
DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity | DownloadErrorKind::RangeUnsupported => {
OnlineErrorKind::Malformed
DownloadErrorKind::Timeout | DownloadErrorKind::Network => {
OnlineErrorKind::NotConnected
}
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity => OnlineErrorKind::Integrity,
DownloadErrorKind::RangeUnsupported => OnlineErrorKind::Malformed,
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
// 没有叶子 ⇒ 分不清「没网」与「站点挂了」,按可重试的「没联网」保守处理:
// 上层至少会提示「检查网络后重试」,而不是宣称「没有匹配的内容」。
DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
};
Self::new(kind, error.message)
}
@@ -143,3 +143,80 @@ fn a_wrong_completed_sha1_fails_the_import_instead_of_installing() {
);
assert!(!target.join(PATH).exists(), "失败的导入不许留文件");
}
// ---------------------------------------------------------------- r1 P1-1 后果 B 的守卫
const CF_SHA512: &str = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1\
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1";
/// 两条远端条目:第一条**站点不认得**(等价于托管在 CurseForge CDN),第二条认得。
///
/// 真实 mrpack 大多是这个形状——所以「第一条补不出就掐掉整轮」是 r1 抓到的后果 B。
fn pack_with_one_unknown_sha512(tag: &str) -> (TempRoot, std::path::PathBuf) {
let root = TempRoot::new(tag);
let dir = root.join("src");
let index = format!(
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"混托管",
"files":[
{{"path":"mods/hosted-on-cf.jar","hashes":{{"sha512":"{CF_SHA512}"}},
"downloads":["stub://mods/hosted-on-cf.jar"],"fileSize":10}},
{{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}},
"downloads":["{DOWNLOAD}"],"fileSize":{size}}}
],
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
size = PACK_BYTES.len()
);
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
(root, pack)
}
#[test]
fn a_404_on_one_entry_does_not_stop_the_completion_round() {
// 第一次查(CF 那一条)站点回 404 ⇒ **不许**停:第二条仍然要补出来并参与判定。
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-hosting");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&sha1(PACK_BYTES)));
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect();
assert_eq!(
paths,
vec![PATH],
"第二条(托管在 Modrinth 的那条)必须补出来并判出冲突:{:?}",
paths
);
assert_eq!(scan.unknown_remote, 1, "补不出的那一条仍如实计数");
assert!(
!scan
.notes
.iter()
.any(|note| note.message.contains("未联网")),
"404 不是「没联网」,注记不许这么说:{:?}",
scan.notes.iter().map(|n| &n.message).collect::<Vec<_>>()
);
}
#[test]
fn only_a_real_offline_stops_the_completion_round() {
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-offline");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
.offline_modrinth();
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了");
assert!(scan.conflicts.is_empty());
}
+15
View File
@@ -55,6 +55,8 @@ pub(crate) struct StubFetcher {
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。
modrinth: BTreeMap<String, ModrinthFileMeta>,
/// M162:补全时假装「没联网」(与「站点不认得这个摘要」区分开)。
modrinth_offline: bool,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。
@@ -68,6 +70,7 @@ impl StubFetcher {
files: BTreeMap::new(),
curse: BTreeMap::new(),
modrinth: BTreeMap::new(),
modrinth_offline: false,
calls: Mutex::new(Vec::new()),
artifacts: false,
}
@@ -91,6 +94,12 @@ impl StubFetcher {
self
}
/// 让补全路径假装「没联网」(`FetchErrorKind::Offline`)。
pub(crate) fn offline_modrinth(mut self) -> Self {
self.modrinth_offline = true;
self
}
/// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。
pub(crate) fn with_engine_artifacts(mut self) -> Self {
self.artifacts = true;
@@ -127,6 +136,12 @@ impl PackFetcher for StubFetcher {
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
if self.modrinth_offline {
return Err(super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Offline,
"连不上 api.modrinth.com",
));
}
self.modrinth.get(sha512).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
+8 -2
View File
@@ -7,7 +7,7 @@ use std::time::Instant;
use super::cache::now_ms;
use super::engine::{millis, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::error::{leaf_of, DownloadError, DownloadErrorKind};
use super::http;
use super::progress::ProgressEvent;
use super::types::{Candidate, DownloadId, DownloadRequest};
@@ -33,6 +33,8 @@ impl DownloadEngine {
let _permit = self.acquire().await?;
let mut suppressed: Vec<String> = Vec::new();
// 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。
let mut kinds: Vec<DownloadErrorKind> = Vec::new();
for candidate in &plan {
// 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
@@ -74,6 +76,7 @@ impl DownloadEngine {
retry_in_ms: if retry { millis(delay) } else { 0 },
});
if !retry {
kinds.push(err.kind);
suppressed.push(format!("{}: {}", candidate.source, err.message));
break;
}
@@ -84,11 +87,14 @@ impl DownloadEngine {
}
self.flush_health();
// 叶子成因要跟着出去:上层(在线内容源的 `Transport`)靠它区分「这个 URL 不存在」
// 与「没联网」,只留 `AllSourcesFailed` 的话那套分类在生产路径上永远不可达。
Err(DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
format!("所有候选源都取不到文本资源:{}", req.url),
)
.with_suppressed(suppressed))
.with_suppressed(suppressed)
.with_leaf(leaf_of(kinds)))
}
async fn fetch_bytes(
+125
View File
@@ -70,6 +70,18 @@ pub struct DownloadError {
pub attempts: usize,
/// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
pub suppressed: Vec<String>,
/// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底
/// 是怎么挂的。
///
/// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、
/// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」——
/// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里
/// 反解状态码是靠文案猜,改一句提示就失效。
///
/// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`;
/// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。
/// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。
pub leaf: Option<DownloadErrorKind>,
}
impl DownloadError {
@@ -82,6 +94,7 @@ impl DownloadError {
message: message.into(),
attempts: 0,
suppressed: Vec::new(),
leaf: None,
}
}
@@ -105,6 +118,62 @@ impl DownloadError {
self.suppressed = suppressed;
self
}
/// 标注整体失败之前的叶子成因。
#[must_use]
pub const fn with_leaf(mut self, leaf: DownloadErrorKind) -> Self {
self.leaf = Some(leaf);
self
}
/// 最可诊断的成因:有叶子就用叶子,否则用自身的 kind。
///
/// 上层(`addon::online::transport` / `addon::import::fetch`)靠这一处判定,不各自
/// 再写一遍「AllSourcesFailed 到底算哪一类」。
#[must_use]
pub fn effective_kind(&self) -> DownloadErrorKind {
if self.kind == DownloadErrorKind::AllSourcesFailed {
return self.leaf.unwrap_or(self.kind);
}
self.kind
}
}
/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。
///
/// 规则(与 `online::transport` 的映射表配套,判据只此一处):
///
/// | 各源的情况 | 叶子 |
/// |------------|------|
/// | 全部 404/410/403(`NotFound`) | `NotFound` |
/// | 出现 `Network` / `Timeout` | `Network` |
/// | 全部 5xx(`ServerError`) | `ServerError` |
/// | 其它(`Integrity` / `Io` / `InvalidRequest`) | 该条自己的 kind(后出现的赢) |
/// | 混合了 404 与 5xx | `ServerError`(比「不存在」更可诊断) |
///
/// `kinds` 为空时返回 `AllSourcesFailed`(调用方据此保留原 kind)。
#[must_use]
pub fn leaf_of(kinds: impl IntoIterator<Item = DownloadErrorKind>) -> DownloadErrorKind {
let mut leaf = DownloadErrorKind::AllSourcesFailed;
let mut rank = 0u8;
for kind in kinds {
let (candidate, weight) = match kind {
DownloadErrorKind::Network | DownloadErrorKind::Timeout => {
(DownloadErrorKind::Network, 4)
}
DownloadErrorKind::ServerError => (DownloadErrorKind::ServerError, 3),
DownloadErrorKind::Integrity => (DownloadErrorKind::Integrity, 3),
DownloadErrorKind::Io => (DownloadErrorKind::Io, 3),
DownloadErrorKind::InvalidRequest => (DownloadErrorKind::InvalidRequest, 3),
DownloadErrorKind::NotFound => (DownloadErrorKind::NotFound, 1),
DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => (kind, 2),
};
if weight >= rank {
rank = weight;
leaf = candidate;
}
}
leaf
}
impl fmt::Display for DownloadError {
@@ -123,3 +192,59 @@ impl fmt::Display for DownloadError {
}
impl std::error::Error for DownloadError {}
#[cfg(test)]
mod tests {
use super::{leaf_of, DownloadError, DownloadErrorKind};
/// 四档叶子成因(tower 定案的门禁):全 404 / 有网络错 / 全 5xx / 混合。
#[test]
fn leaf_keeps_the_most_diagnosable_cause() {
use DownloadErrorKind::{Network, NotFound, ServerError, Timeout};
assert_eq!(leaf_of([NotFound, NotFound]), NotFound, "全 404 ⇒ NotFound");
assert_eq!(
leaf_of([Network, NotFound]),
Network,
"有网络错 ⇒ Network(哪怕另一源是 404)"
);
assert_eq!(
leaf_of([ServerError, ServerError]),
ServerError,
"全 5xx ⇒ ServerError"
);
assert_eq!(
leaf_of([NotFound, ServerError]),
ServerError,
"混合 404 与 5xx ⇒ 取更可诊断的那个"
);
assert_eq!(leaf_of([Timeout, NotFound]), Network, "超时归到 Network");
assert_eq!(
leaf_of([]),
DownloadErrorKind::AllSourcesFailed,
"一条都没有 ⇒ 保留原 kind"
);
}
/// 整体失败时上层靠 `effective_kind()` 判定;单源失败时就是它自己的 kind。
#[test]
fn effective_kind_prefers_the_leaf_only_for_all_sources_failed() {
let leaf = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound);
assert_eq!(leaf.effective_kind(), DownloadErrorKind::NotFound);
assert_eq!(
leaf.kind,
DownloadErrorKind::AllSourcesFailed,
"kind 本身不改"
);
let bare = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败");
assert_eq!(
bare.effective_kind(),
DownloadErrorKind::AllSourcesFailed,
"没有叶子 ⇒ 原样返回(上层再决定当哪一类)"
);
let single = DownloadError::new(DownloadErrorKind::NotFound, "404");
assert_eq!(single.effective_kind(), DownloadErrorKind::NotFound);
}
}
+2 -2
View File
@@ -54,8 +54,8 @@ macro_rules! content_entries {
ContentFailedBody => ("Check the network, then retry.", "检查网络后重试。"),
ContentOfflineTitle => ("No connection", "未联网"),
ContentOfflineBody => (
"Content search needs the network and nothing is cached yet. Check your connection, then retry.",
"内容检索需要联网,本地还没有缓存。检查网络后重试。"
"Content search needs the network. Check your connection, then retry.",
"内容检索需要联网。检查网络后重试。"
),
ContentRetry => ("Retry", "重试"),
ContentLoadMore => ("Load more", "加载更多"),
+1 -1
View File
@@ -181,7 +181,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("ContentFailedTitle", "Could not load content", "内容加载失败"),
("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"),
("ContentOfflineTitle", "No connection", "未联网"),
("ContentOfflineBody", "Content search needs the network and nothing is cached yet. Check your connection, then retry.", "内容检索需要联网,本地还没有缓存。检查网络后重试。"),
("ContentOfflineBody", "Content search needs the network. Check your connection, then retry.", "内容检索需要联网。检查网络后重试。"),
("ContentRetry", "Retry", "重试"),
("ContentLoadMore", "Load more", "加载更多"),
("ContentNoMore", "No more results", "没有更多了"),
+26 -5
View File
@@ -308,8 +308,10 @@ pub struct ContentState {
pub last_page_filled: bool,
/// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过)
pub detail: Option<Box<ContentDetail>>,
/// 详情请求在飞
/// 详情请求在飞(哪个项目:迟到结果靠它判「还算不算当前这次」)
pub detail_pending: bool,
/// 在飞的那次详情请求的项目 id(`None` = 没有在飞)
pub detail_requested: Option<String>,
/// 最近一次详情请求的失败
pub detail_failed: Option<ContentFailure>,
}
@@ -324,6 +326,7 @@ impl Default for ContentState {
last_page_filled: false,
detail: None,
detail_pending: false,
detail_requested: None,
detail_failed: None,
}
}
@@ -340,22 +343,40 @@ impl ContentState {
self.detail = None;
}
self.detail_pending = true;
self.detail_requested = Some(project.to_string());
self.detail_failed = None;
}
/// 详情落地(迟到的结果(换了选中项)由调用方按 `project` 判)。
pub fn apply_detail(&mut self, detail: ContentDetail) {
/// 详情落地:**只接受当前这次请求的结果**(迟到的、别的项目的一律丢掉)。
///
/// 场景:选中 A(请求发出)→ 立刻切到 B(又发一条)→ A 的结果后到。没有这道闸门的话,
/// A 的版本列表会画在 B 的详情面板下,A 的失败也会被当成本次失败(r1 P2-3)。
pub fn apply_detail(&mut self, project: &str, detail: ContentDetail) {
if !self.accepts_detail(project) || detail.project != project {
return;
}
self.detail_pending = false;
self.detail_requested = None;
self.detail_failed = None;
self.detail = Some(Box::new(detail));
}
/// 详情失败。
pub fn fail_detail(&mut self, failure: ContentFailure) {
/// 详情失败:同样只看当前这次请求。
pub fn fail_detail(&mut self, project: &str, failure: ContentFailure) {
if !self.accepts_detail(project) {
return;
}
self.detail_pending = false;
self.detail_requested = None;
self.detail_failed = Some(failure);
}
/// 这条详情结果还算当前这次吗(`false` = 迟到 / 用户已经换了选中项)。
#[must_use]
pub fn accepts_detail(&self, project: &str) -> bool {
self.detail_requested.as_deref() == Some(project)
}
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
#[must_use]
pub fn accepts(&self, results: &ContentResults) -> bool {
+10 -2
View File
@@ -139,8 +139,16 @@ pub enum CoreEvent {
ContentResults(Box<ContentResults>),
/// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画
ContentFailed(Box<crate::dto::ContentFailure>),
/// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道
ContentDetail(Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>),
/// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道。
///
/// `project` 是**请求时选中的那个 id**:迟到的结果要靠它判「还算不算当前这次」
/// (用户可能已经切到别的项目了)。
ContentDetail {
/// 请求时选中的项目 id
project: String,
/// `Ok` = 详情;`Err` = 失败大类 + 原因
result: Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>,
},
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
+8
View File
@@ -6,6 +6,8 @@
use serde::{Deserialize, Serialize};
use crate::dto::ContentFailureKind;
/// 安装阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InstallStage {
@@ -70,6 +72,11 @@ pub struct InstallProgressView {
pub eta_secs: u64,
/// 状态/错误文案
pub message: String,
/// 失败大类(M162 r1):`None` = 没失败,或失败来自不产生分类的路径。
///
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
pub failure_kind: Option<ContentFailureKind>,
}
impl Default for InstallProgressView {
@@ -93,6 +100,7 @@ impl InstallProgressView {
speed_bps: 0,
eta_secs: 0,
message: String::new(),
failure_kind: None,
}
}
+5 -1
View File
@@ -97,8 +97,12 @@ pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) {
/// 后台取项目详情 + 版本列表(M162;选中项目才发,一条独立通道)。
pub fn spawn_detail(env: HostEnv, tx: JobSender, request: ContentDetailRequest) {
spawn("content-detail", move || {
let project = request.project.clone();
let result: Result<ContentDetail, ContentFailure> = sources::detail(&env, &request);
let _ = tx.send(HostTask::ContentDetail(Box::new(result)));
let _ = tx.send(HostTask::ContentDetail {
project,
result: Box::new(result),
});
});
}
+80 -81
View File
@@ -1,5 +1,11 @@
//! 「一键装入指定实例」(M109)。
//!
//! ## 网络只走 code 的在线内容源(M162 r1)
//!
//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、
//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。
//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
//!
//! ## 它到底做了什么(诚实版)
//!
//! | 内容类型 | 做什么 | 状态 |
@@ -19,20 +25,21 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{download_verified, EngineArtifactFetcher};
use unlucky_core::addon::online::{
download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use crate::dto::{
loader_facet, ContentInstallRequest, ContentSource, InstallProgressView, InstallStage,
InstanceSummary,
loader_facet, ContentFailure, ContentFailureKind, ContentInstallRequest, ContentSource,
InstallProgressView, InstallStage, InstanceSummary,
};
use super::super::env::HostEnv;
use super::super::job::{HostTask, JobSender};
use super::http::{encode, get};
use super::sources::MODRINTH;
use super::sources::failure_of;
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
pub(super) fn install(
@@ -56,7 +63,7 @@ pub(super) fn install(
fail(
tx,
progress,
"整合包安装尚未开放;内容库对整合包只做检索",
ContentFailure::unsupported("整合包安装尚未开放;内容库对整合包只做检索"),
epoch,
);
return;
@@ -77,32 +84,35 @@ pub(super) fn install(
epoch,
});
}
Err(reason) => {
Err(failure) => {
progress.stage = if cancel.load(Ordering::SeqCst) {
InstallStage::Cancelled
} else {
InstallStage::Failed
};
progress.message = failure.detail.clone();
progress.failure_kind = Some(failure.kind);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(reason),
result: Err(failure),
epoch,
});
}
}
}
fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) {
fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailure, epoch: u64) {
progress.stage = InstallStage::Failed;
progress.message = reason.to_string();
progress.message = failure.detail.clone();
progress.failure_kind = Some(failure.kind);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(reason.to_string()),
result: Err(failure),
epoch,
});
}
/// 真正干活的那一段,返回一句人读的落盘描述。
/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。
fn run(
env: &HostEnv,
request: &ContentInstallRequest,
@@ -110,20 +120,20 @@ fn run(
progress: &mut InstallProgressView,
tx: &JobSender,
epoch: u64,
) -> Result<String, String> {
) -> Result<String, ContentFailure> {
let instance = find_instance(env, &request.instance)?;
let game_dir = game_dir_of(env, &instance)?;
let dir = request
.kind
.dir()
.ok_or_else(|| "该内容类型不支持单文件装入".to_string())?;
.ok_or_else(|| local("该内容类型不支持单文件装入"))?;
let target_dir = game_dir.join(dir);
std::fs::create_dir_all(&target_dir)
.map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?;
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
let file = resolve_file(env, request)?;
if cancel.load(Ordering::SeqCst) {
return Err("已取消(下载开始前)".to_string());
return Err(local("已取消(下载开始前)"));
}
progress.stage = InstallStage::Downloading;
progress.message = file.name.clone();
@@ -145,22 +155,27 @@ fn run(
file.size,
&dest,
))
.map_err(|error| format!("下载失败:{error}"))?;
.map_err(failure_of)?;
Ok(format!("{}({})", file.name, dest.display()))
}
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, String> {
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
fn local(reason: impl Into<String>) -> ContentFailure {
ContentFailure::new(ContentFailureKind::Other, reason)
}
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFailure> {
super::super::game::instances(env)
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| format!("找不到实例 {id}"))
.ok_or_else(|| local(format!("找不到实例 {id}")))
}
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
let global = super::super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
derive_game_dir(
&env.game,
&instance.version_id,
@@ -169,7 +184,7 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Str
None,
)
.map(|derivation| derivation.game_dir)
.map_err(|error| format!("隔离目录派生失败:{error}"))
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
}
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
@@ -182,72 +197,56 @@ struct RemoteFile {
}
/// 解析「这个项目下最适合目标实例的那个文件」。
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
fn resolve_file(
env: &HostEnv,
request: &ContentInstallRequest,
) -> Result<RemoteFile, ContentFailure> {
match request.source {
ContentSource::Modrinth => modrinth_file(env, request),
ContentSource::Curseforge => Err(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
),
ContentSource::Curseforge => Err(ContentFailure::unsupported(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
)),
}
}
fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
let mut url = format!(
"{MODRINTH}/project/{}/version?game_versions={}",
request.project,
encode(&format!("[\"{}\"]", request.game_version))
);
if let Some(facet) = loader_facet(request.loader) {
url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]"))));
/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。
///
/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑,
/// `Version::primary_file()` 就是为这件事存在的。
fn modrinth_file(
env: &HostEnv,
request: &ContentInstallRequest,
) -> Result<RemoteFile, ContentFailure> {
let core_engine = super::super::engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let versions = block_on(client.versions(&VersionQuery {
project: request.project.clone(),
game_version: Some(request.game_version.clone()),
loader: loader_facet(request.loader).map(str::to_string),
}))
.map_err(failure_of)?;
let version = versions.first().ok_or_else(|| {
ContentFailure::new(
ContentFailureKind::NotFound,
"这个项目没有匹配目标实例(版本 / 加载器)的文件",
)
})?;
let file = version
.primary_file()
.ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?;
if file.url.trim().is_empty() {
return Err(ContentFailure::new(
ContentFailureKind::Unsupported,
"版本里的主文件没有下载地址",
));
}
let body = get(env, &url, &[])?;
let json: serde_json::Value = serde_json::from_str(&body)
.map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?;
let versions = json
.as_array()
.ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?;
let first = versions
.first()
.ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?;
let files = first
.get("files")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "版本里没有 files".to_string())?;
// 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑
let file = files
.iter()
.find(|file| {
file.get("primary")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.or_else(|| files.first())
.ok_or_else(|| "版本里没有可用文件".to_string())?;
let url = file
.get("url")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "文件没有 url".to_string())?;
let name = file
.get("filename")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "文件没有 filename".to_string())?;
let digest = |algorithm: &str| {
file.get("hashes")
.and_then(|hashes| hashes.get(algorithm))
.and_then(serde_json::Value::as_str)
.filter(|hex| !hex.trim().is_empty())
.map(ToString::to_string)
};
Ok(RemoteFile {
url: url.to_string(),
name: safe_file_name(name),
sha1: digest("sha1"),
sha512: digest("sha512"),
size: file
.get("size")
.and_then(serde_json::Value::as_u64)
.filter(|size| *size > 0),
url: file.url.clone(),
name: safe_file_name(&file.filename),
sha1: file.sha1().map(ToString::to_string),
sha512: file.sha512().map(ToString::to_string),
size: (file.size > 0).then_some(file.size),
})
}
+2 -2
View File
@@ -43,8 +43,8 @@ use super::http::{encode, get};
use unlucky_core::addon::online::EngineTransport;
use unlucky_core::download::blocking::block_on;
/// Modrinth v2 的根(无需 key)。
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
// Modrinth 的根不再在本文件:URL 拼装与解析都在 `core::addon::online`,这里只留 CF 的
// (它要 `X-API-KEY`,是 core 那一侧**故意**没接的那半)。
/// CurseForge v1 的根(需要 `X-API-KEY`)。
pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
/// CurseForge 的「Minecraft」gameId(官方固定值)。
+4 -1
View File
@@ -316,7 +316,10 @@ fn progress_from_a_cancelled_job_is_dropped() {
});
host.handle(HostTask::InstallDone {
progress: Box::new(progress_snapshot(512)),
result: Err("已取消".to_string()),
result: Err(crate::dto::ContentFailure::new(
crate::dto::ContentFailureKind::Other,
"已取消",
)),
epoch,
});
let after: Vec<AppEvent> = host.drain_events();
+5 -3
View File
@@ -41,8 +41,8 @@ impl CoreHost {
Err(failure) => self.send(CoreEvent::ContentFailed(Box::new(failure))),
},
// M162:详情/版本列表(与检索同一套失败分类)
HostTask::ContentDetail(result) => {
self.send(CoreEvent::ContentDetail(result));
HostTask::ContentDetail { project, result } => {
self.send(CoreEvent::ContentDetail { project, result });
}
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
HostTask::Pack { event, epoch } => {
@@ -86,7 +86,9 @@ impl CoreHost {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
}
Err(reason) => self.toast(ToastKind::Error, reason),
// 只把**一句人读原因**弹出去:大类已经随 `progress.failure_kind` 进了 DTO,
// 面板/调用方按大类决定怎么画(离线 vs 查无此项)。
Err(failure) => self.toast(ToastKind::Error, failure.detail),
}
}
HostTask::Accounts(accounts) => {
+7 -1
View File
@@ -160,9 +160,15 @@ pub fn spawn_install(
format!("{reason}(日志:{})", journal.path().display())
};
progress.message.clone_from(&text);
// 版本安装这条路的失败不是内容库的分类(它是本地/运行期错误),归到 Other:
// 大类字段仍然要填,面板才不会把「装坏了」误画成「没联网」。
progress.failure_kind = Some(crate::dto::ContentFailureKind::Other);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(text),
result: Err(crate::dto::ContentFailure::new(
crate::dto::ContentFailureKind::Other,
text,
)),
epoch,
});
}
+10 -5
View File
@@ -48,8 +48,13 @@ pub enum HostTask {
},
/// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因)
Content(Box<Result<ContentResults, ContentFailure>>),
/// 内容库项目详情 + 版本列表(M162)
ContentDetail(Box<Result<ContentDetail, ContentFailure>>),
/// 内容库项目详情 + 版本列表(M162):项目 id 随载荷一起回来(迟到结果靠它判)
ContentDetail {
/// 请求时选中的项目 id
project: String,
/// `Ok` = 详情;`Err` = 失败大类 + 原因
result: Box<Result<ContentDetail, ContentFailure>>,
},
// M113: import/export
/// 导入/导出(M113):一条事件 + 任务代次
///
@@ -74,12 +79,12 @@ pub enum HostTask {
/// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。
epoch: u64,
},
/// 安装收尾(成功带实例名,失败带文案)
/// 安装收尾(成功带实例名,失败带**大类 + 文案**)
InstallDone {
/// 最终进度快照
progress: Box<InstallProgressView>,
/// 成功时为 `Ok(实例名)`
result: Result<String, String>,
/// 成功时为 `Ok(实例名)`;失败时带分类(离线 ≠ 查无此项)
result: Result<String, ContentFailure>,
/// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`)
epoch: u64,
},
+3 -3
View File
@@ -301,9 +301,9 @@ impl AppState {
self.content.failed = Some(*failure);
}
// M162:详情/版本列表(选中项才拉;迟到结果按项目 id 丢)
CoreEvent::ContentDetail(result) => match *result {
Ok(detail) => self.content.apply_detail(detail),
Err(failure) => self.content.fail_detail(failure),
CoreEvent::ContentDetail { project, result } => match *result {
Ok(detail) => self.content.apply_detail(&project, detail),
Err(failure) => self.content.fail_detail(&project, failure),
},
}
}
+47 -4
View File
@@ -158,7 +158,10 @@ fn the_detail_channel_replaces_the_previous_project() {
let mut state = AppState::new();
state.content.request_detail("p1");
assert!(state.content.detail_pending);
state.apply_core(CoreEvent::ContentDetail(Box::new(Ok(detail("p1")))));
state.apply_core(CoreEvent::ContentDetail {
project: "p1".to_string(),
result: Box::new(Ok(detail("p1"))),
});
assert!(!state.content.detail_pending);
assert_eq!(
state.content.detail.as_ref().map(|d| d.project.as_str()),
@@ -170,9 +173,10 @@ fn the_detail_channel_replaces_the_previous_project() {
state.content.detail.is_none(),
"换了项目就不能留着上一个的版本列表"
);
state.apply_core(CoreEvent::ContentDetail(Box::new(Err(
ContentFailure::offline("connect timed out"),
))));
state.apply_core(CoreEvent::ContentDetail {
project: "p2".to_string(),
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
});
assert!(state
.content
.detail_failed
@@ -199,3 +203,42 @@ fn detail(project: &str) -> ContentDetail {
}],
}
}
/// r1 P2-3:迟到的详情结果(A)不许写进 B 的请求/面板。
#[test]
fn a_late_detail_result_for_the_previous_project_is_dropped() {
let mut state = AppState::new();
state.content.request_detail("p1");
// 用户立刻切到 p2:新的请求在飞
state.content.request_detail("p2");
// p1 的失败这时才回来
state.apply_core(CoreEvent::ContentDetail {
project: "p1".to_string(),
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
});
assert!(
state.content.detail_failed.is_none(),
"A 的失败不许画在 B 的面板上"
);
assert!(state.content.detail_pending, "B 仍应在飞");
assert_eq!(state.content.detail_requested.as_deref(), Some("p2"));
// p1 的成功结果同样被丢掉(哪怕载荷里写着 p1)
state.apply_core(CoreEvent::ContentDetail {
project: "p1".to_string(),
result: Box::new(Ok(detail("p1"))),
});
assert!(state.content.detail.is_none());
assert!(state.content.detail_pending);
// 真正属于 B 的结果才落地
state.apply_core(CoreEvent::ContentDetail {
project: "p2".to_string(),
result: Box::new(Ok(detail("p2"))),
});
assert!(!state.content.detail_pending);
assert_eq!(
state.content.detail.as_ref().map(|d| d.project.as_str()),
Some("p2")
);
}