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

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