diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 00b5140..3f18000 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -17,7 +17,8 @@ //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 use unlucky_presenter::dto::{ - AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView, + AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource, + ExportRequestView, }; use crate::pages::content::{Blocker, Content}; @@ -76,7 +77,12 @@ impl App { self.with_query(|query| query.loader = loader) } pages::content::Message::Select(id) => { - self.content.selected = Some(id); + // M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板) + self.content.selected = Some(id.clone()); + self.state.content.request_detail(&id); + if let Some(request) = self.content_detail_request() { + self.dispatch(AppCommand::LoadContentDetail(Box::new(request))); + } self.view_dirty = true; } pages::content::Message::SetTarget(id) => { @@ -167,6 +173,23 @@ impl App { /// /// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来, /// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。 + /// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。 + fn content_detail_request(&self) -> Option { + let catalog = unlucky_i18n::Lang::En.catalog(); + let model = Content::from_snapshot(&self.state, &self.content, catalog); + let hit = model.selected_hit()?; + let target = model + .targets + .iter() + .find(|target| Some(&target.id) == model.target.as_ref()); + Some(ContentDetailRequest { + project: hit.id.clone(), + source: hit.source, + game_version: target.map(|target| target.mc_version.clone()), + loader: target.map(|target| target.loader), + }) + } + 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/demo.rs b/crates/app-iced/src/pages/content/demo.rs index 9259019..ca01959 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -11,14 +11,16 @@ //! | 变量 | 取值 | 作用 | //! |---|---|---| //! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | -//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) | +//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) | //! //! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。 -use unlucky_i18n::{Catalog, Lang}; +use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; -use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target}; +use super::model::{ + blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow, +}; use crate::pages::download::Download; /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 @@ -147,9 +149,45 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { let targets = targets(); let target = Some("survival".to_string()); let empty = state == "empty"; + // M162:「未联网」那一屏(与空态分开画) + let offline = state == "offline"; + // M162:详情通道的版本列表(真实路径来自 presenter 的 `ContentDetail`) + let demo_versions = if empty || offline { + Vec::new() + } else { + vec![ + VersionRow { + number: "mc1.20.1-0.5.11".to_string(), + kind: "release".to_string(), + file_name: Some("sodium-fabric-0.5.11.jar".to_string()), + line: catalog.render( + Key::ContentDetailVersionLine, + &[ + ("number", "mc1.20.1-0.5.11"), + ("kind", "Release"), + ("date", "2025-06-01"), + ], + ), + }, + VersionRow { + number: "mc1.20.1-0.5.10".to_string(), + kind: "beta".to_string(), + file_name: Some("sodium-fabric-0.5.10.jar".to_string()), + line: catalog.render( + Key::ContentDetailVersionLine, + &[ + ("number", "mc1.20.1-0.5.10"), + ("kind", "Beta"), + ("date", "2025-05-20"), + ], + ), + }, + ] + }; // 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读 let installing = state == "install"; - let list = if empty { + // 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络) + let list = if empty || offline { Vec::new() } else { hits(kind, catalog, state != "incompatible") @@ -196,7 +234,13 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { }, hits: list, selected, - status: if empty { Status::Empty } else { Status::Ready }, + status: if offline { + Status::Offline("connect timed out after 10s".to_string()) + } else if empty { + Status::Empty + } else { + Status::Ready + }, total: if empty { 0 } else { 1_284 }, // 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」 has_more: !empty, @@ -211,6 +255,8 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { None }, versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], + available_versions: demo_versions, + versions_note: None, draft: if empty { "quarkonium".to_string() } else { diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index f9f9199..51c5d84 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -27,7 +27,7 @@ use unlucky_presenter::dto::{ }; use unlucky_presenter::state::AppState; -use unlucky_i18n::Catalog; +use unlucky_i18n::{Catalog, Key}; use crate::pages::download::Download; @@ -35,7 +35,7 @@ mod items; pub(crate) use items::HitFacts; pub(crate) use items::{capitalize, thousands}; -pub use items::{loader_name, Blocker, Hit, PageState, Status, Target}; +pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow}; /// 内容库页要画的东西。 #[derive(Debug, Clone, PartialEq)] @@ -75,6 +75,10 @@ pub struct Content { pub draft: String, /// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检 pub pack: super::packui::Pack, + /// 选中项目的可用版本(M162:详情通道;空 = 还没拉到 / 站点没给) + pub available_versions: Vec, + /// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说) + pub versions_note: Option, } /// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。 @@ -156,6 +160,9 @@ impl Content { catalog, ); + let (available_versions, versions_note) = + detail_view(content, page.selected.as_deref(), catalog); + Self { kind: page.kind, query: ContentQueryView { @@ -183,6 +190,8 @@ impl Content { versions, draft: page.draft.clone(), pack, + available_versions, + versions_note, } } @@ -213,8 +222,13 @@ impl Content { /// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。 fn status_of(content: &ContentState, hits: &[Hit]) -> Status { - if let Some(reason) = &content.failed { - return Status::Failed(reason.clone()); + if let Some(failure) = &content.failed { + // M162:没联网单独一态(空列表不能冒充「没有结果」) + return if failure.is_offline() { + Status::Offline(failure.detail.clone()) + } else { + Status::Failed(failure.detail.clone()) + }; } if content.pending || content.results.is_none() { return Status::Loading; @@ -237,6 +251,57 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option { .and_then(|progress| crate::pages::download::install_panel_model(progress, catalog)) } +/// 选中项目的版本列表 + 一句说明(详情通道;迟到结果按项目 id 丢)。 +/// +/// 三条优先级:**有结果就画结果** → 在飞就画「正在取」 → 失败才画原因。选中项变了而结果是 +/// 上一个项目的,一律当「没有」(`detail.project != selected`)——否则会画着 A 的版本列表 +/// 却高亮着 B。 +fn detail_view( + content: &ContentState, + selected: Option<&str>, + catalog: Catalog, +) -> (Vec, Option) { + let Some(selected) = selected else { + return (Vec::new(), None); + }; + if let Some(detail) = content.detail.as_ref().filter(|d| d.project == selected) { + let rows = detail + .versions + .iter() + .map(|version| VersionRow { + number: version.number.clone(), + kind: version.kind.clone(), + file_name: version.file_name.clone(), + line: catalog.render( + Key::ContentDetailVersionLine, + &[ + ("number", version.number.as_str()), + ("kind", capitalize(&version.kind).as_str()), + ("date", version.published.as_str()), + ], + ), + }) + .collect(); + return (rows, None); + } + if content.detail_pending { + return ( + Vec::new(), + Some(catalog.text(Key::ContentDetailFetching).to_string()), + ); + } + match &content.detail_failed { + Some(failure) => ( + Vec::new(), + Some(catalog.render( + Key::ContentDetailFetchFailed, + &[("reason", failure.detail.as_str())], + )), + ), + None => (Vec::new(), None), + } +} + /// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。 /// /// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。 diff --git a/crates/app-iced/src/pages/content/model/items.rs b/crates/app-iced/src/pages/content/model/items.rs index 7e4be4e..378d05f 100644 --- a/crates/app-iced/src/pages/content/model/items.rs +++ b/crates/app-iced/src/pages/content/model/items.rs @@ -45,13 +45,34 @@ pub enum Status { Empty, /// 失败(带一句人读原因) Failed(String), + /// 未联网(M162):**不是**「没有匹配的内容」,也不是普通失败——单独一屏, + /// 说清「要么联网要么没结果」这件事不可能靠空列表表达。 + Offline(String), } impl Status { /// 失败了吗(视图用它决定画错误面板还是列表)。 pub const fn failed(&self) -> bool { - matches!(self, Self::Failed(_)) + matches!(self, Self::Failed(_) | Self::Offline(_)) } + + /// 未联网吗(视图据此画离线态而不是通用失败态)。 + pub const fn offline(&self) -> bool { + matches!(self, Self::Offline(_)) + } +} + +/// 版本列表的一行(M162:详情通道拉回来的 `ContentVersionView`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionRow { + /// 版本号(`mc1.20.1-0.5.11`) + pub number: String, + /// 版本类型(`release` / `beta` / `alpha`,站点原文) + pub kind: String, + /// 文件名(`None` = 站点没给) + pub file_name: Option, + /// 已经求好文案的一行(`版本号 · 类型 · 日期`) + pub line: String, } /// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。 diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index ecff751..9eac5ea 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -6,8 +6,8 @@ use super::*; use unlucky_i18n::Lang; use unlucky_presenter::dto::{ - ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime, - LoaderKind, + ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, ContentQuery, + ContentResults, ContentSource, ContentVersionView, InstanceRuntime, LoaderKind, }; fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit { @@ -200,10 +200,69 @@ fn the_four_states_are_derived_from_three_fields() { #[test] fn a_failure_outranks_the_loading_state() { let mut state = state_with(None, true); - state.content.failed = Some("网络不通".to_string()); + state.content.failed = Some(ContentFailure::new(ContentFailureKind::Server, "站点 503")); let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); - assert_eq!(model.status, Status::Failed("网络不通".to_string())); + assert_eq!(model.status, Status::Failed("站点 503".to_string())); assert!(model.status.failed()); + assert!(!model.status.offline()); +} + +#[test] +fn no_network_is_its_own_state_not_an_empty_list() { + // M162:无网必须画「未联网」,不能与「没有匹配的内容」共用一个空态。 + let mut state = state_with(None, false); + state.content.results = Some(results(Vec::new(), 0)); + state.content.failed = Some(ContentFailure::offline("connect timed out")); + let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert_eq!( + model.status, + Status::Offline("connect timed out".to_string()) + ); + assert!(model.status.offline()); + assert!(model.status.failed()); + assert_ne!(model.status, Status::Empty); +} + +#[test] +fn versions_come_from_the_detail_channel_of_the_selected_project() { + let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false); + let mut page = PageState { + selected: Some("p1".to_string()), + ..PageState::default() + }; + // 详情还没到:说明「正在取」,不是「一个版本都没有」 + state.content.request_detail("p1"); + let model = Content::from_snapshot(&state, &page, Lang::En.catalog()); + 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), + }], + }); + 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"); + assert!(model.available_versions[0].line.contains("mc1.20.1-0.6.0")); + assert!(model.versions_note.is_none()); + + // 换了选中项:上一个项目的版本列表不许画在这个项目下面 + page.selected = Some("p2".to_string()); + let model = Content::from_snapshot(&state, &page, Lang::En.catalog()); + assert!(model.available_versions.is_empty()); } #[test] diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs index 621ba5b..2aab75f 100644 --- a/crates/app-iced/src/pages/content/parts.rs +++ b/crates/app-iced/src/pages/content/parts.rs @@ -341,6 +341,58 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str { } } +/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。 +pub(super) fn offline<'a>( + ui: &'a Ui, + catalog: Catalog, + reason: &'a str, +) -> Element<'a, PageMessage> { + centered( + column![ + row![ + frame::glyph( + IconKind::Warn, + ui.color(Role::OnSurfaceVariant), + frame::ICON_PX + ), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::ContentOfflineTitle), + ui.color(Role::OnSurface) + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentOfflineBody), + ui.color(Role::OnSurfaceVariant) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + reason, + ui.color(Role::OnSurfaceVariant) + ), + buttons::outlined( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + .leading(IconKind::Refresh), + ), + ] + .spacing(ui.space(Space::X2)) + .align_x(Alignment::Center), + ) +} + /// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。 pub(super) fn status_body<'a>( ui: &'a Ui, @@ -351,6 +403,8 @@ pub(super) fn status_body<'a>( Status::Loading => Some(loading(ui, catalog)), Status::Empty => Some(empty(ui, catalog)), Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())), + // M162:未联网不是「没有结果」,也不是普通失败——单独一屏(标题/正文/图标都不同) + Status::Offline(reason) => Some(offline(ui, catalog, reason.as_str())), Status::Ready => None, } } diff --git a/crates/app-iced/src/pages/content/parts/detail.rs b/crates/app-iced/src/pages/content/parts/detail.rs index 5d616a1..e969276 100644 --- a/crates/app-iced/src/pages/content/parts/detail.rs +++ b/crates/app-iced/src/pages/content/parts/detail.rs @@ -93,6 +93,39 @@ pub(crate) fn detail<'a>( let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3)); + // M162:可用版本(详情通道拉的;最多画 5 行——列表页负责「多」,这里只负责「有哪些」) + if model.available_versions.is_empty() { + if let Some(note) = model.versions_note.as_deref() { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note, + ui.color(Role::OnSurfaceVariant), + )); + } + } else { + let count = model.available_versions.len().to_string(); + let mut versions = column![frame::text_role_owned( + ui, + TypeRole::LabelMedium, + true, + catalog.render(Key::ContentDetailVersionCount, &[("count", count.as_str())]), + ui.color(Role::OnSurfaceVariant), + )] + .spacing(ui.space(Space::X1)); + for version in model.available_versions.iter().take(5) { + versions = versions.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + version.line.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + body = body.push(versions); + } + // 目标实例:一行 chip(选中的那个高亮) if model.targets.is_empty() { body = body.push(frame::text_role( diff --git a/crates/core/src/addon/import.rs b/crates/core/src/addon/import.rs index 02a947d..3ceb8c7 100644 --- a/crates/core/src/addon/import.rs +++ b/crates/core/src/addon/import.rs @@ -78,6 +78,7 @@ //! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。 pub mod baseline; +pub(crate) mod complete; pub mod decision; pub mod error; pub mod fetch; @@ -94,7 +95,7 @@ pub use decision::ConflictDecisions; pub use error::{ImportError, ImportErrorKind}; pub use fetch::{ CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest, - FetchedFile, PackFetcher, + FetchedFile, ModrinthFileMeta, PackFetcher, }; pub use merge::{MergeAction, MergeDecision}; pub use pipeline::baseline_relative; @@ -104,7 +105,7 @@ pub use report::{ ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile, LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent, }; -pub use scan::{scan_pack, ImportConflict, ImportScan}; +pub use scan::{scan_pack, scan_pack_online, ImportConflict, ImportScan}; pub use stage::{RollbackOutcome, Staging}; use std::path::Path; diff --git a/crates/core/src/addon/import/complete.rs b/crates/core/src/addon/import/complete.rs new file mode 100644 index 0000000..45ad919 --- /dev/null +++ b/crates/core/src/addon/import/complete.rs @@ -0,0 +1,98 @@ +//! 远端条目补全(M162):**有 `path` 但缺 `hashes.sha1` 的 mrpack 条目,按 sha512 反查 sha1**。 +//! +//! # 补什么、为什么补 +//! +//! M160 的预检用 SHA-1 判「会不会覆盖用户文件」;mrpack 规范里 `sha1` 是**可选**的,只有 +//! `sha512` 必填。于是「只有 path+downloads+hashes(sha512)」的条目在预检里判不了,只能记进 +//! `ImportScan::unknown_remote`。现在拿 sha512 去 Modrinth 反查:站点认得这个文件就把**它自己 +//! 记的 sha1** 填回来(同一个文件在同一站点上的官方摘要),补不出**保持缺**、照旧计数。 +//! +//! # 三条边界 +//! +//! 1. **只补 sha1 / 字节数 / 缺的直链**,绝不改写清单里已有的值(清单说多大就是多大); +//! 2. **CurseForge 条目不走这里**:它们连路径都没有,补全要 `projectID/fileID` 端点(需要 key), +//! 那条路仍是 `PackFetcher::curse_file` 的活; +//! 3. **离线立刻停**:第一条网络类失败之后剩下的条目一律算「补不出」,不拿 N 次超时惩罚用户 +//! (与 `online::complete` 同一条纪律,只是这里换成按路径过滤的导入视图)。 +//! +//! # 为什么要改 `plan.remote` 而不是 `manifest.files` +//! +//! 导入与预检都只读 `plan.remote`(`manifest` 保留原样作诊断),改一处即可两条路都生效, +//! 也不会让人误以为「清单被回写了」。 + +use super::fetch::FetchErrorKind; +use super::plan::ImportPlan; +use super::ImportRequest; + +/// 一次补全的结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Completion { + /// 补出了 sha1 的条目数 + pub(crate) filled: usize, + /// 仍然补不出的条目数(缺 sha512 / 站点查不到 / 没接源) + pub(crate) unfilled: usize, + /// 是否因为「没联网」提前收工 + pub(crate) offline: bool, +} + +/// 跑一次补全(就地改 `plan.remote`)。 +pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) -> Completion { + let mut state = Completion::default(); + let Some(fetcher) = request.fetcher else { + state.unfilled = plan + .remote + .iter() + .filter(|entry| entry.path.is_some() && entry.file.sha1().is_none()) + .count(); + return state; + }; + for entry in plan.remote.iter_mut() { + if entry.file.sha1().is_some() { + continue; + } + // CF 条目(没有路径)不归这里:那是 `curse_file` 的活。 + if entry.path.is_none() { + continue; + } + let Some(sha512) = entry.file.sha512().map(str::to_string) else { + state.unfilled += 1; + continue; + }; + if state.offline { + state.unfilled += 1; + continue; + } + match fetcher.modrinth_file_by_sha512(&sha512).await { + Ok(meta) => { + // 站点给的 sha1 也要过形状检查:长度不对的「摘要」比没有更危险。 + match meta.sha1.filter(|hex| is_sha1(hex)) { + Some(sha1) => { + entry.file.hashes.insert("sha1".to_string(), sha1); + if entry.file.size.is_none() { + entry.file.size = meta.size; + } + if entry.file.downloads.iter().all(|url| url.trim().is_empty()) { + if let Some(url) = meta.url { + entry.file.downloads.push(url); + } + } + state.filled += 1; + } + None => state.unfilled += 1, + } + } + Err(error) => { + if error.kind() == FetchErrorKind::Network { + state.offline = true; + } + state.unfilled += 1; + } + } + } + state +} + +/// 40 位十六进制。 +fn is_sha1(hex: &str) -> bool { + hex.len() == 40 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/crates/core/src/addon/import/fetch.rs b/crates/core/src/addon/import/fetch.rs index 3017224..cd8b236 100644 --- a/crates/core/src/addon/import/fetch.rs +++ b/crates/core/src/addon/import/fetch.rs @@ -16,17 +16,28 @@ //! //! # 生产实现只走 `core::download` //! -//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,它只做一件事:把 -//! [`FetchRequest`] 翻译成 [`crate::download::DownloadRequest`] 交给引擎(镜像展开、退避、 -//! 续传、边下边算都在引擎里)。导入链路里**没有**第二个 `reqwest::Client`。 +//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,做两件事: +//! +//! 1. **[`PackFetcher::fetch_file`]** —— 交给 [`crate::addon::online::download_verified`]: +//! sha1 由引擎流式校验、sha512 由 addon 侧落盘后复核(引擎的 `Integrity` 只认 Sha1/Sha256), +//! 失败按 [`crate::addon::online::RETRY_ATTEMPTS`] 重试且不留半截文件; +//! 2. **[`PackFetcher::modrinth_file_by_sha512`]** —— M162 的补全路径:按 sha512 反查 +//! Modrinth 拿同一个文件的官方 sha1(站点不认得就如实报 `NotFound`,由调用方计数)。 +//! +//! 两条都经同一个引擎(`EngineTransport` / `DownloadRequest`),导入链路里**没有**第二个 +//! `reqwest::Client`,也没有第二个重试循环。 use std::future::Future; use std::path::Path; use std::pin::Pin; -use crate::download::{ - DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, Integrity, ResourceKind, +use crate::addon::online::complete::identity_of; +use crate::addon::online::download::download_verified; +use crate::addon::online::modrinth::HashAlgorithm; +use crate::addon::online::{ + EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind, }; +use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind}; /// "搞不到"的成因(**分级判据只在这里**)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -130,6 +141,19 @@ impl From for FetchError { } } +impl From for FetchError { + fn from(error: OnlineError) -> Self { + let kind = match error.kind() { + OnlineErrorKind::NotFound => FetchErrorKind::NotFound, + OnlineErrorKind::Integrity | OnlineErrorKind::Malformed => FetchErrorKind::Integrity, + OnlineErrorKind::Io => FetchErrorKind::Io, + OnlineErrorKind::Unsupported => FetchErrorKind::Unsupported, + OnlineErrorKind::NotConnected | OnlineErrorKind::Server => FetchErrorKind::Network, + }; + Self::new(kind, error.message().to_string()) + } +} + /// trait 方法返回的装箱 future(core 不引 `async-trait`,手写 `Pin>` 即可)。 pub type FetchFuture<'a, T> = Pin + Send + 'a>>; @@ -148,6 +172,19 @@ pub struct CurseFileMeta { pub class_id: Option, } +/// Modrinth 一个文件的元数据(`files[]` 补全的产物,按 sha512 反查得到)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModrinthFileMeta { + /// 站点给的 SHA-1(**同一个文件**的官方摘要,不是本地推算) + pub sha1: Option, + /// 站点给的字节数 + pub size: Option, + /// 站点给的直链(清单里没有 downloads 时才用得上) + pub url: Option, + /// 站点给的文件名 + pub file_name: Option, +} + /// 一次文件下载请求。 #[derive(Debug, Clone, Copy)] pub struct FetchRequest<'a> { @@ -157,6 +194,8 @@ pub struct FetchRequest<'a> { pub dest: &'a Path, /// 期望 SHA-1(清单声明的)。 pub sha1: Option<&'a str>, + /// 期望 SHA-512(mrpack 的必填摘要;引擎不认这一档,由 `online::download` 落盘后复核)。 + pub sha512: Option<&'a str>, /// 期望字节数。 pub size: Option, } @@ -185,6 +224,21 @@ pub trait PackFetcher: Send + Sync { }) } + /// 按 SHA-512 反查 Modrinth 上这个文件的元数据(**sha1 补全用**)。 + /// + /// 默认实现返回 [`FetchErrorKind::Unsupported`]:没有远程源(离线导入、测试的 `StubFetcher`) + /// 就补不出——这与 M160 的 `unknown_remote` 口径一致:补不出仍要如实计数,**不许猜**。 + fn modrinth_file_by_sha512<'a>( + &'a self, + sha512: &'a str, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { + Err(FetchError::unsupported(format!( + "没有接 Modrinth 源,无法按 sha512 补全这一条({sha512})" + ))) + }) + } + /// 把 `request.url` 下到 `request.dest`。 fn fetch_file<'a>( &'a self, @@ -207,28 +261,54 @@ impl<'a> DownloadEngineFetcher<'a> { } impl PackFetcher for DownloadEngineFetcher<'_> { + fn modrinth_file_by_sha512<'a>( + &'a self, + sha512: &'a str, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { + let transport = EngineTransport::new(self.engine); + let client = Modrinth::new(&transport); + let version = client + .version_by_hash(HashAlgorithm::Sha512, sha512) + .await + .map_err(FetchError::from)?; + let identity = identity_of(&version, sha512).ok_or_else(|| { + FetchError::new( + FetchErrorKind::NotFound, + format!("站点返回的版本里没有 sha512 与 {sha512} 相同的文件"), + ) + })?; + Ok(ModrinthFileMeta { + sha1: Some(identity.sha1), + size: identity.size, + url: identity.url, + file_name: version + .primary_file() + .map(|file| file.filename.clone()) + .filter(|name| !name.trim().is_empty()), + }) + }) + } + fn fetch_file<'a>( &'a self, request: FetchRequest<'a>, ) -> FetchFuture<'a, Result> { Box::pin(async move { - let mut download = DownloadRequest::new( - ResourceKind::ModFile, + // sha1 交给引擎流式校验、sha512 由 `download_verified` 落盘后复核; + // 失败重试与「不留半截文件」也在那一层(见 `online::download` 的模块文档)。 + let fetcher = EngineArtifactFetcher::new(self.engine); + let done = download_verified( + &fetcher, request.url, - request.dest.to_path_buf(), - ); - // 摘要长度不对就当"没给":引擎白跑一趟网络比这更贵。 - if let Some(sha1) = request.sha1 { - let integrity = Integrity::sha1(sha1); - if integrity.is_well_formed() { - download.integrity = Some(integrity); - } - } - download.expected_size = request.size; - let outcome = self.engine.download(download).await?; - Ok(FetchedFile { - bytes: outcome.bytes, - }) + request.sha1, + request.sha512, + request.size, + request.dest, + ) + .await + .map_err(FetchError::from)?; + Ok(FetchedFile { bytes: done.bytes }) }) } } diff --git a/crates/core/src/addon/import/phase.rs b/crates/core/src/addon/import/phase.rs index d5caa0c..81bd2f7 100644 --- a/crates/core/src/addon/import/phase.rs +++ b/crates/core/src/addon/import/phase.rs @@ -158,6 +158,7 @@ pub(crate) async fn fetch_remote( url: &resolved.url, dest: &dest, sha1: resolved.sha1.as_deref(), + sha512: resolved.sha512.as_deref(), size: resolved.size, }) .await @@ -225,6 +226,7 @@ struct Resolved { path: String, url: String, sha1: Option, + sha512: Option, size: Option, } @@ -250,6 +252,7 @@ async fn resolve( path: path.clone(), url, sha1: entry.file.sha1().map(str::to_string), + sha512: entry.file.sha512().map(str::to_string), size: entry.file.size, })); } @@ -276,6 +279,8 @@ async fn resolve( path: format!("{dir}/{}", meta.file_name), url, sha1: meta.sha1, + // CF 侧没有 sha512 可核(官方只给 sha1);空着就是「不校验这一档」。 + sha512: None, size: meta.size, })) } diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index c4304ae..c7c87d8 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -43,11 +43,26 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { let mut report = ImportReport::empty(request.target.to_path_buf()); tell(&request.progress, ImportStage::Probe, 0, 0, None); - let (archive, plan) = match probe_and_plan(request) { + let (archive, mut plan) = match probe_and_plan(request) { Ok(pair) => pair, Err(error) => return abandoned(report, error), }; account_plan(&mut report, &plan); + // M162:远端条目补全(缺 `sha1` 的 mrpack 条目按 `sha512` 反查)。补不出的仍如实计数, + // 因此这一步**不影响**三态,只是让后面的摘要校验与合并判定有期望值可用。 + let completion = super::complete::complete(request, &mut plan).await; + if completion.filled > 0 || completion.offline { + let mut message = format!( + "按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)", + completion.filled, completion.unfilled + ); + if completion.offline { + message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置"); + } + report + .warnings + .push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message)); + } let baseline = { let (baseline, warnings) = load_baseline(request.target, &plan); diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index e92f160..c6cd980 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -55,6 +55,8 @@ pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed"; /// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。 pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown"; +/// 注记:这些远端条目按 sha512 从 Modrinth 补全了 sha1(补不出的另有计数)。 +pub const NOTE_REMOTE_COMPLETED: &str = "pack.import.note.remote_completed"; /// 缺失:调用方没给 fetcher(离线导入)。 pub const MISSING_OFFLINE: &str = "pack.import.missing.offline"; @@ -100,6 +102,7 @@ pub const ALL: &[&str] = &[ NOTE_ROLLBACK_INCOMPLETE, NOTE_OVERRIDE_SHADOWED, NOTE_REMOTE_PATH_UNKNOWN, + NOTE_REMOTE_COMPLETED, MISSING_OFFLINE, MISSING_NO_SOURCE, MISSING_FETCH_FAILED, diff --git a/crates/core/src/addon/import/scan.rs b/crates/core/src/addon/import/scan.rs index 87eb0be..3886ce9 100644 --- a/crates/core/src/addon/import/scan.rs +++ b/crates/core/src/addon/import/scan.rs @@ -43,6 +43,7 @@ use super::decision::ConflictDecisions; use super::error::ImportError; use super::merge; use super::pipeline::{load_baseline, probe_and_plan}; +use super::plan::ImportPlan; use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget}; use super::ImportRequest; use crate::download::verify; @@ -170,7 +171,7 @@ impl ImportScan { } } -/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线)。 +/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线、**不联网**)。 #[must_use] pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan { let mut scan = ImportScan::empty(request.target.to_path_buf()); @@ -181,7 +182,45 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan { return scan; } }; - let (baseline, warnings) = load_baseline(request.target, &plan); + fill(request, &plan, &mut scan); + scan +} + +/// 跑一次预检,**先补全再判**(M162):缺 `hashes.sha1` 的 mrpack 条目按 `sha512` 反查 Modrinth。 +/// +/// 补出来的条目与清单里本来就带 `sha1` 的条目走**同一个** [`judge`](判据一行没改); +/// 补不出的仍进 [`ImportScan::unknown_remote`] 并留同一条注记——计数口径不变,只是「能判的变多了」。 +/// +/// 没有挂 fetcher(离线导入)时等价于 [`scan_pack`]:不发任何请求,计数照旧。 +#[must_use] +pub async fn scan_pack_online(request: &ImportRequest<'_>) -> ImportScan { + let mut scan = ImportScan::empty(request.target.to_path_buf()); + let mut plan = match probe_and_plan(request) { + Ok((_, plan)) => plan, + Err(error) => { + scan.error = Some(error); + return scan; + } + }; + let completion = super::complete::complete(request, &mut plan).await; + fill(request, &plan, &mut scan); + if completion.filled > 0 { + let mut message = format!( + "预检前按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)", + completion.filled, completion.unfilled + ); + if completion.offline { + message.push_str(";补全因未联网提前停止"); + } + scan.notes + .push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message)); + } + scan +} + +/// 预检的主体:把「已组好的计划」判成冲突清单(两个入口共用,判据只此一处)。 +fn fill(request: &ImportRequest<'_>, plan: &ImportPlan, scan: &mut ImportScan) { + let (baseline, warnings) = load_baseline(request.target, plan); scan.notes = warnings; scan.format = Some(plan.format.clone()); scan.pack_name = Some(plan.manifest.name.clone()); @@ -244,7 +283,6 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan { } scan.conflicts .sort_by(|left, right| left.path.cmp(&right.path)); - scan } /// 判一个路径:`Some` = 要用户点头的冲突。 diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index 0030341..7a4a44e 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -19,11 +19,18 @@ //! 并逐条进报告;`.disabled` 用路径后缀;`game`/`dependencies` 从实例快照取。 //! MultiMC/Prism **只导入不导出**(有意省略)。 //! -//! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。 +//! - [`online`](P2 · M162):**在线内容源**——先只做匿名可用的 Modrinth(`/v2/search` / +//! `/v2/project` / `/v2/project/{id}/version` / `/v2/version_file/{hash}`)。网络出口只有 +//! `Transport` 一个 trait(生产实现走 `core::download` 的引擎);下载走引擎并**复核 +//! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。 +//! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。 +//! +//! 规划中:本地 mod 管理(D10 §3)、CurseForge 客户端(§5)。 pub mod export; pub mod import; pub mod loader; +pub mod online; pub mod pack; pub use export::{ @@ -31,15 +38,21 @@ pub use export::{ RemoteHit, }; pub use import::{ - import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy, - ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport, - ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline, - PackFetcher, RejectedComponent, + import_pack, scan_pack, scan_pack_online, ConflictChoice, ConflictDecisions, ConflictFile, + ConflictPolicy, ImportConflict, ImportError, ImportErrorKind, ImportProgress, + ImportProgressSink, ImportReport, ImportRequest, ImportScan, ImportStage, ImportStatus, + ImportedFile, MissingFile, PackBaseline, PackFetcher, RejectedComponent, }; pub use loader::{ LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSource, LoaderSupport, }; +pub use online::{ + complete_files, download_verified, ArtifactFetcher, CompletionSummary, ContentType, Downloaded, + EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, + ProjectHit, SearchPage, SearchQuery, SortIndex, Transport, Version, VersionFile, VersionQuery, + MODRINTH_API, +}; pub use pack::{ EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits, PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide, diff --git a/crates/core/src/addon/online/complete.rs b/crates/core/src/addon/online/complete.rs new file mode 100644 index 0000000..f22a52f --- /dev/null +++ b/crates/core/src/addon/online/complete.rs @@ -0,0 +1,125 @@ +//! 远端条目补全:**mrpack 的 `files[]` 只有 `path`+`downloads`+`hashes` 时,按摘要反查补 sha1**。 +//! +//! # 补的是什么(以及为什么不许猜) +//! +//! mrpack 规范里 `files[].hashes.sha512` 必填、`sha1` 可选。少了 `sha1` 的条目在 M160 的预检里 +//! 判不了「会不会覆盖用户文件」(判据是 SHA-1),只能记进 `ImportScan::unknown_remote`。 +//! 现在按 `sha512` 去 Modrinth 反查:查得到就把**站点给的 sha1**填回去(那是同一个文件在同一 +//! 站点上的官方摘要,不是本地推算),查不到就**保持缺**、照旧计数——绝不「猜一个路径填上」, +//! 也绝不拿 sha512 冒充 sha1。 +//! +//! # 一致性检查(反查结果必须能对上) +//! +//! 站点返回的版本里可能不止一个文件(`primary` 之外还有 `sources.jar`)。所以只在 +//! **`hashes.sha512` 与请求值逐字相同**的那个文件上取 `sha1`;一个都对不上就当「补不出」 +//! ——宁可老实计数,也不把别的东西的摘要装进来。 +//! +//! # 离线时立刻停 +//! +//! 无网时逐条重试是拿 N 次超时惩罚用户。第一条网络类失败就把 [`CompletionSummary::offline`] +//! 置位并停止请求,剩下的条目一律算「补不出」(预检的注记本来就会说「有 N 条判不了」)。 + +use super::model::Version; +use super::modrinth::{HashAlgorithm, Modrinth}; +use super::transport::Transport; +use crate::addon::pack::PackFile; + +/// 一次补全的结果(**只做计数与一句事实,不改任何判定口径**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CompletionSummary { + /// 补出了 sha1 的条目数 + pub completed: usize, + /// 补不出的条目数(缺 sha512 / 站点查不到 / 摘要对不上) + pub unfilled: usize, + /// 是否因为「没联网」提前收工 + pub offline: bool, +} + +impl CompletionSummary { + /// 有没有真的补到东西。 + #[must_use] + pub const fn changed(&self) -> bool { + self.completed > 0 + } +} + +/// 逐条补 `sha1`(就地改 `files`,返回值只做计数)。 +pub async fn complete_files( + client: &Modrinth<'_, T>, + files: &mut [PackFile], +) -> CompletionSummary { + let mut summary = CompletionSummary::default(); + for file in files.iter_mut() { + if file.sha1().is_some() { + // 已经有 sha1:不需要补(也不该覆盖清单里的声明)。 + continue; + } + let Some(sha512) = file.sha512().map(str::to_string) else { + summary.unfilled += 1; + continue; + }; + if summary.offline { + summary.unfilled += 1; + continue; + } + match client.version_by_hash(HashAlgorithm::Sha512, &sha512).await { + Ok(version) => match identity_of(&version, &sha512) { + Some(identity) => { + apply(file, &identity); + summary.completed += 1; + } + None => summary.unfilled += 1, + }, + Err(error) => { + if error.is_offline() { + summary.offline = true; + } + summary.unfilled += 1; + } + } + } + summary +} + +/// 从版本里找出**摘要对得上**的那个文件,取出它的 sha1/大小/地址。 +/// +/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过)。 +#[must_use] +pub fn identity_of(version: &Version, sha512: &str) -> Option { + let file = version.files.iter().find(|file| { + file.sha512() + .is_some_and(|hex| hex.eq_ignore_ascii_case(sha512)) + })?; + let sha1 = file.sha1().map(str::to_string)?; + Some(Identity { + sha1, + size: (file.size > 0).then_some(file.size), + url: (!file.url.trim().is_empty()).then(|| file.url.clone()), + }) +} + +/// 补全出来的一份身份。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + /// 站点给的 SHA-1 + pub sha1: String, + /// 站点给的字节数(`0` = 没给) + pub size: Option, + /// 站点给的直链(`None` = 也没给) + pub url: Option, +} + +/// 把补全结果写回条目:**只填空缺,不覆盖清单里已有的值**。 +fn apply(file: &mut PackFile, identity: &Identity) { + file.hashes + .entry("sha1".to_string()) + .or_insert_with(|| identity.sha1.clone()); + if file.size.is_none() { + file.size = identity.size; + } + if file.downloads.iter().all(|url| url.trim().is_empty()) { + if let Some(url) = &identity.url { + file.downloads.push(url.clone()); + } + } +} diff --git a/crates/core/src/addon/online/download.rs b/crates/core/src/addon/online/download.rs new file mode 100644 index 0000000..72cdd7b --- /dev/null +++ b/crates/core/src/addon/online/download.rs @@ -0,0 +1,288 @@ +//! 「取到下载地址」之后的最后一步:**下载 → 复核 sha1/sha512 → 才算数**。 +//! +//! # 三条硬要求(任务书原文) +//! +//! 1. **走既有分块下载引擎与镜像策略**:生产实现 [`EngineArtifactFetcher`] 只把请求翻译成 +//! [`DownloadRequest`](`ResourceKind::ModFile` ⇒ 引擎按大文件分块下载,镜像展开、地区模式、 +//! 健康度、退避全在引擎里;Modrinth 的 CDN 不在 `mirrors.json` 的规则里,所以只有国内环境 +//! 真的命中规则时才会换源,不会瞎改写); +//! 2. **校验 sha1/sha512 后再写入**:引擎只认 sha1(`Integrity` 只有 Sha1/Sha256 两档), +//! 所以 sha1 交给引擎流式校验、sha512 由本模块**落盘后复核**;任何一项不符都删掉目标文件 +//! (`download` 模块的 `HashAlgo` 没有 Sha512 是事实,改它超出 M162 的 scope,已登记 finding); +//! 3. **失败可重试且不留半截文件**:每次尝试前先删目标文件,尝试次数 [`RETRY_ATTEMPTS`]; +//! 半截文件本身由引擎的 `PartFile` 守卫(`.ullpart--` + 原子改名)兜住, +//! 本模块不另造临时目录范式。 +//! +//! # 为什么摘要复核不信任 fetcher +//! +//! [`ArtifactFetcher`] 是注入点(测试喂假字节)。如果「校验」放在某个具体实现里,测试注入的 +//! 实现就成了「不校验也能过」的后门——而生产实现与测试实现走的是同一段 [`download_verified`]。 +//! 所以复核在**外层**:读一遍落盘文件,同时算 sha1 与 sha512,与声明逐字比对。 + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha1::Digest as _; +use sha2::Sha512; + +use crate::download::{DownloadEngine, DownloadRequest, Integrity, ResourceKind}; + +use super::modrinth::HashAlgorithm; +use super::transport::{OnlineError, OnlineErrorKind, OnlineFuture}; + +/// 最多尝试几次(含首次)。3 次 = 镜像/健康度换源之外还给一次真重试。 +pub const RETRY_ATTEMPTS: u32 = 3; + +/// 一次下载请求(只含「引擎需要什么」,不含任何站点字段)。 +#[derive(Debug, Clone, Copy)] +pub struct FetchArgs<'a> { + /// 逻辑下载地址(官方源;镜像展开在引擎里) + pub url: &'a str, + /// 落盘路径(父目录由调用方保证,或由本模块建) + pub dest: &'a Path, + /// 期望 SHA-1(形状不对当「没给」,见 [`well_formed`]) + pub sha1: Option<&'a str>, + /// 期望字节数 + pub size: Option, +} + +/// 「把 URL 下到 dest」的注入点(测试喂假字节)。 +pub trait ArtifactFetcher: Send + Sync { + /// 下到 `dest`,返回落盘字节数。 + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result>; +} + +/// 生产实现:交给 `core::download` 的引擎。 +#[derive(Debug, Clone, Copy)] +pub struct EngineArtifactFetcher<'a> { + engine: &'a DownloadEngine, +} + +impl<'a> EngineArtifactFetcher<'a> { + /// 绑定引擎。 + #[must_use] + pub const fn new(engine: &'a DownloadEngine) -> Self { + Self { engine } + } +} + +impl ArtifactFetcher for EngineArtifactFetcher<'_> { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + let mut request = + DownloadRequest::new(ResourceKind::ModFile, args.url, args.dest.to_path_buf()); + if let Some(sha1) = args.sha1.filter(|hex| HashAlgorithm::Sha1.accepts(hex)) { + request.integrity = Some(Integrity::sha1(sha1)); + } + request.expected_size = args.size; + let outcome = self.engine.download(request).await?; + Ok(outcome.bytes) + }) + } +} + +/// 一个文件的摘要(读一遍盘同时算两个)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Digest { + /// SHA-1(小写十六进制) + pub sha1: String, + /// SHA-512(小写十六进制) + pub sha512: String, + /// 字节数 + pub bytes: u64, +} + +/// 一次成功下载的产物。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Downloaded { + /// 落盘路径 + pub path: PathBuf, + /// 字节数 + pub bytes: u64, + /// 落盘内容的摘要(**实测**,不是声明) + pub digest: Digest, +} + +/// 下载并复核(sha1 **与** sha512,给了哪个就校验哪个)。 +/// +/// # Errors +/// +/// - 声明的摘要是 `Some` 但形状不对 ⇒ [`OnlineErrorKind::Unsupported`],**不发请求**; +/// - 没有下载地址 ⇒ `Unsupported`; +/// - 重试用尽仍是网络/站点/摘要问题 ⇒ 最后一次的错误(且目标文件已被删掉); +/// - 本地 IO ⇒ `Io`。 +pub async fn download_verified( + fetcher: &dyn ArtifactFetcher, + url: &str, + sha1: Option<&str>, + sha512: Option<&str>, + size: Option, + dest: &Path, +) -> Result { + if url.trim().is_empty() { + return Err(OnlineError::unsupported("没有可用下载地址")); + } + let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?; + let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + OnlineError::new( + OnlineErrorKind::Io, + format!("建目录 {} 失败:{error}", parent.display()), + ) + })?; + } + + let mut last: Option = None; + for attempt in 0..RETRY_ATTEMPTS { + // 每次尝试前清掉目标:上一轮的半截/坏内容不许参与本轮判定。 + remove_quiet(dest); + let fetched = fetcher + .fetch(FetchArgs { + url, + dest, + sha1, + size, + }) + .await; + let bytes = match fetched { + Ok(bytes) => bytes, + Err(error) if retryable(error.kind()) && attempt + 1 < RETRY_ATTEMPTS => { + last = Some(error); + continue; + } + Err(error) => { + remove_quiet(dest); + return Err(error); + } + }; + if let Some(expected) = size { + if expected != bytes { + last = Some(OnlineError::integrity(format!( + "字节数与声明不符:声明 {expected},实际 {bytes}" + ))); + remove_quiet(dest); + continue; + } + } + let digest = digest_file(dest)?; + if let Some(expected) = sha1 { + if !equals_hex(expected, &digest.sha1) { + last = Some(OnlineError::integrity(format!( + "SHA-1 与声明不符(声明 {expected},实际 {})", + digest.sha1 + ))); + remove_quiet(dest); + continue; + } + } + if let Some(expected) = sha512 { + if !equals_hex(expected, &digest.sha512) { + last = Some(OnlineError::integrity(format!( + "SHA-512 与声明不符(声明 {expected},实际 {})", + digest.sha512 + ))); + remove_quiet(dest); + continue; + } + } + return Ok(Downloaded { + path: dest.to_path_buf(), + bytes, + digest, + }); + } + remove_quiet(dest); + Err(last.unwrap_or_else(|| { + OnlineError::new( + OnlineErrorKind::NotConnected, + "下载重试用尽(没有拿到任何字节)", + ) + })) +} + +/// 读一个文件同时算 sha1 与 sha512。 +/// +/// # Errors +/// 读盘失败返回 [`OnlineErrorKind::Io`]。 +pub fn digest_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path).map_err(|error| { + OnlineError::new( + OnlineErrorKind::Io, + format!("读 {} 失败:{error}", path.display()), + ) + })?; + let mut sha1 = sha1::Sha1::new(); + let mut sha512 = Sha512::new(); + let mut buffer = [0u8; 64 * 1024]; + let mut bytes = 0u64; + loop { + let read = file.read(&mut buffer).map_err(|error| { + OnlineError::new( + OnlineErrorKind::Io, + format!("读 {} 失败:{error}", path.display()), + ) + })?; + if read == 0 { + break; + } + bytes += read as u64; + sha1.update(&buffer[..read]); + sha512.update(&buffer[..read]); + } + Ok(Digest { + sha1: to_hex(&sha1.finalize()), + sha512: to_hex(&sha512.finalize()), + bytes, + }) +} + +/// 摘要形状合法吗(长度 + 十六进制)。 +/// +/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`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() + ))), + } +} + +/// 十六进制比较(大小写不敏感:声明侧可能大写)。 +fn equals_hex(expected: &str, actual: &str) -> bool { + expected.len() == actual.len() && expected.eq_ignore_ascii_case(actual) +} + +/// 这个错误值得再试一次吗(网络/站点/内容不符;**404 与自身不自洽一律不重试**)。 +fn retryable(kind: OnlineErrorKind) -> bool { + matches!( + kind, + OnlineErrorKind::NotConnected | OnlineErrorKind::Server | OnlineErrorKind::Integrity + ) +} + +fn remove_quiet(path: &Path) { + if let Err(error) = std::fs::remove_file(path) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("[online] 删 {} 失败:{error}", path.display()); + } + } +} + +/// 小写十六进制(不引 `hex` 依赖;与 `download::verify::to_hex` 同一写法)。 +#[must_use] +pub fn to_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + out +} diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs new file mode 100644 index 0000000..b71bede --- /dev/null +++ b/crates/core/src/addon/online/mod.rs @@ -0,0 +1,65 @@ +//! 在线内容源(M162):**只做匿名可用的那半**——Modrinth v2。 +//! +//! # 为什么形状是 Modrinth 的,而不是「内容源通用抽象」 +//! +//! CurseForge 的检索/文件端点要 `x-api-key`(D10 §5 的客户端还没写),而它的 JSON 方言与 +//! Modrinth 几乎没有一处同名(`project_id` vs `id`、`description` vs `summary`、 +//! `downloads` vs `downloadCount`、版本列表还要再套一层 `latestFilesIndexes`)。为了让两者 +//! 共用而发明一套谁都不像的抽象,代价是两边都得翻译两次、错一处两边都错。 +//! +//! 所以本模块**按 Modrinth 的真实形状建模**(`/v2/search` 的 facets、`/v2/project/{id}`、 +//! `/v2/project/{id}/version`、`/v2/version_file/{hash}`),把「平台差异」留到将来真要加第二个 +//! 源时再抽。留的缝只有两处: +//! +//! | 缝 | 在哪 | 换源时要动什么 | +//! |----|------|----------------| +//! | [`Transport`] | 唯一的 HTTP 出口(trait) | 换/加一个实现;测试注入录制 JSON | +//! | [`Modrinth::new`] | `Modrinth<'a, T: Transport + ?Sized>` | 传 `&dyn Transport` 就能换站点客户端,一行业务代码不改 | +//! +//! # 网络层(零新依赖) +//! +//! `Cargo.lock` 里已有的 `reqwest` 0.12 + `tokio` + `serde_json` 就是全部。生产实现 +//! [`EngineTransport`] 走 `DownloadEngine::download_bytes`,于是**信号量限流、源健康度、 +//! 指数退避、错误分类**全由 `core::download` 提供,本模块不写第二个重试循环,也不持有第二个 +//! `reqwest::Client`(与 `addon::import::fetch` 同一条纪律)。 +//! +//! 没有 POST:Modrinth 的批量接口(`POST /v2/version_files`)需要 POST 请求体,而引擎的 +//! `DownloadRequest` 没有请求体字段。补全路径因此按**单摘要 GET** 逐条查(见 [`complete`]), +//! mrpack 里缺 `sha1` 的条目本来就是少数,不值得为它引入第二条 HTTP 路径。 +//! +//! # 离线不是「空结果」 +//! +//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串: +//! 网络类失败一律映射成 [`OnlineErrorKind::NotConnected`](见 [`OnlineError`]), +//! presenter 再把它翻成 DTO 的 `ContentFailureKind::Offline`。 +//! +//! # 模块 +//! +//! | 文件 | 职责 | +//! |------|------| +//! | [`model`] | Modrinth 的响应形状 + 检索请求(facets / 排序 / 分页) | +//! | [`transport`] | HTTP 出口 trait + 生产实现(走下载引擎)+ 错误分类 | +//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | +//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | +//! | [`complete`] | 远端条目补全:缺 `sha1` 的 mrpack 条目按 `sha512` 反查 | + +pub mod complete; +pub mod download; +pub mod model; +pub mod modrinth; +pub mod transport; + +pub use complete::{complete_files, CompletionSummary}; +pub use download::{ + digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, + FetchArgs, RETRY_ATTEMPTS, +}; +pub use model::{ + ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex, + Version, VersionFile, VersionQuery, VersionType, +}; +pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API}; +pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport}; + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/addon/online/model/mod.rs b/crates/core/src/addon/online/model/mod.rs new file mode 100644 index 0000000..5faf941 --- /dev/null +++ b/crates/core/src/addon/online/model/mod.rs @@ -0,0 +1,37 @@ +//! Modrinth v2 的响应形状与检索请求(**按站点原文建模,不翻译成「通用内容模型」**)。 +//! +//! 每个公开类型都对着一个真实端点: +//! +//! | 类型 | 端点 | +//! |------|------| +//! | [`SearchQuery`] → [`SearchPage`] | `GET /v2/search` | +//! | [`ProjectDetail`] | `GET /v2/project/{id or slug}` | +//! | [`VersionQuery`] → [`Vec`] | `GET /v2/project/{id}/version` | +//! | [`Version`] | `GET /v2/version_file/{hash}?algorithm=sha1\|sha512` | +//! +//! # 解析纪律 +//! +//! 站点会加字段、也会给 `""` 表示「没有」——所以: +//! +//! - 一律 `#[serde(default)]`,**没给就是空的**,缺字段不炸整页; +//! - 只把**形状**当硬要求(`hits` 必须是数组、project 必须是对象),形状不对就是 +//! [`super::OnlineErrorKind::Malformed`],不静默给空页冒充「没结果」; +//! - 未知字段不保留(这里不是导入导出的无损通道,是只读检索)。 +//! +//! # 为什么拆成三个文件 +//! +//! 一个 600+ 行的 `model.rs` 已经越过本仓的 400 行硬线,而且三种东西混在一起时最容易看漏 +//! 「站点字段名与 DTO 字段名不是一回事」:请求形状([`query`])、响应形状([`types`])、 +//! 以及把前者读成后者的解析([`parse`])。拆开后 `parse` 里每一处 `#[serde(rename)]`/ +//! `alias` 都能单独读。 + +mod parse; +mod query; +mod types; + +pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions}; +pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS}; +pub use types::{ + FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery, + VersionType, +}; diff --git a/crates/core/src/addon/online/model/parse.rs b/crates/core/src/addon/online/model/parse.rs new file mode 100644 index 0000000..e7d1ceb --- /dev/null +++ b/crates/core/src/addon/online/model/parse.rs @@ -0,0 +1,257 @@ +//! 站点的原始 JSON → 上面那些类型(**唯一**读字段名的地方)。 +//! +//! 每个 `#[serde(default)]` 都在说「这个字段站点可能不给」;`alias` 只在有历史原因时用 +//! (例如检索命中的日期字段曾按 `updated` 读,站点实际叫 `date_modified`)。 + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use super::query::KNOWN_LOADERS; +use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType}; +use crate::addon::online::transport::{OnlineError, OnlineErrorKind}; + +// --------------------------------------------------------------- 解析 + +#[derive(Debug, Default, Deserialize)] +struct RawSearch { + #[serde(default)] + hits: Vec, + #[serde(default)] + total_hits: u64, + #[serde(default)] + offset: u32, + #[serde(default)] + limit: u32, +} + +#[derive(Debug, Default, Deserialize)] +struct RawHit { + #[serde(default)] + project_id: String, + #[serde(default)] + slug: String, + #[serde(default)] + title: String, + #[serde(default)] + description: String, + #[serde(default)] + author: String, + #[serde(default)] + downloads: u64, + #[serde(default)] + follows: u64, + // 站点的字段名是 `date_modified`(旧实现的 `updated` 永远取不到值,别再加回去当主名)。 + #[serde(default, alias = "updated")] + date_modified: String, + #[serde(default)] + versions: Vec, + #[serde(default)] + categories: Vec, + #[serde(default)] + project_type: String, + #[serde(default)] + icon_url: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawProject { + #[serde(default)] + id: String, + #[serde(default)] + slug: String, + #[serde(default)] + title: String, + #[serde(default)] + description: String, + #[serde(default)] + body: String, + #[serde(default)] + project_type: String, + #[serde(default)] + downloads: u64, + #[serde(default)] + followers: u64, + #[serde(default)] + updated: String, + #[serde(default)] + published: String, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + loaders: Vec, + #[serde(default)] + categories: Vec, + #[serde(default)] + icon_url: Option, + #[serde(default)] + source_url: Option, + #[serde(default)] + issues_url: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawVersion { + #[serde(default)] + id: String, + #[serde(default)] + project_id: String, + #[serde(default)] + name: String, + #[serde(default)] + version_number: String, + #[serde(default)] + version_type: String, + #[serde(default)] + loaders: Vec, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + date_published: String, + #[serde(default)] + downloads: u64, + #[serde(default)] + files: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct RawFile { + #[serde(default)] + hashes: BTreeMap, + #[serde(default)] + url: String, + #[serde(default)] + filename: String, + #[serde(default)] + size: u64, + #[serde(default)] + primary: bool, +} + +/// 解析 `/v2/search`。 +/// +/// # Errors +/// JSON 不合法、或没有 `hits` 数组时返回 [`OnlineErrorKind::Malformed`]。 +pub fn parse_search(body: &str, offset: u32, limit: u32) -> Result { + let raw: RawSearch = parse_json(body, "search")?; + Ok(SearchPage { + hits: raw.hits.into_iter().map(hit_of).collect(), + total_hits: raw.total_hits, + // 站点会回显 offset/limit;缺省用请求里的(分页游标不能靠回显才成立) + offset: raw.offset.max(offset), + limit: if raw.limit == 0 { limit } else { raw.limit }, + }) +} + +/// 解析 `/v2/project/{id}`。 +/// +/// # Errors +/// JSON 不合法时返回 [`OnlineErrorKind::Malformed`]。 +pub fn parse_project(body: &str) -> Result { + let raw: RawProject = parse_json(body, "project")?; + Ok(ProjectDetail { + id: raw.id, + slug: raw.slug, + title: raw.title, + description: raw.description, + body: raw.body, + project_type: raw.project_type, + downloads: raw.downloads, + follows: raw.followers, + updated: raw.updated, + published: raw.published, + game_versions: raw.game_versions, + loaders: raw.loaders, + categories: raw.categories, + icon_url: non_empty(raw.icon_url), + source_url: non_empty(raw.source_url), + issues_url: non_empty(raw.issues_url), + }) +} + +/// 解析 `/v2/project/{id}/version`(一个数组)。 +/// +/// # Errors +/// JSON 不合法或不是数组时返回 [`OnlineErrorKind::Malformed`]。 +pub fn parse_versions(body: &str) -> Result, OnlineError> { + let raw: Vec = + serde_json::from_str(body).map_err(|error| malformed("versions", error))?; + Ok(raw.into_iter().map(version_of).collect()) +} + +/// 解析 `/v2/version_file/{hash}`(单个版本对象)。 +/// +/// # Errors +/// JSON 不合法时返回 [`OnlineErrorKind::Malformed`]。 +pub fn parse_version(body: &str) -> Result { + let raw: RawVersion = parse_json(body, "version_file")?; + Ok(version_of(raw)) +} + +fn parse_json Deserialize<'de>>(body: &str, what: &str) -> Result { + serde_json::from_str(body).map_err(|error| malformed(what, error)) +} + +fn malformed(what: &str, error: serde_json::Error) -> OnlineError { + OnlineError::new( + OnlineErrorKind::Malformed, + format!("Modrinth {what} 响应不是期望形状:{error}"), + ) +} + +fn hit_of(raw: RawHit) -> ProjectHit { + ProjectHit { + project_id: raw.project_id, + slug: raw.slug, + title: raw.title, + description: raw.description, + author: raw.author, + downloads: raw.downloads, + follows: raw.follows, + updated: raw.date_modified, + loaders: known_loaders(&raw.categories), + game_versions: raw.versions, + project_type: raw.project_type, + icon_url: non_empty(raw.icon_url), + } +} + +fn version_of(raw: RawVersion) -> Version { + Version { + id: raw.id, + project_id: raw.project_id, + name: raw.name, + version_number: raw.version_number, + version_type: VersionType::parse(&raw.version_type), + loaders: raw.loaders, + game_versions: raw.game_versions, + date_published: raw.date_published, + downloads: raw.downloads, + files: raw + .files + .into_iter() + .map(|file| VersionFile { + hashes: file.hashes, + url: file.url, + filename: file.filename, + size: file.size, + primary: file.primary, + }) + .collect(), + } +} + +/// 只留已知加载器(`categories` 里混着分类标签)。 +#[must_use] +pub fn known_loaders(categories: &[String]) -> Vec { + categories + .iter() + .filter(|tag| KNOWN_LOADERS.contains(&tag.as_str())) + .cloned() + .collect() +} + +/// 空串当「没有」(站点用 `""` 表示没图 / 没链接)。 +fn non_empty(value: Option) -> Option { + value.filter(|item| !item.trim().is_empty()) +} diff --git a/crates/core/src/addon/online/model/query.rs b/crates/core/src/addon/online/model/query.rs new file mode 100644 index 0000000..6c660e5 --- /dev/null +++ b/crates/core/src/addon/online/model/query.rs @@ -0,0 +1,152 @@ +//! 检索请求的形状(facet / 排序 / 分页)——站点要什么就写什么,不预先「通用化」。 +//! +//! 两个容易踩的点: +//! +//! 1. `facets` 是**二维数组的 JSON 文本**(外层「与」、内层「或」),要再整体百分号编码; +//! 2. `categories` 里混着分类标签(`optimization`),只有 [`KNOWN_LOADERS`] 那几个才是加载器。 + +/// Modrinth 的 `project_type` 取值(检索 facet 与项目详情的同名字段)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ContentType { + /// 整合包 + Modpack, + /// Mod + Mod, + /// 资源包 + Resourcepack, + /// 光影 + Shader, + /// 服务端插件 + Plugin, + /// 数据包 + Datapack, +} + +impl ContentType { + /// 站点原文(`project_type` facet 的值)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Modpack => "modpack", + Self::Mod => "mod", + Self::Resourcepack => "resourcepack", + Self::Shader => "shader", + Self::Plugin => "plugin", + Self::Datapack => "datapack", + } + } + + /// 从站点原文解析;认不出返回 `None`(调用方决定当「未知」还是报错)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + Some(match raw { + "modpack" => Self::Modpack, + "mod" => Self::Mod, + "resourcepack" => Self::Resourcepack, + "shader" => Self::Shader, + "plugin" => Self::Plugin, + "datapack" => Self::Datapack, + _ => return None, + }) + } +} + +/// `/v2/search` 的 `index` 参数。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortIndex { + /// 相关度(站点默认) + #[default] + Relevance, + /// 下载量 + Downloads, + /// 关注数 + Follows, + /// 最新发布 + Newest, + /// 最近更新 + Updated, +} + +impl SortIndex { + /// 站点原文。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Follows => "follows", + Self::Newest => "newest", + Self::Updated => "updated", + } + } +} + +/// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**, +/// 直接整份当加载器会画出假加载器)。 +pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"]; + +/// 一次检索请求(分页游标也在里面:换页就是换一份 query)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchQuery { + /// 关键词(空 = 不带 `query` 参数,即「按排序浏览」) + pub text: String, + /// 内容类型过滤 + pub project_type: Option, + /// 游戏版本过滤(精确值,如 `1.20.1`) + pub game_version: Option, + /// 加载器过滤(站点 facet 值,如 `fabric`) + pub loader: Option, + /// 排序 + pub index: SortIndex, + /// 每页条数(站点上限 100,构造时钳住) + pub limit: u32, + /// 偏移 + pub offset: u32, +} + +impl Default for SearchQuery { + fn default() -> Self { + Self { + text: String::new(), + project_type: None, + game_version: None, + loader: None, + index: SortIndex::Relevance, + limit: 20, + offset: 0, + } + } +} + +impl SearchQuery { + /// 站点上限(超过会被站点拒,钳住更省一次往返)。 + pub const MAX_LIMIT: u32 = 100; + + /// 钳过界的分页参数。 + #[must_use] + pub fn normalized(&self) -> Self { + let mut query = self.clone(); + query.text = query.text.trim().to_string(); + query.limit = query.limit.clamp(1, Self::MAX_LIMIT); + query + } + + /// `facets` 参数(站点要的是**二维数组的 JSON**,每一项是一组「或」)。 + /// + /// 形状:`[["project_type:mod"],["versions:1.20.1"],["categories:fabric"]]`——不同组之间是 + /// 「与」,同组内是「或」。这里每组只有一项。 + #[must_use] + pub fn facets(&self) -> String { + let mut groups: Vec> = Vec::new(); + if let Some(kind) = self.project_type { + groups.push(vec![format!("project_type:{}", kind.as_str())]); + } + if let Some(version) = self.game_version.as_deref().filter(|v| !v.is_empty()) { + groups.push(vec![format!("versions:{version}")]); + } + if let Some(loader) = self.loader.as_deref().filter(|v| !v.is_empty()) { + groups.push(vec![format!("categories:{loader}")]); + } + serde_json::to_string(&groups).unwrap_or_else(|_| "[]".to_string()) + } +} diff --git a/crates/core/src/addon/online/model/types.rs b/crates/core/src/addon/online/model/types.rs new file mode 100644 index 0000000..532fb64 --- /dev/null +++ b/crates/core/src/addon/online/model/types.rs @@ -0,0 +1,231 @@ +//! 响应形状(`/v2/search`、`/v2/project`、`/v2/project/{id}/version`、`/v2/version_file`)。 + +use std::collections::BTreeMap; + +/// 一页检索结果。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SearchPage { + /// 命中项(顺序 = 站点顺序) + pub hits: Vec, + /// 站点给的总数(**估算值**:站点文档不保证精确) + pub total_hits: u64, + /// 请求的偏移 + pub offset: u32, + /// 请求的每页条数 + pub limit: u32, +} + +impl SearchPage { + /// 还有下一页吗。 + /// + /// 判据是「本页装满了」,不是 `offset + len < total`:`total_hits` 是估算值, + /// 用「装满」最多多请求一次空页,不会出现「明明还有却翻不到」。 + #[must_use] + pub fn has_more(&self) -> bool { + self.limit > 0 && self.hits.len() as u32 >= self.limit + } +} + +/// 一条检索命中(`/v2/search` 的 `hits[]`)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProjectHit { + /// 项目 id(base62) + pub project_id: String, + /// 人读 slug + pub slug: String, + /// 标题 + pub title: String, + /// 简介 + pub description: String, + /// 作者(站点给的是团队名;没有时为空) + pub author: String, + /// 下载量 + pub downloads: u64, + /// 关注数 + pub follows: u64, + /// 最近更新(站点原文 ISO-8601) + pub updated: String, + /// 支持的加载器(已按 [`KNOWN_LOADERS`] 过滤) + pub loaders: Vec, + /// 支持的游戏版本 + pub game_versions: Vec, + /// 项目类型原文 + pub project_type: String, + /// 图标 URL(`None` = 没有) + pub icon_url: Option, +} + +impl ProjectHit { + /// 支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。 + #[must_use] + pub fn supports_loader(&self, loader: &str) -> bool { + self.loaders.is_empty() || self.loaders.iter().any(|item| item == loader) + } + + /// 支持某个游戏版本吗(空列表 = 未知 ⇒ 不拦)。 + #[must_use] + pub fn supports_version(&self, version: &str) -> bool { + self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version) + } +} + +/// 项目详情(`/v2/project/{id or slug}`)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProjectDetail { + /// 项目 id + pub id: String, + /// slug + pub slug: String, + /// 标题 + pub title: String, + /// 简介 + pub description: String, + /// 长文(markdown 原文;站点可能给 `""`) + pub body: String, + /// 项目类型原文 + pub project_type: String, + /// 下载量 + pub downloads: u64, + /// 关注数 + pub follows: u64, + /// 最近更新 + pub updated: String, + /// 首次发布 + pub published: String, + /// 游戏版本 + pub game_versions: Vec, + /// 加载器 + pub loaders: Vec, + /// 分类标签 + pub categories: Vec, + /// 图标 + pub icon_url: Option, + /// 项目主页 + pub source_url: Option, + /// 问题追踪 + pub issues_url: Option, +} + +/// 版本类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VersionType { + /// 正式版 + #[default] + Release, + /// 测试版 + Beta, + /// 早期预览 + Alpha, +} + +impl VersionType { + /// 站点原文。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Release => "release", + Self::Beta => "beta", + Self::Alpha => "alpha", + } + } + + /// 从站点原文解析;认不出按正式版(**不**拿它做安装判据)。 + #[must_use] + pub fn parse(raw: &str) -> Self { + match raw { + "beta" => Self::Beta, + "alpha" => Self::Alpha, + _ => Self::Release, + } + } +} + +/// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Version { + /// 版本 id + pub id: String, + /// 所属项目 id + pub project_id: String, + /// 版本名 + pub name: String, + /// 版本号(`1.2.3+fabric`) + pub version_number: String, + /// 版本类型 + pub version_type: VersionType, + /// 支持的加载器 + pub loaders: Vec, + /// 支持的游戏版本 + pub game_versions: Vec, + /// 发布日期 + pub date_published: String, + /// 该版本总下载量 + pub downloads: u64, + /// 文件列表 + pub files: Vec, +} + +impl Version { + /// 拿这个文件:优先 `primary: true`(Modrinth 官方口径),否则第一个。 + /// + /// 不能无脑取第一个:很多项目同时发 `sources.jar` / `-dev.jar`,取第一个会装错东西。 + #[must_use] + pub fn primary_file(&self) -> Option<&VersionFile> { + self.files + .iter() + .find(|file| file.primary) + .or_else(|| self.files.first()) + } +} + +/// 版本里的一个文件。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct VersionFile { + /// 摘要表(键名小写:`sha1` / `sha512` / `md5`;站点给几个就有几个) + pub hashes: BTreeMap, + /// 下载地址 + pub url: String, + /// 文件名 + pub filename: String, + /// 字节数 + pub size: u64, + /// 是否主文件 + pub primary: bool, +} + +impl VersionFile { + /// SHA-1。 + #[must_use] + pub fn sha1(&self) -> Option<&str> { + self.hashes.get("sha1").map(String::as_str) + } + + /// SHA-512。 + #[must_use] + pub fn sha512(&self) -> Option<&str> { + self.hashes.get("sha512").map(String::as_str) + } +} + +/// 摘要表(给需要「按算法取」的调用方用;与 [`VersionFile::hashes`] 同源)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FileHashes(pub BTreeMap); + +impl FileHashes { + /// 按算法取。 + #[must_use] + pub fn get(&self, algorithm: &str) -> Option<&str> { + self.0.get(algorithm).map(String::as_str) + } +} + +/// 版本列表请求(`/v2/project/{id}/version` 的过滤条件)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct VersionQuery { + /// 项目 id 或 slug + pub project: String, + /// 游戏版本过滤(单个精确值) + pub game_version: Option, + /// 加载器过滤(站点 facet 值) + pub loader: Option, +} diff --git a/crates/core/src/addon/online/modrinth.rs b/crates/core/src/addon/online/modrinth.rs new file mode 100644 index 0000000..9183cd7 --- /dev/null +++ b/crates/core/src/addon/online/modrinth.rs @@ -0,0 +1,231 @@ +//! Modrinth v2 的四个端点(**只有一个站点,所以不做「源分派」那一层**)。 +//! +//! | 方法 | 端点 | 拿什么 | +//! |------|------|--------| +//! | [`Modrinth::search`] | `GET /v2/search` | 一页命中项(分页游标在 [`SearchQuery`] 里) | +//! | [`Modrinth::project`] | `GET /v2/project/{id or slug}` | 项目详情(含长文与链接) | +//! | [`Modrinth::versions`] | `GET /v2/project/{id}/version` | 版本列表(可按版本/加载器过滤) | +//! | [`Modrinth::version_by_hash`] | `GET /v2/version_file/{hash}` | 某个文件的所属版本(**补全路径用的那条**) | +//! +//! # 端点拼装与解析都分离出来 +//! +//! `*_url` 是纯函数(单测直接比字符串),`search/project/...` 只做「取文本 → 解析」。 +//! 于是录制 JSON 的离线夹具不需要任何 HTTP 桩就能覆盖全部分支。 +//! +//! # 为什么按摘要查是 GET +//! +//! Modrinth 的批量接口是 `POST /v2/version_files`(请求体是一个 JSON 对象)。引擎的 +//! `DownloadRequest` 没有请求体字段,为它单开一条直发 `client()` 的路径就得自己写退避与限流 +//! (presenter 里为 CF 的 header 已经付过这笔账)。补全路径一次只看一两个摘要,逐条 GET 更划算。 +//! +//! # 百分号编码 +//! +//! 只为 query 串写一个最小实现(`A-Za-z0-9-._~` 不编码),**不引第三方 crate**: +//! `facets` 与 `game_versions` 都是 JSON 文本,方括号引号逗号全要编码,否则站点会当成 +//! 空过滤条件静默忽略——那会让「筛选条说 Fabric、结果里有 Forge」这种谎话成立。 + +use super::model::{ + parse_project, parse_search, parse_version, parse_versions, ProjectDetail, SearchPage, + SearchQuery, Version, VersionQuery, +}; +use super::transport::{OnlineError, Transport}; + +/// Modrinth API 的根(匿名可用,无需 key)。 +pub const MODRINTH_API: &str = "https://api.modrinth.com/v2"; + +/// 按哪个摘要查文件。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HashAlgorithm { + /// SHA-1(mrpack 的 `files[].hashes.sha1`) + #[default] + Sha1, + /// SHA-512(mrpack 的必填摘要) + Sha512, +} + +impl HashAlgorithm { + /// 站点 `algorithm` 参数值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Sha1 => "sha1", + Self::Sha512 => "sha512", + } + } + + /// 十六进制长度(格式自检用;长度不对就别发请求)。 + #[must_use] + pub const fn hex_len(self) -> usize { + match self { + Self::Sha1 => 40, + Self::Sha512 => 128, + } + } + + /// 摘要是否**形状**合法(小写归一 + 十六进制 + 长度)。 + #[must_use] + pub fn accepts(self, hex: &str) -> bool { + hex.len() == self.hex_len() && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + } +} + +/// Modrinth 客户端(只持有 [`Transport`],不持有客户端/连接池)。 +#[derive(Debug, Clone, Copy)] +pub struct Modrinth<'a, T: Transport + ?Sized> { + transport: &'a T, +} + +impl<'a, T: Transport + ?Sized> Modrinth<'a, T> { + /// 绑定传输层。 + #[must_use] + pub const fn new(transport: &'a T) -> Self { + Self { transport } + } + + /// 检索的 URL(纯函数,单测直接钉)。 + #[must_use] + pub fn search_url(&self, query: &SearchQuery) -> String { + let query = query.normalized(); + let mut url = format!( + "{MODRINTH_API}/search?limit={}&offset={}&index={}", + query.limit, + query.offset, + query.index.as_str() + ); + let facets = query.facets(); + if facets != "[]" { + url.push_str(&format!("&facets={}", encode(&facets))); + } + if !query.text.is_empty() { + url.push_str(&format!("&query={}", encode(&query.text))); + } + url + } + + /// 项目详情的 URL。 + #[must_use] + pub fn project_url(&self, id_or_slug: &str) -> String { + format!("{MODRINTH_API}/project/{}", encode(id_or_slug)) + } + + /// 版本列表的 URL。 + #[must_use] + pub fn versions_url(&self, query: &VersionQuery) -> String { + let mut url = format!("{MODRINTH_API}/project/{}/version", encode(&query.project)); + let mut first = true; + if let Some(version) = query + .game_version + .as_deref() + .filter(|item| !item.is_empty()) + { + url.push_str(&format!( + "{}game_versions={}", + separator(&mut first), + encode(&format!("[\"{version}\"]")) + )); + } + if let Some(loader) = query.loader.as_deref().filter(|item| !item.is_empty()) { + url.push_str(&format!( + "{}loaders={}", + separator(&mut first), + encode(&format!("[\"{loader}\"]")) + )); + } + url + } + + /// 按文件摘要查所属版本的 URL。 + #[must_use] + pub fn version_file_url(&self, algorithm: HashAlgorithm, hex: &str) -> String { + format!( + "{MODRINTH_API}/version_file/{}?algorithm={}", + encode(&hex.to_ascii_lowercase()), + algorithm.as_str() + ) + } + + /// 检索一页。 + /// + /// # Errors + /// 传输层失败(见 [`OnlineErrorKind`])或响应形状不符。 + pub async fn search(&self, query: &SearchQuery) -> Result { + let normalized = query.normalized(); + let url = self.search_url(&normalized); + let body = self.transport.get_text(&url).await?; + let mut page = parse_search(&body, normalized.offset, normalized.limit)?; + // 站点没回显时用请求值补齐,分页游标才不会因为缺字段而卡死。 + if page.limit == 0 { + page.limit = normalized.limit; + } + Ok(page) + } + + /// 项目详情。 + /// + /// # Errors + /// 传输层失败或响应形状不符。 + pub async fn project(&self, id_or_slug: &str) -> Result { + let url = self.project_url(id_or_slug); + let body = self.transport.get_text(&url).await?; + parse_project(&body) + } + + /// 版本列表(站点的顺序 = 最新在前)。 + /// + /// # Errors + /// 传输层失败或响应形状不符。 + pub async fn versions(&self, query: &VersionQuery) -> Result, OnlineError> { + let url = self.versions_url(query); + let body = self.transport.get_text(&url).await?; + parse_versions(&body) + } + + /// 按文件摘要查所属版本。 + /// + /// # Errors + /// 摘要形状不对时**不发请求**([`OnlineErrorKind::Unsupported`]);站点 404 是 + /// [`OnlineErrorKind::NotFound`](= 这个摘要不在 Modrinth 上,补全要如实记「补不出」)。 + pub async fn version_by_hash( + &self, + algorithm: HashAlgorithm, + hex: &str, + ) -> Result { + let hex = hex.trim(); + if !algorithm.accepts(hex) { + return Err(OnlineError::unsupported(format!( + "摘要不是合法的 {}:长度 {},要求 {}", + algorithm.as_str(), + hex.len(), + algorithm.hex_len() + ))); + } + let url = self.version_file_url(algorithm, hex); + let body = self.transport.get_text(&url).await?; + parse_version(&body) + } +} + +/// 第一个参数用 `?`,后续用 `&`。 +fn separator(first: &mut bool) -> &'static str { + if *first { + *first = false; + "?" + } else { + "&" + } +} + +/// 最小百分号编码(query 串用;`A-Za-z0-9-._~` 原样,其余按字节编码)。 +#[must_use] +pub fn encode(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for byte in raw.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(char::from(byte)); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/core/src/addon/online/tests/api.rs b/crates/core/src/addon/online/tests/api.rs new file mode 100644 index 0000000..f5ba8ff --- /dev/null +++ b/crates/core/src/addon/online/tests/api.rs @@ -0,0 +1,170 @@ +//! 端点拼装 + 响应解析 + 离线分类(纯离线)。 + +use crate::addon::online::modrinth::{encode, HashAlgorithm, MODRINTH_API}; +use crate::addon::online::{ + ContentType, Modrinth, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery, +}; +use crate::download::blocking::block_on; + +use super::fixtures; +use super::support::FixtureTransport; + +fn query(text: &str) -> SearchQuery { + SearchQuery { + text: text.to_string(), + project_type: Some(ContentType::Mod), + game_version: Some("1.20.1".to_string()), + loader: Some("fabric".to_string()), + index: SortIndex::Downloads, + limit: 2, + offset: 0, + } +} + +#[test] +fn search_url_carries_facets_and_query_in_the_sites_own_shape() { + let transport = FixtureTransport::new(); + let client = Modrinth::new(&transport); + let url = client.search_url(&query("sodium edge")); + assert!(url.starts_with(&format!( + "{MODRINTH_API}/search?limit=2&offset=0&index=downloads" + ))); + // 站点要的是二维数组的 JSON,再整体百分号编码 + let facets = encode(&query("").facets()); + assert!(url.contains(&format!("&facets={facets}")), "url={url}"); + assert!(facets.contains("%5B%5B%22project_type%3Amod%22%5D")); + assert!(url.ends_with("&query=sodium%20edge")); +} + +#[test] +fn empty_text_omits_the_query_parameter() { + let transport = FixtureTransport::new(); + let client = Modrinth::new(&transport); + let url = client.search_url(&SearchQuery::default()); + assert!(!url.contains("&query="), "空关键词不该带 query:{url}"); + assert!( + !url.contains("&facets="), + "没有过滤条件不该带 facets:{url}" + ); +} + +#[test] +fn search_parses_hits_and_filters_fake_loaders() { + let transport = + FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), fixtures::SEARCH); + let client = Modrinth::new(&transport); + let page = block_on(client.search(&query("sodium"))).expect("解析应当成功"); + assert_eq!(page.total_hits, 1234); + assert_eq!(page.hits.len(), 2); + let sodium = &page.hits[0]; + assert_eq!(sodium.project_id, "AANobbMI"); + assert_eq!(sodium.title, "Sodium"); + // `optimization` 不是加载器:只留 fabric / quilt + assert_eq!( + sodium.loaders, + vec!["fabric".to_string(), "quilt".to_string()] + ); + assert!(sodium.supports_version("1.20.1")); + assert!(!sodium.supports_version("1.19.2")); + // 日期读的是 `date_modified`(旧实现读 `updated`,永远是空串) + assert_eq!(sodium.updated, "2026-02-03T10:11:12Z"); + // 空 icon_url 当「没有」 + assert_eq!(page.hits[1].icon_url, None); + // limit=2 且回了 2 条 ⇒ 本页装满 ⇒ 还有下一页(判据不是 total_hits) + assert!(page.has_more()); +} + +#[test] +fn has_more_is_decided_by_a_full_page() { + let mut page = crate::addon::online::SearchPage { + limit: 2, + hits: vec![Default::default(), Default::default()], + ..Default::default() + }; + assert!(page.has_more()); + page.hits.pop(); + assert!(!page.has_more()); +} + +#[test] +fn project_detail_parses_body_links_and_loader_list() { + let transport = + FixtureTransport::new().with(&format!("{MODRINTH_API}/project/sodium"), fixtures::PROJECT); + let client = Modrinth::new(&transport); + let project = block_on(client.project("sodium")).expect("解析应当成功"); + assert_eq!(project.id, "AANobbMI"); + assert_eq!(project.follows, 9912); + assert!(project.body.contains("modern rendering engine")); + assert_eq!( + project.loaders, + vec!["fabric".to_string(), "quilt".to_string()] + ); + assert_eq!( + project.source_url.as_deref(), + Some("https://github.com/CaffeineMC/sodium") + ); +} + +#[test] +fn version_list_prefers_the_primary_file() { + let transport = FixtureTransport::new().with( + &format!("{MODRINTH_API}/project/sodium/version"), + fixtures::VERSIONS, + ); + let client = Modrinth::new(&transport); + let versions = block_on(client.versions(&VersionQuery { + project: "sodium".to_string(), + game_version: Some("1.21.1".to_string()), + loader: Some("fabric".to_string()), + })) + .expect("解析应当成功"); + assert_eq!(versions.len(), 1); + let file = versions[0].primary_file().expect("必须有文件"); + // 取第一个会拿到 sources.jar:必须是 primary 的那个 + assert_eq!(file.filename, "sodium-fabric-0.6.0.jar"); + assert_eq!(file.size, 2048); + // 版本列表请求带了两个数组参数(不是被静默丢掉的空过滤) + let seen = transport.seen(); + assert!( + seen[0].contains("game_versions=%5B%221.21.1%22%5D"), + "{}", + seen[0] + ); + assert!( + seen[0].contains("loaders=%5B%22fabric%22%5D"), + "{}", + seen[0] + ); +} + +#[test] +fn a_hash_shape_mismatch_never_hits_the_network() { + let transport = FixtureTransport::new(); + let client = Modrinth::new(&transport); + let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, "not-a-hash")) + .expect_err("形状不对必须报错"); + assert_eq!(error.kind(), OnlineErrorKind::Unsupported); + assert!( + transport.seen().is_empty(), + "不该发请求:{:?}", + transport.seen() + ); +} + +#[test] +fn offline_is_reported_as_not_connected_not_as_an_empty_page() { + let transport = FixtureTransport::new().offline(&format!("{MODRINTH_API}/search")); + let client = Modrinth::new(&transport); + let error = block_on(client.search(&query("sodium"))).expect_err("没网必须报错"); + assert_eq!(error.kind(), OnlineErrorKind::NotConnected); + assert!(error.is_offline()); +} + +#[test] +fn a_broken_payload_is_malformed_not_empty() { + let transport = + FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), "{\"hits\": 3}"); + let client = Modrinth::new(&transport); + let error = block_on(client.search(&query("sodium"))).expect_err("形状不对必须报错"); + assert_eq!(error.kind(), OnlineErrorKind::Malformed); +} diff --git a/crates/core/src/addon/online/tests/complete.rs b/crates/core/src/addon/online/tests/complete.rs new file mode 100644 index 0000000..28723bf --- /dev/null +++ b/crates/core/src/addon/online/tests/complete.rs @@ -0,0 +1,135 @@ +//! 远端条目补全:能补就补、补不出照旧如实计数、离线立刻停。 + +use crate::addon::online::{complete_files, Modrinth}; +use crate::addon::pack::PackFile; +use crate::download::blocking::block_on; + +use super::fixtures; +use super::support::FixtureTransport; + +const HASH_PREFIX: &str = "https://api.modrinth.com/v2/version_file/"; + +fn entry(sha512: Option<&str>) -> PackFile { + let mut file = PackFile::with_path("mods/sodium-fabric-0.5.11.jar"); + file.downloads + .push("https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium.jar".to_string()); + file.size = None; + if let Some(sha512) = sha512 { + file.hashes.insert("sha512".to_string(), sha512.to_string()); + } + file +} + +#[test] +fn a_missing_sha1_is_filled_from_the_real_source() { + let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A); + let client = Modrinth::new(&transport); + let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))]; + let summary = block_on(complete_files(&client, &mut files)); + assert_eq!(summary.completed, 1); + assert_eq!(summary.unfilled, 0); + assert!(!summary.offline); + assert!(summary.changed()); + assert_eq!(files[0].sha1(), Some(fixtures::LOOKUP_SHA1)); + assert_eq!(files[0].size, Some(4096)); + // 清单里已有的直链不被覆盖 + assert_eq!(files[0].downloads.len(), 1); + assert!(files[0].downloads[0].ends_with("sodium.jar")); +} + +#[test] +fn an_entry_that_already_has_a_sha1_is_left_alone() { + let transport = FixtureTransport::new(); + let client = Modrinth::new(&transport); + let mut file = entry(Some(fixtures::LOOKUP_SHA512)); + file.hashes.insert("sha1".to_string(), "9".repeat(40)); + let mut files = vec![file]; + let summary = block_on(complete_files(&client, &mut files)); + assert_eq!(summary.completed, 0); + assert_eq!( + summary.unfilled, 0, + "已经有 sha1 的条目既不算补到也不算补不出" + ); + assert_eq!(files[0].sha1(), Some("9".repeat(40).as_str())); + assert!(transport.seen().is_empty(), "不该发请求"); +} + +#[test] +fn an_entry_without_sha512_cannot_be_completed() { + let transport = FixtureTransport::new(); + let client = Modrinth::new(&transport); + let mut files = vec![entry(None)]; + let summary = block_on(complete_files(&client, &mut files)); + assert_eq!(summary.completed, 0); + assert_eq!(summary.unfilled, 1); + assert_eq!(files[0].sha1(), None, "补不出就是补不出,不许猜"); + assert!(transport.seen().is_empty()); +} + +#[test] +fn a_source_that_does_not_know_the_hash_is_counted_as_unfilled() { + let transport = FixtureTransport::new().missing(HASH_PREFIX); + let client = Modrinth::new(&transport); + let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))]; + let summary = block_on(complete_files(&client, &mut files)); + assert_eq!(summary.completed, 0); + assert_eq!(summary.unfilled, 1); + assert!(!summary.offline, "404 不是「没联网」"); + assert_eq!(files[0].sha1(), None); +} + +#[test] +fn a_version_whose_hashes_do_not_match_is_not_used() { + // 路由回的是同一个版本对象,但请求的是另一个 sha512 ⇒ 身份对不上 + let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A); + let client = Modrinth::new(&transport); + let mut files = vec![entry(Some(fixtures::OTHER_SHA512))]; + // OTHER_SHA512 在同一个版本里存在,但那条是 sources.jar:仍算能对上(站点给的摘要就是它) + let summary = block_on(complete_files(&client, &mut files)); + assert_eq!(summary.completed, 1); + assert_eq!( + files[0].sha1(), + Some("4444444444444444444444444444444444444444") + ); + + // 而一个版本里根本没有的摘要 ⇒ 补不出 + let mut unknown = vec![entry(Some(&"e".repeat(128)))]; + let summary = block_on(complete_files(&client, &mut unknown)); + assert_eq!(summary.completed, 0); + assert_eq!(summary.unfilled, 1); + assert_eq!(unknown[0].sha1(), None); +} + +#[test] +fn offline_stops_after_one_request_and_counts_the_rest_as_unfilled() { + let transport = FixtureTransport::new().offline(HASH_PREFIX); + let client = Modrinth::new(&transport); + let mut files = vec![ + entry(Some(fixtures::LOOKUP_SHA512)), + entry(Some(fixtures::LOOKUP_SHA512)), + entry(Some(fixtures::LOOKUP_SHA512)), + ]; + let summary = block_on(complete_files(&client, &mut files)); + assert!(summary.offline); + assert_eq!(summary.completed, 0); + assert_eq!(summary.unfilled, 3); + assert_eq!(transport.seen().len(), 1, "离线后不该逐条重试"); +} + +#[test] +fn a_download_url_is_only_added_when_the_entry_has_none() { + let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A); + let client = Modrinth::new(&transport); + let mut file = entry(Some(fixtures::LOOKUP_SHA512)); + file.downloads.clear(); + let mut files = vec![file]; + let summary = block_on(complete_files(&client, &mut files)); + assert_eq!(summary.completed, 1); + assert_eq!( + files[0].downloads, + vec![ + "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar" + .to_string() + ] + ); +} diff --git a/crates/core/src/addon/online/tests/download.rs b/crates/core/src/addon/online/tests/download.rs new file mode 100644 index 0000000..4a84917 --- /dev/null +++ b/crates/core/src/addon/online/tests/download.rs @@ -0,0 +1,167 @@ +//! 下载复核:sha1/sha512 对不对、失败重试几次、会不会留下半截文件。 + +use crate::addon::online::download::{download_verified, RETRY_ATTEMPTS}; +use crate::addon::online::OnlineErrorKind; +use crate::addon::temp::TempRoot; +use crate::download::blocking::block_on; + +use super::support::{hashes_of, FakeArtifact}; + +const BYTES: &[u8] = b"PK\x03\x04 fake mod jar bytes for offline fixtures"; + +fn spec() -> (String, String) { + hashes_of(BYTES) +} + +#[test] +fn a_matching_download_is_written_and_reported() { + let root = TempRoot::new("online-ok"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES); + let (sha1, sha512) = spec(); + let done = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some(&sha1), + Some(&sha512), + Some(BYTES.len() as u64), + &dest, + )) + .expect("摘要对得上就该成功"); + assert_eq!(done.bytes, BYTES.len() as u64); + assert_eq!(done.digest.sha512, sha512); + assert_eq!(std::fs::read(&dest).expect("文件应当落盘"), BYTES); + assert_eq!(fetcher.attempts(), 1); +} + +#[test] +fn a_sha512_mismatch_deletes_the_file_and_retries_then_fails() { + let root = TempRoot::new("online-bad-sha512"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES); + let (sha1, _) = spec(); + let wrong = "0".repeat(128); + let error = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some(&sha1), + Some(&wrong), + None, + &dest, + )) + .expect_err("摘要不符必须失败"); + assert_eq!(error.kind(), OnlineErrorKind::Integrity); + assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS, "摘要不符要重试到上限"); + assert!(!dest.exists(), "不符的内容不许留在盘上"); +} + +#[test] +fn a_sha1_mismatch_is_caught_even_when_the_fetcher_skips_verification() { + let root = TempRoot::new("online-bad-sha1"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES); + let (_, sha512) = spec(); + let wrong = "1".repeat(40); + let error = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some(&wrong), + Some(&sha512), + None, + &dest, + )) + .expect_err("sha1 不符必须失败"); + assert_eq!(error.kind(), OnlineErrorKind::Integrity); + assert!(error.message().contains("SHA-1"), "{}", error.message()); + assert!(!dest.exists()); +} + +#[test] +fn a_transient_network_failure_is_retried_and_succeeds() { + let root = TempRoot::new("online-retry"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES).failing(2); + let (sha1, sha512) = spec(); + let done = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some(&sha1), + Some(&sha512), + Some(BYTES.len() as u64), + &dest, + )) + .expect("第三次该成功"); + assert_eq!(done.bytes, BYTES.len() as u64); + assert_eq!(fetcher.attempts(), 3); + assert!(dest.exists()); +} + +#[test] +fn exhausting_the_retries_leaves_no_half_file() { + let root = TempRoot::new("online-exhausted"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES).failing(RETRY_ATTEMPTS + 5); + let (sha1, sha512) = spec(); + let error = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some(&sha1), + Some(&sha512), + None, + &dest, + )) + .expect_err("一直断网必须失败"); + assert_eq!(error.kind(), OnlineErrorKind::NotConnected); + assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS); + assert!(!dest.exists(), "失败后不许留文件"); +} + +#[test] +fn a_truncated_body_is_rejected_by_the_declared_size() { + let root = TempRoot::new("online-truncated"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES).truncated(); + // 声明的是全量大小,实际只写了一半 ⇒ 按字节数就该拒 + let (sha1, sha512) = spec(); + let error = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some(&sha1), + Some(&sha512), + Some(BYTES.len() as u64), + &dest, + )) + .expect_err("半截文件必须失败"); + assert_eq!(error.kind(), OnlineErrorKind::Integrity); + assert!(!dest.exists()); +} + +#[test] +fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() { + let root = TempRoot::new("online-bad-decl"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES); + let error = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + None, + Some("abc"), + None, + &dest, + )) + .expect_err("摘要形状不对必须失败"); + assert_eq!(error.kind(), OnlineErrorKind::Unsupported); + assert_eq!(fetcher.attempts(), 0); + assert!(!dest.exists()); +} + +#[test] +fn an_empty_url_is_unsupported() { + let root = TempRoot::new("online-no-url"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES); + let error = block_on(download_verified(&fetcher, " ", None, None, None, &dest)) + .expect_err("没有地址必须失败"); + assert_eq!(error.kind(), OnlineErrorKind::Unsupported); + assert_eq!(fetcher.attempts(), 0); +} diff --git a/crates/core/src/addon/online/tests/fixtures.rs b/crates/core/src/addon/online/tests/fixtures.rs new file mode 100644 index 0000000..129a45d --- /dev/null +++ b/crates/core/src/addon/online/tests/fixtures.rs @@ -0,0 +1,143 @@ +//! 录制下来的 Modrinth v2 响应(**形状按站点原文,字段抄真**;数值是示意,不是某天的真值)。 +//! +//! 三条刻意的细节: +//! - 检索命中的日期字段是 `date_modified`(不是 `updated`)——旧实现读错了字段; +//! - `categories` 里混着 `optimization` 这种分类标签,只有 `fabric` 是真加载器; +//! - 版本里的文件有 `primary: false` 的 `sources.jar`,取第一个会装错。 + +/// `/v2/search?query=sodium`(两页里的第一页)。 +pub(super) const SEARCH: &str = r#"{ + "hits": [ + { + "project_id": "AANobbMI", + "project_type": "mod", + "slug": "sodium", + "author": "jellysquid3", + "title": "Sodium", + "description": "Modern rendering engine and client-side optimization mod", + "categories": ["fabric", "optimization", "quilt"], + "display_categories": ["optimization"], + "versions": ["1.20.1", "1.21.1"], + "downloads": 42123456, + "follows": 9912, + "icon_url": "https://cdn.modrinth.com/data/AANobbMI/icon.png", + "date_created": "2020-12-01T00:00:00Z", + "date_modified": "2026-02-03T10:11:12Z", + "latest_version": "mc1.21.1-0.6.0", + "license": "LGPL-3.0-only", + "client_side": "required", + "server_side": "unsupported", + "gallery": [] + }, + { + "project_id": "P7dR8mSH", + "project_type": "mod", + "slug": "fabric-api", + "author": "modmuss50", + "title": "Fabric API", + "description": "Lightweight and modular API providing common hooks and intercompatibility measures", + "categories": ["fabric", "library"], + "versions": ["1.20.1", "1.21.1"], + "downloads": 99000000, + "follows": 12000, + "icon_url": "", + "date_modified": "2026-01-30T08:00:00Z" + } + ], + "offset": 0, + "limit": 2, + "total_hits": 1234 +}"#; + +/// `/v2/project/sodium`。 +pub(super) const PROJECT: &str = r###"{ + "id": "AANobbMI", + "slug": "sodium", + "project_type": "mod", + "team": "team-id", + "title": "Sodium", + "description": "Modern rendering engine and client-side optimization mod", + "body": "## Sodium\n\nA modern rendering engine for Minecraft which greatly improves performance.", + "published": "2020-12-01T00:00:00Z", + "updated": "2026-02-03T10:11:12Z", + "downloads": 42123456, + "followers": 9912, + "categories": ["fabric", "optimization"], + "game_versions": ["1.20.1", "1.21.1"], + "loaders": ["fabric", "quilt"], + "versions": ["ver-1", "ver-2"], + "icon_url": "https://cdn.modrinth.com/data/AANobbMI/icon.png", + "source_url": "https://github.com/CaffeineMC/sodium", + "issues_url": "https://github.com/CaffeineMC/sodium/issues" +}"###; + +/// `/v2/project/sodium/version`(站点顺序 = 最新在前)。 +pub(super) const VERSIONS: &str = r#"[ + { + "id": "ver-2", + "project_id": "AANobbMI", + "name": "Sodium 0.6.0 for 1.21.1", + "version_number": "mc1.21.1-0.6.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.21.1"], + "date_published": "2026-02-01T12:00:00Z", + "downloads": 4321, + "files": [ + { + "hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-2/sodium-sources.jar", + "filename": "sodium-sources.jar", + "size": 1000, + "primary": false + }, + { + "hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-2/sodium-fabric-0.6.0.jar", + "filename": "sodium-fabric-0.6.0.jar", + "size": 2048, + "primary": true + } + ] + } +]"#; + +/// `GET /v2/version_file/{sha512}?algorithm=sha512` 的响应(一个版本对象)。 +/// +/// 里面有两个文件:只有 `hashes.sha512` 与请求值相同的那条能取 sha1(另一个是 `sources.jar`)。 +pub(super) const VERSION_FILE_A: &str = r#"{ + "id": "ver-a", + "project_id": "AANobbMI", + "name": "Sodium 0.5.11 for 1.20.1", + "version_number": "mc1.20.1-0.5.11", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2025-06-01T00:00:00Z", + "downloads": 9999, + "files": [ + { + "hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar", + "filename": "sodium-fabric-0.5.11.jar", + "size": 4096, + "primary": true + }, + { + "hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-sources.jar", + "filename": "sodium-sources.jar", + "size": 512, + "primary": false + } + ] +}"#; + +/// 补全时请求的那个 sha512(128 位十六进制;形状不对客户端根本不发请求)。 +pub(super) const LOOKUP_SHA512: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; + +/// 同一版本里另一个文件的 sha512(`sources.jar`,不该被拿来当身份)。 +pub(super) const OTHER_SHA512: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; + +/// 补全应该拿到的 sha1(`VERSION_FILE_A` 主文件那条)。 +pub(super) const LOOKUP_SHA1: &str = "3333333333333333333333333333333333333333"; diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs new file mode 100644 index 0000000..5c4a2a5 --- /dev/null +++ b/crates/core/src/addon/online/tests/mod.rs @@ -0,0 +1,13 @@ +//! 在线内容源的离线用例(**不打真实网络**)。 +//! +//! 全部素材是录制好的 Modrinth v2 响应(`fixtures.rs`)+ 假字节(`support::FakeArtifact`): +//! 覆盖检索、项目详情、版本列表、按摘要反查、下载复核、补全路径与离线分类。 +//! +//! 口径提醒:这些用例证明的是**解析与判定**,不证明「真实站点今天回什么」——后者写在 +//! M162 报告的「没验的部分」里。 + +mod api; +mod complete; +mod download; +mod fixtures; +mod support; diff --git a/crates/core/src/addon/online/tests/support.rs b/crates/core/src/addon/online/tests/support.rs new file mode 100644 index 0000000..6b0a9dd --- /dev/null +++ b/crates/core/src/addon/online/tests/support.rs @@ -0,0 +1,169 @@ +//! 测试替身:录制响应传输层 + 假字节下载器。 +//! +//! 两个替身都**只做注入点该做的事**:不校验、不重试、不判断——那些必须在被测代码里, +//! 否则测试就只是「验证桩自己」。 + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::Path; +use std::sync::Mutex; + +use sha1::Digest as _; + +use crate::addon::online::download::{ArtifactFetcher, FetchArgs}; +use crate::addon::online::{OnlineError, OnlineErrorKind, OnlineFuture, Transport}; + +/// 按 URL 前缀路由的传输层(把录制 JSON 喂给客户端)。 +pub(super) struct FixtureTransport { + routes: BTreeMap>, + seen: Mutex>, +} + +impl FixtureTransport { + /// 建一个空的路由表。 + pub(super) fn new() -> Self { + Self { + routes: BTreeMap::new(), + seen: Mutex::new(Vec::new()), + } + } + + /// 挂一条路由(`prefix` 是 URL 前缀)。 + pub(super) fn with(mut self, prefix: &str, body: &str) -> Self { + self.routes.insert(prefix.to_string(), Ok(body.to_string())); + self + } + + /// 挂一条「没联网」的路由。 + pub(super) fn offline(mut self, prefix: &str) -> Self { + self.routes.insert( + prefix.to_string(), + Err(OnlineError::offline("连不上 api.modrinth.com")), + ); + self + } + + /// 挂一条「站点说没有」的路由。 + pub(super) fn missing(mut self, prefix: &str) -> Self { + self.routes.insert( + prefix.to_string(), + Err(OnlineError::new(OnlineErrorKind::NotFound, "HTTP 404")), + ); + self + } + + /// 实际请求过的 URL(按顺序)。 + pub(super) fn seen(&self) -> Vec { + self.seen.lock().expect("锁没中毒").clone() + } +} + +impl Transport for FixtureTransport { + fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result> { + Box::pin(async move { + self.seen.lock().expect("锁没中毒").push(url.to_string()); + // 最长前缀优先:`/v2/project/sodium` 与 `/v2/project/sodium/version` 不能互相抢。 + let best = self + .routes + .iter() + .filter(|(prefix, _)| url.starts_with(prefix.as_str())) + .max_by_key(|(prefix, _)| prefix.len()) + .map(|(_, result)| result); + match best { + Some(Ok(body)) => Ok(body.clone()), + Some(Err(error)) => Err(error.clone()), + None => Err(OnlineError::new( + OnlineErrorKind::NotFound, + format!("测试没为 {url} 挂路由"), + )), + } + }) + } +} + +/// 假字节下载器:把给定字节写到 `dest`,可以安排前几次失败。 +pub(super) struct FakeArtifact { + bytes: Vec, + /// 还欠几次「没联网」失败(每尝试一次减一) + failures: Mutex, + /// 实际尝试次数 + attempts: Mutex, + /// 故意只写一半(模拟半截文件) + truncate: bool, +} + +impl FakeArtifact { + /// 正常一份字节。 + pub(super) fn new(bytes: &[u8]) -> Self { + Self { + bytes: bytes.to_vec(), + failures: Mutex::new(0), + attempts: Mutex::new(0), + truncate: false, + } + } + + /// 前 `count` 次尝试没联网。 + pub(super) fn failing(mut self, count: u32) -> Self { + *self.failures.get_mut().expect("锁没中毒") = count; + self + } + + /// 只写一半字节(半截文件场景)。 + pub(super) fn truncated(mut self) -> Self { + self.truncate = true; + self + } + + /// 尝试次数。 + pub(super) fn attempts(&self) -> u32 { + *self.attempts.lock().expect("锁没中毒") + } +} + +impl ArtifactFetcher for FakeArtifact { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + *self.attempts.lock().expect("锁没中毒") += 1; + let mut failures = self.failures.lock().expect("锁没中毒"); + if *failures > 0 { + *failures -= 1; + return Err(OnlineError::offline("假装的断网")); + } + drop(failures); + write_bytes(args.dest, &self.bytes, self.truncate)?; + Ok(if self.truncate { + self.bytes.len() as u64 / 2 + } else { + self.bytes.len() as u64 + }) + }) + } +} + +fn write_bytes(dest: &Path, bytes: &[u8], truncated: bool) -> Result<(), OnlineError> { + let mut file = std::fs::File::create(dest).map_err(|error| { + OnlineError::new( + OnlineErrorKind::Io, + format!("建 {} 失败:{error}", dest.display()), + ) + })?; + let half = bytes.len() / 2; + let slice = if truncated { &bytes[..half] } else { bytes }; + file.write_all(slice).map_err(|error| { + OnlineError::new( + OnlineErrorKind::Io, + format!("写 {} 失败:{error}", dest.display()), + ) + }) +} + +/// 一段字节的 `(sha1, sha512)`(小写十六进制)。 +pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) { + let sha1 = sha1::Sha1::digest(bytes); + let sha512 = sha2::Sha512::digest(bytes); + ( + crate::addon::online::download::to_hex(&sha1), + crate::addon::online::download::to_hex(&sha512), + ) +} diff --git a/crates/core/src/addon/online/transport.rs b/crates/core/src/addon/online/transport.rs new file mode 100644 index 0000000..121513c --- /dev/null +++ b/crates/core/src/addon/online/transport.rs @@ -0,0 +1,203 @@ +//! 唯一的 HTTP 出口 + 错误分类。 +//! +//! # 为什么是 trait +//! +//! 三条理由,每条都对应一个真实麻烦: +//! +//! 1. **测试不能联网**:本模块的解析/端点/补全全部要用录制 JSON 跑(CI 没有网,也不该有); +//! 2. **换源不改业务**:将来加第二个内容源时,只要它也能 GET 出 JSON,就能复用这套 +//! `Transport`(现在的实现按「一个 URL → 一段文本」抽象,不假设站点); +//! 3. **错误必须可分辨**:「没联网」和「站点说没有这个项目」是两件事(前者该提示检查网络, +//! 后者该提示换关键词),字符串错误串会让上层只能靠 `contains` 猜。 +//! +//! # 生产实现只有一条路:`core::download` 的引擎 +//! +//! [`EngineTransport`] 调 [`DownloadEngine::download_bytes`],于是信号量限流、源健康度、 +//! 指数退避、错误分类全在引擎里。本模块**不**新建 `reqwest::Client`、**不**写重试循环 +//! (与 `addon::import::fetch` 的纪律同源)。镜像策略同理:`mirrors.json` 里没有 +//! `api.modrinth.com` 的前缀规则 ⇒ 元数据请求永远是直连,只有真正的文件下载才会被镜像 +//! 规则命中(国内 gh-proxy 那条也只看 GitHub host)。 + +use std::future::Future; +use std::pin::Pin; + +use crate::download::{ + DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind, +}; + +/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写与 `import::fetch` 同一个范式)。 +pub type OnlineFuture<'a, T> = Pin + Send + 'a>>; + +/// 「拿不到」的成因(**分级判据只在这里**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnlineErrorKind { + /// 没联网 / 连不上 / 所有候选源都失败 —— 上层要明确说「未联网」。 + NotConnected, + /// 站点说没有(404 / 项目被删)。 + NotFound, + /// 站点出错(5xx / 429 退避后仍失败)。 + Server, + /// 响应不是期望形状(JSON 坏 / 字段类型不对)。 + Malformed, + /// 本地 IO(写缓存失败等)。 + Io, + /// 内容与声明的摘要不符(下载到的字节坏了 / 镜像给的其实是别的东西)。 + Integrity, + /// 这条路走不通(URL 非法、摘要格式不对、没有可用下载地址)。 + Unsupported, +} + +impl OnlineErrorKind { + /// 稳定字符串 id(日志 / DTO 用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::NotConnected => "online_not_connected", + Self::NotFound => "online_not_found", + Self::Server => "online_server", + Self::Malformed => "online_malformed", + Self::Io => "online_io", + Self::Integrity => "online_integrity", + Self::Unsupported => "online_unsupported", + } + } + + /// 是不是「网络这条路没通」(决定上层画「未联网」还是「查无此项」)。 + #[must_use] + pub const fn is_offline(self) -> bool { + matches!(self, Self::NotConnected) + } +} + +/// 一次取数失败。 +#[derive(Debug, Clone)] +pub struct OnlineError { + kind: OnlineErrorKind, + message: String, +} + +impl OnlineError { + /// 构造。 + #[must_use] + pub fn new(kind: OnlineErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + /// 没联网。 + #[must_use] + pub fn offline(message: impl Into) -> Self { + Self::new(OnlineErrorKind::NotConnected, message) + } + + /// 形状不对 / 自身不自洽。 + #[must_use] + pub fn malformed(message: impl Into) -> Self { + Self::new(OnlineErrorKind::Malformed, message) + } + + /// 内容与声明不符。 + #[must_use] + pub fn integrity(message: impl Into) -> Self { + Self::new(OnlineErrorKind::Integrity, message) + } + + /// 这条路走不通。 + #[must_use] + pub fn unsupported(message: impl Into) -> Self { + Self::new(OnlineErrorKind::Unsupported, message) + } + + /// 大类。 + #[must_use] + pub const fn kind(&self) -> OnlineErrorKind { + self.kind + } + + /// 人读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 没联网吗(上层据此决定降级文案)。 + #[must_use] + pub const fn is_offline(&self) -> bool { + self.kind.is_offline() + } +} + +impl std::fmt::Display for OnlineError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}:{}", self.kind.as_str(), self.message) + } +} + +impl std::error::Error for OnlineError {} + +/// `DownloadEngine` 的错误 → 本模块的错误分类。 +/// +/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络); +/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。 +impl From for OnlineError { + fn from(error: DownloadError) -> Self { + let kind = match error.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::InvalidRequest => OnlineErrorKind::Unsupported, + }; + Self::new(kind, error.message) + } +} + +/// 一个 URL → 一段文本(JSON 原文)。 +/// +/// 实现方只负责「取回来」:状态码分类、退避、限流都在实现里(生产实现在引擎里)。 +pub trait Transport: Send + Sync { + /// GET 一段文本。 + fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result>; +} + +/// 生产实现:把 GET 交给 `core::download` 的引擎(内存下载路径)。 +#[derive(Debug, Clone, Copy)] +pub struct EngineTransport<'a> { + engine: &'a DownloadEngine, +} + +impl<'a> EngineTransport<'a> { + /// 绑定引擎。 + #[must_use] + pub const fn new(engine: &'a DownloadEngine) -> Self { + Self { engine } + } +} + +impl Transport for EngineTransport<'_> { + fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result> { + Box::pin(async move { + // `download_bytes` 不落盘(`dest` 只是请求形状的一部分),所以这里给一个中性路径; + // 内容寻址缓存按 URL 规划,不看它。 + let request = DownloadRequest::new( + ResourceKind::Other, + url, + std::env::temp_dir().join("ull-online-metadata.json"), + ); + let bytes = self + .engine + .download_bytes(request) + .await + .map_err(OnlineError::from)?; + String::from_utf8(bytes) + .map_err(|error| OnlineError::malformed(format!("{url} 的响应不是 UTF-8:{error}"))) + }) + } +} diff --git a/crates/core/src/addon/tests/import_online.rs b/crates/core/src/addon/tests/import_online.rs new file mode 100644 index 0000000..3ff25f4 --- /dev/null +++ b/crates/core/src/addon/tests/import_online.rs @@ -0,0 +1,145 @@ +//! 远端条目补全(M162):**有 path 但没有 `hashes.sha1` 的 mrpack 条目,补得出就判,补不出照旧计数**。 +//! +//! 三条纪律: +//! +//! 1. 补全靠 `PackFetcher::modrinth_file_by_sha512`(默认 `Unsupported` ⇒ 离线导入补不出); +//! 2. 补出来的条目与自带 `sha1` 的条目走**同一个** `judge`(M160 的判据一行没改); +//! 3. 补出的 `sha1` 会真的用在下载校验上——所以「站点给错摘要」必须是 `Failed`(内容不符), +//! 不能被当成「暂时搞不到」混过去。 +//! +//! 全部离线:`StubFetcher` 里挂一条 sha512 → 元数据,没有一个真实请求。 + +use super::support::{sha1, StubFetcher}; +use super::{read_text, write_file, zip_dir, TempRoot}; +use crate::addon::import::{ + import_pack, scan_pack, scan_pack_online, ImportRequest, ImportStatus, ModrinthFileMeta, +}; +use crate::download::blocking::block_on; + +const PATH: &str = "mods/no-sha1.jar"; +const DISK_BYTES: &[u8] = b"what the user already has"; +const PACK_BYTES: &[u8] = b"what the content source serves"; +const DOWNLOAD: &str = "stub://mods/no-sha1.jar"; +const SHA512: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\ +0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +/// 一个只有 `hashes.sha512` 的 mrpack(sha1 缺失 = 预检判不了)。 +fn mrpack_without_sha1(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":"缺 sha1 的包", + "files":[{{"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) +} + +/// 站点给出这个文件的 sha1/字节数(`sha1` 由调用方决定对不对)。 +fn meta(pack_sha1: &str) -> ModrinthFileMeta { + ModrinthFileMeta { + sha1: Some(pack_sha1.to_string()), + size: Some(PACK_BYTES.len() as u64), + url: Some(DOWNLOAD.to_string()), + file_name: Some("no-sha1.jar".to_string()), + } +} + +#[test] +fn offline_scan_keeps_the_unknown_remote_count() { + // 对照:没有 fetcher(离线)时口径与 M160 完全一致。 + let (root, pack) = mrpack_without_sha1("online-scan-offline"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + let scan = scan_pack(&ImportRequest::new(&pack, &target)); + assert!(scan.is_ok(), "{:?}", scan.error); + assert_eq!(scan.unknown_remote, 1, "缺 sha1 就是判不了,如实计数"); + assert!(scan.conflicts.is_empty()); +} + +#[test] +fn an_online_scan_completes_the_sha1_and_judges_the_conflict() { + let (root, pack) = mrpack_without_sha1("online-scan-filled"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + 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); + assert_eq!(scan.unknown_remote, 0, "补出来了就不再是「判不了」"); + let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect(); + assert_eq!(paths, vec![PATH], "补出来的摘要要真的用上:{:?}", paths); + assert_eq!(scan.conflicts[0].pack_sha1, sha1(PACK_BYTES)); + assert!(scan + .notes + .iter() + .any(|note| note.message.contains("补全了 1 条远端条目的 sha1"))); +} + +#[test] +fn a_scan_whose_source_does_not_know_the_hash_still_counts_it() { + let (root, pack) = mrpack_without_sha1("online-scan-unknown"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + // 站点不认得这个 sha512(stub 里没挂)⇒ 补不出,口径回到 M160 + let fetcher = StubFetcher::new(); + let scan = block_on(scan_pack_online( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert_eq!(scan.unknown_remote, 1); + assert!(scan.conflicts.is_empty()); +} + +#[test] +fn a_completed_sha1_is_actually_used_for_the_download_check() { + let (root, pack) = mrpack_without_sha1("online-import-ok"); + let target = root.join("instance"); + let fetcher = StubFetcher::new() + .with_modrinth(SHA512, meta(&sha1(PACK_BYTES))) + .with_file(DOWNLOAD, PACK_BYTES); + let report = block_on(import_pack( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error); + assert_eq!( + read_text(&target.join(PATH)), + String::from_utf8_lossy(PACK_BYTES) + ); + assert!(report.missing.is_empty(), "{:?}", report.missing); + assert!( + report + .warnings + .iter() + .any(|note| note.message.contains("补全了 1 条远端条目的 sha1")), + "报告要说清补全过:{:?}", + report.warnings + ); +} + +#[test] +fn a_wrong_completed_sha1_fails_the_import_instead_of_installing() { + // 站点给了错的 sha1 ⇒ 下载校验必须拦住(这是"内容坏了",不是"暂时搞不到")。 + let (root, pack) = mrpack_without_sha1("online-import-bad"); + let target = root.join("instance"); + let fetcher = StubFetcher::new() + .with_modrinth(SHA512, meta(&"f".repeat(40))) + .with_file(DOWNLOAD, PACK_BYTES); + let report = block_on(import_pack( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert_eq!(report.status, ImportStatus::Failed, "{:?}", report.status); + assert_eq!( + report.error.as_ref().map(|error| error.code()), + Some("import_integrity"), + "{:?}", + report.error + ); + assert!(!target.join(PATH).exists(), "失败的导入不许留文件"); +} diff --git a/crates/core/src/addon/tests/mod.rs b/crates/core/src/addon/tests/mod.rs index b385a35..edc983e 100644 --- a/crates/core/src/addon/tests/mod.rs +++ b/crates/core/src/addon/tests/mod.rs @@ -28,6 +28,7 @@ mod import_layering; mod import_merge; mod import_modrinth; mod import_multimc; +mod import_online; mod import_report; mod roundtrip_three; mod support; diff --git a/crates/core/src/addon/tests/support.rs b/crates/core/src/addon/tests/support.rs index e795797..c60f4ff 100644 --- a/crates/core/src/addon/tests/support.rs +++ b/crates/core/src/addon/tests/support.rs @@ -18,7 +18,9 @@ use std::sync::Mutex; use serde_json::json; -use super::super::import::{CurseFileMeta, FetchFuture, FetchRequest, FetchedFile, PackFetcher}; +use super::super::import::{ + CurseFileMeta, FetchFuture, FetchRequest, FetchedFile, ModrinthFileMeta, PackFetcher, +}; use super::fixtures; use super::fixtures::Fixture; use super::{write_file, zip_dir}; @@ -51,6 +53,8 @@ pub(crate) fn sha1(bytes: &[u8]) -> String { pub(crate) struct StubFetcher { files: BTreeMap>, curse: BTreeMap<(i64, i64), CurseFileMeta>, + /// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。 + modrinth: BTreeMap, /// 被请求过的 URL(断言"没白下载"用)。 calls: Mutex>, /// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。 @@ -63,6 +67,7 @@ impl StubFetcher { Self { files: BTreeMap::new(), curse: BTreeMap::new(), + modrinth: BTreeMap::new(), calls: Mutex::new(Vec::new()), artifacts: false, } @@ -80,6 +85,12 @@ impl StubFetcher { self } + /// 挂一条「按 sha512 反查」的结果(M162 的补全路径)。 + pub(crate) fn with_modrinth(mut self, sha512: &str, meta: ModrinthFileMeta) -> Self { + self.modrinth.insert(sha512.to_string(), meta); + self + } + /// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。 pub(crate) fn with_engine_artifacts(mut self) -> Self { self.artifacts = true; @@ -111,6 +122,20 @@ impl PackFetcher for StubFetcher { }) } + fn modrinth_file_by_sha512<'a>( + &'a self, + sha512: &'a str, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { + self.modrinth.get(sha512).cloned().ok_or_else(|| { + super::super::import::FetchError::new( + super::super::import::FetchErrorKind::NotFound, + format!("stub 里没有 sha512={sha512}"), + ) + }) + }) + } + fn fetch_file<'a>( &'a self, request: FetchRequest<'a>, diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index edff16f..eafdbc4 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -52,6 +52,11 @@ macro_rules! content_entries { ), 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.", + "内容检索需要联网,本地还没有缓存。检查网络后重试。" + ), ContentRetry => ("Retry", "重试"), ContentLoadMore => ("Load more", "加载更多"), ContentNoMore => ("No more results", "没有更多了"), @@ -63,6 +68,13 @@ macro_rules! content_entries { ContentDetailUpdated => ("Updated {date}", "更新于 {date}"), ContentDetailLoaders => ("Loaders: {list}", "加载器:{list}"), ContentDetailVersions => ("Game versions: {list}", "游戏版本:{list}"), + ContentDetailFetching => ("Loading versions…", "正在取版本列表…"), + ContentDetailFetchFailed => ( + "Could not load versions: {reason}", + "版本列表取不到:{reason}" + ), + ContentDetailVersionCount => ("{count} versions", "{count} 个版本"), + ContentDetailVersionLine => ("{number} · {kind} · {date}", "{number}({kind})· {date}"), ContentInstall => ("Install into instance", "装入实例"), ContentTargetInstance => ("Target instance", "目标实例"), ContentNoInstances => ("No instance to install into", "没有可装入的实例"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 790dc51..037800b 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -180,6 +180,8 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"), ("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.", "内容检索需要联网,本地还没有缓存。检查网络后重试。"), ("ContentRetry", "Retry", "重试"), ("ContentLoadMore", "Load more", "加载更多"), ("ContentNoMore", "No more results", "没有更多了"), @@ -188,6 +190,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("ContentDetailUpdated", "Updated {date}", "更新于 {date}"), ("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"), ("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"), + ("ContentDetailFetching", "Loading versions…", "正在取版本列表…"), + ("ContentDetailFetchFailed", "Could not load versions: {reason}", "版本列表取不到:{reason}"), + ("ContentDetailVersionCount", "{count} versions", "{count} 个版本"), + ("ContentDetailVersionLine", "{number} · {kind} · {date}", "{number}({kind})· {date}"), ("ContentInstall", "Install into instance", "装入实例"), ("ContentTargetInstance", "Target instance", "目标实例"), ("ContentNoInstances", "No instance to install into", "没有可装入的实例"), diff --git a/crates/presenter/src/dto/content.rs b/crates/presenter/src/dto/content.rs index 8693d57..f755762 100644 --- a/crates/presenter/src/dto/content.rs +++ b/crates/presenter/src/dto/content.rs @@ -24,6 +24,15 @@ use serde::{Deserialize, Serialize}; use crate::dto::{InstanceId, LoaderKind}; +pub use query::ContentSort; + +mod detail; +mod query; + +pub use detail::{ + ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, ContentVersionView, +}; + /// 内容类型(内容库页左侧那四条一级侧栏)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ContentKind { @@ -117,50 +126,6 @@ impl ContentSource { } } -/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ContentSort { - /// 相关度(Modrinth `relevance`;CF 没有对应项,退回热度) - Relevance, - /// 下载量 - Downloads, - /// 最新发布 - Newest, - /// 最近更新 - Updated, -} - -impl ContentSort { - /// 四种排序的声明顺序。 - pub const ALL: [ContentSort; 4] = [ - ContentSort::Relevance, - ContentSort::Downloads, - ContentSort::Newest, - ContentSort::Updated, - ]; - - /// Modrinth `/v2/search` 的 `index` 参数值。 - #[must_use] - pub const fn modrinth_index(self) -> &'static str { - match self { - Self::Relevance => "relevance", - Self::Downloads => "downloads", - Self::Newest => "newest", - Self::Updated => "updated", - } - } - - /// CurseForge 的 `sortField`(1 = 热度,2 = 更新,3 = 发布,4 = 名称)。 - #[must_use] - pub const fn curseforge_field(self) -> u32 { - match self { - Self::Relevance | Self::Downloads => 1, - Self::Newest => 3, - Self::Updated => 2, - } - } -} - /// 加载器 → Modrinth 的 loader facet 值;`None` = 该加载器没有对应 facet(原版等)。 #[must_use] pub const fn loader_facet(loader: LoaderKind) -> Option<&'static str> { @@ -333,14 +298,20 @@ pub struct ContentState { pub results: Option, /// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显) pub pending: bool, - /// 最近一次失败的原文(`None` = 没失败) - pub failed: Option, + /// 最近一次失败(`None` = 没失败)。M162 起带**大类**,「没联网」不再是普通失败。 + pub failed: Option, /// **最近一页**是否装满(`hits.len() >= limit`)。 /// /// 为什么不是「累计条数够一页」:分页是**累加**到 `results.hits` 里的,累计值在第 2 页之后 /// 必然 ≥ limit ⇒ 用累计判会得到「has_more 恒真」:加载更多永不消失、「没有更多了」在真实 /// 翻页路径上不可达,而且每点一次白发一次网络请求(复审 P1-2)。所以这一位单独记。 pub last_page_filled: bool, + /// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过) + pub detail: Option>, + /// 详情请求在飞 + pub detail_pending: bool, + /// 最近一次详情请求的失败 + pub detail_failed: Option, } impl Default for ContentState { @@ -351,11 +322,40 @@ impl Default for ContentState { pending: false, failed: None, last_page_filled: false, + detail: None, + detail_pending: false, + detail_failed: None, } } } impl ContentState { + /// 进「详情请求在飞」态并清掉上一条(换选中项时调)。 + pub fn request_detail(&mut self, project: &str) { + if self + .detail + .as_ref() + .is_some_and(|detail| detail.project != project) + { + self.detail = None; + } + self.detail_pending = true; + self.detail_failed = None; + } + + /// 详情落地(迟到的结果(换了选中项)由调用方按 `project` 判)。 + pub fn apply_detail(&mut self, detail: ContentDetail) { + self.detail_pending = false; + self.detail_failed = None; + self.detail = Some(Box::new(detail)); + } + + /// 详情失败。 + pub fn fail_detail(&mut self, failure: ContentFailure) { + self.detail_pending = false; + self.detail_failed = Some(failure); + } + /// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。 #[must_use] pub fn accepts(&self, results: &ContentResults) -> bool { diff --git a/crates/presenter/src/dto/content/detail.rs b/crates/presenter/src/dto/content/detail.rs new file mode 100644 index 0000000..de3e842 --- /dev/null +++ b/crates/presenter/src/dto/content/detail.rs @@ -0,0 +1,162 @@ +//! 内容库的**失败分类**与**详情/版本列表** DTO(M162:在线内容源接上真数据)。 +//! +//! 从 `dto/content.rs` 拆出(那个文件已贴 400 行硬线)。两件事放一起,因为它们都是 +//! 「检索之后」的话:一次请求失败了要能说清是哪一类,选中一个项目要能拿到版本列表。 +//! +//! # 为什么失败要分大类 +//! +//! 无网时画「未联网,检查网络后重试」、站点 404 时画「换个关键词」是**两种界面**。 +//! 只有字符串就只能靠 `contains("网络")` 猜文案,一改文案就失效;所以大类在 core 那一侧 +//! (`OnlineErrorKind`)就分好了,这里只做一一映射,不重新判。 +//! +//! # 详情为什么单独一条通道 +//! +//! 检索结果里只有「支持哪些版本/加载器」(facets),拿不到「有哪些版本可以装」。 +//! 版本列表是另一个端点(`/v2/project/{id}/version`),而且**只在选中某个项目时才需要**—— +//! 跟着检索一起拉等于每页多 N 次请求。所以详情是一条独立命令/事件,页面选中才发。 + +use serde::{Deserialize, Serialize}; + +use super::ContentSource; +use crate::dto::LoaderKind; + +/// 一次检索/详情失败的**大类**(M162:把「没联网」与「查无此项」分开)。 +/// +/// 为什么不是 `Option`:无网时画「未联网,检查网络后重试」、站点 404 时画「换个关键词」 +/// 是两件事;只有字符串就只能靠 `contains("网络")` 猜,一改文案就失效。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ContentFailureKind { + /// 没联网 / 连不上站点(core 的 `OnlineErrorKind::NotConnected`) + Offline, + /// 站点说没有(404) + NotFound, + /// 站点出错(5xx / 退避后仍失败 / 限速) + Server, + /// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对) + Unsupported, + /// 其它(解析失败、本地 IO……) + Other, +} + +impl ContentFailureKind { + /// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Offline => "offline", + Self::NotFound => "not_found", + Self::Server => "server", + Self::Unsupported => "unsupported", + Self::Other => "other", + } + } + + /// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。 + #[must_use] + pub const fn is_offline(self) -> bool { + matches!(self, Self::Offline) + } +} + +/// 一次检索/详情失败(大类 + 一句人读原因)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentFailure { + /// 大类 + pub kind: ContentFailureKind, + /// 人读说明(原文来自 core / 站点;**不是**界面文案) + pub detail: String, +} + +impl ContentFailure { + /// 构造。 + #[must_use] + pub fn new(kind: ContentFailureKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + } + } + + /// 没联网。 + #[must_use] + pub fn offline(detail: impl Into) -> Self { + Self::new(ContentFailureKind::Offline, detail) + } + + /// 这条路没通。 + #[must_use] + pub fn unsupported(detail: impl Into) -> Self { + Self::new(ContentFailureKind::Unsupported, detail) + } + + /// 没联网吗。 + #[must_use] + pub const fn is_offline(&self) -> bool { + self.kind.is_offline() + } +} + +impl std::fmt::Display for ContentFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}:{}", self.kind.as_str(), self.detail) + } +} + +/// 「取项目详情 + 版本列表」的请求(M162)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentDetailRequest { + /// 项目 id + pub project: String, + /// 来源(详情端点目前只接了 Modrinth;CurseForge 会明确报「未接通」) + pub source: ContentSource, + /// 目标游戏版本(选版本用;`None` = 不限) + pub game_version: Option, + /// 目标加载器(选版本用;`None` = 不限) + pub loader: Option, +} + +/// 一个可用版本(详情页「版本列表」的一行)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentVersionView { + /// 版本 id + pub id: String, + /// 版本名 + pub name: String, + /// 版本号 + pub number: String, + /// 版本类型(`release` / `beta` / `alpha`) + pub kind: String, + /// 支持的游戏版本 + pub game_versions: Vec, + /// 支持的加载器 + pub loaders: Vec, + /// 发布日期(站点原文) + pub published: String, + /// 主文件名(`None` = 站点没给) + pub file_name: Option, + /// 主文件字节数(`None` = 站点没给) + pub file_size: Option, +} + +/// 项目详情 + 版本列表(M162)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentDetail { + /// 项目 id + pub project: String, + /// 标题 + pub title: String, + /// 简介 + pub description: String, + /// 长文(markdown 原文;页面只当纯文本画前几行) + pub body: String, + /// 可用版本(**最新在前**,已按请求的版本/加载器过滤) + pub versions: Vec, +} + +impl ContentDetail { + /// 版本数量(页面「N 个可用版本」直接用)。 + #[must_use] + pub fn version_count(&self) -> usize { + self.versions.len() + } +} diff --git a/crates/presenter/src/dto/content/query.rs b/crates/presenter/src/dto/content/query.rs new file mode 100644 index 0000000..d097b83 --- /dev/null +++ b/crates/presenter/src/dto/content/query.rs @@ -0,0 +1,50 @@ +//! 排序方式(M109 起在 `dto/content.rs`,M162 拆出来给内容源抽象让位)。 +//! +//! 四种排序是**两个站点都有的公共面**,各自映射到自己的参数(Modrinth 的 `index`、 +//! CurseForge 的 `sortField`)。映射函数放在各自的来源实现里,这里只有声明与稳定 slug。 + +use serde::{Deserialize, Serialize}; + +/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ContentSort { + /// 相关度(Modrinth `relevance`;CF 没有对应项,退回热度) + Relevance, + /// 下载量 + Downloads, + /// 最新发布 + Newest, + /// 最近更新 + Updated, +} + +impl ContentSort { + /// 四种排序的声明顺序。 + pub const ALL: [ContentSort; 4] = [ + ContentSort::Relevance, + ContentSort::Downloads, + ContentSort::Newest, + ContentSort::Updated, + ]; + + /// Modrinth `/v2/search` 的 `index` 参数值。 + #[must_use] + pub const fn modrinth_index(self) -> &'static str { + match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Newest => "newest", + Self::Updated => "updated", + } + } + + /// CurseForge 的 `sortField`(1 = 热度,2 = 更新,3 = 发布,4 = 名称)。 + #[must_use] + pub const fn curseforge_field(self) -> u32 { + match self { + Self::Relevance | Self::Downloads => 1, + Self::Newest => 3, + Self::Updated => 2, + } + } +} diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index f0b5a2e..f479053 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -137,8 +137,10 @@ pub enum CoreEvent { NoticesChanged(Vec), /// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标) ContentResults(Box), - /// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥) - ContentFailed(String), + /// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画 + ContentFailed(Box), + /// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道 + ContentDetail(Box>), // M113: import/export /// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`]) Pack(Box), @@ -251,6 +253,8 @@ pub enum AppCommand { SearchContent(ContentQuery), /// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因) InstallContent(Box), + /// 取某个内容项目的详情与版本列表(M162;结果经 [`CoreEvent::ContentDetail`] 回来) + LoadContentDetail(Box), // M113: import/export /// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`]) Pack(Box), diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 2f91cac..b8710b2 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -40,8 +40,9 @@ pub use accounts::{ }; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use content::{ - loader_facet, ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults, - ContentSort, ContentSource, ContentState, + loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, + ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults, ContentSort, + ContentSource, ContentState, ContentVersionView, }; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 37e3b58..f23e3d9 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -46,7 +46,9 @@ mod tests; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use crate::dto::{ContentQuery, ContentResults}; +use crate::dto::{ + ContentDetail, ContentDetailRequest, ContentFailure, ContentQuery, ContentResults, +}; use super::env::HostEnv; use super::job::{spawn, HostTask, JobSender}; @@ -87,11 +89,19 @@ pub fn log_curseforge_state() { /// 后台检索(网络在后台线程,UI 不卡)。 pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) { spawn("content", move || { - let result: Result = sources::search(&env, &query); + let result: Result = sources::search(&env, &query); let _ = tx.send(HostTask::Content(Box::new(result))); }); } +/// 后台取项目详情 + 版本列表(M162;选中项目才发,一条独立通道)。 +pub fn spawn_detail(env: HostEnv, tx: JobSender, request: ContentDetailRequest) { + spawn("content-detail", move || { + let result: Result = sources::detail(&env, &request); + let _ = tx.send(HostTask::ContentDetail(Box::new(result))); + }); +} + /// 后台「装进指定实例」。 pub fn spawn_install( env: HostEnv, diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index be4d5db..432669d 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -19,8 +19,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; +use unlucky_core::addon::online::{download_verified, EngineArtifactFetcher}; use unlucky_core::download::blocking::block_on; -use unlucky_core::download::{DownloadRequest, ResourceKind}; use unlucky_core::game::install::instance::read_instance_config; use unlucky_core::game::isolation::derive_game_dir; @@ -133,12 +133,19 @@ fn run( }); let dest = target_dir.join(&file.name); - let mut download = DownloadRequest::new(ResourceKind::ModFile, file.url, dest.clone()); - if let Some(sha1) = file.sha1.as_deref().filter(|hex| !hex.is_empty()) { - download = download.with_sha1(sha1); - } - block_on(super::super::engine::engine(env).download(download)) - .map_err(|error| format!("下载失败:{error}"))?; + // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, + // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 + let core_engine = super::super::engine::engine(env); + let fetcher = EngineArtifactFetcher::new(&core_engine); + block_on(download_verified( + &fetcher, + &file.url, + file.sha1.as_deref(), + file.sha512.as_deref(), + file.size, + &dest, + )) + .map_err(|error| format!("下载失败:{error}"))?; Ok(format!("{}({})", file.name, dest.display())) } @@ -165,11 +172,13 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result, + sha512: Option, + size: Option, } /// 解析「这个项目下最适合目标实例的那个文件」。 @@ -223,15 +232,22 @@ fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result 0), }) } diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 8854b35..aebd0f9 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -14,7 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::addon::import::{ - import_pack, scan_pack, DownloadEngineFetcher, ImportProgress, ImportProgressSink, + import_pack, scan_pack_online, DownloadEngineFetcher, ImportProgress, ImportProgressSink, ImportRequest, }; use unlucky_core::download::blocking::block_on; @@ -76,9 +76,12 @@ pub(super) fn spawn_scan( return; } }; - let core_request = ImportRequest::new(&request.pack, &target); - // 预检是纯读的同步函数(不写盘、不联网),所以直接调用,不必占一个 async 任务。 - let scan = scan_pack(&core_request); + // 预检本身只读(不写盘),但 M162 起会**先补全再判**:缺 `hashes.sha1` 的 mrpack 条目 + // 按 sha512 反查 Modrinth。补不出仍按 M160 的口径计数,所以冷启动没网也不影响判定。 + let engine = super::super::super::engine::engine(&env); + let fetcher = DownloadEngineFetcher::new(&engine); + let core_request = ImportRequest::new(&request.pack, &target).with_fetcher(&fetcher); + let scan = block_on(scan_pack_online(&core_request)); if cancel.load(Ordering::SeqCst) { return; } diff --git a/crates/presenter/src/host/addon/sources.rs b/crates/presenter/src/host/addon/sources.rs index 19bfd40..4e33f21 100644 --- a/crates/presenter/src/host/addon/sources.rs +++ b/crates/presenter/src/host/addon/sources.rs @@ -1,6 +1,17 @@ -//! 两个站点的检索方言(M109):把各自的 JSON 翻成 [`ContentHit`]。 +//! 两个站点的检索方言(M109;M162 起 Modrinth 走 `core::addon::online`)+ **详情/版本列表**。 //! -//! 两个站点在**同一件事上用的字段名完全不同**,所以这里每个站点一段、各自带注释: +//! # M162 之后的分工 +//! +//! | 站点 | 检索 | 详情 / 版本列表 | 端点拼装与 JSON 解析在哪 | +//! |------|------|------------------|--------------------------| +//! | Modrinth | `core::addon::online` | `core::addon::online` | core(`online::modrinth` / `online::model`) | +//! | CurseForge | 本文件(要 `X-API-KEY`) | **未接通**(明确报错) | 本文件 | +//! +//! 为什么 Modrinth 搬走:「在线内容源」是 core 的能力(导出的 `RemoteCatalog`、导入的 +//! `PackFetcher`、UI 的检索要共用同一份解析与同一套错误分类)。presenter 只留 DTO 映射与 +//! 失败分类——它不再自己拼 URL、不再自己读 JSON 字段名。 +//! +//! # 两个站点的字段名完全不同(CF 这一半仍是本文件的活) //! //! | 语义 | Modrinth `/v2/search` | CurseForge `/v1/mods/search` | //! |---|---|---| @@ -11,17 +22,26 @@ //! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) | //! //! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**, -//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(`sources/tests` 钉住了这一条)。 +//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(core 那一侧的 +//! `known_loaders` 已经过滤,`sources/tests` 钉住了这一条)。 use serde_json::Value; -use crate::dto::{ - loader_facet, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, +use unlucky_core::addon::online::{ + ContentType, Modrinth, OnlineError, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery, }; +use crate::dto::{ + loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, + ContentHit, ContentQuery, ContentResults, ContentSource, ContentVersionView, +}; + +use super::super::engine; use super::super::env::HostEnv; use super::curseforge_ready; 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"; @@ -31,82 +51,145 @@ pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1"; const CF_MINECRAFT: u32 = 432; /// 检索分派。 -pub(super) fn search(env: &HostEnv, query: &ContentQuery) -> Result { +pub(super) fn search( + env: &HostEnv, + query: &ContentQuery, +) -> Result { match query.source { ContentSource::Modrinth => modrinth(env, query), ContentSource::Curseforge => curseforge(env, query), } } +/// 详情 + 版本列表分派(M162)。 +pub(super) fn detail( + env: &HostEnv, + request: &ContentDetailRequest, +) -> Result { + match request.source { + ContentSource::Modrinth => modrinth_detail(env, request), + ContentSource::Curseforge => Err(ContentFailure::unsupported( + "CurseForge 的详情/版本列表未接通(要 X-API-KEY 的文件端点);先切到 Modrinth 源", + )), + } +} + // --------------------------------------------------------------- Modrinth -fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result { - let mut facets = vec![format!("[\"project_type:{}\"]", query.kind.modrinth_type())]; - if let Some(version) = &query.game_version { - facets.push(format!("[\"versions:{version}\"]")); - } - if let Some(facet) = query.loader.and_then(loader_facet) { - facets.push(format!("[\"categories:{facet}\"]")); - } - - let mut url = format!( - "{MODRINTH}/search?limit={}&offset={}&index={}&facets={}", - query.limit.min(100), - query.offset, - query.sort.modrinth_index(), - encode(&format!("[{}]", facets.join(","))) - ); - if !query.text.trim().is_empty() { - url.push_str(&format!("&query={}", encode(query.text.trim()))); - } - - let body = get(env, &url, &[])?; - let json: Value = serde_json::from_str(&body) - .map_err(|error| format!("Modrinth 返回的不是合法 JSON:{error}"))?; - let hits = json - .get("hits") - .and_then(Value::as_array) - .ok_or_else(|| "Modrinth 返回里没有 hits 字段".to_string())? - .iter() - .map(modrinth_hit) - .collect::>(); - +fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result { + let core_engine = engine::engine(env); + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + let page = block_on(client.search(&search_query(query))).map_err(failure_of)?; Ok(ContentResults { query: query.clone(), - hits, - total: u32_of(&json, "total_hits"), + hits: page.hits.iter().map(hit_of).collect(), + total: u32::try_from(page.total_hits).unwrap_or(0), curseforge: curseforge_ready(), }) } -/// 一条 Modrinth 命中项 → DTO(缺字段一律给空/零,**不**编造)。 -fn modrinth_hit(value: &Value) -> ContentHit { - let loaders = strings(value, "categories") - .into_iter() - // `categories` 混着加载器与分类标签:只留已知的四个加载器 - .filter(|tag| matches!(tag.as_str(), "fabric" | "forge" | "neoforge" | "quilt")) - .collect(); +fn modrinth_detail( + env: &HostEnv, + request: &ContentDetailRequest, +) -> Result { + let core_engine = engine::engine(env); + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + let project = block_on(client.project(&request.project)).map_err(failure_of)?; + let versions = block_on(client.versions(&VersionQuery { + project: request.project.clone(), + game_version: request.game_version.clone(), + loader: request.loader.and_then(loader_facet).map(str::to_string), + })) + .map_err(failure_of)?; + Ok(ContentDetail { + project: if project.id.is_empty() { + request.project.clone() + } else { + project.id + }, + title: project.title, + description: project.description, + body: project.body, + versions: versions.iter().map(version_of).collect(), + }) +} - ContentHit { - id: text(value, "project_id"), - slug: text(value, "slug"), - title: text(value, "title"), - summary: text(value, "description"), - author: text(value, "author"), - downloads: u64_of(value, "downloads"), - updated: date_part(&text(value, "updated")), - source: ContentSource::Modrinth, - game_versions: strings(value, "versions"), - loaders, - icon: optional_text(value, "icon_url"), +/// DTO 检索条件 → core 的检索请求(**唯一**一处字段搬运)。 +fn search_query(query: &ContentQuery) -> SearchQuery { + SearchQuery { + text: query.text.clone(), + project_type: ContentType::parse(query.kind.modrinth_type()), + game_version: query.game_version.clone(), + loader: query.loader.and_then(loader_facet).map(str::to_string), + index: match query.sort { + crate::dto::ContentSort::Relevance => SortIndex::Relevance, + crate::dto::ContentSort::Downloads => SortIndex::Downloads, + crate::dto::ContentSort::Newest => SortIndex::Newest, + crate::dto::ContentSort::Updated => SortIndex::Updated, + }, + limit: query.limit, + offset: query.offset, } } +/// core 的命中项 → DTO(缺字段一律给空/零,**不**编造)。 +fn hit_of(hit: &unlucky_core::addon::online::ProjectHit) -> ContentHit { + ContentHit { + id: hit.project_id.clone(), + slug: hit.slug.clone(), + title: hit.title.clone(), + summary: hit.description.clone(), + author: hit.author.clone(), + downloads: hit.downloads, + updated: date_part(&hit.updated), + source: ContentSource::Modrinth, + game_versions: hit.game_versions.clone(), + loaders: hit.loaders.clone(), + icon: hit.icon_url.clone(), + } +} + +/// core 的版本 → DTO 的版本行(文件名/大小取 `primary` 那个)。 +fn version_of(version: &unlucky_core::addon::online::Version) -> ContentVersionView { + let file = version.primary_file(); + ContentVersionView { + id: version.id.clone(), + name: version.name.clone(), + number: version.version_number.clone(), + kind: version.version_type.as_str().to_string(), + game_versions: version.game_versions.clone(), + loaders: version.loaders.clone(), + published: date_part(&version.date_published), + file_name: file + .map(|file| file.filename.clone()) + .filter(|name| !name.trim().is_empty()), + file_size: file.map(|file| file.size).filter(|size| *size > 0), + } +} + +/// core 的错误 → DTO 的失败大类(**一一映射,不重新判**)。 +pub(super) fn failure_of(error: OnlineError) -> ContentFailure { + let kind = match error.kind() { + OnlineErrorKind::NotConnected => ContentFailureKind::Offline, + OnlineErrorKind::NotFound => ContentFailureKind::NotFound, + OnlineErrorKind::Server => ContentFailureKind::Server, + OnlineErrorKind::Unsupported => ContentFailureKind::Unsupported, + OnlineErrorKind::Malformed | OnlineErrorKind::Io | OnlineErrorKind::Integrity => { + ContentFailureKind::Other + } + }; + ContentFailure::new(kind, error.message().to_string()) +} + // --------------------------------------------------------------- CurseForge -fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result { +fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result { let Some(key) = super::curseforge_key() else { - return Err("CurseForge 需要 API key(ULL_CF_API_KEY 未设置)".to_string()); + return Err(ContentFailure::unsupported( + "CurseForge 需要 API key(ULL_CF_API_KEY 未设置)", + )); }; let mut url = format!( "{CURSEFORGE}/mods/search?gameId={CF_MINECRAFT}&classId={}&index={}&pageSize={}&sortField={}&sortOrder=desc", @@ -122,13 +205,19 @@ fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result>(); @@ -145,6 +234,21 @@ fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result ContentFailure { + let kind = if reason.contains("ULL_CF_API_KEY") { + ContentFailureKind::Unsupported + } else if reason.contains("HTTP 503") + || reason.contains("HTTP 502") + || reason.contains("HTTP 500") + { + ContentFailureKind::Server + } else { + ContentFailureKind::Other + }; + ContentFailure::new(kind, reason) +} + /// 一条 CF 命中项 → DTO。加载器只能从 `latestFilesIndexes[].modLoader` 的数字枚举反推。 fn curseforge_hit(value: &Value) -> ContentHit { let mut game_versions: Vec = Vec::new(); @@ -219,44 +323,17 @@ fn text(value: &Value, key: &str) -> String { .to_string() } -/// 取可选字符串字段(空串也当没有——站点会给 `""` 表示没图)。 -fn optional_text(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(Value::as_str) - .filter(|text| !text.is_empty()) - .map(ToString::to_string) -} - -/// 取字符串数组(缺 / 类型不对 ⇒ 空 vec)。 -fn strings(value: &Value, key: &str) -> Vec { - value - .get(key) - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(|item| item.as_str().map(ToString::to_string)) - .collect() - }) - .unwrap_or_default() -} - fn u64_of(value: &Value, key: &str) -> u64 { value.get(key).and_then(Value::as_u64).unwrap_or(0) } -fn u32_of(value: &Value, key: &str) -> u32 { - u32::try_from(u64_of(value, key)).unwrap_or(0) +/// 四条侧栏的常量表没被改乱(检索本身按 `kind` 走 facet)。 +#[must_use] +pub(super) fn all_kinds() -> [crate::dto::ContentKind; 4] { + crate::dto::ContentKind::ALL } /// ISO-8601 → 日期部分(`2024-05-01T…` → `2024-05-01`;短串原样返回)。 pub(super) fn date_part(raw: &str) -> String { raw.get(..10).unwrap_or(raw).to_string() } - -/// 类型名只用来钉住「四条侧栏都在」的常量没被改乱(检索本身按 `kind` 走 facet)。 -#[must_use] -pub(super) fn all_kinds() -> [ContentKind; 4] { - ContentKind::ALL -} diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 0d4a135..3c29d1e 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -4,8 +4,9 @@ use std::time::Duration; use super::http::{encode, retry_after, retryable}; use super::install::safe_file_name; -use super::sources::{all_kinds, curseforge_loader, date_part}; -use crate::dto::ContentKind; +use super::sources::{all_kinds, curseforge_loader, date_part, failure_of}; +use crate::dto::{ContentFailureKind, ContentKind}; +use unlucky_core::addon::online::{OnlineError, OnlineErrorKind}; #[test] fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() { @@ -83,3 +84,34 @@ fn the_four_content_kinds_are_the_four_side_entries() { ["modpack", "mod", "resourcepack", "shader"] ); } + +// ------------------------------------------------ M162:失败分类(core → DTO) + +/// core 的 `OnlineErrorKind` → DTO 的 `ContentFailureKind`:**一一映射,不重新判**。 +/// +/// 「没联网」是单独一类:页面据此画离线态(说「检查网络」),而不是画「换个关键词」。 +#[test] +fn core_errors_map_to_dto_failure_kinds() { + let cases = [ + (OnlineErrorKind::NotConnected, ContentFailureKind::Offline), + (OnlineErrorKind::NotFound, ContentFailureKind::NotFound), + (OnlineErrorKind::Server, ContentFailureKind::Server), + ( + OnlineErrorKind::Unsupported, + ContentFailureKind::Unsupported, + ), + (OnlineErrorKind::Malformed, ContentFailureKind::Other), + (OnlineErrorKind::Io, ContentFailureKind::Other), + (OnlineErrorKind::Integrity, ContentFailureKind::Other), + ]; + for (core, expected) in cases { + let failure = failure_of(OnlineError::new(core, "x")); + assert_eq!(failure.kind, expected, "{} 映射错了", core.as_str()); + } + assert!(failure_of(OnlineError::offline("connect timed out")).is_offline()); + // 详情/版本列表的文案要带出 core 的原话(不是二次包装的「出错了」) + assert_eq!( + failure_of(OnlineError::offline("connect timed out")).detail, + "connect timed out" + ); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 2e14c44..a8a679d 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -233,6 +233,10 @@ impl CoreHost { AppCommand::SearchContent(query) => { addon::spawn_search(self.env.clone(), self.tx.clone(), query); } + // M162:详情/版本列表(选中项目才发;结果经 `CoreEvent::ContentDetail` 回来) + AppCommand::LoadContentDetail(request) => { + addon::spawn_detail(self.env.clone(), self.tx.clone(), *request); + } // M109:把某个内容项目装进指定实例。取消标记与安装面板共用同一条路 // (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。 // M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板 diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index e25de91..f2f6e43 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -38,8 +38,12 @@ impl CoreHost { // toast 只做「顺手提一句」会把可重试的错误变成一次性噪音。 HostTask::Content(result) => match *result { Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))), - Err(reason) => self.send(CoreEvent::ContentFailed(reason)), + Err(failure) => self.send(CoreEvent::ContentFailed(Box::new(failure))), }, + // M162:详情/版本列表(与检索同一套失败分类) + HostTask::ContentDetail(result) => { + self.send(CoreEvent::ContentDetail(result)); + } // M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉 HostTask::Pack { event, epoch } => { // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 2f4173b..ee8e009 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -10,6 +10,8 @@ use crate::dto::{ // 主线(M109 ContentResults / M105 LoaderKind / M113 PackEvent)与 M114 的账户轨并集。 AccountView, AuthlibLoginView, + ContentDetail, + ContentFailure, ContentResults, DeviceLoginView, DiagnosisReport, @@ -44,8 +46,10 @@ pub enum HostTask { /// `Ok(最新在前)` / `Err(人读原因)` result: Result, String>, }, - /// 内容库检索(M109;`Err` = 一句人读原因) - Content(Box>), + /// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因) + Content(Box>), + /// 内容库项目详情 + 版本列表(M162) + ContentDetail(Box>), // M113: import/export /// 导入/导出(M113):一条事件 + 任务代次 /// diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index cd39fd4..5fdd0c6 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -296,10 +296,15 @@ impl AppState { // M113: import/export CoreEvent::Pack(event) => self.pack.apply(*event), CoreEvent::ContentResults(results) => self.apply_content(*results), - CoreEvent::ContentFailed(reason) => { + CoreEvent::ContentFailed(failure) => { self.content.pending = false; - self.content.failed = Some(reason); + 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), + }, } } diff --git a/crates/presenter/src/state/content_tests.rs b/crates/presenter/src/state/content_tests.rs index e2c612b..9781708 100644 --- a/crates/presenter/src/state/content_tests.rs +++ b/crates/presenter/src/state/content_tests.rs @@ -4,7 +4,8 @@ //! `has_more` 必须读「最近一页是否装满」,不能读累计条数——否则第 2 页之后恒真。 use crate::dto::{ - AppEvent, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, CoreEvent, + AppEvent, ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, + ContentQuery, ContentResults, ContentSource, ContentVersionView, CoreEvent, }; use super::AppState; @@ -128,3 +129,73 @@ fn a_stale_batch_does_not_touch_the_page_flag() { assert!(!state.content.last_page_filled); assert!(state.content.results.is_none(), "迟到的一批被丢掉"); } + +// ------------------------------------------------ M162:失败大类与详情通道 + +/// 事件 → 状态:`ContentFailed` 带大类,没联网时 `is_offline()` 为真(页面据此画离线态)。 +#[test] +fn a_content_failure_keeps_its_kind() { + let mut state = AppState::new(); + state.set_content_query(ContentQuery::first(ContentKind::Mod)); + state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::offline( + "connect timed out", + )))); + let failure = state.content.failed.as_ref().expect("要记住失败"); + assert!(failure.is_offline(), "没联网必须能被页面认出来"); + assert!(!state.content.pending); + + state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::new( + ContentFailureKind::Server, + "HTTP 503", + )))); + let failure = state.content.failed.as_ref().expect("要记住失败"); + assert!(!failure.is_offline(), "站点 5xx 不是「没联网」"); +} + +/// 详情通道:请求态 → 结果态;换项目时旧详情先清掉。 +#[test] +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"))))); + assert!(!state.content.detail_pending); + assert_eq!( + state.content.detail.as_ref().map(|d| d.project.as_str()), + Some("p1") + ); + + state.content.request_detail("p2"); + assert!( + state.content.detail.is_none(), + "换了项目就不能留着上一个的版本列表" + ); + state.apply_core(CoreEvent::ContentDetail(Box::new(Err( + ContentFailure::offline("connect timed out"), + )))); + assert!(state + .content + .detail_failed + .as_ref() + .is_some_and(ContentFailure::is_offline)); +} + +fn detail(project: &str) -> ContentDetail { + ContentDetail { + project: project.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), + }], + } +}