feat(m187): 启动失败弹窗 + 诊断包导出,并接通 DialogQueue

用户实测「MC 启动失败找不到崩溃日志」的直接修法:失败时弹一个框,把退出码、
一句人话原因、证据形态、四条真实路径与三个按钮摆在同一个地方。

- app-iced:最小一层模态(遮罩 + 卡片 + 主/次按钮 + Esc/点遮罩关闭),
  消费 presenter 的 DialogQueue(此前全仓零引用,所有弹窗都画不出来);
  队列管弹窗,CrashCard 管这次失败的事实,按 DialogId 绑定。
- 弹窗区分两种失败:游戏自己写了 crash-reports(贴 Description + 栈片段)/
  游戏没来得及写(明说「这次没有生成崩溃报告」并贴出捕获的最后 30 行)/
  什么都没抓到(给最可能原因分类)。绝不对第二种说「找不到日志」。
- 导出诊断包:core 侧新 crash_bundle 写 zip(清单 + 启动器日志 + 会话 JSONL +
  crash-reports/* + hs_err + latest.log + 版本 JSON),落盘即脱敏(家目录 / token /
  会话 id),单文件 4 MiB、总量 48 MiB 上限并在清单记截断。
- 启动器自身日志落盘(core::launch::launcher_log → <.ULL>/logs/launcher.log,
  登记目录前先缓冲),补上「双击启动时诊断行直接蒸发」的缺口。
- i18n:25 条中英文案进 catalog/logs.rs + 金表。
- 取证:ULL_DIALOG_FIXTURE=crash-report|no-logs|export 在临时目录造真文件出图,
  wgpu 与 tiny-skia 各一套,见 DevDocs/artifacts/m187/。

未复现 M190 的「owned 文字在 wgpu 下丢字」:同一串中文两条文字路径、两种后端逐字相同
(ab-wgpu.png / ab-tinyskia.png),因此本轮未改任何渲染路径。
This commit is contained in:
AskaEth committed 2026-11-08 22:42:00 +08:00
1 parent 29da7e2e47
commit 414f6ff886
23 files changed
+2818 -10

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。
//!
//! ## 为什么先补这一层
//!
//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**,
//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事
//! 在此之前根本无处落脚。本模块是最小可用的一层:
//!
//! ```text
//! presenter AppState::dialogs(唯一队列)
//! │ current()
//! ▼
//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里)
//! │
//! ▼
//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮
//! ```
//!
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
//!
//! ## 崩溃卡片额外带什么
//!
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 /
//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段
//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**,
//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。
//!
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
//! 不重算:**界面不读盘、不猜原因**。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
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;
#[cfg(test)]
mod tests;
pub(super) mod view;
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
#[derive(Debug, Clone)]
pub struct CrashCard {
/// 现场。
pub report: CrashReport,
/// 「详细崩溃说明」是否展开。
pub expanded: bool,
/// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。
pub dialog: DialogId,
/// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。
///
/// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点**
/// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的
/// 结果不能挂在一条不显示的通道上。
pub outcome: Option<String>,
}
/// 弹窗消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 主按钮。
Primary,
/// 次按钮。
Secondary,
/// 点遮罩 / Esc。
Dismiss,
/// 卡片内的空白处(吞掉点击,避免误关)。
Swallow,
/// 展开 / 收起详细说明。
Toggle,
/// 打开日志文件夹。
OpenFolder,
/// 复制全部路径。
CopyPaths,
/// 复制第 `index` 条路径(`Modal::paths` 的下标)。
CopyPath(usize),
}
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(super) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(super) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
// --------------------------------------------------------------- 与 App 的接线
impl App {
/// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。
pub(super) fn dialog_modal(&self) -> Option<Modal> {
let request = self.state.dialogs.current()?;
let view = DialogRequestView::of(request);
let card = self
.dialog_card
.as_ref()
.filter(|card| card.dialog == request.id);
Some(current(self.lang(), &view, card))
}
/// 收到一次启动收尾:失败就弹框(成功不打扰)。
///
/// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。
pub(super) fn open_failure_dialog(
&mut self,
finished: &unlucky_presenter::dto::LaunchFinished,
) {
let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok
|| finished.failure.is_some();
if !failed {
return;
}
let Some(report) = unlucky_core::launch::crash_report::failing_launch() else {
return;
};
if !finished.session.is_empty() && report.session != finished.session {
// 登记的是另一场(并发启动):不拿它去解释这一场。
return;
}
let catalog = self.lang().catalog();
let crashed = report.started();
let title = catalog.text(if crashed {
Key::CrashDialogTitleCrashed
} else {
Key::CrashDialogTitleFailed
});
let summary = self.dialog_summary(&report);
let at = self.lang();
let id = self.state.dialogs.push(
DialogSource::UserAction,
title,
summary,
DialogKind::Confirm {
primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(),
secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(),
},
);
self.dialog_card = Some(CrashCard {
report,
// 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。
expanded: true,
dialog: id,
outcome: None,
});
self.view_dirty = true;
}
/// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。
fn dialog_summary(&self, report: &CrashReport) -> String {
let catalog = self.lang().catalog();
match report.exit_code {
Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]),
None => catalog.text(Key::CrashDialogTitleFailed).to_string(),
}
}
/// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。
pub(super) fn close_dialog(&mut self) {
let closed = self.state.dialogs.current().map(|request| request.id);
self.state.dialogs.close_current();
if self
.dialog_card
.as_ref()
.is_some_and(|card| closed == Some(card.dialog))
{
self.dialog_card = None;
}
self.view_dirty = true;
}
/// 展开 / 收起详细说明。
pub(super) fn toggle_dialog_detail(&mut self) {
if let Some(card) = &mut self.dialog_card {
card.expanded = !card.expanded;
self.view_dirty = true;
}
}
}
+157
View File
@@ -0,0 +1,157 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`super`] 分开是因为职责不同:那边管**弹窗与事实**,这边管**把事实带出界面**。
//! 打包本体在 `unlucky_core::launch::crash_bundle`(脱敏、上限、清单都在那一处),
//! 本文件只负责「放哪、叫什么名字、怎么打开」。
use std::path::PathBuf;
use unlucky_core::launch::crash_bundle::{self, BundleEntry, BundleSpec};
use unlucky_core::launch::crash_report::CrashReport;
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
///
/// 返回给界面的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」)。
pub(crate) fn export(report: &CrashReport, lang: Lang) -> String {
let catalog = Catalog::of(lang);
let Some(logs) = &report.launcher_logs else {
return catalog.render(
Key::CrashDialogExportFailed,
&[("error", "no launcher log dir")],
);
};
let stamp =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now())
.replace([':', '.', 'T'], "-")
.trim_end_matches('Z')
.to_string();
let name = if report.session.is_empty() {
&report.instance
} else {
&report.session
};
let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip"));
match crash_bundle::write_bundle(&spec_of(report), &target) {
Ok(bundle) => catalog.render(
Key::CrashDialogExportDone,
&[
("bytes", &bundle.bytes.to_string()),
("path", &bundle.path.display().to_string()),
],
),
Err(error) => catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
}
}
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
fn spec_of(report: &CrashReport) -> BundleSpec {
let mut entries: Vec<BundleEntry> = Vec::new();
let mut push = |group: &'static str, path: &std::path::Path| {
if path.exists() {
entries.push(BundleEntry::new(group, path));
}
};
if let Some(logs) = &report.launcher_logs {
push("launcher", &logs.join("launcher.log"));
push("launcher", &logs.join("launcher.1.log"));
push("launcher", &logs.join("install.log"));
}
if let Some(session) = &report.session_log {
push("session", session);
}
for artifact in &report.artifacts {
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
if artifact.source != "latest_log" {
push("game", &artifact.path);
}
}
if let Some(latest) = report.latest_log_path() {
push("game", &latest);
}
if let Some(json) = &report.version_json {
push("instance", json);
}
let mut secrets = vec![report.session.clone()];
secrets.retain(|secret| !secret.is_empty());
BundleSpec {
instance: report.name.clone(),
version: report.version.clone(),
session: report.session.clone(),
exit_code: report.exit_code,
exit_type: report.exit_type.clone(),
uptime_ms: report.uptime_ms,
command: report.command.clone(),
java: report.java.clone(),
system: report.system.clone(),
notes: report.note_lines(),
missing: report.missing_lines(),
entries,
secrets,
home: std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(PathBuf::from),
}
}
/// 打开「日志文件夹」:有现场就开实例运行目录,否则开启动器日志目录。
///
/// 平台差异只在这里一处(`explorer` / `open` / `xdg-open`),不往业务代码里散写 `cfg`。
pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String {
let catalog = Catalog::of(lang);
let target = report
.run_dir
.clone()
.filter(|dir| dir.is_dir())
.or_else(|| report.launcher_logs.clone().filter(|dir| dir.is_dir()));
let Some(dir) = target else {
return catalog.render(
Key::CrashDialogOpenFailed,
&[("error", "no folder on disk")],
);
};
match spawn_opener(&dir) {
Ok(()) => String::new(),
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
}
}
#[cfg(target_os = "windows")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
std::process::Command::new("explorer")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(target_os = "macos")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
std::process::Command::new("open")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
std::process::Command::new("xdg-open")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
/// 「复制路径」的一段文本(每条一行,标签 + 路径)。
pub(crate) fn paths_text(report: &CrashReport) -> String {
report
.path_lines()
.into_iter()
.map(|(label, path)| format!("{label}: {}", path.display()))
.collect::<Vec<_>>()
.join("\n")
}
+266
View File
@@ -0,0 +1,266 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
//!
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
use std::path::PathBuf;
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::{LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView};
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",
};
fn headless() -> App {
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
App::with_presenter(
Options::default(),
FAMILIES,
unlucky_design::color::SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
let run_dir = PathBuf::from("/instances/survival");
let artifacts = if with_crash_report {
vec![ArtifactFact {
source: "crash_report".to_string(),
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
excerpt: Some("Description: Initializing game".to_string()),
}]
} else {
Vec::new()
};
CrashReport {
instance: "survival".to_string(),
name: "生存".to_string(),
version: "1.20.4-fabric".to_string(),
session: "9f2c".to_string(),
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
launcher_logs: Some(PathBuf::from("/logs")),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 1_000,
command: "javaw -Xmx4096m".to_string(),
java: "javaw.exe (21)".to_string(),
system: "os=windows".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
}
}
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
///
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
#[test]
fn queue_drives_the_modal_through_refresh_view() {
let mut app = headless();
app.refresh_view();
assert!(
app.dialog_model.is_none(),
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
);
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
app.state.dialogs.push(
DialogSource::UserAction,
title,
"正文一行",
DialogKind::Confirm {
primary: "主要".to_string(),
secondary: "次要".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
assert_eq!(modal.title, title);
assert_eq!(modal.primary, "主要");
assert_eq!(modal.secondary.as_deref(), Some("次要"));
}
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
///
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
/// 英文,本用例第一处断言立刻红。
#[test]
fn runtime_chinese_reaches_the_body() {
let lang = Lang::ZhCn;
let card = CrashCard {
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"游戏崩溃",
"",
DialogKind::Confirm {
primary: String::new(),
secondary: String::new(),
},
);
let request = queue.current().expect("当前条");
let view = DialogRequestView::of(request);
let modal = current(lang, &view, Some(&card));
let body = modal.body_text();
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
assert!(body.contains("退出码 1"), "{body}");
assert!(body.contains("游戏进程起来了"), "{body}");
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
assert!(body.contains("没有生成崩溃报告"), "{body}");
assert!(body.contains("捕获"), "{body}");
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
let en = current(Lang::En, &view, Some(&card));
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
}
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
#[test]
fn two_failure_shapes_say_two_different_things() {
let lang = Lang::ZhCn;
let with = report(true, vec!["stack line".to_string()]);
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
assert_ne!(with.evidence(), without.evidence());
let with_card = CrashCard {
report: with.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let without_card = CrashCard {
report: without.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"t",
"",
DialogKind::Error {
primary: "p".to_string(),
hint: None,
},
);
let request = queue.current().unwrap();
let view = DialogRequestView::of(request);
let crash = current(lang, &view, Some(&with_card));
let no_log = current(lang, &view, Some(&without_card));
assert_ne!(crash.body_text(), no_log.body_text());
assert!(crash.body_text().contains("写出了崩溃报告"));
assert!(!no_log.body_text().contains("写出了崩溃报告"));
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
assert!(path_rows(&lang.catalog(), &without)
.iter()
.any(|(_, path)| path.ends_with("latest.log")));
}
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
#[test]
fn closing_the_dialog_clears_the_card() {
let mut app = headless();
crash_report::publish(report(false, vec!["line".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(), "失败必须弹框");
assert!(app.dialog_card.is_some());
app.close_dialog();
assert!(app.state.dialogs.current().is_none());
assert!(app.dialog_card.is_none());
}
/// 成功收尾不打扰用户。
#[test]
fn a_successful_launch_does_not_pop_anything() {
let mut app = headless();
crash_report::publish(report(false, Vec::new()));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Ok,
exit_code: Some(0),
uptime_ms: 90_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_none());
}
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
#[test]
fn export_writes_a_bundle_even_without_a_crash_report() {
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
spec_report.launcher_logs = Some(root.join("logs"));
spec_report.session_log = None;
spec_report.run_dir = None;
spec_report.version_json = None;
let text = super::export::export(&spec_report, Lang::ZhCn);
assert!(text.contains("已导出"), "{text}");
assert!(text.contains("字节"), "{text}");
let exports = root.join("logs").join("exports");
let files: Vec<_> = std::fs::read_dir(&exports)
.expect("导出目录")
.filter_map(Result::ok)
.collect();
assert_eq!(files.len(), 1, "一次导出只该落一个包");
let size = files[0].metadata().expect("元数据").len();
assert!(size > 0, "包不该是空文件");
assert!(
text.contains(&size.to_string()),
"报出来的字节数要与磁盘一致:{text} / {size}"
);
}
+275
View File
@@ -0,0 +1,275 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
//!
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
//!
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
//! 立刻停。所以三层的顺序是:
//!
//! ```text
//! stack![ 页面树, 遮罩(mouse_area → Dismiss), 卡片(mouse_area → Swallow) ]
//! ```
//!
//! 卡片那层用 `mouse_area` 把左键按下**吃掉**([`super::Message::Swallow`]),于是点卡片里的
//! 空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获,连 Swallow 都不会发。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::{Message as DialogMessage, Modal};
/// 卡片最大宽(对话框档:单列 560,窄窗按视口收)。
const CARD_WIDTH: f32 = 560.0;
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
const CARD_HEIGHT: f32 = 620.0;
/// 遮罩不透明度(Material scrim 档;0.32 时下面的页面还看得见轮廓)。
const SCRIM_ALPHA: f32 = 0.32;
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
pub(crate) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
modal: &'a Modal,
) -> Element<'a, Message> {
let scrim = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(scrim_color(ui))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
let overlay = mouse_area(
container(card(ui, modal))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6)),
)
.on_press(Message::Dialog(DialogMessage::Swallow));
iced::widget::stack![base, scrim, overlay].into()
}
fn scrim_color(ui: &Ui) -> Color {
Color {
a: SCRIM_ALPHA,
..ui.color(Role::OnSurface)
}
}
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let shape = Component::OutlinedCard.spec().shape;
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
header = header.push(role(
ui,
TypeRole::HeadlineSmall,
true,
modal.title.as_str(),
));
for line in &modal.body {
header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface)));
}
let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
// **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。
if card.expanded {
if let Some(title) = &card.detail_title {
middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str()));
}
for line in &card.detail_lines {
middle = middle.push(mono(ui, line.clone()));
}
middle = middle.push(frame::divider(ui).map(Message::Page));
}
for (index, (label, path)) in card.paths.iter().enumerate() {
middle = middle.push(
button(
column![
role(ui, TypeRole::LabelMedium, false, label.as_str()),
mono(ui, path.clone()),
]
.spacing(0.0)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::Dialog(DialogMessage::CopyPath(index))),
);
}
}
let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill);
if let Some(notice) = &modal.notice {
footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary)));
}
footer = footer.push(actions(ui, modal));
// 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面
// (实测第一版就是这样:导出/关闭两个按钮整排不可见)。
let body = if modal.card.is_some() {
column![
header,
scrollable(middle).height(Length::Fixed(SCROLL_HEIGHT)),
frame::divider(ui).map(Message::Page),
footer,
]
} else {
column![header, frame::divider(ui).map(Message::Page), footer,]
}
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.max_width(CARD_WIDTH + 48.0)
.max_height(CARD_HEIGHT + 120.0)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(shape),
shadow: Shadow {
color: Color {
a: 0.18,
..Color::BLACK
},
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 24.0,
},
..container::Style::default()
})
.into()
}
/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。
///
/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样,
/// 「导出诊断包」被挤成两行单字)。
fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
column_actions = column_actions.push(
row![
buttons::outlined(
ui,
buttons::Button::new(
card.actions[1].as_str(),
Message::Dialog(DialogMessage::OpenFolder),
),
),
buttons::outlined(
ui,
buttons::Button::new(
card.actions[2].as_str(),
Message::Dialog(DialogMessage::CopyPaths),
),
),
buttons::text(
ui,
buttons::Button::new(
card.toggle.as_str(),
Message::Dialog(DialogMessage::Toggle),
),
),
]
.spacing(ui.space(Sp::X2))
.align_y(Alignment::Center),
);
}
let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2));
if let Some(secondary) = &modal.secondary {
line = line.push(buttons::text(
ui,
buttons::Button::new(
secondary.as_str(),
Message::Dialog(DialogMessage::Secondary),
),
));
}
if !modal.blocking_progress {
line = line.push(buttons::filled(
ui,
buttons::Button::new(
modal.primary.as_str(),
Message::Dialog(DialogMessage::Primary),
),
));
}
column_actions.push(line.align_y(Alignment::Center)).into()
}
/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。
fn role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
) -> Element<'a, Message> {
frame::text_role(
ui,
role,
emphasized,
content,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。
fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(Font {
family: iced::font::Family::Name(ui.families().mono),
..Font::DEFAULT
})
.color(ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。
fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodyMedium);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
+2
View File
@@ -67,6 +67,8 @@ pub enum Message {
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
+1
View File
@@ -30,6 +30,7 @@
mod appearance;
mod backend;
mod boot;
mod dialog;
mod message;
mod state;
mod subscription;
+10
View File
@@ -193,6 +193,16 @@ pub struct App {
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
pub(super) dialog_card: Option<super::dialog::CrashCard>,
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
///
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -135,6 +135,8 @@ impl App {
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0,
launch_ends_seen: 0,
dialog_card: None,
dialog_model: None,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
+6
View File
@@ -51,6 +51,9 @@ impl App {
ToastKind::Error
};
self.state.toasts.push(kind, text);
// M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。
let ended = finished.clone();
self.open_failure_dialog(&ended);
}
}
@@ -78,6 +81,9 @@ impl App {
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
self.dialog_model = self.dialog_modal();
self.view_dirty = false;
self.refreshes += 1;
}
+27 -1
View File
@@ -48,12 +48,25 @@ mod boot_env;
mod clock;
mod content;
mod content_pack;
mod dialog;
mod dialog_fixture;
mod download;
mod instances;
mod page_messages;
mod settings;
mod update_action;
/// 这一条键盘事件是不是 `Esc` 按下。
fn is_escape(event: &iced::keyboard::Event) -> bool {
matches!(
event,
iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}
)
}
impl App {
/// 一条消息进来,改状态并返回后续任务。
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
@@ -119,7 +132,16 @@ impl App {
Task::none()
}
Message::Page(message) => self.on_page(message),
Message::Agent(message) => self.on_agent(message),
Message::Dialog(message) => dialog::on_dialog(self, message),
Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message {
if is_escape(event) && dialog::escape(self) {
return Task::none();
}
}
self.on_agent(message)
}
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => {
self.state.navigate(route_of(nav));
@@ -202,6 +224,10 @@ impl App {
self.ensure_content_loaded();
}
account::force_account_state(self);
// M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。
if let Some(mode) = dialog_fixture::apply(self) {
eprintln!("[boot] ULL_DIALOG_FIXTURE → {mode}");
}
self.auto_select();
self.view_dirty = true;
+101
View File
@@ -0,0 +1,101 @@
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
use iced::Task;
use unlucky_presenter::toast::ToastKind;
use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message {
DialogMessage::Primary => {
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
if let Some(card) = app.dialog_card.clone() {
let text = export::export(&card.report, app.lang());
let failed = card.report.launcher_logs.is_none();
app.state.toasts.push(
if failed {
ToastKind::Error
} else {
ToastKind::Success
},
text.clone(),
);
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
// (app-iced 的 Toast 至今没有渲染点)。
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(text);
}
app.view_dirty = true;
app.refresh_view();
return Task::none();
}
app.close_dialog();
Task::none()
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
app.close_dialog();
Task::none()
}
DialogMessage::Swallow => Task::none(),
DialogMessage::Toggle => {
app.toggle_dialog_detail();
Task::none()
}
DialogMessage::OpenFolder => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let problem = export::open_folder(&card.report, app.lang());
if !problem.is_empty() {
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(problem.clone());
}
app.state.toasts.push(ToastKind::Error, problem);
app.view_dirty = true;
app.refresh_view();
}
Task::none()
}
DialogMessage::CopyPaths => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(export::paths_text(&card.report))
}
DialogMessage::CopyPath(index) => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(path.display().to_string())
}
}
}
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool {
if app.state.dialogs.current().is_none() {
return false;
}
app.close_dialog();
true
}
@@ -0,0 +1,152 @@
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
//!
//! ## 为什么要有它
//!
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
//!
//! 取值:
//!
//! | 值 | 现场 |
//! |---|---|
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
//!
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
use std::path::{Path, PathBuf};
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_scan::{self, CrashSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use crate::app::state::App;
/// 现场根(`%TEMP%/ull-m187-fixture-<pid>`)。
fn root() -> PathBuf {
std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id()))
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, text);
}
/// 出图用的夹具(`None` = 没有开关,什么都不做)。
pub(super) fn apply(app: &mut App) -> Option<String> {
let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?;
let mode = mode.trim().to_lowercase();
if !app.has_screenshot() {
return None;
}
let root = root();
let _ = std::fs::remove_dir_all(&root);
let logs = root.join("logs");
write(
&logs.join("launcher.log"),
"2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\
2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\
2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n",
);
write(&logs.join("install.log"), "installed 1.20.4-fabric\n");
let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e";
let session_log = root
.join("sessions")
.join("game")
.join(format!("{session}.jsonl"));
write(
&session_log,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\
{\"kind\":\"exit\",\"payload\":{\"code\":1}}\n",
);
let tail: Vec<String> = vec![
"[main/INFO]: Loading Minecraft 1.20.4".to_string(),
"java.lang.RuntimeException: mixin injection failed".to_string(),
"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(),
];
let run_dir = root.join("instances").join("survival");
let latest = run_dir.join("logs").join("latest.log");
let version_json = root
.join("game")
.join("versions")
.join("1.20.4-fabric")
.join("1.20.4-fabric.json");
write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n");
let mut artifacts: Vec<ArtifactFact> = Vec::new();
if mode != "no-logs" {
write(
&run_dir
.join("crash-reports")
.join("crash-2026-11-08_15.30.19-client.txt"),
"---- Minecraft Crash Report ----\n\
Time: 2026-11-08 15:30:19\n\
Description: Initializing game\n\n\
java.lang.RuntimeException: mixin injection failed\n\
\tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\
\tat net.minecraft.client.main.Main.main(Main.java:51)\n",
);
write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n");
let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
artifacts = crash_scan::scan(&run_dir, since, 8)
.into_iter()
.map(|artifact| ArtifactFact {
source: artifact.source.as_str().to_string(),
path: artifact.path,
excerpt: artifact.excerpt,
})
.collect();
}
let report = CrashReport {
instance: "survival".to_string(),
name: "生存服的 1.20.4".to_string(),
version: "1.20.4-fabric".to_string(),
session: session.to_string(),
session_log: Some(session_log),
version_json: Some(version_json),
launcher_logs: Some(logs),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 7_400,
command: "javaw -Xmx4096m -Djava.library.path=<home>… ".to_string(),
java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(),
system: "os=windows version=10.0.26100 arch=x86_64".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
};
crash_report::publish(report);
let finished = LaunchFinished {
id: "survival".to_string(),
session: session.to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 7_400,
failure: None,
};
app.open_failure_dialog(&finished);
if mode == "export" {
// 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。
let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary);
}
Some(mode)
}
/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。
#[allow(dead_code)]
pub(super) fn crash_report_source() -> &'static str {
CrashSource::CrashReport.as_str()
}
+7 -1
View File
@@ -75,7 +75,8 @@ impl App {
};
let account = self.rail_account();
match pages::Screen::of(self.state.route()) {
// M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account(
&self.ui,
shell::NavRail {
@@ -124,6 +125,11 @@ impl App {
self.options.force_state,
self.lang(),
),
};
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree,
}
}
}