fix(launch): M298 收尾失败不再静默、Toast 接上渲染点

三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」:

1) 收尾失败静默不弹:finish() 报错那条出口把 LaunchFinished.session 发成真实
   会话,而它登记的现场走 before_start 写死空串 ⇒ dialog.rs 的并发守卫
   (!finished.session.is_empty() && report.session != finished.session)成立后
   return。改成**会话归属单一来源**:before_start 新增可选 SessionAttribution,
   presenter 的 publish_before_start 把它写进报告后**返回同一个值**给 DTO;
   两处不可能再各写一遍。守卫原样保留(A 场的现场不许解释 B 场,有用例钉住)。

2) Toast 只 push 不渲染:新增 app/view/toast.rs 覆盖层(MD3E 胶囊 =
   ShapeScale::Full,底部居中,InverseSurface 底),接在页面树之上、模态之下;
   过期仍走 clock.rs 的 expire;进/出过渡复用模态那一对腿(motion::modal_spec /
   modal_scale / scaled),时间轴在 update 侧由 state/toast.rs 采样,关掉动效时
   恒为终值(出图可复现)。两个拷贝路径补上 view_dirty(推了不置脏要等下一次事件)。

3) 三个贴线文件先拆再改(纯搬移 + pub use 保路径,机器核对:搬出的 14 个函数体
   逐行相同):crash_report.rs 394→318(+context.rs / slot.rs)、
   host/launch.rs 399→233(+build.rs)、app/dialog.rs 390→178(+model.rs)。

证据:DevDocs/artifacts/m298/toast-capsule.png(--screenshot + ULL_TOAST_FIXTURE,
图上可见「游戏崩溃 · 12.0 秒」胶囊)。
This commit is contained in:
AskaEth committed 2026-11-13 13:18:11 +08:00
1 parent fc8f5c6794
commit 97768b7e3f
19 files changed
+1699 -553

No files matched your search

+9 -221
View File
@@ -18,6 +18,9 @@
//!
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
//!
//! M298 把**视图模型**那一段(`Modal` / `CardView` / `current` 与四个纯函数)搬到 [`model`],
//! `pub use` 保路径:本文件只留「卡片事实 + 消息枚举 + 与 `App` 的接线」。
//!
//! ## 崩溃卡片额外带什么
//!
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
@@ -29,18 +32,22 @@
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
//! 不重算:**界面不读盘、不猜原因**。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
use unlucky_core::launch::crash_report::CrashReport;
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
use crate::app::state::App;
pub(super) mod export;
mod model;
#[cfg(test)]
mod tests;
pub(super) mod view;
#[cfg(test)]
pub(super) use model::path_rows;
pub(super) use model::{current, DialogRequestView, Modal};
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
#[derive(Debug, Clone)]
pub struct CrashCard {
@@ -77,225 +84,6 @@ pub enum Message {
CopyPath(usize),
}
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(super) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(super) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
// --------------------------------------------------------------- 与 App 的接线
impl App {
+230
View File
@@ -0,0 +1,230 @@
//! 弹窗的**视图模型**:队列当前条 + 崩溃卡片 → 「这一屏该出现什么」(M298 从 `dialog.rs` 拆出)。
//!
//! 纯函数、不读盘、不看 `App`:测试与渲染共用同一份(`crate::app::dialog::{current, Modal}`)。
//! 拆分的直接原因只有行数(`dialog.rs` 当时 390 行、贴着 400 行硬线),**没有行为变化**:
//! 每个函数体逐行搬移,符号经父模块 `pub use` 保路径。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use super::CrashCard;
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(crate) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(crate) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(crate) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
pub(crate) fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
+196 -2
View File
@@ -14,11 +14,13 @@
use std::path::PathBuf;
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_report::{
self, ArtifactFact, CrashReport, ReportContext, SessionAttribution,
};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView};
@@ -32,6 +34,28 @@ const FAMILIES: Families = Families {
mono: "Consolas",
};
/// 串行闸:`crash_report` 的现场是**进程级单槽**,本文件里凡是 publish 的用例都要拿它。
///
/// 不拿会怎样:A 用例 publish 自己的现场之后、读之前,B 用例插进来 publish 一份 —— A 拿到的是
/// B 的会话,断言随机红(这条守卫是 M298 加重并发用例之后补的)。
fn slot_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|error| error.into_inner())
}
/// 一次启动的现场上下文(与 presenter 里那份同形:实例 / 运行目录 / 日志目录)。
fn context() -> ReportContext {
ReportContext::new(
"survival",
"生存",
"1.20.4",
std::path::Path::new("/instances/survival"),
std::path::Path::new("/logs"),
std::time::SystemTime::now(),
"os=windows",
)
}
fn headless() -> App {
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
@@ -197,6 +221,7 @@ fn two_failure_shapes_say_two_different_things() {
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
#[test]
fn closing_the_dialog_clears_the_card() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(false, vec!["line".to_string()]));
let finished = LaunchFinished {
@@ -219,6 +244,7 @@ fn closing_the_dialog_clears_the_card() {
/// 成功收尾不打扰用户。
#[test]
fn a_successful_launch_does_not_pop_anything() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(false, Vec::new()));
let finished = LaunchFinished {
@@ -342,3 +368,171 @@ fn boot_facts_are_recorded_into_the_launcher_log() {
#[path = "tests/export.rs"]
mod export;
// ------------------------------------------------------------------ M298:收尾失败必须弹
/// **真收尾失败必须弹出**(M298 修的那条静默漏弹)。
///
/// 出口形状取自 `crates/presenter/src/host/launch.rs` 的 `finish()` 失败分支,逐字段对齐:
/// 进程**起来过**(有会话)、`finish()` 报错 ⇒ `outcome = SilentFail` + `failure = Some(Spawn)`。
/// 现场由 core 的 `CrashReport::before_start` 登记,会话归因 = 收尾事件里那个会话。
///
/// ⚠️ 这一条出口**拿不到退出码**:`finish()` 只在「等进程出错」或「看门狗放弃等待」时失败,
/// 两种都没有 `ExitStatus`(`pending_ms` 那条路径进程还在跑)。所以「exit_code 有值」在本出口上
/// 不成立,判据改用「报告带会话 + 进程起来过」;带退出码的那条出口见下一条用例。
///
/// **变异证明**:把 `CrashReport::before_start` 的 `session` 改回写死空串(本 mission 之前的
/// 写法),`open_failure_dialog` 的并发守卫成立并 `return` ⇒ 本用例第一段立刻红。
#[test]
fn a_finish_failure_pops_the_dialog_with_its_own_session() {
let _slot = slot_guard();
let mut app = headless();
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
crash_report::publish(CrashReport::before_start(
&context(),
"spawn",
"会话写入器不可用(锁中毒)",
"spawn",
Some(SessionAttribution {
id: "9f2c",
log: Some(log.as_path()),
}),
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
"会话写入器不可用(锁中毒)",
)),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_some(),
"真收尾失败必须弹框——用户原话是「找半天没找到崩溃日志」"
);
let card = app.dialog_card.as_ref().expect("卡片");
assert_eq!(
card.report.session, finished.session,
"弹的是**这一场**的现场"
);
// 现场带着会话 ⇒ 事件流路径必须出现在可复制的路径行里(不是「进程没起来」那一套)。
let modal = app.dialog_modal().expect("模型");
let paths = modal.card.as_ref().expect("卡片视图").paths.clone();
assert!(
paths.iter().any(|(_, path)| path.ends_with("9f2c.jsonl")),
"会话 JSONL 必须可复制:{paths:?}"
);
crash_report::clear();
}
/// 带**退出码**的那条出口(真崩溃)照旧要弹:报告的 `session` 与收尾事件同源。
///
/// **变异证明**:把 `CrashReport::collect` 里的 `session: session.session_id.clone()` 改成空串,
/// 本用例红(并发守卫把"自己这一场"判成别人的)。
#[test]
fn a_crashed_launch_with_an_exit_code_pops_with_its_evidence() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(report(true, vec!["Exception in thread main".to_string()]));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 1_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_some(), "真崩溃必须弹框");
let modal = app.dialog_modal().expect("模型");
assert!(
modal.body_text().contains("退出码 1"),
"正文必须带退出码:{}",
modal.body_text()
);
crash_report::clear();
}
/// **并发守卫的原意不许被松掉**:登记的是另一场 ⇒ 不许拿它解释这一场。
///
/// 这条与上一条是一对:修「自己这一场被误判」不能靠删守卫(删了这条就红)。
#[test]
fn a_report_from_another_session_is_never_reused() {
let _slot = slot_guard();
let mut app = headless();
crash_report::publish(CrashReport::before_start(
&context(),
"spawn",
"另一场的现场",
"spawn",
Some(SessionAttribution {
id: "aaaa",
log: None,
}),
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(LaunchFailureStage::Spawn, "x")),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_none(),
"A 场的现场不许拿来解释 B 场"
);
crash_report::clear();
}
/// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。
///
/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让
/// 装配失败的现场指向一个不存在的会话)。
#[test]
fn a_session_less_failure_still_pops() {
let _slot = slot_guard();
for (stage, detail) in [
(LaunchFailureStage::VersionManifest, "缺 1.20.4.json"),
(LaunchFailureStage::Spawn, "javaw.exe 不存在"),
] {
let mut app = headless();
crash_report::publish(CrashReport::before_start(
&context(),
stage.slug(),
detail,
stage.slug(),
None,
));
let finished = LaunchFinished {
id: "survival".to_string(),
session: String::new(),
outcome: LogOutcome::Crashed,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(stage, detail)),
};
app.open_failure_dialog(&finished);
assert!(
app.state.dialogs.current().is_some(),
"{stage:?} 出口必须弹框(这条出口的 session 为空是事实)"
);
let card = app.dialog_card.as_ref().expect("卡片");
assert!(card.report.session.is_empty(), "{stage:?}");
assert_eq!(
card.report.exit_code, None,
"{stage:?}: 没有退出码就是 None"
);
assert_eq!(
card.report.stage.as_deref(),
Some(stage.slug()),
"{stage:?}"
);
crash_report::clear();
}
}
+9
View File
@@ -71,6 +71,10 @@ mod boot_probe;
mod construct;
mod mods_shot;
mod refresh;
// M298: toast 的动效时间轴(进/出进度;`view` 只采样)
pub(super) mod toast;
// M298: 出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`)
mod toast_shot;
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
mod saves_shot;
mod theme;
@@ -229,6 +233,11 @@ pub struct App {
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// Toast 的动效时间轴(M298):每条提示的出生时刻 + 这一拍的进/出进度。
///
/// 为什么住在 `App` 而不是 presenter:它是**动效**(这一帧该缩放多少),不是业务事实。
/// 事实(文案 / 语义色 / 剩余寿命)全在 `state.toasts`,这边只记时间。
pub(super) toast_tracks: toast::ToastTracks,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -155,6 +155,7 @@ impl App {
mods_failed_seen: None,
dialog_card: None,
dialog_model: None,
toast_tracks: super::toast::ToastTracks::default(),
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
@@ -182,6 +183,8 @@ impl App {
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
// M298:出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`;产品路径读不到这个变量)。
super::toast_shot::seed(&mut app);
app
}
}
+12
View File
@@ -119,6 +119,18 @@ impl App {
}
self.view_dirty = false;
self.refreshes += 1;
// M298:toast 的进/出场也要「每帧一次」——而 `refresh_view` 只在 `view_dirty` 时跑。
// 于是这里把「还有提示在动」翻译成**再续一帧**(`view_dirty` 重新置位);静止在位之后
// 它回到 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。
self.view_dirty = self.sync_toasts();
}
/// toast 的动效时间轴采样(**update 侧唯一入口**,见 `state/toast.rs` 的模块文档)。
///
/// 返回 `true` = 还有提示在进/出场,这一帧要续。
fn sync_toasts(&mut self) -> bool {
let toasts = self.state.toasts.items();
self.toast_tracks.observe(toasts, std::time::Instant::now())
}
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
+216
View File
@@ -0,0 +1,216 @@
//! Toast 的**动效时间轴**(M298):一条提示的进场 / 退场进度从哪来。
//!
//! ## 为什么不新造一条轨道
//!
//! `components::motion` 里的帧泵只认三类轨道(页面 / 模态 / 右栗折叠),而 toasts 的寿命不归
//! 它管:`ToastCenter` 已经按 33 ms 一拍递减 `remaining_ms`(`app/update/clock.rs`),到期自己
//! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是
//! 「提示还在、动画已经结束」。
//!
//! 所以这里只做一件帧泵不做的事:记住**每一条是什么时候出现的**(`born`),然后每拍按 toast
//! 自己的两个已知量采样进度——`born` 到现在的时长(进场)与 `remaining_ms`(退场)。
//! 时长档与缓动档**复用模态那一对腿**([`motion::modal_spec`]):toast 与对话框同属 M3 的
//! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。
//!
//! ## 为什么采样发生在 `update` 侧
//!
//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里
//! 的 [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑,
//! 结果是「这一帧每条该缩放多少」——`view` 拿到的只是一份只读快照。
//!
//! ## 关掉动效 = 一帧都不动
//!
//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**,且
//! [`ToastTracks::observe`] 返回 `false` ⇒ 不会为了动画续帧。出图因此可复现(D30)。
use std::time::Instant;
use unlucky_presenter::toast::{Toast, ToastId};
use crate::app::components::motion;
/// 一条 toast 的动效记录。
#[derive(Debug, Clone, Copy)]
struct Entry {
/// toast id(`ToastCenter` 重新编号,进程内唯一)。
id: ToastId,
/// 第一次被看见的时刻。
born: Instant,
/// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。
progress: f32,
}
/// toast 的动效时间轴(挂在 [`App`](super::App) 上,`Default` = 空)。
#[derive(Debug, Default)]
pub(crate) struct ToastTracks {
entries: Vec<Entry>,
}
impl ToastTracks {
/// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。
///
/// **update 侧每拍调一次**:登记新出现的、忘掉已经消失的、重算每条进度。
pub(crate) fn observe(&mut self, toasts: &[Toast], now: Instant) -> bool {
self.entries
.retain(|entry| toasts.iter().any(|toast| toast.id == entry.id));
let spec = motion::modal_spec();
let scale = motion::scale();
let mut moving = false;
for toast in toasts {
let born = match self.entries.iter().find(|entry| entry.id == toast.id) {
Some(entry) => entry.born,
None => {
self.entries.push(Entry {
id: toast.id,
born: now,
progress: 1.0,
});
now
}
};
// 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。
let progress = if scale.is_off() {
1.0
} else {
let (progress, in_flight) = sample(toast, born, now, &spec, scale);
moving |= in_flight;
progress
};
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == toast.id) {
entry.progress = progress;
}
}
moving
}
/// 这一条当前该缩放成什么样(`0..=1`;没登记过 = 在位,宁可不动也不许卡在中间)。
pub(crate) fn progress(&self, id: ToastId) -> f32 {
self.entries
.iter()
.find(|entry| entry.id == id)
.map_or(1.0, |entry| entry.progress)
}
}
/// 一条 toast 的进 / 出采样(纯函数,测试直接打这里)。
///
/// 进场:`born` 之后走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。
/// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下
/// 全尺寸再消失)。
fn sample(
toast: &Toast,
born: Instant,
now: Instant,
spec: &motion::ModalSpec,
scale: unlucky_design::motion::MotionScale,
) -> (f32, bool) {
let enter_ms = scale.ms(spec.enter.duration).max(1.0);
let exit_ms = scale.ms(spec.exit.duration).max(1.0);
let lived_ms = now.saturating_duration_since(born).as_secs_f32() * 1000.0;
let enter_t = (lived_ms / enter_ms).clamp(0.0, 1.0);
let exit_t = (1.0 - toast.remaining_ms as f32 / exit_ms).clamp(0.0, 1.0);
let progress = spec
.enter
.easing
.sample(enter_t)
.min(1.0 - spec.exit.easing.sample(exit_t))
.clamp(0.0, 1.0);
(progress, enter_t < 1.0 || exit_t > 0.0)
}
#[cfg(test)]
mod tests {
//! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。
//!
//! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了
//! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` 必须返回 `false`)。
//! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。
use super::*;
use std::time::Duration;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::toast::ToastKind;
fn toast(id: ToastId, remaining_ms: u64) -> Toast {
Toast {
id,
kind: ToastKind::Info,
text: "已取消安装".to_string(),
remaining_ms,
}
}
fn tracks(scale: MotionScale) -> ToastTracks {
motion::reset();
motion::configure_for_test(scale, None);
ToastTracks::default()
}
#[test]
fn a_fresh_toast_enters_from_zero_and_stays_at_one() {
let mut tracks = tracks(MotionScale::STANDARD);
let now = Instant::now();
let items = [toast(1, 4_000)];
// 第一拍:刚登记 = 进场起点。
assert!(tracks.observe(&items, now), "进场这一段必须在动");
assert!(
tracks.progress(1) < 0.05,
"刚出现必须是进场起点,实测 {}",
tracks.progress(1)
);
// 进场腿走完之后:停在 1.0,且不再报「在动」。
let later = now + Duration::from_millis(400);
assert!(!tracks.observe(&items, later), "进场走完就不该再续帧");
assert_eq!(tracks.progress(1), 1.0, "在位就必须是满进度");
}
#[test]
fn the_last_moments_play_the_exit_leg_down_to_zero() {
let mut tracks = tracks(MotionScale::STANDARD);
let now = Instant::now();
tracks.observe(&[toast(7, 4_000)], now);
let settled = now + Duration::from_millis(400);
tracks.observe(&[toast(7, 4_000)], settled);
assert_eq!(tracks.progress(7), 1.0);
// 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。
assert!(
tracks.observe(&[toast(7, 60)], settled),
"退场这一段必须在动"
);
let mid = tracks.progress(7);
assert!(
mid < 1.0 && mid > 0.0,
"退场途中的进度应在 (0,1),实测 {mid}"
);
// 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉,
// 中间不许留一帧「还满着」。
tracks.observe(&[toast(7, 0)], settled);
assert!(tracks.progress(7) < 0.05, "到期当拍必须归零");
}
#[test]
fn motion_off_is_bit_exact_still_and_never_asks_for_frames() {
let mut tracks = tracks(MotionScale::NONE);
let now = Instant::now();
assert!(
!tracks.observe(&[toast(1, 4_000)], now),
"关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)"
);
assert_eq!(tracks.progress(1), 1.0, "NONE 下进度恒为终值");
}
#[test]
fn a_vanished_toast_is_forgotten() {
let mut tracks = tracks(MotionScale::STANDARD);
let now = Instant::now();
tracks.observe(&[toast(1, 4_000)], now);
assert!(tracks.observe(&[toast(2, 4_000)], now), "新的那条在进场");
assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)");
assert!(
!tracks.entries.iter().any(|entry| entry.id == 1),
"消失的条目必须被清掉(否则长时间常驻会线性增长)"
);
}
}
@@ -0,0 +1,48 @@
//! 出图取证用的 Toast 夹具(`ULL_TOAST_FIXTURE`;产品路径零影响)。
//!
//! # 它解决什么
//!
//! M298 的交付图要证明「提示真的画在窗口底部」,而产品路径的 toast 由事件推出来
//! (安装取消 / 启动收尾 / 复制成功),截图通道在第 3 帧就出图:等不到那条事件。
//! 处理方式与 `mods_shot` / `saves_shot` 一致——**状态注入**,注在「快照 → 页面模型」之前,
//! 于是覆盖层、胶囊几何、i18n 文案走的都是产品代码。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效(`ULL_TOAST_FIXTURE` 非空且不为 `0`);
//! - 推的是**一条真实形状的提示**:文案由产品函数 `launch_toast` 按**生效语言**现求,
//! 不是夹具里写死的一句中文;
//! - 剩余寿命走 `ToastCenter` 的默认 TTL(4 s),过期仍由 `app/update/clock.rs` 的 `expire` 管。
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use unlucky_presenter::toast::ToastKind;
use super::App;
/// 夹具开关是否打开(`ULL_TOAST_FIXTURE` 非空且不为 `0`)。
fn enabled() -> bool {
std::env::var("ULL_TOAST_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 推一条「游戏崩溃」的收尾提示(就是用户在真机上看到的那一句)。
pub(super) fn seed(app: &mut App) {
if !enabled() {
return;
}
// 形状取自真实收尾事件:崩溃 + 退出码 1 + 跑了 12 秒。
let finished = LaunchFinished {
id: "survival".to_string(),
session: String::new(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 12_000,
failure: None,
};
let text = crate::app::view::instances::launch_toast(&finished, app.lang());
app.state.toasts.push(ToastKind::Error, text);
}
+5
View File
@@ -123,6 +123,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
// M298:提示有了渲染点之后,「推了不置脏」= 要等下一次事件才出现(拷贝成功尤其
// 刺眼:用户盯着屏幕等一句"已复制")。两条拷贝路径都在这里补上。
app.view_dirty = true;
iced::clipboard::write(export::paths_text(&card.report))
}
DialogMessage::CopyPath(index) => {
@@ -138,6 +141,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
// M298:同 `CopyPaths`(推了必须置脏,否则这一条要等下一次事件)。
app.view_dirty = true;
iced::clipboard::write(path.display().to_string())
}
}
+10
View File
@@ -24,6 +24,7 @@ mod account_page;
pub(super) mod instances;
mod logs;
mod rail;
mod toast;
mod update;
use instances::{detail_of, runtime_status_row, supporting_line};
@@ -186,6 +187,15 @@ impl App {
self.lang(),
),
};
// M298:Toast 覆盖层。**接在页面树之上、模态之下**:一条提示不该盖住对话框
// (启动失败时两者会同时出现——Toast 说结局,对话框给证据),但必须盖住页面内容。
// 队列为空时 `layer` 原样返回,所以没有提示的那些帧树形与改造前逐节点相同。
let tree = toast::layer(
&self.ui,
tree,
self.state.toasts.items(),
&self.toast_tracks,
);
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
let tree = match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
+294
View File
@@ -0,0 +1,294 @@
//! Toast 覆盖层(M298):`state.toasts` 的**唯一渲染点**。
//!
//! ## 缺口有多真
//!
//! M297 盘查:`state.toasts` 全仓只被 **push**(安装取消 / 启动收尾 / mod 启停失败 / 复制成功)
//! 与 **expire**(`app/update/clock.rs`),`view.rs` 与 `pages/**` 对 `toasts` 零命中 ⇒ 提示一条
//! 都看不见。与「收尾失败静默不弹框」叠在一起,启动失败可以**完全无反馈**。
//!
//! ## 这一层画什么
//!
//! MD3E 胶囊:`InverseSurface` 底、`InverseOnSurface` 字、**`ShapeScale::Full`**(高的一半 ⇒
//! 真胶囊)、贴**底部居中**、多条自下而上叠(最新的那条在最下面,与推送顺序一致)。
//! 几何取组件层 [`SnackbarGeometry`](高 52 / 横内距 16 / 竖 6 / 间距 8 / 外边距 12 / 上限宽 600),
//! 不在这里写第二份数字。**不画阴影**:D27(CPU 档禁模糊)+ 组件层 D-4 的同一口径。
//!
//! ## 进 / 出过渡:复用模态那一对腿,不新造弹簧
//!
//! 进度由 [`ToastTracks`](super::super::state::toast::ToastTracks) 在 **update 侧**采样
//! (toast 自己的出生时刻 + `remaining_ms`),这里只把进度换成缩放:`motion::modal_scale`
//! 与 [`motion::scaled`]。档位、缓动、缩放起点全是既有令牌(D31:适配层不新造数)。
//! 关掉动效时进度恒 `1.0` ⇒ 恒等变换、出图逐像素不变。
//!
//! ## 为什么这一层不吃点击
//!
//! 它是一层铺满窗口的 `container`(`Fill`/`Fill`):iced 的 `Stack` 只在子控件
//! **捕获**事件时才停止向下派发,而普通容器不捕获 ⇒ 点到胶囊以外(以及胶囊本身)的按下照样
//! 落到下面的页面树。所以这一层不提供「点掉」交互:`ToastCenter::dismiss` 今天没有调用点,
//! 到期由 `clock.rs` 的 `expire` 统一处理(任务要求:过期仍走既有那条)。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::widget::{container, row, text, Space};
use iced::{
Alignment, Background, Border, Element, Event, Length, Padding, Rectangle, Size, Vector,
};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::components::feedback::panel::SnackbarGeometry;
use crate::app::components::motion;
use crate::theme::Ui;
use super::super::message::Message;
use super::super::state::toast::ToastTracks;
/// 把 toast 覆盖层叠在 `base` 上(队列为空时原样返回 `base`)。
pub(super) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
toasts: &'a [Toast],
tracks: &'a ToastTracks,
) -> Element<'a, Message> {
if toasts.is_empty() {
return base;
}
let geometry = SnackbarGeometry::of(ui);
// 推送顺序 = 出现顺序:最早的在上,最新的贴底(MD3E 的堆叠方向)。
let mut column = iced::widget::Column::new()
.spacing(geometry.gap)
.align_x(Alignment::Center);
for toast in toasts {
column = column.push(capsule(ui, &geometry, toast, tracks.progress(toast.id)));
}
let plane = container(column)
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding::from(geometry.margin))
.align_x(Alignment::Center)
.align_y(Alignment::End);
let texts = toasts
.iter()
.map(|toast| toast.text.clone())
.collect::<Vec<_>>();
iced::widget::stack![base, tagged(plane.into(), texts)].into()
}
/// 一条胶囊:语义色的点 + 一行字(高度固定,字按词折行)。
fn capsule<'a>(
ui: &'a Ui,
geometry: &SnackbarGeometry,
toast: &'a Toast,
progress: f32,
) -> Element<'a, Message> {
let height = geometry.height_for(1);
let style = ui.style(TypeRole::BodyMedium);
let surface = ui.color(Role::InverseSurface);
// 语义色**只**落在前面那个点上:底与字必须保持 inverse 那一对(否则深色档对比度会掉)。
let accent = match toast.kind {
ToastKind::Info | ToastKind::Success => Role::InversePrimary,
ToastKind::Warn | ToastKind::Error => Role::Error,
};
// 点的直径取组件层的 **间距档**(8),圆角 `Full` ⇒ 正圆;不另写一个点径数字。
let dot_side = geometry.gap;
let dot: Element<'a, Message> = container(
Space::new()
.width(Length::Fixed(dot_side))
.height(Length::Fixed(dot_side)),
)
.style(move |_theme: &iced::Theme| container::Style {
background: Some(Background::Color(ui.color(accent))),
border: Border {
radius: ui.radius(ShapeScale::Full, dot_side),
..Border::default()
},
..container::Style::default()
})
.into();
let body = row![
dot,
text(toast.text.as_str())
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::InverseOnSurface))
.width(Length::Fill),
]
.align_y(Alignment::Center)
.spacing(geometry.gap);
let bar: Element<'a, Message> = container(body)
.width(Length::Fill)
.max_width(geometry.max_width)
.height(Length::Fixed(height))
.padding(Padding {
top: geometry.pad_v,
right: geometry.pad_h,
bottom: geometry.pad_v,
left: geometry.pad_h,
})
.style(move |_theme: &iced::Theme| container::Style {
background: Some(Background::Color(surface)),
border: Border {
radius: capsule_radius(ui, height),
..Border::default()
},
..container::Style::default()
})
.into();
motion::scaled(bar, motion::modal_scale(progress))
}
/// 胶囊圆角:`ShapeScale::Full` 对高求值(= 高的一半 ⇒ 真胶囊)。
///
/// 与组件层 snackbar 的 **16** 不是同一个口径(那是规格卡的矩形提示条);MD3E 的 toast 是
/// 胶囊,任务点名要 `ShapeScale::Full`,所以这里按**高的一半**算,且只在这一处求值。
fn capsule_radius(ui: &Ui, height: f32) -> iced::border::Radius {
ui.radius(ShapeScale::Full, height)
}
/// 覆盖层的**自证状态**:树里带着它 = 这些提示真的进了控件树。
///
/// 为什么需要它:iced 0.14 没有「把整棵树遍历成文本」的入口(`Element` 不透出内容、
/// `Widget::children()` 只给 [`Tree`],而 `Tree` 不带文字)。所以这一层把自己的**文案清单**
/// 作为 [`Widget::state`] 交出去——用例顺着 `Tree::state` 就能读到「画出来的到底是哪几句」,
/// 而不是只看 `state.toasts.len()`(那证明不了覆盖层被接上)。
#[derive(Debug)]
pub(super) struct CapsuleState {
/// 这一帧真正画出去的文案(顺序 = 自下而上的堆叠顺序)。
///
/// 只有用例读它(产品路径把状态交给 iced 就完事),所以非测试构建里显式放过 dead_code。
#[allow(dead_code)]
pub texts: Vec<String>,
}
/// 给覆盖层挂上自证状态(`tag` 用状态类型,见 iced 的 `container` / `text` 同一手法)。
pub(super) fn tagged<'a>(
content: Element<'a, Message>,
texts: Vec<String>,
) -> Element<'a, Message> {
Element::new(Tagging { content, texts })
}
struct Tagging<'a, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
texts: Vec<String>,
}
impl<Theme, Renderer> Widget<Message, Theme, Renderer> for Tagging<'_, Theme, Renderer>
where
Renderer: iced::advanced::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<CapsuleState>()
}
fn state(&self) -> tree::State {
tree::State::new(CapsuleState {
texts: self.texts.clone(),
})
}
fn children(&self) -> Vec<Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut Tree) {
tree.state = self.state();
self.content.as_widget().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn iced::advanced::widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests;
+132
View File
@@ -0,0 +1,132 @@
//! 覆盖层的守门用例(M298)。
//!
//! ## 这一组抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`a_pushed_toast_really_reaches_the_widget_tree`] | 覆盖层**接在 `view()` 上**,且真的把那句话交给了控件树(把 `view.rs` 里那行 `toast::layer(..)` 删掉即红) | 像素:胶囊长什么样(`DevDocs/artifacts/m298/toast-capsule.png` 守) |
//! | [`the_capsule_is_a_full_radius_pill`] | 圆角是 `ShapeScale::Full`(高的一半 = 26),不是组件层的 16 | 观感对不对 |
//! | [`an_empty_queue_adds_no_layer_at_all`] | 没有提示时树形与改造前**逐节点相同**(覆盖层不是常驻空壳) | —— |
//!
//! ## 为什么不是「只断 `state.toasts.len()`」
//!
//! 长度能证明队列里有东西,证明不了**有人画它**(M297 的缺口正是这样活下来的:队列里一直有
//! 数据,渲染点一处都没有)。所以这里顺着 `Widget::children()` 走真控件树,靠
//! [`CapsuleState`] 把「画出去的文案」读回来。
use iced::advanced::widget::Tree;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::Platform;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::ToastKind;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::theme::Families;
use super::{capsule_radius, CapsuleState};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 顺着真控件树把所有 `CapsuleState` 里的文案收集出来(`Tree::state` 是 iced 给控件挂
/// 自证状态的官方位置,`container` / `text` 用它存自己的缓存)。
fn painted_texts(element: &iced::Element<'_, Message>) -> Vec<String> {
fn walk(tree: &Tree, out: &mut Vec<String>) {
if tree.tag == iced::advanced::widget::tree::Tag::of::<CapsuleState>() {
let state = tree.state.downcast_ref::<CapsuleState>();
out.extend(state.texts.iter().cloned());
}
for child in &tree.children {
walk(child, out);
}
}
let tree = Tree::new(element.as_widget());
let mut out = Vec::new();
walk(&tree, &mut out);
out
}
/// 守卫①:**push 一条 toast ⇒ 视图树里真的出现那句话**。
///
/// **变异证明**:把 `app/view.rs` 里 `let tree = toast::layer(...)` 那一行删掉 / 改成直接返回
/// `tree`,本用例第二段立刻红(`painted_texts` 拿到空表)。
#[test]
fn a_pushed_toast_really_reaches_the_widget_tree() {
let mut app = headless();
app.refresh_view();
assert!(
painted_texts(&app.view()).is_empty(),
"出厂态不该有覆盖层节点(那时队列是空的)"
);
app.state.toasts.push(ToastKind::Info, "已取消安装");
app.refresh_view();
let texts = painted_texts(&app.view());
assert!(
texts.iter().any(|text| text == "已取消安装"),
"push 之后控件树里必须有这句话——实际拿到 {texts:?}"
);
// 队列里那一条仍然是**事实来源**,覆盖层只是它的投影(不是第二份真相)。
assert_eq!(app.state.toasts.len(), 1);
}
/// 守卫②:胶囊是真胶囊(`ShapeScale::Full` ⇒ 高的一半)。
///
/// **变异证明**:把 `capsule` 里那一行改成 `geometry.radius`(组件层的 16),本用例立刻红。
#[test]
fn the_capsule_is_a_full_radius_pill() {
let ui = crate::theme::Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES);
let height = super::SnackbarGeometry::of(&ui).height_for(1);
let radius = capsule_radius(&ui, height);
assert_eq!(
radius,
iced::border::Radius::from(height / 2.0),
"MD3E 胶囊的圆角必须等于高的一半(ShapeScale::Full),实测高 {height}"
);
// 同一件事的第二种说法:就是 `ShapeScale::Full` 在这一档上的求值。
assert_eq!(radius, ui.radius(ShapeScale::Full, height));
// 反向守卫:组件层 snackbar 的 16 不是胶囊(改回 `geometry.radius` 时上面两条立刻红)。
assert_ne!(radius, iced::border::Radius::from(16.0));
}
/// 守卫③:空队列 ⇒ **不叠任何东西**(没有提示的那些帧树形与改造前一致)。
///
/// **变异证明**:把 `layer` 开头的 `if toasts.is_empty() { return base; }` 删掉,本用例立刻红
/// ——那时每帧都会多一层空容器(白白多一棵子树、也让「有没有提示」在树上不可辨)。
#[test]
fn an_empty_queue_adds_no_layer_at_all() {
fn nodes(element: &iced::Element<'_, Message>) -> usize {
fn walk(tree: &Tree) -> usize {
1 + tree.children.iter().map(walk).sum::<usize>()
}
walk(&Tree::new(element.as_widget()))
}
let mut app = headless();
app.refresh_view();
let empty = nodes(&app.view());
app.state.toasts.push(ToastKind::Info, "已取消安装");
app.refresh_view();
let with_toast = nodes(&app.view());
assert!(
with_toast > empty,
"有提示时树上必须多出覆盖层(空队列 {empty} 个节点,有提示 {with_toast} 个)"
);
}
+46 -122
View File
@@ -18,11 +18,17 @@
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use super::crash_scan::{self, CrashArtifact, CrashSource};
use super::crash_scan::{self, CrashSource};
use super::launch_session::SessionHandle;
use super::redact::Redactor;
mod context;
mod slot;
pub use context::ReportContext;
pub use slot::{clear, failing_launch, publish};
use context::{fact, scrub_tail};
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
pub const TAIL_LINES: usize = 30;
@@ -83,6 +89,19 @@ pub struct CrashReport {
pub stderr_tail: Vec<String>,
}
/// 「进程确实起来过」的那一场会话归因([`CrashReport::before_start`] 的可选入参)。
///
/// 只带两样东西:**会话 id**(必须与收尾事件里的 `session` 是同一个值)与事件流落点。
/// 收尾失败时进程真的起来过,所以这份现场**有主人**;写成空串等于说「这场失败不属于任何人」,
/// 界面侧只能把它丢掉(M298)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionAttribution<'a> {
/// 会话 id。
pub id: &'a str,
/// 该会话的事件流 JSONL(`None` = 拿不到落点,仍然记得住归属)。
pub log: Option<&'a std::path::Path>,
}
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Evidence {
@@ -208,19 +227,39 @@ impl CrashReport {
}
}
/// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。
/// 收集一次**没有 `SessionHandle`** 的现场。
///
/// 两个用法,判据是同一个参数(`session`):
///
/// | 出口 | `session` | 为什么 |
/// |---|---|---|
/// | 装配失败 / spawn 失败 | `None` | 进程根本没起来,这一场**确实**没有会话 |
/// | 收尾失败(`finish()` 报错) | `Some(归因)` | 进程起来过,这次失败属于那一场 |
///
/// 第二种是 M298 修的缺口:此前两个出口都写空串,而界面侧的并发守卫
/// (`CrashReport::session != LaunchFinished::session`)会因此把「自己这一场」判成
/// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责
/// 把它原样记进报告——两边同源,守卫才可能判对。
///
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
#[must_use]
pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self {
pub fn before_start(
ctx: &ReportContext,
stage: &str,
detail: &str,
exit_type: &str,
session: Option<SessionAttribution<'_>>,
) -> Self {
let redactor = ctx.redactor();
let session_id = session.map_or_else(String::new, |it| it.id.to_string());
let session_log = session.and_then(|it| it.log.map(PathBuf::from));
Self {
instance: ctx.instance.clone(),
name: ctx.name.clone(),
version: ctx.version.clone(),
session: String::new(),
session_log: None,
session: session_id,
session_log,
version_json: ctx.version_json.clone(),
launcher_logs: Some(ctx.launcher_logs.clone()),
run_dir: Some(ctx.run_dir.clone()),
@@ -275,120 +314,5 @@ impl CrashReport {
}
}
/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。
///
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportContext {
/// 实例 id。
pub instance: String,
/// 实例名。
pub name: String,
/// 版本 id。
pub version: String,
/// 实例运行目录。
pub run_dir: PathBuf,
/// 启动器日志目录(`.ULL/logs`)。
pub launcher_logs: PathBuf,
/// 本次会话的开始时刻(现场文件的时间闸)。
pub started_at: std::time::SystemTime,
/// 该实例绑定的版本 JSON。
pub version_json: Option<PathBuf>,
/// Java 一行说明。
pub java: String,
/// 系统一行说明。
pub system: String,
/// 要额外抹掉的串(会话 id、access token 等)。
pub secrets: Vec<String>,
}
impl ReportContext {
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
#[must_use]
pub fn new(
instance: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
run_dir: impl Into<PathBuf>,
launcher_logs: impl Into<PathBuf>,
started_at: std::time::SystemTime,
system: impl Into<String>,
) -> Self {
Self {
instance: instance.into(),
name: name.into(),
version: version.into(),
run_dir: run_dir.into(),
launcher_logs: launcher_logs.into(),
started_at,
version_json: None,
java: String::new(),
system: system.into(),
secrets: Vec::new(),
}
}
fn redactor(&self) -> Redactor {
let mut redactor = Redactor::new();
for key in ["USERPROFILE", "HOME"] {
if let Ok(home) = std::env::var(key) {
if !home.trim().is_empty() {
redactor = redactor.with_home_str(home);
}
}
}
for secret in &self.secrets {
redactor = redactor.with_secret(secret.clone());
}
redactor
}
}
fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
lines
.iter()
.map(|line| redactor.scrub(line).into_owned())
.collect()
}
fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
let excerpt = artifact.excerpt.as_ref().map(|text| {
let scrubbed = redactor.scrub(text);
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
trimmed
});
ArtifactFact {
source: artifact.source.as_str().to_string(),
path: artifact.path.clone(),
excerpt,
}
}
fn slot() -> &'static Mutex<Option<CrashReport>> {
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
/// 登记一次失败启动的现场(启动线程收尾时调用)。
pub fn publish(report: CrashReport) {
if let Ok(mut guard) = slot().lock() {
*guard = Some(report);
}
}
/// 读最近一次失败启动的现场。
#[must_use]
pub fn failing_launch() -> Option<CrashReport> {
slot().lock().ok().and_then(|guard| guard.clone())
}
/// 清空槽位(测试用)。
pub fn clear() {
if let Ok(mut guard) = slot().lock() {
*guard = None;
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,108 @@
//! 现场的**输入**与脱敏(M298 从 `crash_report.rs` 拆出,纯搬移)。
//!
//! 这个文件只回答两件事:①收集一次现场要知道哪几样事实([`ReportContext`]);
//! ②它们在被写进 [`CrashReport`](super::CrashReport) 之前必须先过哪一道脱敏闸
//! ([`fact`] / [`scrub_tail`])。
//!
//! 脱敏器**不缓存**:每次 [`ReportContext::redactor`] 都重新读一遍主目录环境变量。这条路径
//! 只在启动收尾时走一次,缓存省不下什么,却会引入「进程启动后 HOME 变了」的第二份真相。
use std::path::PathBuf;
use super::super::crash_scan::CrashArtifact;
use super::super::redact::Redactor;
use super::{ArtifactFact, MAX_EXCERPT_CHARS};
/// [`CrashReport::collect`](super::CrashReport::collect) /
/// [`CrashReport::before_start`](super::CrashReport::before_start) 的输入(全是启动侧已有的东西)。
///
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportContext {
/// 实例 id。
pub instance: String,
/// 实例名。
pub name: String,
/// 版本 id。
pub version: String,
/// 实例运行目录。
pub run_dir: PathBuf,
/// 启动器日志目录(`.ULL/logs`)。
pub launcher_logs: PathBuf,
/// 本次会话的开始时刻(现场文件的时间闸)。
pub started_at: std::time::SystemTime,
/// 该实例绑定的版本 JSON。
pub version_json: Option<PathBuf>,
/// Java 一行说明。
pub java: String,
/// 系统一行说明。
pub system: String,
/// 要额外抹掉的串(会话 id、access token 等)。
pub secrets: Vec<String>,
}
impl ReportContext {
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
#[must_use]
pub fn new(
instance: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
run_dir: impl Into<PathBuf>,
launcher_logs: impl Into<PathBuf>,
started_at: std::time::SystemTime,
system: impl Into<String>,
) -> Self {
Self {
instance: instance.into(),
name: name.into(),
version: version.into(),
run_dir: run_dir.into(),
launcher_logs: launcher_logs.into(),
started_at,
version_json: None,
java: String::new(),
system: system.into(),
secrets: Vec::new(),
}
}
/// 本次现场用的脱敏器(主目录 + `secrets` 两把刀)。
pub(super) fn redactor(&self) -> Redactor {
let mut redactor = Redactor::new();
for key in ["USERPROFILE", "HOME"] {
if let Ok(home) = std::env::var(key) {
if !home.trim().is_empty() {
redactor = redactor.with_home_str(home);
}
}
}
for secret in &self.secrets {
redactor = redactor.with_secret(secret.clone());
}
redactor
}
}
/// 捕获输出尾巴的脱敏(逐行过同一把刀)。
pub(super) fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
lines
.iter()
.map(|line| redactor.scrub(line).into_owned())
.collect()
}
/// 现场文件 → 报告里的一条事实(摘要抹平 + 截断到 [`MAX_EXCERPT_CHARS`])。
pub(super) fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
let excerpt = artifact.excerpt.as_ref().map(|text| {
let scrubbed = redactor.scrub(text);
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
trimmed
});
ArtifactFact {
source: artifact.source.as_str().to_string(),
path: artifact.path.clone(),
excerpt,
}
}
@@ -0,0 +1,38 @@
//! 进程级**单槽**:最近一次失败启动的现场(M298 从 `crash_report.rs` 拆出,纯搬移)。
//!
//! ## 为什么是一格而不是一条队列
//!
//! 失败弹窗的消费方是「收尾事件到达的那一拍」——一个 [`LaunchFinished`] 只解释**一场**失败。
//! 多存几份没有读者,反而会让「这份现场是哪一场的」变成要靠时间猜的问题。
//!
//! 归属的正确性**不靠这个槽**:报告自己带 `session`,界面侧的并发守卫拿它与收尾事件比
//! (见 `crates/app-iced/src/app/dialog.rs`;M298 把 session 归属做成单一来源)。
use std::sync::{Mutex, OnceLock};
use super::CrashReport;
fn slot() -> &'static Mutex<Option<CrashReport>> {
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
/// 登记一次失败启动的现场(启动线程收尾时调用)。
pub fn publish(report: CrashReport) {
if let Ok(mut guard) = slot().lock() {
*guard = Some(report);
}
}
/// 读最近一次失败启动的现场。
#[must_use]
pub fn failing_launch() -> Option<CrashReport> {
slot().lock().ok().and_then(|guard| guard.clone())
}
/// 清空槽位(测试用)。
pub fn clear() {
if let Ok(mut guard) = slot().lock() {
*guard = None;
}
}
+45 -1
View File
@@ -2,7 +2,10 @@
use std::path::PathBuf;
use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext};
use super::{
clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext,
SessionAttribution,
};
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
CrashReport {
@@ -104,6 +107,7 @@ fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() {
"spawn",
&format!("{home}/java/bin/javaw.exe 不存在"),
"spawn",
None,
);
assert!(!report.started(), "进程没起来");
assert_eq!(report.evidence(), Evidence::Nothing);
@@ -123,3 +127,43 @@ fn slot_keeps_the_last_failure_only() {
clear();
assert!(failing_launch().is_none());
}
/// M298:收尾失败出口的现场**必须记得自己是哪一场**。
///
/// **变异证明**:把 `before_start` 里的 `session` 参数改回写死 `String::new()`(本 mission 修之前
/// 的写法),本用例第一段立刻红。产品上那条变异的表现是「真收尾失败静默不弹框」——
/// 界面侧的并发守卫会拿空串去和收尾事件里的真实会话比,判成「别人的报告」后 `return`。
///
/// 第二段是方向相反的守卫:装配失败 / spawn 失败**确实**没有会话,那里不许为了"凑一个非空"
/// 而编一个 id 出来(那会让那些出口的现场指向不存在的会话)。
#[test]
fn a_finish_failure_report_keeps_its_session() {
let ctx = ReportContext::new(
"survival",
"生存",
"1.20.4",
std::path::Path::new("/instances/survival"),
std::path::Path::new("/.ULL/logs"),
std::time::SystemTime::now(),
"os=windows",
);
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
let session = SessionAttribution {
id: "9f2c",
log: Some(log.as_path()),
};
let finished =
CrashReport::before_start(&ctx, "spawn", "会话写入器不可用", "spawn", Some(session));
assert_eq!(finished.session, "9f2c", "现场必须带着自己那一场会话");
assert_eq!(finished.session_log.as_deref(), Some(log.as_path()));
// 会话在,就不该在清单里说「进程没起来」。
assert!(!finished
.missing_lines()
.iter()
.any(|line| line.contains("会话 JSONL")));
let never_started =
CrashReport::before_start(&ctx, "version_manifest", "缺清单", "version_manifest", None);
assert!(never_started.session.is_empty(), "装配失败确实没有会话");
assert!(never_started.session_log.is_none());
}
+41 -207
View File
@@ -7,20 +7,13 @@
//! 本模块内被读一次,绝不进 DTO。
//!
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
//!
//! M298 把装配那一段(`build_request` 与它的两个私有助手)搬到 [`build`],`pub use` 保路径:
//! 本文件只留「起一次启动 / 停一次启动 / 失败出口怎么登记现场」这条生命周期。
use std::path::PathBuf;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext};
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext, SessionAttribution};
use unlucky_core::launch::launcher_log;
use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::{Launcher, LauncherConfig};
@@ -31,9 +24,11 @@ use crate::dto::{
use super::env::HostEnv;
mod build;
mod java;
use super::job::{HostTask, JobSender};
pub use build::build_request;
pub use java::pick_java;
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
@@ -52,178 +47,6 @@ pub struct LaunchTarget {
pub account: Option<String>,
}
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
///
/// # Errors
///
/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
pub fn build_request(
env: &HostEnv,
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
let version_id = target.version_id.clone();
// **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、
// assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。
let game = super::game::game_for(env, &target.id);
let manifest_path = game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let manifest = VersionJson::from_json_str(&text).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved = game::resolve(&source, &manifest).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
})?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) {
continue;
}
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else {
continue;
};
(coordinate, library.name.clone())
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, name));
}
let natives = unlucky_core::launch::natives::collect(
&game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, &game, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
// 装配的门限 / 分支按**继承链的 MC 根版本**判,不是带加载器后缀的 version_id:
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
let (mc_version, _) = super::game::describe_version(&game, &version_id);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
version_type: None,
game_version: Some(mc_version.as_str()),
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let overrides = super::launch_args::resolved(env, &target.id);
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let options = AdapterOptions {
java: java.selection,
memory: MemoryOptions {
min_mb: None,
max_mb: Some(overrides.memory_mb),
},
auth,
launcher_name: LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let mut warnings: Vec<String> = output.warnings.iter().map(describe_warning).collect();
warnings.push(java.summary);
warnings.push(identity_line);
let mut request = output.request;
request.instance.id = target.id.clone();
request.instance.name = target.name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &overrides);
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::game::base_mode`])。
///
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
fn run_dir(
env: &HostEnv,
game: &unlucky_core::game::GameLayout,
target: &LaunchTarget,
) -> Result<PathBuf, LaunchFailureView> {
let base = super::game::base_mode(env, game, &target.id, &target.version_id);
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
})?;
let derivation = derive_game_dir(game, &target.version_id, base, config.as_ref(), None)
.map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
})?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
}
Ok(derivation.game_dir)
}
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let id = target.id.clone();
@@ -259,7 +82,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
Err(failure) => {
let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
launcher_log::record("launch", &line);
publish_before_start(&report, failure.stage.slug(), &failure.detail);
publish_before_start(&report, None, failure.stage.slug(), &failure.detail);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
@@ -300,7 +123,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
"launch",
&format!("instance={} spawn 失败:{error}", target.id),
);
publish_before_start(&report, "spawn", &failure.detail);
publish_before_start(&report, None, "spawn", &failure.detail);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
@@ -314,6 +137,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
};
let session_id = session.session_id().to_string();
let pid = session.pid();
// **归属的唯一一份**(M298):收尾失败那条出口要把「这次失败属于哪一场」写进现场,
// 收尾事件也要带同一个值——两处都从这里取,界面侧的并发守卫才可能判对。
let session_log = session.log_path().to_path_buf();
let attribution = SessionAttribution {
id: &session_id,
log: Some(session_log.as_path()),
};
let _ = tx.send(HostTask::LaunchStarted {
id: id.clone(),
session_id: session_id.clone(),
@@ -335,7 +165,8 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
crash_report::publish(CrashReport::collect(&handle, &report, &lines));
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
// 与 `CrashReport::collect` 取自同一处(`handle.session_id`)。
session: handle.session_id.clone(),
outcome,
exit_code: Some(handle.exit.code),
uptime_ms: handle.uptime_ms,
@@ -350,10 +181,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
target.id
);
launcher_log::record("launch", &line);
publish_before_start(&report, "spawn", &failure.detail);
// **收尾失败时进程真的起来过** ⇒ 这份现场属于 `attribution` 那一场。
// 返回值就是报告里写的那条会话 id(同一处取值),所以「报告空串 vs 事件非空」
// 这种守卫必然误判的组合从构造上就不存在了(M298)。
let session =
publish_before_start(&report, Some(&attribution), "spawn", &failure.detail);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: session_id,
session,
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
@@ -364,9 +199,23 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
});
}
/// 登记一次「进程没起来」的现场(四个失败出口共用这一处,避免各写一遍 exit_type)。
fn publish_before_start(report: &ReportContext, stage: &str, detail: &str) {
crash_report::publish(CrashReport::before_start(report, stage, detail, stage));
/// 登记一次**没有 handle** 的失败现场,并返回该写进 `LaunchFinished.session` 的值。
///
/// **会话归属的单一来源**(M298):报告里的 `session` 与收尾事件里的 `session` 都由
/// `attribution` 这一处产出——调用方直接拿返回值填 DTO,于是两处不会再各写一遍、也不会出现
/// 「报告写空串、事件写真实会话」这种守卫必然误判的组合。
///
/// `attribution` 为 `None` = 这一场**确实**没有会话(装配失败 / spawn 失败,进程没起来)。
fn publish_before_start(
report: &ReportContext,
attribution: Option<&SessionAttribution<'_>>,
stage: &str,
detail: &str,
) -> String {
let registered = CrashReport::before_start(report, stage, detail, stage, attribution.copied());
let session = registered.session.clone();
crash_report::publish(registered);
session
}
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
@@ -375,21 +224,6 @@ pub fn stop(pid: u32) -> Result<(), String> {
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
}
/// 适配层告警 → 人读一行。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(),
AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(),
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
/// 隔离档位的展示串(`isolated` / `shared`)。
pub fn isolation_label(mode: IsolationMode) -> &'static str {
mode.as_str()
+213
View File
@@ -0,0 +1,213 @@
//! 装配 [`LaunchRequest`]:实例 → 版本 JSON → 继承链 → 参数展开 → 适配器(M298 从
//! `host/launch.rs` 拆出,纯搬移 + `pub use` 保路径)。
//!
//! 这一层与 CLI 的 `snapshot::prepare` 同源同序,差异只在「实例信息从哪来」:CLI 从命令行,
//! 这里从 GUI 选中的实例([`LaunchTarget`])。
//!
//! 拆分的直接原因只有行数(`host/launch.rs` 当时 399 行、贴着 400 行硬线),**没有行为变化**:
//! 函数体与顺序逐行一致,调用点仍是 `super::launch::build_request`(父模块 `pub use` 转出)。
use std::path::PathBuf;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::{derive_game_dir, observe_mode};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use crate::dto::{LaunchFailureStage, LaunchFailureView};
use super::super::env::HostEnv;
use super::super::{game as host_game, identity, launch_args};
use super::{pick_java, LaunchTarget, LAUNCHER_NAME};
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
///
/// # Errors
///
/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
pub fn build_request(
env: &HostEnv,
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
let version_id = target.version_id.clone();
// **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、
// assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。
let game = host_game::game_for(env, &target.id);
let manifest_path = game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let manifest = VersionJson::from_json_str(&text).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved = game::resolve(&source, &manifest).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
})?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) {
continue;
}
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else {
continue;
};
(coordinate, library.name.clone())
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, name));
}
let natives = unlucky_core::launch::natives::collect(
&game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, &game, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
// 装配的门限 / 分支按**继承链的 MC 根版本**判,不是带加载器后缀的 version_id:
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
let (mc_version, _) = host_game::describe_version(&game, &version_id);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
version_type: None,
game_version: Some(mc_version.as_str()),
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let overrides = launch_args::resolved(env, &target.id);
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
let (auth, identity_line) = identity::resolve(env, target.account.as_deref())?;
let options = AdapterOptions {
java: java.selection,
memory: MemoryOptions {
min_mb: None,
max_mb: Some(overrides.memory_mb),
},
auth,
launcher_name: LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let mut warnings: Vec<String> = output.warnings.iter().map(describe_warning).collect();
warnings.push(java.summary);
warnings.push(identity_line);
let mut request = output.request;
request.instance.id = target.id.clone();
request.instance.name = target.name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &overrides);
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::super::game::base_mode`])。
///
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
fn run_dir(
env: &HostEnv,
game: &unlucky_core::game::GameLayout,
target: &LaunchTarget,
) -> Result<PathBuf, LaunchFailureView> {
let base = host_game::base_mode(env, game, &target.id, &target.version_id);
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
})?;
let derivation = derive_game_dir(game, &target.version_id, base, config.as_ref(), None)
.map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
})?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
}
Ok(derivation.game_dir)
}
/// 适配层告警 → 人读一行。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => "版本 JSON 没有游戏参数".to_string(),
AdapterWarning::MissingAssetsIndex => "版本 JSON 没有资源索引".to_string(),
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
+44
View File
@@ -94,3 +94,47 @@ fn weekly_loader_instance_assembles_against_the_mc_root_version() {
"周快照刻意不可排序(is_known=false),门限只能靠库表兜底"
);
}
/// M298:收尾失败出口的**会话归属单一来源**。
///
/// 抓得住:`publish_before_start` 的返回值与它登记的那份现场的 `session` 不是同一个值——
/// 那正是「报告空串 / 收尾事件非空 ⇒ 界面侧并发守卫判成别人的报告 ⇒ 静默不弹」的成因。
/// 抓不住:界面那一步真的弹了(那条在 `crates/app-iced/src/app/dialog/tests.rs`)。
#[test]
fn a_failure_with_a_session_registers_and_returns_the_same_session() {
let dir = TempDir::new("attribution");
let env = HostEnv::at_root(dir.path());
let ctx = ReportContext::new(
"inst-1",
"生存",
"1.20.4",
env.layout.data().join("instances/inst-1"),
env.layout.logs(),
std::time::SystemTime::now(),
"os=windows",
);
let log = env.sessions_root().join("9f2c.jsonl");
let attribution = SessionAttribution {
id: "9f2c",
log: Some(log.as_path()),
};
let session = publish_before_start(
&ctx,
Some(&attribution),
"spawn",
"会话写入器不可用(锁中毒)",
);
assert_eq!(session, "9f2c", "发出去的会话必须是归属的那一条");
let report = crash_report::failing_launch().expect("登记过现场");
assert_eq!(report.session, session, "报告与收尾事件必须同源");
assert_eq!(report.session_log.as_deref(), Some(log.as_path()));
// 反向守卫:进程没起来的出口,空串是事实(不许为了"凑非空"编一个 id)。
let none = publish_before_start(&ctx, None, "version_manifest", "缺清单");
assert!(none.is_empty());
assert!(crash_report::failing_launch()
.expect("登记过")
.session
.is_empty());
crash_report::clear();
}