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

+70 -8
View File
@@ -17,6 +17,8 @@ use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
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::time::{format_rfc3339_millis, now};
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 tx_progress = tx.clone();
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 {
id: id.clone(),
runtime: InstanceRuntime::Busy {
@@ -226,6 +241,9 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let (request, warnings) = match build_request(&env, &target) {
Ok(ok) => ok,
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 {
id,
session: String::new(),
@@ -237,6 +255,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
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 {
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
&format!("launch-warn:{}", target.id),
@@ -245,21 +270,33 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
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())
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let session = match Launcher::new(config).spawn(&request) {
Ok(session) => session,
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 {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
error.to_string(),
)),
failure: Some(failure),
})));
return;
}
@@ -278,6 +315,22 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
match session.finish() {
Ok(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 {
id,
session: session_id,
@@ -289,22 +342,31 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
})));
}
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 {
id,
session: session_id,
outcome: LogOutcome::SilentFail,
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
error.to_string(),
)),
failure: Some(failure),
})));
}
}
});
}
/// 登记一次「进程没起来」的现场(四个失败出口共用这一处,避免各写一遍 exit_type)。
fn publish_before_start(report: &ReportContext, stage: &str, detail: &str) {
crash_report::publish(CrashReport::before_start(report, stage, detail, stage));
}
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
pub fn stop(pid: u32) -> Result<(), String> {
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))