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:
AskaEth committed 2026-11-08 23:21:26 +08:00
1 parent 414f6ff886
commit 3cd371b537
11 files changed
+513 -112

No files matched your search

+114 -27
View File
@@ -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}");
}