Files
Unlucky-Launcher/crates/cli/src/commands/pack.rs
T
AskaEth 79b796fd58 fix(m161-r1): 崩溃/静默失败不再冒充「启动进程失败」;补三处假绿的守卫
复审 P1-1(用户可见假文案):`LaunchEnd` 在 `failure == None`(跑起来之后崩的)时
临时造了 `Stage::Spawn + format!("exit {:?}")`,于是磁贴写「启动失败 · 启动游戏进程」、
详情页漏出 `exit Some(1)`、静默失败丢掉「exit 0 却没起来」,而同一时刻 Toast 说「崩溃」。
- 新增 `LaunchFailureCause{Stage|Crashed|SilentFailure}`(dto/launch_failure.rs),
  `LaunchFailureCause::of(outcome, failure, exit_code)` 是唯一组装点;`InstanceRuntime::Failed{cause}`。
  `LaunchFailureStage` 保持「只装装配阶段」(文档那句「崩溃是结局不是阶段」现在成立)。
- 界面:`InstancesFailed`(框架)+ `InstancesCrashed` + `InstancesSilentFailed`;
  删掉 `InstancesFailedStage`。退出码按数字出词,不再有 Debug 文本。
- 用例:presenter 断言**枚举变体**(三种形状逐条)、app 断言**具体句子**
  (「不许出现 Some(」「进程起来了不许说启动游戏进程」「静默失败要点出没起来」)。
  mock 夹具与 launch_state_tests 的期望值同步;launch-error 三张图重拍。

复审 P2-1(`convert::failure_stage` 零覆盖):新增 `convert/stage_tests.rs` 5 条(三档 +
「既没回滚也没宣称干净」+ 非致命无阶段 + `failed_report_with` ⇒ NotStarted)。
变异实测:改成恒 `RolledBack` ⇒ 3 条红。

复审 P2-2(矮窗口用例的算式三个数全错、`body_height` 零测试):用例改成直接调
`frame::body_height`,chrome 从 `Ui::metrics` + `WidthClass::gutter` 现取,并把门槛与真实
chrome 绑死(chrome+MIN ⇒ Split、-1px ⇒ ScrollWhole);新增 `frame` 自己的 `body_height` 用例。
变异实测:`2.0 * gutter` → `gutter` ⇒ 2 条红。

复审 P2-3(源指纹闸门 / `Refused` 零覆盖):新增两条 core 用例——闸门(原地留着 prefs.json
+ 预先写好的同源收据 ⇒ Nothing 且收据未被重写,反证:源变了允许重放)、`Refused`
(`$schema` 1.7.0 ⇒ ReadOnly ⇒ 不动旧文件、不写收据、理由含「不可写」)。
变异实测:删闸门 ⇒ 1 条红;`can_save` 守卫改 `if false` ⇒ 1 条红。

顺手收:
- CLI `pack import` 那句「已整包回滚」也按三档出词(`RollbackStage` + `rollback_line`),
  加 2 条单测。CLI 不依赖 presenter,判定暂与 presenter 各一份 → 已 file finding 建议下沉 core。
- 设置页 `status_text()` 抽出 applying 档,用例断言「正在安装…」且非错误色。

金表:新 key 加在 `golden.rs`(81 行),未动 `golden/tail.rs`(211 行),避免与 M162 的拆分冲突。
2026-11-07 17:01:13 +08:00

377 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `pack import <file>`:整合包导入(M111)。
//!
//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。
//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,
//! 不是这里的打印逻辑。
//!
//! # 三态退出码(脚本契约)
//!
//! | `data.status` | 退出码 | 含义 |
//! |---------------|--------|------|
//! | `success` | `0` | 全部落地、无冲突 |
//! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) |
//! | `failed` | `1` | 致命失败,**已整包回滚** |
//!
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
//! 数据根不可用),与其它命令一致。
use std::path::Path;
use std::sync::{Arc, Mutex};
use serde_json::json;
use unlucky_core::addon::import::{
import_pack, ConflictChoice, ConflictPolicy, DownloadEngineFetcher, FileAction, ImportProgress,
ImportReport, ImportRequest, ImportStatus,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use crate::cli::PackImportArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// `ull pack import`。
///
/// # Errors
///
/// 整合包路径不存在(用户错误)。导入本身的失败**不是** `Err`:它走三态报告 + 退出码。
pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
if !args.path.exists() {
return Err(CliError::user(
"not_found",
format!("整合包不存在:{}", args.path.display()),
)
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
}
let target = args
.target
.clone()
.unwrap_or_else(|| ctx.game().root().to_path_buf());
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&stages);
let sink: unlucky_core::addon::ImportProgressSink =
Arc::new(move |progress: &ImportProgress| {
if let Ok(mut seen) = recorder.lock() {
if seen.last().copied() != Some(progress.stage.as_str()) {
seen.push(progress.stage.as_str());
}
}
});
let engine = build_engine(ctx);
let fetcher = DownloadEngineFetcher::new(&engine);
let layout = ctx.layout();
let mut request = ImportRequest::new(&args.path, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
if args.overwrite {
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
}
if !args.no_register {
request = request.with_registration(&layout, args.name.as_deref());
}
let report = block_on(import_pack(&request));
let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default();
Ok(outcome(ctx, &report, &target, &seen, args))
}
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
fn outcome(
ctx: &Ctx,
report: &ImportReport,
target: &Path,
stages: &[&'static str],
args: &PackImportArgs,
) -> Outcome {
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
json!({
"status": report.status.as_str(),
"error": {"code": "serialize", "message": "报告序列化失败"},
})
});
if let Some(object) = data.as_object_mut() {
object.insert("target".to_string(), json!(target.display().to_string()));
object.insert("stages".to_string(), json!(stages));
object.insert("status_key".to_string(), json!(report.status.message_key()));
object.insert("installed_files".to_string(), json!(report.installed()));
object.insert(
"target_is_default".to_string(),
json!(args.target.is_none()),
);
}
let mut human: Vec<String> = Vec::new();
human.push(format!(
"整合包 {}({})→ {}{}",
report.pack_name.as_deref().unwrap_or("<未读成>"),
report.format.as_deref().unwrap_or("-"),
target.display(),
if args.target.is_none() {
"(缺省 = 数据根的共享游戏目录)"
} else {
""
}
));
if !stages.is_empty() {
human.push(format!("阶段:{}", stages.join(" → ")));
}
human.push(format!(
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
status_label(report),
count_action(report, FileAction::Installed),
count_action(report, FileAction::Replaced),
count_action(report, FileAction::SkippedIdentical),
count_action(report, FileAction::KeptUser),
report.conflicts.len(),
report.missing.len(),
));
if let Some(loader) = &report.loader {
human.push(format!(
"加载器:{} {}(版本 id {})",
loader.kind.label(),
loader.loader_version,
loader.version_id
));
if let Some(version_id) = &report.version_id {
if !ctx.game().is_version_installed(version_id) {
human.push(format!(
"提示:{version_id} 还没安装,启动前先跑 `%prog% install` 装它"
));
}
}
}
if let Some(memory) = report.max_memory_mb {
human.push(format!("内存:{memory} MB(来自 instance.cfg)"));
}
for component in &report.rejected_components {
human.push(format!(
"未支持的 component:{} {}({})",
component.uid,
component.version.as_deref().unwrap_or("-"),
component.message_key
));
}
for conflict in &report.conflicts {
human.push(format!(
"冲突:{}({},已{})",
conflict.path,
conflict.message_key,
match conflict.choice {
ConflictChoice::KeepUser => "保留你的文件",
ConflictChoice::Overwrite => "按包覆盖",
}
));
}
for missing in &report.missing {
human.push(format!(
"缺文件:{}({}:{})",
missing.display, missing.message_key, missing.detail
));
}
if let Some(registered) = &report.registered {
human.push(format!(
"已登记实例 {}({}){}",
registered.name,
registered.version_id,
if registered.created {
""
} else {
"·复用已有档案"
}
));
}
if let Some(error) = &report.error {
human.push(format!("错误:{}({})", error.message(), error.code()));
if let Some(hint) = error.hint() {
human.push(format!("提示:{hint}"));
}
human.push(rollback_line(report));
}
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
}
/// 状态的人读名。**失败要按「动没动过目标目录」分叉**(见 [`rollback_line`])。
fn status_label(report: &ImportReport) -> &'static str {
match rollback_stage(report) {
Some(RollbackStage::NotStarted) => "失败(未写入:目标目录没被动过)",
Some(RollbackStage::RolledBack) => "失败(已回滚)",
Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
None => match report.status {
ImportStatus::Success => "成功",
_ => "部分成功",
},
}
}
/// 「这次失败到底动没动过目标目录」(三档)。
///
/// 与 `presenter` 的 `host::addon::pack::convert::failure_stage` **同义**——CLI 不依赖 presenter
/// 层(`unlucky-cli` 只依赖 core + agent),所以这套判据在这里各写一份。已登记 finding:
/// 更干净的做法是把三档挪进 `core::addon::import::ImportReport`(那儿有 `rollback` /
/// `rolled_back` 两个机器位),两处都改成读它。
///
/// 为什么要三档而不是一个 `rolled_back` 布尔:core 的 `rolled_back` 在「回滚完整完成」与
/// **「从未写过目标目录」**两种情形下都是 `true`。只按它出词,预检失败会被说成「已整包回滚:目标
/// 目录与导入前一致」——那句话在**一个字节都没写**时是假的(M161 复审的 P1 同族问题)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RollbackStage {
/// 预检/装配阶段就失败:目标目录一个字节都没写
NotStarted,
/// 写过,回滚**完整**完成
RolledBack,
/// 写过,回滚有失败项(目标目录可能与导入前不同)
Incomplete,
}
/// 三档判据(与 presenter 那份逐条对应)。
fn rollback_stage(report: &ImportReport) -> Option<RollbackStage> {
if report.status != ImportStatus::Failed {
return None;
}
Some(match &report.rollback {
Some(outcome) if outcome.is_complete() => RollbackStage::RolledBack,
Some(_) => RollbackStage::Incomplete,
None if report.rolled_back => RollbackStage::NotStarted,
None => RollbackStage::Incomplete,
})
}
/// 失败收尾那一段人读文案(**三档三句**,不许共用)。
fn rollback_line(report: &ImportReport) -> String {
match rollback_stage(report) {
Some(RollbackStage::NotStarted) => {
"预检未通过:目标目录一个字节都没写(不需要回滚)".to_string()
}
Some(RollbackStage::RolledBack) => "已整包回滚:目标目录与导入前一致".to_string(),
Some(RollbackStage::Incomplete) => match &report.rollback {
Some(rollback) => format!(
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
rollback.failure_count()
),
None => "回滚状态不明:目标目录可能与导入前不同(详见 --json)".to_string(),
},
None => String::new(),
}
}
fn count_action(report: &ImportReport, action: FileAction) -> usize {
report
.files
.iter()
.filter(|file| file.action == action)
.count()
}
/// 与 `install` 共用同一套引擎构造(镜像 / 地区 / 健康度 / 内容缓存)。
fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
#[cfg(test)]
mod tests {
//! `pack import` 失败收尾的三档文案(M161 复审「不计入但该记」第一条:CLI 也曾把
//! 预检失败说成「已整包回滚」)。
//!
//! 抓得住:三档塌成两句(预检 → 已回滚);`is_complete()` 被丢掉;`status != Failed` 也出话。
use std::path::PathBuf;
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
use super::*;
fn failed() -> ImportReport {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
report.fail(ImportError::integrity("sha1 mismatch"));
report
}
/// 三档三句,**互不相同**(同族的谎就是这个 mission 要消灭的那类)。
#[test]
fn the_three_rollback_stages_get_three_different_sentences() {
// ① 预检没过:一个字节都没写。
let mut preflight = failed();
preflight.rolled_back = true;
assert_eq!(rollback_stage(&preflight), Some(RollbackStage::NotStarted));
let not_started = rollback_line(&preflight);
assert!(not_started.contains("没写"), "{not_started}");
assert!(
!not_started.contains("回滚:目标目录"),
"预检失败不许说「已回滚」:{not_started}"
);
assert_eq!(status_label(&preflight), "失败(未写入:目标目录没被动过)");
// ② 写过又完整回滚。
let mut rolled_back = failed();
rolled_back.rolled_back = true;
rolled_back.rollback = Some(RollbackOutcome {
attempted: 2,
failed: Vec::new(),
});
assert_eq!(
rollback_stage(&rolled_back),
Some(RollbackStage::RolledBack)
);
assert!(rollback_line(&rolled_back).contains("已整包回滚"));
assert_eq!(status_label(&rolled_back), "失败(已回滚)");
// ③ 回滚没做完。
let mut incomplete = failed();
incomplete.rolled_back = false;
incomplete.rollback = Some(RollbackOutcome {
attempted: 2,
failed: vec!["mods/a.jar".to_string()],
});
assert_eq!(rollback_stage(&incomplete), Some(RollbackStage::Incomplete));
assert!(rollback_line(&incomplete).contains("1 项没回去"));
assert_eq!(status_label(&incomplete), "失败(回滚不完整)");
// 三句必须互不相同(把任何两档并成一句,这里就红)。
let lines = [
rollback_line(&preflight),
rollback_line(&rolled_back),
rollback_line(&incomplete),
];
assert_eq!(
lines.iter().collect::<std::collections::HashSet<_>>().len(),
3,
"{lines:?}"
);
}
/// 不是致命失败 ⇒ 没有回滚那一段话(成功/部分成功不该出现「已回滚」)。
#[test]
fn a_non_fatal_report_says_nothing_about_rollback() {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
report.status = ImportStatus::Partial;
assert_eq!(rollback_stage(&report), None);
assert_eq!(rollback_line(&report), "");
assert_eq!(status_label(&report), "部分成功");
}
}