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
@@ -67,8 +67,6 @@ pub enum Message {
|
||||
Secondary,
|
||||
/// 点遮罩 / Esc。
|
||||
Dismiss,
|
||||
/// 卡片内的空白处(吞掉点击,避免误关)。
|
||||
Swallow,
|
||||
/// 展开 / 收起详细说明。
|
||||
Toggle,
|
||||
/// 打开日志文件夹。
|
||||
|
||||
@@ -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<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() {
|
||||
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,
|
||||
// 也不会出现「清单写 <redacted:token> 而包里是原始 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());
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -24,3 +24,38 @@ pub(super) fn env_start_route() -> Option<Route> {
|
||||
_ => 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());
|
||||
}
|
||||
@@ -14,34 +14,36 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
match message {
|
||||
DialogMessage::Primary => {
|
||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user