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),