P1-1(p1-1items 的根因): - download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与 `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有 Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」 取最可诊断的那个(**不从 suppressed 文本猜状态码**)。 - download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。 - online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是 NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。 - import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开; `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。 - import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN, Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此 只会在真断网时出现。 P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用 `core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`; `HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`, `InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。 P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回 `content_install_request`,详情方法只留自己那一行。 P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的 项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到 结果清掉。 顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过; 两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的 ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。 用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条 (AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、 单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」 (共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
388 lines
14 KiB
Rust
388 lines
14 KiB
Rust
//! 内容库 DTO(M109 `CONTENTDL`):Modrinth / CurseForge 上「整合包 / Mod / 资源包 / 光影」的
|
||
//! **检索**与**装入指定实例**。
|
||
//!
|
||
//! ## 为什么这些类型在 presenter 而不是 core
|
||
//!
|
||
//! D3 / 01c §5:UI 只经 `crate::dto` 消费后端,跨边界类型必须可序列化。而**检索本身**
|
||
//! (HTTP + 两个站点的 JSON 方言)今天落在 `crate::host::addon`:`crates/core` 的 `addon` 模块
|
||
//! 只有加载器安装与整合包**格式**解析,没有 CF / Modrinth 的 API 客户端(M111 才做导入管线)。
|
||
//! 所以这是「宿主层临时落点」的契约面——长期应下沉到 `core::addon`,届时这里只留薄 DTO。
|
||
//!
|
||
//! 两个形状是**故意**的:
|
||
//!
|
||
//! - [`ContentHit::game_versions`] / [`ContentHit::loaders`] 来自检索结果的 facets ⇒ 页面在用户
|
||
//! **点之前**就能判「装不装得上」(任务书第 3 条),不用点完才报错;
|
||
//! - [`ContentKind::dir`] 的 `None`(整合包)是「按钮禁用」的**唯一**判据,不是一个文案开关。
|
||
//!
|
||
//! ## 密钥纪律(任务书第 5 条)
|
||
//!
|
||
//! CurseForge 的 `X-API-KEY` 从环境变量 `ULL_CF_API_KEY` 读,**不进仓库、不进 DTO、不进日志**。
|
||
//! 这里只有 [`ContentResults::curseforge`] 一个 `bool`:源可用与否。
|
||
//! 于是「密钥泄漏」在类型层就不可能——本 crate 没有任何字段能装下它。
|
||
|
||
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 {
|
||
/// 整合包(Modpack)
|
||
Modpack,
|
||
/// Mod
|
||
Mod,
|
||
/// 资源包(Resource pack)
|
||
Resourcepack,
|
||
/// 光影(Shader pack)
|
||
Shader,
|
||
}
|
||
|
||
impl ContentKind {
|
||
/// 四条侧栏的声明顺序(= 画出来的顺序)。
|
||
pub const ALL: [ContentKind; 4] = [
|
||
ContentKind::Modpack,
|
||
ContentKind::Mod,
|
||
ContentKind::Resourcepack,
|
||
ContentKind::Shader,
|
||
];
|
||
|
||
/// 稳定 slug(日志 / 文件名 / 命令行参数用,**不要改名**)。
|
||
#[must_use]
|
||
pub const fn slug(self) -> &'static str {
|
||
match self {
|
||
Self::Modpack => "modpack",
|
||
Self::Mod => "mod",
|
||
Self::Resourcepack => "resourcepack",
|
||
Self::Shader => "shader",
|
||
}
|
||
}
|
||
|
||
/// Modrinth 的 `project_type` facet 值。
|
||
#[must_use]
|
||
pub const fn modrinth_type(self) -> &'static str {
|
||
match self {
|
||
Self::Modpack => "modpack",
|
||
Self::Mod => "mod",
|
||
Self::Resourcepack => "resourcepack",
|
||
Self::Shader => "shader",
|
||
}
|
||
}
|
||
|
||
/// 装入实例时的落盘目录(相对运行目录);`None` = 不是「一个文件进一个目录」这种语义。
|
||
///
|
||
/// 整合包没有目录:它要走 core 的导入管线(解压 + 补全 + 登记实例),那是 M111 的活,
|
||
/// 所以这里是 `None`——**按钮禁用由这个 `None` 驱动**,不是靠一句文案。
|
||
#[must_use]
|
||
pub const fn dir(self) -> Option<&'static str> {
|
||
match self {
|
||
Self::Modpack => None,
|
||
Self::Mod => Some("mods"),
|
||
Self::Resourcepack => Some("resourcepacks"),
|
||
Self::Shader => Some("shaderpacks"),
|
||
}
|
||
}
|
||
|
||
/// CurseForge 的 `classId`(CF API 的类目字段;数值是官方文档里的固定值)。
|
||
#[must_use]
|
||
pub const fn curseforge_class(self) -> u32 {
|
||
match self {
|
||
Self::Modpack => 4471,
|
||
Self::Mod => 6,
|
||
Self::Resourcepack => 12,
|
||
Self::Shader => 6552,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 内容来源。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub enum ContentSource {
|
||
/// Modrinth(无需 API key,默认源)
|
||
Modrinth,
|
||
/// CurseForge(需要 `ULL_CF_API_KEY`)
|
||
Curseforge,
|
||
}
|
||
|
||
impl ContentSource {
|
||
/// 两个来源的声明顺序(= 筛选条顺序)。
|
||
pub const ALL: [ContentSource; 2] = [ContentSource::Modrinth, ContentSource::Curseforge];
|
||
|
||
/// 稳定 slug。
|
||
#[must_use]
|
||
pub const fn slug(self) -> &'static str {
|
||
match self {
|
||
Self::Modrinth => "modrinth",
|
||
Self::Curseforge => "curseforge",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 加载器 → Modrinth 的 loader facet 值;`None` = 该加载器没有对应 facet(原版等)。
|
||
#[must_use]
|
||
pub const fn loader_facet(loader: LoaderKind) -> Option<&'static str> {
|
||
match loader {
|
||
LoaderKind::Fabric => Some("fabric"),
|
||
LoaderKind::Quilt => Some("quilt"),
|
||
LoaderKind::Forge => Some("forge"),
|
||
LoaderKind::NeoForge => Some("neoforge"),
|
||
LoaderKind::Vanilla | LoaderKind::OptiFine | LoaderKind::LiteLoader => None,
|
||
}
|
||
}
|
||
|
||
/// 一次检索请求(**页面把它整份发出去**,不做本地过滤之外的乐观处理)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct ContentQuery {
|
||
/// 内容类型
|
||
pub kind: ContentKind,
|
||
/// 来源
|
||
pub source: ContentSource,
|
||
/// 关键词(空串 = 不带 `query` 参数,即「按热度浏览」)
|
||
pub text: String,
|
||
/// 游戏版本过滤(`None` = 不过滤)
|
||
pub game_version: Option<String>,
|
||
/// 加载器过滤(`None` = 不过滤;对资源包 / 光影也照发——静默丢掉会让筛选条撒谎)
|
||
pub loader: Option<LoaderKind>,
|
||
/// 排序
|
||
pub sort: ContentSort,
|
||
/// 偏移(分页游标)
|
||
pub offset: u32,
|
||
/// 每页条数
|
||
pub limit: u32,
|
||
}
|
||
|
||
impl ContentQuery {
|
||
/// 每页条数(20 = Modrinth 默认上限的一半,翻页手感与请求量折中)。
|
||
pub const PAGE: u32 = 20;
|
||
|
||
/// 某个类型的首页(来源 Modrinth、无关键词、无过滤、按相关度)。
|
||
#[must_use]
|
||
pub fn first(kind: ContentKind) -> Self {
|
||
Self {
|
||
kind,
|
||
source: ContentSource::Modrinth,
|
||
text: String::new(),
|
||
game_version: None,
|
||
loader: None,
|
||
sort: ContentSort::Relevance,
|
||
offset: 0,
|
||
limit: Self::PAGE,
|
||
}
|
||
}
|
||
|
||
/// 换一批(保留全部筛选条件,只把游标往后挪一页)。
|
||
#[must_use]
|
||
pub fn next_page(&self) -> Self {
|
||
Self {
|
||
offset: self.offset.saturating_add(self.limit),
|
||
..self.clone()
|
||
}
|
||
}
|
||
|
||
/// 筛选条件是否与另一份相同(**不含游标**:判「结果是不是同一批条件拉回来的」用它)。
|
||
#[must_use]
|
||
pub fn same_filters(&self, other: &Self) -> bool {
|
||
self.kind == other.kind
|
||
&& self.source == other.source
|
||
&& self.text == other.text
|
||
&& self.game_version == other.game_version
|
||
&& self.loader == other.loader
|
||
&& self.sort == other.sort
|
||
}
|
||
}
|
||
|
||
/// 一条检索结果(一个项目,不是文件)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct ContentHit {
|
||
/// 项目 id(Modrinth 的 base62 id / CF 的 modId;装入指令的载荷)
|
||
pub id: String,
|
||
/// 人读 slug(`sodium`;链接与日志里更可读)
|
||
pub slug: String,
|
||
/// 标题
|
||
pub title: String,
|
||
/// 一句话说明(站点原文,不翻译:它是用户内容,不是界面文案)
|
||
pub summary: String,
|
||
/// 作者(Modrinth 只有团队 id 时给空串)
|
||
pub author: String,
|
||
/// 下载量
|
||
pub downloads: u64,
|
||
/// 最近更新时间(ISO-8601 的日期部分;未知给空串)
|
||
pub updated: String,
|
||
/// 来源
|
||
pub source: ContentSource,
|
||
/// 支持的游戏版本(升序由站点给;页面只做「包不包含」判断)
|
||
pub game_versions: Vec<String>,
|
||
/// 支持的加载器(`fabric` / `forge` / `neoforge` / `quilt`)
|
||
pub loaders: Vec<String>,
|
||
/// 图标 URL(`None` = 没有;页面不拉图,只把主机名画成占位标记)
|
||
pub icon: Option<String>,
|
||
}
|
||
|
||
impl ContentHit {
|
||
/// 这个项目支持某个游戏版本吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
|
||
#[must_use]
|
||
pub fn supports_version(&self, version: &str) -> bool {
|
||
self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version)
|
||
}
|
||
|
||
/// 这个项目支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
|
||
#[must_use]
|
||
pub fn supports_loader(&self, loader: LoaderKind) -> bool {
|
||
let Some(facet) = loader_facet(loader) else {
|
||
return true;
|
||
};
|
||
self.loaders.is_empty() || self.loaders.iter().any(|item| item == facet)
|
||
}
|
||
}
|
||
|
||
/// 一批检索结果。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct ContentResults {
|
||
/// 这份结果对应的请求(页面据此判断「这批还算不算当前条件」)
|
||
pub query: ContentQuery,
|
||
/// 命中项(顺序 = 站点顺序)
|
||
pub hits: Vec<ContentHit>,
|
||
/// 命中总数(站点给的分页总数;`0` = 站点没给)
|
||
pub total: u32,
|
||
/// CurseForge 源此刻是否可用(`false` = 没有 `ULL_CF_API_KEY`)
|
||
pub curseforge: bool,
|
||
}
|
||
|
||
impl ContentResults {
|
||
/// 还有下一页吗。
|
||
///
|
||
/// 判据是「本页装满了」而不是 `offset + len < total`:站点给的 `total` 有时是估算值,
|
||
/// 用「装满」判定最多多请求一次空页,不会出现「明明还有却翻不到」。
|
||
#[must_use]
|
||
pub fn has_more(&self) -> bool {
|
||
self.hits.len() as u32 >= self.query.limit
|
||
}
|
||
}
|
||
|
||
/// 「装入指定实例」的请求(UI 侧只发意图;选哪个文件由 host 按版本 + 加载器选)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct ContentInstallRequest {
|
||
/// 项目 id
|
||
pub project: String,
|
||
/// 项目标题(进度面板上显示的那一行;**不是** i18n 文案)
|
||
pub title: String,
|
||
/// 内容类型(决定落盘目录与「能不能装」)
|
||
pub kind: ContentKind,
|
||
/// 来源
|
||
pub source: ContentSource,
|
||
/// 目标实例
|
||
pub instance: InstanceId,
|
||
/// 目标实例的游戏版本(选文件用)
|
||
pub game_version: String,
|
||
/// 目标实例的加载器(选文件用)
|
||
pub loader: LoaderKind,
|
||
}
|
||
|
||
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
|
||
///
|
||
/// 条件为什么也存在这里:页面层不持有业务状态是老纪律(同 `download_query`);换页只挪游标、
|
||
/// 条件只有一份,才不会出现「筛选条说的是 A、列表画的是 B」。
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct ContentState {
|
||
/// 当前检索条件(游标也在里面:换页就是换一份 `query`)
|
||
pub query: ContentQuery,
|
||
/// 最近一批结果(`None` = 还没拉过)
|
||
pub results: Option<ContentResults>,
|
||
/// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显)
|
||
pub pending: bool,
|
||
/// 最近一次失败(`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,
|
||
/// 在飞的那次详情请求的项目 id(`None` = 没有在飞)
|
||
pub detail_requested: Option<String>,
|
||
/// 最近一次详情请求的失败
|
||
pub detail_failed: Option<ContentFailure>,
|
||
}
|
||
|
||
impl Default for ContentState {
|
||
fn default() -> Self {
|
||
Self {
|
||
query: ContentQuery::first(ContentKind::Mod),
|
||
results: None,
|
||
pending: false,
|
||
failed: None,
|
||
last_page_filled: false,
|
||
detail: None,
|
||
detail_pending: false,
|
||
detail_requested: None,
|
||
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_requested = Some(project.to_string());
|
||
self.detail_failed = None;
|
||
}
|
||
|
||
/// 详情落地:**只接受当前这次请求的结果**(迟到的、别的项目的一律丢掉)。
|
||
///
|
||
/// 场景:选中 A(请求发出)→ 立刻切到 B(又发一条)→ A 的结果后到。没有这道闸门的话,
|
||
/// A 的版本列表会画在 B 的详情面板下,A 的失败也会被当成本次失败(r1 P2-3)。
|
||
pub fn apply_detail(&mut self, project: &str, detail: ContentDetail) {
|
||
if !self.accepts_detail(project) || detail.project != project {
|
||
return;
|
||
}
|
||
self.detail_pending = false;
|
||
self.detail_requested = None;
|
||
self.detail_failed = None;
|
||
self.detail = Some(Box::new(detail));
|
||
}
|
||
|
||
/// 详情失败:同样只看当前这次请求。
|
||
pub fn fail_detail(&mut self, project: &str, failure: ContentFailure) {
|
||
if !self.accepts_detail(project) {
|
||
return;
|
||
}
|
||
self.detail_pending = false;
|
||
self.detail_requested = None;
|
||
self.detail_failed = Some(failure);
|
||
}
|
||
|
||
/// 这条详情结果还算当前这次吗(`false` = 迟到 / 用户已经换了选中项)。
|
||
#[must_use]
|
||
pub fn accepts_detail(&self, project: &str) -> bool {
|
||
self.detail_requested.as_deref() == Some(project)
|
||
}
|
||
|
||
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
|
||
#[must_use]
|
||
pub fn accepts(&self, results: &ContentResults) -> bool {
|
||
results.query.same_filters(&self.query)
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests;
|