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),
})
}