Files
Unlucky-Launcher/crates/app-iced/src/app/view/instances.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

267 lines
13 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.
//! 实例页的**展示转换**(M120 从 `app/view.rs` 原样搬出:那边给 i18n 让出行数)。
//!
//! | 函数 | 产物 | 谁读 |
//! |---|---|---|
//! | [`supporting_line`] | 列表行 / 详情副标题 `1.20.4 · Fabric 0.16.5` | [`crate::pages::Row`] / [`crate::pages::Detail`] |
//! | [`runtime_status`] | 运行状态行(列表 overline + 详情状态行共用一份) | 同上 |
//! | [`detail_of`] | 详情列的全部字段 | [`crate::pages::Detail`] |
//! | [`last_played`] | `2026-10-29 19:22 UTC` / `Never` | 元信息卡 `LAST PLAYED` |
//!
//! 这里只做「快照字段 → 要画什么」,不碰控件树。**文案的语言由调用方的 [`Catalog`] 决定**
//! (`view::model` 把 `App::lang` 的目录传进来),不再是 M90 时期的英文基线字面量。
//!
//! ## 两条接法上的取舍
//!
//! 1. **原版徽标走词表**:`LoaderInfo::badge()` 对原版返回**中文**「原版」(那是 presenter 的
//! 面向中文日志的 `label()`),英文界面里直接画它就是「中文漏进英文界面」。所以原版这一支
//! 改走 [`Key::LoaderVanilla`],装加载器时仍是 `Fabric 0.16.5`(品牌 + 版本号,不翻译);
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome,
};
use crate::pages::{Detail, LaunchFacts, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
format!(
"{} · {}",
instance.mc_version,
loader_badge(instance, catalog)
)
}
/// 加载器徽标:原版走词表,其余交给 `LoaderInfo::badge()`(品牌 + 版本号,原样)。
///
/// **判据只能是「加载器类型本身是原版」**。曾经写成「原版 **或** 版本号为空」⇒
/// `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 这类**取不到版本段**的 id(`badge()`
/// 对它们给的就是品牌名 `Forge` / `NeoForge` / `OptiFine`)会被显示成「原版 / Vanilla」——
/// 那是 M120 r1 的 p2-1 回归,`the_loader_badge_keeps_the_brand_when_the_version_is_missing` 守着。
fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String {
match instance.loader.kind {
// 唯一需要过词表的一支:`LoaderKind::label()` 对原版返回的是**中文**「原版」
LoaderKind::Vanilla => catalog.text(Key::LoaderVanilla).to_string(),
// 其余品牌名不翻译;版本号为空时 `badge()` 自己就只给品牌名
_ => instance.loader.badge(),
}
}
/// 隔离档位:`isolated` / `shared` 是两个稳定 id(`core::game::IsolationMode::as_str`),
/// 直接上屏就是英文界面漏中文、中文界面漏英文。只认这两档,**其余原样**(`inherit` / `custom` /
/// 未来新增的值不许在这里被猜成一个意思)。
fn isolation(raw: &str, catalog: Catalog) -> String {
match raw {
"isolated" => catalog.text(Key::InstancesIsolationIsolated).to_string(),
"shared" => catalog.text(Key::InstancesIsolationShared).to_string(),
other => other.to_string(),
}
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
InstanceRuntime::Busy { stage, progress } => Some(format!(
"{} · {:.0}%",
stage_label(*stage, catalog),
progress.ratio() * 100.0
)),
InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
}
// 失败有**两种形状**,各说各的话(复审 P1-1):
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
// 绝不说成「启动游戏进程失败」。
//
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
}
}
/// 一条失败状态行。
///
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
match cause {
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(*stage, catalog)),
("detail", detail.as_str()),
],
),
LaunchFailureCause::Stage { stage, .. } => catalog.render(
Key::InstancesFailed,
&[("reason", failure_stage_label(*stage, catalog))],
),
LaunchFailureCause::Crashed { exit_code } => {
let reason = match exit_code {
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
};
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
LaunchFailureCause::SilentFailure { .. } => {
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
}
}
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
///
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
other => runtime_status(other, catalog),
}
}
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
///
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
})
}
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
///
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
#[must_use]
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
let catalog = lang.catalog();
if let Some(failure) = &finished.failure {
return catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(failure.stage, catalog)),
("detail", failure.detail.as_str()),
],
);
}
let outcome = catalog.text(match finished.outcome {
LogOutcome::Ok => Key::LaunchOutcomeNormal,
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
});
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
catalog.render(
Key::LaunchToastEnded,
&[("outcome", outcome), ("seconds", seconds.as_str())],
)
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchStage::Download => Key::InstancesStageDownload,
LaunchStage::Verify => Key::InstancesStageVerify,
LaunchStage::Launch => Key::InstancesStageLaunch,
LaunchStage::Window => Key::InstancesStageWindow,
})
}
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail {
Detail {
id: instance.id.clone(),
name: instance.name.clone(),
subtitle: supporting_line(instance, catalog),
meta: vec![
meta(
catalog.text(Key::InstancesMetaVersion),
&instance.mc_version,
),
meta(
catalog.text(Key::InstancesMetaLoader),
&loader_badge(instance, catalog),
),
meta(
catalog.text(Key::InstancesMetaIsolation),
&isolation(&instance.isolation, catalog),
),
meta(
catalog.text(Key::InstancesMetaVersionId),
&instance.version_id,
),
meta(
catalog.text(Key::InstancesMetaFiles),
catalog.text(if instance.version_present {
Key::InstancesPresentOnDisk
} else {
Key::InstancesMissing
}),
),
meta(
catalog.text(Key::InstancesMetaLastPlayed),
&last_played(instance.last_played.as_deref(), catalog),
),
],
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
note: None,
status: runtime_status(&instance.runtime, catalog),
can_launch: matches!(
instance.runtime,
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
),
can_stop: matches!(
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
}
}
fn meta(label: &str, value: &str) -> Meta {
Meta {
label: label.to_string(),
value: value.to_string(),
}
}
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
///
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等时间格式 mission。
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
let Some(text) = raw else {
return catalog.text(Key::InstancesNeverPlayed).to_string();
};
match (text.get(0..10), text.get(11..16)) {
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
format!("{date} {time} UTC")
}
_ => text.to_string(),
}
}