复审 P2-1:面板原来只画 `P7dR8mSH` 这种项目键,用户拿到的是一串哈希。
可读名其实都在手边,只是没进 DTO——现在补上,按复审给的**最小修法**做,没扩散:
- `InstallFailureView` 加 `label: String`(**语言中立**:站点标题或文件名),
配 `line()` 统一决定「名字 (id)」还是「只有 id」(名字为空时不编造)。
`detail`(core 中文散文)**依旧不进界面**——那会踩 i18n R2。
- `plan::label_of` 三个来源,零额外请求:
根项目 → 请求里的 `title`;计划里有节点(下载失败)→ `node.file.filename`;
都没有(解析 404 / 不兼容的前置)→ 空,界面退回只画 id(复审认这条兜底)。
- 计划被记住时连显示名一起记(`plan::remember` 多一个 `title`),于是重试那一轮
面板头与失败名单沿用的是同一个名字(顺手收掉复审 N5 的「重试时面板头显示 id」)。
- N2:删掉新加的 `ContentInstallDismiss`,复用既有的 `DownloadDismiss`(同一句话不立两条 key)。
- 行数/clippy:`collect` 的参数捆成 `Placement{dir,title}`(9 个参数超限);
`plan.rs` 加完 `label_of` 到 429 行,把「记住的计划」表纯搬移到 `plan/remembered.rs`(363 + 82)。
守门(真跑红再还原):
- 把 `label_of` 置空 ⇒ `the_retry_list_shows_a_readable_name_not_just_the_project_id` 红
(三行全变成 `["AANobbMI","P7dR8mSH","1eAoo2KR"]`);
- 审阅指出的另一半判据也补上了:`Done` + 残留名单 ⇒ 必须不画;现在**只去掉**
`stage != Failed` 那一半就红(之前只去掉它仍是全绿,报告的表述不准,已改)。
376 lines
15 KiB
Rust
376 lines
15 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 / M191)
|
||
//!
|
||
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
|
||
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
|
||
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。
|
||
//!
|
||
//! ## 「只重试这些」真的只重试这些(M191)
|
||
//!
|
||
//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry`
|
||
//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`,
|
||
//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。
|
||
//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。
|
||
//!
|
||
//! 下载阶段的失败**也进同一套名单**(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::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
|
||
use unlucky_core::addon::online::{
|
||
resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, 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::{downloads, 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 直接发指令」也不会静默做半件事。
|
||
// 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。
|
||
if !request.retry && 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`] 变成一条带大类的失败(**前置缺了不算装成**)。
|
||
///
|
||
/// 两条解析入口,别混:
|
||
///
|
||
/// | 入口 | 请求从哪来 | 发几次 |
|
||
/// |---|---|---|
|
||
/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS |
|
||
/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_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)?;
|
||
|
||
// 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。
|
||
// 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。
|
||
if request.retry {
|
||
let Some((target_dir, title, previous)) = plan::remembered(env, &request.instance) else {
|
||
return Err(local(RETRY_LOST));
|
||
};
|
||
// 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5)
|
||
progress.version = title.clone();
|
||
let present = plan::present_files(&target_dir);
|
||
let core_engine = super::super::engine::engine(env);
|
||
let dependency_plan = plan::retry_plan(&previous, present, |retry| {
|
||
let transport = EngineTransport::new(&core_engine);
|
||
let client = Modrinth::new(&transport);
|
||
block_on(resolve(&client, retry))
|
||
});
|
||
return collect(
|
||
env,
|
||
request,
|
||
dependency_plan,
|
||
Placement {
|
||
dir: &target_dir,
|
||
title: &title,
|
||
},
|
||
cancel,
|
||
progress,
|
||
tx,
|
||
epoch,
|
||
);
|
||
}
|
||
|
||
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 dependency_plan = {
|
||
let transport = EngineTransport::new(&core_engine);
|
||
let client = Modrinth::new(&transport);
|
||
block_on(resolve(&client, &resolve_request))
|
||
};
|
||
collect(
|
||
env,
|
||
request,
|
||
dependency_plan,
|
||
Placement {
|
||
dir: &target_dir,
|
||
title: &request.title,
|
||
},
|
||
cancel,
|
||
progress,
|
||
tx,
|
||
epoch,
|
||
)
|
||
}
|
||
|
||
/// 「装到哪、那次叫什么」——这两样总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。
|
||
struct Placement<'a> {
|
||
/// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`)
|
||
dir: &'a Path,
|
||
/// 那次装入的显示名(失败名单里根项目的可读名就是它)
|
||
title: &'a str,
|
||
}
|
||
|
||
/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。
|
||
///
|
||
/// 收口的两件事在同一个地方做,是因为它们必须同时发生:
|
||
///
|
||
/// | 结果 | 计划 | 可重试名单 |
|
||
/// |---|---|---|
|
||
/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) |
|
||
/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) |
|
||
fn collect(
|
||
env: &HostEnv,
|
||
request: &ContentInstallRequest,
|
||
mut dependency_plan: DependencyPlan,
|
||
placement: Placement<'_>,
|
||
cancel: &AtomicBool,
|
||
progress: &mut InstallProgressView,
|
||
tx: &JobSender,
|
||
epoch: u64,
|
||
) -> Result<String, ContentFailure> {
|
||
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 core_engine = super::super::engine::engine(env);
|
||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
|
||
let installed = downloads::download_stage(
|
||
&mut dependency_plan,
|
||
placement.dir,
|
||
&fetcher,
|
||
cancel,
|
||
progress,
|
||
tx,
|
||
epoch,
|
||
)?;
|
||
|
||
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
|
||
// 并列出「可只重试这 N 个」。
|
||
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
|
||
progress.retry = plan::retry_items(&dependency_plan, placement.title);
|
||
plan::remember(
|
||
env,
|
||
&request.instance,
|
||
placement.dir,
|
||
placement.title,
|
||
&dependency_plan,
|
||
);
|
||
return Err(failure);
|
||
}
|
||
progress.retry.clear();
|
||
plan::forget(env, &request.instance);
|
||
Ok(plan::summary(&dependency_plan, &installed))
|
||
}
|
||
|
||
/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。
|
||
const RETRY_LOST: &str =
|
||
"没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。";
|
||
|
||
/// 完成态:`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;
|
||
}
|
||
|
||
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
|
||
pub(super) fn node_label(node: &PlannedNode) -> String {
|
||
if node.is_root() {
|
||
node.file.filename.clone()
|
||
} else {
|
||
format!("{}(前置)", node.file.filename)
|
||
}
|
||
}
|
||
|
||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||
pub(super) 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
|
||
}
|
||
}
|