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:
1 parent
0241f793b2
commit
81278f55af
50 files changed
+3794
-222
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` = 要用户点头的冲突。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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(), "失败的导入不许留文件");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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", "没有可装入的实例"),
|
||||
|
||||
@@ -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", "没有可装入的实例"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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 —— 导入 / 预检 / 确认写盘 / 关面板
|
||||
|
||||
@@ -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):共用安装的计数器会让
|
||||
|
||||
@@ -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):一条事件 + 任务代次
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}],
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user