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:
1 parent
29da7e2e47
commit
414f6ff886
23 files changed
+2818
-10
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user