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

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

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

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

No files matched your search

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