复审 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 的拆分冲突。
377 lines
14 KiB
Rust
377 lines
14 KiB
Rust
//! `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(®ion_path, now).unwrap_or_else(|| {
|
||
let profile = decide_from(&detect_signals(), now);
|
||
let _ = store_cached(®ion_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), "部分成功");
|
||
}
|
||
}
|