diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 3f18000..42668b8 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -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 { 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 { let catalog = unlucky_i18n::Lang::En.catalog(); let model = Content::from_snapshot(&self.state, &self.content, catalog); diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index 9eac5ea..2b60022 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -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"); diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index 646056f..f55d29c 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -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(), } } diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 63d9b38..4658ce1 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -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, ) diff --git a/crates/core/src/addon/import/complete.rs b/crates/core/src/addon/import/complete.rs index 45ad919..29b3b95 100644 --- a/crates/core/src/addon/import/complete.rs +++ b/crates/core/src/addon/import/complete.rs @@ -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; diff --git a/crates/core/src/addon/import/error.rs b/crates/core/src/addon/import/error.rs index 600a977..8901b2e 100644 --- a/crates/core/src/addon/import/error.rs +++ b/crates/core/src/addon/import/error.rs @@ -176,9 +176,10 @@ impl From 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, diff --git a/crates/core/src/addon/import/fetch.rs b/crates/core/src/addon/import/fetch.rs index cd8b236..14d1ca2 100644 --- a/crates/core/src/addon/import/fetch.rs +++ b/crates/core/src/addon/import/fetch.rs @@ -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 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 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()) } diff --git a/crates/core/src/addon/online/download.rs b/crates/core/src/addon/online/download.rs index 72cdd7b..5befc04 100644 --- a/crates/core/src/addon/online/download.rs +++ b/crates/core/src/addon/online/download.rs @@ -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 { /// 摘要形状合法吗(长度 + 十六进制)。 /// -/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`equals_hex`]。 -fn check_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Result, 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)) } /// 十六进制比较(大小写不敏感:声明侧可能大写)。 diff --git a/crates/core/src/addon/online/tests/classify.rs b/crates/core/src/addon/online/tests/classify.rs new file mode 100644 index 0000000..657f60c --- /dev/null +++ b/crates/core/src/addon/online/tests/classify.rs @@ -0,0 +1,89 @@ +//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。 +//! +//! 这一族的价值全在「经过真的 `From`」:直接给 `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(), + "没联网仍然是「这条文件搞不到」,实例照装" + ); +} diff --git a/crates/core/src/addon/online/tests/download.rs b/crates/core/src/addon/online/tests/download.rs index 4a84917..788afbd 100644 --- a/crates/core/src/addon/online/tests/download.rs +++ b/crates/core/src/addon/online/tests/download.rs @@ -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"); diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index 5c4a2a5..960ea2a 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -7,6 +7,7 @@ //! M162 报告的「没验的部分」里。 mod api; +mod classify; mod complete; mod download; mod fixtures; diff --git a/crates/core/src/addon/online/transport.rs b/crates/core/src/addon/online/transport.rs index 121513c..4233068 100644 --- a/crates/core/src/addon/online/transport.rs +++ b/crates/core/src/addon/online/transport.rs @@ -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 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) } diff --git a/crates/core/src/addon/tests/import_online.rs b/crates/core/src/addon/tests/import_online.rs index 3ff25f4..ced8c64 100644 --- a/crates/core/src/addon/tests/import_online.rs +++ b/crates/core/src/addon/tests/import_online.rs @@ -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::>() + ); +} + +#[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()); +} diff --git a/crates/core/src/addon/tests/support.rs b/crates/core/src/addon/tests/support.rs index c60f4ff..7b6cc61 100644 --- a/crates/core/src/addon/tests/support.rs +++ b/crates/core/src/addon/tests/support.rs @@ -55,6 +55,8 @@ pub(crate) struct StubFetcher { curse: BTreeMap<(i64, i64), CurseFileMeta>, /// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。 modrinth: BTreeMap, + /// M162:补全时假装「没联网」(与「站点不认得这个摘要」区分开)。 + modrinth_offline: bool, /// 被请求过的 URL(断言"没白下载"用)。 calls: Mutex>, /// 是否像真引擎那样在目标旁边留下续传残片(`.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> { 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, diff --git a/crates/core/src/download/bytes.rs b/crates/core/src/download/bytes.rs index f191f51..c0a1fc0 100644 --- a/crates/core/src/download/bytes.rs +++ b/crates/core/src/download/bytes.rs @@ -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 = Vec::new(); + // 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。 + let mut kinds: Vec = 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( diff --git a/crates/core/src/download/error.rs b/crates/core/src/download/error.rs index ed5f6cc..3152de9 100644 --- a/crates/core/src/download/error.rs +++ b/crates/core/src/download/error.rs @@ -70,6 +70,18 @@ pub struct DownloadError { pub attempts: usize, /// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。 pub suppressed: Vec, + /// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底 + /// 是怎么挂的。 + /// + /// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、 + /// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」—— + /// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里 + /// 反解状态码是靠文案猜,改一句提示就失效。 + /// + /// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`; + /// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。 + /// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。 + pub leaf: Option, } 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) -> 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); + } +} diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index eafdbc4..196e20a 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -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", "加载更多"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 037800b..74a851d 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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", "没有更多了"), diff --git a/crates/presenter/src/dto/content.rs b/crates/presenter/src/dto/content.rs index f755762..3ef817c 100644 --- a/crates/presenter/src/dto/content.rs +++ b/crates/presenter/src/dto/content.rs @@ -308,8 +308,10 @@ pub struct ContentState { pub last_page_filled: bool, /// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过) pub detail: Option>, - /// 详情请求在飞 + /// 详情请求在飞(哪个项目:迟到结果靠它判「还算不算当前这次」) pub detail_pending: bool, + /// 在飞的那次详情请求的项目 id(`None` = 没有在飞) + pub detail_requested: Option, /// 最近一次详情请求的失败 pub detail_failed: Option, } @@ -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 { diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index f479053..819d645 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -139,8 +139,16 @@ pub enum CoreEvent { ContentResults(Box), /// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画 ContentFailed(Box), - /// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道 - ContentDetail(Box>), + /// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道。 + /// + /// `project` 是**请求时选中的那个 id**:迟到的结果要靠它判「还算不算当前这次」 + /// (用户可能已经切到别的项目了)。 + ContentDetail { + /// 请求时选中的项目 id + project: String, + /// `Ok` = 详情;`Err` = 失败大类 + 原因 + result: Box>, + }, // M113: import/export /// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`]) Pack(Box), diff --git a/crates/presenter/src/dto/install.rs b/crates/presenter/src/dto/install.rs index a960a22..527536a 100644 --- a/crates/presenter/src/dto/install.rs +++ b/crates/presenter/src/dto/install.rs @@ -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, } impl Default for InstallProgressView { @@ -93,6 +100,7 @@ impl InstallProgressView { speed_bps: 0, eta_secs: 0, message: String::new(), + failure_kind: None, } } diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index f23e3d9..be2e7c4 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -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 = sources::detail(&env, &request); - let _ = tx.send(HostTask::ContentDetail(Box::new(result))); + let _ = tx.send(HostTask::ContentDetail { + project, + result: Box::new(result), + }); }); } diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 432669d..e71388d 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -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 { +) -> Result { 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 { +/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 +fn local(reason: impl Into) -> ContentFailure { + ContentFailure::new(ContentFailureKind::Other, reason) +} + +fn find_instance(env: &HostEnv, id: &str) -> Result { 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 { +fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result { 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 Result { +fn resolve_file( + env: &HostEnv, + request: &ContentInstallRequest, +) -> Result { 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 { - 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 { + 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), }) } diff --git a/crates/presenter/src/host/addon/sources.rs b/crates/presenter/src/host/addon/sources.rs index 4e33f21..1dcbc1c 100644 --- a/crates/presenter/src/host/addon/sources.rs +++ b/crates/presenter/src/host/addon/sources.rs @@ -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(官方固定值)。 diff --git a/crates/presenter/src/host/cancel_tests.rs b/crates/presenter/src/host/cancel_tests.rs index c044e1b..ce22bd7 100644 --- a/crates/presenter/src/host/cancel_tests.rs +++ b/crates/presenter/src/host/cancel_tests.rs @@ -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 = host.drain_events(); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index f2f6e43..102f2b4 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -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) => { diff --git a/crates/presenter/src/host/install.rs b/crates/presenter/src/host/install.rs index a8f404d..20a6ee9 100644 --- a/crates/presenter/src/host/install.rs +++ b/crates/presenter/src/host/install.rs @@ -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, }); } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index ee8e009..7def756 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -48,8 +48,13 @@ pub enum HostTask { }, /// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因) Content(Box>), - /// 内容库项目详情 + 版本列表(M162) - ContentDetail(Box>), + /// 内容库项目详情 + 版本列表(M162):项目 id 随载荷一起回来(迟到结果靠它判) + ContentDetail { + /// 请求时选中的项目 id + project: String, + /// `Ok` = 详情;`Err` = 失败大类 + 原因 + result: Box>, + }, // M113: import/export /// 导入/导出(M113):一条事件 + 任务代次 /// @@ -74,12 +79,12 @@ pub enum HostTask { /// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。 epoch: u64, }, - /// 安装收尾(成功带实例名,失败带文案) + /// 安装收尾(成功带实例名,失败带**大类 + 文案**) InstallDone { /// 最终进度快照 progress: Box, - /// 成功时为 `Ok(实例名)` - result: Result, + /// 成功时为 `Ok(实例名)`;失败时带分类(离线 ≠ 查无此项) + result: Result, /// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`) epoch: u64, }, diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index 5fdd0c6..cc4b897 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -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), }, } } diff --git a/crates/presenter/src/state/content_tests.rs b/crates/presenter/src/state/content_tests.rs index 9781708..9755758 100644 --- a/crates/presenter/src/state/content_tests.rs +++ b/crates/presenter/src/state/content_tests.rs @@ -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") + ); +}