Files
Unlucky-Launcher/crates/presenter/src/host/addon/pack/convert.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

382 lines
15 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.
//! core 报告 → DTO(M113):**逐字段照搬,不重算任何结论**。
//!
//! 这一层的唯一职责是翻译:三态、五阶段、四种处置、三种冲突成因、十一种导出原因,
//! 全部一一对应。任何一个「顺手换算一下」都会让 UI 说的和管线干的不是一回事,
//! 所以这里没有一处 re-derive(唯一例外是把 `&'static str` 的稳定码换成人读串)。
//!
//! 翻译用的是 core 的稳定 slug(`as_str()`)+ DTO 的 `from_slug()`,
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
use std::path::{Path, PathBuf};
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
use unlucky_core::addon::import::report::ImportStatus;
use unlucky_core::addon::import::{
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
};
use crate::dto::{
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
ImportReportView, ImportScanView, ImportStageView, ImportStatusView, ImportedFileView,
MissingFileView, MissingReasonView, PackFormatView,
};
/// 一次导入**预检** → DTO(M160:冲突清单)。
pub(super) fn scan_view(scan: &ImportScan) -> ImportScanView {
ImportScanView {
target: scan.target.clone(),
format: scan.format.as_deref().and_then(PackFormatView::from_slug),
pack_name: scan.pack_name.clone(),
pack_version: scan.pack_version.clone(),
game_version: scan.game_version.clone(),
conflicts: scan
.conflicts
.iter()
.map(|conflict| ImportConflictView {
path: conflict.path.clone(),
source: source_view(conflict.source),
reason: reason_of(conflict.reason),
disabled: conflict.disabled,
pack_sha1: conflict.pack_sha1.clone(),
pack_size: conflict.pack_size,
disk_sha1: conflict.disk_sha1.clone(),
disk_size: conflict.disk_size,
base_sha1: conflict.base_sha1.clone(),
})
.collect(),
unknown_remote: scan.unknown_remote,
lost_bytes: scan.lost_bytes(),
incoming_bytes: scan.incoming_bytes(),
notes: scan
.notes
.iter()
.map(|issue| issue.message.clone())
.collect(),
error: scan.error.as_ref().map(error_view),
}
}
/// DTO 的决策 → core 的决策表(**形状一换,语义不换**)。
///
/// 三条入口一一对应;没点名的路径在 core 侧落到"保留"(`ConflictDecisions` 的缺省档),
/// 所以"页面少传一条"永远不会变成"覆盖"。
pub(super) fn decisions_of(view: &ConflictDecisionsView) -> ConflictDecisions {
let mut decisions = match view.default_choice() {
ConflictChoiceView::KeepUser => ConflictDecisions::keep_all(),
ConflictChoiceView::Overwrite => ConflictDecisions::overwrite_all(),
};
for item in view.items() {
decisions.set(item.path.clone(), choice_of(item.choice));
}
decisions
}
const fn choice_of(choice: ConflictChoiceView) -> ConflictChoice {
match choice {
ConflictChoiceView::KeepUser => ConflictChoice::KeepUser,
ConflictChoiceView::Overwrite => ConflictChoice::Overwrite,
}
}
const fn source_view(source: FileSource) -> ConflictSourceView {
match source {
FileSource::Overrides => ConflictSourceView::Overrides,
FileSource::ClientOverrides => ConflictSourceView::ClientOverrides,
FileSource::CurseForge => ConflictSourceView::CurseForge,
FileSource::Modrinth => ConflictSourceView::Modrinth,
}
}
const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
match reason {
ConflictReason::UserModified => ConflictReasonView::UserModified,
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
}
}
/// 一次导入结果 → DTO。
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
ImportReportView {
status: ImportStatusView::from_slug(report.status.as_str())
.unwrap_or(ImportStatusView::Failed),
format: report.format.as_deref().and_then(PackFormatView::from_slug),
pack_name: report.pack_name.clone(),
pack_version: report.pack_version.clone(),
game_version: report.game_version.clone(),
loader: report.loader.as_ref().map(loader_label),
version_id: report.version_id.clone(),
target: target.to_path_buf(),
files: report
.files
.iter()
.map(|file| ImportedFileView {
path: file.path.clone(),
action: action_view(file.action),
bytes: file.bytes,
})
.collect(),
conflicts: report.conflicts.iter().map(conflict_view).collect(),
missing: report
.missing
.iter()
.map(|file| MissingFileView {
path: file.path.clone(),
display: file.display.clone(),
reason: missing_view(file.reason),
detail: file.detail.clone(),
})
.collect(),
rejected: report
.rejected_components
.iter()
.map(|component| match &component.version {
Some(version) => format!("{} {version}", component.uid),
None => component.uid.clone(),
})
.collect(),
warnings: report
.warnings
.iter()
.map(|issue| issue.message.clone())
.collect(),
error: report.error.as_ref().map(error_view),
registered: report
.registered
.as_ref()
.map(|registered| registered.name.clone()),
bytes_written: report.bytes_written,
rolled_back: report.rolled_back,
failure_stage: failure_stage(report),
}
}
/// 致命失败发生在哪一步(M161)。
///
/// 判据只用 core 的两个机器位,**不看 warnings 的措辞**:
/// - `rollback: Some(outcome)` ⇒ 真的跑过回滚(写过了),完整与否看 `is_complete()`;
/// - `rollback: None` ⇒ 没跑过回滚。core 的约定是「失败发生在碰目标目录之前」(`abandoned`),
/// 此时 `rolled_back == true`;若 `rolled_back` 也是 `false`(既没回滚也没宣称干净),
/// 那就如实报「可能不一致」,不替它圆场。
fn failure_stage(report: &ImportReport) -> Option<ImportFailureStageView> {
if report.status != ImportStatus::Failed {
return None;
}
Some(match &report.rollback {
Some(outcome) if outcome.is_complete() => ImportFailureStageView::RolledBack,
Some(_) => ImportFailureStageView::RollbackIncomplete,
None if report.rolled_back => ImportFailureStageView::NotStarted,
None => ImportFailureStageView::RollbackIncomplete,
})
}
/// 一次导出结果 → DTO。
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
ExportDoneView {
path: report.path.clone(),
bytes: report.bytes,
remote: u32::try_from(report.remote_count()).unwrap_or(u32::MAX),
inline: u32::try_from(report.inline_count()).unwrap_or(u32::MAX),
skipped: u32::try_from(report.skipped_count()).unwrap_or(u32::MAX),
}
}
/// 一条导入进度 → DTO。
pub(super) fn progress_view(progress: &ImportProgress, pack: &str) -> ImportProgressView {
ImportProgressView {
stage: ImportStageView::from_slug(progress.stage.as_str())
.unwrap_or(ImportStageView::Probe),
done: u32::try_from(progress.done).unwrap_or(u32::MAX),
total: u32::try_from(progress.total).unwrap_or(u32::MAX),
current: progress.current.clone(),
pack: pack.to_string(),
}
}
/// 致命失败 → DTO(稳定码原样带走,日志与 `--json` 用的是同一串)。
fn error_view(error: &ImportError) -> ImportErrorView {
ImportErrorView {
code: error.kind().as_str().to_string(),
label: error.kind().label().to_string(),
message: error.message().to_string(),
hint: error.hint().map(str::to_string),
}
}
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
ConflictFileView {
path: file.path.clone(),
reason: reason_of(file.reason),
choice: choice_view(file.choice),
pack_sha1: file.pack_sha1.clone(),
base_sha1: file.base_sha1.clone(),
disk_sha1: file.disk_sha1.clone(),
pack_size: file.pack_size,
}
}
/// 一条 `ConflictChoice` → DTO(预检与事后账共用一张翻译表)。
const fn choice_view(choice: ConflictChoice) -> ConflictChoiceView {
match choice {
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
}
}
const fn action_view(action: FileAction) -> FileActionView {
match action {
FileAction::Installed => FileActionView::Installed,
FileAction::Replaced => FileActionView::Replaced,
FileAction::SkippedIdentical => FileActionView::SkippedIdentical,
FileAction::KeptUser => FileActionView::KeptUser,
}
}
const fn missing_view(reason: MissingReason) -> MissingReasonView {
match reason {
MissingReason::Offline => MissingReasonView::Offline,
MissingReason::NoSource => MissingReasonView::NoSource,
MissingReason::FetchFailed => MissingReasonView::FetchFailed,
}
}
pub(super) fn reason_view(reason: FileReason) -> ExportReasonView {
ExportReasonView::from_slug(reason.as_str()).unwrap_or(ExportReasonView::Unreadable)
}
pub(super) fn class_view(class: FileClass) -> ExportClassView {
match class {
FileClass::Remote => ExportClassView::Remote,
FileClass::Inline => ExportClassView::Inline,
FileClass::Skipped => ExportClassView::Skipped,
}
}
/// 加载器 → 人读串(`fabric-loader 0.15.11` / `vanilla 1.20.1`)。
fn loader_label(target: &LoaderTarget) -> String {
format!("{} {}", target.kind.as_str(), target.loader_version)
}
/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。
///
/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。
/// `target` 是**目标实例目录**(不知道就给空路径),不是包路径(复审 r1 P2-9 顺手纠的 DTO 语义)。
pub(super) fn failed_report(
target: PathBuf,
pack: &Path,
code: &str,
label: &str,
message: &str,
) -> ImportReportView {
failed_report_with(
target,
pack,
ImportErrorView {
code: code.to_string(),
label: label.to_string(),
message: message.to_string(),
hint: None,
},
)
}
/// 一条 core 的 [`ImportError`] → 失败报告(**逐字段照搬**,含 `hint`)。
///
/// 预检失败(`scan.error`)走它:那些错本来就是 core 判出来的(`pack_traversal` /
/// `pack_limits` / `pack_metadata` …),没必要在宿主层再解释一遍,更不能把 `hint`
/// ("先装好游戏再导出"这种可执行下一步)丢掉——丢掉的话用户看到的就是"失败了,然后呢"。
pub(super) fn failed_report_of(
target: PathBuf,
pack: &Path,
error: &ImportError,
) -> ImportReportView {
failed_report_with(target, pack, error_view(error))
}
/// 组装:形状只此一处,文案由调用方给。
fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> ImportReportView {
ImportReportView {
status: ImportStatusView::Failed,
format: None,
pack_name: Some(pack_stem(pack)),
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target,
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: Some(error),
registered: None,
bytes_written: 0,
rolled_back: true,
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
failure_stage: Some(ImportFailureStageView::NotStarted),
}
}
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
pub(super) fn pack_stem(pack: &Path) -> String {
pack.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "imported-pack".to_string())
}
/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。
///
/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时
/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。
pub(super) fn dir_name(name: &str) -> String {
let base = slug(name);
let faithful = !name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' ');
if faithful {
base
} else {
format!("{base}-{}", hash8(name))
}
}
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
fn hash8(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", hash as u32)
}
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
pub(super) fn slug(text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"pack".to_string()
} else {
trimmed
}
}
#[cfg(test)]
#[path = "convert/stage_tests.rs"]
mod stage_tests;