//! 「一键装入指定实例」(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 { 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 = 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) -> ContentFailure { ContentFailure::new(ContentFailureKind::Other, reason) } fn find_instance(env: &HostEnv, id: &str) -> Result { 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 { 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 } }