feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)
用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。
- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
(Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
`ULL_CONTENT_STATE` 供出图矩阵选类型与状态
取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)
门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
This commit is contained in:
1 parent
5adac230fe
commit
6b161227d8
42 files changed
+4071
-25
No files matched your search
@@ -0,0 +1,359 @@
|
||||
//! 内容库 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};
|
||||
|
||||
/// 内容类型(内容库页左侧那四条一级侧栏)。
|
||||
#[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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。
|
||||
#[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> {
|
||||
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` = 没失败)
|
||||
pub failed: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ContentState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
results: None,
|
||||
pending: false,
|
||||
failed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentState {
|
||||
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
|
||||
#[must_use]
|
||||
pub fn accepts(&self, results: &ContentResults) -> bool {
|
||||
results.query.same_filters(&self.query)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 内容库 DTO 的守门测试(M109)。从 `dto/content.rs` 拆出:那个文件已贴 400 行硬上限。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn hit(game_versions: Vec<String>, loaders: Vec<String>) -> ContentHit {
|
||||
ContentHit {
|
||||
id: "1".to_string(),
|
||||
slug: "s".to_string(),
|
||||
title: "t".to_string(),
|
||||
summary: String::new(),
|
||||
author: String::new(),
|
||||
downloads: 0,
|
||||
updated: String::new(),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_kinds_have_install_directories() {
|
||||
// 抓得住:把整合包也塞一个目录(那等于把「解压+补全+登记实例」偷偷降级成「拷一个文件进 mods」,
|
||||
// 正是任务书第 3 条禁止的事)。
|
||||
assert_eq!(ContentKind::Modpack.dir(), None);
|
||||
assert_eq!(ContentKind::Mod.dir(), Some("mods"));
|
||||
assert_eq!(ContentKind::Resourcepack.dir(), Some("resourcepacks"));
|
||||
assert_eq!(ContentKind::Shader.dir(), Some("shaderpacks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_map_to_the_official_facets_and_class_ids() {
|
||||
assert_eq!(ContentKind::Modpack.modrinth_type(), "modpack");
|
||||
assert_eq!(ContentKind::Shader.modrinth_type(), "shader");
|
||||
// CF 的四个 classId 是官方固定值,抄错就搜不到东西(而且不会报错,只会空列表)
|
||||
assert_eq!(ContentKind::Mod.curseforge_class(), 6);
|
||||
assert_eq!(ContentKind::Resourcepack.curseforge_class(), 12);
|
||||
assert_eq!(ContentKind::Modpack.curseforge_class(), 4471);
|
||||
assert_eq!(ContentKind::Shader.curseforge_class(), 6552);
|
||||
for kind in ContentKind::ALL {
|
||||
assert!(!kind.slug().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_keeps_its_filters_and_only_moves_the_cursor() {
|
||||
let mut query = ContentQuery::first(ContentKind::Mod);
|
||||
query.game_version = Some("1.20.1".to_string());
|
||||
query.text = "sodium".to_string();
|
||||
|
||||
let next = query.next_page();
|
||||
assert_eq!(next.offset, ContentQuery::PAGE, "游标按一页走");
|
||||
assert!(next.same_filters(&query), "换页不改筛选条件");
|
||||
|
||||
let mut other = next.clone();
|
||||
other.text = "lithium".to_string();
|
||||
assert!(!other.same_filters(&query), "关键词变了就不是同一批条件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_checks_treat_missing_site_data_as_unknown_not_incompatible() {
|
||||
let empty = hit(Vec::new(), Vec::new());
|
||||
// 站点没给数据时不许拦人(否则「不知道」会被画成「不兼容」)
|
||||
assert!(empty.supports_version("1.20.1"));
|
||||
assert!(empty.supports_loader(LoaderKind::Fabric));
|
||||
|
||||
let filled = hit(vec!["1.20.1".to_string()], vec!["fabric".to_string()]);
|
||||
assert!(filled.supports_version("1.20.1"));
|
||||
assert!(!filled.supports_version("1.21.9"));
|
||||
assert!(filled.supports_loader(LoaderKind::Fabric));
|
||||
assert!(!filled.supports_loader(LoaderKind::Forge));
|
||||
// 原版没有 facet:不该被加载器过滤误伤
|
||||
assert!(filled.supports_loader(LoaderKind::Vanilla));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_more_is_driven_by_a_full_page() {
|
||||
let mut results = ContentResults {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
hits: Vec::new(),
|
||||
total: 999,
|
||||
curseforge: false,
|
||||
};
|
||||
assert!(!results.has_more(), "空页 = 没有更多了");
|
||||
results.hits = vec![hit(Vec::new(), Vec::new()); ContentQuery::PAGE as usize];
|
||||
assert!(results.has_more(), "装满一页 = 还有下一页");
|
||||
}
|
||||
@@ -9,9 +9,10 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::dialogs::{DialogId, DialogRequest};
|
||||
use crate::dto::CreateRequest;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
|
||||
InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession,
|
||||
NoticeView, SessionId, SettingGroupView, VersionCategory, VersionSummary,
|
||||
AccountView, AgentEvent, ContentInstallRequest, ContentQuery, ContentResults, DeviceLoginView,
|
||||
DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, InstanceSummary,
|
||||
JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, SessionId,
|
||||
SettingGroupView, VersionCategory, VersionSummary,
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
@@ -93,6 +94,10 @@ pub enum CoreEvent {
|
||||
SettingsChanged(Vec<SettingGroupView>),
|
||||
/// 横幅通知刷新(隔离切换提示/启动失败等)
|
||||
NoticesChanged(Vec<NoticeView>),
|
||||
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
|
||||
ContentResults(Box<ContentResults>),
|
||||
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
|
||||
ContentFailed(String),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
@@ -177,6 +182,10 @@ pub enum AppCommand {
|
||||
StopAgent,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
SetYolo(bool),
|
||||
/// 检索内容库(M109;结果经 [`CoreEvent::ContentResults`] 回来)
|
||||
SearchContent(ContentQuery),
|
||||
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
|
||||
InstallContent(Box<ContentInstallRequest>),
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
|
||||
//!
|
||||
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
|
||||
//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写
|
||||
//! settings / install / notices / content / events),全量 `pub use` 出去,调用方依旧写
|
||||
//! `crate::dto::InstanceSummary` 这类路径。
|
||||
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`;
|
||||
//! UI 侧的唯一入口与 iced 接线用法见 crate 文档(`src/lib.rs`)。
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
mod accounts;
|
||||
mod agent;
|
||||
mod content;
|
||||
mod events;
|
||||
mod install;
|
||||
mod instances;
|
||||
@@ -27,6 +28,10 @@ mod versions;
|
||||
|
||||
pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
|
||||
pub use content::{
|
||||
loader_facet, ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults,
|
||||
ContentSort, ContentSource, ContentState,
|
||||
};
|
||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||
pub use instances::{
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! 内容库的宿主实现(M109 `CONTENTDL`):**检索** + **装进指定实例**。
|
||||
//!
|
||||
//! ## 为什么这些代码在 presenter 而不是 core(临时落点,必须写清)
|
||||
//!
|
||||
//! `crates/core` 的 `addon` 模块今天只有两件事:加载器安装(`addon::loader`)与整合包**格式**
|
||||
//! 解析(`addon::pack`,M110)。它**没有** CurseForge / Modrinth 的 API 客户端,也没有
|
||||
//! 「远程整合包一键装进实例」的管线(M111/M112 才做)。而任务书的硬约束是「UI 层不许自己发
|
||||
//! HTTP,新增能力一律经 presenter 的 host 转发」——所以本轮把客户端放在这里。
|
||||
//!
|
||||
//! **长期应下沉到 `core::addon`**(已报 finding):那时本模块只剩「DTO ↔ 领域类型」的翻译,
|
||||
//! 与 `host/versions.rs` 现在的角色一样。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//!
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 网络只在后台线程 | [`spawn_search`] / [`spawn_install`] 走 `job::spawn`,UI 线程只收事件 |
|
||||
//! | Modrinth 走 core 的下载引擎 | `addon/http.rs` 的 `download_bytes` 路径带**信号量限流 + 源健康度 + 指数退避**,不另写重试循环 |
|
||||
//! | 密钥不进仓库 / 日志 | CF 的 `X-API-KEY` 只从 `ULL_CF_API_KEY` 读,日志只打 `set`/`unset`,DTO 里只有一个 `bool` |
|
||||
//!
|
||||
//! ## 模块
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] |
|
||||
//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 |
|
||||
//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 |
|
||||
//!
|
||||
//! ## 整合包与 CF 的边界(都写进报告)
|
||||
//!
|
||||
//! - 整合包**不做**「一键装入」:`ContentKind::Modpack.dir()` 是 `None`,UI 侧按钮禁用带原因,
|
||||
//! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。
|
||||
//! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。
|
||||
|
||||
mod http;
|
||||
mod install;
|
||||
mod sources;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::dto::{ContentQuery, ContentResults};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
|
||||
/// 日志里只出现这两种状态,**永远不打密钥本身**。
|
||||
const KEY_UNSET: &str = "ULL_CF_API_KEY unset(CurseForge 源不可用)";
|
||||
const KEY_SET: &str = "ULL_CF_API_KEY set(CurseForge 源可用)";
|
||||
|
||||
/// CurseForge 的密钥(环境变量;空串 / 空白按「没配」处理)。
|
||||
///
|
||||
/// 为什么不放设置项:core 的 `setting` schema 不在本 mission 的 scope(已登记 finding)。
|
||||
/// 环境变量这条路至少满足「不进仓库、不进日志」两条硬要求。
|
||||
fn curseforge_key() -> Option<String> {
|
||||
std::env::var("ULL_CF_API_KEY")
|
||||
.ok()
|
||||
.map(|key| key.trim().to_string())
|
||||
.filter(|key| !key.is_empty())
|
||||
}
|
||||
|
||||
/// CF 源此刻可用吗(页面据此禁用那条来源)。
|
||||
#[must_use]
|
||||
pub fn curseforge_ready() -> bool {
|
||||
curseforge_key().is_some()
|
||||
}
|
||||
|
||||
/// 打一行「配没配」的日志(**不含**密钥内容;host 装配时调一次)。
|
||||
pub fn log_curseforge_state() {
|
||||
eprintln!(
|
||||
"[content] {}",
|
||||
if curseforge_ready() {
|
||||
KEY_SET
|
||||
} else {
|
||||
KEY_UNSET
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// 后台检索(网络在后台线程,UI 不卡)。
|
||||
pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) {
|
||||
spawn("content", move || {
|
||||
let result: Result<ContentResults, String> = sources::search(&env, &query);
|
||||
let _ = tx.send(HostTask::Content(Box::new(result)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台「装进指定实例」。
|
||||
pub fn spawn_install(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: crate::dto::ContentInstallRequest,
|
||||
cancel: Arc<AtomicBool>,
|
||||
epoch: u64,
|
||||
) {
|
||||
spawn("content-install", move || {
|
||||
install::install(&env, &tx, &request, &cancel, epoch);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! HTTP 取文本(内容库唯一的网络出口)。
|
||||
//!
|
||||
//! ## 两条路径,为什么不是一条
|
||||
//!
|
||||
//! | 请求 | 走哪 | 拿到什么 |
|
||||
//! |---|---|---|
|
||||
//! | 无自定义 header(Modrinth 的全部请求) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 |
|
||||
//! | 带 header(只有 CurseForge 的 `X-API-KEY`) | `engine.client()` 直发 | 引擎的请求体没有 header 字段,只能自己发;代价是这条路径自己带一个 429/5xx 退避 |
|
||||
//!
|
||||
//! ## 密钥纪律
|
||||
//!
|
||||
//! 带 header 的那条路径**只把 header 值喂给请求**:不进日志、不进错误串。错误串里只有 URL
|
||||
//! (CF 的 URL 不含密钥)与状态码。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::{DownloadRequest, ResourceKind};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
|
||||
/// 站点要求的 User-Agent(Modrinth 明文要求带联系方式,否则限流)。
|
||||
pub(super) const USER_AGENT: &str =
|
||||
"Unlucky-Launcher/0.1 (+https://github.com/AskaEth/Unlucky-Launcher)";
|
||||
|
||||
/// 最多重试次数(含首次)。
|
||||
const ATTEMPTS: u32 = 3;
|
||||
|
||||
/// 取一段文本(JSON 原文)。
|
||||
pub(super) fn get(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
if headers.is_empty() {
|
||||
get_via_engine(env, url)
|
||||
} else {
|
||||
get_with_headers(env, url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的下载引擎:限流与退避都在里面,这里只负责「URL → 文本」。
|
||||
fn get_via_engine(env: &HostEnv, url: &str) -> Result<String, String> {
|
||||
let engine = super::super::engine::engine(env);
|
||||
let dir = env.layout.cache().join("content");
|
||||
std::fs::create_dir_all(&dir).map_err(|error| format!("建缓存目录失败:{error}"))?;
|
||||
let request = DownloadRequest::new(ResourceKind::Other, url, dir.join("response.json"));
|
||||
let bytes = block_on(engine.download_bytes(request))
|
||||
.map_err(|error| format!("请求 {url} 失败:{error}"))?;
|
||||
String::from_utf8(bytes).map_err(|error| format!("响应不是 UTF-8:{error}"))
|
||||
}
|
||||
|
||||
/// 带 header 的请求(只有 CF 走这条):429 / 5xx / 连接类失败按退避重试。
|
||||
fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
let engine = super::super::engine::engine(env);
|
||||
let owned: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
|
||||
.collect();
|
||||
|
||||
for attempt in 0..ATTEMPTS {
|
||||
let result = block_on(async {
|
||||
let mut builder = engine.client().get(url).header("User-Agent", USER_AGENT);
|
||||
for (name, value) in &owned {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
}
|
||||
let response = builder.send().await.map_err(|error| error.to_string())?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return response.text().await.map_err(|error| error.to_string());
|
||||
}
|
||||
let retry = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
Err(format!("HTTP {} (retry_after={retry:?})", status.as_u16()))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(body) => return Ok(body),
|
||||
Err(reason) if attempt + 1 < ATTEMPTS && retryable(&reason) => {
|
||||
std::thread::sleep(retry_after(&reason, attempt));
|
||||
}
|
||||
Err(reason) => return Err(format!("请求 {url} 失败:{reason}")),
|
||||
}
|
||||
}
|
||||
Err(format!("请求 {url} 失败:重试用尽"))
|
||||
}
|
||||
|
||||
/// 这个失败值得再试一次吗(429 / 5xx / 连接类;**401/403/404 一律不重试**,重试只会更糟)。
|
||||
pub(super) fn retryable(reason: &str) -> bool {
|
||||
reason.contains("HTTP 429")
|
||||
|| reason.contains("HTTP 500")
|
||||
|| reason.contains("HTTP 502")
|
||||
|| reason.contains("HTTP 503")
|
||||
|| reason.contains("HTTP 504")
|
||||
|| reason.contains("error sending request")
|
||||
|| reason.contains("timed out")
|
||||
}
|
||||
|
||||
/// 退避时长:优先吃服务端给的 `Retry-After`(封顶 30s),否则指数退避(1s、2s、4s)。
|
||||
pub(super) fn retry_after(reason: &str, attempt: u32) -> Duration {
|
||||
let parsed = reason
|
||||
.split("retry_after=Some(")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(')').next())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
match parsed {
|
||||
Some(secs) => Duration::from_secs(secs.min(30)),
|
||||
None => Duration::from_secs(1u64 << attempt.min(4)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 最小百分号编码(query 串用;不为一件小事引第三方 crate)。
|
||||
pub(super) 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
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 「一键装入指定实例」(M109)。
|
||||
//!
|
||||
//! ## 它到底做了什么(诚实版)
|
||||
//!
|
||||
//! | 内容类型 | 做什么 | 状态 |
|
||||
//! |---|---|---|
|
||||
//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 |
|
||||
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
|
||||
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
|
||||
//!
|
||||
//! ## 为什么进度只有阶段级,没有字节条
|
||||
//!
|
||||
//! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有
|
||||
//! `tokio` / `futures-util`(`Cargo.toml` 不在本 mission 的 scope),拿不到接收端的 `recv()`。
|
||||
//! 所以这里给的是**阶段 + 文件名**(`Planning` → `Downloading` → `Done`/`Failed`),
|
||||
//! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。
|
||||
//! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
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;
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentInstallRequest, ContentSource, InstallProgressView, InstallStage,
|
||||
InstanceSummary,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::http::{encode, get};
|
||||
use super::sources::MODRINTH;
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
request: &ContentInstallRequest,
|
||||
cancel: &AtomicBool,
|
||||
epoch: u64,
|
||||
) {
|
||||
let mut progress = InstallProgressView {
|
||||
version: request.title.clone(),
|
||||
instance: request.instance.clone(),
|
||||
loader: request.loader.label().to_string(),
|
||||
files_total: 1,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
|
||||
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
|
||||
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
|
||||
if request.kind.dir().is_none() {
|
||||
fail(
|
||||
tx,
|
||||
progress,
|
||||
"整合包安装管线未落地(M111);内容库对整合包只做检索",
|
||||
epoch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
match run(env, request, cancel, &mut progress, tx, epoch) {
|
||||
Ok(done) => {
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = 1;
|
||||
progress.message = done;
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Ok(request.title.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Err(reason) => {
|
||||
progress.stage = if cancel.load(Ordering::SeqCst) {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) {
|
||||
progress.stage = InstallStage::Failed;
|
||||
progress.message = reason.to_string();
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason.to_string()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述。
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<String, String> {
|
||||
let instance = find_instance(env, &request.instance)?;
|
||||
let game_dir = game_dir_of(env, &instance)?;
|
||||
let dir = request
|
||||
.kind
|
||||
.dir()
|
||||
.ok_or_else(|| "该内容类型不支持单文件装入".to_string())?;
|
||||
let target_dir = game_dir.join(dir);
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?;
|
||||
|
||||
let file = resolve_file(env, request)?;
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err("已取消(下载开始前)".to_string());
|
||||
}
|
||||
progress.stage = InstallStage::Downloading;
|
||||
progress.message = file.name.clone();
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
|
||||
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}"))?;
|
||||
Ok(format!("{}({})", file.name, dest.display()))
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, String> {
|
||||
super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| format!("找不到实例 {id}"))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("隔离目录派生失败:{error}"))
|
||||
}
|
||||
|
||||
/// 要下载的那个文件。
|
||||
struct RemoteFile {
|
||||
url: String,
|
||||
name: String,
|
||||
sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 解析「这个项目下最适合目标实例的那个文件」。
|
||||
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
match request.source {
|
||||
ContentSource::Modrinth => modrinth_file(env, request),
|
||||
ContentSource::Curseforge => Err(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
let mut url = format!(
|
||||
"{MODRINTH}/project/{}/version?game_versions={}",
|
||||
request.project,
|
||||
encode(&format!("[\"{}\"]", request.game_version))
|
||||
);
|
||||
if let Some(facet) = loader_facet(request.loader) {
|
||||
url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]"))));
|
||||
}
|
||||
let body = get(env, &url, &[])?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?;
|
||||
let versions = json
|
||||
.as_array()
|
||||
.ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?;
|
||||
let first = versions
|
||||
.first()
|
||||
.ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?;
|
||||
let files = first
|
||||
.get("files")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "版本里没有 files".to_string())?;
|
||||
// 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑
|
||||
let file = files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
file.get("primary")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.or_else(|| files.first())
|
||||
.ok_or_else(|| "版本里没有可用文件".to_string())?;
|
||||
|
||||
let url = file
|
||||
.get("url")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "文件没有 url".to_string())?;
|
||||
let name = file
|
||||
.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);
|
||||
Ok(RemoteFile {
|
||||
url: url.to_string(),
|
||||
name: safe_file_name(name),
|
||||
sha1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
|
||||
/// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。
|
||||
pub(super) fn safe_file_name(raw: &str) -> String {
|
||||
let name = raw
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
"download.bin".to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 两个站点的检索方言(M109):把各自的 JSON 翻成 [`ContentHit`]。
|
||||
//!
|
||||
//! 两个站点在**同一件事上用的字段名完全不同**,所以这里每个站点一段、各自带注释:
|
||||
//!
|
||||
//! | 语义 | Modrinth `/v2/search` | CurseForge `/v1/mods/search` |
|
||||
//! |---|---|---|
|
||||
//! | 项目 id | `project_id`(base62) | `id`(数字) |
|
||||
//! | 标题 / 简介 | `title` / `description` | `name` / `summary` |
|
||||
//! | 下载量 | `downloads` | `downloadCount` |
|
||||
//! | 游戏版本 | `versions` | `latestFilesIndexes[].gameVersion` |
|
||||
//! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) |
|
||||
//!
|
||||
//! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**,
|
||||
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(`sources/tests` 钉住了这一条)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::curseforge_ready;
|
||||
use super::http::{encode, get};
|
||||
|
||||
/// Modrinth v2 的根(无需 key)。
|
||||
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
|
||||
/// CurseForge v1 的根(需要 `X-API-KEY`)。
|
||||
pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
|
||||
/// CurseForge 的「Minecraft」gameId(官方固定值)。
|
||||
const CF_MINECRAFT: u32 = 432;
|
||||
|
||||
/// 检索分派。
|
||||
pub(super) fn search(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
|
||||
match query.source {
|
||||
ContentSource::Modrinth => modrinth(env, query),
|
||||
ContentSource::Curseforge => curseforge(env, query),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 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<_>>();
|
||||
|
||||
Ok(ContentResults {
|
||||
query: query.clone(),
|
||||
hits,
|
||||
total: u32_of(&json, "total_hits"),
|
||||
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();
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- CurseForge
|
||||
|
||||
fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
|
||||
let Some(key) = super::curseforge_key() else {
|
||||
return Err("CurseForge 需要 API key(ULL_CF_API_KEY 未设置)".to_string());
|
||||
};
|
||||
let mut url = format!(
|
||||
"{CURSEFORGE}/mods/search?gameId={CF_MINECRAFT}&classId={}&index={}&pageSize={}&sortField={}&sortOrder=desc",
|
||||
query.kind.curseforge_class(),
|
||||
query.offset,
|
||||
query.limit.min(50),
|
||||
query.sort.curseforge_field()
|
||||
);
|
||||
if !query.text.trim().is_empty() {
|
||||
url.push_str(&format!("&searchFilter={}", encode(query.text.trim())));
|
||||
}
|
||||
if let Some(version) = &query.game_version {
|
||||
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 hits = json
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "CurseForge 返回里没有 data 字段".to_string())?
|
||||
.iter()
|
||||
.map(curseforge_hit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(ContentResults {
|
||||
query: query.clone(),
|
||||
hits,
|
||||
total: json
|
||||
.get("pagination")
|
||||
.map(|pagination| u64_of(pagination, "totalCount"))
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(0),
|
||||
curseforge: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一条 CF 命中项 → DTO。加载器只能从 `latestFilesIndexes[].modLoader` 的数字枚举反推。
|
||||
fn curseforge_hit(value: &Value) -> ContentHit {
|
||||
let mut game_versions: Vec<String> = Vec::new();
|
||||
let mut loaders: Vec<String> = Vec::new();
|
||||
if let Some(indexes) = value.get("latestFilesIndexes").and_then(Value::as_array) {
|
||||
for entry in indexes {
|
||||
if let Some(version) = entry.get("gameVersion").and_then(Value::as_str) {
|
||||
if !game_versions.iter().any(|item| item == version) {
|
||||
game_versions.push(version.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(loader) = entry
|
||||
.get("modLoader")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(curseforge_loader)
|
||||
{
|
||||
if !loaders.iter().any(|item| item == loader) {
|
||||
loaders.push(loader.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let author = value
|
||||
.get("authors")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.map(|entry| text(entry, "name"))
|
||||
.unwrap_or_default();
|
||||
|
||||
ContentHit {
|
||||
id: value
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default(),
|
||||
slug: text(value, "slug"),
|
||||
title: text(value, "name"),
|
||||
summary: text(value, "summary"),
|
||||
author,
|
||||
downloads: u64_of(value, "downloadCount"),
|
||||
updated: date_part(&text(value, "dateModified")),
|
||||
source: ContentSource::Curseforge,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: value
|
||||
.get("logo")
|
||||
.and_then(|logo| logo.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// CF 的 `modLoader` 枚举 → Modrinth 口径的 loader 名(1 Forge / 4 Fabric / 5 Quilt / 6 NeoForge)。
|
||||
pub(super) fn curseforge_loader(raw: u64) -> Option<&'static str> {
|
||||
match raw {
|
||||
1 => Some("forge"),
|
||||
4 => Some("fabric"),
|
||||
5 => Some("quilt"),
|
||||
6 => Some("neoforge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 小工具
|
||||
|
||||
/// 取字符串字段(缺 / 类型不对 ⇒ 空串)。
|
||||
fn text(value: &Value, key: &str) -> String {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 内容库宿主的守门测试(M109):编码、文件名穿越、退避判定、两个站点的方言映射。
|
||||
|
||||
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;
|
||||
|
||||
#[test]
|
||||
fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() {
|
||||
assert_eq!(encode("1.20.1"), "1.20.1");
|
||||
assert_eq!(
|
||||
encode("[\"versions:1.20.1\"]"),
|
||||
"%5B%22versions%3A1.20.1%22%5D"
|
||||
);
|
||||
assert_eq!(encode("中文"), "%E4%B8%AD%E6%96%87");
|
||||
assert_eq!(encode("a b"), "a%20b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_never_carry_a_path() {
|
||||
// 抓得住:站点给的 `../../evil.jar` 落到实例目录之外(穿越)。
|
||||
assert_eq!(safe_file_name("sodium.jar"), "sodium.jar");
|
||||
assert_eq!(safe_file_name("../../evil.jar"), "evil.jar");
|
||||
assert_eq!(safe_file_name("mods\\jei.jar"), "jei.jar");
|
||||
assert_eq!(safe_file_name(".."), "download.bin");
|
||||
assert_eq!(safe_file_name(""), "download.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_are_sliced_not_guessed() {
|
||||
assert_eq!(date_part("2024-05-01T12:00:00Z"), "2024-05-01");
|
||||
assert_eq!(date_part("2024-05"), "2024-05");
|
||||
assert_eq!(date_part(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rate_limited_and_transient_failures_are_retried() {
|
||||
assert!(retryable("HTTP 429 (retry_after=Some(3))"));
|
||||
assert!(retryable("HTTP 503 (retry_after=None)"));
|
||||
assert!(!retryable("HTTP 404 (retry_after=None)"));
|
||||
assert!(
|
||||
!retryable("HTTP 401 (retry_after=None)"),
|
||||
"密钥错重试只会更糟"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_prefers_the_server_and_falls_back_to_exponential() {
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=Some(7))", 0),
|
||||
Duration::from_secs(7)
|
||||
);
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=None)", 0),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=None)", 2),
|
||||
Duration::from_secs(4)
|
||||
);
|
||||
// 服务端要得再多也不等超过 30s(交互与截图不能被一次退避挂死)
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=Some(900))", 0),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_loader_ids_follow_the_official_enum() {
|
||||
assert_eq!(curseforge_loader(1), Some("forge"));
|
||||
assert_eq!(curseforge_loader(4), Some("fabric"));
|
||||
assert_eq!(curseforge_loader(5), Some("quilt"));
|
||||
assert_eq!(curseforge_loader(6), Some("neoforge"));
|
||||
assert_eq!(curseforge_loader(99), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_content_kinds_are_the_four_side_entries() {
|
||||
assert_eq!(
|
||||
all_kinds().map(ContentKind::slug),
|
||||
["modpack", "mod", "resourcepack", "shader"]
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::accounts::{self, LoginJob};
|
||||
use super::{game, install, java, launch, loader_versions, logs, settings, versions, CoreHost};
|
||||
use super::{
|
||||
addon, game, install, java, launch, loader_versions, logs, settings, versions, CoreHost,
|
||||
};
|
||||
|
||||
impl CoreHost {
|
||||
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
|
||||
@@ -168,6 +170,20 @@ impl CoreHost {
|
||||
self.notices.retain(|notice| notice.id != id);
|
||||
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
|
||||
}
|
||||
// M109:内容库检索(网络在后台线程,结果经 `HostTask::Content` 回来)
|
||||
AppCommand::SearchContent(query) => {
|
||||
addon::spawn_search(self.env.clone(), self.tx.clone(), query);
|
||||
}
|
||||
// M109:把某个内容项目装进指定实例。取消标记与安装面板共用同一条路
|
||||
// (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。
|
||||
AppCommand::InstallContent(request) => {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||
// 代次与版本安装共用同一条路(M105 的形状):取消/被顶掉之后,旧任务带回来的
|
||||
// 进度事件一律被 `handle` 丢掉,内容库种的这次安装不会把面板重新点亮。
|
||||
let epoch = self.next_install_epoch();
|
||||
addon::spawn_install(self.env.clone(), self.tx.clone(), *request, cancel, epoch);
|
||||
}
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
self.chat.dispatch(other);
|
||||
|
||||
@@ -34,10 +34,16 @@ impl CoreHost {
|
||||
result,
|
||||
});
|
||||
}
|
||||
// M109:内容库检索。`Err` 不弹 toast——页面自己有失败态(四态之一),
|
||||
// toast 只做「顺手提一句」会把可重试的错误变成一次性噪音。
|
||||
HostTask::Content(result) => match *result {
|
||||
Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))),
|
||||
Err(reason) => self.send(CoreEvent::ContentFailed(reason)),
|
||||
},
|
||||
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
||||
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
|
||||
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
||||
HostTask::Install { progress, epoch } => {
|
||||
// 旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉:这是「点了取消没反应」的根因。
|
||||
// 取消时 `InstallProgress(None)` 刚把面板收回,旧任务的下一条「下载中」事件
|
||||
// 立刻又把它点亮,用户看到的就永远是那块还在涨的进度条。
|
||||
if epoch != self.install_epoch {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::dto::{
|
||||
AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
|
||||
AccountView, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
|
||||
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LoaderKind, LogLine, LogOutcome,
|
||||
LogSession, NoticeView, SessionId, VersionSummary,
|
||||
};
|
||||
@@ -27,6 +27,8 @@ pub enum HostTask {
|
||||
/// `Ok(最新在前)` / `Err(人读原因)`
|
||||
result: Result<Vec<String>, String>,
|
||||
},
|
||||
/// 内容库检索(M109;`Err` = 一句人读原因)
|
||||
Content(Box<Result<ContentResults, String>>),
|
||||
/// 安装进度(高频,host 只做聚合与转发)
|
||||
Install {
|
||||
/// 进度快照
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! 其余页面全部走真实 core。
|
||||
|
||||
mod accounts;
|
||||
mod addon;
|
||||
mod dispatch;
|
||||
mod engine;
|
||||
pub mod env;
|
||||
@@ -141,6 +142,8 @@ impl CoreHost {
|
||||
if refresh {
|
||||
host.refresh_all();
|
||||
}
|
||||
// M109:CF 源可用与否只在装配时打一行(**不含密钥内容**,见 `addon` 模块文档)。
|
||||
addon::log_curseforge_state();
|
||||
host
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::dto::InstanceId;
|
||||
pub enum NavSection {
|
||||
/// 实例
|
||||
Instances,
|
||||
/// 下载
|
||||
/// 下载 / 版本管理 + 内容库(M109:内容库复用这一项,不新增第 6 个图标)
|
||||
Download,
|
||||
/// 日志
|
||||
Logs,
|
||||
@@ -24,8 +24,13 @@ pub enum NavSection {
|
||||
pub enum Route {
|
||||
/// G1 实例页(主页)
|
||||
Instances,
|
||||
/// G2 下载页
|
||||
/// G2 下载页(M107/M108 起是「版本管理」)
|
||||
Download,
|
||||
/// 内容库(M109):整合包 / Mod / 资源包 / 光影,来源 Modrinth / CurseForge
|
||||
///
|
||||
/// 它是**顶层页**(跟版本管理一样整栈替换),但高亮复用 [`NavSection::Download`]:
|
||||
/// 用户令「下载页只负责下载整合包和Mod/光影」⇒ 同一入口下两块内容,不是一个新图标。
|
||||
Content,
|
||||
/// G3 日志页
|
||||
Logs,
|
||||
/// G6 设置页
|
||||
@@ -41,7 +46,7 @@ impl Route {
|
||||
pub fn is_top_level(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Instances | Self::Download | Self::Logs | Self::Settings
|
||||
Self::Instances | Self::Download | Self::Content | Self::Logs | Self::Settings
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +54,7 @@ impl Route {
|
||||
pub fn nav(&self) -> Option<NavSection> {
|
||||
match self {
|
||||
Self::Instances | Self::Detail(_) => Some(NavSection::Instances),
|
||||
Self::Download => Some(NavSection::Download),
|
||||
Self::Download | Self::Content => Some(NavSection::Download),
|
||||
Self::Logs => Some(NavSection::Logs),
|
||||
Self::Settings => Some(NavSection::Settings),
|
||||
Self::Agent => None,
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
use crate::agent::AgentRail;
|
||||
use crate::dialogs::DialogQueue;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
|
||||
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
|
||||
LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
|
||||
AccountView, AgentEvent, AppEvent, ContentQuery, ContentResults, CoreEvent, DeviceLoginView,
|
||||
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView,
|
||||
LoaderKind, LogLine, LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
|
||||
};
|
||||
use crate::prefs::UiPrefs;
|
||||
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
|
||||
pub use crate::dto::ContentState;
|
||||
use crate::router::{AiPanelMode, Route, Router};
|
||||
use crate::toast::{ToastCenter, ToastKind};
|
||||
use crate::wizard::WizardState;
|
||||
@@ -106,6 +108,8 @@ pub struct AppState {
|
||||
pub wizard: WizardState,
|
||||
/// 正在实时尾随的会话
|
||||
pub live_session: Option<String>,
|
||||
/// 内容库(M109):条件 / 结果 / 请求态
|
||||
pub content: ContentState,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -143,6 +147,7 @@ impl AppState {
|
||||
notices: Vec::new(),
|
||||
wizard: WizardState::new(),
|
||||
live_session: None,
|
||||
content: ContentState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,9 +271,46 @@ impl AppState {
|
||||
.insert(mc_version, result);
|
||||
}
|
||||
CoreEvent::NoticesChanged(notices) => self.notices = notices,
|
||||
CoreEvent::ContentResults(results) => self.apply_content(*results),
|
||||
CoreEvent::ContentFailed(reason) => {
|
||||
self.content.pending = false;
|
||||
self.content.failed = Some(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。
|
||||
///
|
||||
/// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。
|
||||
fn apply_content(&mut self, results: ContentResults) {
|
||||
self.content.pending = false;
|
||||
self.content.failed = None;
|
||||
if !self.content.accepts(&results) {
|
||||
// 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 A、列表是 B」。
|
||||
return;
|
||||
}
|
||||
match &mut self.content.results {
|
||||
Some(current) if results.query.offset > 0 => {
|
||||
current.hits.extend(results.hits);
|
||||
current.total = results.total;
|
||||
current.curseforge = results.curseforge;
|
||||
current.query = results.query;
|
||||
}
|
||||
_ => self.content.results = Some(results),
|
||||
}
|
||||
}
|
||||
|
||||
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。
|
||||
pub fn set_content_query(&mut self, query: ContentQuery) {
|
||||
self.content.pending = true;
|
||||
self.content.failed = None;
|
||||
if query.offset == 0 {
|
||||
// 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。
|
||||
self.content.results = None;
|
||||
}
|
||||
self.content.query = query;
|
||||
}
|
||||
|
||||
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
|
||||
///
|
||||
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
||||
|
||||
Reference in new issue
Block a user