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;
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 诊断包的断言:清单齐全、脱敏生效、缺件写明白、超大文件截断。
|
||||
//!
|
||||
//! 为什么这些值得单开文件:守卫的三条(内容有清单 / 脱敏生效 / 没 crash-report 也能导出)
|
||||
//! 都要**真读回 zip** 才作数——只断言 `write_bundle` 返回 `Ok` 抓不住任何一种坏法。
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{default_target_dir, write_bundle, BundleEntry, BundleSpec, MAX_ENTRY_BYTES};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建夹具目录");
|
||||
root
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建父目录");
|
||||
}
|
||||
std::fs::write(path, text).expect("写夹具");
|
||||
}
|
||||
|
||||
/// 打开 zip 读某个条目的全文(不存在返回 `None`)。
|
||||
fn entry_text(path: &Path, name: &str) -> Option<String> {
|
||||
let file = std::fs::File::open(path).expect("打开 zip");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
let mut entry = archive.by_name(name).ok()?;
|
||||
let mut text = String::new();
|
||||
entry.read_to_string(&mut text).expect("读条目");
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn names(path: &Path) -> Vec<String> {
|
||||
let file = std::fs::File::open(path).expect("打开 zip");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
(0..archive.len())
|
||||
.filter_map(|index| {
|
||||
let item = archive.by_index(index).ok()?;
|
||||
item.name().ok().map(|name| name.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spec(root: &Path, secrets: Vec<String>) -> BundleSpec {
|
||||
BundleSpec {
|
||||
instance: "1.20.4 · Fabric".into(),
|
||||
version: "1.20.4-fabric".into(),
|
||||
session: "0f8fad5b-d9cb-469f-a165-70867728950e".into(),
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".into(),
|
||||
uptime_ms: 12_345,
|
||||
command: "javac -Xmx4096m -Djava.library.path=<home>".into(),
|
||||
java: "C:/Java/bin/javaw.exe (major 21)".into(),
|
||||
system: "os=windows version=10.0 arch=x86_64".into(),
|
||||
notes: vec!["本次没有生成 crash-report".into()],
|
||||
missing: vec!["crash-reports/ 不存在".into()],
|
||||
entries: vec![
|
||||
BundleEntry::new("launcher", root.join("launcher.log")),
|
||||
BundleEntry::new("session", root.join("game").join("s1.jsonl")),
|
||||
],
|
||||
secrets,
|
||||
home: Some(root.join("home")),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_has_a_manifest_and_the_collected_files() {
|
||||
let root = temp_dir("manifest");
|
||||
write(&root.join("launcher.log"), "[launch] spawn argv=...\n");
|
||||
write(&root.join("game").join("s1.jsonl"), "{\"kind\":\"exit\"}\n");
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出");
|
||||
|
||||
assert!(bundle.bytes > 0, "落盘字节数不该是 0");
|
||||
assert_eq!(bundle.files, 2, "两个现场文件都该进包");
|
||||
assert_eq!(bundle.truncated, 0);
|
||||
|
||||
let listed = names(&target);
|
||||
assert!(listed.contains(&"manifest.txt".to_string()), "{listed:?}");
|
||||
assert!(
|
||||
listed.contains(&"launcher/launcher.log".to_string()),
|
||||
"{listed:?}"
|
||||
);
|
||||
assert!(
|
||||
listed.contains(&"session/s1.jsonl".to_string()),
|
||||
"{listed:?}"
|
||||
);
|
||||
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
// 清单要能把「发给别人看」需要的三件事说清:哪一场、什么退出、文件在哪。
|
||||
assert!(manifest.contains("instance: 1.20.4 · Fabric"), "{manifest}");
|
||||
assert!(manifest.contains("exit_code: 1"), "{manifest}");
|
||||
assert!(
|
||||
manifest.contains("exit_type: application_error"),
|
||||
"{manifest}"
|
||||
);
|
||||
assert!(manifest.contains("launcher/launcher.log <-"), "{manifest}");
|
||||
}
|
||||
|
||||
/// 守卫②:**脱敏真的生效**(主目录 / token / 会话 id 三条各自替换)。
|
||||
#[test]
|
||||
fn secrets_home_and_session_ids_are_scrubbed_in_every_entry() {
|
||||
let root = temp_dir("scrub");
|
||||
let home = root.join("home");
|
||||
let token = "eyJhbGciOiJIUzI1NiJ9.eyJ4IjoxMjN9.c2lnbmF0dXJlLXZhbHVl";
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
let dirty = format!(
|
||||
"argv=--accessToken {token}\npath={}\nsession={session}\n",
|
||||
home.join("AppData").display()
|
||||
);
|
||||
write(&root.join("launcher.log"), &dirty);
|
||||
write(&root.join("game").join("s1.jsonl"), &dirty);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.command = format!("java -accessToken {token}");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
assert_eq!(bundle.files, 2);
|
||||
|
||||
for name in ["manifest.txt", "launcher/launcher.log", "session/s1.jsonl"] {
|
||||
let text = entry_text(&target, name).unwrap_or_else(|| panic!("缺条目 {name}"));
|
||||
assert!(!text.contains(token), "{name} 漏了 token:{text}");
|
||||
assert!(!text.contains(session), "{name} 漏了会话 id:{text}");
|
||||
}
|
||||
let log = entry_text(&target, "launcher/launcher.log").expect("日志");
|
||||
assert!(log.contains("<home>"), "主目录没被替换:{log}");
|
||||
assert!(
|
||||
!log.contains(&home.display().to_string()),
|
||||
"家目录原文还在:{log}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 守卫③:**没有 crash-report 也能导出**,而且「没有」这件事写进清单(不许静默漏掉)。
|
||||
#[test]
|
||||
fn missing_crash_report_is_stated_in_the_manifest() {
|
||||
let root = temp_dir("missing");
|
||||
write(
|
||||
&root.join("game").join("s1.jsonl"),
|
||||
"{\"kind\":\"stderr\"}\n",
|
||||
);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出");
|
||||
assert_eq!(bundle.files, 1, "只有会话 JSONL 时也要出包");
|
||||
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("missing"), "{manifest}");
|
||||
assert!(manifest.contains("crash-reports/ 不存在"), "{manifest}");
|
||||
assert!(manifest.contains("本次没有生成 crash-report"), "{manifest}");
|
||||
// 清单里也要能看见那个「不存在的文件名」,否则用户以为我们忘收了。
|
||||
assert!(manifest.contains("session/s1.jsonl"), "{manifest}");
|
||||
}
|
||||
|
||||
/// 有上限:单个超限文件只收头部,并在清单里记一行「已截断」。
|
||||
#[test]
|
||||
fn oversized_entries_are_truncated_and_recorded() {
|
||||
let root = temp_dir("truncate");
|
||||
let big = root.join("game").join("latest.log");
|
||||
let body = "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32);
|
||||
write(&big, &body);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::new("game", &big)];
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数");
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("已截断"), "{manifest}");
|
||||
assert!(
|
||||
bundle.bytes < MAX_ENTRY_BYTES + 8 * 1024,
|
||||
"包不该把整个超限文件塞进去({} 字节)",
|
||||
bundle.bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_source_files_are_skipped_not_fatal() {
|
||||
let root = temp_dir("gone");
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::new("game", root.join("nope/absent.log"))];
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("缺文件也要出包");
|
||||
assert_eq!(bundle.files, 0);
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("读不出来"), "{manifest}");
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! 一次失败启动的**现场**(M187):路径 + 捕获输出 + 退出事实,供失败弹窗与诊断包共用。
|
||||
//!
|
||||
//! ## 为什么是「事实」而不是「结论」
|
||||
//!
|
||||
//! 弹窗要说三件事——**是哪一场失败**(装配阶段 / 起进程后崩溃)、**退出码**、**证据在哪**。
|
||||
//! 这三件都是启动器已经知道的事实,不含任何推断。崩溃原因的智能诊断是另一条线(诊断引擎
|
||||
//! `diagnose` / AI),本模块只把事实摆齐,让用户能一键把证据带走。
|
||||
//!
|
||||
//! ## 两种失败形态必须分开(用户投诉的原点)
|
||||
//!
|
||||
//! | 形态 | 判据 | 弹窗该说什么 |
|
||||
//! |---|---|---|
|
||||
//! | 游戏自己写了崩溃报告 | [`CrashReport::has_game_crash_report`] 为真 | 「游戏写出了崩溃报告」,贴 Description/栈片段 |
|
||||
//! | 游戏**没来得及写** | 没有 crash-report,但有捕获输出 | 「这次没有生成崩溃报告,下面是捕获的最后若干行」 |
|
||||
//! | 什么输出都没抓到 | 两者皆无 | 「游戏还没来得及写日志」+ 最可能原因分类(缺 Java / 内存 / 参数 / 库) |
|
||||
//!
|
||||
//! 第二、三种**不许**显示成「找不到日志」——日志在启动器这里,只是游戏没写它自己那份。
|
||||
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::crash_scan::{self, CrashArtifact, CrashSource};
|
||||
use super::launch_session::SessionHandle;
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
|
||||
pub const TAIL_LINES: usize = 30;
|
||||
/// 现场文件的扫描条数上限。
|
||||
pub const MAX_ARTIFACTS: usize = 8;
|
||||
/// 弹窗里贴崩溃报告摘要的字符上限。
|
||||
pub const MAX_EXCERPT_CHARS: usize = 1200;
|
||||
|
||||
/// 一个现场文件(来源 + 路径 + 人读摘要)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtifactFact {
|
||||
/// 来源 slug(`crash_report` / `hs_err` / `latest_log`)。
|
||||
pub source: String,
|
||||
/// 文件路径。
|
||||
pub path: PathBuf,
|
||||
/// 摘要(崩溃报告的 Description + 栈片段;`latest.log` 与 `hs_err` 不给)。
|
||||
pub excerpt: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次失败启动的现场。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CrashReport {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 绑定的版本 id。
|
||||
pub version: String,
|
||||
/// 会话 id(空 = 进程根本没起来)。
|
||||
pub session: String,
|
||||
/// 会话事件流 JSONL(**游戏侧证据的真正落点**)。
|
||||
pub session_log: Option<PathBuf>,
|
||||
/// 该实例绑定的版本 JSON(诊断包要带上它,别人才能复现装配)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 启动器自己的日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: Option<PathBuf>,
|
||||
/// 实例运行目录(`crash-reports/` 与 `logs/` 的父目录)。
|
||||
pub run_dir: Option<PathBuf>,
|
||||
/// 崩溃现场文件(按修改时间倒序)。
|
||||
pub artifacts: Vec<ArtifactFact>,
|
||||
/// 退出码(`None` = 进程没起来 / 平台没给)。
|
||||
pub exit_code: Option<i32>,
|
||||
/// 退出判定 slug(`application_error` / `jvm_error` / `normal` …;进程没起来时是 `spawn`)。
|
||||
pub exit_type: String,
|
||||
/// 存活毫秒数。
|
||||
pub uptime_ms: u64,
|
||||
/// 脱敏后的命令行一行。
|
||||
pub command: String,
|
||||
/// Java 一行说明(二进制 + 主版本)。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 装配阶段失败的 slug(`version_manifest` / `spawn` …);`None` = 进程起来过。
|
||||
pub stage: Option<String>,
|
||||
/// 装配阶段的原始错误文本(不翻译,可能含路径)。
|
||||
pub stage_detail: Option<String>,
|
||||
/// 启动器捕获的最后若干行(已脱敏、已截断到 [`TAIL_LINES`])。
|
||||
pub stderr_tail: Vec<String>,
|
||||
}
|
||||
|
||||
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Evidence {
|
||||
/// 游戏自己写了 `crash-reports/crash-*.txt`。
|
||||
GameCrashReport,
|
||||
/// 游戏没写崩溃报告,但启动器抓到了输出。
|
||||
CapturedOutput,
|
||||
/// 连输出都没抓到(JVM 都没起来 / 立刻失败)。
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
/// 稳定 slug(测试与日志用;不是文案)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::GameCrashReport => "game_crash_report",
|
||||
Self::CapturedOutput => "captured_output",
|
||||
Self::Nothing => "nothing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CrashReport {
|
||||
/// 游戏侧证据的形态(界面按它选文案,**不许**再自己判断)。
|
||||
#[must_use]
|
||||
pub fn evidence(&self) -> Evidence {
|
||||
if self.has_game_crash_report() {
|
||||
Evidence::GameCrashReport
|
||||
} else if self.stderr_tail.is_empty() {
|
||||
Evidence::Nothing
|
||||
} else {
|
||||
Evidence::CapturedOutput
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否找到游戏自己写的崩溃报告。
|
||||
#[must_use]
|
||||
pub fn has_game_crash_report(&self) -> bool {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.any(|a| a.source == CrashSource::CrashReport.as_str())
|
||||
}
|
||||
|
||||
/// 进程是否起来过(决定标题写「启动失败」还是「游戏崩溃」)。
|
||||
#[must_use]
|
||||
pub fn started(&self) -> bool {
|
||||
self.stage.is_none()
|
||||
}
|
||||
|
||||
/// 该实例运行目录下的 `crash-reports/`。
|
||||
#[must_use]
|
||||
pub fn crash_reports_dir(&self) -> Option<PathBuf> {
|
||||
self.run_dir.as_ref().map(|dir| dir.join("crash-reports"))
|
||||
}
|
||||
|
||||
/// 该实例的 `logs/latest.log`。
|
||||
#[must_use]
|
||||
pub fn latest_log_path(&self) -> Option<PathBuf> {
|
||||
self.run_dir
|
||||
.as_ref()
|
||||
.map(|dir| dir.join("logs").join("latest.log"))
|
||||
}
|
||||
|
||||
/// 供「复制路径」用的(标签, 路径)列表(提示词在前、目录在后)。
|
||||
#[must_use]
|
||||
pub fn path_lines(&self) -> Vec<(String, PathBuf)> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(log) = &self.session_log {
|
||||
lines.push(("session".to_string(), log.clone()));
|
||||
}
|
||||
if let Some(dir) = &self.launcher_logs {
|
||||
lines.push(("launcher_logs".to_string(), dir.clone()));
|
||||
}
|
||||
if let Some(path) = self.latest_log_path() {
|
||||
lines.push(("latest_log".to_string(), path));
|
||||
}
|
||||
if let Some(dir) = self.crash_reports_dir() {
|
||||
lines.push(("crash_reports".to_string(), dir));
|
||||
}
|
||||
if let Some(dir) = &self.run_dir {
|
||||
lines.push(("run_dir".to_string(), dir.clone()));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// 收集一次**已经起来过**的启动的现场。
|
||||
///
|
||||
/// `session` 由调用方在 `finish()` 之后给出;`lines` 是该会话的事件流行(**调用方读**,
|
||||
/// 因为读 JSONL 属于 host 的活)。只有最后 [`TAIL_LINES`] 行进弹窗。
|
||||
#[must_use]
|
||||
pub fn collect(session: &SessionHandle, ctx: &ReportContext, lines: &[String]) -> Self {
|
||||
let run_dir = ctx.run_dir.clone();
|
||||
let artifacts = crash_scan::scan(&run_dir, ctx.started_at, MAX_ARTIFACTS)
|
||||
.into_iter()
|
||||
.map(|artifact| fact(&artifact, &ctx.redactor()))
|
||||
.collect();
|
||||
|
||||
let raw: Vec<String> = lines.iter().rev().take(TAIL_LINES).rev().cloned().collect();
|
||||
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: session.session_id.clone(),
|
||||
session_log: Some(session.log_path.clone()),
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(session.exit.code),
|
||||
exit_type: session.exit.exit_type.as_str().to_string(),
|
||||
uptime_ms: session.uptime_ms,
|
||||
command: ctx
|
||||
.redactor()
|
||||
.scrub(session.redacted_display().as_str())
|
||||
.into_owned(),
|
||||
java: ctx.java.clone(),
|
||||
system: ctx.system.clone(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: scrub_tail(&raw, &ctx.redactor()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。
|
||||
///
|
||||
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
|
||||
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
|
||||
#[must_use]
|
||||
pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self {
|
||||
let redactor = ctx.redactor();
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: String::new(),
|
||||
session_log: None,
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(ctx.run_dir.clone()),
|
||||
artifacts: Vec::new(),
|
||||
exit_code: None,
|
||||
exit_type: exit_type.to_string(),
|
||||
uptime_ms: 0,
|
||||
command: String::new(),
|
||||
java: ctx.java.clone(),
|
||||
system: ctx.system.clone(),
|
||||
stage: Some(stage.to_string()),
|
||||
stage_detail: Some(redactor.scrub(detail).into_owned()),
|
||||
stderr_tail: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断包清单里要写明的「没有的东西」(**不许静默省略**)。
|
||||
#[must_use]
|
||||
pub fn missing_lines(&self) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
if !self.has_game_crash_report() {
|
||||
missing.push("crash-reports/crash-*.txt: 本次没有(游戏没来得及写)".to_string());
|
||||
}
|
||||
if let Some(path) = self.latest_log_path() {
|
||||
if !path.is_file() {
|
||||
missing.push(format!("logs/latest.log: 不存在({})", path.display()));
|
||||
}
|
||||
}
|
||||
if self.session_log.is_none() {
|
||||
missing.push("会话 JSONL: 不存在(进程没起来)".to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// 备注(清单里「这次发生了什么」那一段)。
|
||||
#[must_use]
|
||||
pub fn note_lines(&self) -> Vec<String> {
|
||||
let mut notes = match self.evidence() {
|
||||
Evidence::GameCrashReport => vec!["游戏写出了崩溃报告(crash-reports/)".to_string()],
|
||||
Evidence::CapturedOutput => {
|
||||
vec!["本次没有生成崩溃报告;证据是启动器捕获的输出".to_string()]
|
||||
}
|
||||
Evidence::Nothing => vec![
|
||||
"本次没有崩溃报告,也没捕获到输出(最可能:缺 Java / 内存不足 / 参数错误 / 库缺失)"
|
||||
.to_string(),
|
||||
],
|
||||
};
|
||||
if let Some(stage) = &self.stage {
|
||||
notes.push(format!("进程没有起来,失败在:{stage}"));
|
||||
}
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。
|
||||
///
|
||||
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
||||
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReportContext {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 实例运行目录。
|
||||
pub run_dir: PathBuf,
|
||||
/// 启动器日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: PathBuf,
|
||||
/// 本次会话的开始时刻(现场文件的时间闸)。
|
||||
pub started_at: std::time::SystemTime,
|
||||
/// 该实例绑定的版本 JSON。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// Java 一行说明。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 要额外抹掉的串(会话 id、access token 等)。
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
instance: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
run_dir: impl Into<PathBuf>,
|
||||
launcher_logs: impl Into<PathBuf>,
|
||||
started_at: std::time::SystemTime,
|
||||
system: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance: instance.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
run_dir: run_dir.into(),
|
||||
launcher_logs: launcher_logs.into(),
|
||||
started_at,
|
||||
version_json: None,
|
||||
java: String::new(),
|
||||
system: system.into(),
|
||||
secrets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| redactor.scrub(line).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
||||
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
||||
let scrubbed = redactor.scrub(text);
|
||||
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
||||
trimmed
|
||||
});
|
||||
ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path.clone(),
|
||||
excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
||||
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
||||
pub fn publish(report: CrashReport) {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = Some(report);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读最近一次失败启动的现场。
|
||||
#[must_use]
|
||||
pub fn failing_launch() -> Option<CrashReport> {
|
||||
slot().lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// 清空槽位(测试用)。
|
||||
pub fn clear() {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 现场事实的断言:三态证据分类、脱敏、缺件写明、槽位读写。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
|
||||
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
|
||||
CrashReport {
|
||||
instance: "survival".into(),
|
||||
name: "生存".into(),
|
||||
version: "1.20.4".into(),
|
||||
session: "0f8fad5b".into(),
|
||||
session_log: Some(PathBuf::from("/sessions/game/0f8fad5b.jsonl")),
|
||||
version_json: None,
|
||||
launcher_logs: Some(PathBuf::from("/.ULL/logs")),
|
||||
run_dir: Some(PathBuf::from("/instances/survival")),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".into(),
|
||||
uptime_ms: 12_000,
|
||||
command: "javaw -Xmx4096m".into(),
|
||||
java: "javaw.exe (21)".into(),
|
||||
system: "os=windows".into(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_tells_the_three_shapes_apart() {
|
||||
let with_report = report(
|
||||
vec![ArtifactFact {
|
||||
source: "crash_report".into(),
|
||||
path: PathBuf::from("/instances/survival/crash-reports/crash-1.txt"),
|
||||
excerpt: Some("Description: Ticking entity".into()),
|
||||
}],
|
||||
vec!["Exception in thread main".into()],
|
||||
);
|
||||
assert_eq!(with_report.evidence(), Evidence::GameCrashReport);
|
||||
assert_eq!(with_report.evidence().slug(), "game_crash_report");
|
||||
|
||||
let stderr_only = report(Vec::new(), vec!["Error: could not find java.dll".into()]);
|
||||
assert_eq!(
|
||||
stderr_only.evidence(),
|
||||
Evidence::CapturedOutput,
|
||||
"没有 crash-report 但有捕获输出 ⇒ 不许说「找不到日志」"
|
||||
);
|
||||
|
||||
let nothing = report(Vec::new(), Vec::new());
|
||||
assert_eq!(nothing.evidence(), Evidence::Nothing);
|
||||
|
||||
// 三种 slug 必须互不相同(界面按它选 key,撞了就会显示错的一句)。
|
||||
let slugs = [
|
||||
with_report.evidence().slug(),
|
||||
stderr_only.evidence().slug(),
|
||||
nothing.evidence().slug(),
|
||||
];
|
||||
let mut unique = slugs.to_vec();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), 3, "{slugs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_crash_report_is_listed_not_silently_dropped() {
|
||||
let report = report(Vec::new(), vec!["line".into()]);
|
||||
let missing = report.missing_lines().join("\n");
|
||||
assert!(missing.contains("crash-reports"), "{missing}");
|
||||
assert!(missing.contains("游戏没来得及写"), "{missing}");
|
||||
assert!(
|
||||
!missing.contains("会话 JSONL"),
|
||||
"会话在,不该说没会话:{missing}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_name_the_fallback_reason_classification() {
|
||||
let report = report(Vec::new(), Vec::new());
|
||||
let notes = report.note_lines().join("\n");
|
||||
assert!(notes.contains("缺 Java"), "{notes}");
|
||||
assert!(notes.contains("内存不足"), "{notes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() {
|
||||
let home = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.unwrap_or_default();
|
||||
if home.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx = ReportContext::new(
|
||||
"survival",
|
||||
"生存",
|
||||
"1.20.4",
|
||||
std::path::Path::new("/instances/survival"),
|
||||
std::path::Path::new("/.ULL/logs"),
|
||||
std::time::SystemTime::now(),
|
||||
"os=windows",
|
||||
);
|
||||
let report = CrashReport::before_start(
|
||||
&ctx,
|
||||
"spawn",
|
||||
&format!("{home}/java/bin/javaw.exe 不存在"),
|
||||
"spawn",
|
||||
);
|
||||
assert!(!report.started(), "进程没起来");
|
||||
assert_eq!(report.evidence(), Evidence::Nothing);
|
||||
let detail = report.stage_detail.expect("阶段原文");
|
||||
assert!(detail.contains("<home>"), "{detail}");
|
||||
assert!(!detail.contains(&home), "主目录原文漏了:{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_keeps_the_last_failure_only() {
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
publish(report(Vec::new(), vec!["first".into()]));
|
||||
publish(report(Vec::new(), vec!["second".into()]));
|
||||
let last = failing_launch().expect("最近一次");
|
||||
assert_eq!(last.stderr_tail, vec!["second".to_string()]);
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 启动器**自己的**运行日志(M187):`<.ULL>/logs/launcher.log`。
|
||||
//!
|
||||
//! ## 为什么要有这个文件
|
||||
//!
|
||||
//! 用户实测(2026-11-08):「游戏崩溃后只有一个安装日志,没有崩溃日志」。核查结论是:
|
||||
//! `logs/` 分区里此前只有 `install.log`(安装流水)与 auth journal——**启动器自己没有运行期
|
||||
//! 日志**。双击启动的 GUI 进程没有 stderr 去向,所有 `eprintln!` 诊断当场蒸发,于是
|
||||
//! 「启动器视角发生了什么」在事故之后无法复原。
|
||||
//!
|
||||
//! 本模块只做一件事:把一行行 `[scope] message` 追加到一个文件里,**落盘即脱敏**。
|
||||
//!
|
||||
//! ## 三条约束(都是有意的)
|
||||
//!
|
||||
//! 1. **best-effort**:写不进去就静默放过。日志失败不该让启动器崩、也不该弹提示。
|
||||
//! 2. **不阻塞**:不建后台线程、不做异步队列。调用点都是「一次启动 / 一次导出」这种低频事件,
|
||||
//! 每次一行 `write_all` 的代价可以忽略;引入通道反而多一处要收尾的生命周期。
|
||||
//! 3. **有上限**:超过 [`MAX_BYTES`] 就滚成 `launcher.1.log`(只保留一份历史)。日志文件
|
||||
//! 无限长会让「导出诊断包」变成搬砖。
|
||||
//!
|
||||
//! ## 目录从哪来
|
||||
//!
|
||||
//! 目录**不是**本模块自己解析的:`.ULL` 的落点由 `platform::data_dir` 的三模式决定(导出的
|
||||
//! exe 目录 / 标准目录 / 自定义根),解析它需要读配置——那是宿主(presenter / app)已经做过
|
||||
//! 的事。所以这里是「**先到者登记**」:知道真相的调用方用 [`set_dir`] 登记,
|
||||
//! 登记之前 [`record`] 的行进内存缓冲(最多 [`MAX_BUFFERED`] 行),登记时一次刷出去。
|
||||
//! 这样启动早期(字体 / GPU / 路由)那几行也不会丢。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 日志文件名。
|
||||
pub const FILE_NAME: &str = "launcher.log";
|
||||
/// 单个文件上限(超过就滚成 `launcher.1.log`,只留一份历史)。
|
||||
pub const MAX_BYTES: u64 = 1024 * 1024;
|
||||
/// 登记目录之前最多缓冲多少行。
|
||||
pub const MAX_BUFFERED: usize = 200;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
dir: Option<PathBuf>,
|
||||
buffer: Vec<String>,
|
||||
}
|
||||
|
||||
fn state() -> &'static Mutex<State> {
|
||||
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
|
||||
STATE.get_or_init(|| Mutex::new(State::default()))
|
||||
}
|
||||
|
||||
/// 登记日志目录(`<.ULL>/logs`)。重复登记时以**最后一次**为准(数据根可能被 `redirect.json` 改)。
|
||||
pub fn set_dir(logs_dir: impl Into<PathBuf>) {
|
||||
let dir = logs_dir.into();
|
||||
let mut guard = match state().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
if guard.dir.as_ref() == Some(&dir) {
|
||||
return;
|
||||
}
|
||||
guard.dir = Some(dir);
|
||||
let buffered = std::mem::take(&mut guard.buffer);
|
||||
let Some(directory) = guard.dir.clone() else {
|
||||
return;
|
||||
};
|
||||
drop(guard);
|
||||
for line in buffered {
|
||||
append(&directory, &line);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前日志文件路径(`None` = 还没登记目录)。
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let guard = state().lock().ok()?;
|
||||
guard.dir.as_ref().map(|dir| dir.join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// 记一行(`[scope] message`)。scope 是稳定短的英文标签(`boot` / `launch` / `dialog`)。
|
||||
///
|
||||
/// 时间戳走 `launch::time::now()`(与事件流同一份时钟口径),所以日志行与 JSONL 能对上。
|
||||
pub fn record(scope: &str, message: &str) {
|
||||
let line = format!(
|
||||
"{} [{scope}] {message}",
|
||||
super::time::format_rfc3339_millis(super::time::now())
|
||||
);
|
||||
let dir = {
|
||||
let mut guard = match state().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
match guard.dir.clone() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
if guard.buffer.len() < MAX_BUFFERED {
|
||||
guard.buffer.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
append(&dir, &line);
|
||||
}
|
||||
|
||||
/// 脱敏器:主目录换成 `<home>`(同一份规则见 [`super::redact`])。
|
||||
fn redactor() -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
fn append(dir: &Path, line: &str) {
|
||||
let path = dir.join(FILE_NAME);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// 滚动:只在超过上限时做一次 rename,平时的路径只有「打开 → 追加 → 关」。
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > MAX_BYTES) {
|
||||
let _ = std::fs::rename(&path, dir.join("launcher.1.log"));
|
||||
}
|
||||
let scrubbed = redactor().scrub(line).into_owned();
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = file.write_all(scrubbed.as_bytes());
|
||||
let _ = file.write_all(b"\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn state_reset() {
|
||||
if let Ok(mut guard) = state().lock() {
|
||||
guard.dir = None;
|
||||
guard.buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_BUFFERED;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 测试之间共享全局状态,必须串行(`cargo test` 默认多线程)。
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-log-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建夹具目录");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_lines_are_flushed_once_the_dir_is_known() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
let dir = temp_dir("flush");
|
||||
|
||||
super::record("boot", "before the dir was known");
|
||||
assert!(super::path().is_none(), "没登记目录时不该报出路径");
|
||||
super::set_dir(&dir);
|
||||
super::record("launch", "after");
|
||||
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
assert!(text.contains("before the dir was known"), "{text}");
|
||||
assert!(text.contains("[launch] after"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_paths_are_scrubbed_on_write() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
let dir = temp_dir("scrub");
|
||||
super::set_dir(&dir);
|
||||
let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME"));
|
||||
let Ok(home) = home else {
|
||||
return;
|
||||
};
|
||||
super::record("launch", &format!("argv --nativesDir {home}/natives"));
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
assert!(text.contains("<home>"), "{text}");
|
||||
assert!(!text.contains(home.as_str()), "家目录原文还在:{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_is_bounded_before_the_dir_is_known() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
for index in 0..(MAX_BUFFERED + 50) {
|
||||
super::record("boot", &format!("line {index}"));
|
||||
}
|
||||
let dir = temp_dir("bounded");
|
||||
super::set_dir(&dir);
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
let lines = text.lines().count();
|
||||
assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行");
|
||||
assert!(lines > 0);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ pub mod adapter;
|
||||
pub mod args_table;
|
||||
pub mod argument;
|
||||
pub mod command_builder;
|
||||
pub mod crash_bundle;
|
||||
pub mod crash_report;
|
||||
pub mod crash_scan;
|
||||
pub mod defaults;
|
||||
pub mod diagnosis_hook;
|
||||
@@ -38,6 +40,7 @@ pub mod game_version;
|
||||
pub mod jvm_args;
|
||||
pub mod launch_session;
|
||||
pub mod launcher;
|
||||
pub mod launcher_log;
|
||||
pub mod log_level;
|
||||
pub mod natives;
|
||||
pub mod overrides;
|
||||
@@ -62,6 +65,7 @@ pub use adapter::{
|
||||
build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
pub use args_table::{assemble, AssembledCommand, AssemblyWarning};
|
||||
pub use crash_report::{ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
pub use crash_scan::{CrashArtifact, CrashSource};
|
||||
pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis};
|
||||
pub use error::{LaunchError, MissingComponent, Result};
|
||||
|
||||
Reference in new issue
Block a user