diff --git a/crates/cli/src/commands/install/loader.rs b/crates/cli/src/commands/install/loader.rs index 4a7fb7c..630f275 100644 --- a/crates/cli/src/commands/install/loader.rs +++ b/crates/cli/src/commands/install/loader.rs @@ -26,8 +26,8 @@ use unlucky_core::game::install::execute; use super::report_loader::ProcessorSection; use super::{ - build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, - register_instance, report, report_loader, vanilla, CONCURRENCY, + build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, progress, + register_instance, report_loader, vanilla, CONCURRENCY, }; use crate::cli::InstallArgs; use crate::context::Ctx; @@ -119,7 +119,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result Result<(InstallReport, ProcessorRun), CliError> { - let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len()); + let sink = progress::progress_sink(args.progress, args.json_progress, plan.libraries.len()); let libraries = block_on(processors::download_libraries( engine, &plan.libraries, diff --git a/crates/cli/src/commands/install/mod.rs b/crates/cli/src/commands/install/mod.rs index 16452d3..fcd31f2 100644 --- a/crates/cli/src/commands/install/mod.rs +++ b/crates/cli/src/commands/install/mod.rs @@ -12,6 +12,7 @@ mod loader; mod loader_processors; +mod progress; mod report; mod report_loader; mod vanilla; diff --git a/crates/cli/src/commands/install/progress.rs b/crates/cli/src/commands/install/progress.rs new file mode 100644 index 0000000..204b2f7 --- /dev/null +++ b/crates/cli/src/commands/install/progress.rs @@ -0,0 +1,300 @@ +//! stderr 实时进度(原版安装 / 加载器库 / processor 依赖库三段共用)。 +//! +//! **M210/D11 的教训**:进度行的数字必须是**已经发生的事实**(每个文件的 [`FileStatus`]), +//! 不能拿「处理到第几个文件」冒充「下载了几个」。旧实现在回调里只累加一个 `done` 就打印 +//! `下载 {done}/{total}`,于是重装同版本(3961 个文件全部已就绪、一个字节都没下)时会连打 +//! `下载 32/3961 … 3961/3961`——进度条在跑,下载量是 0,用户在读一句假话。 +//! +//! 现在分三个计数(下载 / 跳过 / 失败),人读行的措辞随计数走:下载数为 0 时**不出现「下载」 +//! 二字**,改说「已就绪 N/M(跳过 N 个)」。 + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink}; + +use crate::output::progress_line; + +/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。 +const PROGRESS_EVERY: usize = 32; + +/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。 +const MAX_PROGRESS_FAILURES: usize = 5; + +/// 一次进度快照:每个字段都是**真实结局**的计数。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Snapshot { + /// 已处理文件数(含跳过与失败)。 + pub done: usize, + /// 计划文件总数。 + pub total: usize, + /// 本次真的下载并落盘的文件数。 + pub downloaded: usize, + /// 已存在且校验通过、本次跳过的文件数。 + pub skipped: usize, + /// 失败文件数。 + pub failed: usize, +} + +impl Snapshot { + /// 人读进度行。 + /// + /// 下载数为 0 时**不提「下载」**:本次一个字节都没下,再写「下载 0/N」也只是换个说法的假进度。 + pub(crate) fn text(&self) -> String { + let failure = if self.failed > 0 { + format!(" / 失败 {}", self.failed) + } else { + String::new() + }; + if self.downloaded == 0 { + format!( + "已就绪 {}/{}(跳过 {} 个{failure})", + self.done, self.total, self.skipped + ) + } else { + format!( + "下载 {} 个 / 跳过 {} 个(进度 {}/{}{failure})", + self.downloaded, self.skipped, self.done, self.total + ) + } + } + + /// `--json` 模式的进度事件(字段名稳定,`done`/`total` 保持旧名以兼容脚本)。 + fn json(&self) -> serde_json::Value { + serde_json::json!({ + "event": "progress", + "done": self.done, + "total": self.total, + "downloaded": self.downloaded, + "skipped": self.skipped, + "failed": self.failed, + }) + } +} + +/// 计数:回调来自多个下载线程,全部原子累加。 +#[derive(Debug, Default)] +struct Tally { + done: AtomicUsize, + downloaded: AtomicUsize, + skipped: AtomicUsize, + failed: AtomicUsize, + failure_lines: AtomicUsize, +} + +impl Tally { + /// 记一个结局,返回记完之后的快照。 + fn observe(&self, outcome: &FileOutcome, total: usize) -> Snapshot { + let done = self.done.fetch_add(1, Ordering::Relaxed) + 1; + match outcome.status { + FileStatus::Downloaded => { + self.downloaded.fetch_add(1, Ordering::Relaxed); + } + FileStatus::Skipped => { + self.skipped.fetch_add(1, Ordering::Relaxed); + } + FileStatus::Failed => { + self.failed.fetch_add(1, Ordering::Relaxed); + } + } + Snapshot { + done, + total, + downloaded: self.downloaded.load(Ordering::Relaxed), + skipped: self.skipped.load(Ordering::Relaxed), + failed: self.failed.load(Ordering::Relaxed), + } + } +} + +/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。 +/// +/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流), +/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。 +/// +/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。 +pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option { + sink_with(enabled, json, total, Arc::new(progress_line)) +} + +/// 可注入输出端的构造器(测试据此抓行;生产只有一个调用者 [`progress_sink`])。 +fn sink_with( + enabled: bool, + json: bool, + total: usize, + out: Arc, +) -> Option { + if !enabled { + return None; + } + let tally = Arc::new(Tally::default()); + Some(Arc::new(move |outcome: &FileOutcome| { + let snapshot = tally.observe(outcome, total); + if let Some(failure) = &outcome.failure { + let seen = tally.failure_lines.fetch_add(1, Ordering::Relaxed); + if seen < MAX_PROGRESS_FAILURES { + emit( + json, + serde_json::json!({ + "event": "file_failed", + "done": snapshot.done, + "total": total, + "name": outcome.name, + "role": outcome.role.as_str(), + "kind": failure.kind.as_str(), + "message": failure.message, + }), + &format!( + "失败 {}/{} {}:{}", + snapshot.done, total, outcome.name, failure.message + ), + &out, + ); + } else if seen == MAX_PROGRESS_FAILURES { + emit( + json, + serde_json::json!({ + "event": "progress_truncated", + "suppressed_after": MAX_PROGRESS_FAILURES, + }), + "(更多失败不再逐条打印,见最终错误汇总)", + &out, + ); + } + return; + } + if snapshot.done.is_multiple_of(PROGRESS_EVERY) || snapshot.done == total { + emit(json, snapshot.json(), &snapshot.text(), &out); + } + })) +} + +/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。 +fn emit( + json_mode: bool, + record: serde_json::Value, + text: &str, + out: &Arc, +) { + if json_mode { + let line = record.to_string(); + out(&line); + } else { + out(text); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use unlucky_core::download::DownloadErrorKind; + use unlucky_core::game::install::{FileFailure, FileRole}; + + use super::*; + + /// 抓行的输出端。 + fn recorder() -> (Arc, Arc>>) { + let lines = Arc::new(Mutex::new(Vec::new())); + let sink_lines = Arc::clone(&lines); + let out: Arc = + Arc::new(move |line: &str| sink_lines.lock().expect("锁").push(line.to_string())); + (out, lines) + } + + fn outcome(name: &str, status: FileStatus) -> FileOutcome { + FileOutcome { + role: FileRole::Asset, + name: name.to_string(), + dest: std::path::PathBuf::from(format!("assets/{name}")), + status, + bytes: 1234, + source: Some("cache".to_string()), + failure: None, + } + } + + /// **D11 用例**:全部已存在(重装同版本)⇒ 进度行里不出现「下载」,下载计数为 0。 + /// + /// 抓得住:把「处理序号」当下载量打印的旧实现(它会打出 `下载 32/96` 这类行); + /// 以及任何「下载数 > 0 却说没下载」的表述。抓不住:行里出现的其它夸张词(措辞审查), + /// 也不管单行是否真的落到 stderr(那是 [`crate::output::progress_line`] 的事)。 + #[test] + fn all_skipped_progress_never_claims_downloads() { + const TOTAL: usize = 96; // 3 的倍数,必然跨过 PROGRESS_EVERY + let (out, lines) = recorder(); + let sink = sink_with(true, false, TOTAL, out).expect("进度开关打开时必须有 sink"); + for index in 0..TOTAL { + sink(&outcome(&format!("asset-{index}"), FileStatus::Skipped)); + } + let lines = lines.lock().expect("锁").clone(); + assert!(!lines.is_empty(), "应当有进度行"); + for line in &lines { + assert!( + !line.contains("下载"), + "全部跳过时进度行不许提「下载」:{line}" + ); + } + let last = lines.last().expect("至少一行"); + assert!( + last.contains(&format!("已就绪 {TOTAL}/{TOTAL}")) + && last.contains(&format!("跳过 {TOTAL}")), + "全跳过应当说「已就绪 N/N(跳过 N 个)」:{last}" + ); + } + + /// 同一场景的 `--json` 形态:`downloaded` 必须是 0(脚本据此判断「其实没下」)。 + #[test] + fn all_skipped_json_progress_reports_zero_downloads() { + const TOTAL: usize = 32; + let (out, lines) = recorder(); + let sink = sink_with(true, true, TOTAL, out).expect("进度开关打开时必须有 sink"); + for index in 0..TOTAL { + sink(&outcome(&format!("lib-{index}"), FileStatus::Skipped)); + } + let records: Vec = lines + .lock() + .expect("锁") + .iter() + .map(|line| serde_json::from_str(line).expect("JSONL")) + .collect(); + let progress = records + .iter() + .find(|record| record["event"] == "progress") + .expect("应当有 progress 事件"); + assert_eq!(progress["downloaded"], 0); + assert_eq!(progress["skipped"], TOTAL); + assert_eq!(progress["done"], TOTAL); + assert_eq!(progress["total"], TOTAL); + } + + /// 真的有下载时,计数与措辞都跟着事实走(避免「一刀切不打印」这种假修复)。 + #[test] + fn mixed_outcomes_report_real_counts() { + let (out, lines) = recorder(); + let sink = sink_with(true, false, 4, out).expect("sink"); + sink(&outcome("client.jar", FileStatus::Downloaded)); + let failed = FileOutcome { + failure: Some(FileFailure { + kind: DownloadErrorKind::Network, + message: "连接被重置".to_string(), + }), + ..outcome("b", FileStatus::Failed) + }; + sink(&failed); + sink(&outcome("c", FileStatus::Skipped)); + sink(&outcome("d", FileStatus::Skipped)); + let lines = lines.lock().expect("锁").clone(); + // 失败有自己的行(`失败 2/4 b:…`),进度行不拿它冒充下载量。 + assert!( + lines.iter().any(|line| line.starts_with("失败 2/4 b")), + "{lines:?}" + ); + let last = lines.last().expect("至少一行"); + // done == total 时无条件打最后一行。 + assert!(last.contains("下载 1 个"), "{last}"); + assert!(last.contains("跳过 2 个"), "{last}"); + assert!(last.contains("失败 1"), "{last}"); + assert!(last.contains("进度 4/4"), "{last}"); + } +} diff --git a/crates/cli/src/commands/install/report.rs b/crates/cli/src/commands/install/report.rs index af04893..750204b 100644 --- a/crates/cli/src/commands/install/report.rs +++ b/crates/cli/src/commands/install/report.rs @@ -1,84 +1,15 @@ -//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。 +//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷(stderr 实时进度见 [`super::progress`])。 //! //! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立: //! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版, //! 差别只是多出逐文件清单与源选择。 -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; - use serde_json::json; use unlucky_core::download::DownloadEngine; -use unlucky_core::game::install::{ - FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration, -}; +use unlucky_core::game::install::{FileOutcome, InstallPlan, InstallReport, Registration}; use crate::context::Ctx; -use crate::output::{progress_line, Report}; - -/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。 -const PROGRESS_EVERY: usize = 32; - -/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。 -const MAX_PROGRESS_FAILURES: usize = 5; - -/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。 -/// -/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流), -/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。 -/// -/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。 -pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option { - if !enabled { - return None; - } - let done = Arc::new(AtomicUsize::new(0)); - let failures = Arc::new(AtomicUsize::new(0)); - Some(Arc::new(move |outcome: &FileOutcome| { - let index = done.fetch_add(1, Ordering::Relaxed) + 1; - if let Some(failure) = &outcome.failure { - let seen = failures.fetch_add(1, Ordering::Relaxed); - if seen < MAX_PROGRESS_FAILURES { - emit( - json, - json!({ - "event": "file_failed", - "done": index, - "total": total, - "name": outcome.name, - "role": outcome.role.as_str(), - "kind": failure.kind.as_str(), - "message": failure.message, - }), - &format!("失败 {index}/{total} {}:{}", outcome.name, failure.message), - ); - } else if seen == MAX_PROGRESS_FAILURES { - emit( - json, - json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}), - "(更多失败不再逐条打印,见最终错误汇总)", - ); - } - return; - } - if index.is_multiple_of(PROGRESS_EVERY) || index == total { - emit( - json, - json!({"event": "progress", "done": index, "total": total}), - &format!("下载 {index}/{total}"), - ); - } - })) -} - -/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。 -fn emit(json_mode: bool, record: serde_json::Value, text: &str) { - if json_mode { - progress_line(&record.to_string()); - } else { - progress_line(text); - } -} +use crate::output::Report; /// 排版一次安装结果。 #[allow(clippy::too_many_arguments)] diff --git a/crates/cli/src/commands/install/vanilla.rs b/crates/cli/src/commands/install/vanilla.rs index b0a074a..65b6157 100644 --- a/crates/cli/src/commands/install/vanilla.rs +++ b/crates/cli/src/commands/install/vanilla.rs @@ -21,7 +21,7 @@ use unlucky_core::game::install::{ }; use super::{ - build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY, + build_engine, ensure_name_free, failure_error, map_install_error, progress, report, CONCURRENCY, }; use crate::cli::InstallArgs; use crate::context::Ctx; @@ -142,7 +142,7 @@ pub(super) fn plan_and_install( "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`", )); } - let sink = report::progress_sink(progress, json_progress, plan.file_count()); + let sink = progress::progress_sink(progress, json_progress, plan.file_count()); let report = execute(engine, &plan, CONCURRENCY, sink).await?; Ok(Attempt { plan, diff --git a/crates/core/src/download/mirrors.json b/crates/core/src/download/mirrors.json index 8075108..722ffc8 100644 --- a/crates/core/src/download/mirrors.json +++ b/crates/core/src/download/mirrors.json @@ -117,7 +117,8 @@ { "id": "bmclapi-libraries", "from": "https://libraries.minecraft.net", - "to": "https://bmclapi2.bangbang93.com" + "to": "https://bmclapi2.bangbang93.com", + "inject": "/maven" }, { "id": "bmclapi-assets-objects", diff --git a/crates/core/tests/download/mirrors_test.rs b/crates/core/tests/download/mirrors_test.rs index c5eceeb..3ff95ba 100644 --- a/crates/core/tests/download/mirrors_test.rs +++ b/crates/core/tests/download/mirrors_test.rs @@ -30,15 +30,115 @@ fn prefix_rule_preserves_sub_path() { .expect("libraries 规则缺失"); let candidate = rule.apply(LIBRARIES_URL).expect("重写失败"); assert_eq!(candidate.source, "bmclapi"); + // 子路径逐字保留(`/maven` 是镜像侧的固定前缀,不属于子路径)。 + // + // M210/D1:这条断言此前写的是「换成 `https://bmclapi2.bangbang93.com` 就完事」,等于把 + // **丢 inject 的 bug 钉成了期望**——实测 70/70 个库 404(无 `/maven` 回 404 + 9 B, + // 带 `/maven` 回 200、sha1 与官方逐字节一致)。现在把 `/maven` 写进期望值。 assert_eq!( candidate.url, LIBRARIES_URL.replace( "https://libraries.minecraft.net", - "https://bmclapi2.bangbang93.com" + "https://bmclapi2.bangbang93.com/maven" ) ); } +/// **守卫(M210/D1)**:每一条「给 libraries 用」的镜像规则,镜像前缀都必须带 `/maven`。 +/// +/// 官方库域名(`libraries.minecraft.net`)与各 loader 的 maven 在 BMCLAPI 上统一挂在 `/maven` +/// 下;`apply()` 是纯前缀替换,`to` 里少了这一段就会把 `com/google/guava/...` 直接拼到镜像根上。 +/// +/// 抓得住:删掉/改错任一条库镜像规则的 `inject`(本用例对 `bmclapi-libraries` 的变异会红)。 +/// 抓不住:`/maven` **写歪**(如 `/maven2`、`/maven/` 之外的等价路径)只能靠行为用例 +/// [`library_urls_expand_to_the_maven_subpath_on_bmclapi`];路径结构本就不同的镜像表(资源对象走 +/// `/assets`、元数据走 `/fabric-meta`)不在此判据内;新增但 `from` 不像 maven 仓库的库镜像也不在此列。 +#[test] +fn every_library_mirror_rule_carries_the_maven_segment() { + let table = MirrorTable::builtin(); + // 判据:「给 libraries 用」= `from` 指向 maven 仓库(host/路径里带 maven,或就是 Mojang 库域名)。 + // 新增一条库镜像规则时这条自动纳入,不必改测试。 + fn serves_maven(from: &str) -> bool { + host_of(from).unwrap_or_default().contains("maven") + || from.contains("maven") + || host_of(from) == Some("libraries.minecraft.net") + } + // 判据覆盖的规则 id(内置表里就是这 5 条;`bmclapi-liteloader` 的 `from` 不像 maven 仓库,不在列, + // 它指向的镜像路径另有一个「镜像上不存在」的观察,见 M210 报告的 §5)。 + const EXPECTED: [&str; 5] = [ + "bmclapi-libraries", + "bmclapi-forge-maven", + "bmclapi-forge-maven-net", + "bmclapi-neoforge", + "bmclapi-fabric-maven", + ]; + let mut checked: Vec<&str> = Vec::new(); + for rule in table.rules.iter().filter(|rule| serves_maven(&rule.from)) { + let prefix = format!("{}{}", rule.to, rule.inject); + assert!( + prefix.ends_with("/maven"), + "规则 {} 的镜像前缀 `{prefix}` 少了 `/maven`:官方 `{}` 在镜像上挂在 `/maven/` 下,\ + 丢了它整张表 404(M210/D1)", + rule.id, + rule.from + ); + checked.push(rule.id.as_str()); + } + // 判据失效(规则被改名/改形/删掉)时这里红:期望的规则一条都不能漏。 + for expected in EXPECTED { + assert!( + checked.contains(&expected), + "库镜像规则 {expected} 没被形态判据覆盖到(被改名/改形/删掉了?):{checked:?}" + ); + } + // 多出来的(新增 maven 规则)也得登记进 EXPECTED,否则说明它绕过了这道判据的意图审查。 + assert_eq!( + checked.len(), + EXPECTED.len(), + "库镜像规则数与预期不符(新增/删除了规则?):{checked:?}" + ); +} + +/// **行为守卫(M210/D1)**:真实库 URL 展开后必须落在镜像的 `/maven/<子路径>` 上。 +/// +/// 与上一条互为补充:那条看规则形态,这条看用户实际会请求到的 URL。 +/// 抓得住:`inject` 丢失、`to` 里手写 `/maven` 被删、或 `from`/`to` 被改成拼不出 `/maven` 的形态。 +/// 抓不住:只覆盖这 4 个官方 maven 根(各处未必都写全);镜像侧真实可达性(要网络,留给 e2e)。 +#[test] +fn library_urls_expand_to_the_maven_subpath_on_bmclapi() { + let table = MirrorTable::builtin(); + let region = RegionProfile::mirror_first(0); + let cases = [ + ( + LIBRARIES_URL, + "https://bmclapi2.bangbang93.com/maven/com/google/guava/guava/32.0.0/guava-32.0.0.jar", + ), + ( + "https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.15.11/fabric-loader-0.15.11.jar", + "https://bmclapi2.bangbang93.com/maven/net/fabricmc/fabric-loader/0.15.11/fabric-loader-0.15.11.jar", + ), + ( + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar", + "https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar", + ), + ( + "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar", + "https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar", + ), + ]; + for (official, expected) in cases { + let candidates = table.expand(official, ResourceKind::Library, ®ion); + let mirror = candidates + .iter() + .find(|candidate| candidate.source == "bmclapi") + .unwrap_or_else(|| panic!("{official} 没有 bmclapi 候选:{candidates:?}")); + assert_eq!( + mirror.url, expected, + "库镜像 URL 形状不对(官方 {official})" + ); + } +} + #[test] fn asset_rule_injects_path_prefix() { let table = MirrorTable::builtin();