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
@@ -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