diff --git a/crates/app-iced/src/app/dialog.rs b/crates/app-iced/src/app/dialog.rs index 51d8a11..9eb707d 100644 --- a/crates/app-iced/src/app/dialog.rs +++ b/crates/app-iced/src/app/dialog.rs @@ -67,8 +67,6 @@ pub enum Message { Secondary, /// 点遮罩 / Esc。 Dismiss, - /// 卡片内的空白处(吞掉点击,避免误关)。 - Swallow, /// 展开 / 收起详细说明。 Toggle, /// 打开日志文件夹。 diff --git a/crates/app-iced/src/app/dialog/export.rs b/crates/app-iced/src/app/dialog/export.rs index 887cd67..0ce34a1 100644 --- a/crates/app-iced/src/app/dialog/export.rs +++ b/crates/app-iced/src/app/dialog/export.rs @@ -11,16 +11,32 @@ use unlucky_core::launch::crash_report::CrashReport; use unlucky_i18n::catalog::{Catalog, Key}; use unlucky_i18n::Lang; +/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Exported { + /// 给用户看的那一句(已本地化)。 + pub text: String, + /// 是否成功。 + pub ok: bool, + /// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。 + pub truncated: usize, +} + /// 导出诊断包:打到「启动器日志目录 / 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 Some(logs) = &report.launcher_logs else { - return catalog.render( - Key::CrashDialogExportFailed, - &[("error", "no launcher log dir")], - ); + return Exported { + text: catalog.render( + Key::CrashDialogExportFailed, + &[("error", "no launcher log dir")], + ), + ok: false, + truncated: 0, + }; }; let stamp = 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")); match crash_bundle::write_bundle(&spec_of(report), &target) { - Ok(bundle) => catalog.render( - Key::CrashDialogExportDone, - &[ - ("bytes", &bundle.bytes.to_string()), - ("path", &bundle.path.display().to_string()), - ], - ), - Err(error) => catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]), + Ok(bundle) => { + let mut text = catalog.render( + Key::CrashDialogExportDone, + &[ + ("bytes", &bundle.bytes.to_string()), + ("path", &bundle.path.display().to_string()), + ], + ); + 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 { let mut entries: Vec = Vec::new(); - let mut push = |group: &'static str, path: &std::path::Path| { + let push = |entries: &mut Vec, group: &'static str, path: &std::path::Path| { if path.exists() { entries.push(BundleEntry::new(group, path)); } }; if let Some(logs) = &report.launcher_logs { - push("launcher", &logs.join("launcher.log")); - push("launcher", &logs.join("launcher.1.log")); - push("launcher", &logs.join("install.log")); + push(&mut entries, "launcher", &logs.join("launcher.log")); + push(&mut entries, "launcher", &logs.join("launcher.1.log")); + push(&mut entries, "launcher", &logs.join("install.log")); } if let Some(session) = &report.session_log { - push("session", session); + // 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id, + // 也不会出现「清单写 而包里是原始 id」(r1 复审 P2-2)。 + if session.exists() { + entries.push(BundleEntry::named("session", session, "session.jsonl")); + } } for artifact in &report.artifacts { // `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() { - push("game", &latest); + push(&mut entries, "game", &latest); } if let Some(json) = &report.version_json { - push("instance", json); + push(&mut entries, "instance", json); } let mut secrets = vec![report.session.clone()]; secrets.retain(|secret| !secret.is_empty()); diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 0e39759..983c1af 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -247,7 +247,10 @@ fn export_writes_a_bundle_even_without_a_crash_report() { spec_report.run_dir = 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}"); @@ -264,3 +267,106 @@ fn export_writes_a_bundle_even_without_a_crash_report() { "报出来的字节数要与磁盘一致:{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}"); +} diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs index f7a9efb..34d6ab8 100644 --- a/crates/app-iced/src/app/dialog/view.rs +++ b/crates/app-iced/src/app/dialog/view.rs @@ -6,11 +6,13 @@ //! 立刻停。所以三层的顺序是: //! //! ```text -//! stack![ 页面树, 遮罩(mouse_area → Dismiss), 卡片(mouse_area → Swallow) ] +//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ] //! ``` //! -//! 卡片那层用 `mouse_area` 把左键按下**吃掉**([`super::Message::Swallow`]),于是点卡片里的 -//! 空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获,连 Swallow 都不会发。 +//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用 +//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。 +//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗, +//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。 use iced::widget::text::LineHeight; 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 crate::app::components::buttons; +use crate::app::components::feedback::dialog::scrim; +use crate::app::components::feedback::panel::DialogGeometry; use crate::pages::frame; use crate::theme::Ui; use super::super::message::Message; use super::{Message as DialogMessage, Modal}; -/// 卡片最大宽(对话框档:单列 560,窄窗按视口收)。 -const CARD_WIDTH: f32 = 560.0; /// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。 const SCROLL_HEIGHT: f32 = 300.0; +/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。 +const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0; /// 卡片最大高(兜底:不许把边框撑出屏幕)。 -const CARD_HEIGHT: f32 = 620.0; -/// 遮罩不透明度(Material scrim 档;0.32 时下面的页面还看得见轮廓)。 -const SCRIM_ALPHA: f32 = 0.32; +const CARD_HEIGHT: f32 = 660.0; /// 把模态层叠在 `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>( ui: &'a Ui, base: Element<'a, Message>, modal: &'a Modal, ) -> Element<'a, Message> { - let scrim = mouse_area( + let backdrop = mouse_area( container(Space::new()) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| container::Style { - background: Some(Background::Color(scrim_color(ui))), + background: Some(Background::Color(backdrop_color(ui))), ..container::Style::default() }), ) .on_press(Message::Dialog(DialogMessage::Dismiss)); - let overlay = mouse_area( - container(card(ui, modal)) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .padding(ui.space(Sp::X6)), - ) - .on_press(Message::Dialog(DialogMessage::Swallow)); + // 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。 + let centered = container(card_plane(ui, modal)) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(ui.space(Sp::X6)); - iced::widget::stack![base, scrim, overlay].into() + iced::widget::stack![base, backdrop, centered].into() } -fn scrim_color(ui: &Ui) -> Color { - Color { - a: SCRIM_ALPHA, - ..ui.color(Role::OnSurface) - } +/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。 +/// +/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把 +/// 整窗**提亮**(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> { - 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() { column![ 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), footer, ] @@ -149,21 +179,12 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { .width(Length::Fill); container(body) - .width(Length::Fixed(CARD_WIDTH)) - .max_width(CARD_WIDTH + 48.0) - .max_height(CARD_HEIGHT + 120.0) - .padding(ui.metrics().card_padding) + .width(Length::Fixed(geometry.max_width)) + .max_height(CARD_HEIGHT) + .padding(geometry.padding) .style(move |_theme| container::Style { background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), 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() }) .into() diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index e1e13dd..efea010 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -44,7 +44,7 @@ use super::CAPTURE_AT_FRAME; mod account; mod agent; mod appearance; -mod boot_env; +pub(super) mod boot_env; mod clock; mod content; mod content_pack; @@ -90,6 +90,8 @@ impl App { None => { eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); self.log_appearance("boot", None); + // M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。 + boot_env::record_boot_log(self); } } // M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`, @@ -198,6 +200,8 @@ impl App { self.appearance.apply_env(); self.rebuild_ui(); self.log_appearance("boot", None); + // M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。 + boot_env::record_boot_log(self); // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) if self.options.wants_system_theme() { diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index 65690a9..31e0419 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -24,3 +24,38 @@ pub(super) fn env_start_route() -> Option { _ => 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()); +} diff --git a/crates/app-iced/src/app/update/dialog.rs b/crates/app-iced/src/app/update/dialog.rs index a3d08eb..8c9f12a 100644 --- a/crates/app-iced/src/app/update/dialog.rs +++ b/crates/app-iced/src/app/update/dialog.rs @@ -14,34 +14,36 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task { // 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。 - if let Some(card) = app.dialog_card.clone() { - let text = export::export(&card.report, app.lang()); - let failed = card.report.launcher_logs.is_none(); - app.state.toasts.push( - if failed { - ToastKind::Error - } else { - ToastKind::Success - }, - text.clone(), - ); - // 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast - // (app-iced 的 Toast 至今没有渲染点)。 - if let Some(state) = &mut app.dialog_card { - state.outcome = Some(text); - } - app.view_dirty = true; - app.refresh_view(); + let Some(card) = app.dialog_card.clone() else { + app.close_dialog(); return Task::none(); + }; + let done = export::export(&card.report, app.lang()); + // 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 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 { + state.outcome = Some(done.text); } - app.close_dialog(); + app.view_dirty = true; + app.refresh_view(); Task::none() } DialogMessage::Secondary | DialogMessage::Dismiss => { app.close_dialog(); + // 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒 + // (r1 复审 P3-1)。 + app.refresh_view(); Task::none() } - DialogMessage::Swallow => Task::none(), DialogMessage::Toggle => { app.toggle_dialog_detail(); Task::none() @@ -97,5 +99,6 @@ pub(super) fn escape(app: &mut App) -> bool { return false; } app.close_dialog(); + app.refresh_view(); true } diff --git a/crates/core/src/launch/crash_bundle.rs b/crates/core/src/launch/crash_bundle.rs index be453e8..99cbdcf 100644 --- a/crates/core/src/launch/crash_bundle.rs +++ b/crates/core/src/launch/crash_bundle.rs @@ -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**(`.jsonl`), + /// 拿它当条目名要么泄漏 id、要么让「清单里的名字」与「包里的名字」对不上。钉成 + /// `session.jsonl` 两个问题一起消失(见 [`BundleSpec::entry_names`])。 + pub name: Option, } impl BundleEntry { - /// 建一条。 + /// 建一条(包内名 = 源文件名)。 pub fn new(group: &'static str, path: impl Into) -> Self { Self { group, path: path.into(), + name: None, + } + } + + /// 建一条并**钉死包内文件名**(会话 JSONL、`latest.log` 这类有唯一身份的用它)。 + pub fn named(group: &'static str, path: impl Into, 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 = 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 { + 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 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 = Vec::new(); + let mut collected: Vec = Vec::new(); let mut total: u64 = 0; let mut files = 0usize; let mut truncated = 0usize; - let mut used_names: Vec = Vec::new(); + let mut used: Vec = 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 } 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, group: &str, name: &str) -> String { - let base = format!("{group}/{name}"); +/// 包内重名时在**目录名后**加序号(`game/latest.log` 与 `game/2-latest.log` 不会互相覆盖)。 +fn unique_name(used: &mut Vec, 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, 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 { + 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!( + " + +" + ) + .as_bytes(), + ); + out.extend_from_slice(&body[body.len() - tail..]); + out +} + /// 默认落点:启动器日志分区下的 `exports/`。 /// /// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope), diff --git a/crates/core/src/launch/crash_bundle/tests.rs b/crates/core/src/launch/crash_bundle/tests.rs index ea47b1c..d7b6a4d 100644 --- a/crates/core/src/launch/crash_bundle/tests.rs +++ b/crates/core/src/launch/crash_bundle/tests.rs @@ -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(写 ``)⇒ 清单指向的条目在包里不存在。 +#[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 = 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 = 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(" ("File paths copied to the clipboard", "文件路径已复制到剪贴板"), CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), 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}"), } } };