diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 00b5140..42668b8 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) => { @@ -160,6 +166,26 @@ impl App { .is_some_and(|results| results.curseforge) } + /// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。 + /// + /// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本 + /// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。 + fn content_detail_request(&self) -> Option { + let catalog = unlucky_i18n::Lang::En.catalog(); + let model = Content::from_snapshot(&self.state, &self.content, catalog); + 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), + }) + } + /// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。 /// /// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`), 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..2b60022 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,72 @@ 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( + "p1", + ContentDetail { + project: "p1".to_string(), + title: "Sodium".to_string(), + description: "fast".to_string(), + body: String::new(), + versions: vec![ContentVersionView { + id: "v1".to_string(), + name: "Sodium 0.6.0".to_string(), + number: "mc1.20.1-0.6.0".to_string(), + kind: "release".to_string(), + game_versions: vec!["1.20.1".to_string()], + loaders: vec!["fabric".to_string()], + published: "2026-02-01T00:00:00Z".to_string(), + file_name: Some("sodium.jar".to_string()), + file_size: Some(2048), + }], + }, + ); + let model = Content::from_snapshot(&state, &page, Lang::En.catalog()); + assert_eq!(model.available_versions.len(), 1); + assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0"); + 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/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index 646056f..f55d29c 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -98,6 +98,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { bytes_total: 0, speed_bps: 0, eta_secs: 0, + // 整合包导入的失败不产生内容库分类(它有自己的报告视图)。 + failure_kind: None, message: progress.current.clone().unwrap_or_default(), } } diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs index 621ba5b..806492a 100644 --- a/crates/app-iced/src/pages/content/parts.rs +++ b/crates/app-iced/src/pages/content/parts.rs @@ -21,13 +21,12 @@ use unlucky_presenter::dto::ContentSort; use unlucky_presenter::router::Route; use crate::app::components::buttons::{self, Button}; -use crate::app::components::feedback::{empty_state, EmptyState}; use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::app::components::selection::{chip, Chip}; use crate::theme::Ui; use super::layout::Rails; -use super::model::{Content, Hit, Status}; +use super::model::{Content, Hit}; mod detail; mod filters; @@ -209,111 +208,6 @@ pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element ) } -pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { - centered( - column![ - frame::text_role_centered( - ui, - TypeRole::TitleMedium, - true, - catalog.text(Key::ContentLoading), - ui.color(Role::OnSurface) - ), - frame::text_role_centered( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::ContentLoadingHint), - ui.color(Role::OnSurfaceVariant) - ), - buttons::text( - ui, - Button::new( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry) - ) - ), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .align_x(Alignment::Center), - ) -} - -/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。 -pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { - centered(empty_state( - ui, - EmptyState::new( - IconKind::Search, - catalog.text(Key::ContentEmptyTitle), - catalog.text(Key::ContentEmptyBody), - ) - .action( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry), - ), - )) -} - -/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。 -pub(super) fn failed<'a>( - ui: &'a Ui, - catalog: Catalog, - reason: &'a str, -) -> Element<'a, PageMessage> { - centered( - column![ - row![ - frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), - frame::text_role( - ui, - TypeRole::TitleSmall, - true, - catalog.text(Key::ContentFailedTitle), - ui.color(Role::Error) - ), - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - reason, - ui.color(Role::OnSurfaceVariant) - ), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::ContentFailedBody), - ui.color(Role::OnSurfaceVariant) - ), - buttons::filled( - ui, - Button::new( - catalog.text(Key::ContentRetry), - intent(ContentMessage::Retry) - ) - .leading(IconKind::Refresh) - ), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .align_x(Alignment::Center), - ) -} - -fn centered<'a>(content: impl Into>) -> Element<'a, PageMessage> { - container(content) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .into() -} - /// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标, /// 已在报告里登记为偏离)。 pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind { @@ -341,16 +235,6 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str { } } -/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。 -pub(super) fn status_body<'a>( - ui: &'a Ui, - catalog: Catalog, - model: &'a Content, -) -> Option> { - match &model.status { - Status::Loading => Some(loading(ui, catalog)), - Status::Empty => Some(empty(ui, catalog)), - Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())), - Status::Ready => None, - } -} +mod status; + +pub(crate) use status::status_body; 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/app-iced/src/pages/content/parts/status.rs b/crates/app-iced/src/pages/content/parts/status.rs new file mode 100644 index 0000000..79bad48 --- /dev/null +++ b/crates/app-iced/src/pages/content/parts/status.rs @@ -0,0 +1,200 @@ +//! 内容库页的**状态层**:加载 / 空 / 失败 / 未联网 / 状态分派。 +//! +//! 从 `parts.rs` 拆出(M161 合入后那份越过了 400 行;状态层本来就是「一屏只有一个」的 +//! 独立东西,拆开后 `parts.rs` 只剩筛选条与列表)。四态各一支,判据在 +//! `model::Status`,这里只负责画。 +//! +//! **未联网(M162)与失败是两屏**:前者说「要么联网、要么真的没有」,后者说「拉取失败了」。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::{empty_state, EmptyState}; +use crate::theme::Ui; + +use super::super::model::{Content, Status}; +use super::intent; +use crate::pages::content::Message as ContentMessage; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +pub(crate) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { + centered( + column![ + frame::text_role_centered( + ui, + TypeRole::TitleMedium, + true, + catalog.text(Key::ContentLoading), + ui.color(Role::OnSurface) + ), + frame::text_role_centered( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentLoadingHint), + ui.color(Role::OnSurfaceVariant) + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} + +/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。 +pub(crate) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { + centered(empty_state( + ui, + EmptyState::new( + IconKind::Search, + catalog.text(Key::ContentEmptyTitle), + catalog.text(Key::ContentEmptyBody), + ) + .action( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry), + ), + )) +} + +/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。 +pub(crate) fn failed<'a>( + ui: &'a Ui, + catalog: Catalog, + reason: &'a str, +) -> Element<'a, PageMessage> { + centered( + column![ + row![ + frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::ContentFailedTitle), + ui.color(Role::Error) + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + reason, + ui.color(Role::OnSurfaceVariant) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentFailedBody), + ui.color(Role::OnSurfaceVariant) + ), + buttons::filled( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + .leading(IconKind::Refresh) + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} + +fn centered<'a>(content: impl Into>) -> Element<'a, PageMessage> { + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} + +/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。 +pub(crate) 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(crate) fn status_body<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Option> { + match &model.status { + 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/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 63d9b38..4658ce1 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -295,6 +295,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView { speed_bps: if failed { 0 } else { 1_258_291 }, eta_secs: if failed { 0 } else { 75 }, message: String::new(), + // 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。 + failure_kind: None, }, catalog, ) diff --git a/crates/core/src/addon/import.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..c19468e --- /dev/null +++ b/crates/core/src/addon/import/complete.rs @@ -0,0 +1,323 @@ +//! 远端条目补全(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. **只有「没联网」才立刻停**:第一条 [`FetchErrorKind::Offline`] 之后剩下的条目一律算 +//! 「补不出」,不拿 N 次超时惩罚用户;**404 / 5xx 不停**——真实 mrpack 的文件大多托管在 +//! CurseForge CDN,Modrinth 的 `version_file` 对它们必然 404,把「补不出」当「没联网」 +//! 会让「能补就补」只在第一条恰好托管在 Modrinth 时成立(r1 P1-1 后果 B)。 +//! +//! # 为什么要改 `plan.remote` 而不是 `manifest.files` +//! +//! 导入与预检都只读 `plan.remote`(`manifest` 保留原样作诊断),改一处即可两条路都生效, +//! 也不会让人误以为「清单被回写了」。 + +use super::fetch::FetchErrorKind; +use super::plan::ImportPlan; +use super::report::{keys, ImportIssue, ImportReport}; +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, +} + +impl Completion { + /// 补全结果 → 报告注记(`None` = 既没补到、也没因断网停下,不必占一行)。 + /// + /// `lead` 是场景前缀(导入用空串、预检用「预检前」):**文案只此一处**,两个入口不许 + /// 各写一句——r1 抓到的「404 却写因未联网提前停止」正是两处文案各自演进的后果。 + /// 现在 `offline` 只由真 `Offline` 置位,这句话只会在真断网时出现。 + pub(crate) fn note(&self, lead: &str) -> Option { + if self.filled == 0 && !self.offline { + return None; + } + let mut message = if self.filled == 0 { + // 一条也没补到(只可能是断网):说「补全了 0 条」读起来像成功。 + format!( + "{lead}按 sha512 从 Modrinth 补全 sha1:一条也没补到(补不出 {} 条)", + self.unfilled + ) + } else { + format!( + "{lead}按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)", + self.filled, self.unfilled + ) + }; + if self.offline { + message.push_str(if self.filled == 0 { + ";补全因未联网提前停止" + } else { + ";补全因未联网提前停止,其余条目照旧按「缺省保留」处置" + }); + } + Some(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message)) + } +} + +/// 补全 + 把注记写进报告(导入管线唯一的入口:一行调用,文案与计数都在本模块)。 +pub(crate) async fn complete_and_note( + request: &ImportRequest<'_>, + plan: &mut ImportPlan, + report: &mut ImportReport, +) { + if let Some(note) = complete(request, plan).await.note("") { + report.warnings.push(note); + } +} + +/// 跑一次补全(就地改 `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) => { + // 只有**真正的「没联网」**才提前收工:404(这条不在 Modrinth 上,或托管在 + // CF CDN)与 5xx 都只影响这一条,后面那些仍然可能补得出来。 + if error.kind() == FetchErrorKind::Offline { + 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()) +} + +#[cfg(test)] +mod tests { + //! 覆盖 **pack 用例走不到**的两条分支(r3 复审指出的缺口): + //! `sha512` 缺失臂与「已有 downloads 不许追加第二个直链」。 + //! + //! mrpack 的读者要求 `hashes.sha512` 必填,所以「有 path 但没有 sha512」在真实文件里进不来 + //! ——它是**防御分支**。直接构造 `ImportPlan` 是唯一能让它变红的办法(不是放宽断言)。 + + use std::path::Path; + use std::sync::atomic::{AtomicU32, Ordering}; + + use super::{complete, Completion}; + use crate::addon::import::fetch::{ + FetchError, FetchFuture, FetchRequest, FetchedFile, ModrinthFileMeta, PackFetcher, + }; + use crate::addon::import::plan::{ImportPlan, PlanRemote}; + use crate::addon::import::report::FileSource; + use crate::addon::import::ImportRequest; + use crate::addon::pack::{PackFile, PackManifest}; + use crate::download::blocking::block_on; + + /// 只回答 `modrinth_file_by_sha512` 的假 fetcher(并记下被问了几次)。 + struct Lookup { + meta: Result, + asked: AtomicU32, + } + + impl PackFetcher for Lookup { + fn modrinth_file_by_sha512<'a>( + &'a self, + _sha512: &'a str, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { + self.asked.fetch_add(1, Ordering::SeqCst); + match &self.meta { + Ok(meta) => Ok(meta.clone()), + Err(error) => Err(error.clone()), + } + }) + } + + fn fetch_file<'a>( + &'a self, + _request: FetchRequest<'a>, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { Err(FetchError::unsupported("本用例不下载")) }) + } + } + + fn plan_with(file: PackFile) -> ImportPlan { + ImportPlan { + format: "modrinth".to_string(), + manifest: PackManifest::default(), + mc_version: "1.20.1".to_string(), + loader: None, + version_id: "1.20.1".to_string(), + rejected: Vec::new(), + notes: Vec::new(), + overrides: Vec::new(), + remote: vec![PlanRemote { + file, + path: Some("mods/no-sha1.jar".to_string()), + source: FileSource::Modrinth, + disabled: false, + }], + max_memory_mb: None, + } + } + + fn entry(sha512: Option<&str>, downloads: &[&str]) -> PackFile { + let mut file = PackFile::with_path("mods/no-sha1.jar"); + file.downloads = downloads.iter().map(|url| (*url).to_string()).collect(); + if let Some(sha512) = sha512 { + file.hashes.insert("sha512".to_string(), sha512.to_string()); + } + file + } + + fn meta(sha1: &str, url: &str) -> ModrinthFileMeta { + ModrinthFileMeta { + sha1: Some(sha1.to_string()), + size: Some(16), + url: Some(url.to_string()), + file_name: Some("no-sha1.jar".to_string()), + } + } + + #[test] + fn an_entry_without_sha512_is_counted_not_guessed() { + // 防御分支:有 path、有 downloads、**没有 sha512** ⇒ 无从反查,如实计数、不发请求。 + let lookup = Lookup { + meta: Ok(meta(&"a".repeat(40), "stub://from-source")), + asked: AtomicU32::new(0), + }; + let mut plan = plan_with(entry(None, &["stub://original"])); + let request = ImportRequest::new(Path::new("pack.mrpack"), Path::new("instance")) + .with_fetcher(&lookup); + let summary = block_on(complete(&request, &mut plan)); + assert_eq!(summary.filled, 0); + assert_eq!(summary.unfilled, 1, "缺 sha512 要计入「补不出」"); + assert_eq!( + lookup.asked.load(Ordering::SeqCst), + 0, + "没有摘要就不许发请求" + ); + assert_eq!(plan.remote[0].file.sha1(), None, "补不出就是补不出,不许猜"); + } + + #[test] + fn an_existing_download_list_is_never_extended() { + // 已有直链 ⇒ 站点给的直链**不许**再追加一条(列表必须逐字相同)。 + let lookup = Lookup { + meta: Ok(meta(&"a".repeat(40), "stub://from-source")), + asked: AtomicU32::new(0), + }; + let mut plan = plan_with(entry(Some(&"c".repeat(128)), &["stub://original"])); + let request = ImportRequest::new(Path::new("pack.mrpack"), Path::new("instance")) + .with_fetcher(&lookup); + let summary = block_on(complete(&request, &mut plan)); + assert_eq!(summary.filled, 1); + assert_eq!(plan.remote[0].file.sha1(), Some("a".repeat(40).as_str())); + assert_eq!( + plan.remote[0].file.downloads, + vec!["stub://original".to_string()], + "已有直链时不许追加第二个(长度与内容都要逐字相同)" + ); + assert_eq!(lookup.asked.load(Ordering::SeqCst), 1); + } + + #[test] + fn offline_with_nothing_filled_does_not_say_zero_items_were_filled() { + let note = Completion { + filled: 0, + unfilled: 3, + offline: true, + } + .note("预检前") + .expect("断网要留一条注记"); + assert!( + note.message.contains("一条也没补到"), + "断网且一条没补到时不许说「补全了 0 条」:{}", + note.message + ); + assert!( + !note.message.contains("补全了 0 条"), + "文案读起来像成功:{}", + note.message + ); + assert!( + !note.message.contains("缺省保留"), + "预检场景没有「缺省保留」这回事:{}", + note.message + ); + + // 真的补到过东西时,仍是原来那句(并带上「其余条目按缺省保留」)。 + let note = Completion { + filled: 2, + unfilled: 1, + offline: true, + } + .note("") + .expect("补到过就要留注记"); + assert!(note.message.contains("补全了 2 条")); + assert!(note.message.contains("缺省保留")); + + // 没补到、也没断网(例如所有摘要站点都不认得)⇒ 不占一行。 + assert!(Completion { + filled: 0, + unfilled: 2, + offline: false + } + .note("") + .is_none()); + } +} diff --git a/crates/core/src/addon/import/error.rs b/crates/core/src/addon/import/error.rs index 600a977..8901b2e 100644 --- a/crates/core/src/addon/import/error.rs +++ b/crates/core/src/addon/import/error.rs @@ -176,9 +176,10 @@ impl From for ImportError { let kind = match error.kind() { FetchErrorKind::Integrity => ImportErrorKind::Integrity, FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io), - FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => { - ImportErrorKind::Fetch - } + FetchErrorKind::NotFound + | FetchErrorKind::Network + | FetchErrorKind::Offline + | FetchErrorKind::Unsupported => ImportErrorKind::Fetch, }; Self { kind, diff --git a/crates/core/src/addon/import/fetch.rs b/crates/core/src/addon/import/fetch.rs index 3017224..14d1ca2 100644 --- a/crates/core/src/addon/import/fetch.rs +++ b/crates/core/src/addon/import/fetch.rs @@ -16,25 +16,39 @@ //! //! # 生产实现只走 `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)] pub enum FetchErrorKind { /// 源上确实没有(404 / 文件被删)。 NotFound, - /// 网络侧失败(超时、连不上、所有候选源都失败)。 + /// 网络侧失败(超时、连不上、站点 5xx 退避后仍失败)。 Network, + /// **没联网**:连不上任何候选源(与 `Network` 分开,是因为「补全」见到它要立刻停, + /// 而见到 404 / 5xx 不该停 —— 下一条可能补得出来)。 + Offline, /// 本地 IO(写盘失败、磁盘满)。 Io, /// 内容与声明的摘要不符。 @@ -50,6 +64,7 @@ impl FetchErrorKind { match self { Self::NotFound => "fetch_not_found", Self::Network => "fetch_network", + Self::Offline => "fetch_offline", Self::Io => "fetch_io", Self::Integrity => "fetch_integrity", Self::Unsupported => "fetch_unsupported", @@ -59,7 +74,10 @@ impl FetchErrorKind { /// 是否属于"某个文件搞不到"(可容忍,实例照装)。 #[must_use] pub const fn is_tolerable(self) -> bool { - matches!(self, Self::NotFound | Self::Network | Self::Unsupported) + matches!( + self, + Self::NotFound | Self::Network | Self::Offline | Self::Unsupported + ) } } @@ -115,14 +133,15 @@ impl std::error::Error for FetchError {} impl From for FetchError { fn from(error: DownloadError) -> Self { - let kind = match error.kind { + // 用**叶子成因**(`effective_kind`):候选源循环结束时只有一条 `AllSourcesFailed`, + // 直接按它判定会把「这个文件不存在」也算成「网络失败」。 + let kind = match error.effective_kind() { DownloadErrorKind::NotFound => FetchErrorKind::NotFound, DownloadErrorKind::Integrity => FetchErrorKind::Integrity, DownloadErrorKind::Io => FetchErrorKind::Io, DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported, + DownloadErrorKind::Timeout | DownloadErrorKind::Network => FetchErrorKind::Offline, DownloadErrorKind::ServerError - | DownloadErrorKind::Timeout - | DownloadErrorKind::Network | DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network, }; @@ -130,6 +149,21 @@ 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, + // 「没联网」要能被「补全立刻停」认出来;站点 5xx 只是这一条补不出。 + OnlineErrorKind::NotConnected => FetchErrorKind::Offline, + 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 +182,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 +204,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 +234,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 +271,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..0f83f61 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 @@ -224,12 +225,12 @@ pub(crate) async fn fetch_remote( struct Resolved { path: String, url: String, - sha1: Option, + sha1: Option, // 两档摘要各自可能缺(CF 只有 sha1、mrpack 可能只有 sha512) + sha512: Option, size: Option, } /// 把条目补全成"有路径、有地址"。 -/// /// `Ok(None)` = 这条补不出来(进 `missing`);`Err` = 致命(本地 IO / 内容不符)。 async fn resolve( request: &ImportRequest<'_>, @@ -250,6 +251,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, })); } @@ -275,7 +277,9 @@ async fn resolve( Ok(Some(Resolved { path: format!("{dir}/{}", meta.file_name), url, + // CF 官方只给 sha1,这一档没有可核的期望值。 sha1: meta.sha1, + sha512: None, size: meta.size, })) } @@ -283,8 +287,7 @@ async fn resolve( /// 合并判定 + 决策 → 实际动作。 /// /// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]): -/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错 -/// 一个保留"不会把一份全新文件漏掉。 +/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。 fn settle( decision: merge::MergeDecision, disk_exists: bool, diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index c4304ae..14620cd 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -43,11 +43,12 @@ 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); + super::complete::complete_and_note(request, &mut plan, &mut report).await; 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..0ea938e 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,37 @@ 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 let Some(note) = completion.note("预检前") { + scan.notes.push(note); + } + 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 +275,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..08b09e9 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,20 @@ 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::{ + download_verified, ArtifactFetcher, 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..98d64ae --- /dev/null +++ b/crates/core/src/addon/online/complete.rs @@ -0,0 +1,119 @@ +//! 按摘要认文件:[`identity_of`] 判断「站点返回的这个版本里,哪个文件才是我们要的那个」。 +//! +//! # 为什么只留这一件 +//! +//! 生产上的补全循环只有**一处**:`crate::addon::import::complete`(它走 `PackFetcher`, +//! 因此能区分「站点不认得这个摘要(404)」与「没联网(Offline)」,这决定了要不要提前收工)。 +//! 这里曾经还有一份 `complete_files` 循环,r2 复审指出「看着像生产代码其实没人调、两套循环 +//! 迟早漂移」⇒ 已删除。本文件只留**判定**,两个调用点共用: +//! +//! | 调用方 | 用它做什么 | +//! |--------|------------| +//! | `addon::import::fetch`(生产补全) | 按 sha512 反查回来的版本 → 取出那份文件的官方 sha1/大小/直链 | +//! | `online::complete` 的用例 | 钉住「对不上就不许用」 | +//! +//! # 一致性比「有值」重要 +//! +//! 站点返回的版本里可能有多个文件(`primary` 之外还有 `sources.jar`)。只在 +//! **`hashes.sha512` 与请求值逐字相同**的那条上取 `sha1`;一个都对不上就返回 `None` +//! ——宁可让上游记「补不出」,也不把别的东西的摘要装进来。 + +use super::model::Version; + +/// 从版本里找出**摘要对得上**的那个文件,取出它的 sha1/大小/地址。 +/// +/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过), +/// 或者对上的那条没有 sha1 可用。 +#[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, +} + +#[cfg(test)] +mod tests { + use super::identity_of; + use crate::addon::online::model::{Version, VersionFile}; + + fn version(files: Vec) -> Version { + Version { + id: "v1".to_string(), + project_id: "p1".to_string(), + files, + ..Version::default() + } + } + + fn file(sha1: Option<&str>, sha512: &str, primary: bool) -> VersionFile { + let mut file = VersionFile { + url: "https://cdn.modrinth.com/data/p1/v1/a.jar".to_string(), + filename: "a.jar".to_string(), + size: 2048, + primary, + ..VersionFile::default() + }; + file.hashes.insert("sha512".to_string(), sha512.to_string()); + if let Some(sha1) = sha1 { + file.hashes.insert("sha1".to_string(), sha1.to_string()); + } + file + } + + #[test] + fn the_matching_file_wins_even_when_it_is_not_the_primary() { + let wanted = "a".repeat(128); + let other = "b".repeat(128); + let version = version(vec![ + file(Some(&"1".repeat(40)), &other, true), + file(Some(&"2".repeat(40)), &wanted, false), + ]); + let identity = identity_of(&version, &wanted).expect("摘要对得上,必须给出身份"); + assert_eq!(identity.sha1, "2".repeat(40)); + assert_eq!(identity.size, Some(2048)); + assert!(identity.url.is_some()); + } + + #[test] + fn a_version_without_that_hash_yields_nothing() { + let version = version(vec![file(Some(&"1".repeat(40)), &"b".repeat(128), true)]); + assert_eq!(identity_of(&version, &"c".repeat(128)), None); + } + + #[test] + fn a_matching_file_without_a_sha1_is_not_usable() { + // 站点只给了 sha512:这时没有可填的 sha1,宁可「补不出」也不许拿 sha512 冒充。 + let wanted = "a".repeat(128); + let version = version(vec![file(None, &wanted, true)]); + assert_eq!(identity_of(&version, &wanted), None); + } + + #[test] + fn hex_case_does_not_matter() { + let wanted = "AbCd".repeat(32); + let version = version(vec![file( + Some(&"1".repeat(40)), + &wanted.to_ascii_lowercase(), + true, + )]); + assert!(identity_of(&version, &wanted).is_some()); + } +} diff --git a/crates/core/src/addon/online/download.rs b/crates/core/src/addon/online/download.rs new file mode 100644 index 0000000..5befc04 --- /dev/null +++ b/crates/core/src/addon/online/download.rs @@ -0,0 +1,300 @@ +//! 「取到下载地址」之后的最后一步:**下载 → 复核 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("没有可用下载地址")); + } + // 两档摘要**各自**过形状检查:坏掉的那一档丢掉,用另一档校验(mrpack 的权威摘要是 + // sha512,CLI/整合包都可能给出一个人写坏的 sha1——为此整条不装是过分惩罚)。 + // 但如果**声明过**摘要却一档都用不上,就必须失败:那是「没有任何校验」的伪装。 + let declared = sha1.is_some() || sha512.is_some(); + let sha1 = usable_hash(sha1, HashAlgorithm::Sha1); + let sha512 = usable_hash(sha512, HashAlgorithm::Sha512); + if declared && sha1.is_none() && sha512.is_none() { + return Err(OnlineError::unsupported( + "声明的 sha1/sha512 都不是合法摘要,无法校验(拒绝在「没有校验」的情况下落盘)", + )); + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + OnlineError::new( + 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 = match digest_file(dest) { + Ok(digest) => digest, + Err(error) => { + // 与其它失败路径一致:读不出来就不许把文件留在盘上。 + remove_quiet(dest); + return Err(error); + } + }; + if let Some(expected) = sha1 { + if !equals_hex(expected, &digest.sha1) { + last = Some(OnlineError::integrity(format!( + "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`]),形状不合法就当「没给」。 +/// +/// 为什么丢掉而不是报错:`sha1` 与 `sha512` 是同一个文件的两档校验,**任一**都能证明内容; +/// 一档写坏不该让整条装不上(r1 顺手项)。两档都不可用时由 `download_verified` 统一拒绝。 +fn usable_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Option<&str> { + raw.map(str::trim) + .filter(|hex| !hex.is_empty()) + .filter(|hex| algorithm.accepts(hex)) +} + +/// 十六进制比较(大小写不敏感:声明侧可能大写)。 +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..d548b17 --- /dev/null +++ b/crates/core/src/addon/online/mod.rs @@ -0,0 +1,68 @@ +//! 在线内容源(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** 逐条查(循环在 +//! `crate::addon::import::complete`,本模块只用 [`identity_of`] 认文件), +//! mrpack 里缺 `sha1` 的条目本来就是少数,不值得为它引入第二条 HTTP 路径。 +//! +//! # 离线不是「空结果」 +//! +//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串: +//! 引擎把叶子成因(404 / 5xx / 网络)放在 `DownloadError::leaf` 上,[`OnlineError`] 按它映射 +//! 成 `NotFound` / `Server` / `NotConnected`(见 `transport::From` 的映射表), +//! presenter 再翻成 DTO 的 `ContentFailureKind::{Offline,NotFound,Server}`。 +//! 没有叶子时保守归到 `NotConnected`——宁可说「检查网络」,也不声称「没有匹配的内容」。 +//! +//! # 模块 +//! +//! | 文件 | 职责 | +//! |------|------| +//! | [`model`] | Modrinth 的响应形状 + 检索请求(facets / 排序 / 分页) | +//! | [`transport`] | HTTP 出口 trait + 生产实现(走下载引擎)+ 错误分类 | +//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | +//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | +//! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 | + +pub mod complete; +pub mod download; +pub mod model; +pub mod modrinth; +pub mod transport; + +pub use complete::identity_of; +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..38ea9d5 --- /dev/null +++ b/crates/core/src/addon/online/tests/api.rs @@ -0,0 +1,205 @@ +//! 端点拼装 + 响应解析 + 离线分类(纯离线)。 + +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); +} + +// ------------------------------------------------ 按摘要反查(补全路径的那两个端点行为) + +#[test] +fn version_file_lookup_parses_the_recorded_payload() { + let transport = FixtureTransport::new().with( + &format!("{MODRINTH_API}/version_file/"), + fixtures::VERSION_FILE_A, + ); + let client = Modrinth::new(&transport); + let version = block_on(client.version_by_hash(HashAlgorithm::Sha512, fixtures::LOOKUP_SHA512)) + .expect("录制响应应当解析成功"); + assert_eq!(version.id, "ver-a"); + assert_eq!(version.game_versions, vec!["1.20.1".to_string()]); + + // 身份判定只认「sha512 逐字相同」的那条:同版本里的 sources.jar 不许被当成它 + let identity = crate::addon::online::complete::identity_of(&version, fixtures::LOOKUP_SHA512) + .expect("摘要对得上就该给出身份"); + assert_eq!(identity.sha1, fixtures::LOOKUP_SHA1); + assert_eq!(identity.size, Some(4096)); + let other = crate::addon::online::complete::identity_of(&version, fixtures::OTHER_SHA512) + .expect("另一个文件也认得出来"); + assert_ne!(other.sha1, fixtures::LOOKUP_SHA1, "两档摘要不许串"); +} + +#[test] +fn a_hash_the_source_does_not_know_is_not_found_and_not_offline() { + // 真实 mrpack 大多托管在 CurseForge CDN:Modrinth 对这个 sha512 必然 404。 + let transport = FixtureTransport::new().missing(&format!("{MODRINTH_API}/version_file/")); + let client = Modrinth::new(&transport); + let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, &"a1".repeat(64))) + .expect_err("站点不认得就该报错"); + assert_eq!(error.kind(), OnlineErrorKind::NotFound); + assert!(!error.is_offline(), "404 不是「没联网」"); +} diff --git a/crates/core/src/addon/online/tests/classify.rs b/crates/core/src/addon/online/tests/classify.rs new file mode 100644 index 0000000..657f60c --- /dev/null +++ b/crates/core/src/addon/online/tests/classify.rs @@ -0,0 +1,89 @@ +//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。 +//! +//! 这一族的价值全在「经过真的 `From`」:直接给 `OnlineError` 造一个 +//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射, +//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。 + +use crate::addon::online::{OnlineError, OnlineErrorKind}; +use crate::download::{DownloadError, DownloadErrorKind}; + +#[test] +fn an_all_sources_failed_with_a_404_leaf_is_not_found() { + // 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。 + let error = DownloadError::new( + DownloadErrorKind::AllSourcesFailed, + "所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...", + ) + .with_suppressed(vec!["official: HTTP 404".to_string()]) + .with_leaf(DownloadErrorKind::NotFound); + let online: OnlineError = error.into(); + assert_eq!( + online.kind(), + OnlineErrorKind::NotFound, + "项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」" + ); + assert!(!online.is_offline()); +} + +#[test] +fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() { + let online: OnlineError = + DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into(); + assert_eq!(online.kind(), OnlineErrorKind::NotConnected); + assert!(online.is_offline()); +} + +#[test] +fn leaf_kinds_map_one_to_one() { + let cases = [ + (DownloadErrorKind::NotFound, OnlineErrorKind::NotFound), + (DownloadErrorKind::ServerError, OnlineErrorKind::Server), + (DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected), + (DownloadErrorKind::Network, OnlineErrorKind::NotConnected), + (DownloadErrorKind::Integrity, OnlineErrorKind::Integrity), + (DownloadErrorKind::Io, OnlineErrorKind::Io), + ( + DownloadErrorKind::InvalidRequest, + OnlineErrorKind::Unsupported, + ), + ( + DownloadErrorKind::RangeUnsupported, + OnlineErrorKind::Malformed, + ), + ]; + for (leaf, expected) in cases { + let error = + DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf); + let online: OnlineError = error.into(); + assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str()); + } +} + +#[test] +fn a_single_source_failure_uses_its_own_kind() { + // 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。 + let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into(); + assert_eq!(online.kind(), OnlineErrorKind::NotFound); +} + +#[test] +fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() { + // 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。 + use crate::addon::import::FetchErrorKind; + let not_found: crate::addon::import::FetchError = + DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败") + .with_leaf(DownloadErrorKind::NotFound) + .into(); + assert_eq!(not_found.kind(), FetchErrorKind::NotFound); + assert!(not_found.kind().is_tolerable()); + + let offline: crate::addon::import::FetchError = + DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败") + .with_leaf(DownloadErrorKind::Network) + .into(); + assert_eq!(offline.kind(), FetchErrorKind::Offline); + assert!( + offline.kind().is_tolerable(), + "没联网仍然是「这条文件搞不到」,实例照装" + ); +} diff --git a/crates/core/src/addon/online/tests/download.rs b/crates/core/src/addon/online/tests/download.rs new file mode 100644 index 0000000..788afbd --- /dev/null +++ b/crates/core/src/addon/online/tests/download.rs @@ -0,0 +1,187 @@ +//! 下载复核: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 a_malformed_sha1_is_dropped_and_the_sha512_still_guards_the_file() { + // r1 顺手项:sha1 形状写坏不该让整条装不上——sha512 才是 mrpack 的权威摘要。 + let root = TempRoot::new("online-bad-sha1-good-sha512"); + let dest = root.join("sodium.jar"); + let fetcher = FakeArtifact::new(BYTES); + let (_, sha512) = spec(); + let done = block_on(download_verified( + &fetcher, + "https://cdn.modrinth.com/data/AANobbMI/sodium.jar", + Some("not-a-sha1-at-all"), + Some(&sha512), + Some(BYTES.len() as u64), + &dest, + )) + .expect("sha512 合法就该能校验并装下"); + assert_eq!(done.digest.sha512, sha512); + assert!(dest.exists()); +} + +#[test] +fn an_empty_url_is_unsupported() { + let root = TempRoot::new("online-no-url"); + 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..f2509a1 --- /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 classify; +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..4233068 --- /dev/null +++ b/crates/core/src/addon/online/transport.rs @@ -0,0 +1,222 @@ +//! 唯一的 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` 的错误 → 本模块的错误分类。 +/// +/// # 为什么读 `effective_kind()` 而不是 `kind` +/// +/// 引擎的候选源循环结束时只留下一条 [`DownloadErrorKind::AllSourcesFailed`],404 / 429 / 5xx / +/// 超时这些**叶子**成因会被折叠掉(`crates/core/src/download/bytes.rs` 的收尾)。 +/// 如果直接按 `kind` 判定,`NotFound` / `Server` / `Integrity` 三档在本模块的生产路径上 +/// **永远不可达**:项目被删、id 拼错、被限速都会画成「未联网」(r1 P1-1 后果 A)。 +/// +/// 引擎现在会把汇总出来的叶子 kind 挂在 `DownloadError::leaf` 上,这里按叶子映射; +/// 没有叶子(单源直失败 / 手工构造)就按自身 kind。**不从 `suppressed` 文本里猜状态码。** +/// +/// # 分类口径 +/// +/// | 引擎侧 | 本模块 | 上层该怎么说 | +/// |--------|--------|--------------| +/// | `NotFound`(404/410/403,全源都是) | [`OnlineErrorKind::NotFound`] | 「查无此项 / 换个关键词」 | +/// | `ServerError`(5xx / 429) | [`OnlineErrorKind::Server`] | 「站点那边出错了」 | +/// | `Network` / `Timeout` | [`OnlineErrorKind::NotConnected`] | 「未联网,检查网络」 | +/// | `AllSourcesFailed`(没有叶子) | [`OnlineErrorKind::NotConnected`] | 同上(保守) | +/// | `Integrity` | [`OnlineErrorKind::Integrity`] | 「内容不对」 | +impl From for OnlineError { + fn from(error: DownloadError) -> Self { + let kind = match error.effective_kind() { + DownloadErrorKind::NotFound => OnlineErrorKind::NotFound, + DownloadErrorKind::ServerError => OnlineErrorKind::Server, + DownloadErrorKind::Timeout | DownloadErrorKind::Network => { + OnlineErrorKind::NotConnected + } + DownloadErrorKind::Io => OnlineErrorKind::Io, + DownloadErrorKind::Integrity => OnlineErrorKind::Integrity, + DownloadErrorKind::RangeUnsupported => OnlineErrorKind::Malformed, + DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported, + // 没有叶子 ⇒ 分不清「没网」与「站点挂了」,按可重试的「没联网」保守处理: + // 上层至少会提示「检查网络后重试」,而不是宣称「没有匹配的内容」。 + DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected, + }; + Self::new(kind, error.message) + } +} + +/// 一个 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..8b03811 --- /dev/null +++ b/crates/core/src/addon/tests/import_online.rs @@ -0,0 +1,311 @@ +//! 远端条目补全(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(), "失败的导入不许留文件"); +} + +// ---------------------------------------------------------------- r1 P1-1 后果 B 的守卫 + +const CF_SHA512: &str = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1\ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1"; + +/// 两条远端条目:第一条**站点不认得**(等价于托管在 CurseForge CDN),第二条认得。 +/// +/// 真实 mrpack 大多是这个形状——所以「第一条补不出就掐掉整轮」是 r1 抓到的后果 B。 +fn pack_with_one_unknown_sha512(tag: &str) -> (TempRoot, std::path::PathBuf) { + let root = TempRoot::new(tag); + let dir = root.join("src"); + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"混托管", + "files":[ + {{"path":"mods/hosted-on-cf.jar","hashes":{{"sha512":"{CF_SHA512}"}}, + "downloads":["stub://mods/hosted-on-cf.jar"],"fileSize":10}}, + {{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}}, + "downloads":["{DOWNLOAD}"],"fileSize":{size}}} + ], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + size = PACK_BYTES.len() + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + let pack = root.join("pack.mrpack"); + zip_dir(&dir, &pack); + (root, pack) +} + +#[test] +fn a_404_on_one_entry_does_not_stop_the_completion_round() { + // 第一次查(CF 那一条)站点回 404 ⇒ **不许**停:第二条仍然要补出来并参与判定。 + let (root, pack) = pack_with_one_unknown_sha512("online-mixed-hosting"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy"); + + let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&sha1(PACK_BYTES))); + let scan = block_on(scan_pack_online( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert!(scan.is_ok(), "{:?}", scan.error); + let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect(); + assert_eq!( + paths, + vec![PATH], + "第二条(托管在 Modrinth 的那条)必须补出来并判出冲突:{:?}", + paths + ); + assert_eq!(scan.unknown_remote, 1, "补不出的那一条仍如实计数"); + assert!( + !scan + .notes + .iter() + .any(|note| note.message.contains("未联网")), + "404 不是「没联网」,注记不许这么说:{:?}", + scan.notes.iter().map(|n| &n.message).collect::>() + ); +} + +#[test] +fn only_a_real_offline_stops_the_completion_round() { + let (root, pack) = pack_with_one_unknown_sha512("online-mixed-offline"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy"); + + let fetcher = StubFetcher::new() + .with_modrinth(SHA512, meta(&sha1(PACK_BYTES))) + .offline_modrinth(); + let scan = block_on(scan_pack_online( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert!(scan.is_ok(), "{:?}", scan.error); + assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了"); + assert!(scan.conflicts.is_empty()); + // 断网且一条也没补到:注记不许说「补全了 0 条」(读起来像成功),也不许带「缺省保留」 + // ——那句是导入场景的尾缀,预检里没有这回事(r3 登记项)。 + let note = scan + .notes + .iter() + .find(|note| note.message.contains("sha512")) + .expect("断网要留一条补全注记"); + assert!(note.message.contains("一条也没补到"), "{}", note.message); + assert!(!note.message.contains("补全了 0 条"), "{}", note.message); + assert!(!note.message.contains("缺省保留"), "{}", note.message); +} + +/// 清单自带 `sha1` 时补全不许插手(也不许改写清单里的摘要)。 +fn pack_with_sha1_and_sha512(tag: &str) -> (TempRoot, std::path::PathBuf) { + let root = TempRoot::new(tag); + let dir = root.join("src"); + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"两档摘要都有", + "files":[{{"path":"{PATH}", + "hashes":{{"sha1":"{sha1}","sha512":"{SHA512}"}}, + "downloads":["{DOWNLOAD}"],"fileSize":{size}}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + sha1 = sha1(PACK_BYTES), + size = PACK_BYTES.len() + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + let pack = root.join("pack.mrpack"); + zip_dir(&dir, &pack); + (root, pack) +} + +#[test] +fn an_entry_that_already_has_a_sha1_is_left_alone() { + let (root, pack) = pack_with_sha1_and_sha512("online-keeps-sha1"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + // 站点那边挂一个**不一样**的 sha1:真去反查并写回的话,这条就会被改写。 + let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&"e".repeat(40))); + let scan = block_on(scan_pack_online( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert!(scan.is_ok(), "{:?}", scan.error); + let conflict = scan.conflicts.first().expect("磁盘内容不同 ⇒ 要判出冲突"); + assert_eq!( + conflict.pack_sha1, + sha1(PACK_BYTES), + "清单里的 sha1 不许被改写" + ); + assert!( + !scan + .notes + .iter() + .any(|note| note.message.contains("补全了")), + "没有条目需要补 ⇒ 不该写「补全了 N 条」的注记" + ); +} + +/// 清单没写 `downloads`:补全给的直链要能真的用上(否则这条只会进 missing)。 +fn pack_without_downloads(tag: &str) -> (TempRoot, std::path::PathBuf) { + let root = TempRoot::new(tag); + let dir = root.join("src"); + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"没有直链", + "files":[{{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}}, + "downloads":[],"fileSize":{size}}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + size = PACK_BYTES.len() + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + let pack = root.join("pack.mrpack"); + zip_dir(&dir, &pack); + (root, pack) +} + +#[test] +fn a_completed_entry_can_supply_the_missing_download_url() { + let (root, pack) = pack_without_downloads("online-url-from-source"); + 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) + ); +} diff --git a/crates/core/src/addon/tests/import_report.rs b/crates/core/src/addon/tests/import_report.rs index 97f4297..bea2a23 100644 --- a/crates/core/src/addon/tests/import_report.rs +++ b/crates/core/src/addon/tests/import_report.rs @@ -137,6 +137,9 @@ const SOURCES: &[&str] = &[ // M160:预检(冲突清单)与逐文件决策的引用也要算进这张表, // 否则 `NOTE_REMOTE_PATH_UNKNOWN` 会被判成"死常量"。 include_str!("../import/scan.rs"), + // M162:补全注记的文案搬到了 `complete.rs`(原来在 pipeline/scan 各写一份), + // 不把它算进这张表的话 `NOTE_REMOTE_COMPLETED` 会被判成死常量。 + include_str!("../import/complete.rs"), include_str!("../import/decision.rs"), include_str!("../import/stage.rs"), include_str!("../mod.rs"), 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..7b6cc61 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,10 @@ pub(crate) fn sha1(bytes: &[u8]) -> String { pub(crate) struct StubFetcher { files: BTreeMap>, curse: BTreeMap<(i64, i64), CurseFileMeta>, + /// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。 + modrinth: BTreeMap, + /// M162:补全时假装「没联网」(与「站点不认得这个摘要」区分开)。 + modrinth_offline: bool, /// 被请求过的 URL(断言"没白下载"用)。 calls: Mutex>, /// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。 @@ -63,6 +69,8 @@ impl StubFetcher { Self { files: BTreeMap::new(), curse: BTreeMap::new(), + modrinth: BTreeMap::new(), + modrinth_offline: false, calls: Mutex::new(Vec::new()), artifacts: false, } @@ -80,6 +88,18 @@ 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 + } + + /// 让补全路径假装「没联网」(`FetchErrorKind::Offline`)。 + pub(crate) fn offline_modrinth(mut self) -> Self { + self.modrinth_offline = true; + self + } + /// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。 pub(crate) fn with_engine_artifacts(mut self) -> Self { self.artifacts = true; @@ -111,6 +131,26 @@ impl PackFetcher for StubFetcher { }) } + fn modrinth_file_by_sha512<'a>( + &'a self, + sha512: &'a str, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { + if self.modrinth_offline { + return Err(super::super::import::FetchError::new( + super::super::import::FetchErrorKind::Offline, + "连不上 api.modrinth.com", + )); + } + self.modrinth.get(sha512).cloned().ok_or_else(|| { + super::super::import::FetchError::new( + super::super::import::FetchErrorKind::NotFound, + format!("stub 里没有 sha512={sha512}"), + ) + }) + }) + } + fn fetch_file<'a>( &'a self, request: FetchRequest<'a>, diff --git a/crates/core/src/download/bytes.rs b/crates/core/src/download/bytes.rs index f191f51..c0a1fc0 100644 --- a/crates/core/src/download/bytes.rs +++ b/crates/core/src/download/bytes.rs @@ -7,7 +7,7 @@ use std::time::Instant; use super::cache::now_ms; use super::engine::{millis, DownloadEngine}; -use super::error::{DownloadError, DownloadErrorKind}; +use super::error::{leaf_of, DownloadError, DownloadErrorKind}; use super::http; use super::progress::ProgressEvent; use super::types::{Candidate, DownloadId, DownloadRequest}; @@ -33,6 +33,8 @@ impl DownloadEngine { let _permit = self.acquire().await?; let mut suppressed: Vec = Vec::new(); + // 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。 + let mut kinds: Vec = Vec::new(); for candidate in &plan { // 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。 @@ -74,6 +76,7 @@ impl DownloadEngine { retry_in_ms: if retry { millis(delay) } else { 0 }, }); if !retry { + kinds.push(err.kind); suppressed.push(format!("{}: {}", candidate.source, err.message)); break; } @@ -84,11 +87,14 @@ impl DownloadEngine { } self.flush_health(); + // 叶子成因要跟着出去:上层(在线内容源的 `Transport`)靠它区分「这个 URL 不存在」 + // 与「没联网」,只留 `AllSourcesFailed` 的话那套分类在生产路径上永远不可达。 Err(DownloadError::new( DownloadErrorKind::AllSourcesFailed, format!("所有候选源都取不到文本资源:{}", req.url), ) - .with_suppressed(suppressed)) + .with_suppressed(suppressed) + .with_leaf(leaf_of(kinds))) } async fn fetch_bytes( diff --git a/crates/core/src/download/error.rs b/crates/core/src/download/error.rs index ed5f6cc..3152de9 100644 --- a/crates/core/src/download/error.rs +++ b/crates/core/src/download/error.rs @@ -70,6 +70,18 @@ pub struct DownloadError { pub attempts: usize, /// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。 pub suppressed: Vec, + /// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底 + /// 是怎么挂的。 + /// + /// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、 + /// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」—— + /// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里 + /// 反解状态码是靠文案猜,改一句提示就失效。 + /// + /// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`; + /// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。 + /// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。 + pub leaf: Option, } impl DownloadError { @@ -82,6 +94,7 @@ impl DownloadError { message: message.into(), attempts: 0, suppressed: Vec::new(), + leaf: None, } } @@ -105,6 +118,62 @@ impl DownloadError { self.suppressed = suppressed; self } + + /// 标注整体失败之前的叶子成因。 + #[must_use] + pub const fn with_leaf(mut self, leaf: DownloadErrorKind) -> Self { + self.leaf = Some(leaf); + self + } + + /// 最可诊断的成因:有叶子就用叶子,否则用自身的 kind。 + /// + /// 上层(`addon::online::transport` / `addon::import::fetch`)靠这一处判定,不各自 + /// 再写一遍「AllSourcesFailed 到底算哪一类」。 + #[must_use] + pub fn effective_kind(&self) -> DownloadErrorKind { + if self.kind == DownloadErrorKind::AllSourcesFailed { + return self.leaf.unwrap_or(self.kind); + } + self.kind + } +} + +/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。 +/// +/// 规则(与 `online::transport` 的映射表配套,判据只此一处): +/// +/// | 各源的情况 | 叶子 | +/// |------------|------| +/// | 全部 404/410/403(`NotFound`) | `NotFound` | +/// | 出现 `Network` / `Timeout` | `Network` | +/// | 全部 5xx(`ServerError`) | `ServerError` | +/// | 其它(`Integrity` / `Io` / `InvalidRequest`) | 该条自己的 kind(后出现的赢) | +/// | 混合了 404 与 5xx | `ServerError`(比「不存在」更可诊断) | +/// +/// `kinds` 为空时返回 `AllSourcesFailed`(调用方据此保留原 kind)。 +#[must_use] +pub fn leaf_of(kinds: impl IntoIterator) -> DownloadErrorKind { + let mut leaf = DownloadErrorKind::AllSourcesFailed; + let mut rank = 0u8; + for kind in kinds { + let (candidate, weight) = match kind { + DownloadErrorKind::Network | DownloadErrorKind::Timeout => { + (DownloadErrorKind::Network, 4) + } + DownloadErrorKind::ServerError => (DownloadErrorKind::ServerError, 3), + DownloadErrorKind::Integrity => (DownloadErrorKind::Integrity, 3), + DownloadErrorKind::Io => (DownloadErrorKind::Io, 3), + DownloadErrorKind::InvalidRequest => (DownloadErrorKind::InvalidRequest, 3), + DownloadErrorKind::NotFound => (DownloadErrorKind::NotFound, 1), + DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => (kind, 2), + }; + if weight >= rank { + rank = weight; + leaf = candidate; + } + } + leaf } impl fmt::Display for DownloadError { @@ -123,3 +192,59 @@ impl fmt::Display for DownloadError { } impl std::error::Error for DownloadError {} + +#[cfg(test)] +mod tests { + use super::{leaf_of, DownloadError, DownloadErrorKind}; + + /// 四档叶子成因(tower 定案的门禁):全 404 / 有网络错 / 全 5xx / 混合。 + #[test] + fn leaf_keeps_the_most_diagnosable_cause() { + use DownloadErrorKind::{Network, NotFound, ServerError, Timeout}; + assert_eq!(leaf_of([NotFound, NotFound]), NotFound, "全 404 ⇒ NotFound"); + assert_eq!( + leaf_of([Network, NotFound]), + Network, + "有网络错 ⇒ Network(哪怕另一源是 404)" + ); + assert_eq!( + leaf_of([ServerError, ServerError]), + ServerError, + "全 5xx ⇒ ServerError" + ); + assert_eq!( + leaf_of([NotFound, ServerError]), + ServerError, + "混合 404 与 5xx ⇒ 取更可诊断的那个" + ); + assert_eq!(leaf_of([Timeout, NotFound]), Network, "超时归到 Network"); + assert_eq!( + leaf_of([]), + DownloadErrorKind::AllSourcesFailed, + "一条都没有 ⇒ 保留原 kind" + ); + } + + /// 整体失败时上层靠 `effective_kind()` 判定;单源失败时就是它自己的 kind。 + #[test] + fn effective_kind_prefers_the_leaf_only_for_all_sources_failed() { + let leaf = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败") + .with_leaf(DownloadErrorKind::NotFound); + assert_eq!(leaf.effective_kind(), DownloadErrorKind::NotFound); + assert_eq!( + leaf.kind, + DownloadErrorKind::AllSourcesFailed, + "kind 本身不改" + ); + + let bare = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败"); + assert_eq!( + bare.effective_kind(), + DownloadErrorKind::AllSourcesFailed, + "没有叶子 ⇒ 原样返回(上层再决定当哪一类)" + ); + + let single = DownloadError::new(DownloadErrorKind::NotFound, "404"); + assert_eq!(single.effective_kind(), DownloadErrorKind::NotFound); + } +} diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index edff16f..196e20a 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. 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.rs b/crates/i18n/src/catalog/golden.rs index 166d720..ddc258a 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -61,10 +61,12 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), ]; -/// 金表的**全量**行(三段:本文件 + [`tail`] + [`tail2`],顺序即 `Key::ALL`)。 +/// 金表的**全量**行(本文件 → [`tail`] → [`tail2`],顺序即 `Key::ALL`)。 /// -/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列, +/// 拆成三段只为守 400 行硬线(M157/M159 往金表追加文案后越线);对调用方是**一条**序列, /// `catalog/tests.rs` 的逐字节断言不用关心分段。 +/// +/// ⚠️ 加段时**必须**在这里也 chain 上:条数与内容都只能从这一条序列导出(见 [`len`])。 pub(super) fn rows() -> impl Iterator { GOLDEN .iter() @@ -73,8 +75,13 @@ pub(super) fn rows() -> impl Iterator usize { - GOLDEN.len() + tail::ROWS.len() + tail2::ROWS.len() + rows().count() } mod tail; diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 790dc51..74a851d 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. 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..3ef817c 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,22 @@ 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, + /// 在飞的那次详情请求的项目 id(`None` = 没有在飞) + pub detail_requested: Option, + /// 最近一次详情请求的失败 + pub detail_failed: Option, } impl Default for ContentState { @@ -351,11 +324,59 @@ impl Default for ContentState { pending: false, failed: None, last_page_filled: false, + detail: None, + detail_pending: false, + detail_requested: None, + 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_requested = Some(project.to_string()); + self.detail_failed = None; + } + + /// 详情落地:**只接受当前这次请求的结果**(迟到的、别的项目的一律丢掉)。 + /// + /// 场景:选中 A(请求发出)→ 立刻切到 B(又发一条)→ A 的结果后到。没有这道闸门的话, + /// A 的版本列表会画在 B 的详情面板下,A 的失败也会被当成本次失败(r1 P2-3)。 + pub fn apply_detail(&mut self, project: &str, detail: ContentDetail) { + if !self.accepts_detail(project) || detail.project != project { + return; + } + self.detail_pending = false; + self.detail_requested = None; + self.detail_failed = None; + self.detail = Some(Box::new(detail)); + } + + /// 详情失败:同样只看当前这次请求。 + pub fn fail_detail(&mut self, project: &str, failure: ContentFailure) { + if !self.accepts_detail(project) { + return; + } + self.detail_pending = false; + self.detail_requested = None; + self.detail_failed = Some(failure); + } + + /// 这条详情结果还算当前这次吗(`false` = 迟到 / 用户已经换了选中项)。 + #[must_use] + pub fn accepts_detail(&self, project: &str) -> bool { + self.detail_requested.as_deref() == Some(project) + } + /// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。 #[must_use] pub fn accepts(&self, results: &ContentResults) -> bool { diff --git a/crates/presenter/src/dto/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..d611b2e 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -137,8 +137,15 @@ pub enum CoreEvent { NoticesChanged(Vec), /// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标) ContentResults(Box), - /// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥) - ContentFailed(String), + /// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画 + ContentFailed(Box), + /// 内容库项目详情 + 版本列表(M162):`project` 是**请求时**选中的 id,迟到结果靠它判。 + ContentDetail { + /// 请求时选中的项目 id + project: String, + /// `Ok` = 详情;`Err` = 失败大类 + 原因 + result: Box>, + }, // M113: import/export /// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`]) Pack(Box), @@ -251,6 +258,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/install.rs b/crates/presenter/src/dto/install.rs index a960a22..527536a 100644 --- a/crates/presenter/src/dto/install.rs +++ b/crates/presenter/src/dto/install.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; +use crate::dto::ContentFailureKind; + /// 安装阶段。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InstallStage { @@ -70,6 +72,11 @@ pub struct InstallProgressView { pub eta_secs: u64, /// 状态/错误文案 pub message: String, + /// 失败大类(M162 r1):`None` = 没失败,或失败来自不产生分类的路径。 + /// + /// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与 + /// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。 + pub failure_kind: Option, } impl Default for InstallProgressView { @@ -93,6 +100,7 @@ impl InstallProgressView { speed_bps: 0, eta_secs: 0, message: String::new(), + failure_kind: None, } } diff --git a/crates/presenter/src/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..be2e7c4 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,23 @@ 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 project = request.project.clone(); + let result: Result = sources::detail(&env, &request); + let _ = tx.send(HostTask::ContentDetail { + project, + result: 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..e71388d 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -1,5 +1,11 @@ //! 「一键装入指定实例」(M109)。 //! +//! ## 网络只走 code 的在线内容源(M162 r1) +//! +//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、 +//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。 +//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 +//! //! ## 它到底做了什么(诚实版) //! //! | 内容类型 | 做什么 | 状态 | @@ -19,20 +25,21 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; +use unlucky_core::addon::online::{ + download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery, +}; 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; use crate::dto::{ - loader_facet, ContentInstallRequest, ContentSource, InstallProgressView, InstallStage, - InstanceSummary, + loader_facet, ContentFailure, ContentFailureKind, ContentInstallRequest, ContentSource, + InstallProgressView, InstallStage, InstanceSummary, }; use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::http::{encode, get}; -use super::sources::MODRINTH; +use super::sources::failure_of; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -56,7 +63,7 @@ pub(super) fn install( fail( tx, progress, - "整合包安装尚未开放;内容库对整合包只做检索", + ContentFailure::unsupported("整合包安装尚未开放;内容库对整合包只做检索"), epoch, ); return; @@ -77,32 +84,35 @@ pub(super) fn install( epoch, }); } - Err(reason) => { + Err(failure) => { progress.stage = if cancel.load(Ordering::SeqCst) { InstallStage::Cancelled } else { InstallStage::Failed }; + progress.message = failure.detail.clone(); + progress.failure_kind = Some(failure.kind); let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), - result: Err(reason), + result: Err(failure), epoch, }); } } } -fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) { +fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailure, epoch: u64) { progress.stage = InstallStage::Failed; - progress.message = reason.to_string(); + progress.message = failure.detail.clone(); + progress.failure_kind = Some(failure.kind); let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), - result: Err(reason.to_string()), + result: Err(failure), epoch, }); } -/// 真正干活的那一段,返回一句人读的落盘描述。 +/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。 fn run( env: &HostEnv, request: &ContentInstallRequest, @@ -110,20 +120,20 @@ fn run( progress: &mut InstallProgressView, tx: &JobSender, epoch: u64, -) -> Result { +) -> Result { let instance = find_instance(env, &request.instance)?; let game_dir = game_dir_of(env, &instance)?; let dir = request .kind .dir() - .ok_or_else(|| "该内容类型不支持单文件装入".to_string())?; + .ok_or_else(|| local("该内容类型不支持单文件装入"))?; let target_dir = game_dir.join(dir); std::fs::create_dir_all(&target_dir) - .map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?; + .map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?; let file = resolve_file(env, request)?; if cancel.load(Ordering::SeqCst) { - return Err("已取消(下载开始前)".to_string()); + return Err(local("已取消(下载开始前)")); } progress.stage = InstallStage::Downloading; progress.message = file.name.clone(); @@ -133,27 +143,39 @@ 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(failure_of)?; Ok(format!("{}({})", file.name, dest.display())) } -fn find_instance(env: &HostEnv, id: &str) -> Result { +/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 +fn local(reason: impl Into) -> ContentFailure { + ContentFailure::new(ContentFailureKind::Other, reason) +} + +fn find_instance(env: &HostEnv, id: &str) -> Result { super::super::game::instances(env) .into_iter() .find(|instance| instance.id == id) - .ok_or_else(|| format!("找不到实例 {id}")) + .ok_or_else(|| local(format!("找不到实例 {id}"))) } /// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。 -fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result { +fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result { let global = super::super::game::global_mode(env); let config = read_instance_config(&env.layout, &instance.id) - .map_err(|error| format!("读实例设置失败:{error}"))?; + .map_err(|error| local(format!("读实例设置失败:{error}")))?; derive_game_dir( &env.game, &instance.version_id, @@ -162,76 +184,69 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result, + sha512: Option, + size: Option, } /// 解析「这个项目下最适合目标实例的那个文件」。 -fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result { +fn resolve_file( + env: &HostEnv, + request: &ContentInstallRequest, +) -> Result { match request.source { ContentSource::Modrinth => modrinth_file(env, request), - ContentSource::Curseforge => Err( - "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(), - ), + ContentSource::Curseforge => Err(ContentFailure::unsupported( + "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", + )), } } -fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result { - let mut url = format!( - "{MODRINTH}/project/{}/version?game_versions={}", - request.project, - encode(&format!("[\"{}\"]", request.game_version)) - ); - if let Some(facet) = loader_facet(request.loader) { - url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]")))); +/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。 +/// +/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑, +/// `Version::primary_file()` 就是为这件事存在的。 +fn modrinth_file( + env: &HostEnv, + request: &ContentInstallRequest, +) -> Result { + let core_engine = super::super::engine::engine(env); + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + let versions = block_on(client.versions(&VersionQuery { + project: request.project.clone(), + game_version: Some(request.game_version.clone()), + loader: loader_facet(request.loader).map(str::to_string), + })) + .map_err(failure_of)?; + let version = versions.first().ok_or_else(|| { + ContentFailure::new( + ContentFailureKind::NotFound, + "这个项目没有匹配目标实例(版本 / 加载器)的文件", + ) + })?; + let file = version + .primary_file() + .ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?; + if file.url.trim().is_empty() { + return Err(ContentFailure::new( + ContentFailureKind::Unsupported, + "版本里的主文件没有下载地址", + )); } - let body = get(env, &url, &[])?; - let json: serde_json::Value = serde_json::from_str(&body) - .map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?; - let versions = json - .as_array() - .ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?; - let first = versions - .first() - .ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?; - let files = first - .get("files") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "版本里没有 files".to_string())?; - // 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑 - let file = files - .iter() - .find(|file| { - file.get("primary") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - }) - .or_else(|| files.first()) - .ok_or_else(|| "版本里没有可用文件".to_string())?; - - let url = file - .get("url") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "文件没有 url".to_string())?; - let name = file - .get("filename") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "文件没有 filename".to_string())?; - let sha1 = file - .get("hashes") - .and_then(|hashes| hashes.get("sha1")) - .and_then(serde_json::Value::as_str) - .map(ToString::to_string); Ok(RemoteFile { - url: url.to_string(), - name: safe_file_name(name), - sha1, + url: file.url.clone(), + name: safe_file_name(&file.filename), + sha1: file.sha1().map(ToString::to_string), + sha512: file.sha512().map(ToString::to_string), + size: (file.size > 0).then_some(file.size), }) } diff --git a/crates/presenter/src/host/addon/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..1dcbc1c 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,102 +22,174 @@ //! | 加载器 | `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"; +// Modrinth 的根不再在本文件:URL 拼装与解析都在 `core::addon::online`,这里只留 CF 的 +// (它要 `X-API-KEY`,是 core 那一侧**故意**没接的那半)。 /// CurseForge v1 的根(需要 `X-API-KEY`)。 pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1"; /// CurseForge 的「Minecraft」gameId(官方固定值)。 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/cancel_tests.rs b/crates/presenter/src/host/cancel_tests.rs index c044e1b..ce22bd7 100644 --- a/crates/presenter/src/host/cancel_tests.rs +++ b/crates/presenter/src/host/cancel_tests.rs @@ -316,7 +316,10 @@ fn progress_from_a_cancelled_job_is_dropped() { }); host.handle(HostTask::InstallDone { progress: Box::new(progress_snapshot(512)), - result: Err("已取消".to_string()), + result: Err(crate::dto::ContentFailure::new( + crate::dto::ContentFailureKind::Other, + "已取消", + )), epoch, }); let after: Vec = host.drain_events(); diff --git a/crates/presenter/src/host/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..102f2b4 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 { project, result } => { + self.send(CoreEvent::ContentDetail { project, result }); + } // M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉 HostTask::Pack { event, epoch } => { // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 @@ -82,7 +86,9 @@ impl CoreHost { self.refresh_instances(); self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); } - Err(reason) => self.toast(ToastKind::Error, reason), + // 只把**一句人读原因**弹出去:大类已经随 `progress.failure_kind` 进了 DTO, + // 面板/调用方按大类决定怎么画(离线 vs 查无此项)。 + Err(failure) => self.toast(ToastKind::Error, failure.detail), } } HostTask::Accounts(accounts) => { diff --git a/crates/presenter/src/host/install.rs b/crates/presenter/src/host/install.rs index a8f404d..20a6ee9 100644 --- a/crates/presenter/src/host/install.rs +++ b/crates/presenter/src/host/install.rs @@ -160,9 +160,15 @@ pub fn spawn_install( format!("{reason}(日志:{})", journal.path().display()) }; progress.message.clone_from(&text); + // 版本安装这条路的失败不是内容库的分类(它是本地/运行期错误),归到 Other: + // 大类字段仍然要填,面板才不会把「装坏了」误画成「没联网」。 + progress.failure_kind = Some(crate::dto::ContentFailureKind::Other); let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), - result: Err(text), + result: Err(crate::dto::ContentFailure::new( + crate::dto::ContentFailureKind::Other, + text, + )), epoch, }); } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 2f4173b..7def756 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,15 @@ pub enum HostTask { /// `Ok(最新在前)` / `Err(人读原因)` result: Result, String>, }, - /// 内容库检索(M109;`Err` = 一句人读原因) - Content(Box>), + /// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因) + Content(Box>), + /// 内容库项目详情 + 版本列表(M162):项目 id 随载荷一起回来(迟到结果靠它判) + ContentDetail { + /// 请求时选中的项目 id + project: String, + /// `Ok` = 详情;`Err` = 失败大类 + 原因 + result: Box>, + }, // M113: import/export /// 导入/导出(M113):一条事件 + 任务代次 /// @@ -70,12 +79,12 @@ pub enum HostTask { /// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。 epoch: u64, }, - /// 安装收尾(成功带实例名,失败带文案) + /// 安装收尾(成功带实例名,失败带**大类 + 文案**) InstallDone { /// 最终进度快照 progress: Box, - /// 成功时为 `Ok(实例名)` - result: Result, + /// 成功时为 `Ok(实例名)`;失败时带分类(离线 ≠ 查无此项) + result: Result, /// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`) epoch: u64, }, diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index cd39fd4..d0187ed 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -296,9 +296,10 @@ impl AppState { // M113: import/export CoreEvent::Pack(event) => self.pack.apply(*event), CoreEvent::ContentResults(results) => self.apply_content(*results), - CoreEvent::ContentFailed(reason) => { - self.content.pending = false; - self.content.failed = Some(reason); + CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure), + // M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递) + CoreEvent::ContentDetail { project, result } => { + self.apply_content_detail(&project, *result); } } } diff --git a/crates/presenter/src/state/content.rs b/crates/presenter/src/state/content.rs index d4ca6c7..8fb6522 100644 --- a/crates/presenter/src/state/content.rs +++ b/crates/presenter/src/state/content.rs @@ -3,7 +3,7 @@ //! 从 `state.rs` 拆出(那个文件重新合流 M105 之后贴着 400 行上限)。放在 `impl AppState` 的 //! **另一个 impl 块**里,路径与可见性都不变:`apply_core` 直接调 `self.apply_content(..)`。 -use crate::dto::{ContentQuery, ContentResults}; +use crate::dto::{ContentDetail, ContentFailure, ContentQuery, ContentResults}; use super::AppState; @@ -31,6 +31,25 @@ impl AppState { } } + /// 检索失败落地(M162 起带大类):清掉「在飞」并记下失败。 + pub(super) fn apply_content_failure(&mut self, failure: ContentFailure) { + self.content.pending = false; + self.content.failed = Some(failure); + } + + /// 详情事件落地(M162):`Ok` 走 `apply_detail`、`Err` 走 `fail_detail`,两者都按 + /// **请求时的项目 id** 判迟到(用户可能已经切到别的项目了)。 + pub(super) fn apply_content_detail( + &mut self, + project: &str, + result: Result, + ) { + match result { + Ok(detail) => self.content.apply_detail(project, detail), + Err(failure) => self.content.fail_detail(project, failure), + } + } + /// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。 pub fn set_content_query(&mut self, query: ContentQuery) { self.content.pending = true; diff --git a/crates/presenter/src/state/content_tests.rs b/crates/presenter/src/state/content_tests.rs index e2c612b..9755758 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,116 @@ 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 { + project: "p1".to_string(), + result: Box::new(Ok(detail("p1"))), + }); + assert!(!state.content.detail_pending); + assert_eq!( + state.content.detail.as_ref().map(|d| d.project.as_str()), + Some("p1") + ); + + state.content.request_detail("p2"); + assert!( + state.content.detail.is_none(), + "换了项目就不能留着上一个的版本列表" + ); + state.apply_core(CoreEvent::ContentDetail { + project: "p2".to_string(), + result: 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), + }], + } +} + +/// r1 P2-3:迟到的详情结果(A)不许写进 B 的请求/面板。 +#[test] +fn a_late_detail_result_for_the_previous_project_is_dropped() { + let mut state = AppState::new(); + state.content.request_detail("p1"); + // 用户立刻切到 p2:新的请求在飞 + state.content.request_detail("p2"); + // p1 的失败这时才回来 + state.apply_core(CoreEvent::ContentDetail { + project: "p1".to_string(), + result: Box::new(Err(ContentFailure::offline("connect timed out"))), + }); + assert!( + state.content.detail_failed.is_none(), + "A 的失败不许画在 B 的面板上" + ); + assert!(state.content.detail_pending, "B 仍应在飞"); + assert_eq!(state.content.detail_requested.as_deref(), Some("p2")); + + // p1 的成功结果同样被丢掉(哪怕载荷里写着 p1) + state.apply_core(CoreEvent::ContentDetail { + project: "p1".to_string(), + result: Box::new(Ok(detail("p1"))), + }); + assert!(state.content.detail.is_none()); + assert!(state.content.detail_pending); + + // 真正属于 B 的结果才落地 + state.apply_core(CoreEvent::ContentDetail { + project: "p2".to_string(), + result: Box::new(Ok(detail("p2"))), + }); + assert!(!state.content.detail_pending); + assert_eq!( + state.content.detail.as_ref().map(|d| d.project.as_str()), + Some("p2") + ); +}