- P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按 `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents` 折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复; - P2-2 `RawDependency.dependency_type` 改 `Option<String>`:`#[serde(default)]` 兜不住 `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上; 现在 null/未知一律 `Unknown`(不递归); - P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3); 解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」; - P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红; 另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`); - P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件: 已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request` 自动覆盖它们; - nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档); nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略); nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。
317 lines
13 KiB
Rust
317 lines
13 KiB
Rust
//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。
|
||
//!
|
||
//! ## 网络只走 code 的在线内容源(M162 r1)
|
||
//!
|
||
//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在
|
||
//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼
|
||
//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
|
||
//!
|
||
//! ## 它到底做了什么(诚实版)
|
||
//!
|
||
//! | 内容类型 | 做什么 | 状态 |
|
||
//! |---|---|---|
|
||
//! | 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 r1):以前 `download_verified` 一失败就整单中断,
|
||
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
|
||
//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。
|
||
//!
|
||
//! ## 光影与数据包:支持到哪一步(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 的依赖表里没有
|
||
//! `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::addon::online::{
|
||
download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport,
|
||
FailureKind, Modrinth, PlannedNode,
|
||
};
|
||
use unlucky_core::download::blocking::block_on;
|
||
use unlucky_core::game::install::instance::read_instance_config;
|
||
use unlucky_core::game::isolation::derive_game_dir;
|
||
|
||
use crate::dto::{
|
||
loader_facet, ContentFailure, ContentFailureKind, ContentInstallRequest, ContentSource,
|
||
InstallProgressView, InstallStage, InstanceSummary,
|
||
};
|
||
|
||
use super::super::env::HostEnv;
|
||
use super::super::job::{HostTask, JobSender};
|
||
use super::plan;
|
||
|
||
/// 跑一次装入(同步;由 [`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(),
|
||
// 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。
|
||
files_total: 0,
|
||
..InstallProgressView::new()
|
||
};
|
||
|
||
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
|
||
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
|
||
if request.kind.dir().is_none() {
|
||
fail(
|
||
tx,
|
||
progress,
|
||
ContentFailure::unsupported("整合包安装尚未开放;内容库对整合包只做检索"),
|
||
epoch,
|
||
);
|
||
return;
|
||
}
|
||
|
||
let _ = tx.send(HostTask::Install {
|
||
progress: Box::new(progress.clone()),
|
||
epoch,
|
||
});
|
||
match run(env, request, cancel, &mut progress, tx, epoch) {
|
||
Ok(done) => {
|
||
finish(&mut progress);
|
||
progress.message = done;
|
||
let _ = tx.send(HostTask::InstallDone {
|
||
progress: Box::new(progress),
|
||
result: Ok(request.title.clone()),
|
||
epoch,
|
||
});
|
||
}
|
||
Err(failure) => {
|
||
progress.stage = if cancel.load(Ordering::SeqCst) {
|
||
InstallStage::Cancelled
|
||
} else {
|
||
InstallStage::Failed
|
||
};
|
||
progress.message = failure.detail.clone();
|
||
progress.failure_kind = Some(failure.kind);
|
||
let _ = tx.send(HostTask::InstallDone {
|
||
progress: Box::new(progress),
|
||
result: Err(failure),
|
||
epoch,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailure, epoch: u64) {
|
||
progress.stage = InstallStage::Failed;
|
||
progress.message = failure.detail.clone();
|
||
progress.failure_kind = Some(failure.kind);
|
||
let _ = tx.send(HostTask::InstallDone {
|
||
progress: Box::new(progress),
|
||
result: Err(failure),
|
||
epoch,
|
||
});
|
||
}
|
||
|
||
/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。
|
||
///
|
||
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
|
||
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
|
||
fn run(
|
||
env: &HostEnv,
|
||
request: &ContentInstallRequest,
|
||
cancel: &AtomicBool,
|
||
progress: &mut InstallProgressView,
|
||
tx: &JobSender,
|
||
epoch: u64,
|
||
) -> Result<String, ContentFailure> {
|
||
let instance = find_instance(env, &request.instance)?;
|
||
let game_dir = game_dir_of(env, &instance)?;
|
||
let dir = request
|
||
.kind
|
||
.dir()
|
||
.ok_or_else(|| local("该内容类型不支持单文件装入"))?;
|
||
let target_dir = game_dir.join(dir);
|
||
std::fs::create_dir_all(&target_dir)
|
||
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
|
||
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 mut dependency_plan = {
|
||
let transport = EngineTransport::new(&core_engine);
|
||
let client = Modrinth::new(&transport);
|
||
block_on(resolve(&client, &resolve_request))
|
||
};
|
||
|
||
progress.stage = InstallStage::Downloading;
|
||
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 fetcher = EngineArtifactFetcher::new(&core_engine);
|
||
let mut installed: Vec<PlannedNode> = Vec::new();
|
||
for index in 0..dependency_plan.nodes.len() {
|
||
if cancel.load(Ordering::SeqCst) {
|
||
return Err(local("已取消(下载中途)"));
|
||
}
|
||
let node = dependency_plan.nodes[index].clone();
|
||
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));
|
||
let outcome = block_on(download_verified(
|
||
&fetcher,
|
||
&node.file.url,
|
||
node.file.sha1(),
|
||
node.file.sha512(),
|
||
(node.file.size > 0).then_some(node.file.size),
|
||
&dest,
|
||
));
|
||
match outcome {
|
||
Ok(_) => installed.push(node),
|
||
Err(error) => {
|
||
// 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因):
|
||
// 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。
|
||
// 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。
|
||
dependency_plan.record_failure(DependencyFailure {
|
||
requested_as: node.requested_as.clone(),
|
||
required_by: node.required_by.clone(),
|
||
depth: node.depth,
|
||
kind: FailureKind::Unreachable,
|
||
cause: Some(error.kind()),
|
||
message: format!(
|
||
"下载 {} 失败({}):{}",
|
||
node.file.filename,
|
||
error.kind().as_str(),
|
||
error.message()
|
||
),
|
||
});
|
||
continue;
|
||
}
|
||
}
|
||
progress.files_done += 1;
|
||
let _ = tx.send(HostTask::Install {
|
||
progress: Box::new(progress.clone()),
|
||
epoch,
|
||
});
|
||
}
|
||
|
||
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
|
||
// 并列出「可只重试这 N 个」。
|
||
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
|
||
return Err(failure);
|
||
}
|
||
Ok(plan::summary(&dependency_plan, &installed))
|
||
}
|
||
|
||
/// 完成态:`files_done` 对齐 `files_total`。
|
||
///
|
||
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
|
||
/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。
|
||
pub(super) fn finish(progress: &mut InstallProgressView) {
|
||
progress.stage = InstallStage::Done;
|
||
progress.files_done = progress.files_total;
|
||
}
|
||
|
||
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
|
||
fn node_label(node: &PlannedNode) -> String {
|
||
if node.is_root() {
|
||
node.file.filename.clone()
|
||
} else {
|
||
format!("{}(前置)", node.file.filename)
|
||
}
|
||
}
|
||
|
||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||
fn local(reason: impl Into<String>) -> ContentFailure {
|
||
ContentFailure::new(ContentFailureKind::Other, reason)
|
||
}
|
||
|
||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFailure> {
|
||
super::super::game::instances(env)
|
||
.into_iter()
|
||
.find(|instance| instance.id == id)
|
||
.ok_or_else(|| local(format!("找不到实例 {id}")))
|
||
}
|
||
|
||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
|
||
let global = super::super::game::global_mode(env);
|
||
let config = read_instance_config(&env.layout, &instance.id)
|
||
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
|
||
derive_game_dir(
|
||
&env.game,
|
||
&instance.version_id,
|
||
global,
|
||
config.as_ref(),
|
||
None,
|
||
)
|
||
.map(|derivation| derivation.game_dir)
|
||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||
}
|
||
|
||
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
|
||
/// (与 `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
|
||
}
|
||
}
|