Files
Unlucky-Launcher/crates/core/src/addon/online/modrinth.rs
T
AskaEth 81278f55af 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 各补失败分类与详情状态机用例。
2026-11-07 17:28:36 +08:00

232 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
}