feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态

core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。

core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。

core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。

presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。

app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。

测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
This commit is contained in:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 0241f793b2
commit 81278f55af
50 files changed
+3794 -222

No files matched your search

+25 -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) => {
@@ -167,6 +173,23 @@ impl App {
///
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
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),
})
}
fn content_install_request(&self) -> Option<ContentInstallRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
+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,69 @@ fn the_four_states_are_derived_from_three_fields() {
#[test]
fn a_failure_outranks_the_loading_state() {
let mut state = state_with(None, true);
state.content.failed = Some("网络不通".to_string());
state.content.failed = Some(ContentFailure::new(ContentFailureKind::Server, "站点 503"));
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
assert_eq!(model.status, Status::Failed("站点 503".to_string()));
assert!(model.status.failed());
assert!(!model.status.offline());
}
#[test]
fn no_network_is_its_own_state_not_an_empty_list() {
// M162:无网必须画「未联网」,不能与「没有匹配的内容」共用一个空态。
let mut state = state_with(None, false);
state.content.results = Some(results(Vec::new(), 0));
state.content.failed = Some(ContentFailure::offline("connect timed out"));
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert_eq!(
model.status,
Status::Offline("connect timed out".to_string())
);
assert!(model.status.offline());
assert!(model.status.failed());
assert_ne!(model.status, Status::Empty);
}
#[test]
fn versions_come_from_the_detail_channel_of_the_selected_project() {
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
let mut page = PageState {
selected: Some("p1".to_string()),
..PageState::default()
};
// 详情还没到:说明「正在取」,不是「一个版本都没有」
state.content.request_detail("p1");
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert!(model.available_versions.is_empty());
assert!(model.versions_note.is_some());
state.content.apply_detail(ContentDetail {
project: "p1".to_string(),
title: "Sodium".to_string(),
description: "fast".to_string(),
body: String::new(),
versions: vec![ContentVersionView {
id: "v1".to_string(),
name: "Sodium 0.6.0".to_string(),
number: "mc1.20.1-0.6.0".to_string(),
kind: "release".to_string(),
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
published: "2026-02-01T00:00:00Z".to_string(),
file_name: Some("sodium.jar".to_string()),
file_size: Some(2048),
}],
});
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert_eq!(model.available_versions.len(), 1);
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
assert!(model.available_versions[0].line.contains("mc1.20.1-0.6.0"));
assert!(model.versions_note.is_none());
// 换了选中项:上一个项目的版本列表不许画在这个项目下面
page.selected = Some("p2".to_string());
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert!(model.available_versions.is_empty());
}
#[test]
@@ -341,6 +341,58 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
}
}
/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。
pub(super) fn offline<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(
IconKind::Warn,
ui.color(Role::OnSurfaceVariant),
frame::ICON_PX
),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentOfflineTitle),
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentOfflineBody),
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh),
),
]
.spacing(ui.space(Space::X2))
.align_x(Alignment::Center),
)
}
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
pub(super) fn status_body<'a>(
ui: &'a Ui,
@@ -351,6 +403,8 @@ pub(super) fn status_body<'a>(
Status::Loading => Some(loading(ui, catalog)),
Status::Empty => Some(empty(ui, catalog)),
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
// M162:未联网不是「没有结果」,也不是普通失败——单独一屏(标题/正文/图标都不同)
Status::Offline(reason) => Some(offline(ui, catalog, reason.as_str())),
Status::Ready => None,
}
}
@@ -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(
+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;
+98
View File
@@ -0,0 +1,98 @@
//! 远端条目补全(M162):**有 `path` 但缺 `hashes.sha1` 的 mrpack 条目,按 sha512 反查 sha1**。
//!
//! # 补什么、为什么补
//!
//! M160 的预检用 SHA-1 判「会不会覆盖用户文件」;mrpack 规范里 `sha1` 是**可选**的,只有
//! `sha512` 必填。于是「只有 path+downloads+hashes(sha512)」的条目在预检里判不了,只能记进
//! `ImportScan::unknown_remote`。现在拿 sha512 去 Modrinth 反查:站点认得这个文件就把**它自己
//! 记的 sha1** 填回来(同一个文件在同一站点上的官方摘要),补不出**保持缺**、照旧计数。
//!
//! # 三条边界
//!
//! 1. **只补 sha1 / 字节数 / 缺的直链**,绝不改写清单里已有的值(清单说多大就是多大);
//! 2. **CurseForge 条目不走这里**:它们连路径都没有,补全要 `projectID/fileID` 端点(需要 key),
//! 那条路仍是 `PackFetcher::curse_file` 的活;
//! 3. **离线立刻停**:第一条网络类失败之后剩下的条目一律算「补不出」,不拿 N 次超时惩罚用户
//! (与 `online::complete` 同一条纪律,只是这里换成按路径过滤的导入视图)。
//!
//! # 为什么要改 `plan.remote` 而不是 `manifest.files`
//!
//! 导入与预检都只读 `plan.remote`(`manifest` 保留原样作诊断),改一处即可两条路都生效,
//! 也不会让人误以为「清单被回写了」。
use super::fetch::FetchErrorKind;
use super::plan::ImportPlan;
use super::ImportRequest;
/// 一次补全的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct Completion {
/// 补出了 sha1 的条目数
pub(crate) filled: usize,
/// 仍然补不出的条目数(缺 sha512 / 站点查不到 / 没接源)
pub(crate) unfilled: usize,
/// 是否因为「没联网」提前收工
pub(crate) offline: bool,
}
/// 跑一次补全(就地改 `plan.remote`)。
pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) -> Completion {
let mut state = Completion::default();
let Some(fetcher) = request.fetcher else {
state.unfilled = plan
.remote
.iter()
.filter(|entry| entry.path.is_some() && entry.file.sha1().is_none())
.count();
return state;
};
for entry in plan.remote.iter_mut() {
if entry.file.sha1().is_some() {
continue;
}
// CF 条目(没有路径)不归这里:那是 `curse_file` 的活。
if entry.path.is_none() {
continue;
}
let Some(sha512) = entry.file.sha512().map(str::to_string) else {
state.unfilled += 1;
continue;
};
if state.offline {
state.unfilled += 1;
continue;
}
match fetcher.modrinth_file_by_sha512(&sha512).await {
Ok(meta) => {
// 站点给的 sha1 也要过形状检查:长度不对的「摘要」比没有更危险。
match meta.sha1.filter(|hex| is_sha1(hex)) {
Some(sha1) => {
entry.file.hashes.insert("sha1".to_string(), sha1);
if entry.file.size.is_none() {
entry.file.size = meta.size;
}
if entry.file.downloads.iter().all(|url| url.trim().is_empty()) {
if let Some(url) = meta.url {
entry.file.downloads.push(url);
}
}
state.filled += 1;
}
None => state.unfilled += 1,
}
}
Err(error) => {
if error.kind() == FetchErrorKind::Network {
state.offline = true;
}
state.unfilled += 1;
}
}
}
state
}
/// 40 位十六进制。
fn is_sha1(hex: &str) -> bool {
hex.len() == 40 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}
+101 -21
View File
@@ -16,17 +16,28 @@
//!
//! # 生产实现只走 `core::download`
//!
//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,它只做一件事:把
//! [`FetchRequest`] 翻译成 [`crate::download::DownloadRequest`] 交给引擎(镜像展开、退避、
//! 续传、边下边算都在引擎里)。导入链路里**没有**第二个 `reqwest::Client`。
//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,做两件事:
//!
//! 1. **[`PackFetcher::fetch_file`]** —— 交给 [`crate::addon::online::download_verified`]:
//! sha1 由引擎流式校验、sha512 由 addon 侧落盘后复核(引擎的 `Integrity` 只认 Sha1/Sha256),
//! 失败按 [`crate::addon::online::RETRY_ATTEMPTS`] 重试且不留半截文件;
//! 2. **[`PackFetcher::modrinth_file_by_sha512`]** —— M162 的补全路径:按 sha512 反查
//! Modrinth 拿同一个文件的官方 sha1(站点不认得就如实报 `NotFound`,由调用方计数)。
//!
//! 两条都经同一个引擎(`EngineTransport` / `DownloadRequest`),导入链路里**没有**第二个
//! `reqwest::Client`,也没有第二个重试循环。
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, Integrity, ResourceKind,
use crate::addon::online::complete::identity_of;
use crate::addon::online::download::download_verified;
use crate::addon::online::modrinth::HashAlgorithm;
use crate::addon::online::{
EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind,
};
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind};
/// "搞不到"的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -130,6 +141,19 @@ impl From<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,
OnlineErrorKind::NotConnected | 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 +172,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 +194,8 @@ pub struct FetchRequest<'a> {
pub dest: &'a Path,
/// 期望 SHA-1(清单声明的)。
pub sha1: Option<&'a str>,
/// 期望 SHA-512(mrpack 的必填摘要;引擎不认这一档,由 `online::download` 落盘后复核)。
pub sha512: Option<&'a str>,
/// 期望字节数。
pub size: Option<u64>,
}
@@ -185,6 +224,21 @@ pub trait PackFetcher: Send + Sync {
})
}
/// 按 SHA-512 反查 Modrinth 上这个文件的元数据(**sha1 补全用**)。
///
/// 默认实现返回 [`FetchErrorKind::Unsupported`]:没有远程源(离线导入、测试的 `StubFetcher`)
/// 就补不出——这与 M160 的 `unknown_remote` 口径一致:补不出仍要如实计数,**不许猜**。
fn modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<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 +261,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 })
})
}
}
+5
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
@@ -225,6 +226,7 @@ struct Resolved {
path: String,
url: String,
sha1: Option<String>,
sha512: Option<String>,
size: Option<u64>,
}
@@ -250,6 +252,7 @@ async fn resolve(
path: path.clone(),
url,
sha1: entry.file.sha1().map(str::to_string),
sha512: entry.file.sha512().map(str::to_string),
size: entry.file.size,
}));
}
@@ -276,6 +279,8 @@ async fn resolve(
path: format!("{dir}/{}", meta.file_name),
url,
sha1: meta.sha1,
// CF 侧没有 sha512 可核(官方只给 sha1);空着就是「不校验这一档」。
sha512: None,
size: meta.size,
}))
}
+16 -1
View File
@@ -43,11 +43,26 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
let mut report = ImportReport::empty(request.target.to_path_buf());
tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, plan) = match probe_and_plan(request) {
let (archive, mut plan) = match probe_and_plan(request) {
Ok(pair) => pair,
Err(error) => return abandoned(report, error),
};
account_plan(&mut report, &plan);
// M162:远端条目补全(缺 `sha1` 的 mrpack 条目按 `sha512` 反查)。补不出的仍如实计数,
// 因此这一步**不影响**三态,只是让后面的摘要校验与合并判定有期望值可用。
let completion = super::complete::complete(request, &mut plan).await;
if completion.filled > 0 || completion.offline {
let mut message = format!(
"按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
completion.filled, completion.unfilled
);
if completion.offline {
message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置");
}
report
.warnings
.push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message));
}
let baseline = {
let (baseline, warnings) = load_baseline(request.target, &plan);
@@ -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,
+41 -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,45 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
return scan;
}
};
let (baseline, warnings) = load_baseline(request.target, &plan);
fill(request, &plan, &mut scan);
scan
}
/// 跑一次预检,**先补全再判**(M162):缺 `hashes.sha1` 的 mrpack 条目按 `sha512` 反查 Modrinth。
///
/// 补出来的条目与清单里本来就带 `sha1` 的条目走**同一个** [`judge`](判据一行没改);
/// 补不出的仍进 [`ImportScan::unknown_remote`] 并留同一条注记——计数口径不变,只是「能判的变多了」。
///
/// 没有挂 fetcher(离线导入)时等价于 [`scan_pack`]:不发任何请求,计数照旧。
#[must_use]
pub async fn scan_pack_online(request: &ImportRequest<'_>) -> ImportScan {
let mut scan = ImportScan::empty(request.target.to_path_buf());
let mut plan = match probe_and_plan(request) {
Ok((_, plan)) => plan,
Err(error) => {
scan.error = Some(error);
return scan;
}
};
let completion = super::complete::complete(request, &mut plan).await;
fill(request, &plan, &mut scan);
if completion.filled > 0 {
let mut message = format!(
"预检前按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
completion.filled, completion.unfilled
);
if completion.offline {
message.push_str(";补全因未联网提前停止");
}
scan.notes
.push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message));
}
scan
}
/// 预检的主体:把「已组好的计划」判成冲突清单(两个入口共用,判据只此一处)。
fn fill(request: &ImportRequest<'_>, plan: &ImportPlan, scan: &mut ImportScan) {
let (baseline, warnings) = load_baseline(request.target, plan);
scan.notes = warnings;
scan.format = Some(plan.format.clone());
scan.pack_name = Some(plan.manifest.name.clone());
@@ -244,7 +283,6 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
}
scan.conflicts
.sort_by(|left, right| left.path.cmp(&right.path));
scan
}
/// 判一个路径:`Some` = 要用户点头的冲突。
+18 -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,21 @@ pub use export::{
RemoteHit,
};
pub use import::{
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy,
ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport,
ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline,
PackFetcher, RejectedComponent,
import_pack, scan_pack, scan_pack_online, ConflictChoice, ConflictDecisions, ConflictFile,
ConflictPolicy, ImportConflict, ImportError, ImportErrorKind, ImportProgress,
ImportProgressSink, ImportReport, ImportRequest, ImportScan, ImportStage, ImportStatus,
ImportedFile, MissingFile, PackBaseline, PackFetcher, RejectedComponent,
};
pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport,
};
pub use online::{
complete_files, download_verified, ArtifactFetcher, CompletionSummary, ContentType, Downloaded,
EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail,
ProjectHit, SearchPage, SearchQuery, SortIndex, Transport, Version, VersionFile, VersionQuery,
MODRINTH_API,
};
pub use pack::{
EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits,
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
+125
View File
@@ -0,0 +1,125 @@
//! 远端条目补全:**mrpack 的 `files[]` 只有 `path`+`downloads`+`hashes` 时,按摘要反查补 sha1**。
//!
//! # 补的是什么(以及为什么不许猜)
//!
//! mrpack 规范里 `files[].hashes.sha512` 必填、`sha1` 可选。少了 `sha1` 的条目在 M160 的预检里
//! 判不了「会不会覆盖用户文件」(判据是 SHA-1),只能记进 `ImportScan::unknown_remote`。
//! 现在按 `sha512` 去 Modrinth 反查:查得到就把**站点给的 sha1**填回去(那是同一个文件在同一
//! 站点上的官方摘要,不是本地推算),查不到就**保持缺**、照旧计数——绝不「猜一个路径填上」,
//! 也绝不拿 sha512 冒充 sha1。
//!
//! # 一致性检查(反查结果必须能对上)
//!
//! 站点返回的版本里可能不止一个文件(`primary` 之外还有 `sources.jar`)。所以只在
//! **`hashes.sha512` 与请求值逐字相同**的那个文件上取 `sha1`;一个都对不上就当「补不出」
//! ——宁可老实计数,也不把别的东西的摘要装进来。
//!
//! # 离线时立刻停
//!
//! 无网时逐条重试是拿 N 次超时惩罚用户。第一条网络类失败就把 [`CompletionSummary::offline`]
//! 置位并停止请求,剩下的条目一律算「补不出」(预检的注记本来就会说「有 N 条判不了」)。
use super::model::Version;
use super::modrinth::{HashAlgorithm, Modrinth};
use super::transport::Transport;
use crate::addon::pack::PackFile;
/// 一次补全的结果(**只做计数与一句事实,不改任何判定口径**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CompletionSummary {
/// 补出了 sha1 的条目数
pub completed: usize,
/// 补不出的条目数(缺 sha512 / 站点查不到 / 摘要对不上)
pub unfilled: usize,
/// 是否因为「没联网」提前收工
pub offline: bool,
}
impl CompletionSummary {
/// 有没有真的补到东西。
#[must_use]
pub const fn changed(&self) -> bool {
self.completed > 0
}
}
/// 逐条补 `sha1`(就地改 `files`,返回值只做计数)。
pub async fn complete_files<T: Transport + ?Sized>(
client: &Modrinth<'_, T>,
files: &mut [PackFile],
) -> CompletionSummary {
let mut summary = CompletionSummary::default();
for file in files.iter_mut() {
if file.sha1().is_some() {
// 已经有 sha1:不需要补(也不该覆盖清单里的声明)。
continue;
}
let Some(sha512) = file.sha512().map(str::to_string) else {
summary.unfilled += 1;
continue;
};
if summary.offline {
summary.unfilled += 1;
continue;
}
match client.version_by_hash(HashAlgorithm::Sha512, &sha512).await {
Ok(version) => match identity_of(&version, &sha512) {
Some(identity) => {
apply(file, &identity);
summary.completed += 1;
}
None => summary.unfilled += 1,
},
Err(error) => {
if error.is_offline() {
summary.offline = true;
}
summary.unfilled += 1;
}
}
}
summary
}
/// 从版本里找出**摘要对得上**的那个文件,取出它的 sha1/大小/地址。
///
/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过)。
#[must_use]
pub fn identity_of(version: &Version, sha512: &str) -> Option<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>,
}
/// 把补全结果写回条目:**只填空缺,不覆盖清单里已有的值**。
fn apply(file: &mut PackFile, identity: &Identity) {
file.hashes
.entry("sha1".to_string())
.or_insert_with(|| identity.sha1.clone());
if file.size.is_none() {
file.size = identity.size;
}
if file.downloads.iter().all(|url| url.trim().is_empty()) {
if let Some(url) = &identity.url {
file.downloads.push(url.clone());
}
}
}
+288
View File
@@ -0,0 +1,288 @@
//! 「取到下载地址」之后的最后一步:**下载 → 复核 sha1/sha512 → 才算数**。
//!
//! # 三条硬要求(任务书原文)
//!
//! 1. **走既有分块下载引擎与镜像策略**:生产实现 [`EngineArtifactFetcher`] 只把请求翻译成
//! [`DownloadRequest`](`ResourceKind::ModFile` ⇒ 引擎按大文件分块下载,镜像展开、地区模式、
//! 健康度、退避全在引擎里;Modrinth 的 CDN 不在 `mirrors.json` 的规则里,所以只有国内环境
//! 真的命中规则时才会换源,不会瞎改写);
//! 2. **校验 sha1/sha512 后再写入**:引擎只认 sha1(`Integrity` 只有 Sha1/Sha256 两档),
//! 所以 sha1 交给引擎流式校验、sha512 由本模块**落盘后复核**;任何一项不符都删掉目标文件
//! (`download` 模块的 `HashAlgo` 没有 Sha512 是事实,改它超出 M162 的 scope,已登记 finding);
//! 3. **失败可重试且不留半截文件**:每次尝试前先删目标文件,尝试次数 [`RETRY_ATTEMPTS`];
//! 半截文件本身由引擎的 `PartFile` 守卫(`<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("没有可用下载地址"));
}
let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?;
let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("建目录 {} 失败:{error}", parent.display()),
)
})?;
}
let mut last: Option<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 = digest_file(dest)?;
if let Some(expected) = sha1 {
if !equals_hex(expected, &digest.sha1) {
last = Some(OnlineError::integrity(format!(
"SHA-1 与声明不符(声明 {expected},实际 {})",
digest.sha1
)));
remove_quiet(dest);
continue;
}
}
if let Some(expected) = sha512 {
if !equals_hex(expected, &digest.sha512) {
last = Some(OnlineError::integrity(format!(
"SHA-512 与声明不符(声明 {expected},实际 {})",
digest.sha512
)));
remove_quiet(dest);
continue;
}
}
return Ok(Downloaded {
path: dest.to_path_buf(),
bytes,
digest,
});
}
remove_quiet(dest);
Err(last.unwrap_or_else(|| {
OnlineError::new(
OnlineErrorKind::NotConnected,
"下载重试用尽(没有拿到任何字节)",
)
}))
}
/// 读一个文件同时算 sha1 与 sha512。
///
/// # Errors
/// 读盘失败返回 [`OnlineErrorKind::Io`]。
pub fn digest_file(path: &Path) -> Result<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`]。
fn check_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Result<Option<&str>, OnlineError> {
match raw.map(str::trim) {
None => Ok(None),
Some("") => Ok(None),
Some(hex) if algorithm.accepts(hex) => Ok(Some(hex)),
Some(hex) => Err(OnlineError::unsupported(format!(
"声明的 {} 不是合法摘要:{:?}(要求 {} 位十六进制)",
algorithm.as_str(),
hex,
algorithm.hex_len()
))),
}
}
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
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
}
+65
View File
@@ -0,0 +1,65 @@
//! 在线内容源(M162):**只做匿名可用的那半**——Modrinth v2。
//!
//! # 为什么形状是 Modrinth 的,而不是「内容源通用抽象」
//!
//! CurseForge 的检索/文件端点要 `x-api-key`(D10 §5 的客户端还没写),而它的 JSON 方言与
//! Modrinth 几乎没有一处同名(`project_id` vs `id`、`description` vs `summary`、
//! `downloads` vs `downloadCount`、版本列表还要再套一层 `latestFilesIndexes`)。为了让两者
//! 共用而发明一套谁都不像的抽象,代价是两边都得翻译两次、错一处两边都错。
//!
//! 所以本模块**按 Modrinth 的真实形状建模**(`/v2/search` 的 facets、`/v2/project/{id}`、
//! `/v2/project/{id}/version`、`/v2/version_file/{hash}`),把「平台差异」留到将来真要加第二个
//! 源时再抽。留的缝只有两处:
//!
//! | 缝 | 在哪 | 换源时要动什么 |
//! |----|------|----------------|
//! | [`Transport`] | 唯一的 HTTP 出口(trait) | 换/加一个实现;测试注入录制 JSON |
//! | [`Modrinth::new`] | `Modrinth<'a, T: Transport + ?Sized>` | 传 `&dyn Transport` 就能换站点客户端,一行业务代码不改 |
//!
//! # 网络层(零新依赖)
//!
//! `Cargo.lock` 里已有的 `reqwest` 0.12 + `tokio` + `serde_json` 就是全部。生产实现
//! [`EngineTransport`] 走 `DownloadEngine::download_bytes`,于是**信号量限流、源健康度、
//! 指数退避、错误分类**全由 `core::download` 提供,本模块不写第二个重试循环,也不持有第二个
//! `reqwest::Client`(与 `addon::import::fetch` 同一条纪律)。
//!
//! 没有 POST:Modrinth 的批量接口(`POST /v2/version_files`)需要 POST 请求体,而引擎的
//! `DownloadRequest` 没有请求体字段。补全路径因此按**单摘要 GET** 逐条查(见 [`complete`]),
//! mrpack 里缺 `sha1` 的条目本来就是少数,不值得为它引入第二条 HTTP 路径。
//!
//! # 离线不是「空结果」
//!
//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串:
//! 网络类失败一律映射成 [`OnlineErrorKind::NotConnected`](见 [`OnlineError`]),
//! presenter 再把它翻成 DTO 的 `ContentFailureKind::Offline`。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |------|------|
//! | [`model`] | Modrinth 的响应形状 + 检索请求(facets / 排序 / 分页) |
//! | [`transport`] | HTTP 出口 trait + 生产实现(走下载引擎)+ 错误分类 |
//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) |
//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 |
//! | [`complete`] | 远端条目补全:缺 `sha1` 的 mrpack 条目按 `sha512` 反查 |
pub mod complete;
pub mod download;
pub mod model;
pub mod modrinth;
pub mod transport;
pub use complete::{complete_files, CompletionSummary};
pub use download::{
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
FetchArgs, RETRY_ATTEMPTS,
};
pub use model::{
ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex,
Version, VersionFile, VersionQuery, VersionType,
};
pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API};
pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport};
#[cfg(test)]
mod tests;
+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
}
+170
View File
@@ -0,0 +1,170 @@
//! 端点拼装 + 响应解析 + 离线分类(纯离线)。
use crate::addon::online::modrinth::{encode, HashAlgorithm, MODRINTH_API};
use crate::addon::online::{
ContentType, Modrinth, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery,
};
use crate::download::blocking::block_on;
use super::fixtures;
use super::support::FixtureTransport;
fn query(text: &str) -> SearchQuery {
SearchQuery {
text: text.to_string(),
project_type: Some(ContentType::Mod),
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
index: SortIndex::Downloads,
limit: 2,
offset: 0,
}
}
#[test]
fn search_url_carries_facets_and_query_in_the_sites_own_shape() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let url = client.search_url(&query("sodium edge"));
assert!(url.starts_with(&format!(
"{MODRINTH_API}/search?limit=2&offset=0&index=downloads"
)));
// 站点要的是二维数组的 JSON,再整体百分号编码
let facets = encode(&query("").facets());
assert!(url.contains(&format!("&facets={facets}")), "url={url}");
assert!(facets.contains("%5B%5B%22project_type%3Amod%22%5D"));
assert!(url.ends_with("&query=sodium%20edge"));
}
#[test]
fn empty_text_omits_the_query_parameter() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let url = client.search_url(&SearchQuery::default());
assert!(!url.contains("&query="), "空关键词不该带 query:{url}");
assert!(
!url.contains("&facets="),
"没有过滤条件不该带 facets:{url}"
);
}
#[test]
fn search_parses_hits_and_filters_fake_loaders() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), fixtures::SEARCH);
let client = Modrinth::new(&transport);
let page = block_on(client.search(&query("sodium"))).expect("解析应当成功");
assert_eq!(page.total_hits, 1234);
assert_eq!(page.hits.len(), 2);
let sodium = &page.hits[0];
assert_eq!(sodium.project_id, "AANobbMI");
assert_eq!(sodium.title, "Sodium");
// `optimization` 不是加载器:只留 fabric / quilt
assert_eq!(
sodium.loaders,
vec!["fabric".to_string(), "quilt".to_string()]
);
assert!(sodium.supports_version("1.20.1"));
assert!(!sodium.supports_version("1.19.2"));
// 日期读的是 `date_modified`(旧实现读 `updated`,永远是空串)
assert_eq!(sodium.updated, "2026-02-03T10:11:12Z");
// 空 icon_url 当「没有」
assert_eq!(page.hits[1].icon_url, None);
// limit=2 且回了 2 条 ⇒ 本页装满 ⇒ 还有下一页(判据不是 total_hits)
assert!(page.has_more());
}
#[test]
fn has_more_is_decided_by_a_full_page() {
let mut page = crate::addon::online::SearchPage {
limit: 2,
hits: vec![Default::default(), Default::default()],
..Default::default()
};
assert!(page.has_more());
page.hits.pop();
assert!(!page.has_more());
}
#[test]
fn project_detail_parses_body_links_and_loader_list() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/project/sodium"), fixtures::PROJECT);
let client = Modrinth::new(&transport);
let project = block_on(client.project("sodium")).expect("解析应当成功");
assert_eq!(project.id, "AANobbMI");
assert_eq!(project.follows, 9912);
assert!(project.body.contains("modern rendering engine"));
assert_eq!(
project.loaders,
vec!["fabric".to_string(), "quilt".to_string()]
);
assert_eq!(
project.source_url.as_deref(),
Some("https://github.com/CaffeineMC/sodium")
);
}
#[test]
fn version_list_prefers_the_primary_file() {
let transport = FixtureTransport::new().with(
&format!("{MODRINTH_API}/project/sodium/version"),
fixtures::VERSIONS,
);
let client = Modrinth::new(&transport);
let versions = block_on(client.versions(&VersionQuery {
project: "sodium".to_string(),
game_version: Some("1.21.1".to_string()),
loader: Some("fabric".to_string()),
}))
.expect("解析应当成功");
assert_eq!(versions.len(), 1);
let file = versions[0].primary_file().expect("必须有文件");
// 取第一个会拿到 sources.jar:必须是 primary 的那个
assert_eq!(file.filename, "sodium-fabric-0.6.0.jar");
assert_eq!(file.size, 2048);
// 版本列表请求带了两个数组参数(不是被静默丢掉的空过滤)
let seen = transport.seen();
assert!(
seen[0].contains("game_versions=%5B%221.21.1%22%5D"),
"{}",
seen[0]
);
assert!(
seen[0].contains("loaders=%5B%22fabric%22%5D"),
"{}",
seen[0]
);
}
#[test]
fn a_hash_shape_mismatch_never_hits_the_network() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, "not-a-hash"))
.expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert!(
transport.seen().is_empty(),
"不该发请求:{:?}",
transport.seen()
);
}
#[test]
fn offline_is_reported_as_not_connected_not_as_an_empty_page() {
let transport = FixtureTransport::new().offline(&format!("{MODRINTH_API}/search"));
let client = Modrinth::new(&transport);
let error = block_on(client.search(&query("sodium"))).expect_err("没网必须报错");
assert_eq!(error.kind(), OnlineErrorKind::NotConnected);
assert!(error.is_offline());
}
#[test]
fn a_broken_payload_is_malformed_not_empty() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), "{\"hits\": 3}");
let client = Modrinth::new(&transport);
let error = block_on(client.search(&query("sodium"))).expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Malformed);
}
@@ -0,0 +1,135 @@
//! 远端条目补全:能补就补、补不出照旧如实计数、离线立刻停。
use crate::addon::online::{complete_files, Modrinth};
use crate::addon::pack::PackFile;
use crate::download::blocking::block_on;
use super::fixtures;
use super::support::FixtureTransport;
const HASH_PREFIX: &str = "https://api.modrinth.com/v2/version_file/";
fn entry(sha512: Option<&str>) -> PackFile {
let mut file = PackFile::with_path("mods/sodium-fabric-0.5.11.jar");
file.downloads
.push("https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium.jar".to_string());
file.size = None;
if let Some(sha512) = sha512 {
file.hashes.insert("sha512".to_string(), sha512.to_string());
}
file
}
#[test]
fn a_missing_sha1_is_filled_from_the_real_source() {
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(summary.unfilled, 0);
assert!(!summary.offline);
assert!(summary.changed());
assert_eq!(files[0].sha1(), Some(fixtures::LOOKUP_SHA1));
assert_eq!(files[0].size, Some(4096));
// 清单里已有的直链不被覆盖
assert_eq!(files[0].downloads.len(), 1);
assert!(files[0].downloads[0].ends_with("sodium.jar"));
}
#[test]
fn an_entry_that_already_has_a_sha1_is_left_alone() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let mut file = entry(Some(fixtures::LOOKUP_SHA512));
file.hashes.insert("sha1".to_string(), "9".repeat(40));
let mut files = vec![file];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(
summary.unfilled, 0,
"已经有 sha1 的条目既不算补到也不算补不出"
);
assert_eq!(files[0].sha1(), Some("9".repeat(40).as_str()));
assert!(transport.seen().is_empty(), "不该发请求");
}
#[test]
fn an_entry_without_sha512_cannot_be_completed() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let mut files = vec![entry(None)];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert_eq!(files[0].sha1(), None, "补不出就是补不出,不许猜");
assert!(transport.seen().is_empty());
}
#[test]
fn a_source_that_does_not_know_the_hash_is_counted_as_unfilled() {
let transport = FixtureTransport::new().missing(HASH_PREFIX);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert!(!summary.offline, "404 不是「没联网」");
assert_eq!(files[0].sha1(), None);
}
#[test]
fn a_version_whose_hashes_do_not_match_is_not_used() {
// 路由回的是同一个版本对象,但请求的是另一个 sha512 ⇒ 身份对不上
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::OTHER_SHA512))];
// OTHER_SHA512 在同一个版本里存在,但那条是 sources.jar:仍算能对上(站点给的摘要就是它)
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(
files[0].sha1(),
Some("4444444444444444444444444444444444444444")
);
// 而一个版本里根本没有的摘要 ⇒ 补不出
let mut unknown = vec![entry(Some(&"e".repeat(128)))];
let summary = block_on(complete_files(&client, &mut unknown));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert_eq!(unknown[0].sha1(), None);
}
#[test]
fn offline_stops_after_one_request_and_counts_the_rest_as_unfilled() {
let transport = FixtureTransport::new().offline(HASH_PREFIX);
let client = Modrinth::new(&transport);
let mut files = vec![
entry(Some(fixtures::LOOKUP_SHA512)),
entry(Some(fixtures::LOOKUP_SHA512)),
entry(Some(fixtures::LOOKUP_SHA512)),
];
let summary = block_on(complete_files(&client, &mut files));
assert!(summary.offline);
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 3);
assert_eq!(transport.seen().len(), 1, "离线后不该逐条重试");
}
#[test]
fn a_download_url_is_only_added_when_the_entry_has_none() {
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut file = entry(Some(fixtures::LOOKUP_SHA512));
file.downloads.clear();
let mut files = vec![file];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(
files[0].downloads,
vec![
"https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar"
.to_string()
]
);
}
@@ -0,0 +1,167 @@
//! 下载复核:sha1/sha512 对不对、失败重试几次、会不会留下半截文件。
use crate::addon::online::download::{download_verified, RETRY_ATTEMPTS};
use crate::addon::online::OnlineErrorKind;
use crate::addon::temp::TempRoot;
use crate::download::blocking::block_on;
use super::support::{hashes_of, FakeArtifact};
const BYTES: &[u8] = b"PK\x03\x04 fake mod jar bytes for offline fixtures";
fn spec() -> (String, String) {
hashes_of(BYTES)
}
#[test]
fn a_matching_download_is_written_and_reported() {
let root = TempRoot::new("online-ok");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (sha1, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("摘要对得上就该成功");
assert_eq!(done.bytes, BYTES.len() as u64);
assert_eq!(done.digest.sha512, sha512);
assert_eq!(std::fs::read(&dest).expect("文件应当落盘"), BYTES);
assert_eq!(fetcher.attempts(), 1);
}
#[test]
fn a_sha512_mismatch_deletes_the_file_and_retries_then_fails() {
let root = TempRoot::new("online-bad-sha512");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (sha1, _) = spec();
let wrong = "0".repeat(128);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&wrong),
None,
&dest,
))
.expect_err("摘要不符必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS, "摘要不符要重试到上限");
assert!(!dest.exists(), "不符的内容不许留在盘上");
}
#[test]
fn a_sha1_mismatch_is_caught_even_when_the_fetcher_skips_verification() {
let root = TempRoot::new("online-bad-sha1");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (_, sha512) = spec();
let wrong = "1".repeat(40);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&wrong),
Some(&sha512),
None,
&dest,
))
.expect_err("sha1 不符必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert!(error.message().contains("SHA-1"), "{}", error.message());
assert!(!dest.exists());
}
#[test]
fn a_transient_network_failure_is_retried_and_succeeds() {
let root = TempRoot::new("online-retry");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).failing(2);
let (sha1, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("第三次该成功");
assert_eq!(done.bytes, BYTES.len() as u64);
assert_eq!(fetcher.attempts(), 3);
assert!(dest.exists());
}
#[test]
fn exhausting_the_retries_leaves_no_half_file() {
let root = TempRoot::new("online-exhausted");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).failing(RETRY_ATTEMPTS + 5);
let (sha1, sha512) = spec();
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
None,
&dest,
))
.expect_err("一直断网必须失败");
assert_eq!(error.kind(), OnlineErrorKind::NotConnected);
assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS);
assert!(!dest.exists(), "失败后不许留文件");
}
#[test]
fn a_truncated_body_is_rejected_by_the_declared_size() {
let root = TempRoot::new("online-truncated");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).truncated();
// 声明的是全量大小,实际只写了一半 ⇒ 按字节数就该拒
let (sha1, sha512) = spec();
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect_err("半截文件必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert!(!dest.exists());
}
#[test]
fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
let root = TempRoot::new("online-bad-decl");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
None,
Some("abc"),
None,
&dest,
))
.expect_err("摘要形状不对必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert_eq!(fetcher.attempts(), 0);
assert!(!dest.exists());
}
#[test]
fn an_empty_url_is_unsupported() {
let root = TempRoot::new("online-no-url");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let error = block_on(download_verified(&fetcher, " ", None, None, None, &dest))
.expect_err("没有地址必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert_eq!(fetcher.attempts(), 0);
}
@@ -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 complete;
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),
)
}
+203
View File
@@ -0,0 +1,203 @@
//! 唯一的 HTTP 出口 + 错误分类。
//!
//! # 为什么是 trait
//!
//! 三条理由,每条都对应一个真实麻烦:
//!
//! 1. **测试不能联网**:本模块的解析/端点/补全全部要用录制 JSON 跑(CI 没有网,也不该有);
//! 2. **换源不改业务**:将来加第二个内容源时,只要它也能 GET 出 JSON,就能复用这套
//! `Transport`(现在的实现按「一个 URL → 一段文本」抽象,不假设站点);
//! 3. **错误必须可分辨**:「没联网」和「站点说没有这个项目」是两件事(前者该提示检查网络,
//! 后者该提示换关键词),字符串错误串会让上层只能靠 `contains` 猜。
//!
//! # 生产实现只有一条路:`core::download` 的引擎
//!
//! [`EngineTransport`] 调 [`DownloadEngine::download_bytes`],于是信号量限流、源健康度、
//! 指数退避、错误分类全在引擎里。本模块**不**新建 `reqwest::Client`、**不**写重试循环
//! (与 `addon::import::fetch` 的纪律同源)。镜像策略同理:`mirrors.json` 里没有
//! `api.modrinth.com` 的前缀规则 ⇒ 元数据请求永远是直连,只有真正的文件下载才会被镜像
//! 规则命中(国内 gh-proxy 那条也只看 GitHub host)。
use std::future::Future;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
};
/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写与 `import::fetch` 同一个范式)。
pub type OnlineFuture<'a, T> = Pin<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` 的错误 → 本模块的错误分类。
///
/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络);
/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。
impl From<DownloadError> for OnlineError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
DownloadErrorKind::NotFound => OnlineErrorKind::NotFound,
DownloadErrorKind::ServerError => OnlineErrorKind::Server,
DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity | DownloadErrorKind::RangeUnsupported => {
OnlineErrorKind::Malformed
}
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
};
Self::new(kind, error.message)
}
}
/// 一个 URL → 一段文本(JSON 原文)。
///
/// 实现方只负责「取回来」:状态码分类、退避、限流都在实现里(生产实现在引擎里)。
pub trait Transport: Send + Sync {
/// GET 一段文本。
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<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,145 @@
//! 远端条目补全(M162):**有 path 但没有 `hashes.sha1` 的 mrpack 条目,补得出就判,补不出照旧计数**。
//!
//! 三条纪律:
//!
//! 1. 补全靠 `PackFetcher::modrinth_file_by_sha512`(默认 `Unsupported` ⇒ 离线导入补不出);
//! 2. 补出来的条目与自带 `sha1` 的条目走**同一个** `judge`(M160 的判据一行没改);
//! 3. 补出的 `sha1` 会真的用在下载校验上——所以「站点给错摘要」必须是 `Failed`(内容不符),
//! 不能被当成「暂时搞不到」混过去。
//!
//! 全部离线:`StubFetcher` 里挂一条 sha512 → 元数据,没有一个真实请求。
use super::support::{sha1, StubFetcher};
use super::{read_text, write_file, zip_dir, TempRoot};
use crate::addon::import::{
import_pack, scan_pack, scan_pack_online, ImportRequest, ImportStatus, ModrinthFileMeta,
};
use crate::download::blocking::block_on;
const PATH: &str = "mods/no-sha1.jar";
const DISK_BYTES: &[u8] = b"what the user already has";
const PACK_BYTES: &[u8] = b"what the content source serves";
const DOWNLOAD: &str = "stub://mods/no-sha1.jar";
const SHA512: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// 一个只有 `hashes.sha512` 的 mrpack(sha1 缺失 = 预检判不了)。
fn mrpack_without_sha1(tag: &str) -> (TempRoot, std::path::PathBuf) {
let root = TempRoot::new(tag);
let dir = root.join("src");
let index = format!(
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"缺 sha1 的包",
"files":[{{"path":"{PATH}",
"hashes":{{"sha512":"{SHA512}"}},
"downloads":["{DOWNLOAD}"],"fileSize":{size}}}],
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
size = PACK_BYTES.len()
);
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
(root, pack)
}
/// 站点给出这个文件的 sha1/字节数(`sha1` 由调用方决定对不对)。
fn meta(pack_sha1: &str) -> ModrinthFileMeta {
ModrinthFileMeta {
sha1: Some(pack_sha1.to_string()),
size: Some(PACK_BYTES.len() as u64),
url: Some(DOWNLOAD.to_string()),
file_name: Some("no-sha1.jar".to_string()),
}
}
#[test]
fn offline_scan_keeps_the_unknown_remote_count() {
// 对照:没有 fetcher(离线)时口径与 M160 完全一致。
let (root, pack) = mrpack_without_sha1("online-scan-offline");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
let scan = scan_pack(&ImportRequest::new(&pack, &target));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 1, "缺 sha1 就是判不了,如实计数");
assert!(scan.conflicts.is_empty());
}
#[test]
fn an_online_scan_completes_the_sha1_and_judges_the_conflict() {
let (root, pack) = mrpack_without_sha1("online-scan-filled");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&sha1(PACK_BYTES)));
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 0, "补出来了就不再是「判不了」");
let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect();
assert_eq!(paths, vec![PATH], "补出来的摘要要真的用上:{:?}", paths);
assert_eq!(scan.conflicts[0].pack_sha1, sha1(PACK_BYTES));
assert!(scan
.notes
.iter()
.any(|note| note.message.contains("补全了 1 条远端条目的 sha1")));
}
#[test]
fn a_scan_whose_source_does_not_know_the_hash_still_counts_it() {
let (root, pack) = mrpack_without_sha1("online-scan-unknown");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
// 站点不认得这个 sha512(stub 里没挂)⇒ 补不出,口径回到 M160
let fetcher = StubFetcher::new();
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(scan.unknown_remote, 1);
assert!(scan.conflicts.is_empty());
}
#[test]
fn a_completed_sha1_is_actually_used_for_the_download_check() {
let (root, pack) = mrpack_without_sha1("online-import-ok");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
.with_file(DOWNLOAD, PACK_BYTES);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(
read_text(&target.join(PATH)),
String::from_utf8_lossy(PACK_BYTES)
);
assert!(report.missing.is_empty(), "{:?}", report.missing);
assert!(
report
.warnings
.iter()
.any(|note| note.message.contains("补全了 1 条远端条目的 sha1")),
"报告要说清补全过:{:?}",
report.warnings
);
}
#[test]
fn a_wrong_completed_sha1_fails_the_import_instead_of_installing() {
// 站点给了错的 sha1 ⇒ 下载校验必须拦住(这是"内容坏了",不是"暂时搞不到")。
let (root, pack) = mrpack_without_sha1("online-import-bad");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&"f".repeat(40)))
.with_file(DOWNLOAD, PACK_BYTES);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Failed, "{:?}", report.status);
assert_eq!(
report.error.as_ref().map(|error| error.code()),
Some("import_integrity"),
"{:?}",
report.error
);
assert!(!target.join(PATH).exists(), "失败的导入不许留文件");
}
+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;
+26 -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,8 @@ 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>,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。
@@ -63,6 +67,7 @@ impl StubFetcher {
Self {
files: BTreeMap::new(),
curse: BTreeMap::new(),
modrinth: BTreeMap::new(),
calls: Mutex::new(Vec::new()),
artifacts: false,
}
@@ -80,6 +85,12 @@ impl StubFetcher {
self
}
/// 挂一条「按 sha512 反查」的结果(M162 的补全路径)。
pub(crate) fn with_modrinth(mut self, sha512: &str, meta: ModrinthFileMeta) -> Self {
self.modrinth.insert(sha512.to_string(), meta);
self
}
/// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。
pub(crate) fn with_engine_artifacts(mut self) -> Self {
self.artifacts = true;
@@ -111,6 +122,20 @@ impl PackFetcher for StubFetcher {
})
}
fn modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
self.modrinth.get(sha512).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 sha512={sha512}"),
)
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
+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 and nothing is cached yet. Check your connection, then retry.",
"内容检索需要联网,本地还没有缓存。检查网络后重试。"
),
ContentRetry => ("Retry", "重试"),
ContentLoadMore => ("Load more", "加载更多"),
ContentNoMore => ("No more results", "没有更多了"),
@@ -63,6 +68,13 @@ macro_rules! content_entries {
ContentDetailUpdated => ("Updated {date}", "更新于 {date}"),
ContentDetailLoaders => ("Loaders: {list}", "加载器:{list}"),
ContentDetailVersions => ("Game versions: {list}", "游戏版本:{list}"),
ContentDetailFetching => ("Loading versions…", "正在取版本列表…"),
ContentDetailFetchFailed => (
"Could not load versions: {reason}",
"版本列表取不到:{reason}"
),
ContentDetailVersionCount => ("{count} versions", "{count} 个版本"),
ContentDetailVersionLine => ("{number} · {kind} · {date}", "{number}({kind})· {date}"),
ContentInstall => ("Install into instance", "装入实例"),
ContentTargetInstance => ("Target instance", "目标实例"),
ContentNoInstances => ("No instance to install into", "没有可装入的实例"),
+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 and nothing is cached yet. Check your connection, then retry.", "内容检索需要联网,本地还没有缓存。检查网络后重试。"),
("ContentRetry", "Retry", "重试"),
("ContentLoadMore", "Load more", "加载更多"),
("ContentNoMore", "No more results", "没有更多了"),
@@ -188,6 +190,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("ContentDetailUpdated", "Updated {date}", "更新于 {date}"),
("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"),
("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"),
("ContentDetailFetching", "Loading versions…", "正在取版本列表…"),
("ContentDetailFetchFailed", "Could not load versions: {reason}", "版本列表取不到:{reason}"),
("ContentDetailVersionCount", "{count} versions", "{count} 个版本"),
("ContentDetailVersionLine", "{number} · {kind} · {date}", "{number}({kind})· {date}"),
("ContentInstall", "Install into instance", "装入实例"),
("ContentTargetInstance", "Target instance", "目标实例"),
("ContentNoInstances", "No instance to install into", "没有可装入的实例"),
+46 -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,20 @@ 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,
/// 最近一次详情请求的失败
pub detail_failed: Option<ContentFailure>,
}
impl Default for ContentState {
@@ -351,11 +322,40 @@ impl Default for ContentState {
pending: false,
failed: None,
last_page_filled: false,
detail: None,
detail_pending: false,
detail_failed: None,
}
}
}
impl ContentState {
/// 进「详情请求在飞」态并清掉上一条(换选中项时调)。
pub fn request_detail(&mut self, project: &str) {
if self
.detail
.as_ref()
.is_some_and(|detail| detail.project != project)
{
self.detail = None;
}
self.detail_pending = true;
self.detail_failed = None;
}
/// 详情落地(迟到的结果(换了选中项)由调用方按 `project` 判)。
pub fn apply_detail(&mut self, detail: ContentDetail) {
self.detail_pending = false;
self.detail_failed = None;
self.detail = Some(Box::new(detail));
}
/// 详情失败。
pub fn fail_detail(&mut self, failure: ContentFailure) {
self.detail_pending = false;
self.detail_failed = Some(failure);
}
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
#[must_use]
pub fn accepts(&self, results: &ContentResults) -> bool {
+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,
}
}
}
+6 -2
View File
@@ -137,8 +137,10 @@ pub enum CoreEvent {
NoticesChanged(Vec<NoticeView>),
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
ContentResults(Box<ContentResults>),
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
ContentFailed(String),
/// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画
ContentFailed(Box<crate::dto::ContentFailure>),
/// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道
ContentDetail(Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>),
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
@@ -251,6 +253,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>),
+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};
+12 -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,19 @@ 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 result: Result<ContentDetail, ContentFailure> = sources::detail(&env, &request);
let _ = tx.send(HostTask::ContentDetail(Box::new(result)));
});
}
/// 后台「装进指定实例」。
pub fn spawn_install(
env: HostEnv,
+30 -14
View File
@@ -19,8 +19,8 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{download_verified, EngineArtifactFetcher};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::{DownloadRequest, ResourceKind};
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
@@ -133,12 +133,19 @@ fn run(
});
let dest = target_dir.join(&file.name);
let mut download = DownloadRequest::new(ResourceKind::ModFile, file.url, dest.clone());
if let Some(sha1) = file.sha1.as_deref().filter(|hex| !hex.is_empty()) {
download = download.with_sha1(sha1);
}
block_on(super::super::engine::engine(env).download(download))
.map_err(|error| format!("下载失败:{error}"))?;
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
let core_engine = super::super::engine::engine(env);
let fetcher = EngineArtifactFetcher::new(&core_engine);
block_on(download_verified(
&fetcher,
&file.url,
file.sha1.as_deref(),
file.sha512.as_deref(),
file.size,
&dest,
))
.map_err(|error| format!("下载失败:{error}"))?;
Ok(format!("{}({})", file.name, dest.display()))
}
@@ -165,11 +172,13 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Str
.map_err(|error| format!("隔离目录派生失败:{error}"))
}
/// 要下载的那个文件。
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
struct RemoteFile {
url: String,
name: String,
sha1: Option<String>,
sha512: Option<String>,
size: Option<u64>,
}
/// 解析「这个项目下最适合目标实例的那个文件」。
@@ -223,15 +232,22 @@ fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<Remot
.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);
let digest = |algorithm: &str| {
file.get("hashes")
.and_then(|hashes| hashes.get(algorithm))
.and_then(serde_json::Value::as_str)
.filter(|hex| !hex.trim().is_empty())
.map(ToString::to_string)
};
Ok(RemoteFile {
url: url.to_string(),
name: safe_file_name(name),
sha1,
sha1: digest("sha1"),
sha512: digest("sha512"),
size: file
.get("size")
.and_then(serde_json::Value::as_u64)
.filter(|size| *size > 0),
})
}
@@ -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;
}
+172 -95
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,17 +22,26 @@
//! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) |
//!
//! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**,
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(`sources/tests` 钉住了这一条)。
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(core 那一侧的
//! `known_loaders` 已经过滤,`sources/tests` 钉住了这一条)。
use serde_json::Value;
use crate::dto::{
loader_facet, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource,
use unlucky_core::addon::online::{
ContentType, Modrinth, OnlineError, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery,
};
use crate::dto::{
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
ContentHit, ContentQuery, ContentResults, ContentSource, ContentVersionView,
};
use super::super::engine;
use super::super::env::HostEnv;
use super::curseforge_ready;
use super::http::{encode, get};
use unlucky_core::addon::online::EngineTransport;
use unlucky_core::download::blocking::block_on;
/// Modrinth v2 的根(无需 key)。
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
@@ -31,82 +51,145 @@ pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
const CF_MINECRAFT: u32 = 432;
/// 检索分派。
pub(super) fn search(env: &HostEnv, query: &ContentQuery) -> Result<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
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 —— 导入 / 预检 / 确认写盘 / 关面板
+5 -1
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(result) => {
self.send(CoreEvent::ContentDetail(result));
}
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
HostTask::Pack { event, epoch } => {
// pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让
+6 -2
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,10 @@ 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)
ContentDetail(Box<Result<ContentDetail, ContentFailure>>),
// M113: import/export
/// 导入/导出(M113):一条事件 + 任务代次
///
+7 -2
View File
@@ -296,10 +296,15 @@ impl AppState {
// M113: import/export
CoreEvent::Pack(event) => self.pack.apply(*event),
CoreEvent::ContentResults(results) => self.apply_content(*results),
CoreEvent::ContentFailed(reason) => {
CoreEvent::ContentFailed(failure) => {
self.content.pending = false;
self.content.failed = Some(reason);
self.content.failed = Some(*failure);
}
// M162:详情/版本列表(选中项才拉;迟到结果按项目 id 丢)
CoreEvent::ContentDetail(result) => match *result {
Ok(detail) => self.content.apply_detail(detail),
Err(failure) => self.content.fail_detail(failure),
},
}
}
+72 -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,73 @@ fn a_stale_batch_does_not_touch_the_page_flag() {
assert!(!state.content.last_page_filled);
assert!(state.content.results.is_none(), "迟到的一批被丢掉");
}
// ------------------------------------------------ M162:失败大类与详情通道
/// 事件 → 状态:`ContentFailed` 带大类,没联网时 `is_offline()` 为真(页面据此画离线态)。
#[test]
fn a_content_failure_keeps_its_kind() {
let mut state = AppState::new();
state.set_content_query(ContentQuery::first(ContentKind::Mod));
state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::offline(
"connect timed out",
))));
let failure = state.content.failed.as_ref().expect("要记住失败");
assert!(failure.is_offline(), "没联网必须能被页面认出来");
assert!(!state.content.pending);
state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::new(
ContentFailureKind::Server,
"HTTP 503",
))));
let failure = state.content.failed.as_ref().expect("要记住失败");
assert!(!failure.is_offline(), "站点 5xx 不是「没联网」");
}
/// 详情通道:请求态 → 结果态;换项目时旧详情先清掉。
#[test]
fn the_detail_channel_replaces_the_previous_project() {
let mut state = AppState::new();
state.content.request_detail("p1");
assert!(state.content.detail_pending);
state.apply_core(CoreEvent::ContentDetail(Box::new(Ok(detail("p1")))));
assert!(!state.content.detail_pending);
assert_eq!(
state.content.detail.as_ref().map(|d| d.project.as_str()),
Some("p1")
);
state.content.request_detail("p2");
assert!(
state.content.detail.is_none(),
"换了项目就不能留着上一个的版本列表"
);
state.apply_core(CoreEvent::ContentDetail(Box::new(Err(
ContentFailure::offline("connect timed out"),
))));
assert!(state
.content
.detail_failed
.as_ref()
.is_some_and(ContentFailure::is_offline));
}
fn detail(project: &str) -> ContentDetail {
ContentDetail {
project: project.to_string(),
title: "Sodium".to_string(),
description: "fast".to_string(),
body: String::new(),
versions: vec![ContentVersionView {
id: "v1".to_string(),
name: "Sodium 0.6.0".to_string(),
number: "mc1.20.1-0.6.0".to_string(),
kind: "release".to_string(),
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
published: "2026-02-01T00:00:00Z".to_string(),
file_name: Some("sodium.jar".to_string()),
file_size: Some(2048),
}],
}
}