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
+499 -98

No files matched your search

-2
View File
@@ -67,8 +67,6 @@ pub enum Message {
Secondary, Secondary,
/// 点遮罩 / Esc。 /// 点遮罩 / Esc。
Dismiss, Dismiss,
/// 卡片内的空白处(吞掉点击,避免误关)。
Swallow,
/// 展开 / 收起详细说明。 /// 展开 / 收起详细说明。
Toggle, Toggle,
/// 打开日志文件夹。 /// 打开日志文件夹。
+53 -15
View File
@@ -11,16 +11,32 @@ use unlucky_core::launch::crash_report::CrashReport;
use unlucky_i18n::catalog::{Catalog, Key}; use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang; use unlucky_i18n::Lang;
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Exported {
/// 给用户看的那一句(已本地化)。
pub text: String,
/// 是否成功。
pub ok: bool,
/// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。
pub truncated: usize,
}
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。 /// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
/// ///
/// 返回给界面的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」)。 /// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」);
pub(crate) fn export(report: &CrashReport, lang: Lang) -> String { /// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。
pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported {
let catalog = Catalog::of(lang); let catalog = Catalog::of(lang);
let Some(logs) = &report.launcher_logs else { let Some(logs) = &report.launcher_logs else {
return catalog.render( return Exported {
text: catalog.render(
Key::CrashDialogExportFailed, Key::CrashDialogExportFailed,
&[("error", "no launcher log dir")], &[("error", "no launcher log dir")],
); ),
ok: false,
truncated: 0,
};
}; };
let stamp = let stamp =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()) unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now())
@@ -34,44 +50,66 @@ pub(crate) fn export(report: &CrashReport, lang: Lang) -> String {
}; };
let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip")); let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip"));
match crash_bundle::write_bundle(&spec_of(report), &target) { match crash_bundle::write_bundle(&spec_of(report), &target) {
Ok(bundle) => catalog.render( Ok(bundle) => {
let mut text = catalog.render(
Key::CrashDialogExportDone, Key::CrashDialogExportDone,
&[ &[
("bytes", &bundle.bytes.to_string()), ("bytes", &bundle.bytes.to_string()),
("path", &bundle.path.display().to_string()), ("path", &bundle.path.display().to_string()),
], ],
), );
Err(error) => catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]), 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 { fn spec_of(report: &CrashReport) -> BundleSpec {
let mut entries: Vec<BundleEntry> = Vec::new(); let mut entries: Vec<BundleEntry> = Vec::new();
let mut push = |group: &'static str, path: &std::path::Path| { let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
if path.exists() { if path.exists() {
entries.push(BundleEntry::new(group, path)); entries.push(BundleEntry::new(group, path));
} }
}; };
if let Some(logs) = &report.launcher_logs { if let Some(logs) = &report.launcher_logs {
push("launcher", &logs.join("launcher.log")); push(&mut entries, "launcher", &logs.join("launcher.log"));
push("launcher", &logs.join("launcher.1.log")); push(&mut entries, "launcher", &logs.join("launcher.1.log"));
push("launcher", &logs.join("install.log")); push(&mut entries, "launcher", &logs.join("install.log"));
} }
if let Some(session) = &report.session_log { if let Some(session) = &report.session_log {
push("session", session); // 源文件名就是会话 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 { for artifact in &report.artifacts {
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。 // `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
if artifact.source != "latest_log" { if artifact.source != "latest_log" {
push("game", &artifact.path); push(&mut entries, "game", &artifact.path);
} }
} }
if let Some(latest) = report.latest_log_path() { if let Some(latest) = report.latest_log_path() {
push("game", &latest); push(&mut entries, "game", &latest);
} }
if let Some(json) = &report.version_json { if let Some(json) = &report.version_json {
push("instance", json); push(&mut entries, "instance", json);
} }
let mut secrets = vec![report.session.clone()]; let mut secrets = vec![report.session.clone()];
secrets.retain(|secret| !secret.is_empty()); secrets.retain(|secret| !secret.is_empty());
+107 -1
View File
@@ -247,7 +247,10 @@ fn export_writes_a_bundle_even_without_a_crash_report() {
spec_report.run_dir = None; spec_report.run_dir = None;
spec_report.version_json = None; spec_report.version_json = None;
let text = super::export::export(&spec_report, Lang::ZhCn); let done = super::export::export(&spec_report, Lang::ZhCn);
let text = done.text.clone();
assert!(done.ok, "{text}");
assert_eq!(done.truncated, 0);
assert!(text.contains("已导出"), "{text}"); assert!(text.contains("已导出"), "{text}");
assert!(text.contains("字节"), "{text}"); assert!(text.contains("字节"), "{text}");
@@ -264,3 +267,106 @@ fn export_writes_a_bundle_even_without_a_crash_report() {
"报出来的字节数要与磁盘一致:{text} / {size}" "报出来的字节数要与磁盘一致:{text} / {size}"
); );
} }
/// P2-1:**卡片那一层只能是卡片大**。
///
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
///
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
#[test]
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
use iced::Length;
let mut app = headless();
app.state.dialogs.push(
DialogSource::UserAction,
"t",
"b",
DialogKind::Confirm {
primary: "p".to_string(),
secondary: "s".to_string(),
},
);
let modal = {
let request = app.state.dialogs.current().expect("当前条");
let view = DialogRequestView::of(request);
super::current(app.lang(), &view, None)
};
let plane = super::view::card_plane(app.ui(), &modal);
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
assert_ne!(
hint.width,
Length::Fill,
"卡片层宽度不能 Fill(会撑成整窗)"
);
assert_ne!(
hint.height,
Length::Fill,
"卡片层高度不能 Fill(会撑成整窗)"
);
}
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
///
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
///
/// 抓不住:具体色值对不对(那是令牌层的事)。
#[test]
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
let backdrop = super::view::backdrop_color(&ui);
assert_eq!(
backdrop.a, 1.0,
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
);
let canvas = crate::theme::to_iced(
unlucky_design::color::Scheme::baseline(flavor)
.get(MaterialLayer::Canvas.surface_role()),
);
assert!(
luma(backdrop) < luma(canvas),
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
luma(backdrop),
luma(canvas)
);
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
}
}
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
///
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
#[test]
fn boot_facts_are_recorded_into_the_launcher_log() {
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建目录");
unlucky_core::launch::launcher_log::set_dir(&dir);
let app = headless();
crate::app::update::boot_env::record_boot_log(&app);
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
let text = std::fs::read_to_string(&path).expect("launcher.log");
assert!(text.contains("[boot] app boot"), "{text}");
assert!(text.contains("theme="), "{text}");
assert!(text.contains("window="), "{text}");
}
+56 -35
View File
@@ -6,11 +6,13 @@
//! 立刻停。所以三层的顺序是: //! 立刻停。所以三层的顺序是:
//! //!
//! ```text //! ```text
//! stack![ 页面树, 遮罩(mouse_area → Dismiss), 卡片(mouse_area → Swallow) ] //! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
//! ``` //! ```
//! //!
//! 卡片那层用 `mouse_area` 把左键按下**吃掉**([`super::Message::Swallow`]),于是点卡片里的 //! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
//! 空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获,连 Swallow 都不会发。 //! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
use iced::widget::text::LineHeight; use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space}; use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
@@ -23,60 +25,84 @@ use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use crate::app::components::buttons; use crate::app::components::buttons;
use crate::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry;
use crate::pages::frame; use crate::pages::frame;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::message::Message; use super::super::message::Message;
use super::{Message as DialogMessage, Modal}; use super::{Message as DialogMessage, Modal};
/// 卡片最大宽(对话框档:单列 560,窄窗按视口收)。
const CARD_WIDTH: f32 = 560.0;
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。 /// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0; const SCROLL_HEIGHT: f32 = 300.0;
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。 /// 卡片最大高(兜底:不许把边框撑出屏幕)。
const CARD_HEIGHT: f32 = 620.0; const CARD_HEIGHT: f32 = 660.0;
/// 遮罩不透明度(Material scrim 档;0.32 时下面的页面还看得见轮廓)。
const SCRIM_ALPHA: f32 = 0.32;
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。 /// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
///
/// ## 三层的命中语义(r1 复审 P2-1 的修法)
///
/// ```text
/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ]
/// ```
///
/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里
/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。
/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它
/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」
/// 只存在于注释里。
pub(crate) fn layer<'a>( pub(crate) fn layer<'a>(
ui: &'a Ui, ui: &'a Ui,
base: Element<'a, Message>, base: Element<'a, Message>,
modal: &'a Modal, modal: &'a Modal,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let scrim = mouse_area( let backdrop = mouse_area(
container(Space::new()) container(Space::new())
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| container::Style { .style(move |_theme| container::Style {
background: Some(Background::Color(scrim_color(ui))), background: Some(Background::Color(backdrop_color(ui))),
..container::Style::default() ..container::Style::default()
}), }),
) )
.on_press(Message::Dialog(DialogMessage::Dismiss)); .on_press(Message::Dialog(DialogMessage::Dismiss));
let overlay = mouse_area( // 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
container(card(ui, modal)) let centered = container(card_plane(ui, modal))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.align_x(Alignment::Center) .align_x(Alignment::Center)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.padding(ui.space(Sp::X6)), .padding(ui.space(Sp::X6));
)
.on_press(Message::Dialog(DialogMessage::Swallow));
iced::widget::stack![base, scrim, overlay].into() iced::widget::stack![base, backdrop, centered].into()
} }
fn scrim_color(ui: &Ui) -> Color { /// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
Color { ///
a: SCRIM_ALPHA, /// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
..ui.color(Role::OnSurface) /// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
} /// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
///
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui)
} }
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
///
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
iced::widget::opaque(card(ui, modal))
}
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let shape = Component::OutlinedCard.spec().shape; // 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
let geometry = DialogGeometry::of(ui);
let shape = Component::Dialog.spec().shape;
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在 // 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。 // (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
@@ -138,7 +164,11 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let body = if modal.card.is_some() { let body = if modal.card.is_some() {
column![ column![
header, header,
scrollable(middle).height(Length::Fixed(SCROLL_HEIGHT)), scrollable(middle).height(Length::Fixed(if modal.notice.is_some() {
SCROLL_HEIGHT_WITH_NOTICE
} else {
SCROLL_HEIGHT
})),
frame::divider(ui).map(Message::Page), frame::divider(ui).map(Message::Page),
footer, footer,
] ]
@@ -149,21 +179,12 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
.width(Length::Fill); .width(Length::Fill);
container(body) container(body)
.width(Length::Fixed(CARD_WIDTH)) .width(Length::Fixed(geometry.max_width))
.max_width(CARD_WIDTH + 48.0) .max_height(CARD_HEIGHT)
.max_height(CARD_HEIGHT + 120.0) .padding(geometry.padding)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style { .style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(shape), border: ui.outlined(shape),
shadow: Shadow {
color: Color {
a: 0.18,
..Color::BLACK
},
offset: iced::Vector::new(0.0, 8.0),
blur_radius: 24.0,
},
..container::Style::default() ..container::Style::default()
}) })
.into() .into()
+5 -1
View File
@@ -44,7 +44,7 @@ use super::CAPTURE_AT_FRAME;
mod account; mod account;
mod agent; mod agent;
mod appearance; mod appearance;
mod boot_env; pub(super) mod boot_env;
mod clock; mod clock;
mod content; mod content;
mod content_pack; mod content_pack;
@@ -90,6 +90,8 @@ impl App {
None => { None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None); self.log_appearance("boot", None);
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
boot_env::record_boot_log(self);
} }
} }
// M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`, // M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`,
@@ -198,6 +200,8 @@ impl App {
self.appearance.apply_env(); self.appearance.apply_env();
self.rebuild_ui(); self.rebuild_ui();
self.log_appearance("boot", None); self.log_appearance("boot", None);
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
boot_env::record_boot_log(self);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() { if self.options.wants_system_theme() {
@@ -24,3 +24,38 @@ pub(super) fn env_start_route() -> Option<Route> {
_ => None, _ => None,
} }
} }
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
///
/// ## 为什么这一拍只能「先记、后落」
///
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
/// (core 侧缓冲,最多 200 行);第一次启动时 presenter 调 `launcher_log::set_dir`,缓冲一次
/// 刷进 `<.ULL>/logs/launcher.log`。
///
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
///
/// 边界(写在明处):进程若一次都没启动游戏、又没触发导出,`set_dir` 就不会被调用,
/// 这些行只留在内存里 → 那个进程不产生 `launcher.log`。要彻底解决得让 presenter 在构造
/// `HostEnv` 时就登记目录(`host/mod.rs` 不在本 mission scope,已登记为 follow-up)。
pub(crate) fn record_boot_log(app: &super::super::state::App) {
use unlucky_core::launch::launcher_log;
launcher_log::record("boot", "app boot");
launcher_log::record(
"boot",
&format!(
"theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}",
app.ui().name(),
app.lang(),
app.state.route(),
app.width,
app.height,
app.has_screenshot(),
app.state.instances.len(),
),
);
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
}
+20 -17
View File
@@ -14,34 +14,36 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
match message { match message {
DialogMessage::Primary => { DialogMessage::Primary => {
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。 // 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
if let Some(card) = app.dialog_card.clone() { let Some(card) = app.dialog_card.clone() else {
let text = export::export(&card.report, app.lang()); app.close_dialog();
let failed = card.report.launcher_logs.is_none(); return Task::none();
app.state.toasts.push( };
if failed { let done = export::export(&card.report, app.lang());
ToastKind::Error
} else {
ToastKind::Success
},
text.clone(),
);
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast // 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
// (app-iced 的 Toast 至今没有渲染点)。 // (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
app.state.toasts.push(
if done.ok {
ToastKind::Success
} else {
ToastKind::Error
},
done.text.clone(),
);
if let Some(state) = &mut app.dialog_card { if let Some(state) = &mut app.dialog_card {
state.outcome = Some(text); state.outcome = Some(done.text);
} }
app.view_dirty = true; app.view_dirty = true;
app.refresh_view(); app.refresh_view();
return Task::none();
}
app.close_dialog();
Task::none() Task::none()
} }
DialogMessage::Secondary | DialogMessage::Dismiss => { DialogMessage::Secondary | DialogMessage::Dismiss => {
app.close_dialog(); app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
app.refresh_view();
Task::none() Task::none()
} }
DialogMessage::Swallow => Task::none(),
DialogMessage::Toggle => { DialogMessage::Toggle => {
app.toggle_dialog_detail(); app.toggle_dialog_detail();
Task::none() Task::none()
@@ -97,5 +99,6 @@ pub(super) fn escape(app: &mut App) -> bool {
return false; return false;
} }
app.close_dialog(); app.close_dialog();
app.refresh_view();
true true
} }
+114 -27
View File
@@ -26,8 +26,13 @@ use std::path::{Path, PathBuf};
use super::redact::Redactor; use super::redact::Redactor;
/// 单个文件最多收多少字节(超出只收头部,清单里记「已截断」)。 /// 单个文件最多收多少字节(超出按**头 + 尾**截断,清单里记「已截断」)。
///
/// 为什么不是「只收头部」:崩溃证据在**尾部**——会话 JSONL 的最后几行是 `exit` 事件与
/// stderr,`latest.log` 的栈也在尾。只留头部的包等于「证据是残的,而用户不知道」。
pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024; 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; pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024;
@@ -38,14 +43,30 @@ pub struct BundleEntry {
pub group: &'static str, pub group: &'static str,
/// 源文件绝对路径。 /// 源文件绝对路径。
pub path: PathBuf, pub path: PathBuf,
/// 包内固定文件名(`None` = 用源文件名)。
///
/// 为什么要能钉死:会话 JSONL 的源文件名就是**会话 id**(`<uuid>.jsonl`),
/// 拿它当条目名要么泄漏 id、要么让「清单里的名字」与「包里的名字」对不上。钉成
/// `session.jsonl` 两个问题一起消失(见 [`BundleSpec::entry_names`])。
pub name: Option<String>,
} }
impl BundleEntry { impl BundleEntry {
/// 建一条。 /// 建一条(包内名 = 源文件名)。
pub fn new(group: &'static str, path: impl Into<PathBuf>) -> Self { pub fn new(group: &'static str, path: impl Into<PathBuf>) -> Self {
Self { Self {
group, group,
path: path.into(), 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 { impl BundleSpec {
/// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。 /// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。
///
/// 这是「自算条目名」的包装(测试与单独看清单用);真正落盘时用的是
/// [`Self::header`] + 收集循环里长出来的行,保证与包内条目一字不差。
#[must_use]
pub fn manifest(&self) -> String { 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(); let mut text = String::new();
text.push_str("Unlucky Launcher — launch failure bundle\n"); text.push_str("Unlucky Launcher — launch failure bundle\n");
text.push_str("=======================================\n"); text.push_str("=======================================\n");
@@ -118,17 +154,33 @@ impl BundleSpec {
} }
text.push_str("\nfiles\n-----\n"); text.push_str("\nfiles\n-----\n");
for entry in &self.entries { // 清单里的名字**必须**与包里的条目一一对应(r1 复审 P2-2 就是这里对不上):
text.push_str(&format!( // 两边都走同一份解析(`rows` 由调用方给出,行内已是最终条目名):
"- {}/{} <- {}\n", // 清单与 zip 不许各算一遍 —— 各算一遍就是 r1 复审 P2-2。
entry.group, for row in rows {
file_name(&entry.path), text.push_str(row);
entry.path.display() text.push('\n');
));
} }
text 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 { fn redactor(&self) -> Redactor {
let mut redactor = Redactor::new(); let mut redactor = Redactor::new();
if let Some(home) = &self.home { if let Some(home) = &self.home {
@@ -186,35 +238,39 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String>
let options = let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut manifest = spec.manifest(); // 名字与清单**先在同一个循环里长出来**:`files` 段只列真进了包的条目,
// 截断与缺失在**收集时**才知道,所以清单最后再补一段。 // 谁被跳过、谁被截断都在 `collected` 段写明(r1 复审 P2-2 的修法)。
let mut collected = Vec::new(); 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 total: u64 = 0;
let mut files = 0usize; let mut files = 0usize;
let mut truncated = 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 { for (index, entry) in spec.entries.iter().enumerate() {
let name = unique_name(&mut used_names, entry.group, &file_name(&entry.path)); let name = unique_name(&mut used, &names[index]);
let Ok(bytes) = std::fs::read(&entry.path) else { let Ok(bytes) = std::fs::read(&entry.path) else {
collected.push(format!("- {name}: 读不出来(已跳过)")); collected.push(format!("- {name}: 读不出来(已跳过)"));
truncated += 1;
continue; continue;
}; };
let original_len = bytes.len(); let original_len = bytes.len();
let mut body = bytes; let mut body = bytes;
if total + body.len() as u64 > MAX_TOTAL_BYTES || body.len() as u64 > MAX_ENTRY_BYTES { let budget = MAX_TOTAL_BYTES.saturating_sub(total).min(MAX_ENTRY_BYTES);
let keep = usize::try_from(MAX_ENTRY_BYTES.min(body.len() as u64)).unwrap_or(0); if body.len() as u64 > budget {
let keep = usize::try_from(MAX_TOTAL_BYTES.saturating_sub(total).min(keep as u64)) let clipped = clip(&body, budget);
.unwrap_or(0); if clipped.is_empty() {
if keep == 0 {
collected.push(format!("- {name}: 超出总量上限,未收内容")); collected.push(format!("- {name}: 超出总量上限,未收内容"));
truncated += 1; truncated += 1;
continue; continue;
} }
body.truncate(keep); body = clipped;
collected.push(format!( 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; truncated += 1;
} }
@@ -224,9 +280,11 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String>
} }
total += body.len() as u64; total += body.len() as u64;
files += 1; files += 1;
listed.push(format!("- {name} <- {}", entry.path.display()));
write_entry(&mut zip, &name, &body, options)?; write_entry(&mut zip, &name, &body, options)?;
} }
let mut manifest = spec.header(&listed);
if !collected.is_empty() { if !collected.is_empty() {
manifest.push_str("\ncollected\n---------\n"); manifest.push_str("\ncollected\n---------\n");
for line in collected { for line in collected {
@@ -272,15 +330,16 @@ fn file_name(path: &Path) -> String {
.unwrap_or_else(|| "file".to_string()) .unwrap_or_else(|| "file".to_string())
} }
/// 包内重名时加序号(`latest.log` 与 `crash-reports/latest.log` 不会互相覆盖)。 /// 包内重名时在**目录名后**加序号(`game/latest.log` 与 `game/2-latest.log` 不会互相覆盖)。
fn unique_name(used: &mut Vec<String>, group: &str, name: &str) -> String { fn unique_name(used: &mut Vec<String>, name: &str) -> String {
let base = format!("{group}/{name}"); let base = name.to_string();
if !used.contains(&base) { if !used.contains(&base) {
used.push(base.clone()); used.push(base.clone());
return base; return base;
} }
let (group, file) = base.split_once('/').unwrap_or(("", base.as_str()));
for index in 2.. { for index in 2.. {
let candidate = format!("{group}/{index}-{name}"); let candidate = format!("{group}/{index}-{file}");
if !used.contains(&candidate) { if !used.contains(&candidate) {
used.push(candidate.clone()); used.push(candidate.clone());
return candidate; return candidate;
@@ -289,6 +348,34 @@ fn unique_name(used: &mut Vec<String>, group: &str, name: &str) -> String {
unreachable!("序号一定找得到空位") 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/`。 /// 默认落点:启动器日志分区下的 `exports/`。
/// ///
/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope), /// 为什么不弹文件选择框:本仓没有 `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("清单"); let manifest = entry_text(&target, "manifest.txt").expect("清单");
assert!(manifest.contains("读不出来"), "{manifest}"); 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}");
}
+1
View File
@@ -267,6 +267,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"), ("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"),
("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), ("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"),
("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"), ("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"),
("CrashDialogExportTruncated", "{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"),
("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"), ("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"),
("AgentTitle", "Assistant", "助手"), ("AgentTitle", "Assistant", "助手"),
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"), ("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
+1
View File
@@ -57,6 +57,7 @@ macro_rules! logs_entries {
CrashDialogCopied => ("File paths copied to the clipboard", "文件路径已复制到剪贴板"), CrashDialogCopied => ("File paths copied to the clipboard", "文件路径已复制到剪贴板"),
CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"),
CrashDialogExportFailed => ("Export failed: {error}", "导出失败:{error}"), CrashDialogExportFailed => ("Export failed: {error}", "导出失败:{error}"),
CrashDialogExportTruncated => ("{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"),
CrashDialogOpenFailed => ("Could not open the folder: {error}", "打不开文件夹:{error}"), CrashDialogOpenFailed => ("Could not open the folder: {error}", "打不开文件夹:{error}"),
} } } }
}; };