feat(m187): 启动失败弹窗 + 诊断包导出,并接通 DialogQueue
用户实测「MC 启动失败找不到崩溃日志」的直接修法:失败时弹一个框,把退出码、 一句人话原因、证据形态、四条真实路径与三个按钮摆在同一个地方。 - app-iced:最小一层模态(遮罩 + 卡片 + 主/次按钮 + Esc/点遮罩关闭), 消费 presenter 的 DialogQueue(此前全仓零引用,所有弹窗都画不出来); 队列管弹窗,CrashCard 管这次失败的事实,按 DialogId 绑定。 - 弹窗区分两种失败:游戏自己写了 crash-reports(贴 Description + 栈片段)/ 游戏没来得及写(明说「这次没有生成崩溃报告」并贴出捕获的最后 30 行)/ 什么都没抓到(给最可能原因分类)。绝不对第二种说「找不到日志」。 - 导出诊断包:core 侧新 crash_bundle 写 zip(清单 + 启动器日志 + 会话 JSONL + crash-reports/* + hs_err + latest.log + 版本 JSON),落盘即脱敏(家目录 / token / 会话 id),单文件 4 MiB、总量 48 MiB 上限并在清单记截断。 - 启动器自身日志落盘(core::launch::launcher_log → <.ULL>/logs/launcher.log, 登记目录前先缓冲),补上「双击启动时诊断行直接蒸发」的缺口。 - i18n:25 条中英文案进 catalog/logs.rs + 金表。 - 取证:ULL_DIALOG_FIXTURE=crash-report|no-logs|export 在临时目录造真文件出图, wgpu 与 tiny-skia 各一套,见 DevDocs/artifacts/m187/。 未复现 M190 的「owned 文字在 wgpu 下丢字」:同一串中文两条文字路径、两种后端逐字相同 (ab-wgpu.png / ab-tinyskia.png),因此本轮未改任何渲染路径。
This commit is contained in:
1 parent
29da7e2e47
commit
414f6ff886
23 files changed
+2818
-10
No files matched your search
@@ -0,0 +1,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;
|
||||
Reference in new issue
Block a user