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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -67,6 +67,8 @@ pub enum Message {
|
||||
Captured(window::Screenshot),
|
||||
/// gallery(组件库 harness)里的演示交互。
|
||||
Demo(components::gallery::DemoMessage),
|
||||
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
|
||||
Dialog(super::dialog::Message),
|
||||
}
|
||||
|
||||
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
mod appearance;
|
||||
mod backend;
|
||||
mod boot;
|
||||
mod dialog;
|
||||
mod message;
|
||||
mod state;
|
||||
mod subscription;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! 诊断包(M187):把一次启动失败的现场打成一个 zip 交给用户。
|
||||
//!
|
||||
//! ## 为什么不只是「打开日志文件夹」
|
||||
//!
|
||||
//! 用户实测(2026-11-08):「MC 启动失败『游戏崩溃 退出码 1』,找半天没找到崩溃日志」。
|
||||
//! 现场散在四处——启动器日志(`.ULL/logs/`)、会话事件流 JSONL(`data/agent/sessions/game/`)、
|
||||
//! 游戏自己写的 `crash-reports/` 与 `logs/latest.log`、还有环境(系统 / Java / 命令行)。
|
||||
//! 让用户自己去四个目录里拼,等于没修。打成一个包 + 一行清单,才是「能发给别人看」的东西。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//!
|
||||
//! 1. **落盘即脱敏**(`launch::redact` 的唯一一份规则):主目录 → `<home>`,token → 占位符,
|
||||
//! 长不透明串(base64/hex)与带点 token(JWT 形状)同样替换;调用方另外把**会话 id**
|
||||
//! 之类也要抹掉的串塞进 [`BundleSpec::secrets`]。
|
||||
//! 2. **缺什么要写出来**:`crash-reports/` 不存在不是错误——那是「游戏没来得及写」的**证据**,
|
||||
//! 清单里必须有一行说清楚,不许静默漏掉。见 [`BundleSpec::missing`]。
|
||||
//! 3. **有上限**:单个文件最多收 [`MAX_ENTRY_BYTES`],包内总字节最多 [`MAX_TOTAL_BYTES`]。
|
||||
//! 超了就在清单里记一行「已截断」。数百 MB 的 `latest.log` 不该把导出卡成十秒。
|
||||
//!
|
||||
//! 不做的事:不判断崩溃原因(那是诊断引擎 `diagnose` 的活),不改任何图片之外的二进制,
|
||||
//! 不联网。打包只读盘。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 单个文件最多收多少字节(超出只收头部,清单里记「已截断」)。
|
||||
pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// 整个包最多收多少字节(超出后剩余文件只记清单、不收内容)。
|
||||
pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024;
|
||||
|
||||
/// 一个要进包的文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BundleEntry {
|
||||
/// 包内分组(`launcher` / `session` / `game` / `instance`),决定包内目录。
|
||||
pub group: &'static str,
|
||||
/// 源文件绝对路径。
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl BundleEntry {
|
||||
/// 建一条。
|
||||
pub fn new(group: &'static str, path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
group,
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导出的输入(全部是事实,不含任何结论)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BundleSpec {
|
||||
/// 实例名。
|
||||
pub instance: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 会话 id(**同时会被脱敏**)。
|
||||
pub session: String,
|
||||
/// 退出码(`None` = 进程没起来)。
|
||||
pub exit_code: Option<i32>,
|
||||
/// 退出判定 slug(`application_error` / `jvm_error` / `normal` …)。
|
||||
pub exit_type: String,
|
||||
/// 存活毫秒数。
|
||||
pub uptime_ms: u64,
|
||||
/// 脱敏后的命令行(**调用方**已经过 [`Redactor::scrub_all`])。
|
||||
pub command: String,
|
||||
/// Java 一行说明(路径 + 主版本)。
|
||||
pub java: String,
|
||||
/// 系统一行说明(`OsInfo::summary_line`)。
|
||||
pub system: String,
|
||||
/// 额外说明(例如「本次没有生成 crash-report」)。
|
||||
pub notes: Vec<String>,
|
||||
/// 明确不存在的现场(清单里写成「没有:…」)。
|
||||
pub missing: Vec<String>,
|
||||
/// 要收的文件。
|
||||
pub entries: Vec<BundleEntry>,
|
||||
/// 除了主目录之外还要抹掉的串(会话 id 等)。
|
||||
pub secrets: Vec<String>,
|
||||
/// 用户主目录(`None` = 不抹)。
|
||||
pub home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl BundleSpec {
|
||||
/// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。
|
||||
pub fn manifest(&self) -> String {
|
||||
let mut text = String::new();
|
||||
text.push_str("Unlucky Launcher — launch failure bundle\n");
|
||||
text.push_str("=======================================\n");
|
||||
text.push_str(&format!("instance: {}\n", self.instance));
|
||||
text.push_str(&format!("version: {}\n", self.version));
|
||||
text.push_str(&format!("session: {}\n", self.session));
|
||||
text.push_str(&format!(
|
||||
"exit_code: {}\n",
|
||||
self.exit_code
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "(none)".to_string())
|
||||
));
|
||||
text.push_str(&format!("exit_type: {}\n", self.exit_type));
|
||||
text.push_str(&format!("uptime: {:.1}s\n", self.uptime_ms as f64 / 1000.0));
|
||||
text.push_str(&format!("os: {}\n", self.system));
|
||||
text.push_str(&format!("java: {}\n", self.java));
|
||||
text.push_str(&format!("command: {}\n", self.command));
|
||||
|
||||
if !self.notes.is_empty() {
|
||||
text.push_str("\nnotes\n-----\n");
|
||||
for note in &self.notes {
|
||||
text.push_str(&format!("- {note}\n"));
|
||||
}
|
||||
}
|
||||
if !self.missing.is_empty() {
|
||||
text.push_str("\nmissing (不存在)\n----------------\n");
|
||||
for item in &self.missing {
|
||||
text.push_str(&format!("- {item}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
text.push_str("\nfiles\n-----\n");
|
||||
for entry in &self.entries {
|
||||
text.push_str(&format!(
|
||||
"- {}/{} <- {}\n",
|
||||
entry.group,
|
||||
file_name(&entry.path),
|
||||
entry.path.display()
|
||||
));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
if let Some(home) = &self.home {
|
||||
redactor = redactor.with_home(home);
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导出的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bundle {
|
||||
/// 落盘路径。
|
||||
pub path: PathBuf,
|
||||
/// 字节数(**落盘后**的真实大小)。
|
||||
pub bytes: u64,
|
||||
/// 收进包的文件数(不含清单)。
|
||||
pub files: usize,
|
||||
/// 被截断 / 因总量上限没收内容的文件数。
|
||||
pub truncated: usize,
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// 一行说明(Toast 与日志共用)。
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"{} ({} bytes, {} files, {} truncated)",
|
||||
self.path.display(),
|
||||
self.bytes,
|
||||
self.files,
|
||||
self.truncated
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `spec` 打成一个 zip 写到 `target`(父目录不存在会建)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 目录建不出来、zip 写失败、清单写失败时返回人话(导出失败要能一眼看出哪一步断)。
|
||||
pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String> {
|
||||
let redactor = spec.redactor();
|
||||
|
||||
if let Some(parent) = target.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
let file = File::create(target)
|
||||
.map_err(|error| format!("建诊断包 {} 失败:{error}", target.display()))?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
let mut manifest = spec.manifest();
|
||||
// 截断与缺失在**收集时**才知道,所以清单最后再补一段。
|
||||
let mut collected = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
let mut files = 0usize;
|
||||
let mut truncated = 0usize;
|
||||
let mut used_names: Vec<String> = Vec::new();
|
||||
|
||||
for entry in &spec.entries {
|
||||
let name = unique_name(&mut used_names, entry.group, &file_name(&entry.path));
|
||||
let Ok(bytes) = std::fs::read(&entry.path) else {
|
||||
collected.push(format!("- {name}: 读不出来(已跳过)"));
|
||||
truncated += 1;
|
||||
continue;
|
||||
};
|
||||
let original_len = bytes.len();
|
||||
let mut body = bytes;
|
||||
if total + body.len() as u64 > MAX_TOTAL_BYTES || body.len() as u64 > MAX_ENTRY_BYTES {
|
||||
let keep = usize::try_from(MAX_ENTRY_BYTES.min(body.len() as u64)).unwrap_or(0);
|
||||
let keep = usize::try_from(MAX_TOTAL_BYTES.saturating_sub(total).min(keep as u64))
|
||||
.unwrap_or(0);
|
||||
if keep == 0 {
|
||||
collected.push(format!("- {name}: 超出总量上限,未收内容"));
|
||||
truncated += 1;
|
||||
continue;
|
||||
}
|
||||
body.truncate(keep);
|
||||
collected.push(format!(
|
||||
"- {name}: 已截断(只收前 {keep} 字节,原 {original_len} 字节)"
|
||||
));
|
||||
truncated += 1;
|
||||
}
|
||||
// 文本才脱敏;二进制原样(截断过的文本尾部可能是半个 UTF-8 字符,退化成原样字节)。
|
||||
if let Ok(text) = String::from_utf8(body.clone()) {
|
||||
body = redactor.scrub(&text).into_owned().into_bytes();
|
||||
}
|
||||
total += body.len() as u64;
|
||||
files += 1;
|
||||
write_entry(&mut zip, &name, &body, options)?;
|
||||
}
|
||||
|
||||
if !collected.is_empty() {
|
||||
manifest.push_str("\ncollected\n---------\n");
|
||||
for line in collected {
|
||||
manifest.push_str(&line);
|
||||
manifest.push('\n');
|
||||
}
|
||||
}
|
||||
write_entry(
|
||||
&mut zip,
|
||||
"manifest.txt",
|
||||
redactor.scrub(&manifest).as_bytes(),
|
||||
options,
|
||||
)?;
|
||||
zip.finish()
|
||||
.map_err(|error| format!("收尾诊断包失败:{error}"))?;
|
||||
|
||||
let bytes = std::fs::metadata(target)
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0);
|
||||
Ok(Bundle {
|
||||
path: target.to_path_buf(),
|
||||
bytes,
|
||||
files,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_entry(
|
||||
zip: &mut zip::ZipWriter<File>,
|
||||
name: &str,
|
||||
body: &[u8],
|
||||
options: zip::write::SimpleFileOptions,
|
||||
) -> Result<(), String> {
|
||||
zip.start_file(name, options)
|
||||
.map_err(|error| format!("写入 {name} 失败:{error}"))?;
|
||||
zip.write_all(body)
|
||||
.map_err(|error| format!("写入 {name} 失败:{error}"))
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".to_string())
|
||||
}
|
||||
|
||||
/// 包内重名时加序号(`latest.log` 与 `crash-reports/latest.log` 不会互相覆盖)。
|
||||
fn unique_name(used: &mut Vec<String>, group: &str, name: &str) -> String {
|
||||
let base = format!("{group}/{name}");
|
||||
if !used.contains(&base) {
|
||||
used.push(base.clone());
|
||||
return base;
|
||||
}
|
||||
for index in 2.. {
|
||||
let candidate = format!("{group}/{index}-{name}");
|
||||
if !used.contains(&candidate) {
|
||||
used.push(candidate.clone());
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!("序号一定找得到空位")
|
||||
}
|
||||
|
||||
/// 默认落点:启动器日志分区下的 `exports/`。
|
||||
///
|
||||
/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope),
|
||||
/// 也不该为一次导出引一棵新依赖树。导出后界面把**完整路径**与字节数都给出来,另有
|
||||
/// 「打开文件夹」按钮直达——用户不用猜。
|
||||
pub fn default_target_dir(logs_dir: &Path) -> PathBuf {
|
||||
logs_dir.join("exports")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 诊断包的断言:清单齐全、脱敏生效、缺件写明白、超大文件截断。
|
||||
//!
|
||||
//! 为什么这些值得单开文件:守卫的三条(内容有清单 / 脱敏生效 / 没 crash-report 也能导出)
|
||||
//! 都要**真读回 zip** 才作数——只断言 `write_bundle` 返回 `Ok` 抓不住任何一种坏法。
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{default_target_dir, write_bundle, BundleEntry, BundleSpec, MAX_ENTRY_BYTES};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建夹具目录");
|
||||
root
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建父目录");
|
||||
}
|
||||
std::fs::write(path, text).expect("写夹具");
|
||||
}
|
||||
|
||||
/// 打开 zip 读某个条目的全文(不存在返回 `None`)。
|
||||
fn entry_text(path: &Path, name: &str) -> Option<String> {
|
||||
let file = std::fs::File::open(path).expect("打开 zip");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
let mut entry = archive.by_name(name).ok()?;
|
||||
let mut text = String::new();
|
||||
entry.read_to_string(&mut text).expect("读条目");
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn names(path: &Path) -> Vec<String> {
|
||||
let file = std::fs::File::open(path).expect("打开 zip");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
(0..archive.len())
|
||||
.filter_map(|index| {
|
||||
let item = archive.by_index(index).ok()?;
|
||||
item.name().ok().map(|name| name.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spec(root: &Path, secrets: Vec<String>) -> BundleSpec {
|
||||
BundleSpec {
|
||||
instance: "1.20.4 · Fabric".into(),
|
||||
version: "1.20.4-fabric".into(),
|
||||
session: "0f8fad5b-d9cb-469f-a165-70867728950e".into(),
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".into(),
|
||||
uptime_ms: 12_345,
|
||||
command: "javac -Xmx4096m -Djava.library.path=<home>".into(),
|
||||
java: "C:/Java/bin/javaw.exe (major 21)".into(),
|
||||
system: "os=windows version=10.0 arch=x86_64".into(),
|
||||
notes: vec!["本次没有生成 crash-report".into()],
|
||||
missing: vec!["crash-reports/ 不存在".into()],
|
||||
entries: vec![
|
||||
BundleEntry::new("launcher", root.join("launcher.log")),
|
||||
BundleEntry::new("session", root.join("game").join("s1.jsonl")),
|
||||
],
|
||||
secrets,
|
||||
home: Some(root.join("home")),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_has_a_manifest_and_the_collected_files() {
|
||||
let root = temp_dir("manifest");
|
||||
write(&root.join("launcher.log"), "[launch] spawn argv=...\n");
|
||||
write(&root.join("game").join("s1.jsonl"), "{\"kind\":\"exit\"}\n");
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出");
|
||||
|
||||
assert!(bundle.bytes > 0, "落盘字节数不该是 0");
|
||||
assert_eq!(bundle.files, 2, "两个现场文件都该进包");
|
||||
assert_eq!(bundle.truncated, 0);
|
||||
|
||||
let listed = names(&target);
|
||||
assert!(listed.contains(&"manifest.txt".to_string()), "{listed:?}");
|
||||
assert!(
|
||||
listed.contains(&"launcher/launcher.log".to_string()),
|
||||
"{listed:?}"
|
||||
);
|
||||
assert!(
|
||||
listed.contains(&"session/s1.jsonl".to_string()),
|
||||
"{listed:?}"
|
||||
);
|
||||
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
// 清单要能把「发给别人看」需要的三件事说清:哪一场、什么退出、文件在哪。
|
||||
assert!(manifest.contains("instance: 1.20.4 · Fabric"), "{manifest}");
|
||||
assert!(manifest.contains("exit_code: 1"), "{manifest}");
|
||||
assert!(
|
||||
manifest.contains("exit_type: application_error"),
|
||||
"{manifest}"
|
||||
);
|
||||
assert!(manifest.contains("launcher/launcher.log <-"), "{manifest}");
|
||||
}
|
||||
|
||||
/// 守卫②:**脱敏真的生效**(主目录 / token / 会话 id 三条各自替换)。
|
||||
#[test]
|
||||
fn secrets_home_and_session_ids_are_scrubbed_in_every_entry() {
|
||||
let root = temp_dir("scrub");
|
||||
let home = root.join("home");
|
||||
let token = "eyJhbGciOiJIUzI1NiJ9.eyJ4IjoxMjN9.c2lnbmF0dXJlLXZhbHVl";
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
let dirty = format!(
|
||||
"argv=--accessToken {token}\npath={}\nsession={session}\n",
|
||||
home.join("AppData").display()
|
||||
);
|
||||
write(&root.join("launcher.log"), &dirty);
|
||||
write(&root.join("game").join("s1.jsonl"), &dirty);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.command = format!("java -accessToken {token}");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
assert_eq!(bundle.files, 2);
|
||||
|
||||
for name in ["manifest.txt", "launcher/launcher.log", "session/s1.jsonl"] {
|
||||
let text = entry_text(&target, name).unwrap_or_else(|| panic!("缺条目 {name}"));
|
||||
assert!(!text.contains(token), "{name} 漏了 token:{text}");
|
||||
assert!(!text.contains(session), "{name} 漏了会话 id:{text}");
|
||||
}
|
||||
let log = entry_text(&target, "launcher/launcher.log").expect("日志");
|
||||
assert!(log.contains("<home>"), "主目录没被替换:{log}");
|
||||
assert!(
|
||||
!log.contains(&home.display().to_string()),
|
||||
"家目录原文还在:{log}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 守卫③:**没有 crash-report 也能导出**,而且「没有」这件事写进清单(不许静默漏掉)。
|
||||
#[test]
|
||||
fn missing_crash_report_is_stated_in_the_manifest() {
|
||||
let root = temp_dir("missing");
|
||||
write(
|
||||
&root.join("game").join("s1.jsonl"),
|
||||
"{\"kind\":\"stderr\"}\n",
|
||||
);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出");
|
||||
assert_eq!(bundle.files, 1, "只有会话 JSONL 时也要出包");
|
||||
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("missing"), "{manifest}");
|
||||
assert!(manifest.contains("crash-reports/ 不存在"), "{manifest}");
|
||||
assert!(manifest.contains("本次没有生成 crash-report"), "{manifest}");
|
||||
// 清单里也要能看见那个「不存在的文件名」,否则用户以为我们忘收了。
|
||||
assert!(manifest.contains("session/s1.jsonl"), "{manifest}");
|
||||
}
|
||||
|
||||
/// 有上限:单个超限文件只收头部,并在清单里记一行「已截断」。
|
||||
#[test]
|
||||
fn oversized_entries_are_truncated_and_recorded() {
|
||||
let root = temp_dir("truncate");
|
||||
let big = root.join("game").join("latest.log");
|
||||
let body = "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32);
|
||||
write(&big, &body);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::new("game", &big)];
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数");
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("已截断"), "{manifest}");
|
||||
assert!(
|
||||
bundle.bytes < MAX_ENTRY_BYTES + 8 * 1024,
|
||||
"包不该把整个超限文件塞进去({} 字节)",
|
||||
bundle.bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_source_files_are_skipped_not_fatal() {
|
||||
let root = temp_dir("gone");
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::new("game", root.join("nope/absent.log"))];
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("缺文件也要出包");
|
||||
assert_eq!(bundle.files, 0);
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("读不出来"), "{manifest}");
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! 一次失败启动的**现场**(M187):路径 + 捕获输出 + 退出事实,供失败弹窗与诊断包共用。
|
||||
//!
|
||||
//! ## 为什么是「事实」而不是「结论」
|
||||
//!
|
||||
//! 弹窗要说三件事——**是哪一场失败**(装配阶段 / 起进程后崩溃)、**退出码**、**证据在哪**。
|
||||
//! 这三件都是启动器已经知道的事实,不含任何推断。崩溃原因的智能诊断是另一条线(诊断引擎
|
||||
//! `diagnose` / AI),本模块只把事实摆齐,让用户能一键把证据带走。
|
||||
//!
|
||||
//! ## 两种失败形态必须分开(用户投诉的原点)
|
||||
//!
|
||||
//! | 形态 | 判据 | 弹窗该说什么 |
|
||||
//! |---|---|---|
|
||||
//! | 游戏自己写了崩溃报告 | [`CrashReport::has_game_crash_report`] 为真 | 「游戏写出了崩溃报告」,贴 Description/栈片段 |
|
||||
//! | 游戏**没来得及写** | 没有 crash-report,但有捕获输出 | 「这次没有生成崩溃报告,下面是捕获的最后若干行」 |
|
||||
//! | 什么输出都没抓到 | 两者皆无 | 「游戏还没来得及写日志」+ 最可能原因分类(缺 Java / 内存 / 参数 / 库) |
|
||||
//!
|
||||
//! 第二、三种**不许**显示成「找不到日志」——日志在启动器这里,只是游戏没写它自己那份。
|
||||
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::crash_scan::{self, CrashArtifact, CrashSource};
|
||||
use super::launch_session::SessionHandle;
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
|
||||
pub const TAIL_LINES: usize = 30;
|
||||
/// 现场文件的扫描条数上限。
|
||||
pub const MAX_ARTIFACTS: usize = 8;
|
||||
/// 弹窗里贴崩溃报告摘要的字符上限。
|
||||
pub const MAX_EXCERPT_CHARS: usize = 1200;
|
||||
|
||||
/// 一个现场文件(来源 + 路径 + 人读摘要)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtifactFact {
|
||||
/// 来源 slug(`crash_report` / `hs_err` / `latest_log`)。
|
||||
pub source: String,
|
||||
/// 文件路径。
|
||||
pub path: PathBuf,
|
||||
/// 摘要(崩溃报告的 Description + 栈片段;`latest.log` 与 `hs_err` 不给)。
|
||||
pub excerpt: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次失败启动的现场。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CrashReport {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 绑定的版本 id。
|
||||
pub version: String,
|
||||
/// 会话 id(空 = 进程根本没起来)。
|
||||
pub session: String,
|
||||
/// 会话事件流 JSONL(**游戏侧证据的真正落点**)。
|
||||
pub session_log: Option<PathBuf>,
|
||||
/// 该实例绑定的版本 JSON(诊断包要带上它,别人才能复现装配)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 启动器自己的日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: Option<PathBuf>,
|
||||
/// 实例运行目录(`crash-reports/` 与 `logs/` 的父目录)。
|
||||
pub run_dir: Option<PathBuf>,
|
||||
/// 崩溃现场文件(按修改时间倒序)。
|
||||
pub artifacts: Vec<ArtifactFact>,
|
||||
/// 退出码(`None` = 进程没起来 / 平台没给)。
|
||||
pub exit_code: Option<i32>,
|
||||
/// 退出判定 slug(`application_error` / `jvm_error` / `normal` …;进程没起来时是 `spawn`)。
|
||||
pub exit_type: String,
|
||||
/// 存活毫秒数。
|
||||
pub uptime_ms: u64,
|
||||
/// 脱敏后的命令行一行。
|
||||
pub command: String,
|
||||
/// Java 一行说明(二进制 + 主版本)。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 装配阶段失败的 slug(`version_manifest` / `spawn` …);`None` = 进程起来过。
|
||||
pub stage: Option<String>,
|
||||
/// 装配阶段的原始错误文本(不翻译,可能含路径)。
|
||||
pub stage_detail: Option<String>,
|
||||
/// 启动器捕获的最后若干行(已脱敏、已截断到 [`TAIL_LINES`])。
|
||||
pub stderr_tail: Vec<String>,
|
||||
}
|
||||
|
||||
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Evidence {
|
||||
/// 游戏自己写了 `crash-reports/crash-*.txt`。
|
||||
GameCrashReport,
|
||||
/// 游戏没写崩溃报告,但启动器抓到了输出。
|
||||
CapturedOutput,
|
||||
/// 连输出都没抓到(JVM 都没起来 / 立刻失败)。
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
/// 稳定 slug(测试与日志用;不是文案)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::GameCrashReport => "game_crash_report",
|
||||
Self::CapturedOutput => "captured_output",
|
||||
Self::Nothing => "nothing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CrashReport {
|
||||
/// 游戏侧证据的形态(界面按它选文案,**不许**再自己判断)。
|
||||
#[must_use]
|
||||
pub fn evidence(&self) -> Evidence {
|
||||
if self.has_game_crash_report() {
|
||||
Evidence::GameCrashReport
|
||||
} else if self.stderr_tail.is_empty() {
|
||||
Evidence::Nothing
|
||||
} else {
|
||||
Evidence::CapturedOutput
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否找到游戏自己写的崩溃报告。
|
||||
#[must_use]
|
||||
pub fn has_game_crash_report(&self) -> bool {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.any(|a| a.source == CrashSource::CrashReport.as_str())
|
||||
}
|
||||
|
||||
/// 进程是否起来过(决定标题写「启动失败」还是「游戏崩溃」)。
|
||||
#[must_use]
|
||||
pub fn started(&self) -> bool {
|
||||
self.stage.is_none()
|
||||
}
|
||||
|
||||
/// 该实例运行目录下的 `crash-reports/`。
|
||||
#[must_use]
|
||||
pub fn crash_reports_dir(&self) -> Option<PathBuf> {
|
||||
self.run_dir.as_ref().map(|dir| dir.join("crash-reports"))
|
||||
}
|
||||
|
||||
/// 该实例的 `logs/latest.log`。
|
||||
#[must_use]
|
||||
pub fn latest_log_path(&self) -> Option<PathBuf> {
|
||||
self.run_dir
|
||||
.as_ref()
|
||||
.map(|dir| dir.join("logs").join("latest.log"))
|
||||
}
|
||||
|
||||
/// 供「复制路径」用的(标签, 路径)列表(提示词在前、目录在后)。
|
||||
#[must_use]
|
||||
pub fn path_lines(&self) -> Vec<(String, PathBuf)> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(log) = &self.session_log {
|
||||
lines.push(("session".to_string(), log.clone()));
|
||||
}
|
||||
if let Some(dir) = &self.launcher_logs {
|
||||
lines.push(("launcher_logs".to_string(), dir.clone()));
|
||||
}
|
||||
if let Some(path) = self.latest_log_path() {
|
||||
lines.push(("latest_log".to_string(), path));
|
||||
}
|
||||
if let Some(dir) = self.crash_reports_dir() {
|
||||
lines.push(("crash_reports".to_string(), dir));
|
||||
}
|
||||
if let Some(dir) = &self.run_dir {
|
||||
lines.push(("run_dir".to_string(), dir.clone()));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// 收集一次**已经起来过**的启动的现场。
|
||||
///
|
||||
/// `session` 由调用方在 `finish()` 之后给出;`lines` 是该会话的事件流行(**调用方读**,
|
||||
/// 因为读 JSONL 属于 host 的活)。只有最后 [`TAIL_LINES`] 行进弹窗。
|
||||
#[must_use]
|
||||
pub fn collect(session: &SessionHandle, ctx: &ReportContext, lines: &[String]) -> Self {
|
||||
let run_dir = ctx.run_dir.clone();
|
||||
let artifacts = crash_scan::scan(&run_dir, ctx.started_at, MAX_ARTIFACTS)
|
||||
.into_iter()
|
||||
.map(|artifact| fact(&artifact, &ctx.redactor()))
|
||||
.collect();
|
||||
|
||||
let raw: Vec<String> = lines.iter().rev().take(TAIL_LINES).rev().cloned().collect();
|
||||
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: session.session_id.clone(),
|
||||
session_log: Some(session.log_path.clone()),
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(session.exit.code),
|
||||
exit_type: session.exit.exit_type.as_str().to_string(),
|
||||
uptime_ms: session.uptime_ms,
|
||||
command: ctx
|
||||
.redactor()
|
||||
.scrub(session.redacted_display().as_str())
|
||||
.into_owned(),
|
||||
java: ctx.java.clone(),
|
||||
system: ctx.system.clone(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: scrub_tail(&raw, &ctx.redactor()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。
|
||||
///
|
||||
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
|
||||
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
|
||||
#[must_use]
|
||||
pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self {
|
||||
let redactor = ctx.redactor();
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: String::new(),
|
||||
session_log: None,
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(ctx.run_dir.clone()),
|
||||
artifacts: Vec::new(),
|
||||
exit_code: None,
|
||||
exit_type: exit_type.to_string(),
|
||||
uptime_ms: 0,
|
||||
command: String::new(),
|
||||
java: ctx.java.clone(),
|
||||
system: ctx.system.clone(),
|
||||
stage: Some(stage.to_string()),
|
||||
stage_detail: Some(redactor.scrub(detail).into_owned()),
|
||||
stderr_tail: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断包清单里要写明的「没有的东西」(**不许静默省略**)。
|
||||
#[must_use]
|
||||
pub fn missing_lines(&self) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
if !self.has_game_crash_report() {
|
||||
missing.push("crash-reports/crash-*.txt: 本次没有(游戏没来得及写)".to_string());
|
||||
}
|
||||
if let Some(path) = self.latest_log_path() {
|
||||
if !path.is_file() {
|
||||
missing.push(format!("logs/latest.log: 不存在({})", path.display()));
|
||||
}
|
||||
}
|
||||
if self.session_log.is_none() {
|
||||
missing.push("会话 JSONL: 不存在(进程没起来)".to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// 备注(清单里「这次发生了什么」那一段)。
|
||||
#[must_use]
|
||||
pub fn note_lines(&self) -> Vec<String> {
|
||||
let mut notes = match self.evidence() {
|
||||
Evidence::GameCrashReport => vec!["游戏写出了崩溃报告(crash-reports/)".to_string()],
|
||||
Evidence::CapturedOutput => {
|
||||
vec!["本次没有生成崩溃报告;证据是启动器捕获的输出".to_string()]
|
||||
}
|
||||
Evidence::Nothing => vec![
|
||||
"本次没有崩溃报告,也没捕获到输出(最可能:缺 Java / 内存不足 / 参数错误 / 库缺失)"
|
||||
.to_string(),
|
||||
],
|
||||
};
|
||||
if let Some(stage) = &self.stage {
|
||||
notes.push(format!("进程没有起来,失败在:{stage}"));
|
||||
}
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。
|
||||
///
|
||||
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
||||
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReportContext {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 实例运行目录。
|
||||
pub run_dir: PathBuf,
|
||||
/// 启动器日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: PathBuf,
|
||||
/// 本次会话的开始时刻(现场文件的时间闸)。
|
||||
pub started_at: std::time::SystemTime,
|
||||
/// 该实例绑定的版本 JSON。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// Java 一行说明。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 要额外抹掉的串(会话 id、access token 等)。
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
instance: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
run_dir: impl Into<PathBuf>,
|
||||
launcher_logs: impl Into<PathBuf>,
|
||||
started_at: std::time::SystemTime,
|
||||
system: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance: instance.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
run_dir: run_dir.into(),
|
||||
launcher_logs: launcher_logs.into(),
|
||||
started_at,
|
||||
version_json: None,
|
||||
java: String::new(),
|
||||
system: system.into(),
|
||||
secrets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| redactor.scrub(line).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
||||
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
||||
let scrubbed = redactor.scrub(text);
|
||||
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
||||
trimmed
|
||||
});
|
||||
ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path.clone(),
|
||||
excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
||||
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
||||
pub fn publish(report: CrashReport) {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = Some(report);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读最近一次失败启动的现场。
|
||||
#[must_use]
|
||||
pub fn failing_launch() -> Option<CrashReport> {
|
||||
slot().lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// 清空槽位(测试用)。
|
||||
pub fn clear() {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 现场事实的断言:三态证据分类、脱敏、缺件写明、槽位读写。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
|
||||
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
|
||||
CrashReport {
|
||||
instance: "survival".into(),
|
||||
name: "生存".into(),
|
||||
version: "1.20.4".into(),
|
||||
session: "0f8fad5b".into(),
|
||||
session_log: Some(PathBuf::from("/sessions/game/0f8fad5b.jsonl")),
|
||||
version_json: None,
|
||||
launcher_logs: Some(PathBuf::from("/.ULL/logs")),
|
||||
run_dir: Some(PathBuf::from("/instances/survival")),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".into(),
|
||||
uptime_ms: 12_000,
|
||||
command: "javaw -Xmx4096m".into(),
|
||||
java: "javaw.exe (21)".into(),
|
||||
system: "os=windows".into(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_tells_the_three_shapes_apart() {
|
||||
let with_report = report(
|
||||
vec![ArtifactFact {
|
||||
source: "crash_report".into(),
|
||||
path: PathBuf::from("/instances/survival/crash-reports/crash-1.txt"),
|
||||
excerpt: Some("Description: Ticking entity".into()),
|
||||
}],
|
||||
vec!["Exception in thread main".into()],
|
||||
);
|
||||
assert_eq!(with_report.evidence(), Evidence::GameCrashReport);
|
||||
assert_eq!(with_report.evidence().slug(), "game_crash_report");
|
||||
|
||||
let stderr_only = report(Vec::new(), vec!["Error: could not find java.dll".into()]);
|
||||
assert_eq!(
|
||||
stderr_only.evidence(),
|
||||
Evidence::CapturedOutput,
|
||||
"没有 crash-report 但有捕获输出 ⇒ 不许说「找不到日志」"
|
||||
);
|
||||
|
||||
let nothing = report(Vec::new(), Vec::new());
|
||||
assert_eq!(nothing.evidence(), Evidence::Nothing);
|
||||
|
||||
// 三种 slug 必须互不相同(界面按它选 key,撞了就会显示错的一句)。
|
||||
let slugs = [
|
||||
with_report.evidence().slug(),
|
||||
stderr_only.evidence().slug(),
|
||||
nothing.evidence().slug(),
|
||||
];
|
||||
let mut unique = slugs.to_vec();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), 3, "{slugs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_crash_report_is_listed_not_silently_dropped() {
|
||||
let report = report(Vec::new(), vec!["line".into()]);
|
||||
let missing = report.missing_lines().join("\n");
|
||||
assert!(missing.contains("crash-reports"), "{missing}");
|
||||
assert!(missing.contains("游戏没来得及写"), "{missing}");
|
||||
assert!(
|
||||
!missing.contains("会话 JSONL"),
|
||||
"会话在,不该说没会话:{missing}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_name_the_fallback_reason_classification() {
|
||||
let report = report(Vec::new(), Vec::new());
|
||||
let notes = report.note_lines().join("\n");
|
||||
assert!(notes.contains("缺 Java"), "{notes}");
|
||||
assert!(notes.contains("内存不足"), "{notes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() {
|
||||
let home = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.unwrap_or_default();
|
||||
if home.is_empty() {
|
||||
return;
|
||||
}
|
||||
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 report = CrashReport::before_start(
|
||||
&ctx,
|
||||
"spawn",
|
||||
&format!("{home}/java/bin/javaw.exe 不存在"),
|
||||
"spawn",
|
||||
);
|
||||
assert!(!report.started(), "进程没起来");
|
||||
assert_eq!(report.evidence(), Evidence::Nothing);
|
||||
let detail = report.stage_detail.expect("阶段原文");
|
||||
assert!(detail.contains("<home>"), "{detail}");
|
||||
assert!(!detail.contains(&home), "主目录原文漏了:{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_keeps_the_last_failure_only() {
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
publish(report(Vec::new(), vec!["first".into()]));
|
||||
publish(report(Vec::new(), vec!["second".into()]));
|
||||
let last = failing_launch().expect("最近一次");
|
||||
assert_eq!(last.stderr_tail, vec!["second".to_string()]);
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 启动器**自己的**运行日志(M187):`<.ULL>/logs/launcher.log`。
|
||||
//!
|
||||
//! ## 为什么要有这个文件
|
||||
//!
|
||||
//! 用户实测(2026-11-08):「游戏崩溃后只有一个安装日志,没有崩溃日志」。核查结论是:
|
||||
//! `logs/` 分区里此前只有 `install.log`(安装流水)与 auth journal——**启动器自己没有运行期
|
||||
//! 日志**。双击启动的 GUI 进程没有 stderr 去向,所有 `eprintln!` 诊断当场蒸发,于是
|
||||
//! 「启动器视角发生了什么」在事故之后无法复原。
|
||||
//!
|
||||
//! 本模块只做一件事:把一行行 `[scope] message` 追加到一个文件里,**落盘即脱敏**。
|
||||
//!
|
||||
//! ## 三条约束(都是有意的)
|
||||
//!
|
||||
//! 1. **best-effort**:写不进去就静默放过。日志失败不该让启动器崩、也不该弹提示。
|
||||
//! 2. **不阻塞**:不建后台线程、不做异步队列。调用点都是「一次启动 / 一次导出」这种低频事件,
|
||||
//! 每次一行 `write_all` 的代价可以忽略;引入通道反而多一处要收尾的生命周期。
|
||||
//! 3. **有上限**:超过 [`MAX_BYTES`] 就滚成 `launcher.1.log`(只保留一份历史)。日志文件
|
||||
//! 无限长会让「导出诊断包」变成搬砖。
|
||||
//!
|
||||
//! ## 目录从哪来
|
||||
//!
|
||||
//! 目录**不是**本模块自己解析的:`.ULL` 的落点由 `platform::data_dir` 的三模式决定(导出的
|
||||
//! exe 目录 / 标准目录 / 自定义根),解析它需要读配置——那是宿主(presenter / app)已经做过
|
||||
//! 的事。所以这里是「**先到者登记**」:知道真相的调用方用 [`set_dir`] 登记,
|
||||
//! 登记之前 [`record`] 的行进内存缓冲(最多 [`MAX_BUFFERED`] 行),登记时一次刷出去。
|
||||
//! 这样启动早期(字体 / GPU / 路由)那几行也不会丢。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 日志文件名。
|
||||
pub const FILE_NAME: &str = "launcher.log";
|
||||
/// 单个文件上限(超过就滚成 `launcher.1.log`,只留一份历史)。
|
||||
pub const MAX_BYTES: u64 = 1024 * 1024;
|
||||
/// 登记目录之前最多缓冲多少行。
|
||||
pub const MAX_BUFFERED: usize = 200;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
dir: Option<PathBuf>,
|
||||
buffer: Vec<String>,
|
||||
}
|
||||
|
||||
fn state() -> &'static Mutex<State> {
|
||||
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
|
||||
STATE.get_or_init(|| Mutex::new(State::default()))
|
||||
}
|
||||
|
||||
/// 登记日志目录(`<.ULL>/logs`)。重复登记时以**最后一次**为准(数据根可能被 `redirect.json` 改)。
|
||||
pub fn set_dir(logs_dir: impl Into<PathBuf>) {
|
||||
let dir = logs_dir.into();
|
||||
let mut guard = match state().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
if guard.dir.as_ref() == Some(&dir) {
|
||||
return;
|
||||
}
|
||||
guard.dir = Some(dir);
|
||||
let buffered = std::mem::take(&mut guard.buffer);
|
||||
let Some(directory) = guard.dir.clone() else {
|
||||
return;
|
||||
};
|
||||
drop(guard);
|
||||
for line in buffered {
|
||||
append(&directory, &line);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前日志文件路径(`None` = 还没登记目录)。
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let guard = state().lock().ok()?;
|
||||
guard.dir.as_ref().map(|dir| dir.join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// 记一行(`[scope] message`)。scope 是稳定短的英文标签(`boot` / `launch` / `dialog`)。
|
||||
///
|
||||
/// 时间戳走 `launch::time::now()`(与事件流同一份时钟口径),所以日志行与 JSONL 能对上。
|
||||
pub fn record(scope: &str, message: &str) {
|
||||
let line = format!(
|
||||
"{} [{scope}] {message}",
|
||||
super::time::format_rfc3339_millis(super::time::now())
|
||||
);
|
||||
let dir = {
|
||||
let mut guard = match state().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
match guard.dir.clone() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
if guard.buffer.len() < MAX_BUFFERED {
|
||||
guard.buffer.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
append(&dir, &line);
|
||||
}
|
||||
|
||||
/// 脱敏器:主目录换成 `<home>`(同一份规则见 [`super::redact`])。
|
||||
fn redactor() -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
fn append(dir: &Path, line: &str) {
|
||||
let path = dir.join(FILE_NAME);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// 滚动:只在超过上限时做一次 rename,平时的路径只有「打开 → 追加 → 关」。
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > MAX_BYTES) {
|
||||
let _ = std::fs::rename(&path, dir.join("launcher.1.log"));
|
||||
}
|
||||
let scrubbed = redactor().scrub(line).into_owned();
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = file.write_all(scrubbed.as_bytes());
|
||||
let _ = file.write_all(b"\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn state_reset() {
|
||||
if let Ok(mut guard) = state().lock() {
|
||||
guard.dir = None;
|
||||
guard.buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_BUFFERED;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 测试之间共享全局状态,必须串行(`cargo test` 默认多线程)。
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-log-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建夹具目录");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_lines_are_flushed_once_the_dir_is_known() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
let dir = temp_dir("flush");
|
||||
|
||||
super::record("boot", "before the dir was known");
|
||||
assert!(super::path().is_none(), "没登记目录时不该报出路径");
|
||||
super::set_dir(&dir);
|
||||
super::record("launch", "after");
|
||||
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
assert!(text.contains("before the dir was known"), "{text}");
|
||||
assert!(text.contains("[launch] after"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_paths_are_scrubbed_on_write() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
let dir = temp_dir("scrub");
|
||||
super::set_dir(&dir);
|
||||
let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME"));
|
||||
let Ok(home) = home else {
|
||||
return;
|
||||
};
|
||||
super::record("launch", &format!("argv --nativesDir {home}/natives"));
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
assert!(text.contains("<home>"), "{text}");
|
||||
assert!(!text.contains(home.as_str()), "家目录原文还在:{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_is_bounded_before_the_dir_is_known() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
for index in 0..(MAX_BUFFERED + 50) {
|
||||
super::record("boot", &format!("line {index}"));
|
||||
}
|
||||
let dir = temp_dir("bounded");
|
||||
super::set_dir(&dir);
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
let lines = text.lines().count();
|
||||
assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行");
|
||||
assert!(lines > 0);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ pub mod adapter;
|
||||
pub mod args_table;
|
||||
pub mod argument;
|
||||
pub mod command_builder;
|
||||
pub mod crash_bundle;
|
||||
pub mod crash_report;
|
||||
pub mod crash_scan;
|
||||
pub mod defaults;
|
||||
pub mod diagnosis_hook;
|
||||
@@ -38,6 +40,7 @@ pub mod game_version;
|
||||
pub mod jvm_args;
|
||||
pub mod launch_session;
|
||||
pub mod launcher;
|
||||
pub mod launcher_log;
|
||||
pub mod log_level;
|
||||
pub mod natives;
|
||||
pub mod overrides;
|
||||
@@ -62,6 +65,7 @@ pub use adapter::{
|
||||
build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
pub use args_table::{assemble, AssembledCommand, AssemblyWarning};
|
||||
pub use crash_report::{ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
pub use crash_scan::{CrashArtifact, CrashSource};
|
||||
pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis};
|
||||
pub use error::{LaunchError, MissingComponent, Result};
|
||||
|
||||
@@ -244,6 +244,30 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("LogsEmptyTitle", "No output yet", "还没有输出"),
|
||||
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
|
||||
("LogsEmptyAction", "Choose an instance", "选择实例"),
|
||||
("CrashDialogTitleFailed", "Launch failed", "启动失败"),
|
||||
("CrashDialogTitleCrashed", "The game crashed", "游戏崩溃"),
|
||||
("CrashDialogExitCode", "Exit code {code}", "退出码 {code}"),
|
||||
("CrashDialogStartedThenDied", "The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"),
|
||||
("CrashDialogSilentFailure", "The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"),
|
||||
("CrashDialogStageReason", "It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"),
|
||||
("CrashDialogCrashReportFound", "The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"),
|
||||
("CrashDialogNoGameLog", "No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"),
|
||||
("CrashDialogNoLogAtAll", "No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"),
|
||||
("CrashDialogShowDetail", "Show crash details", "展开崩溃说明"),
|
||||
("CrashDialogHideDetail", "Hide crash details", "收起崩溃说明"),
|
||||
("CrashDialogLauncherLogs", "Launcher logs", "启动器日志"),
|
||||
("CrashDialogSessionLog", "This session", "本次会话"),
|
||||
("CrashDialogGameLog", "Game latest.log", "游戏 latest.log"),
|
||||
("CrashDialogCrashReports", "Crash reports", "崩溃报告目录"),
|
||||
("CrashDialogCapturedTail", "CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"),
|
||||
("CrashDialogExport", "Export report bundle", "导出诊断包"),
|
||||
("CrashDialogOpenFolder", "Open log folder", "打开日志文件夹"),
|
||||
("CrashDialogCopyPaths", "Copy file paths", "复制文件路径"),
|
||||
("CrashDialogClose", "Close", "关闭"),
|
||||
("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"),
|
||||
("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"),
|
||||
("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"),
|
||||
("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"),
|
||||
("AgentTitle", "Assistant", "助手"),
|
||||
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
|
||||
("AgentCollapse", "Collapse", "收起"),
|
||||
|
||||
@@ -33,6 +33,31 @@ macro_rules! logs_entries {
|
||||
"启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"
|
||||
),
|
||||
LogsEmptyAction => ("Choose an instance", "选择实例"),
|
||||
// --- 启动失败弹窗(M187)---
|
||||
CrashDialogTitleFailed => ("Launch failed", "启动失败"),
|
||||
CrashDialogTitleCrashed => ("The game crashed", "游戏崩溃"),
|
||||
CrashDialogExitCode => ("Exit code {code}", "退出码 {code}"),
|
||||
CrashDialogStartedThenDied => ("The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"),
|
||||
CrashDialogSilentFailure => ("The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"),
|
||||
CrashDialogStageReason => ("It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"),
|
||||
CrashDialogCrashReportFound => ("The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"),
|
||||
CrashDialogNoGameLog => ("No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"),
|
||||
CrashDialogNoLogAtAll => ("No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"),
|
||||
CrashDialogShowDetail => ("Show crash details", "展开崩溃说明"),
|
||||
CrashDialogHideDetail => ("Hide crash details", "收起崩溃说明"),
|
||||
CrashDialogLauncherLogs => ("Launcher logs", "启动器日志"),
|
||||
CrashDialogSessionLog => ("This session", "本次会话"),
|
||||
CrashDialogGameLog => ("Game latest.log", "游戏 latest.log"),
|
||||
CrashDialogCrashReports => ("Crash reports", "崩溃报告目录"),
|
||||
CrashDialogCapturedTail => ("CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"),
|
||||
CrashDialogExport => ("Export report bundle", "导出诊断包"),
|
||||
CrashDialogOpenFolder => ("Open log folder", "打开日志文件夹"),
|
||||
CrashDialogCopyPaths => ("Copy file paths", "复制文件路径"),
|
||||
CrashDialogClose => ("Close", "关闭"),
|
||||
CrashDialogCopied => ("File paths copied to the clipboard", "文件路径已复制到剪贴板"),
|
||||
CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"),
|
||||
CrashDialogExportFailed => ("Export failed: {error}", "导出失败:{error}"),
|
||||
CrashDialogOpenFailed => ("Could not open the folder: {error}", "打不开文件夹:{error}"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ 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::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;
|
||||
@@ -216,6 +218,19 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let id = target.id.clone();
|
||||
let tx_progress = tx.clone();
|
||||
super::job::spawn("launch", move || {
|
||||
// M187:启动器自己的运行日志从这里开始有落点(早于这一拍的行由 core 缓冲接住)。
|
||||
let logs_dir = env.layout.logs();
|
||||
launcher_log::set_dir(&logs_dir);
|
||||
let started_at = std::time::SystemTime::now();
|
||||
let mut report = ReportContext::new(
|
||||
&target.id,
|
||||
&target.name,
|
||||
&target.version_id,
|
||||
&env.data.game_home,
|
||||
&logs_dir,
|
||||
started_at,
|
||||
unlucky_core::platform::os_info::current().summary_line(),
|
||||
);
|
||||
let _ = tx_progress.send(HostTask::Runtime {
|
||||
id: id.clone(),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
@@ -226,6 +241,9 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let (request, warnings) = match build_request(&env, &target) {
|
||||
Ok(ok) => ok,
|
||||
Err(failure) => {
|
||||
let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
|
||||
launcher_log::record("launch", &line);
|
||||
publish_before_start(&report, failure.stage.slug(), &failure.detail);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
@@ -237,6 +255,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
report.run_dir = request.dirs.game_dir.clone();
|
||||
report.version_json = Some(env.game.version_json(&target.version_id));
|
||||
report.java = format!(
|
||||
"{} (major {})",
|
||||
request.java.binary.display(),
|
||||
request.java.major
|
||||
);
|
||||
for warning in &warnings {
|
||||
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
||||
&format!("launch-warn:{}", target.id),
|
||||
@@ -245,21 +270,33 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
warning,
|
||||
))));
|
||||
}
|
||||
// 命令行(脱敏版)进启动器日志:事故之后「到底用什么参数起的」是第一个要看的。
|
||||
let line = format!(
|
||||
"instance={} game_dir={} java={} memory={}MB",
|
||||
target.id,
|
||||
report.run_dir.display(),
|
||||
report.java,
|
||||
request.memory.max_mb.unwrap_or(0)
|
||||
);
|
||||
launcher_log::record("launch", &line);
|
||||
let config = LauncherConfig::new(env.sessions_root())
|
||||
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||||
let session = match Launcher::new(config).spawn(&request) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let failure = LaunchFailureView::new(LaunchFailureStage::Spawn, error.to_string());
|
||||
launcher_log::record(
|
||||
"launch",
|
||||
&format!("instance={} spawn 失败:{error}", target.id),
|
||||
);
|
||||
publish_before_start(&report, "spawn", &failure.detail);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: None,
|
||||
uptime_ms: 0,
|
||||
failure: Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::Spawn,
|
||||
error.to_string(),
|
||||
)),
|
||||
failure: Some(failure),
|
||||
})));
|
||||
return;
|
||||
}
|
||||
@@ -278,6 +315,22 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
match session.finish() {
|
||||
Ok(handle) => {
|
||||
let outcome = super::identity::outcome_of(&handle);
|
||||
let line = format!(
|
||||
"instance={} session={session_id} exit={} type={} uptime={}ms outcome={:?} artifacts={}",
|
||||
target.id,
|
||||
handle.exit.code,
|
||||
handle.exit.exit_type.as_str(),
|
||||
handle.uptime_ms,
|
||||
outcome,
|
||||
handle.artifacts.len()
|
||||
);
|
||||
launcher_log::record("launch", &line);
|
||||
report.secrets.push(session_id.clone());
|
||||
let lines: Vec<String> = super::logs::read_lines(&handle.log_path)
|
||||
.into_iter()
|
||||
.map(|line| line.text)
|
||||
.collect();
|
||||
crash_report::publish(CrashReport::collect(&handle, &report, &lines));
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: session_id,
|
||||
@@ -289,22 +342,31 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
})));
|
||||
}
|
||||
Err(error) => {
|
||||
let failure = LaunchFailureView::new(LaunchFailureStage::Spawn, error.to_string());
|
||||
let line = format!(
|
||||
"instance={} session={session_id} 收尾失败:{error}",
|
||||
target.id
|
||||
);
|
||||
launcher_log::record("launch", &line);
|
||||
publish_before_start(&report, "spawn", &failure.detail);
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: session_id,
|
||||
outcome: LogOutcome::SilentFail,
|
||||
exit_code: None,
|
||||
uptime_ms: 0,
|
||||
failure: Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::Spawn,
|
||||
error.to_string(),
|
||||
)),
|
||||
failure: Some(failure),
|
||||
})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 登记一次「进程没起来」的现场(四个失败出口共用这一处,避免各写一遍 exit_type)。
|
||||
fn publish_before_start(report: &ReportContext, stage: &str, detail: &str) {
|
||||
crash_report::publish(CrashReport::before_start(report, stage, detail, stage));
|
||||
}
|
||||
|
||||
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
|
||||
pub fn stop(pid: u32) -> Result<(), String> {
|
||||
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 后端 × 中文:什么断言只在软渲染下成立,什么必须在 GPU 下看(M187)
|
||||
|
||||
本文件登记一条**通道级缺口**,不是一条 bug:自动化证据全部走软件渲染,所以「只有 GPU 后端
|
||||
才出现」的缺陷在 CI 里永远是盲区。下次有人要验「文字/像素在 GPU 上是否正常」时,先读这一页。
|
||||
|
||||
## 1. 现状:所有自动出图都是 `--software-renderer`
|
||||
|
||||
| 通道 | 位置 | 后端 |
|
||||
|---|---|---|
|
||||
| CI 冒烟截屏 | `.github/workflows/ci.yml:101` | `--software-renderer`(强制 CPU) |
|
||||
| CI 交互冒烟 | `.github/workflows/ci.yml:246`、`:283` | `--software-renderer` |
|
||||
| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh:90` | `--software-renderer` |
|
||||
| D30 视觉验收(人工,按协议出图) | `DevDocs/PlanDoc/`(视觉验收协议) | 同上(CI 同款命令) |
|
||||
|
||||
`--renderer=auto`(默认)会挑 wgpu;`--software-renderer` 强制 tiny-skia。CI 走后者是**有意的**:
|
||||
容器/无 GPU 机器上没有 Vulkan/DX12 设备,硬要 wgpu 会红在一句「adapter 创建失败」上,与皮肤无关。
|
||||
|
||||
**代价**:tiny-skia 与 wgpu 是两套完全不同的文字光栅化路径(前者 `cosmic-text` + CPU 位图上传,
|
||||
后者经 `cryoglyph` 上传到 GPU 字形图集)。所以「GPU 侧字形图集出错」这类缺陷,**任何自动证据都抓不到**。
|
||||
|
||||
## 2. 这轮实测:M190 的「owned 文字在 wgpu 下丢字」在本 branch 上**没有复现**
|
||||
|
||||
同一份构建、同一串中文、同一字号,一次 wgpu 一次 tiny-skia:
|
||||
|
||||
| 图 | 后端 | 内容 |
|
||||
|---|---|---|
|
||||
| `DevDocs/artifacts/m187/ab-wgpu.png` | wgpu(Vulkan,AMD RX 6600 XT) | 四行:`text_role_owned` / `text_role` × 大字号 / 正文字号,逐字相同 |
|
||||
| `DevDocs/artifacts/m187/ab-tinyskia.png` | tiny-skia(`--software-renderer`) | 同上,与 wgpu 那张逐字一致 |
|
||||
| `DevDocs/artifacts/m187/probe-acc-wgpu.png` | wgpu | 账户页设备码面板(M190 钉的那一行「剩 14:59」在 wgpu 下**正常画出**) |
|
||||
|
||||
结论(只对这份构建、这台机器成立,不推广):`frame::text_role_owned` 与 `frame::text_role`
|
||||
产出的控件在小规模中文下**行为一致**,两种后端都能画;把缺陷归因到「owned 路径」在本 branch
|
||||
上没有证据支撑。要复现 M190 的现象,需要它当时的构建/环境。
|
||||
|
||||
## 3. 分工:哪些结论只信软渲染,哪些必须在 GPU 上看
|
||||
|
||||
| 断言类型 | 软渲染(tiny-skia)够吗 | 必须在 GPU 下复核 | 理由 |
|
||||
|---|---|---|---|
|
||||
| 版面几何(位置、宽高、折行、间距) | ✅ 够 | 建议一次 | 几何由布局树决定,两后端共用 |
|
||||
| 文字**在不在**(整行缺失 / 空块) | ⚠️ 只能证明 CPU 路径 | **必须** | GPU 字形图集是独立实现(`cryoglyph`) |
|
||||
| 字形形状 / 连字 / 每字形字体落点(「个别字换了字体」) | ⚠️ 同上 | **必须** | 两种后备字体选择可能不同 |
|
||||
| 颜色 / 对比度 / 主题令牌 | ✅ 够(纯色由令牌给) | 建议一次 | 面板混合色只有 GPU 路径可验 |
|
||||
| 材质层 / 高程阴影 | ⚠️ | **必须** | 阴影在 wgpu 与 tiny-skia 是两套画法 |
|
||||
| 整窗 α / 无边框圆角(Win32) | ❌ 不看 | **必须**(且只在 Windows) | 与渲染后端无关,靠 DWM 下发 |
|
||||
| 背景图 / 壁纸取色 / 图片缩放 | ⚠️ | **必须** | GPU 侧走纹理采样 |
|
||||
| SVG 图标(含描边宽度) | ✅ 够 | 建议一次 | 光栅化后端不同 |
|
||||
|
||||
读法:**「有没有画出来」与「字形对不对」这两类,只认 GPU 图**;几何与令牌只认一份即可。
|
||||
|
||||
## 4. 怎么在 GPU 下出图(本机命令)
|
||||
|
||||
```bash
|
||||
# GPU(wgpu;会自动挑适配器,日志里 [gpu] Selected: AdapterInfo{…} 是「真用上 GPU」的实证)
|
||||
unlucky-launcher-iced --screenshot out-gpu.png --screenshot-theme=light
|
||||
|
||||
# CPU(tiny-skia)
|
||||
unlucky-launcher-iced --screenshot out-cpu.png --screenshot-theme=light --software-renderer
|
||||
|
||||
# 两图逐像素比对(本程序自带,不需要 Python/PIL)
|
||||
unlucky-launcher-iced --diff out-gpu.png out-cpu.png
|
||||
```
|
||||
|
||||
`--diff` 的退出码:`0` 一致 / `1` 有差异 / `2` 参数错。**几何应当一致**(D30 的硬项),
|
||||
颜色与字形抗锯齿允许不同——所以「有差异」不等于坏,要**看着图**判。
|
||||
|
||||
启动失败弹窗的两态各两张(wgpu / tiny-skia)就是这么出的:
|
||||
|
||||
```bash
|
||||
ULL_DIALOG_FIXTURE=crash-report unlucky-launcher-iced --screenshot dialog-crash-wgpu.png
|
||||
ULL_DIALOG_FIXTURE=no-logs unlucky-launcher-iced --screenshot dialog-no-logs-wgpu.png
|
||||
```
|
||||
|
||||
## 5. 机器条件(写下来,免得下次怀疑「没 GPU」)
|
||||
|
||||
本机 `--renderer=auto` 的实测选择:`AMD Radeon RX 6600 XT` / Vulkan / `device_type: DiscreteGpu`。
|
||||
无独显的机器上 wgpu 会落到 `Microsoft Basic Render Driver`(CPU 实现,`device_type: Cpu`)——
|
||||
那种图**不能**算 GPU 证据(它仍是 CPU 光栅化,只是走了 DX12 接口)。
|
||||
判据:看 `[gpu] Selected:` 那行的 `device_type`,`Cpu` 一律不算。
|
||||
Reference in new issue
Block a user