fix(m187): r1 复审——深色遮罩提亮、点遮罩关闭、包内条目名、截断保尾、启动日志接线
P1(阻断):遮罩从 Role::OnSurface@32% 换成组件层的不透明预合成 (feedback::dialog::scrim,黑@0.32 叠画布)。深色档此前是「近白@32%」把整窗提亮, 实测 (9,21,28)→(129,138,145);现在 (6,14,19)(= 组件文档预测值),且两后端逐字节相同。 顺带按纪律改用 Component::Dialog + DialogGeometry(半径 28 / 宽 560 / 内距 24), 两后端弹窗图差异从 66% 降到 2.65%(浅)/ 2.76%(深),余量全是文字抗锯齿。 P2-1:卡片那层的命中区改回卡片本身(opaque 只包卡片)。此前 mouse_area 包的是整窗居中 容器 ⇒ 点卡片外先被 Swallow 捕获,遮罩永远收不到,「点遮罩关闭」只在注释里成立。 守卫:断言卡片层的 size_hint 任一维都不是 Fill。 P2-2:条目名与清单**同源**(BundleSpec::entry_names),会话 JSONL 钉成 session/session.jsonl。 此前条目名用原始文件名(会话 id 原样进包)而 manifest 过了 redactor ⇒ 清单指向的条目不存在。 (上一轮报告把这条写反了,本轮以 unzip -l 实输出为准。) P2-3:截断改「头 + 尾」(MAX_ENTRY_BYTES 一半一半,中间插 <truncated N bytes>), 并在弹窗补一句「有 N 个文件太大,已截断」。此前只留头部 ⇒ 长会话 JSONL 的 exit 事件丢失。 P2-4:app 启动那一拍(主题/语言/路由/窗口/实例数/msa 行)真的写进 launcher_log; 目录仍由 presenter 在首次启动时登记,所以这些行先进 core 缓冲、登记时一次刷出。 P3-1/P3-2:关闭与 Esc 立即重画(否则空闲 1Hz 下弹窗再留 1 秒);导出成败按 write_bundle 真实结果判(不再看 launcher_logs 有没有值)。 用例:新增 5 条(深色遮罩方向、卡片层命中区、条目名与清单一致、截断保尾、启动日志落点), 每条都给了变异证明(只改实现侧,均已还原)。
This commit is contained in:
1 parent
414f6ff886
commit
3cd371b537
11 files changed
+513
-112
No files matched your search
@@ -26,8 +26,13 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 单个文件最多收多少字节(超出只收头部,清单里记「已截断」)。
|
||||
/// 单个文件最多收多少字节(超出按**头 + 尾**截断,清单里记「已截断」)。
|
||||
///
|
||||
/// 为什么不是「只收头部」:崩溃证据在**尾部**——会话 JSONL 的最后几行是 `exit` 事件与
|
||||
/// stderr,`latest.log` 的栈也在尾。只留头部的包等于「证据是残的,而用户不知道」。
|
||||
pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// 截断时头部保留的字节(另一半点给尾部)。
|
||||
pub const HEAD_BYTES: u64 = MAX_ENTRY_BYTES / 2;
|
||||
/// 整个包最多收多少字节(超出后剩余文件只记清单、不收内容)。
|
||||
pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024;
|
||||
|
||||
@@ -38,14 +43,30 @@ pub struct BundleEntry {
|
||||
pub group: &'static str,
|
||||
/// 源文件绝对路径。
|
||||
pub path: PathBuf,
|
||||
/// 包内固定文件名(`None` = 用源文件名)。
|
||||
///
|
||||
/// 为什么要能钉死:会话 JSONL 的源文件名就是**会话 id**(`<uuid>.jsonl`),
|
||||
/// 拿它当条目名要么泄漏 id、要么让「清单里的名字」与「包里的名字」对不上。钉成
|
||||
/// `session.jsonl` 两个问题一起消失(见 [`BundleSpec::entry_names`])。
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl BundleEntry {
|
||||
/// 建一条。
|
||||
/// 建一条(包内名 = 源文件名)。
|
||||
pub fn new(group: &'static str, path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
group,
|
||||
path: path.into(),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一条并**钉死包内文件名**(会话 JSONL、`latest.log` 这类有唯一身份的用它)。
|
||||
pub fn named(group: &'static str, path: impl Into<PathBuf>, name: &str) -> Self {
|
||||
Self {
|
||||
group,
|
||||
path: path.into(),
|
||||
name: Some(name.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +106,22 @@ pub struct BundleSpec {
|
||||
|
||||
impl BundleSpec {
|
||||
/// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。
|
||||
///
|
||||
/// 这是「自算条目名」的包装(测试与单独看清单用);真正落盘时用的是
|
||||
/// [`Self::header`] + 收集循环里长出来的行,保证与包内条目一字不差。
|
||||
#[must_use]
|
||||
pub fn manifest(&self) -> String {
|
||||
let rows: Vec<String> = self
|
||||
.entry_names()
|
||||
.iter()
|
||||
.zip(&self.entries)
|
||||
.map(|(name, entry)| format!("- {name} <- {}", entry.path.display()))
|
||||
.collect();
|
||||
self.header(&rows)
|
||||
}
|
||||
|
||||
/// 清单的**事实段 + files 段**(files 段由调用方给,行内已是最终条目名)。
|
||||
fn header(&self, rows: &[String]) -> String {
|
||||
let mut text = String::new();
|
||||
text.push_str("Unlucky Launcher — launch failure bundle\n");
|
||||
text.push_str("=======================================\n");
|
||||
@@ -118,17 +154,33 @@ impl BundleSpec {
|
||||
}
|
||||
|
||||
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()
|
||||
));
|
||||
// 清单里的名字**必须**与包里的条目一一对应(r1 复审 P2-2 就是这里对不上):
|
||||
// 两边都走同一份解析(`rows` 由调用方给出,行内已是最终条目名):
|
||||
// 清单与 zip 不许各算一遍 —— 各算一遍就是 r1 复审 P2-2。
|
||||
for row in rows {
|
||||
text.push_str(row);
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// 包内条目名(与 `entries` 一一对应;**名字也过脱敏**)。
|
||||
///
|
||||
/// 名字与内容分开治:内容走 `redactor` 的整套规则,名字走同一份 redactor 的 token 规则。
|
||||
/// **两边都以返回值为准**——清单与 zip 都读这一份,不许各算一遍(各算一遍就是 P2-2)。
|
||||
#[must_use]
|
||||
pub fn entry_names(&self) -> Vec<String> {
|
||||
let redactor = self.redactor();
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let raw = entry.name.clone().unwrap_or_else(|| file_name(&entry.path));
|
||||
let safe = redactor.scrub(&raw).into_owned();
|
||||
format!("{}/{}", entry.group, safe)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
if let Some(home) = &self.home {
|
||||
@@ -186,35 +238,39 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String>
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
let mut manifest = spec.manifest();
|
||||
// 截断与缺失在**收集时**才知道,所以清单最后再补一段。
|
||||
let mut collected = Vec::new();
|
||||
// 名字与清单**先在同一个循环里长出来**:`files` 段只列真进了包的条目,
|
||||
// 谁被跳过、谁被截断都在 `collected` 段写明(r1 复审 P2-2 的修法)。
|
||||
let names = spec.entry_names();
|
||||
let mut listed: Vec<String> = Vec::new();
|
||||
let mut collected: Vec<String> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
let mut files = 0usize;
|
||||
let mut truncated = 0usize;
|
||||
let mut used_names: Vec<String> = Vec::new();
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
|
||||
for entry in &spec.entries {
|
||||
let name = unique_name(&mut used_names, entry.group, &file_name(&entry.path));
|
||||
for (index, entry) in spec.entries.iter().enumerate() {
|
||||
let name = unique_name(&mut used, &names[index]);
|
||||
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 {
|
||||
let budget = MAX_TOTAL_BYTES.saturating_sub(total).min(MAX_ENTRY_BYTES);
|
||||
if body.len() as u64 > budget {
|
||||
let clipped = clip(&body, budget);
|
||||
if clipped.is_empty() {
|
||||
collected.push(format!("- {name}: 超出总量上限,未收内容"));
|
||||
truncated += 1;
|
||||
continue;
|
||||
}
|
||||
body.truncate(keep);
|
||||
body = clipped;
|
||||
collected.push(format!(
|
||||
"- {name}: 已截断(只收前 {keep} 字节,原 {original_len} 字节)"
|
||||
"- {name}: 已截断(收头 {} B + 尾 {} B,原 {original_len} B)",
|
||||
HEAD_BYTES.min(original_len as u64),
|
||||
MAX_ENTRY_BYTES
|
||||
.saturating_sub(HEAD_BYTES)
|
||||
.min(original_len as u64)
|
||||
));
|
||||
truncated += 1;
|
||||
}
|
||||
@@ -224,9 +280,11 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String>
|
||||
}
|
||||
total += body.len() as u64;
|
||||
files += 1;
|
||||
listed.push(format!("- {name} <- {}", entry.path.display()));
|
||||
write_entry(&mut zip, &name, &body, options)?;
|
||||
}
|
||||
|
||||
let mut manifest = spec.header(&listed);
|
||||
if !collected.is_empty() {
|
||||
manifest.push_str("\ncollected\n---------\n");
|
||||
for line in collected {
|
||||
@@ -272,15 +330,16 @@ fn file_name(path: &Path) -> String {
|
||||
.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}");
|
||||
/// 包内重名时在**目录名后**加序号(`game/latest.log` 与 `game/2-latest.log` 不会互相覆盖)。
|
||||
fn unique_name(used: &mut Vec<String>, name: &str) -> String {
|
||||
let base = name.to_string();
|
||||
if !used.contains(&base) {
|
||||
used.push(base.clone());
|
||||
return base;
|
||||
}
|
||||
let (group, file) = base.split_once('/').unwrap_or(("", base.as_str()));
|
||||
for index in 2.. {
|
||||
let candidate = format!("{group}/{index}-{name}");
|
||||
let candidate = format!("{group}/{index}-{file}");
|
||||
if !used.contains(&candidate) {
|
||||
used.push(candidate.clone());
|
||||
return candidate;
|
||||
@@ -289,6 +348,34 @@ fn unique_name(used: &mut Vec<String>, group: &str, name: &str) -> String {
|
||||
unreachable!("序号一定找得到空位")
|
||||
}
|
||||
|
||||
/// 头 + 尾截断:保留前 [`HEAD_BYTES`] 与后 `budget - HEAD_BYTES` 字节,中间插一行说明。
|
||||
///
|
||||
/// 为什么是头 + 尾:崩溃证据在**尾部**(会话 JSONL 的 `exit` 事件与最后几条 stderr、
|
||||
/// `latest.log` 的栈),只留头部会把最有用的那几行丢掉(r1 复审 P2-3)。
|
||||
fn clip(body: &[u8], budget: u64) -> Vec<u8> {
|
||||
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
|
||||
if body.len() <= budget || budget == 0 {
|
||||
return body.to_vec();
|
||||
}
|
||||
let head = usize::try_from(HEAD_BYTES)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(budget / 2);
|
||||
let tail = budget.saturating_sub(head);
|
||||
let dropped = body.len().saturating_sub(head + tail);
|
||||
let mut out = Vec::with_capacity(head + tail + 64);
|
||||
out.extend_from_slice(&body[..head]);
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"
|
||||
<truncated {dropped} bytes>
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(&body[body.len() - tail..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// 默认落点:启动器日志分区下的 `exports/`。
|
||||
///
|
||||
/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope),
|
||||
|
||||
@@ -189,3 +189,110 @@ fn missing_source_files_are_skipped_not_fatal() {
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("读不出来"), "{manifest}");
|
||||
}
|
||||
|
||||
/// P2-2:**包内条目名与清单里的名字必须一字不差**,且名字也要过脱敏。
|
||||
///
|
||||
/// 抓得住:r1 复审抓到的原 bug —— 条目名用原始文件名(会话 id 原样泄漏),而 manifest 过了
|
||||
/// redactor(写 `<redacted:token>`)⇒ 清单指向的条目在包里不存在。
|
||||
#[test]
|
||||
fn entry_names_are_scrubbed_and_match_the_manifest_exactly() {
|
||||
let root = temp_dir("names");
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
write(
|
||||
&root.join("game").join(format!("{session}.jsonl")),
|
||||
"{}
|
||||
",
|
||||
);
|
||||
write(
|
||||
&root.join("launcher.log"),
|
||||
"boot
|
||||
",
|
||||
);
|
||||
|
||||
// 第三条**不钉名**:它的源文件名里带会话 id,靠 `entry_names` 的脱敏兜住。
|
||||
write(
|
||||
&root.join("game").join(format!("hs-{session}.log")),
|
||||
"x
|
||||
",
|
||||
);
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.entries = vec![
|
||||
BundleEntry::new("launcher", root.join("launcher.log")),
|
||||
// 源文件名就是会话 id:钉成 session.jsonl(调用方 app-iced 的做法)
|
||||
BundleEntry::named(
|
||||
"session",
|
||||
root.join("game").join(format!("{session}.jsonl")),
|
||||
"session.jsonl",
|
||||
),
|
||||
BundleEntry::new("game", root.join("game").join(format!("hs-{session}.log"))),
|
||||
];
|
||||
let target = default_target_dir(&root).join("names.zip");
|
||||
write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
let listed = names(&target);
|
||||
assert!(
|
||||
listed.iter().any(|name| name == "session/session.jsonl"),
|
||||
"钉死的条目名没生效:{listed:?}"
|
||||
);
|
||||
assert!(
|
||||
!listed.iter().any(|name| name.contains(session)),
|
||||
"条目名里还有原始会话 id:{listed:?}"
|
||||
);
|
||||
|
||||
// 清单的 files 段必须与真实条目一一对应(顺序也一致)。
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
let manifest_names: Vec<String> = manifest
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("- ") && line.contains(" <- "))
|
||||
.map(|line| {
|
||||
line.trim_start_matches("- ")
|
||||
.split(" <- ")
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
let zip_names: Vec<String> = listed
|
||||
.iter()
|
||||
.filter(|name| name.as_str() != "manifest.txt")
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(manifest_names, zip_names, "清单与包内条目对不上");
|
||||
}
|
||||
|
||||
/// P2-3:截断必须**保留尾部**——崩溃证据(`exit` 事件 / 栈尾)在尾部。
|
||||
///
|
||||
/// 抓得住:只留头部的截断(r1 复审 P2-3 的原实现)会让包里没有 `exit` 行。
|
||||
#[test]
|
||||
fn truncation_keeps_the_tail_where_the_crash_evidence_lives() {
|
||||
let root = temp_dir("tail");
|
||||
let session = root.join("game").join("session.jsonl");
|
||||
let head_marker = "HEAD-MARKER-first-line";
|
||||
let tail_marker = "{\"kind\":\"exit\",\"payload\":{\"code\":1}}";
|
||||
let body = format!(
|
||||
"{head_marker}
|
||||
{}{tail_marker}
|
||||
",
|
||||
"x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64)
|
||||
);
|
||||
write(&session, &body);
|
||||
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::named("session", &session, "session.jsonl")];
|
||||
let target = default_target_dir(&root).join("tail.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数");
|
||||
let kept = entry_text(&target, "session/session.jsonl").expect("条目");
|
||||
assert!(kept.contains(head_marker), "头部标记丢了");
|
||||
assert!(
|
||||
kept.contains(tail_marker),
|
||||
"尾部标记丢了 —— 包里将没有 exit 事件(证据是残的)"
|
||||
);
|
||||
assert!(
|
||||
kept.contains("<truncated "),
|
||||
"中间该有一行截断说明:{kept:.80}"
|
||||
);
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("已截断"), "{manifest}");
|
||||
}
|
||||
Reference in new issue
Block a user