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

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

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

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

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

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

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

No files matched your search

+30 -14
View File
@@ -19,8 +19,8 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{download_verified, EngineArtifactFetcher};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::{DownloadRequest, ResourceKind};
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
@@ -133,12 +133,19 @@ fn run(
});
let dest = target_dir.join(&file.name);
let mut download = DownloadRequest::new(ResourceKind::ModFile, file.url, dest.clone());
if let Some(sha1) = file.sha1.as_deref().filter(|hex| !hex.is_empty()) {
download = download.with_sha1(sha1);
}
block_on(super::super::engine::engine(env).download(download))
.map_err(|error| format!("下载失败:{error}"))?;
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
let core_engine = super::super::engine::engine(env);
let fetcher = EngineArtifactFetcher::new(&core_engine);
block_on(download_verified(
&fetcher,
&file.url,
file.sha1.as_deref(),
file.sha512.as_deref(),
file.size,
&dest,
))
.map_err(|error| format!("下载失败:{error}"))?;
Ok(format!("{}({})", file.name, dest.display()))
}
@@ -165,11 +172,13 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Str
.map_err(|error| format!("隔离目录派生失败:{error}"))
}
/// 要下载的那个文件。
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
struct RemoteFile {
url: String,
name: String,
sha1: Option<String>,
sha512: Option<String>,
size: Option<u64>,
}
/// 解析「这个项目下最适合目标实例的那个文件」。
@@ -223,15 +232,22 @@ fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<Remot
.get("filename")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "文件没有 filename".to_string())?;
let sha1 = file
.get("hashes")
.and_then(|hashes| hashes.get("sha1"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
let digest = |algorithm: &str| {
file.get("hashes")
.and_then(|hashes| hashes.get(algorithm))
.and_then(serde_json::Value::as_str)
.filter(|hex| !hex.trim().is_empty())
.map(ToString::to_string)
};
Ok(RemoteFile {
url: url.to_string(),
name: safe_file_name(name),
sha1,
sha1: digest("sha1"),
sha512: digest("sha512"),
size: file
.get("size")
.and_then(serde_json::Value::as_u64)
.filter(|size| *size > 0),
})
}
@@ -14,7 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::import::{
import_pack, scan_pack, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
import_pack, scan_pack_online, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
ImportRequest,
};
use unlucky_core::download::blocking::block_on;
@@ -76,9 +76,12 @@ pub(super) fn spawn_scan(
return;
}
};
let core_request = ImportRequest::new(&request.pack, &target);
// 预检是纯读的同步函数(不写盘、不联网),所以直接调用,不必占一个 async 任务。
let scan = scan_pack(&core_request);
// 预检本身只读(不写盘),但 M162 起会**先补全再判**:缺 `hashes.sha1` 的 mrpack 条目
// 按 sha512 反查 Modrinth。补不出仍按 M160 的口径计数,所以冷启动没网也不影响判定。
let engine = super::super::super::engine::engine(&env);
let fetcher = DownloadEngineFetcher::new(&engine);
let core_request = ImportRequest::new(&request.pack, &target).with_fetcher(&fetcher);
let scan = block_on(scan_pack_online(&core_request));
if cancel.load(Ordering::SeqCst) {
return;
}
+172 -95
View File
@@ -1,6 +1,17 @@
//! 两个站点的检索方言(M109):把各自的 JSON 翻成 [`ContentHit`]。
//! 两个站点的检索方言(M109;M162 起 Modrinth 走 `core::addon::online`)+ **详情/版本列表**。
//!
//! 两个站点在**同一件事上用的字段名完全不同**,所以这里每个站点一段、各自带注释:
//! # M162 之后的分工
//!
//! | 站点 | 检索 | 详情 / 版本列表 | 端点拼装与 JSON 解析在哪 |
//! |------|------|------------------|--------------------------|
//! | Modrinth | `core::addon::online` | `core::addon::online` | core(`online::modrinth` / `online::model`) |
//! | CurseForge | 本文件(要 `X-API-KEY`) | **未接通**(明确报错) | 本文件 |
//!
//! 为什么 Modrinth 搬走:「在线内容源」是 core 的能力(导出的 `RemoteCatalog`、导入的
//! `PackFetcher`、UI 的检索要共用同一份解析与同一套错误分类)。presenter 只留 DTO 映射与
//! 失败分类——它不再自己拼 URL、不再自己读 JSON 字段名。
//!
//! # 两个站点的字段名完全不同(CF 这一半仍是本文件的活)
//!
//! | 语义 | Modrinth `/v2/search` | CurseForge `/v1/mods/search` |
//! |---|---|---|
@@ -11,17 +22,26 @@
//! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) |
//!
//! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**,
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(`sources/tests` 钉住了这一条)。
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(core 那一侧的
//! `known_loaders` 已经过滤,`sources/tests` 钉住了这一条)。
use serde_json::Value;
use crate::dto::{
loader_facet, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource,
use unlucky_core::addon::online::{
ContentType, Modrinth, OnlineError, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery,
};
use crate::dto::{
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
ContentHit, ContentQuery, ContentResults, ContentSource, ContentVersionView,
};
use super::super::engine;
use super::super::env::HostEnv;
use super::curseforge_ready;
use super::http::{encode, get};
use unlucky_core::addon::online::EngineTransport;
use unlucky_core::download::blocking::block_on;
/// Modrinth v2 的根(无需 key)。
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
@@ -31,82 +51,145 @@ pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
const CF_MINECRAFT: u32 = 432;
/// 检索分派。
pub(super) fn search(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
pub(super) fn search(
env: &HostEnv,
query: &ContentQuery,
) -> Result<ContentResults, ContentFailure> {
match query.source {
ContentSource::Modrinth => modrinth(env, query),
ContentSource::Curseforge => curseforge(env, query),
}
}
/// 详情 + 版本列表分派(M162)。
pub(super) fn detail(
env: &HostEnv,
request: &ContentDetailRequest,
) -> Result<ContentDetail, ContentFailure> {
match request.source {
ContentSource::Modrinth => modrinth_detail(env, request),
ContentSource::Curseforge => Err(ContentFailure::unsupported(
"CurseForge 的详情/版本列表未接通(要 X-API-KEY 的文件端点);先切到 Modrinth 源",
)),
}
}
// --------------------------------------------------------------- Modrinth
fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
let mut facets = vec![format!("[\"project_type:{}\"]", query.kind.modrinth_type())];
if let Some(version) = &query.game_version {
facets.push(format!("[\"versions:{version}\"]"));
}
if let Some(facet) = query.loader.and_then(loader_facet) {
facets.push(format!("[\"categories:{facet}\"]"));
}
let mut url = format!(
"{MODRINTH}/search?limit={}&offset={}&index={}&facets={}",
query.limit.min(100),
query.offset,
query.sort.modrinth_index(),
encode(&format!("[{}]", facets.join(",")))
);
if !query.text.trim().is_empty() {
url.push_str(&format!("&query={}", encode(query.text.trim())));
}
let body = get(env, &url, &[])?;
let json: Value = serde_json::from_str(&body)
.map_err(|error| format!("Modrinth 返回的不是合法 JSON:{error}"))?;
let hits = json
.get("hits")
.and_then(Value::as_array)
.ok_or_else(|| "Modrinth 返回里没有 hits 字段".to_string())?
.iter()
.map(modrinth_hit)
.collect::<Vec<_>>();
fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, ContentFailure> {
let core_engine = engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let page = block_on(client.search(&search_query(query))).map_err(failure_of)?;
Ok(ContentResults {
query: query.clone(),
hits,
total: u32_of(&json, "total_hits"),
hits: page.hits.iter().map(hit_of).collect(),
total: u32::try_from(page.total_hits).unwrap_or(0),
curseforge: curseforge_ready(),
})
}
/// 一条 Modrinth 命中项 → DTO(缺字段一律给空/零,**不**编造)。
fn modrinth_hit(value: &Value) -> ContentHit {
let loaders = strings(value, "categories")
.into_iter()
// `categories` 混着加载器与分类标签:只留已知的四个加载器
.filter(|tag| matches!(tag.as_str(), "fabric" | "forge" | "neoforge" | "quilt"))
.collect();
fn modrinth_detail(
env: &HostEnv,
request: &ContentDetailRequest,
) -> Result<ContentDetail, ContentFailure> {
let core_engine = engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let project = block_on(client.project(&request.project)).map_err(failure_of)?;
let versions = block_on(client.versions(&VersionQuery {
project: request.project.clone(),
game_version: request.game_version.clone(),
loader: request.loader.and_then(loader_facet).map(str::to_string),
}))
.map_err(failure_of)?;
Ok(ContentDetail {
project: if project.id.is_empty() {
request.project.clone()
} else {
project.id
},
title: project.title,
description: project.description,
body: project.body,
versions: versions.iter().map(version_of).collect(),
})
}
ContentHit {
id: text(value, "project_id"),
slug: text(value, "slug"),
title: text(value, "title"),
summary: text(value, "description"),
author: text(value, "author"),
downloads: u64_of(value, "downloads"),
updated: date_part(&text(value, "updated")),
source: ContentSource::Modrinth,
game_versions: strings(value, "versions"),
loaders,
icon: optional_text(value, "icon_url"),
/// DTO 检索条件 → core 的检索请求(**唯一**一处字段搬运)。
fn search_query(query: &ContentQuery) -> SearchQuery {
SearchQuery {
text: query.text.clone(),
project_type: ContentType::parse(query.kind.modrinth_type()),
game_version: query.game_version.clone(),
loader: query.loader.and_then(loader_facet).map(str::to_string),
index: match query.sort {
crate::dto::ContentSort::Relevance => SortIndex::Relevance,
crate::dto::ContentSort::Downloads => SortIndex::Downloads,
crate::dto::ContentSort::Newest => SortIndex::Newest,
crate::dto::ContentSort::Updated => SortIndex::Updated,
},
limit: query.limit,
offset: query.offset,
}
}
/// core 的命中项 → DTO(缺字段一律给空/零,**不**编造)。
fn hit_of(hit: &unlucky_core::addon::online::ProjectHit) -> ContentHit {
ContentHit {
id: hit.project_id.clone(),
slug: hit.slug.clone(),
title: hit.title.clone(),
summary: hit.description.clone(),
author: hit.author.clone(),
downloads: hit.downloads,
updated: date_part(&hit.updated),
source: ContentSource::Modrinth,
game_versions: hit.game_versions.clone(),
loaders: hit.loaders.clone(),
icon: hit.icon_url.clone(),
}
}
/// core 的版本 → DTO 的版本行(文件名/大小取 `primary` 那个)。
fn version_of(version: &unlucky_core::addon::online::Version) -> ContentVersionView {
let file = version.primary_file();
ContentVersionView {
id: version.id.clone(),
name: version.name.clone(),
number: version.version_number.clone(),
kind: version.version_type.as_str().to_string(),
game_versions: version.game_versions.clone(),
loaders: version.loaders.clone(),
published: date_part(&version.date_published),
file_name: file
.map(|file| file.filename.clone())
.filter(|name| !name.trim().is_empty()),
file_size: file.map(|file| file.size).filter(|size| *size > 0),
}
}
/// core 的错误 → DTO 的失败大类(**一一映射,不重新判**)。
pub(super) fn failure_of(error: OnlineError) -> ContentFailure {
let kind = match error.kind() {
OnlineErrorKind::NotConnected => ContentFailureKind::Offline,
OnlineErrorKind::NotFound => ContentFailureKind::NotFound,
OnlineErrorKind::Server => ContentFailureKind::Server,
OnlineErrorKind::Unsupported => ContentFailureKind::Unsupported,
OnlineErrorKind::Malformed | OnlineErrorKind::Io | OnlineErrorKind::Integrity => {
ContentFailureKind::Other
}
};
ContentFailure::new(kind, error.message().to_string())
}
// --------------------------------------------------------------- CurseForge
fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, ContentFailure> {
let Some(key) = super::curseforge_key() else {
return Err("CurseForge 需要 API key(ULL_CF_API_KEY 未设置)".to_string());
return Err(ContentFailure::unsupported(
"CurseForge 需要 API key(ULL_CF_API_KEY 未设置)",
));
};
let mut url = format!(
"{CURSEFORGE}/mods/search?gameId={CF_MINECRAFT}&classId={}&index={}&pageSize={}&sortField={}&sortOrder=desc",
@@ -122,13 +205,19 @@ fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, Str
url.push_str(&format!("&gameVersion={}", encode(version)));
}
let body = get(env, &url, &[("x-api-key", key.as_str())])?;
let json: Value = serde_json::from_str(&body)
.map_err(|error| format!("CurseForge 返回的不是合法 JSON:{error}"))?;
let body = get(env, &url, &[("x-api-key", key.as_str())]).map_err(cf_failure)?;
let json: Value = serde_json::from_str(&body).map_err(|error| {
ContentFailure::new(
ContentFailureKind::Other,
format!("CurseForge 返回的不是合法 JSON:{error}"),
)
})?;
let hits = json
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| "CurseForge 返回里没有 data 字段".to_string())?
.ok_or_else(|| {
ContentFailure::new(ContentFailureKind::Other, "CurseForge 返回里没有 data 字段")
})?
.iter()
.map(curseforge_hit)
.collect::<Vec<_>>();
@@ -145,6 +234,21 @@ fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, Str
})
}
/// CurseForge 的字符串错误 → 失败大类:只有「缺密钥」是 Unsupported,其余按站点/其它。
fn cf_failure(reason: String) -> ContentFailure {
let kind = if reason.contains("ULL_CF_API_KEY") {
ContentFailureKind::Unsupported
} else if reason.contains("HTTP 503")
|| reason.contains("HTTP 502")
|| reason.contains("HTTP 500")
{
ContentFailureKind::Server
} else {
ContentFailureKind::Other
};
ContentFailure::new(kind, reason)
}
/// 一条 CF 命中项 → DTO。加载器只能从 `latestFilesIndexes[].modLoader` 的数字枚举反推。
fn curseforge_hit(value: &Value) -> ContentHit {
let mut game_versions: Vec<String> = Vec::new();
@@ -219,44 +323,17 @@ fn text(value: &Value, key: &str) -> String {
.to_string()
}
/// 取可选字符串字段(空串也当没有——站点会给 `""` 表示没图)。
fn optional_text(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToString::to_string)
}
/// 取字符串数组(缺 / 类型不对 ⇒ 空 vec)。
fn strings(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_default()
}
fn u64_of(value: &Value, key: &str) -> u64 {
value.get(key).and_then(Value::as_u64).unwrap_or(0)
}
fn u32_of(value: &Value, key: &str) -> u32 {
u32::try_from(u64_of(value, key)).unwrap_or(0)
/// 四条侧栏的常量表没被改乱(检索本身按 `kind` 走 facet)。
#[must_use]
pub(super) fn all_kinds() -> [crate::dto::ContentKind; 4] {
crate::dto::ContentKind::ALL
}
/// ISO-8601 → 日期部分(`2024-05-01T…` → `2024-05-01`;短串原样返回)。
pub(super) fn date_part(raw: &str) -> String {
raw.get(..10).unwrap_or(raw).to_string()
}
/// 类型名只用来钉住「四条侧栏都在」的常量没被改乱(检索本身按 `kind` 走 facet)。
#[must_use]
pub(super) fn all_kinds() -> [ContentKind; 4] {
ContentKind::ALL
}
+34 -2
View File
@@ -4,8 +4,9 @@ use std::time::Duration;
use super::http::{encode, retry_after, retryable};
use super::install::safe_file_name;
use super::sources::{all_kinds, curseforge_loader, date_part};
use crate::dto::ContentKind;
use super::sources::{all_kinds, curseforge_loader, date_part, failure_of};
use crate::dto::{ContentFailureKind, ContentKind};
use unlucky_core::addon::online::{OnlineError, OnlineErrorKind};
#[test]
fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() {
@@ -83,3 +84,34 @@ fn the_four_content_kinds_are_the_four_side_entries() {
["modpack", "mod", "resourcepack", "shader"]
);
}
// ------------------------------------------------ M162:失败分类(core → DTO)
/// core 的 `OnlineErrorKind` → DTO 的 `ContentFailureKind`:**一一映射,不重新判**。
///
/// 「没联网」是单独一类:页面据此画离线态(说「检查网络」),而不是画「换个关键词」。
#[test]
fn core_errors_map_to_dto_failure_kinds() {
let cases = [
(OnlineErrorKind::NotConnected, ContentFailureKind::Offline),
(OnlineErrorKind::NotFound, ContentFailureKind::NotFound),
(OnlineErrorKind::Server, ContentFailureKind::Server),
(
OnlineErrorKind::Unsupported,
ContentFailureKind::Unsupported,
),
(OnlineErrorKind::Malformed, ContentFailureKind::Other),
(OnlineErrorKind::Io, ContentFailureKind::Other),
(OnlineErrorKind::Integrity, ContentFailureKind::Other),
];
for (core, expected) in cases {
let failure = failure_of(OnlineError::new(core, "x"));
assert_eq!(failure.kind, expected, "{} 映射错了", core.as_str());
}
assert!(failure_of(OnlineError::offline("connect timed out")).is_offline());
// 详情/版本列表的文案要带出 core 的原话(不是二次包装的「出错了」)
assert_eq!(
failure_of(OnlineError::offline("connect timed out")).detail,
"connect timed out"
);
}