Merge branch 'feat/m162-onlinecontent-modrinth-online-conte'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-07 17:54:42 +08:00
commit 5d5fe8aa73
62 files changed
+4728 -435

No files matched your search

+28 -2
View File
@@ -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<ContentDetailRequest> {
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`),
+51 -5
View File
@@ -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 {
+69 -4
View File
@@ -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<VersionRow>,
/// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说)
pub versions_note: Option<String>,
}
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `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<Download> {
.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<VersionRow>, Option<String>) {
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((版本, 加载器))` = 选中了。
@@ -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<String>,
/// 已经求好文案的一行(`版本号 · 类型 · 日期`)
pub line: String,
}
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
@@ -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]
@@ -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(),
}
}
+4 -120
View File
@@ -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>>) -> 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<Element<'a, PageMessage>> {
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;
@@ -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(
@@ -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>>) -> 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<Element<'a, PageMessage>> {
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,
}
}
@@ -295,6 +295,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
speed_bps: if failed { 0 } else { 1_258_291 },
eta_secs: if failed { 0 } else { 75 },
message: String::new(),
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
failure_kind: None,
},
catalog,
)
+3 -2
View File
@@ -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;
+323
View File
@@ -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<ImportIssue> {
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<ModrinthFileMeta, FetchError>,
asked: AtomicU32,
}
impl PackFetcher for Lookup {
fn modrinth_file_by_sha512<'a>(
&'a self,
_sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, FetchError>> {
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<FetchedFile, FetchError>> {
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());
}
}
+4 -3
View File
@@ -176,9 +176,10 @@ impl From<FetchError> for ImportError {
let kind = match error.kind() {
FetchErrorKind::Integrity => ImportErrorKind::Integrity,
FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io),
FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => {
ImportErrorKind::Fetch
}
FetchErrorKind::NotFound
| FetchErrorKind::Network
| FetchErrorKind::Offline
| FetchErrorKind::Unsupported => ImportErrorKind::Fetch,
};
Self {
kind,
+116 -26
View File
@@ -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<DownloadError> for FetchError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
// 用**叶子成因**(`effective_kind`):候选源循环结束时只有一条 `AllSourcesFailed`,
// 直接按它判定会把「这个文件不存在」也算成「网络失败」。
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
DownloadErrorKind::Io => FetchErrorKind::Io,
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
DownloadErrorKind::Timeout | DownloadErrorKind::Network => FetchErrorKind::Offline,
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
};
@@ -130,6 +149,21 @@ impl From<DownloadError> for FetchError {
}
}
impl From<OnlineError> 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<Box<dyn Future>>` 即可)。
pub type FetchFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
@@ -148,6 +182,19 @@ pub struct CurseFileMeta {
pub class_id: Option<i64>,
}
/// Modrinth 一个文件的元数据(`files[]` 补全的产物,按 sha512 反查得到)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModrinthFileMeta {
/// 站点给的 SHA-1(**同一个文件**的官方摘要,不是本地推算)
pub sha1: Option<String>,
/// 站点给的字节数
pub size: Option<u64>,
/// 站点给的直链(清单里没有 downloads 时才用得上)
pub url: Option<String>,
/// 站点给的文件名
pub file_name: Option<String>,
}
/// 一次文件下载请求。
#[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<u64>,
}
@@ -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<ModrinthFileMeta, FetchError>> {
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<ModrinthFileMeta, FetchError>> {
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<FetchedFile, FetchError>> {
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 })
})
}
}
+7 -4
View File
@@ -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<String>,
sha1: Option<String>, // 两档摘要各自可能缺(CF 只有 sha1、mrpack 可能只有 sha512)
sha512: Option<String>,
size: Option<u64>,
}
/// 把条目补全成"有路径、有地址"。
///
/// `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,
+2 -1
View File
@@ -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);
@@ -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,
+33 -3
View File
@@ -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` = 要用户点头的冲突。
+17 -5
View File
@@ -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,
+119
View File
@@ -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<Identity> {
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<u64>,
/// 站点给的直链(`None` = 也没给)
pub url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::identity_of;
use crate::addon::online::model::{Version, VersionFile};
fn version(files: Vec<VersionFile>) -> 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());
}
}
+300
View File
@@ -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` 守卫(`<name>.ullpart-<pid>-<seq>` + 原子改名)兜住,
//! 本模块不另造临时目录范式。
//!
//! # 为什么摘要复核不信任 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<u64>,
}
/// 「把 URL 下到 dest」的注入点(测试喂假字节)。
pub trait ArtifactFetcher: Send + Sync {
/// 下到 `dest`,返回落盘字节数。
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>>;
}
/// 生产实现:交给 `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<u64, OnlineError>> {
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<u64>,
dest: &Path,
) -> Result<Downloaded, OnlineError> {
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<OnlineError> = 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<Digest, OnlineError> {
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
}
+68
View File
@@ -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<DownloadError>` 的映射表),
//! 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;
+37
View File
@@ -0,0 +1,37 @@
//! Modrinth v2 的响应形状与检索请求(**按站点原文建模,不翻译成「通用内容模型」**)。
//!
//! 每个公开类型都对着一个真实端点:
//!
//! | 类型 | 端点 |
//! |------|------|
//! | [`SearchQuery`] → [`SearchPage`] | `GET /v2/search` |
//! | [`ProjectDetail`] | `GET /v2/project/{id or slug}` |
//! | [`VersionQuery`] → [`Vec<Version>`] | `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,
};
+257
View File
@@ -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<RawHit>,
#[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<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
project_type: String,
#[serde(default)]
icon_url: Option<String>,
}
#[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<String>,
#[serde(default)]
loaders: Vec<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
icon_url: Option<String>,
#[serde(default)]
source_url: Option<String>,
#[serde(default)]
issues_url: Option<String>,
}
#[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<String>,
#[serde(default)]
game_versions: Vec<String>,
#[serde(default)]
date_published: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
files: Vec<RawFile>,
}
#[derive(Debug, Default, Deserialize)]
struct RawFile {
#[serde(default)]
hashes: BTreeMap<String, String>,
#[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<SearchPage, OnlineError> {
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<ProjectDetail, OnlineError> {
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<Vec<Version>, OnlineError> {
let raw: Vec<RawVersion> =
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<Version, OnlineError> {
let raw: RawVersion = parse_json(body, "version_file")?;
Ok(version_of(raw))
}
fn parse_json<T: for<'de> Deserialize<'de>>(body: &str, what: &str) -> Result<T, OnlineError> {
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<String> {
categories
.iter()
.filter(|tag| KNOWN_LOADERS.contains(&tag.as_str()))
.cloned()
.collect()
}
/// 空串当「没有」(站点用 `""` 表示没图 / 没链接)。
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|item| !item.trim().is_empty())
}
+152
View File
@@ -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<Self> {
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<ContentType>,
/// 游戏版本过滤(精确值,如 `1.20.1`)
pub game_version: Option<String>,
/// 加载器过滤(站点 facet 值,如 `fabric`)
pub loader: Option<String>,
/// 排序
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<String>> = 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())
}
}
+231
View File
@@ -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<ProjectHit>,
/// 站点给的总数(**估算值**:站点文档不保证精确)
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<String>,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 项目类型原文
pub project_type: String,
/// 图标 URL(`None` = 没有)
pub icon_url: Option<String>,
}
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<String>,
/// 加载器
pub loaders: Vec<String>,
/// 分类标签
pub categories: Vec<String>,
/// 图标
pub icon_url: Option<String>,
/// 项目主页
pub source_url: Option<String>,
/// 问题追踪
pub issues_url: Option<String>,
}
/// 版本类型。
#[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<String>,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 发布日期
pub date_published: String,
/// 该版本总下载量
pub downloads: u64,
/// 文件列表
pub files: Vec<VersionFile>,
}
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<String, String>,
/// 下载地址
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<String, String>);
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<String>,
/// 加载器过滤(站点 facet 值)
pub loader: Option<String>,
}
+231
View File
@@ -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<SearchPage, OnlineError> {
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<ProjectDetail, OnlineError> {
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<Vec<Version>, 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<Version, OnlineError> {
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
}
+205
View File
@@ -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 不是「没联网」");
}
@@ -0,0 +1,89 @@
//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。
//!
//! 这一族的价值全在「经过真的 `From<DownloadError>`」:直接给 `OnlineError` 造一个
//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射,
//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。
use crate::addon::online::{OnlineError, OnlineErrorKind};
use crate::download::{DownloadError, DownloadErrorKind};
#[test]
fn an_all_sources_failed_with_a_404_leaf_is_not_found() {
// 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。
let error = DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
"所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...",
)
.with_suppressed(vec!["official: HTTP 404".to_string()])
.with_leaf(DownloadErrorKind::NotFound);
let online: OnlineError = error.into();
assert_eq!(
online.kind(),
OnlineErrorKind::NotFound,
"项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」"
);
assert!(!online.is_offline());
}
#[test]
fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() {
let online: OnlineError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into();
assert_eq!(online.kind(), OnlineErrorKind::NotConnected);
assert!(online.is_offline());
}
#[test]
fn leaf_kinds_map_one_to_one() {
let cases = [
(DownloadErrorKind::NotFound, OnlineErrorKind::NotFound),
(DownloadErrorKind::ServerError, OnlineErrorKind::Server),
(DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Network, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Integrity, OnlineErrorKind::Integrity),
(DownloadErrorKind::Io, OnlineErrorKind::Io),
(
DownloadErrorKind::InvalidRequest,
OnlineErrorKind::Unsupported,
),
(
DownloadErrorKind::RangeUnsupported,
OnlineErrorKind::Malformed,
),
];
for (leaf, expected) in cases {
let error =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf);
let online: OnlineError = error.into();
assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str());
}
}
#[test]
fn a_single_source_failure_uses_its_own_kind() {
// 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。
let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into();
assert_eq!(online.kind(), OnlineErrorKind::NotFound);
}
#[test]
fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() {
// 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。
use crate::addon::import::FetchErrorKind;
let not_found: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound)
.into();
assert_eq!(not_found.kind(), FetchErrorKind::NotFound);
assert!(not_found.kind().is_tolerable());
let offline: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::Network)
.into();
assert_eq!(offline.kind(), FetchErrorKind::Offline);
assert!(
offline.kind().is_tolerable(),
"没联网仍然是「这条文件搞不到」,实例照装"
);
}
@@ -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);
}
@@ -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";
+13
View File
@@ -0,0 +1,13 @@
//! 在线内容源的离线用例(**不打真实网络**)。
//!
//! 全部素材是录制好的 Modrinth v2 响应(`fixtures.rs`)+ 假字节(`support::FakeArtifact`):
//! 覆盖检索、项目详情、版本列表、按摘要反查、下载复核、补全路径与离线分类。
//!
//! 口径提醒:这些用例证明的是**解析与判定**,不证明「真实站点今天回什么」——后者写在
//! M162 报告的「没验的部分」里。
mod api;
mod classify;
mod download;
mod fixtures;
mod support;
@@ -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<String, Result<String, OnlineError>>,
seen: Mutex<Vec<String>>,
}
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<String> {
self.seen.lock().expect("锁没中毒").clone()
}
}
impl Transport for FixtureTransport {
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>> {
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<u8>,
/// 还欠几次「没联网」失败(每尝试一次减一)
failures: Mutex<u32>,
/// 实际尝试次数
attempts: Mutex<u32>,
/// 故意只写一半(模拟半截文件)
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<u64, OnlineError>> {
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),
)
}
+222
View File
@@ -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<Box<dyn Future<Output = T> + 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<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 没联网。
#[must_use]
pub fn offline(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::NotConnected, message)
}
/// 形状不对 / 自身不自洽。
#[must_use]
pub fn malformed(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Malformed, message)
}
/// 内容与声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Integrity, message)
}
/// 这条路走不通。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> 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<DownloadError> 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<String, OnlineError>>;
}
/// 生产实现:把 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<String, OnlineError>> {
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}")))
})
}
}
@@ -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::<Vec<_>>()
);
}
#[test]
fn only_a_real_offline_stops_the_completion_round() {
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-offline");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
.offline_modrinth();
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了");
assert!(scan.conflicts.is_empty());
// 断网且一条也没补到:注记不许说「补全了 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)
);
}
@@ -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"),
+1
View File
@@ -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;
+41 -1
View File
@@ -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<String, Vec<u8>>,
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。
modrinth: BTreeMap<String, ModrinthFileMeta>,
/// M162:补全时假装「没联网」(与「站点不认得这个摘要」区分开)。
modrinth_offline: bool,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.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<ModrinthFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
if self.modrinth_offline {
return Err(super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Offline,
"连不上 api.modrinth.com",
));
}
self.modrinth.get(sha512).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 sha512={sha512}"),
)
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
+8 -2
View File
@@ -7,7 +7,7 @@ use std::time::Instant;
use super::cache::now_ms;
use super::engine::{millis, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::error::{leaf_of, DownloadError, DownloadErrorKind};
use super::http;
use super::progress::ProgressEvent;
use super::types::{Candidate, DownloadId, DownloadRequest};
@@ -33,6 +33,8 @@ impl DownloadEngine {
let _permit = self.acquire().await?;
let mut suppressed: Vec<String> = Vec::new();
// 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。
let mut kinds: Vec<DownloadErrorKind> = Vec::new();
for candidate in &plan {
// 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
@@ -74,6 +76,7 @@ impl DownloadEngine {
retry_in_ms: if retry { millis(delay) } else { 0 },
});
if !retry {
kinds.push(err.kind);
suppressed.push(format!("{}: {}", candidate.source, err.message));
break;
}
@@ -84,11 +87,14 @@ impl DownloadEngine {
}
self.flush_health();
// 叶子成因要跟着出去:上层(在线内容源的 `Transport`)靠它区分「这个 URL 不存在」
// 与「没联网」,只留 `AllSourcesFailed` 的话那套分类在生产路径上永远不可达。
Err(DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
format!("所有候选源都取不到文本资源:{}", req.url),
)
.with_suppressed(suppressed))
.with_suppressed(suppressed)
.with_leaf(leaf_of(kinds)))
}
async fn fetch_bytes(
+125
View File
@@ -70,6 +70,18 @@ pub struct DownloadError {
pub attempts: usize,
/// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
pub suppressed: Vec<String>,
/// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底
/// 是怎么挂的。
///
/// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、
/// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」——
/// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里
/// 反解状态码是靠文案猜,改一句提示就失效。
///
/// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`;
/// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。
/// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。
pub leaf: Option<DownloadErrorKind>,
}
impl DownloadError {
@@ -82,6 +94,7 @@ impl DownloadError {
message: message.into(),
attempts: 0,
suppressed: Vec::new(),
leaf: None,
}
}
@@ -105,6 +118,62 @@ impl DownloadError {
self.suppressed = suppressed;
self
}
/// 标注整体失败之前的叶子成因。
#[must_use]
pub const fn with_leaf(mut self, leaf: DownloadErrorKind) -> Self {
self.leaf = Some(leaf);
self
}
/// 最可诊断的成因:有叶子就用叶子,否则用自身的 kind。
///
/// 上层(`addon::online::transport` / `addon::import::fetch`)靠这一处判定,不各自
/// 再写一遍「AllSourcesFailed 到底算哪一类」。
#[must_use]
pub fn effective_kind(&self) -> DownloadErrorKind {
if self.kind == DownloadErrorKind::AllSourcesFailed {
return self.leaf.unwrap_or(self.kind);
}
self.kind
}
}
/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。
///
/// 规则(与 `online::transport` 的映射表配套,判据只此一处):
///
/// | 各源的情况 | 叶子 |
/// |------------|------|
/// | 全部 404/410/403(`NotFound`) | `NotFound` |
/// | 出现 `Network` / `Timeout` | `Network` |
/// | 全部 5xx(`ServerError`) | `ServerError` |
/// | 其它(`Integrity` / `Io` / `InvalidRequest`) | 该条自己的 kind(后出现的赢) |
/// | 混合了 404 与 5xx | `ServerError`(比「不存在」更可诊断) |
///
/// `kinds` 为空时返回 `AllSourcesFailed`(调用方据此保留原 kind)。
#[must_use]
pub fn leaf_of(kinds: impl IntoIterator<Item = DownloadErrorKind>) -> DownloadErrorKind {
let mut leaf = DownloadErrorKind::AllSourcesFailed;
let mut rank = 0u8;
for kind in kinds {
let (candidate, weight) = match kind {
DownloadErrorKind::Network | DownloadErrorKind::Timeout => {
(DownloadErrorKind::Network, 4)
}
DownloadErrorKind::ServerError => (DownloadErrorKind::ServerError, 3),
DownloadErrorKind::Integrity => (DownloadErrorKind::Integrity, 3),
DownloadErrorKind::Io => (DownloadErrorKind::Io, 3),
DownloadErrorKind::InvalidRequest => (DownloadErrorKind::InvalidRequest, 3),
DownloadErrorKind::NotFound => (DownloadErrorKind::NotFound, 1),
DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => (kind, 2),
};
if weight >= rank {
rank = weight;
leaf = candidate;
}
}
leaf
}
impl fmt::Display for DownloadError {
@@ -123,3 +192,59 @@ impl fmt::Display for DownloadError {
}
impl std::error::Error for DownloadError {}
#[cfg(test)]
mod tests {
use super::{leaf_of, DownloadError, DownloadErrorKind};
/// 四档叶子成因(tower 定案的门禁):全 404 / 有网络错 / 全 5xx / 混合。
#[test]
fn leaf_keeps_the_most_diagnosable_cause() {
use DownloadErrorKind::{Network, NotFound, ServerError, Timeout};
assert_eq!(leaf_of([NotFound, NotFound]), NotFound, "全 404 ⇒ NotFound");
assert_eq!(
leaf_of([Network, NotFound]),
Network,
"有网络错 ⇒ Network(哪怕另一源是 404)"
);
assert_eq!(
leaf_of([ServerError, ServerError]),
ServerError,
"全 5xx ⇒ ServerError"
);
assert_eq!(
leaf_of([NotFound, ServerError]),
ServerError,
"混合 404 与 5xx ⇒ 取更可诊断的那个"
);
assert_eq!(leaf_of([Timeout, NotFound]), Network, "超时归到 Network");
assert_eq!(
leaf_of([]),
DownloadErrorKind::AllSourcesFailed,
"一条都没有 ⇒ 保留原 kind"
);
}
/// 整体失败时上层靠 `effective_kind()` 判定;单源失败时就是它自己的 kind。
#[test]
fn effective_kind_prefers_the_leaf_only_for_all_sources_failed() {
let leaf = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound);
assert_eq!(leaf.effective_kind(), DownloadErrorKind::NotFound);
assert_eq!(
leaf.kind,
DownloadErrorKind::AllSourcesFailed,
"kind 本身不改"
);
let bare = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败");
assert_eq!(
bare.effective_kind(),
DownloadErrorKind::AllSourcesFailed,
"没有叶子 ⇒ 原样返回(上层再决定当哪一类)"
);
let single = DownloadError::new(DownloadErrorKind::NotFound, "404");
assert_eq!(single.effective_kind(), DownloadErrorKind::NotFound);
}
}
+12
View File
@@ -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", "没有可装入的实例"),
+10 -3
View File
@@ -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<Item = &'static (&'static str, &'static str, &'static str)> {
GOLDEN
.iter()
@@ -73,8 +75,13 @@ pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static st
}
/// 金表总行数(`Key::ALL` 的条数)。
///
/// **定义成 `rows().count()`,不是各段 `len()` 相加**:r3 复审实测过一条假绿——把
/// [`rows`] 里的 `chain(tail2::ROWS)` 删掉,`catalog/tests.rs` 仍全绿,因为条数来自一处手工
/// 聚合(仍有 tail2)而内容只遍历 `rows()`(少了 tail2),于是 213/459 行无人看守。
/// 现在条数与内容同源:漏挂一段 ⇒ 条数当场对不上 ⇒ 用例必红。
pub(super) fn len() -> usize {
GOLDEN.len() + tail::ROWS.len() + tail2::ROWS.len()
rows().count()
}
mod tail;
+6
View File
@@ -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", "没有可装入的实例"),
+67 -46
View File
@@ -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<ContentResults>,
/// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显)
pub pending: bool,
/// 最近一次失败的原文(`None` = 没失败)
pub failed: Option<String>,
/// 最近一次失败(`None` = 没失败)。M162 起带**大类**,「没联网」不再是普通失败。
pub failed: Option<ContentFailure>,
/// **最近一页**是否装满(`hits.len() >= limit`)。
///
/// 为什么不是「累计条数够一页」:分页是**累加**到 `results.hits` 里的,累计值在第 2 页之后
/// 必然 ≥ limit ⇒ 用累计判会得到「has_more 恒真」:加载更多永不消失、「没有更多了」在真实
/// 翻页路径上不可达,而且每点一次白发一次网络请求(复审 P1-2)。所以这一位单独记。
pub last_page_filled: bool,
/// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过)
pub detail: Option<Box<ContentDetail>>,
/// 详情请求在飞(哪个项目:迟到结果靠它判「还算不算当前这次」)
pub detail_pending: bool,
/// 在飞的那次详情请求的项目 id(`None` = 没有在飞)
pub detail_requested: Option<String>,
/// 最近一次详情请求的失败
pub detail_failed: Option<ContentFailure>,
}
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 {
+162
View File
@@ -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<String>`:无网时画「未联网,检查网络后重试」、站点 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<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
/// 没联网。
#[must_use]
pub fn offline(detail: impl Into<String>) -> Self {
Self::new(ContentFailureKind::Offline, detail)
}
/// 这条路没通。
#[must_use]
pub fn unsupported(detail: impl Into<String>) -> 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<String>,
/// 目标加载器(选版本用;`None` = 不限)
pub loader: Option<LoaderKind>,
}
/// 一个可用版本(详情页「版本列表」的一行)。
#[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<String>,
/// 支持的加载器
pub loaders: Vec<String>,
/// 发布日期(站点原文)
pub published: String,
/// 主文件名(`None` = 站点没给)
pub file_name: Option<String>,
/// 主文件字节数(`None` = 站点没给)
pub file_size: Option<u64>,
}
/// 项目详情 + 版本列表(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<ContentVersionView>,
}
impl ContentDetail {
/// 版本数量(页面「N 个可用版本」直接用)。
#[must_use]
pub fn version_count(&self) -> usize {
self.versions.len()
}
}
+50
View File
@@ -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,
}
}
}
+11 -2
View File
@@ -137,8 +137,15 @@ pub enum CoreEvent {
NoticesChanged(Vec<NoticeView>),
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
ContentResults(Box<ContentResults>),
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
ContentFailed(String),
/// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画
ContentFailed(Box<crate::dto::ContentFailure>),
/// 内容库项目详情 + 版本列表(M162):`project` 是**请求时**选中的 id,迟到结果靠它判。
ContentDetail {
/// 请求时选中的项目 id
project: String,
/// `Ok` = 详情;`Err` = 失败大类 + 原因
result: Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>,
},
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
@@ -251,6 +258,8 @@ pub enum AppCommand {
SearchContent(ContentQuery),
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
InstallContent(Box<ContentInstallRequest>),
/// 取某个内容项目的详情与版本列表(M162;结果经 [`CoreEvent::ContentDetail`] 回来)
LoadContentDetail(Box<crate::dto::ContentDetailRequest>),
// M113: import/export
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
Pack(Box<PackCommand>),
+8
View File
@@ -6,6 +6,8 @@
use serde::{Deserialize, Serialize};
use crate::dto::ContentFailureKind;
/// 安装阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InstallStage {
@@ -70,6 +72,11 @@ pub struct InstallProgressView {
pub eta_secs: u64,
/// 状态/错误文案
pub message: String,
/// 失败大类(M162 r1):`None` = 没失败,或失败来自不产生分类的路径。
///
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
pub failure_kind: Option<ContentFailureKind>,
}
impl Default for InstallProgressView {
@@ -93,6 +100,7 @@ impl InstallProgressView {
speed_bps: 0,
eta_secs: 0,
message: String::new(),
failure_kind: None,
}
}
+3 -2
View File
@@ -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};
+16 -2
View File
@@ -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<ContentResults, String> = sources::search(&env, &query);
let result: Result<ContentResults, ContentFailure> = 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<ContentDetail, ContentFailure> = sources::detail(&env, &request);
let _ = tx.send(HostTask::ContentDetail {
project,
result: Box::new(result),
});
});
}
/// 后台「装进指定实例」。
pub fn spawn_install(
env: HostEnv,
+95 -80
View File
@@ -1,5 +1,11 @@
//! 「一键装入指定实例」(M109)。
//!
//! ## 网络只走 code 的在线内容源(M162 r1)
//!
//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、
//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。
//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
//!
//! ## 它到底做了什么(诚实版)
//!
//! | 内容类型 | 做什么 | 状态 |
@@ -19,20 +25,21 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{
download_verified, EngineArtifactFetcher, 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<String, String> {
) -> Result<String, ContentFailure> {
let instance = find_instance(env, &request.instance)?;
let game_dir = game_dir_of(env, &instance)?;
let dir = request
.kind
.dir()
.ok_or_else(|| "该内容类型不支持单文件装入".to_string())?;
.ok_or_else(|| local("该内容类型不支持单文件装入"))?;
let target_dir = game_dir.join(dir);
std::fs::create_dir_all(&target_dir)
.map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?;
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
let file = resolve_file(env, request)?;
if cancel.load(Ordering::SeqCst) {
return Err("已取消(下载开始前)".to_string());
return Err(local("已取消(下载开始前)"));
}
progress.stage = InstallStage::Downloading;
progress.message = file.name.clone();
@@ -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<InstanceSummary, String> {
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
fn local(reason: impl Into<String>) -> ContentFailure {
ContentFailure::new(ContentFailureKind::Other, reason)
}
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFailure> {
super::super::game::instances(env)
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| format!("找不到实例 {id}"))
.ok_or_else(|| local(format!("找不到实例 {id}")))
}
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
let global = super::super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
derive_game_dir(
&env.game,
&instance.version_id,
@@ -162,76 +184,69 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Str
None,
)
.map(|derivation| derivation.game_dir)
.map_err(|error| format!("隔离目录派生失败:{error}"))
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
}
/// 要下载的那个文件。
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
struct RemoteFile {
url: String,
name: String,
sha1: Option<String>,
sha512: Option<String>,
size: Option<u64>,
}
/// 解析「这个项目下最适合目标实例的那个文件」。
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
fn resolve_file(
env: &HostEnv,
request: &ContentInstallRequest,
) -> Result<RemoteFile, ContentFailure> {
match request.source {
ContentSource::Modrinth => modrinth_file(env, request),
ContentSource::Curseforge => Err(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
),
ContentSource::Curseforge => Err(ContentFailure::unsupported(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
)),
}
}
fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
let mut url = format!(
"{MODRINTH}/project/{}/version?game_versions={}",
request.project,
encode(&format!("[\"{}\"]", request.game_version))
);
if let Some(facet) = loader_facet(request.loader) {
url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]"))));
/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。
///
/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑,
/// `Version::primary_file()` 就是为这件事存在的。
fn modrinth_file(
env: &HostEnv,
request: &ContentInstallRequest,
) -> Result<RemoteFile, ContentFailure> {
let core_engine = super::super::engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let versions = block_on(client.versions(&VersionQuery {
project: request.project.clone(),
game_version: Some(request.game_version.clone()),
loader: loader_facet(request.loader).map(str::to_string),
}))
.map_err(failure_of)?;
let version = versions.first().ok_or_else(|| {
ContentFailure::new(
ContentFailureKind::NotFound,
"这个项目没有匹配目标实例(版本 / 加载器)的文件",
)
})?;
let file = version
.primary_file()
.ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?;
if file.url.trim().is_empty() {
return Err(ContentFailure::new(
ContentFailureKind::Unsupported,
"版本里的主文件没有下载地址",
));
}
let body = get(env, &url, &[])?;
let json: serde_json::Value = serde_json::from_str(&body)
.map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?;
let versions = json
.as_array()
.ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?;
let first = versions
.first()
.ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?;
let files = first
.get("files")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "版本里没有 files".to_string())?;
// 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑
let file = files
.iter()
.find(|file| {
file.get("primary")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.or_else(|| files.first())
.ok_or_else(|| "版本里没有可用文件".to_string())?;
let url = file
.get("url")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "文件没有 url".to_string())?;
let name = file
.get("filename")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "文件没有 filename".to_string())?;
let 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),
})
}
@@ -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;
}
+174 -97
View File
@@ -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<ContentResults, String> {
pub(super) fn search(
env: &HostEnv,
query: &ContentQuery,
) -> Result<ContentResults, ContentFailure> {
match query.source {
ContentSource::Modrinth => modrinth(env, query),
ContentSource::Curseforge => curseforge(env, query),
}
}
/// 详情 + 版本列表分派(M162)。
pub(super) fn detail(
env: &HostEnv,
request: &ContentDetailRequest,
) -> Result<ContentDetail, ContentFailure> {
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<ContentResults, String> {
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::<Vec<_>>();
fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, ContentFailure> {
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<ContentDetail, ContentFailure> {
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<ContentResults, String> {
fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, ContentFailure> {
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<ContentResults, Str
url.push_str(&format!("&gameVersion={}", encode(version)));
}
let body = get(env, &url, &[("x-api-key", key.as_str())])?;
let json: Value = serde_json::from_str(&body)
.map_err(|error| format!("CurseForge 返回的不是合法 JSON:{error}"))?;
let body = get(env, &url, &[("x-api-key", key.as_str())]).map_err(cf_failure)?;
let json: Value = serde_json::from_str(&body).map_err(|error| {
ContentFailure::new(
ContentFailureKind::Other,
format!("CurseForge 返回的不是合法 JSON:{error}"),
)
})?;
let hits = json
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| "CurseForge 返回里没有 data 字段".to_string())?
.ok_or_else(|| {
ContentFailure::new(ContentFailureKind::Other, "CurseForge 返回里没有 data 字段")
})?
.iter()
.map(curseforge_hit)
.collect::<Vec<_>>();
@@ -145,6 +234,21 @@ fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, Str
})
}
/// CurseForge 的字符串错误 → 失败大类:只有「缺密钥」是 Unsupported,其余按站点/其它。
fn cf_failure(reason: String) -> 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<String> = Vec::new();
@@ -219,44 +323,17 @@ fn text(value: &Value, key: &str) -> String {
.to_string()
}
/// 取可选字符串字段(空串也当没有——站点会给 `""` 表示没图)。
fn optional_text(value: &Value, key: &str) -> Option<String> {
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<String> {
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
}
+34 -2
View File
@@ -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"
);
}
+4 -1
View File
@@ -316,7 +316,10 @@ fn progress_from_a_cancelled_job_is_dropped() {
});
host.handle(HostTask::InstallDone {
progress: Box::new(progress_snapshot(512)),
result: Err("已取消".to_string()),
result: Err(crate::dto::ContentFailure::new(
crate::dto::ContentFailureKind::Other,
"已取消",
)),
epoch,
});
let after: Vec<AppEvent> = host.drain_events();
+4
View File
@@ -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 —— 导入 / 预检 / 确认写盘 / 关面板
+8 -2
View File
@@ -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) => {
+7 -1
View File
@@ -160,9 +160,15 @@ pub fn spawn_install(
format!("{reason}(日志:{})", journal.path().display())
};
progress.message.clone_from(&text);
// 版本安装这条路的失败不是内容库的分类(它是本地/运行期错误),归到 Other:
// 大类字段仍然要填,面板才不会把「装坏了」误画成「没联网」。
progress.failure_kind = Some(crate::dto::ContentFailureKind::Other);
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(text),
result: Err(crate::dto::ContentFailure::new(
crate::dto::ContentFailureKind::Other,
text,
)),
epoch,
});
}
+14 -5
View File
@@ -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<Vec<String>, String>,
},
/// 内容库检索(M109;`Err` = 一句人读原因)
Content(Box<Result<ContentResults, String>>),
/// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因)
Content(Box<Result<ContentResults, ContentFailure>>),
/// 内容库项目详情 + 版本列表(M162):项目 id 随载荷一起回来(迟到结果靠它判)
ContentDetail {
/// 请求时选中的项目 id
project: String,
/// `Ok` = 详情;`Err` = 失败大类 + 原因
result: Box<Result<ContentDetail, ContentFailure>>,
},
// M113: import/export
/// 导入/导出(M113):一条事件 + 任务代次
///
@@ -70,12 +79,12 @@ pub enum HostTask {
/// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。
epoch: u64,
},
/// 安装收尾(成功带实例名,失败带文案)
/// 安装收尾(成功带实例名,失败带**大类 + 文案**)
InstallDone {
/// 最终进度快照
progress: Box<InstallProgressView>,
/// 成功时为 `Ok(实例名)`
result: Result<String, String>,
/// 成功时为 `Ok(实例名)`;失败时带分类(离线 ≠ 查无此项)
result: Result<String, ContentFailure>,
/// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`)
epoch: u64,
},
+4 -3
View File
@@ -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);
}
}
}
+20 -1
View File
@@ -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<ContentDetail, ContentFailure>,
) {
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;
+115 -1
View File
@@ -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")
);
}