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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -267,6 +267,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("CrashDialogCopied", "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}"),
|
||||
("AgentTitle", "Assistant", "助手"),
|
||||
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
|
||||
|
||||
@@ -57,6 +57,7 @@ macro_rules! logs_entries {
|
||||
CrashDialogCopied => ("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}"),
|
||||
} }
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user