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

+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);
}
}