//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。 //! //! 与 [`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 = 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::>() .join("\n") }