用户实测「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),因此本轮未改任何渲染路径。
158 lines
5.6 KiB
Rust
158 lines
5.6 KiB
Rust
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
|
|
//!
|
|
//! 与 [`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")
|
|
}
|