From b0f747074f26f8e66919ff0694d2cf73c26257f5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 08:10:27 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(app):=20GUI=20=E6=8E=A5=E4=B8=8A=20For?= =?UTF-8?q?ge/NeoForge=20installer=20processors=EF=BC=88M45=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host/install_processors:GUI 与 CLI 同序跑 processors(计划 → 依赖库 → 选 Java → 串行 java -cp), 进度经 HostTask::Install 回流(阶段文案 + 每步处理器名 + 文件/字节/速度/ETA),取消在阶段边界生效; - host/install_journal:安装过程追加到 .ULL/logs/install.log,失败文案带日志路径,不再静默失败; - host/install.rs:supported() 扩到原版 + Fabric/Quilt/Forge/NeoForge,删除 Forge/NeoForge 拒装路径 (仅剩 OptiFine/LiteLoader 两家 core 侧无管线者),spawn_install 统一持 Job 上下文; - 失败诊断:缺 Java / processor 非零退出(保留 processor_* 大类码与脱敏 stderr 尾部)/ 依赖库下漏 / 元数据缺失,均为结构化文案 + Toast + 装日志; - dto::InstallStage::Installing 文案改「生成版本」(同时覆盖写版本 JSON 与跑 installer 两段); - screenshot:新增 --screenshot-loader,Forge/NeoForge 中间态可离屏取图; - 测试 +14(138 全绿):supported() 语义、处理器阶段注入 fake 后端覆盖跳过/缺 Java/非零退出/ 依赖库失败/命令行构造/进度文案,另修旧用例(Forge 不再被门禁拒装)。 --- crates/app/src/dto/install.rs | 2 +- crates/app/src/host/install.rs | 164 ++++++-- crates/app/src/host/install_journal.rs | 79 ++++ crates/app/src/host/install_processors.rs | 380 ++++++++++++++++++ .../src/host/install_processors_fixture.rs | 269 +++++++++++++ .../app/src/host/install_processors_tests.rs | 213 ++++++++++ crates/app/src/host/install_tests.rs | 133 ++++-- crates/app/src/host/mod.rs | 2 + crates/app/src/screenshot.rs | 19 +- 9 files changed, 1182 insertions(+), 79 deletions(-) create mode 100644 crates/app/src/host/install_journal.rs create mode 100644 crates/app/src/host/install_processors.rs create mode 100644 crates/app/src/host/install_processors_fixture.rs create mode 100644 crates/app/src/host/install_processors_tests.rs diff --git a/crates/app/src/dto/install.rs b/crates/app/src/dto/install.rs index 2537f78..a960a22 100644 --- a/crates/app/src/dto/install.rs +++ b/crates/app/src/dto/install.rs @@ -31,7 +31,7 @@ impl InstallStage { match self { Self::Planning => "解析版本", Self::Downloading => "下载文件", - Self::Installing => "写入版本", + Self::Installing => "生成版本", Self::Registering => "登记实例", Self::Done => "已完成", Self::Failed => "失败", diff --git a/crates/app/src/host/install.rs b/crates/app/src/host/install.rs index 59596d6..5dc56c9 100644 --- a/crates/app/src/host/install.rs +++ b/crates/app/src/host/install.rs @@ -1,12 +1,14 @@ -//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线(原版 + Fabric/Quilt)→ 登记实例。 +//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。 //! //! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。 -//! 差异:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA), +//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]): +//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。 +//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA), //! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。 //! -//! 加载器范围:原版 + Fabric/Quilt(纯库层)。Forge/NeoForge 要外调 java 跑 installer -//! processors(M35 的执行器在 CLI 侧),GUI 这一轮**显式拒装并说清去哪装**, -//! 而不是装出一个启动必崩的骨架。 +//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader +//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。 +//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。 use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,6 +29,8 @@ use crate::dto::{CreateRequest, InstallStage, LoaderKind}; use crate::toast::{Toast, ToastKind}; use super::env::HostEnv; +use super::install_journal::Journal; +use super::install_processors::{self, StageInput}; use super::job::{spawn, HostTask, JobSender}; use super::meter::{sink_for, Meter}; @@ -36,6 +40,26 @@ pub const CONCURRENCY: usize = 16; /// 用户取消的稳定文案(阶段边界生效)。 pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)"; +/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。 +struct Job<'a> { + env: &'a HostEnv, + tx: &'a JobSender, + meter: &'a Arc>, + cancel: &'a Arc, + journal: &'a Journal, +} + +impl Job<'_> { + /// processor 段的进度出口(同一套 channel/meter/journal)。 + fn reporter(&self) -> install_processors::Reporter<'_> { + install_processors::Reporter { + tx: self.tx, + meter: self.meter, + journal: self.journal, + } + } +} + /// 加载器徽标(`原版` / `Fabric 0.16.5`)。 pub fn loader_label(kind: LoaderKind, version: &str) -> String { if kind == LoaderKind::Vanilla || version.trim().is_empty() { @@ -45,20 +69,26 @@ pub fn loader_label(kind: LoaderKind, version: &str) -> String { } } -/// GUI 侧支持的加载器(Forge/NeoForge 需要 installer processors)。 +/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。 +/// +/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝, +/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。 pub fn supported(kind: LoaderKind) -> bool { matches!( kind, - LoaderKind::Vanilla | LoaderKind::Fabric | LoaderKind::Quilt + LoaderKind::Vanilla + | LoaderKind::Fabric + | LoaderKind::Quilt + | LoaderKind::Forge + | LoaderKind::NeoForge ) } /// 不支持的加载器 → 给用户的可执行建议。 pub fn unsupported_reason(kind: LoaderKind) -> String { format!( - "{} 需要 installer processors(外调 java 改 jar),GUI 这一轮还没接入;请先在终端跑 `ull install <版本> --loader {}`,装完回实例页刷新即可启动。", - kind.label(), - kind.as_str() + "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。", + kind.label() ) } @@ -66,13 +96,28 @@ pub fn unsupported_reason(kind: LoaderKind) -> String { pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc) { spawn("install", move || { let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request))); - let result = run(&env, &tx, &request, &meter, &cancel); + let journal = Journal::open(&env); + journal.line(&format!( + "—— 开始安装 {}({} {})——", + request.instance_name(), + loader_label(request.loader, ""), + request.version + )); + let job = Job { + env: &env, + tx: &tx, + meter: &meter, + cancel: &cancel, + journal: &journal, + }; + let result = run(&job, &request); let mut progress = meter .lock() .map(|guard| guard.progress.clone()) .unwrap_or_else(|_| Meter::new(&request).progress); match result { Ok(name) => { + journal.line(&format!("完成:实例 {name} 已登记")); progress.stage = InstallStage::Done; progress.files_done = progress.files_total; progress.message = format!("已安装 {name}({})", progress.loader); @@ -88,15 +133,24 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel }))); } Err(reason) => { - progress.stage = if reason == CANCELLED { + let cancelled = reason == CANCELLED; + progress.stage = if cancelled { InstallStage::Cancelled } else { InstallStage::Failed }; - progress.message.clone_from(&reason); + // 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失, + // 装日志是用户事后能自己回看的唯一渠道(进度板之外)。 + let text = if cancelled { + reason + } else { + journal.line(&format!("安装未完成:{reason}")); + format!("{reason}(日志:{})", journal.path().display()) + }; + progress.message.clone_from(&text); let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), - result: Err(reason), + result: Err(text), }); } } @@ -104,13 +158,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel } /// 真安装(同步跑完,调用方在后台线程里)。 -fn run( - env: &HostEnv, - tx: &JobSender, - request: &CreateRequest, - meter: &Arc>, - cancel: &Arc, -) -> Result { +fn run(job: &Job<'_>, request: &CreateRequest) -> Result { if request.version.trim().is_empty() { return Err("没有选择版本".to_string()); } @@ -118,26 +166,31 @@ fn run( return Err(unsupported_reason(request.loader)); } let name = request.instance_name(); + let env = job.env; let engine = super::engine::engine(env); { - let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?; guard.stage(InstallStage::Planning, "正在解析版本清单…"); - let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); + let _ = job + .tx + .send(HostTask::Install(Box::new(guard.progress.clone()))); } - if cancel.load(Ordering::SeqCst) { + if job.cancel.load(Ordering::SeqCst) { return Err(CANCELLED.to_string()); } let version_id = if request.loader == LoaderKind::Vanilla { - install_vanilla(env, &engine, request, tx, meter, cancel)? + install_vanilla(job, &engine, request)? } else { - install_loader(env, &engine, request, tx, meter, cancel)? + install_loader(job, &engine, request)? }; { - let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?; guard.stage(InstallStage::Registering, "正在登记实例…"); - let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); + let _ = job + .tx + .send(HostTask::Install(Box::new(guard.progress.clone()))); } let created_at = unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()); @@ -153,17 +206,15 @@ fn run( /// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。 fn install_vanilla( - env: &HostEnv, + job: &Job<'_>, engine: &DownloadEngine, request: &CreateRequest, - tx: &JobSender, - meter: &Arc>, - cancel: &Arc, ) -> Result { + let env = job.env; let version = request.version.trim(); let meta_cache = env.layout.meta_cache(); let now = now_ms(); - let sink = sink_for(tx.clone(), Arc::clone(meter)); + let sink = sink_for(job.tx.clone(), Arc::clone(job.meter)); block_on(async { let source = RemoteSource::new(engine, &env.game, &meta_cache, now); let resolved = source.resolve(version).await.map_err(install_error)?; @@ -197,7 +248,7 @@ fn install_vanilla( } let (bytes_total, _) = plan.total_bytes(); { - let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?; guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX); guard.progress.bytes_total = bytes_total; guard.stage( @@ -205,7 +256,7 @@ fn install_vanilla( &format!("开始下载 {} 个文件…", plan.file_count()), ); } - if cancel.load(Ordering::SeqCst) { + if job.cancel.load(Ordering::SeqCst) { return Err(CANCELLED.to_string()); } let report = execute(engine, &plan, CONCURRENCY, Some(sink)) @@ -216,15 +267,13 @@ fn install_vanilla( }) } -/// 加载器安装(原版前置 → 加载器库 → 版本 JSON 落盘)。 +/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。 fn install_loader( - env: &HostEnv, + job: &Job<'_>, engine: &DownloadEngine, request: &CreateRequest, - tx: &JobSender, - meter: &Arc>, - cancel: &Arc, ) -> Result { + let env = job.env; let version = request.version.trim(); let kind = CoreLoaderKind::parse(request.loader.as_str()) .ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?; @@ -237,6 +286,12 @@ fn install_loader( .await }) .map_err(|error| error.to_string())?; + job.journal.line(&format!( + "加载器解析:{} {} → {}", + release.kind.label(), + release.loader_version, + release.version_id + )); let plan = LoaderInstallPlan::build(LoaderPlanInput { layout: &env.game, @@ -247,10 +302,31 @@ fn install_loader( if plan.vanilla_install_needed { let mut vanilla = request.clone(); vanilla.loader = LoaderKind::Vanilla; - install_vanilla(env, engine, &vanilla, tx, meter, cancel)?; + install_vanilla(job, engine, &vanilla)?; + } + // Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。 + if release.requires_installer() { + let input = StageInput { + kind: release.kind, + mc_version: &release.mc_version, + version_id: &release.version_id, + profile: release.install_profile.as_ref(), + installer: release.installer.as_deref(), + installed: env.game.is_version_installed(&release.version_id), + }; + // `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤), + // 进度与日志都由处理器段自己写全,这里只消费返回值。 + let _ = install_processors::stage( + env, + engine, + &input, + &install_processors::CORE, + &job.reporter(), + job.cancel, + )?; } { - let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?; + let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?; guard.progress.files_total += u64::try_from(plan.files().len()).unwrap_or(u64::MAX); let (bytes, _) = plan.install.total_bytes(); guard.progress.bytes_total = guard.progress.bytes_total.max(bytes); @@ -259,10 +335,10 @@ fn install_loader( &format!("开始下载加载器库({} 个文件)…", plan.files().len()), ); } - if cancel.load(Ordering::SeqCst) { + if job.cancel.load(Ordering::SeqCst) { return Err(CANCELLED.to_string()); } - let sink = sink_for(tx.clone(), Arc::clone(meter)); + let sink = sink_for(job.tx.clone(), Arc::clone(job.meter)); let report = block_on(async { execute(engine, &plan.install, CONCURRENCY, Some(sink)) .await diff --git a/crates/app/src/host/install_journal.rs b/crates/app/src/host/install_journal.rs new file mode 100644 index 0000000..37d3776 --- /dev/null +++ b/crates/app/src/host/install_journal.rs @@ -0,0 +1,79 @@ +//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。 +//! +//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部 +//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/` +//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。 +//! +//! 三条纪律(与 auth 日志同源): +//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制); +//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏); +//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。 + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use unlucky_core::launch::time; + +use super::env::HostEnv; + +/// 日志文件名(在 `.ULL/logs/` 下)。 +pub const INSTALL_LOG_FILE: &str = "install.log"; + +/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。 +#[derive(Debug, Clone)] +pub struct Journal { + path: PathBuf, +} + +impl Journal { + /// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。 + pub fn open(env: &HostEnv) -> Self { + let dir = env.layout.logs(); + let _ = std::fs::create_dir_all(&dir); + Self { + path: dir.join(INSTALL_LOG_FILE), + } + } + + /// 日志路径(失败文案里给用户看)。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 追加一行(自动补 RFC3339 时间戳;写失败静默)。 + pub fn line(&self, text: &str) { + let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + else { + return; + }; + let _ = writeln!( + file, + "[{}] {text}", + time::format_rfc3339_millis(time::now()) + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_are_appended_with_a_timestamp() { + let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let env = HostEnv::at_root(&root); + let journal = Journal::open(&env); + journal.line("第一行"); + journal.line("第二行"); + let text = std::fs::read_to_string(journal.path()).expect("日志存在"); + assert!(text.contains("第一行") && text.contains("第二行"), "{text}"); + assert_eq!(text.lines().count(), 2); + assert!(text.starts_with("[2"), "带时间戳:{text}"); + assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE)); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/app/src/host/install_processors.rs b/crates/app/src/host/install_processors.rs new file mode 100644 index 0000000..13a01ce --- /dev/null +++ b/crates/app/src/host/install_processors.rs @@ -0,0 +1,380 @@ +//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。 +//! +//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/ +//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上: +//! +//! ```text +//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单) +//! → download_libraries(M27 引擎;进度回流 HostTask::Install) +//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘) +//! → run(串行 java -cp;每步一条面板文案 + 一行装日志) +//! ``` +//! +//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽): +//! +//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors +//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证); +//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过; +//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子), +//! 进入前与退出后各查一次 `cancel`; +//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方 +//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。 +//! +//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake, +//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK +//! 的机器上断言。 + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use unlucky_core::addon::loader::forge::InstallProfile; +use unlucky_core::addon::loader::processors::{ + self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink, + StepStatus, +}; +use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink}; +use unlucky_core::game::java::{JavaRequirement, JavaRuntime}; + +use crate::dto::InstallStage; + +use super::env::HostEnv; +use super::install::{CANCELLED, CONCURRENCY}; +use super::install_journal::Journal; +use super::job::{HostTask, JobSender}; +use super::meter::{sink_for, Meter}; + +/// 真实后端(无状态,可以放静态里复用)。 +pub(super) const CORE: CoreBackend = CoreBackend; + +/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。 +pub(super) struct StageInput<'a> { + /// 加载器种类(补库时 maven 基址的兜底来源)。 + pub kind: CoreLoaderKind, + /// 目标原版版本。 + pub mc_version: &'a str, + /// loader 版本 id(依赖库落点 + 临时目录名)。 + pub version_id: &'a str, + /// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。 + pub profile: Option<&'a InstallProfile>, + /// installer jar 的磁盘路径。 + pub installer: Option<&'a Path>, + /// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。 + pub installed: bool, +} + +/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。 +pub(super) trait Backend { + /// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。 + fn download_libraries( + &self, + engine: &DownloadEngine, + files: &[PlanFile], + sink: Option, + ) -> InstallReport; + /// 按 M33 的解析链挑一个能跑 installer 的 Java。 + fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option; + /// 串行跑 processors。 + fn run( + &self, + plan: &ProcessorPlan, + java: &JavaRuntime, + sink: Option, + ) -> Result; +} + +/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。 +pub(super) struct CoreBackend; + +impl Backend for CoreBackend { + fn download_libraries( + &self, + engine: &DownloadEngine, + files: &[PlanFile], + sink: Option, + ) -> InstallReport { + block_on(processors::download_libraries( + engine, + files, + CONCURRENCY, + sink, + )) + } + + fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option { + processors::select_java(managed, requirement) + } + + fn run( + &self, + plan: &ProcessorPlan, + java: &JavaRuntime, + sink: Option, + ) -> Result { + processors::run(plan, java, sink) + } +} + +/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。 +pub(super) struct Reporter<'a> { + /// 后台线程 → host 的通道。 + pub tx: &'a JobSender, + /// 进度记账器。 + pub meter: &'a Arc>, + /// 装日志。 + pub journal: &'a Journal, +} + +impl Reporter<'_> { + /// 换阶段并推一条进度。 + fn stage(&self, stage: InstallStage, message: &str) { + let Ok(mut guard) = self.meter.lock() else { + return; + }; + guard.stage(stage, message); + let _ = self + .tx + .send(HostTask::Install(Box::new(guard.progress.clone()))); + } + + /// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。 + fn expect(&self, files: usize, bytes: u64) { + let Ok(mut guard) = self.meter.lock() else { + return; + }; + let files = u64::try_from(files).unwrap_or(u64::MAX); + guard.progress.files_total = guard.progress.files_total.saturating_add(files); + guard.progress.bytes_total = guard.progress.bytes_total.saturating_add(bytes); + } + + /// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。 + fn fail(&self, reason: String) -> String { + self.journal.line(&format!("失败:{reason}")); + reason + } +} + +/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。 +/// +/// # Errors +/// +/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、 +/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。 +pub(super) fn stage( + env: &HostEnv, + engine: &DownloadEngine, + input: &StageInput<'_>, + backend: &dyn Backend, + reporter: &Reporter<'_>, + cancel: &Arc, +) -> Result, String> { + if input.installed { + reporter.journal.line(&format!( + "{} 的版本 JSON 已在盘上,跳过 installer processors", + input.version_id + )); + return Ok(None); + } + let (Some(profile), Some(installer)) = (input.profile, input.installer) else { + return Err(reporter.fail(metadata_missing(input))); + }; + reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…"); + let scratch = env + .layout + .cache() + .join("processors") + .join(safe_name(input.version_id)); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: input.kind, + mc_version: input.mc_version, + version_id: input.version_id, + profile, + layout: &env.game, + installer, + scratch: &scratch, + environment: &Environment::host(), + }) + .map_err(|error| reporter.fail(diagnostic(&error)))?; + for warning in &plan.warnings { + reporter.journal.line(&format!("规划告警:{warning}")); + } + if plan.is_empty() { + reporter + .journal + .line("install profile 没有 client 侧 processor,跳过这一段"); + return Ok(None); + } + + let total = plan.step_count(); + let bytes = plan.libraries.iter().filter_map(|file| file.size).sum(); + reporter.expect(plan.libraries.len(), bytes); + reporter.stage( + InstallStage::Downloading, + &format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()), + ); + if cancel.load(Ordering::SeqCst) { + return Err(CANCELLED.to_string()); + } + let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter)); + let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink)); + if !libraries.is_complete() { + return Err(reporter.fail(libraries_failed(&libraries))); + } + + let managed = env.layout.java_managed(); + let Some(java) = backend.select_java(&managed, &plan.java_requirement) else { + return Err(reporter.fail(java_missing(&plan.java_requirement, &managed))); + }; + reporter.journal.line(&format!( + "用 {}({})跑 {total} 步 processor", + java.binary.display(), + java_label(&java) + )); + reporter.stage( + InstallStage::Installing, + &format!("运行 installer 处理器 0/{total}({})…", java_label(&java)), + ); + if cancel.load(Ordering::SeqCst) { + return Err(CANCELLED.to_string()); + } + + let steps = step_sink( + reporter.tx.clone(), + Arc::clone(reporter.meter), + reporter.journal.clone(), + total, + ); + let run = backend + .run(&plan, &java, Some(steps)) + .map_err(|error| reporter.fail(diagnostic(&error)))?; + for warning in &run.warnings { + reporter.journal.line(&format!("执行告警:{warning}")); + } + reporter.journal.line(&format!( + "processors 完成:执行 {} 步、跳过 {} 步", + run.executed(), + run.skipped() + )); + reporter.stage( + InstallStage::Installing, + &format!( + "installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…", + run.executed(), + run.skipped() + ), + ); + if cancel.load(Ordering::SeqCst) { + return Err(CANCELLED.to_string()); + } + Ok(Some(run)) +} + +/// 每步完成的回调:面板一行 + 装日志一行。 +/// +/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。 +fn step_sink(tx: JobSender, meter: Arc>, journal: Journal, total: usize) -> StepSink { + Arc::new(move |outcome: &StepOutcome| { + let line = step_line(outcome, total); + journal.line(&line); + let Ok(mut guard) = meter.lock() else { + return; + }; + guard.stage(InstallStage::Installing, &line); + let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone()))); + }) +} + +/// 一步的进度/日志文案(纯函数,单测直接断言)。 +pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String { + let suffix = match outcome.status { + StepStatus::Skipped => "(产物已就绪,跳过)".to_string(), + StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms), + }; + format!( + "installer 处理器 {}/{}:{}{suffix}", + outcome.index, total, outcome.label + ) +} + +/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。 +pub(super) fn diagnostic(error: &ProcessorError) -> String { + let mut text = format!("{}({})", error.message(), error.kind().as_str()); + if let Some(hint) = error.hint() { + text.push_str(";建议:"); + text.push_str(hint); + } + text +} + +/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。 +pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String { + format!( + "跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})", + requirement.describe(), + managed.display() + ) +} + +/// 依赖库没下全 → 列前三条失败明细。 +pub(super) fn libraries_failed(report: &InstallReport) -> String { + let details: Vec = report + .failures(3) + .iter() + .filter_map(|outcome| { + outcome + .failure + .as_ref() + .map(|failure| format!("{}:{}", outcome.name, failure.message)) + }) + .collect(); + format!( + "installer 依赖库没下全({} 个失败,共 {} 个):{}", + report.failed(), + report.files.len(), + details.join(";") + ) +} + +/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。 +fn metadata_missing(input: &StageInput<'_>) -> String { + format!( + "{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)", + input.kind.label(), + input.version_id + ) +} + +/// Java 的一行短标识(`Java 21 · managed`)。 +fn java_label(java: &JavaRuntime) -> String { + match java.major { + Some(major) => format!("Java {major} · {}", java.origin.as_str()), + None => format!("Java 主版本未知 · {}", java.origin.as_str()), + } +} + +/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。 +fn safe_name(raw: &str) -> String { + raw.chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect::() + .trim_end_matches('.') + .to_string() +} + +#[cfg(test)] +#[path = "install_processors_fixture.rs"] +mod fixture; + +#[cfg(test)] +#[path = "install_processors_tests.rs"] +mod tests; diff --git a/crates/app/src/host/install_processors_fixture.rs b/crates/app/src/host/install_processors_fixture.rs new file mode 100644 index 0000000..30a03b8 --- /dev/null +++ b/crates/app/src/host/install_processors_fixture.rs @@ -0,0 +1,269 @@ +//! installer processors 段的单测夹具(拆文件守 400 行上限)。 +//! +//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道, +//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。 + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, Mutex}; + +use unlucky_core::addon::loader::forge::{InstallProfile, Processor}; +use unlucky_core::addon::loader::processors::{ + ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus, +}; +use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind; +use unlucky_core::download::{DownloadEngine, DownloadErrorKind}; +use unlucky_core::game::install::{ + FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink, +}; +use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime}; + +use super::{Backend, Reporter, StageInput}; +use crate::dto::{CreateRequest, LoaderKind}; +use crate::host::env::HostEnv; +use crate::host::install_journal::Journal; +use crate::host::job::{channel, HostTask, JobSender}; +use crate::host::meter::Meter; + +static SEQ: AtomicUsize = AtomicUsize::new(0); + +/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。 +pub(super) struct Harness { + dir: PathBuf, + pub(super) env: HostEnv, + pub(super) engine: DownloadEngine, + tx: JobSender, + rx: Receiver, + pub(super) meter: Arc>, + pub(super) journal: Journal, + pub(super) cancel: Arc, +} + +impl Harness { + pub(super) fn new() -> Self { + let seq = SEQ.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + let engine = crate::host::engine::engine(&env); + let request = CreateRequest { + version: "1.20.1".to_string(), + loader: LoaderKind::Forge, + loader_version: "47.2.0".to_string(), + name: "proc".to_string(), + }; + let (tx, rx) = channel(); + Self { + journal: Journal::open(&env), + meter: Arc::new(Mutex::new(Meter::new(&request))), + cancel: Arc::new(AtomicBool::new(false)), + dir, + env, + engine, + tx, + rx, + } + } + + pub(super) fn reporter(&self) -> Reporter<'_> { + Reporter { + tx: &self.tx, + meter: &self.meter, + journal: &self.journal, + } + } + + /// 进度面板收到的所有文案。 + pub(super) fn messages(&self) -> Vec { + self.rx + .try_iter() + .filter_map(|task| match task { + HostTask::Install(progress) => Some(progress.message.clone()), + _ => None, + }) + .collect() + } + + /// 装日志全文。 + pub(super) fn log(&self) -> String { + std::fs::read_to_string(self.journal.path()).unwrap_or_default() + } +} + +impl Drop for Harness { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.dir).ok(); + } +} + +/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。 +pub(super) struct Fake { + pub(super) libraries_ok: bool, + pub(super) java: Option, + pub(super) run_error: Option, + calls: RefCell>, + steps: RefCell, usize)>>, +} + +impl Fake { + pub(super) fn new() -> Self { + Self { + libraries_ok: true, + java: Some(java()), + run_error: None, + calls: RefCell::new(Vec::new()), + steps: RefCell::new(Vec::new()), + } + } + + pub(super) fn calls(&self) -> Vec<&'static str> { + self.calls.borrow().clone() + } + + /// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。 + pub(super) fn steps(&self) -> Vec<(usize, String, Vec, usize)> { + self.steps.borrow().clone() + } +} + +impl Backend for Fake { + fn download_libraries( + &self, + _engine: &DownloadEngine, + files: &[PlanFile], + _sink: Option, + ) -> InstallReport { + self.calls.borrow_mut().push("libraries"); + let ok = self.libraries_ok; + let outcomes = files + .iter() + .map(|file| FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: if ok { + FileStatus::Skipped + } else { + FileStatus::Failed + }, + bytes: 0, + source: None, + failure: (!ok).then(|| FileFailure { + kind: DownloadErrorKind::Network, + message: "连接超时".to_string(), + }), + }) + .collect(); + InstallReport { + files: outcomes, + version_json: None, + warnings: Vec::new(), + } + } + + fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option { + self.calls.borrow_mut().push("java"); + self.java.clone() + } + + fn run( + &self, + plan: &ProcessorPlan, + java: &JavaRuntime, + sink: Option, + ) -> Result { + self.calls.borrow_mut().push("run"); + *self.steps.borrow_mut() = plan + .steps + .iter() + .map(|step| { + ( + step.index, + step.label.clone(), + step.args.clone(), + step.classpath.len(), + ) + }) + .collect(); + if let Some(error) = &self.run_error { + return Err(error.clone()); + } + let outcomes: Vec = plan + .steps + .iter() + .map(|step| StepOutcome { + index: step.index, + label: step.label.clone(), + status: StepStatus::Executed, + outputs: step.outputs.len(), + elapsed_ms: 12, + stdout_tail: None, + stderr_tail: None, + }) + .collect(); + if let Some(sink) = &sink { + for outcome in &outcomes { + sink(outcome); + } + } + Ok(ProcessorRun { + java: java.clone(), + steps: outcomes, + warnings: Vec::new(), + }) + } +} + +/// 一个主版本已知的假 Java。 +pub(super) fn java() -> JavaRuntime { + JavaRuntime { + binary: PathBuf::from("java"), + origin: JavaOrigin::Managed, + version: Some("21.0.4".to_string()), + major: Some(21), + bits: 64, + vendor: Some("temurin".to_string()), + } +} + +/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。 +pub(super) fn profile() -> InstallProfile { + let mut profile = InstallProfile::default(); + profile.processors.push(Processor { + sides: vec!["client".to_string()], + jar: Some("net.minecraftforge:installertools:1.3.0".to_string()), + classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()], + args: vec!["--task".to_string(), "'MCP_DATA'".to_string()], + ..Processor::default() + }); + profile +} + +pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> { + StageInput { + kind: CoreLoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: Some(profile), + installer: Some(installer), + installed: false, + } +} + +pub(super) fn installer() -> PathBuf { + PathBuf::from("cache/meta/forge-installer.jar") +} + +pub(super) fn outcome(status: StepStatus) -> StepOutcome { + StepOutcome { + index: 3, + label: "net.minecraftforge:installertools:1.3.0".to_string(), + status, + outputs: 2, + elapsed_ms: 1234, + stdout_tail: None, + stderr_tail: None, + } +} diff --git a/crates/app/src/host/install_processors_tests.rs b/crates/app/src/host/install_processors_tests.rs new file mode 100644 index 0000000..dc22eed --- /dev/null +++ b/crates/app/src/host/install_processors_tests.rs @@ -0,0 +1,213 @@ +//! host/install_processors.rs 的单测(拆文件守 400 行上限)。 +//! +//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」 +//! 「哪一步挂了怎么报」「进度文案长什么样」。 + +use std::path::PathBuf; + +use unlucky_core::addon::loader::forge::InstallProfile; +use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus}; +use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind; +use unlucky_core::download::DownloadErrorKind; +use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport}; +use unlucky_core::game::java::JavaRequirement; + +use super::fixture::{input, installer, outcome, profile, Fake, Harness}; +use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput}; + +#[test] +fn step_line_names_the_step_and_marks_the_outcome() { + let executed = step_line(&outcome(StepStatus::Executed), 9); + assert_eq!( + executed, + "installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)" + ); + let skipped = step_line(&outcome(StepStatus::Skipped), 9); + assert!(skipped.contains("产物已就绪"), "{skipped}"); +} + +#[test] +fn diagnostic_keeps_the_stable_code_and_the_hint() { + let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom") + .with_hint("确认 Java 版本与磁盘权限"); + let text = diagnostic(&failed); + assert!(text.contains("processor_failed"), "{text}"); + assert!(text.contains("退出码 1"), "{text}"); + assert!(text.contains("boom"), "stderr 尾部要留:{text}"); + assert!(text.contains("建议:确认 Java 版本"), "{text}"); + + let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`")); + assert!(plan.contains("processor_plan"), "{plan}"); +} + +#[test] +fn java_missing_points_at_the_java_page() { + let managed = PathBuf::from("/tmp/.ULL/java"); + let text = java_missing(&JavaRequirement::table(21, None), &managed); + assert!(text.contains("Java 21+"), "{text}"); + assert!(text.contains("Java」页"), "{text}"); + assert!(text.contains("JAVA_HOME"), "{text}"); + assert!(text.contains(&managed.display().to_string()), "{text}"); +} + +#[test] +fn libraries_failed_lists_at_most_three() { + let files: Vec = (0..4) + .map(|index| FileOutcome { + role: FileRole::Library, + name: format!("lib{index}.jar"), + dest: PathBuf::from(format!("lib{index}.jar")), + status: FileStatus::Failed, + bytes: 0, + source: None, + failure: Some(FileFailure { + kind: DownloadErrorKind::Network, + message: "连接超时".to_string(), + }), + }) + .collect(); + let report = InstallReport { + files, + version_json: None, + warnings: Vec::new(), + }; + let text = libraries_failed(&report); + assert!(text.contains("4 个失败,共 4 个"), "{text}"); + assert!(text.contains("lib0.jar"), "{text}"); + assert!(!text.contains("lib3.jar"), "最多列三条:{text}"); +} + +#[test] +fn stage_skips_when_the_version_json_is_already_there() { + let h = Harness::new(); + let installer = installer(); + let profile = profile(); + let mut input = input(&profile, &installer); + input.installed = true; + let fake = Fake::new(); + let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel); + assert!(matches!(result, Ok(None))); + assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java"); + assert!(h.log().contains("跳过 installer processors"), "{}", h.log()); +} + +#[test] +fn stage_skips_an_empty_processor_list() { + let h = Harness::new(); + let installer = installer(); + let profile = InstallProfile::default(); + let input = input(&profile, &installer); + let fake = Fake::new(); + let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel); + assert!(matches!(result, Ok(None)), "没有 processor 就没这段"); + assert!(fake.calls().is_empty()); +} + +#[test] +fn stage_reports_missing_installer_metadata() { + let h = Harness::new(); + let input = StageInput { + kind: CoreLoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: None, + installer: None, + installed: false, + }; + let fake = Fake::new(); + let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err(); + assert!(err.contains("install profile"), "{err}"); + assert!(h.log().contains("失败:"), "{}", h.log()); +} + +#[test] +fn stage_builds_the_command_line_from_the_install_profile() { + let h = Harness::new(); + let installer = installer(); + let profile = profile(); + let input = input(&profile, &installer); + let fake = Fake::new(); + let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel) + .expect("stage 成功") + .expect("应当真的跑了 processors"); + assert_eq!(run.executed(), 1); + let steps = fake.steps(); + assert_eq!(steps.len(), 1); + let (index, label, args, classpath) = &steps[0]; + assert_eq!(*index, 1); + assert_eq!(label, "net.minecraftforge:installertools:1.3.0"); + assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]); + assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)"); + assert_eq!(fake.calls(), vec!["libraries", "java", "run"]); + let messages = h.messages(); + assert!( + messages.iter().any(|m| m.contains("installer 处理器 1/1")), + "面板要看到当前处理器:{messages:?}" + ); + assert!( + messages + .last() + .is_some_and(|m| m.contains("正在写入版本清单")), + "{messages:?}" + ); + assert!( + h.log().contains("processors 完成:执行 1 步"), + "{}", + h.log() + ); +} + +#[test] +fn stage_fails_loudly_without_a_usable_java() { + let h = Harness::new(); + let installer = installer(); + let profile = profile(); + let input = input(&profile, &installer); + let mut fake = Fake::new(); + fake.java = None; + let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err(); + assert!(err.contains("Java"), "{err}"); + assert!(err.contains("托管目录"), "{err}"); + assert_eq!( + fake.calls(), + vec!["libraries", "java"], + "没 Java 就不该起进程" + ); + assert!(h.log().contains("失败:"), "{}", h.log()); +} + +#[test] +fn stage_fails_loudly_when_a_processor_exits_nonzero() { + let h = Harness::new(); + let installer = installer(); + let profile = profile(); + let input = input(&profile, &installer); + let mut fake = Fake::new(); + fake.run_error = Some(ProcessorError::failed( + "net.minecraftforge:installertools:1.3.0", + "1", + "java.lang.NoSuchMethodError", + )); + let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err(); + assert!(err.contains("processor_failed"), "{err}"); + assert!(err.contains("NoSuchMethodError"), "{err}"); + let log = h.log(); + assert!(log.contains("失败:"), "{log}"); + assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}"); +} + +#[test] +fn stage_reports_incomplete_library_downloads() { + let h = Harness::new(); + let installer = installer(); + let profile = profile(); + let input = input(&profile, &installer); + let mut fake = Fake::new(); + fake.libraries_ok = false; + let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err(); + assert!(err.contains("依赖库没下全"), "{err}"); + assert!(err.contains("连接超时"), "{err}"); + assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java"); + let progress = h.meter.lock().expect("进度锁").progress.clone(); + assert!(progress.files_total >= 2, "依赖库要计入文件总数"); +} diff --git a/crates/app/src/host/install_tests.rs b/crates/app/src/host/install_tests.rs index 4e724b5..ea46c23 100644 --- a/crates/app/src/host/install_tests.rs +++ b/crates/app/src/host/install_tests.rs @@ -1,24 +1,105 @@ //! host/install.rs 的单测(拆文件守 400 行上限)。 use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus}; -use super::{run, supported, unsupported_reason}; +use super::{loader_label, run, supported, unsupported_reason, Job}; use crate::dto::{CreateRequest, LoaderKind}; use crate::host::env::HostEnv; +use crate::host::install_journal::Journal; +use crate::host::job::{channel, JobSender}; use crate::host::meter::Meter; +static SEQ: AtomicUsize = AtomicUsize::new(0); + +/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。 +struct Fixture { + dir: PathBuf, + env: HostEnv, + tx: JobSender, + meter: Arc>, + cancel: Arc, + journal: Journal, +} + +impl Fixture { + fn new(request: &CreateRequest) -> Self { + let seq = SEQ.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + let journal = Journal::open(&env); + Self { + dir, + env, + tx: channel().0, + meter: Arc::new(Mutex::new(Meter::new(request))), + cancel: Arc::new(AtomicBool::new(false)), + journal, + } + } + + fn job(&self) -> Job<'_> { + Job { + env: &self.env, + tx: &self.tx, + meter: &self.meter, + cancel: &self.cancel, + journal: &self.journal, + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.dir).ok(); + } +} + +fn request(loader: LoaderKind, version: &str) -> CreateRequest { + CreateRequest { + version: version.to_string(), + loader, + loader_version: String::new(), + name: String::new(), + } +} + #[test] fn loader_support_matrix_is_explicit() { - assert!(supported(LoaderKind::Vanilla)); - assert!(supported(LoaderKind::Fabric)); - assert!(supported(LoaderKind::Quilt)); - assert!(!supported(LoaderKind::Forge)); - assert!(!supported(LoaderKind::NeoForge)); - assert!(unsupported_reason(LoaderKind::Forge).contains("ull install")); + // M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。 + for kind in [ + LoaderKind::Vanilla, + LoaderKind::Fabric, + LoaderKind::Quilt, + LoaderKind::Forge, + LoaderKind::NeoForge, + ] { + assert!(supported(kind), "{} 应当受支持", kind.label()); + } + // 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。 + for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] { + assert!(!supported(kind), "{} 不该被当成可装", kind.label()); + let reason = unsupported_reason(kind); + assert!(reason.contains(kind.label()), "{reason}"); + assert!(reason.contains("还没有接入"), "{reason}"); + } +} + +#[test] +fn forge_install_is_no_longer_refused_at_the_door() { + // 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。 + assert!(supported(LoaderKind::Forge)); + assert!(supported(LoaderKind::NeoForge)); + assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0"); + assert_eq!( + loader_label(LoaderKind::NeoForge, "21.1.80"), + "NeoForge 21.1.80" + ); } #[test] @@ -57,30 +138,16 @@ fn meter_counts_files_and_skips_skipped_bytes() { #[test] fn empty_version_is_rejected_before_network() { - let dir = std::env::temp_dir().join(format!("ull-host-install-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let env = HostEnv::at_root(&dir); - let (tx, _rx) = super::super::job::channel(); - let request = CreateRequest { - version: String::new(), - loader: LoaderKind::Vanilla, - loader_version: String::new(), - name: String::new(), - }; - let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request))); - let cancel = Arc::new(AtomicBool::new(false)); - let err = run(&env, &tx, &request, &meter, &cancel).unwrap_err(); + let request = request(LoaderKind::Vanilla, ""); + let fixture = Fixture::new(&request); + let err = run(&fixture.job(), &request).unwrap_err(); assert!(err.contains("没有选择版本"), "{err}"); +} - let forge = CreateRequest { - version: "1.20.1".to_string(), - loader: LoaderKind::Forge, - ..request - }; - let meter = Arc::new(std::sync::Mutex::new(Meter::new(&forge))); - let cancel = Arc::new(AtomicBool::new(false)); - assert!(run(&env, &tx, &forge, &meter, &cancel) - .unwrap_err() - .contains("installer processors")); - std::fs::remove_dir_all(&dir).ok(); +#[test] +fn unsupported_loader_is_rejected_before_network() { + let request = request(LoaderKind::OptiFine, "1.20.1"); + let fixture = Fixture::new(&request); + let err = run(&fixture.job(), &request).unwrap_err(); + assert!(err.contains("还没有接入"), "{err}"); } diff --git a/crates/app/src/host/mod.rs b/crates/app/src/host/mod.rs index d009cf3..5d72d65 100644 --- a/crates/app/src/host/mod.rs +++ b/crates/app/src/host/mod.rs @@ -20,6 +20,8 @@ mod game; mod handle; mod identity; mod install; +mod install_journal; +mod install_processors; mod java; mod job; mod launch; diff --git a/crates/app/src/screenshot.rs b/crates/app/src/screenshot.rs index 6704033..50d74a4 100644 --- a/crates/app/src/screenshot.rs +++ b/crates/app/src/screenshot.rs @@ -24,6 +24,8 @@ pub struct ShotOptions { pub wizard: bool, /// 发起一次真实安装并停 `dwell_ms` 后取图(中间态) pub install: Option, + /// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版) + pub loader: Option, /// 发起一次真实启动并停 `dwell_ms` 后取图(中间态) pub launch: Option, /// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片) @@ -58,6 +60,7 @@ pub fn shot_options() -> Option { }, wizard: flag("--screenshot-wizard"), install: value_of("--screenshot-install"), + loader: value_of("--screenshot-loader"), launch: value_of("--screenshot-launch"), session: value_of("--screenshot-session"), dwell_ms: value_of("--screenshot-dwell") @@ -92,7 +95,7 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform } if let Some(version) = &options.install { app.dispatch_and_run( - AppCommand::InstallVersion(version.clone()), + install_command(version, options.loader.as_deref()), options.dwell_ms, ); } @@ -115,6 +118,20 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform Ok(()) } +/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路 +/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。 +fn install_command(version: &str, loader: Option<&str>) -> AppCommand { + match loader.map(crate::dto::LoaderKind::parse) { + Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest { + version: version.to_string(), + loader: kind, + loader_version: String::new(), + name: version.to_string(), + }), + _ => AppCommand::InstallVersion(version.to_string()), + } +} + /// `--flag` 是否存在。 pub fn flag(name: &str) -> bool { std::env::args().skip(1).any(|arg| arg == name) From 3d9372029e65dfd103a6a808c5fbae60a5f967b9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 08:26:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(app):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=99=A8=E6=B5=8B=E8=AF=95=E5=A4=B9=E5=85=B7=E7=94=A8=20StepVi?= =?UTF-8?q?ew=20=E5=88=AB=E5=90=8D=EF=BC=8C=E8=BF=87=20clippy::type=5Fcomp?= =?UTF-8?q?lexity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clippy --all-targets -D warnings 报 very complex type(RefCell, usize)>>), 抽 pub(super) type StepView 别名,语义不变。 --- crates/app/src/host/install_processors_fixture.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/app/src/host/install_processors_fixture.rs b/crates/app/src/host/install_processors_fixture.rs index 30a03b8..3f96509 100644 --- a/crates/app/src/host/install_processors_fixture.rs +++ b/crates/app/src/host/install_processors_fixture.rs @@ -29,6 +29,9 @@ use crate::host::meter::Meter; static SEQ: AtomicUsize = AtomicUsize::new(0); +/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。 +pub(super) type StepView = (usize, String, Vec, usize); + /// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。 pub(super) struct Harness { dir: PathBuf, @@ -104,7 +107,7 @@ pub(super) struct Fake { pub(super) java: Option, pub(super) run_error: Option, calls: RefCell>, - steps: RefCell, usize)>>, + steps: RefCell>, } impl Fake { @@ -123,7 +126,7 @@ impl Fake { } /// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。 - pub(super) fn steps(&self) -> Vec<(usize, String, Vec, usize)> { + pub(super) fn steps(&self) -> Vec { self.steps.borrow().clone() } } From d95c9664c691f29452248a06bf55a957218af6a9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 09:02:39 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(app):=20M45=20r1=20=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=20P2=EF=BC=88=E5=90=91=E5=AF=BC=E6=8B=92=E8=A3=85=E6=96=87?= =?UTF-8?q?=E6=A1=88=20+=20=E5=A4=84=E7=90=86=E5=99=A8=E6=AE=B5=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=BB=A1=E6=A0=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1:wizard.slint 的 Forge/NeoForge 提示还写着「需要 installer processors:本轮请用 ull install」, 改成「需要 installer processors(首装会外调 java,约 1–3 分钟)」;全仓再扫一遍 「ull install / 显式拒装 / 终端跑」等措辞,user-facing 文案已无残留(余下命中全是 OptiFine/LiteLoader 的正确说明与测试注释)。 P2-2:进度条在处理器依赖库下完那一刻就满格(36/36),随后加载器库的 29 个文件才被计入 → 100% 回落再涨回,且 bytes_total 用 .max() 与文件数对不上。 - Meter 新增 expect(files, bytes):文件数与字节都 saturating_add; - install_loader 在处理器段之前就把加载器库的文件/字节一次预记账(两段之和), 加载器库阶段只剩切阶段文案,不再记账; - Reporter::expect 改为委托 Meter::expect,处理器依赖库沿用同一条曲线; - 回归测试钉住:预记账后 files_total=95、bytes_total=17MB、ratio 不再满格, 处理器库下完仍是中间态,加载器库下完才到 100%。 真机重截 + 识图:D:/tmp/ull-m45-shots/forge-mid-fixed-25s.png —— 同一时刻面板 36/65 文件、3.4 MB/15.3 MB、进度条只填了约一半(旧图 36/36 + 满格)。 --- crates/app/src/host/install.rs | 10 ++-- crates/app/src/host/install_processors.rs | 4 +- crates/app/src/host/install_tests.rs | 65 +++++++++++++++++++++++ crates/app/src/host/meter.rs | 10 ++++ crates/app/ui/widgets/wizard.slint | 4 +- 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/crates/app/src/host/install.rs b/crates/app/src/host/install.rs index 5dc56c9..55e368f 100644 --- a/crates/app/src/host/install.rs +++ b/crates/app/src/host/install.rs @@ -304,6 +304,13 @@ fn install_loader( vanilla.loader = LoaderKind::Vanilla; install_vanilla(job, engine, &vanilla)?; } + // 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线, + // 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。 + { + let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?; + let (bytes, _) = plan.install.total_bytes(); + guard.expect(plan.files().len(), bytes); + } // Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。 if release.requires_installer() { let input = StageInput { @@ -327,9 +334,6 @@ fn install_loader( } { let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?; - guard.progress.files_total += u64::try_from(plan.files().len()).unwrap_or(u64::MAX); - let (bytes, _) = plan.install.total_bytes(); - guard.progress.bytes_total = guard.progress.bytes_total.max(bytes); guard.stage( InstallStage::Downloading, &format!("开始下载加载器库({} 个文件)…", plan.files().len()), diff --git a/crates/app/src/host/install_processors.rs b/crates/app/src/host/install_processors.rs index 13a01ce..cb8255c 100644 --- a/crates/app/src/host/install_processors.rs +++ b/crates/app/src/host/install_processors.rs @@ -146,9 +146,7 @@ impl Reporter<'_> { let Ok(mut guard) = self.meter.lock() else { return; }; - let files = u64::try_from(files).unwrap_or(u64::MAX); - guard.progress.files_total = guard.progress.files_total.saturating_add(files); - guard.progress.bytes_total = guard.progress.bytes_total.saturating_add(bytes); + guard.expect(files, bytes); } /// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。 diff --git a/crates/app/src/host/install_tests.rs b/crates/app/src/host/install_tests.rs index ea46c23..5b46f36 100644 --- a/crates/app/src/host/install_tests.rs +++ b/crates/app/src/host/install_tests.rs @@ -69,6 +69,71 @@ fn request(loader: LoaderKind, version: &str) -> CreateRequest { } } +/// 一个「刚下完」的文件结局(记账用)。 +fn downloaded(bytes: u64) -> FileOutcome { + FileOutcome { + role: FileRole::Library, + name: "lib.jar".to_string(), + dest: PathBuf::from("lib.jar"), + status: FileStatus::Downloaded, + bytes, + source: None, + failure: None, + } +} + +#[test] +fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() { + // M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB, + // 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。 + let request = request(LoaderKind::Forge, "1.20.1"); + let mut meter = Meter::new(&request); + + // 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加) + meter.progress.files_total = 30; + meter.progress.bytes_total = 3_000_000; + for _ in 0..30 { + meter.note(&downloaded(100_000)); + } + assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON); + + // install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账 + meter.expect(29, 10_000_000); + meter.expect(36, 4_000_000); + assert_eq!(meter.progress.files_total, 95, "两段之和一次到位"); + assert_eq!( + meter.progress.bytes_total, 17_000_000, + "字节是相加不是取最大" + ); + assert!( + meter.progress.ratio() < 0.7, + "预记账后不能还停在满格:{}", + meter.progress.ratio() + ); + + // 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面) + for _ in 0..36 { + meter.note(&downloaded(110_000)); + } + let ratio = meter.progress.ratio(); + assert!( + (0.6..1.0).contains(&ratio), + "回落到中间态而不是满格:{ratio}" + ); + assert!( + meter.progress.bytes_done < meter.progress.bytes_total, + "字节总量要覆盖已下载量:{}/{}", + meter.progress.bytes_done, + meter.progress.bytes_total + ); + + // 加载器库也下完 → 这时候才该是满格 + for _ in 0..29 { + meter.note(&downloaded(340_000)); + } + assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON); +} + #[test] fn loader_support_matrix_is_explicit() { // M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。 diff --git a/crates/app/src/host/meter.rs b/crates/app/src/host/meter.rs index 813a54f..0962dd9 100644 --- a/crates/app/src/host/meter.rs +++ b/crates/app/src/host/meter.rs @@ -37,6 +37,16 @@ impl Meter { self.previous = None; } + /// 预记账一段待下载的文件(文件数 + 字节)。 + /// + /// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账, + /// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。 + pub(super) fn expect(&mut self, files: usize, bytes: u64) { + let files = u64::try_from(files).unwrap_or(u64::MAX); + self.progress.files_total = self.progress.files_total.saturating_add(files); + self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes); + } + /// 记账一个文件的结局。 pub(super) fn note(&mut self, outcome: &FileOutcome) { self.progress.files_done += 1; diff --git a/crates/app/ui/widgets/wizard.slint b/crates/app/ui/widgets/wizard.slint index fdbc33b..dda00f2 100644 --- a/crates/app/ui/widgets/wizard.slint +++ b/crates/app/ui/widgets/wizard.slint @@ -196,8 +196,8 @@ export component NewInstanceWizard inherits Rectangle { LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } } LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } } LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } } - LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(3); } } - LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(4); } } + LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } } + LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } } } // ③ 名字