feat(addon): Modrinth 前置依赖 BFS 解析与两档失败名单

- model 纳入 `dependencies`(project_id / version_id / file_name + required /
  optional / incompatible / embedded / unknown 五值),只跟 required 递归;
- 新增 `online::dependency`(请求/计划/跳过的形状)与 `online::resolve`
  (BFS:规范项目 id 去重防环、每项目只解一次版本、同层按项目键字典序、
  默认深度 6 且截断写进计划、父子关系进 `required_by`);
- 失败从类型上分两档:`Incompatible`(所选版本/加载器没有匹配项)与
  `Unreachable`(并保留叶子成因 NotFound/NotConnected/…),`retry_request()`
  只含失败项,解析永不返回 Err(失败是数据,不连坐);
- 盘上第二道去重:`present_key` 归一化文件名(小写、砍路径、去 `.disabled`),
  命中即跳过下载,但依赖图仍然展开;
- presenter 装入路径改成「解析计划 → 逐个下载」,文案把两档分开写并列出
  「可只重试这 N 个」;`KNOWN_LOADERS` 补 iris/optifine(光影的加载器);
- 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。
This commit is contained in:
AskaEth committed 2026-11-08 20:23:55 +08:00
1 parent 94f792e978
commit 011d525e8d
14 files changed
+1787 -97

No files matched your search

+101 -90
View File
@@ -1,19 +1,33 @@
//! 「一键装入指定实例」(M109)。
//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。
//!
//! ## 网络只走 code 的在线内容源(M162 r1)
//!
//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、
//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。
//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在
//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼
//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
//!
//! ## 它到底做了什么(诚实版)
//!
//! | 内容类型 | 做什么 | 状态 |
//! |---|---|---|
//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 |
//! | Mod / 资源包 / 光影 | 解析「该项目 + 它的 `required` 前置」在目标实例(版本 + 加载器)下的文件 → core 下载引擎下到 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流 | 已接(M186 起含前置) |
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
//!
//! ## 前置的两档失败与重试名单(M186)
//!
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次,
//! `DependencyPlan::retry_request()` 已经把请求备好了。
//!
//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」)
//!
//! | 内容 | 检索 | 装入 | 差在哪 |
//! |---|---|---|---|
//! | 光影 | 支持(`project_type:shader`);命中项的加载器标签现在认得 `iris` / `optifine`(`KNOWN_LOADERS`) | 支持(落 `shaderpacks/`) | 加载器**过滤**仍筛不了:`LoaderKind::OptiFine` 没有 facet(`loader_facet` 返回 `None`),所以「只看 Iris 光影」只能靠标签目测 |
//! | 数据包 | core 的 `ContentType::Datapack` 有 `datapack` facet | **不支持** | DTO 的 `ContentKind` 没有这一项;数据包还要落到「某个世界的 `datapacks/`」,现在没有选择世界这一步 |
//!
//! ## 为什么进度只有阶段级,没有字节条
//!
//! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有
@@ -26,7 +40,7 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{
download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery,
download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::install::instance::read_instance_config;
@@ -39,7 +53,7 @@ use crate::dto::{
use super::super::env::HostEnv;
use super::super::job::{HostTask, JobSender};
use super::sources::failure_of;
use super::{plan, sources::failure_of};
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
pub(super) fn install(
@@ -112,7 +126,10 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu
});
}
/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。
/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。
///
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
fn run(
env: &HostEnv,
request: &ContentInstallRequest,
@@ -130,33 +147,90 @@ fn run(
let target_dir = game_dir.join(dir);
std::fs::create_dir_all(&target_dir)
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
let file = resolve_file(env, request)?;
if cancel.load(Ordering::SeqCst) {
return Err(local("已取消(下载开始前)"));
if request.source != ContentSource::Modrinth {
return Err(ContentFailure::unsupported(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
));
}
// 解析:根项目 + 只跟 `required` 的前置(顺序与父子关系由 core 给,这里不重排)。
let resolve_request = plan::request(
&request.project,
&request.game_version,
loader_facet(request.loader),
plan::present_files(&target_dir),
);
let core_engine = super::super::engine::engine(env);
let dependency_plan = {
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
block_on(resolve(&client, &resolve_request))
};
progress.stage = InstallStage::Downloading;
progress.message = file.name.clone();
progress.files_total = dependency_plan.nodes.len() as u64;
progress.files_done = 0;
progress.message = if dependency_plan.nodes.is_empty() {
"没有要下载的文件".to_string()
} else {
format!(
"{} 个文件(含 {} 个前置)",
dependency_plan.nodes.len(),
dependency_plan
.nodes
.iter()
.filter(|node| !node.is_root())
.count()
)
};
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
epoch,
});
let dest = target_dir.join(&file.name);
// 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(failure_of)?;
Ok(format!("{}({})", file.name, dest.display()))
for node in &dependency_plan.nodes {
if cancel.load(Ordering::SeqCst) {
return Err(local("已取消(下载中途)"));
}
progress.message = node_label(node);
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
epoch,
});
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
let dest = target_dir.join(safe_file_name(&node.file.filename));
block_on(download_verified(
&fetcher,
&node.file.url,
node.file.sha1(),
node.file.sha512(),
(node.file.size > 0).then_some(node.file.size),
&dest,
))
.map_err(failure_of)?;
progress.files_done += 1;
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
epoch,
});
}
// 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。
if let Some(failure) = plan::failure_of_plan(&dependency_plan) {
return Err(failure);
}
Ok(plan::summary(&dependency_plan))
}
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
fn node_label(node: &PlannedNode) -> String {
if node.is_root() {
node.file.filename.clone()
} else {
format!("{}(前置)", node.file.filename)
}
}
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
@@ -187,69 +261,6 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Con
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
}
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
struct RemoteFile {
url: String,
name: String,
sha1: Option<String>,
sha512: Option<String>,
size: Option<u64>,
}
/// 解析「这个项目下最适合目标实例的那个文件」。
fn resolve_file(
env: &HostEnv,
request: &ContentInstallRequest,
) -> Result<RemoteFile, ContentFailure> {
match request.source {
ContentSource::Modrinth => modrinth_file(env, request),
ContentSource::Curseforge => Err(ContentFailure::unsupported(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
)),
}
}
/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。
///
/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑,
/// `Version::primary_file()` 就是为这件事存在的。
fn modrinth_file(
env: &HostEnv,
request: &ContentInstallRequest,
) -> Result<RemoteFile, ContentFailure> {
let core_engine = super::super::engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let versions = block_on(client.versions(&VersionQuery {
project: request.project.clone(),
game_version: Some(request.game_version.clone()),
loader: loader_facet(request.loader).map(str::to_string),
}))
.map_err(failure_of)?;
let version = versions.first().ok_or_else(|| {
ContentFailure::new(
ContentFailureKind::NotFound,
"这个项目没有匹配目标实例(版本 / 加载器)的文件",
)
})?;
let file = version
.primary_file()
.ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?;
if file.url.trim().is_empty() {
return Err(ContentFailure::new(
ContentFailureKind::Unsupported,
"版本里的主文件没有下载地址",
));
}
Ok(RemoteFile {
url: file.url.clone(),
name: safe_file_name(&file.filename),
sha1: file.sha1().map(ToString::to_string),
sha512: file.sha512().map(ToString::to_string),
size: (file.size > 0).then_some(file.size),
})
}
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
/// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。
pub(super) fn safe_file_name(raw: &str) -> String {