用户报「CI 构建版启动时底下闪一个控制台」:M213 统一了 crates/core/src 的 20 处 Command::new,但守卫射程只有 core/src,app-iced 的 reg.exe(每次 GUI 启动都跑)漏网。 - crates/core/tests/child_quiet_guard.rs:扫描根 crates/core/src → crates/(1219 个 .rs), 角色清单改成相对 crates/ 的路径(文件角色钉精确处数,目录角色至少一处); 「扫不到文件」兜底断言保留,另加角色文件锚点兜底;顺带修掉全树扫描会撞上的 「纯 //! 结论页没有代码行」误判 - app-iced:platform/locale.rs 的 reg.exe、app/update/update_action.rs、 app/boot/fallback.rs、app/boot/termux_window.rs 的 xdpyinfo、 renderer/acceptance/locale.rs 的 defaults 全部改走 quiet(后三处是同一 exe / POSIX 上的 pass-through,为的是规则整体可被守卫钉住) - app/dialog/export.rs 的 explorer / open / xdg-open 保持裸构造并写清「窗口就是产品」, presenter/src/host/opener.rs 同理登记成角色(不在本 mission 改动范围) 门禁:fmt/check --workspace --all-targets/test --workspace -j 4/clippy --workspace --all-targets -D warnings/全仓 400 行 + <2 字节 .rs 扫描,六步退出码全 0,CHAIN_EXIT=0。
218 lines
8.5 KiB
Rust
218 lines
8.5 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;
|
||
|
||
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) struct Exported {
|
||
/// 给用户看的那一句(已本地化)。
|
||
pub text: String,
|
||
/// 是否成功。
|
||
pub ok: bool,
|
||
/// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。
|
||
pub truncated: usize,
|
||
}
|
||
|
||
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
|
||
///
|
||
/// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」);
|
||
/// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。
|
||
pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported {
|
||
let catalog = Catalog::of(lang);
|
||
let Some(logs) = &report.launcher_logs else {
|
||
return Exported {
|
||
text: catalog.render(
|
||
Key::CrashDialogExportFailed,
|
||
&[("error", "no launcher log dir")],
|
||
),
|
||
ok: false,
|
||
truncated: 0,
|
||
};
|
||
};
|
||
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) => {
|
||
let mut text = catalog.render(
|
||
Key::CrashDialogExportDone,
|
||
&[
|
||
("bytes", &bundle.bytes.to_string()),
|
||
("path", &bundle.path.display().to_string()),
|
||
],
|
||
);
|
||
if bundle.truncated > 0 {
|
||
text.push('\n');
|
||
text.push_str(&catalog.render(
|
||
Key::CrashDialogExportTruncated,
|
||
&[("count", &bundle.truncated.to_string())],
|
||
));
|
||
}
|
||
Exported {
|
||
text,
|
||
ok: true,
|
||
truncated: bundle.truncated,
|
||
}
|
||
}
|
||
Err(error) => Exported {
|
||
text: catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
|
||
ok: false,
|
||
truncated: 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
|
||
fn spec_of(report: &CrashReport) -> BundleSpec {
|
||
let mut entries: Vec<BundleEntry> = Vec::new();
|
||
let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
|
||
if path.exists() {
|
||
entries.push(BundleEntry::new(group, path));
|
||
}
|
||
};
|
||
if let Some(logs) = &report.launcher_logs {
|
||
push(&mut entries, "launcher", &logs.join("launcher.log"));
|
||
push(&mut entries, "launcher", &logs.join("launcher.1.log"));
|
||
push(&mut entries, "launcher", &logs.join("install.log"));
|
||
}
|
||
if let Some(session) = &report.session_log {
|
||
// 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id,
|
||
// 也不会出现「清单写 <redacted:token> 而包里是原始 id」(r1 复审 P2-2)。
|
||
if session.exists() {
|
||
entries.push(BundleEntry::named("session", session, "session.jsonl"));
|
||
}
|
||
}
|
||
for artifact in &report.artifacts {
|
||
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
|
||
if artifact.source != "latest_log" {
|
||
push(&mut entries, "game", &artifact.path);
|
||
}
|
||
}
|
||
if let Some(latest) = report.latest_log_path() {
|
||
push(&mut entries, "game", &latest);
|
||
}
|
||
if let Some(json) = &report.version_json {
|
||
push(&mut entries, "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 open_dir(&dir) {
|
||
Ok(()) => String::new(),
|
||
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
|
||
}
|
||
}
|
||
|
||
/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。
|
||
///
|
||
/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。
|
||
pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
|
||
if !dir.is_dir() {
|
||
return Err(format!("{}(目录不存在)", dir.display()));
|
||
}
|
||
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
|
||
}
|
||
|
||
/// 平台各自的「打开这个目录」实现。
|
||
///
|
||
/// ## 为什么这三处**故意**不走 `unlucky_core::platform::child::quiet`(M217)
|
||
///
|
||
/// 这一处 spawn 的**目的就是让桌面上出现一个可见的东西**(文件管理器窗口 / Finder 窗口 /
|
||
/// 桌面环境的文件管理器)。`quiet` 的语义是「把子进程的窗口抑制掉」,用在这里是把目的本身
|
||
/// 抑制掉——改错了用户点「打开文件夹」就只看到「打不开」的报错。
|
||
///
|
||
/// 而且从窗口角度也不需要它:`explorer.exe` / `open` / `xdg-open` 自己都是 GUI 侧程序,由
|
||
/// 无控制台的 GUI 父进程 spawn 时 Windows 不会给它们新建控制台。所以这三处在
|
||
/// `crates/core/tests/child_quiet_guard.rs` 的 `ALLOWED_ROLES` 里登记成「目录打开器」角色,
|
||
/// **不是**漏网。别把它们改成 `quiet`。
|
||
#[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")
|
||
}
|