From 97768b7e3f4b1419bec0662bb254375841095fa0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 13:12:59 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(launch):=20M298=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E3=80=81?= =?UTF-8?q?Toast=20=E6=8E=A5=E4=B8=8A=E6=B8=B2=E6=9F=93=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」: 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 秒」胶囊)。 --- crates/app-iced/src/app/dialog.rs | 230 +------------- crates/app-iced/src/app/dialog/model.rs | 230 ++++++++++++++ crates/app-iced/src/app/dialog/tests.rs | 198 +++++++++++- crates/app-iced/src/app/state.rs | 9 + crates/app-iced/src/app/state/construct.rs | 3 + crates/app-iced/src/app/state/refresh.rs | 12 + crates/app-iced/src/app/state/toast.rs | 216 +++++++++++++ crates/app-iced/src/app/state/toast_shot.rs | 48 +++ crates/app-iced/src/app/update/dialog.rs | 5 + crates/app-iced/src/app/view.rs | 10 + crates/app-iced/src/app/view/toast.rs | 294 ++++++++++++++++++ crates/app-iced/src/app/view/toast/tests.rs | 132 ++++++++ crates/core/src/launch/crash_report.rs | 168 +++------- .../core/src/launch/crash_report/context.rs | 108 +++++++ crates/core/src/launch/crash_report/slot.rs | 38 +++ crates/core/src/launch/crash_report/tests.rs | 46 ++- crates/presenter/src/host/launch.rs | 248 +++------------ crates/presenter/src/host/launch/build.rs | 213 +++++++++++++ crates/presenter/src/host/launch/tests.rs | 44 +++ 19 files changed, 1699 insertions(+), 553 deletions(-) create mode 100644 crates/app-iced/src/app/dialog/model.rs create mode 100644 crates/app-iced/src/app/state/toast.rs create mode 100644 crates/app-iced/src/app/state/toast_shot.rs create mode 100644 crates/app-iced/src/app/view/toast.rs create mode 100644 crates/app-iced/src/app/view/toast/tests.rs create mode 100644 crates/core/src/launch/crash_report/context.rs create mode 100644 crates/core/src/launch/crash_report/slot.rs create mode 100644 crates/presenter/src/host/launch/build.rs diff --git a/crates/app-iced/src/app/dialog.rs b/crates/app-iced/src/app/dialog.rs index 9eb707d..0d4ed1d 100644 --- a/crates/app-iced/src/app/dialog.rs +++ b/crates/app-iced/src/app/dialog.rs @@ -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, - /// 主按钮文字。 - pub primary: String, - /// 次按钮文字(`None` = 不画)。 - pub secondary: Option, - /// 是否有次按钮(`Progress` 没有)。 - pub blocking_progress: bool, - /// 崩溃卡片(普通弹窗为 `None`)。 - pub card: Option, - /// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。 - pub notice: Option, -} - -/// 崩溃卡片的展示内容。 -#[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, - /// 详细说明的正文行。 - pub detail_lines: Vec, - /// 三个按钮的文字。 - 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 { - 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, Vec) { - 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 { diff --git a/crates/app-iced/src/app/dialog/model.rs b/crates/app-iced/src/app/dialog/model.rs new file mode 100644 index 0000000..1b9b59e --- /dev/null +++ b/crates/app-iced/src/app/dialog/model.rs @@ -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, + /// 主按钮文字。 + pub primary: String, + /// 次按钮文字(`None` = 不画)。 + pub secondary: Option, + /// 是否有次按钮(`Progress` 没有)。 + pub blocking_progress: bool, + /// 崩溃卡片(普通弹窗为 `None`)。 + pub card: Option, + /// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。 + pub notice: Option, +} + +/// 崩溃卡片的展示内容。 +#[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, + /// 详细说明的正文行。 + pub detail_lines: Vec, + /// 三个按钮的文字。 + 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 { + 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, Vec) { + 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()) +} diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 41c379a..9527a72 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -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(); + } +} diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 2de6a71..adeb2c9 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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, + /// Toast 的动效时间轴(M298):每条提示的出生时刻 + 这一拍的进/出进度。 + /// + /// 为什么住在 `App` 而不是 presenter:它是**动效**(这一帧该缩放多少),不是业务事实。 + /// 事实(文案 / 语义色 / 剩余寿命)全在 `state.toasts`,这边只记时间。 + pub(super) toast_tracks: toast::ToastTracks, /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 pub(super) avatars: pages::account::avatar::Cache, /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 1e7874c..85708d1 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -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 } } diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index 680786d..e69536e 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -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)。 diff --git a/crates/app-iced/src/app/state/toast.rs b/crates/app-iced/src/app/state/toast.rs new file mode 100644 index 0000000..3731e54 --- /dev/null +++ b/crates/app-iced/src/app/state/toast.rs @@ -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, +} + +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), + "消失的条目必须被清掉(否则长时间常驻会线性增长)" + ); + } +} diff --git a/crates/app-iced/src/app/state/toast_shot.rs b/crates/app-iced/src/app/state/toast_shot.rs new file mode 100644 index 0000000..4d76da8 --- /dev/null +++ b/crates/app-iced/src/app/state/toast_shot.rs @@ -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); +} diff --git a/crates/app-iced/src/app/update/dialog.rs b/crates/app-iced/src/app/update/dialog.rs index 8bb295b..5c283af 100644 --- a/crates/app-iced/src/app/update/dialog.rs +++ b/crates/app-iced/src/app/update/dialog.rs @@ -123,6 +123,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task { @@ -138,6 +141,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task super::dialog::view::layer(&self.ui, tree, modal), diff --git a/crates/app-iced/src/app/view/toast.rs b/crates/app-iced/src/app/view/toast.rs new file mode 100644 index 0000000..8d1d36b --- /dev/null +++ b/crates/app-iced/src/app/view/toast.rs @@ -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::>(); + 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, +} + +/// 给覆盖层挂上自证状态(`tag` 用状态类型,见 iced 的 `container` / `text` 同一手法)。 +pub(super) fn tagged<'a>( + content: Element<'a, Message>, + texts: Vec, +) -> Element<'a, Message> { + Element::new(Tagging { content, texts }) +} + +struct Tagging<'a, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + texts: Vec, +} + +impl Widget for Tagging<'_, Theme, Renderer> +where + Renderer: iced::advanced::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(CapsuleState { + texts: self.texts.clone(), + }) + } + + fn children(&self) -> Vec { + 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 { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + 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> { + self.content + .as_widget_mut() + .overlay(tree, layout, renderer, viewport, translation) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/app/view/toast/tests.rs b/crates/app-iced/src/app/view/toast/tests.rs new file mode 100644 index 0000000..b7c7183 --- /dev/null +++ b/crates/app-iced/src/app/view/toast/tests.rs @@ -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 { + fn walk(tree: &Tree, out: &mut Vec) { + if tree.tag == iced::advanced::widget::tree::Tag::of::() { + let state = tree.state.downcast_ref::(); + 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::() + } + 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} 个)" + ); +} diff --git a/crates/core/src/launch/crash_report.rs b/crates/core/src/launch/crash_report.rs index 2b978ac..ec972eb 100644 --- a/crates/core/src/launch/crash_report.rs +++ b/crates/core/src/launch/crash_report.rs @@ -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, } +/// 「进程确实起来过」的那一场会话归因([`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>, + ) -> 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, - /// Java 一行说明。 - pub java: String, - /// 系统一行说明。 - pub system: String, - /// 要额外抹掉的串(会话 id、access token 等)。 - pub secrets: Vec, -} - -impl ReportContext { - /// 建一份(`java` 可以后补:装配阶段还没选 Java)。 - #[must_use] - pub fn new( - instance: impl Into, - name: impl Into, - version: impl Into, - run_dir: impl Into, - launcher_logs: impl Into, - started_at: std::time::SystemTime, - system: impl Into, - ) -> 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 { - 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> { - static SLOT: OnceLock>> = 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 { - slot().lock().ok().and_then(|guard| guard.clone()) -} - -/// 清空槽位(测试用)。 -pub fn clear() { - if let Ok(mut guard) = slot().lock() { - *guard = None; - } -} - #[cfg(test)] mod tests; diff --git a/crates/core/src/launch/crash_report/context.rs b/crates/core/src/launch/crash_report/context.rs new file mode 100644 index 0000000..edb4f07 --- /dev/null +++ b/crates/core/src/launch/crash_report/context.rs @@ -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, + /// Java 一行说明。 + pub java: String, + /// 系统一行说明。 + pub system: String, + /// 要额外抹掉的串(会话 id、access token 等)。 + pub secrets: Vec, +} + +impl ReportContext { + /// 建一份(`java` 可以后补:装配阶段还没选 Java)。 + #[must_use] + pub fn new( + instance: impl Into, + name: impl Into, + version: impl Into, + run_dir: impl Into, + launcher_logs: impl Into, + started_at: std::time::SystemTime, + system: impl Into, + ) -> 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 { + 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, + } +} diff --git a/crates/core/src/launch/crash_report/slot.rs b/crates/core/src/launch/crash_report/slot.rs new file mode 100644 index 0000000..2be72da --- /dev/null +++ b/crates/core/src/launch/crash_report/slot.rs @@ -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> { + static SLOT: OnceLock>> = 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 { + slot().lock().ok().and_then(|guard| guard.clone()) +} + +/// 清空槽位(测试用)。 +pub fn clear() { + if let Ok(mut guard) = slot().lock() { + *guard = None; + } +} diff --git a/crates/core/src/launch/crash_report/tests.rs b/crates/core/src/launch/crash_report/tests.rs index 1f61fa6..65f84b4 100644 --- a/crates/core/src/launch/crash_report/tests.rs +++ b/crates/core/src/launch/crash_report/tests.rs @@ -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, tail: Vec) -> 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()); +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 1a7b6c1..6650e88 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -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, } -/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。 -/// -/// # Errors -/// -/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 / -/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。 -pub fn build_request( - env: &HostEnv, - target: &LaunchTarget, -) -> Result<(LaunchRequest, Vec), 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 { - 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 = 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 = 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 { - 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() diff --git a/crates/presenter/src/host/launch/build.rs b/crates/presenter/src/host/launch/build.rs new file mode 100644 index 0000000..fd4ee11 --- /dev/null +++ b/crates/presenter/src/host/launch/build.rs @@ -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), 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 { + 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 = 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 = 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 { + 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()) + } + } +} diff --git a/crates/presenter/src/host/launch/tests.rs b/crates/presenter/src/host/launch/tests.rs index 93207b2..a433165 100644 --- a/crates/presenter/src/host/launch/tests.rs +++ b/crates/presenter/src/host/launch/tests.rs @@ -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(); +} From aa25583806bd4fc13ddbd8567afaa924a3ab402b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 13:17:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(launch):=20M298=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E6=90=AC=E5=88=B0=20dialog/tests/m298.rs=EF=BC=88=E5=AE=88=204?= =?UTF-8?q?00=20=E8=A1=8C=E5=8D=95=E6=96=87=E4=BB=B6=E9=97=A8=E7=A6=81?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-iced/src/app/dialog/tests.rs | 189 +----------------- crates/app-iced/src/app/dialog/tests/m298.rs | 192 +++++++++++++++++++ 2 files changed, 196 insertions(+), 185 deletions(-) create mode 100644 crates/app-iced/src/app/dialog/tests/m298.rs diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 9527a72..d969530 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -14,13 +14,11 @@ use std::path::PathBuf; -use unlucky_core::launch::crash_report::{ - self, ArtifactFact, CrashReport, ReportContext, SessionAttribution, -}; +use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport}; use unlucky_i18n::catalog::Key; use unlucky_i18n::Lang; use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource}; -use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome}; +use unlucky_presenter::dto::{LaunchFinished, LogOutcome}; use super::{current, path_rows, CrashCard, DialogRequestView}; @@ -43,19 +41,6 @@ fn slot_guard() -> std::sync::MutexGuard<'static, ()> { 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; @@ -368,171 +353,5 @@ 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(); - } -} +#[path = "tests/m298.rs"] +mod m298; diff --git a/crates/app-iced/src/app/dialog/tests/m298.rs b/crates/app-iced/src/app/dialog/tests/m298.rs new file mode 100644 index 0000000..b68df35 --- /dev/null +++ b/crates/app-iced/src/app/dialog/tests/m298.rs @@ -0,0 +1,192 @@ +//! M298 的收尾失败 / 并发守卫用例(从 `dialog/tests.rs` 拆出:那个文件贴 400 行硬线)。 +//! +//! 共用父模块的夹具:`headless()`(注入式 `App`)、`report()`(一份现场)、`slot_guard()` +//! (`crash_report` 的现场是进程级单槽,凡 publish 的用例都要拿它)。 + +use std::path::PathBuf; + +use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext, SessionAttribution}; +use unlucky_presenter::dto::{LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome}; + +use super::{headless, report, slot_guard}; + +/// 一次启动的现场上下文(与 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", + ) +} + +// ------------------------------------------------------------------ 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(); + } +} From 3b4f8bb9974cac7bb8a33c8210e89a3ef000cd1c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 13:50:41 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(notify):=20M298=20r1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20toast=20=E9=80=80=E5=9C=BA=E8=85=BF=E6=8E=A5?= =?UTF-8?q?=E4=B8=8A=E8=A7=A6=E5=8F=91=E7=82=B9=EF=BC=8C=E5=9B=9B=E6=9D=A1?= =?UTF-8?q?=20nit=20=E4=B8=80=E5=B9=B6=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P2-1:进场腿自己续帧所以能跑,但进场结束 view_dirty 回 false, 3.6s 后 remaining_ms 掉进 150ms 退场窗口时没有任何代码重新置脏 ⇒ 那一段 收缩从不发生,expire 一抹胶囊整块消失(复审探针:120 拍后 progress 仍 1.0)。 - update/clock.rs:expire 之后用无副作用的 ToastTracks::needs_frame 置脏, 落到既有的 if view_dirty { refresh_view() } 上采样;refresh_view 仍是门控, 没有变成每帧无条件调用(静止态判据不变)。 - state/toast.rs:进出两条腿改成**同一个时钟**(寿命 = 首次看见时的 remaining_ms,已活 = 寿命 - remaining),去掉 Instant 与节拍的二时钟错位; 新增 needs_frame;新增两条真节拍用例(退场腿必被抽到 / 到期后不再续帧)。 - N1:装配失败与 spawn 失败也用 publish_before_start 的返回值填 session。 - N2:m298 用例注释改准(它抓不住删守卫,那条由 a_report_from_another_session 守)。 - N3:before_start 文档不再把两种来源都说成「进程没起来」。 - N4:refresh_view 末尾的死赋值 view_dirty = false 删掉。 红灯原文(摘掉那 3 行触发点,其余为最终代码): 退场窗口内必须真的开始收(remaining=Some(40)ms, progress=1) --- crates/app-iced/src/app/dialog/tests/m298.rs | 7 +- crates/app-iced/src/app/state/refresh.rs | 13 +- crates/app-iced/src/app/state/toast.rs | 194 ++++++++++++++----- crates/app-iced/src/app/update/clock.rs | 12 ++ crates/core/src/launch/crash_report.rs | 11 +- crates/presenter/src/host/launch.rs | 11 +- 6 files changed, 190 insertions(+), 58 deletions(-) diff --git a/crates/app-iced/src/app/dialog/tests/m298.rs b/crates/app-iced/src/app/dialog/tests/m298.rs index b68df35..b6bf82d 100644 --- a/crates/app-iced/src/app/dialog/tests/m298.rs +++ b/crates/app-iced/src/app/dialog/tests/m298.rs @@ -146,8 +146,11 @@ fn a_report_from_another_session_is_never_reused() { /// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。 /// -/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让 -/// 装配失败的现场指向一个不存在的会话)。 +/// 抓得住:把「没会话」也编一个 id 出来(那会让装配失败的现场指向一个不存在的会话); +/// 也抓得住这条出口被新判据误伤(例如把「session 为空」当成「必须不弹」)。 +/// +/// **抓不住「把并发守卫整条删掉」**——复审的变异 C 实测:删掉守卫后本用例照样绿(它本来就 +/// 走 session 为空的路径)。那条守卫由 `a_report_from_another_session_is_never_reused` 守。 #[test] fn a_session_less_failure_still_pops() { let _slot = slot_guard(); diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index e69536e..eef5880 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -117,11 +117,13 @@ impl App { if next_modal.is_some() || !motion::modal_closing() { self.dialog_model = next_modal; } - self.view_dirty = false; self.refreshes += 1; - // M298:toast 的进/出场也要「每帧一次」——而 `refresh_view` 只在 `view_dirty` 时跑。 - // 于是这里把「还有提示在动」翻译成**再续一帧**(`view_dirty` 重新置位);静止在位之后 - // 它回到 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。 + // M298:`view_dirty` 的**唯一**收尾赋值(旧写法先写 `false` 再被这里覆盖,是死赋值, + // 复审 N4 指出后删掉)。语义:这一帧已经把模型重建完了,于是清脏;但 toast 若还在 + // 进场 / 退场腿上就得**再续一帧**——`sync_toasts` 的返回值正好是「还有提示在动」。 + // 静止在位之后它为 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。 + // 退场腿不是靠这里点亮的(进场结束就断了):那一下由 `update/clock.rs` 的 + // `needs_frame` 判据在退场窗口到来的那一拍重新置脏。 self.view_dirty = self.sync_toasts(); } @@ -129,8 +131,7 @@ impl App { /// /// 返回 `true` = 还有提示在进/出场,这一帧要续。 fn sync_toasts(&mut self) -> bool { - let toasts = self.state.toasts.items(); - self.toast_tracks.observe(toasts, std::time::Instant::now()) + self.toast_tracks.observe(self.state.toasts.items()) } /// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。 diff --git a/crates/app-iced/src/app/state/toast.rs b/crates/app-iced/src/app/state/toast.rs index 3731e54..e9f896b 100644 --- a/crates/app-iced/src/app/state/toast.rs +++ b/crates/app-iced/src/app/state/toast.rs @@ -7,23 +7,39 @@ //! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是 //! 「提示还在、动画已经结束」。 //! -//! 所以这里只做一件帧泵不做的事:记住**每一条是什么时候出现的**(`born`),然后每拍按 toast -//! 自己的两个已知量采样进度——`born` 到现在的时长(进场)与 `remaining_ms`(退场)。 +//! ## 只有**一个**时钟:toast 自己的 `remaining_ms` +//! +//! 第一版(M298 r0)用 `Instant::now()` 算进场年龄、用 `remaining_ms` 算退场——两个时钟。 +//! 复审 P2-1 的探针把它撞出来了:`Instant` 走**墙上时间**、`remaining_ms` 走**节拍时间**, +//! 于是「测试里连走 120 拍」和「真机上过了 4 秒」不是同一件事;更要紧的是它掩盖了真 bug +//! (退场腿没有触发点,见下)。 +//! +//! 现在两条腿都从同一个量推:第一次看见这条提示时把 `remaining_ms` 记成它的**寿命** +//! (`life_ms` = TTL),此后 `已活 = life_ms - remaining_ms`。进场腿吃 `已活`,退场腿吃 +//! `remaining_ms` —— 与 `expire` 抹掉它的那一刻天然对齐,没有第二份时间。 +//! //! 时长档与缓动档**复用模态那一对腿**([`motion::modal_spec`]):toast 与对话框同属 M3 的 //! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。 //! -//! ## 为什么采样发生在 `update` 侧 +//! ## 为什么采样发生在 `update` 侧,且退场还要**另外一个**置脏判据 //! -//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里 -//! 的 [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑, +//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里的 +//! [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑, //! 结果是「这一帧每条该缩放多少」——`view` 拿到的只是一份只读快照。 //! +//! 但 `refresh_view` 只被 `view_dirty` 开门,而进场腿自己会续帧、退场腿**不会**:进场结束时 +//! `view_dirty` 回到 `false`,等 3.6 秒后 `remaining_ms` 掉进退场窗口时没有任何代码把它重新 +//! 置脏 ⇒ 150 ms 的收缩从不发生,`expire` 一抹胶囊就整块消失(复审 P2-1 实测:120 拍之后 +//! `progress` 仍是 1.0)。所以 [`ToastTracks::needs_frame`] 是给 `update/clock.rs` 的**无副作用** +//! 判据:只要还有条目在退场窗口里,就把 `view_dirty` 点亮,让既有的 +//! `if self.view_dirty { refresh_view() }` 去采样。**`refresh_view` 没有变成每帧无条件调用**, +//! 静止态(无提示 / 提示都在位)帧订阅判据与改造前一样(M192「空闲不烧帧」)。 +//! //! ## 关掉动效 = 一帧都不动 //! -//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**,且 -//! [`ToastTracks::observe`] 返回 `false` ⇒ 不会为了动画续帧。出图因此可复现(D30)。 - -use std::time::Instant; +//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**, +//! [`ToastTracks::observe`] 与 [`ToastTracks::needs_frame`] 都返回 `false` ⇒ 不会为了动画续帧。 +//! 出图因此可复现(D30)。 use unlucky_presenter::toast::{Toast, ToastId}; @@ -34,8 +50,8 @@ use crate::app::components::motion; struct Entry { /// toast id(`ToastCenter` 重新编号,进程内唯一)。 id: ToastId, - /// 第一次被看见的时刻。 - born: Instant, + /// 这条提示**总共活多久**:第一次被看见时 `remaining_ms` 的值(= `ToastCenter` 的 TTL)。 + life_ms: u64, /// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。 progress: f32, } @@ -49,30 +65,30 @@ pub(crate) struct ToastTracks { impl ToastTracks { /// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。 /// - /// **update 侧每拍调一次**:登记新出现的、忘掉已经消失的、重算每条进度。 - pub(crate) fn observe(&mut self, toasts: &[Toast], now: Instant) -> bool { + /// **update 侧每拍调一次**:登记新出现的(记下寿命)、忘掉已经消失的、重算每条进度。 + pub(crate) fn observe(&mut self, toasts: &[Toast]) -> 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, + let life_ms = match self.entries.iter().find(|entry| entry.id == toast.id) { + Some(entry) => entry.life_ms, None => { self.entries.push(Entry { id: toast.id, - born: now, + life_ms: toast.remaining_ms, progress: 1.0, }); - now + toast.remaining_ms } }; // 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。 let progress = if scale.is_off() { 1.0 } else { - let (progress, in_flight) = sample(toast, born, now, &spec, scale); + let (progress, in_flight) = sample(toast, life_ms, &spec, scale); moving |= in_flight; progress }; @@ -90,23 +106,39 @@ impl ToastTracks { .find(|entry| entry.id == id) .map_or(1.0, |entry| entry.progress) } + + /// 还有没有条目落在**退场窗口**里(`update/clock.rs` 唯一的置脏判据,**无副作用**)。 + /// + /// 判据与 [`sample`] 里退场腿那一项同一个算式:`remaining_ms < exit` 腿的时长。 + /// 它只看**已登记**的条目——`observe` 还没跑过的那条不算(那时 `progress` 也是终值)。 + pub(crate) fn needs_frame(&self, toasts: &[Toast]) -> bool { + let scale = motion::scale(); + if scale.is_off() { + return false; + } + let exit_ms = scale.ms(motion::modal_spec().exit.duration).max(1.0); + self.entries.iter().any(|entry| { + toasts + .iter() + .any(|toast| toast.id == entry.id && (toast.remaining_ms as f32) < exit_ms) + }) + } } /// 一条 toast 的进 / 出采样(纯函数,测试直接打这里)。 /// -/// 进场:`born` 之后走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。 +/// 进场:已活时长走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。 /// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下 /// 全尺寸再消失)。 fn sample( toast: &Toast, - born: Instant, - now: Instant, + life_ms: u64, 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 lived_ms = life_ms.saturating_sub(toast.remaining_ms) as f32; 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 @@ -123,14 +155,47 @@ mod tests { //! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。 //! //! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了 - //! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` 必须返回 `false`)。 + //! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` / `needs_frame` 必须为假)、 + //! **退场腿在产品路径上没有触发点**(两条 `ticking_app` 用例走真节拍)。 //! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。 use super::*; use std::time::Duration; + use unlucky_design::color::SchemeFlavor; use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::toast::ToastKind; + use crate::app::state::App; + use crate::cli::Options; + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 一拍的时长(= `App::TICK_MS`,`update/clock.rs` 的节拍口径)。 + const TICK: Duration = Duration::from_millis(33); + + /// 注入式 `App`(不碰磁盘、mock 后端),并把动效钉在 `STANDARD`。 + /// + /// 这两条用例走的是**真节拍**(`update/clock.rs` 的 `advance`),不是在测试里直接喂 + /// `observe`——复审 P2-1 漏掉的正是这一层:`observe` 自己算得再对,没人抽它就是没播。 + fn ticking_app() -> App { + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + fn toast(id: ToastId, remaining_ms: u64) -> Toast { Toast { id, @@ -149,35 +214,27 @@ mod tests { #[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.observe(&[toast(1, 4_000)]), "进场这一段必须在动"); assert!( tracks.progress(1) < 0.05, "刚出现必须是进场起点,实测 {}", tracks.progress(1) ); - // 进场腿走完之后:停在 1.0,且不再报「在动」。 - let later = now + Duration::from_millis(400); - assert!(!tracks.observe(&items, later), "进场走完就不该再续帧"); + // 已活 400 ms(进场腿 200 ms)⇒ 停在 1.0,且不再报「在动」。 + assert!(!tracks.observe(&[toast(1, 3_600)]), "进场走完就不该再续帧"); 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); + tracks.observe(&[toast(7, 4_000)]); + tracks.observe(&[toast(7, 3_600)]); assert_eq!(tracks.progress(7), 1.0); // 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。 - assert!( - tracks.observe(&[toast(7, 60)], settled), - "退场这一段必须在动" - ); + assert!(tracks.observe(&[toast(7, 60)]), "退场这一段必须在动"); let mid = tracks.progress(7); assert!( mid < 1.0 && mid > 0.0, @@ -186,27 +243,76 @@ mod tests { // 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉, // 中间不许留一帧「还满着」。 - tracks.observe(&[toast(7, 0)], settled); + tracks.observe(&[toast(7, 0)]); 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), + !tracks.observe(&[toast(1, 4_000)]), "关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)" ); assert_eq!(tracks.progress(1), 1.0, "NONE 下进度恒为终值"); + assert!( + !tracks.needs_frame(&[toast(1, 40)]), + "NONE 下连退场窗口都不该点灯" + ); + } + + /// **复审 P2-1 的红灯**:退场腿在产品路径上必须真的被抽到。 + /// + /// 抓得住:`observe` 只在 `refresh_view` 里跑、而 `refresh_view` 只被 `view_dirty` 开门 ⇒ + /// 进场腿自己续帧(所以进场能跑),进场结束后没人再置脏,**退场那 150 ms 从不发生**, + /// `expire` 把条目一抹胶囊就整块消失(复审探针实测:120 拍之后 `progress` 仍是 1.0)。 + /// 抓不住:观感(静态图看不出一段没播的动画),也抓不住「置脏了但没画出来」。 + #[test] + fn the_tick_clock_actually_carries_the_exit_leg() { + let mut app = ticking_app(); + let id = app.state.toasts.push(ToastKind::Info, "已取消安装"); + // 进场腿:这几拍由 `observe` 自己续帧(所以它本来就能跑)。 + for _ in 0..10 { + app.advance(TICK); + } + assert_eq!(app.toast_tracks.progress(id), 1.0, "进场走完应当停在满进度"); + // 之后**没有任何事件**,只让节拍继续走:4 s TTL - 10 拍 ≈ 还剩 3.6 s。 + for _ in 0..110 { + app.advance(TICK); + } + let remaining = app.state.toasts.items().first().map(|it| it.remaining_ms); + let progress = app.toast_tracks.progress(id); + assert!( + remaining.is_some_and(|ms| ms < 150), + "这一拍应当已经落在 150 ms 的退场腿里(remaining={remaining:?}ms)" + ); + assert!( + progress < 1.0, + "退场窗口内必须真的开始收(remaining={remaining:?}ms, progress={progress})" + ); + } + + /// 到期之后**不许**继续置脏:否则退场窗口的修复会变成「永不停止的每帧重建」。 + #[test] + fn an_expired_toast_stops_asking_for_frames() { + let mut app = ticking_app(); + app.state.toasts.push(ToastKind::Info, "已取消安装"); + for _ in 0..130 { + app.advance(TICK); + } + assert!(app.state.toasts.is_empty(), "TTL 用完必须被 expire 抹掉"); + for _ in 0..5 { + app.advance(TICK); + } + assert!(!app.view_dirty, "没有提示时不许每帧置脏(M192 空闲不烧帧)"); + assert!(!app.needs_frames(), "静止态不该订阅帧"); } #[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), "新的那条在进场"); + tracks.observe(&[toast(1, 4_000)]); + assert!(tracks.observe(&[toast(2, 4_000)]), "新的那条在进场"); assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)"); assert!( !tracks.entries.iter().any(|entry| entry.id == 1), diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 90165f1..6546b32 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -37,6 +37,18 @@ impl App { self.state .toasts .expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)); + // M298 r1(复审 P2-1):toast **退场腿**的唯一触发点。 + // + // 进场腿自己续帧(`observe` 返回 moving ⇒ `refresh_view` 末尾重新置脏),但进场走完 + // 之后 `view_dirty` 是假的;等 3.6 s 后 `remaining_ms` 掉进退场窗口时**没有任何代码** + // 把它重新置脏 ⇒ 那 150 ms 的收缩从不发生,`expire` 一抹胶囊就整块消失。 + // + // 判据无副作用(`ToastTracks::needs_frame` 只读),置脏之后落到下面**既有的** + // `if self.view_dirty { refresh_view() }` 上采样 —— `refresh_view` 没有变成每帧无条件 + // 调用,静止态(无提示 / 提示都在位)本行为假,M192「空闲不烧帧」不受影响。 + if self.toast_tracks.needs_frame(self.state.toasts.items()) { + self.view_dirty = true; + } // 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动, // 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。 diff --git a/crates/core/src/launch/crash_report.rs b/crates/core/src/launch/crash_report.rs index ec972eb..8f2a19a 100644 --- a/crates/core/src/launch/crash_report.rs +++ b/crates/core/src/launch/crash_report.rs @@ -241,8 +241,15 @@ impl CrashReport { /// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责 /// 把它原样记进报告——两边同源,守卫才可能判对。 /// - /// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以 - /// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。 + /// 两个用法的**证据形态不同,判据同一个**([`CrashReport::evidence`] 只看有没有 + /// crash-report / 有没有捕获输出,**不**看 `stage`): + /// + /// | 用法 | 常见形态 | 界面该说什么 | + /// |---|---|---| + /// | `session: None`(装配 / spawn 失败) | 「找不到 crash-report」,游戏根本没被执行到 ⇒ [`Evidence::Nothing`] | 「游戏还没来得及写日志」 | + /// | `session: Some`(收尾失败) | 进程起来过、事件流有落点,只是拿不到 `SessionHandle` | 按真实证据说(多半有捕获输出) | + /// + /// 别把这张表读成「本函数只描述没起来的那种」——M298 r0 的注释正是这么写的,复审 N3 抓到。 #[must_use] pub fn before_start( ctx: &ReportContext, diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 6650e88..64f50c5 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -82,10 +82,13 @@ 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, None, failure.stage.slug(), &failure.detail); + // 归因是 `None`(进程没起来),但**照样用返回值**填 DTO:单一来源是结构, + // 不是「两边碰巧都写空串」(复审 N1)。 + let session = + publish_before_start(&report, None, failure.stage.slug(), &failure.detail); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, - session: String::new(), + session, outcome: LogOutcome::Crashed, exit_code: None, uptime_ms: 0, @@ -123,10 +126,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { "launch", &format!("instance={} spawn 失败:{error}", target.id), ); - publish_before_start(&report, None, "spawn", &failure.detail); + let session = publish_before_start(&report, None, "spawn", &failure.detail); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, - session: String::new(), + session, outcome: LogOutcome::Crashed, exit_code: None, uptime_ms: 0,