feat(m188): launcher.log 早落盘 + 会话日志搬到 logs/game(旧家只读兼容)
用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 启动器运行日志(.ULL/logs/launcher.log) - presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme]) 当场刷盘,不再是「只有开过一局游戏才有日志」。 - app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope), 含 boot/boot::fallback/state/update 各线。 - boot 那一拍补一行后端事实(request/effective/fallback)。 - 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。 会话日志搬家:data/agent/sessions → logs - LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。 - host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。 - 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。 入口与文案 - 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。 - 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。 - README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有), docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。 守门(每条都有变异证明,见报告) - 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。 - launcher.log 滚动 ⇒ 干掉 rename 变红。 - 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。 - 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。
This commit is contained in:
1 parent
efc663a3cb
commit
5fd5cd47dc
47 files changed
+1371
-119
No files matched your search
@@ -78,6 +78,11 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(font_row(ui, language));
|
||||
|
||||
// M188:日志文件夹(路径 + 打开 + 复制路径)。放在「主题/启动页」之前——用户投诉
|
||||
// 「找不到崩溃日志」要的答案,不该埋在只读行后面。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(super::logs_row::folder_row(ui, &catalog));
|
||||
|
||||
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
for (label, value) in [
|
||||
(Key::SettingsTheme, Key::SettingsThemeFollowSystem),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 设置页「General」组的**日志文件夹**一行(M188)。
|
||||
//!
|
||||
//! 用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。
|
||||
//! 这一行是对那句投诉的直接回答,三件事缺一不可:
|
||||
//!
|
||||
//! 1. **路径永远显示**(不是只在打开成功时才显示):`<数据根>/logs`,三模式解析出来的真值;
|
||||
//! 2. **两个入口**:「打开文件夹」走平台打开器(`app::dialog::export::open_dir`,与崩溃弹窗同一个,
|
||||
//! 不另写一套 `cfg`),「复制路径」把路径送进剪贴板——打开失败时用户至少能照着路径自己去找;
|
||||
//! 3. **说清里面有什么**:`launcher.log`(滚动上限写进文案)与 `logs/game/` 的会话 jsonl,
|
||||
//! 以及旧目录「仍能读、没搬动」——否则用户换了版本会以为旧日志丢了。
|
||||
//!
|
||||
//! 为什么单独一个文件:`pages/settings/general.rs` 贴着 400 行硬线(语言行 + 字体行 + 三行只读),
|
||||
//! 这一行自带按钮与样式,塞回去必破线。
|
||||
|
||||
use iced::widget::{button, column, container, row as row_widget};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
|
||||
use crate::pages::{frame, Action, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 这一行(竖排:标签 / 路径 / 两个按钮 / 一句说明)。
|
||||
pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> {
|
||||
let path = crate::app::logs_folder::logs_dir()
|
||||
.map(|dir| dir.display().to_string())
|
||||
.unwrap_or_else(|| catalog.text(Key::SettingsLogsUnknown).to_string());
|
||||
|
||||
let hint = catalog.text(Key::SettingsLogsFolderHint);
|
||||
let actions = row_widget![
|
||||
action_button(
|
||||
ui,
|
||||
catalog.text(Key::LogsOpenFolder),
|
||||
Message::Action(Action::OpenLogs),
|
||||
),
|
||||
action_button(
|
||||
ui,
|
||||
catalog.text(Key::SettingsLogsCopy),
|
||||
Message::CopySummary(path.clone()),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
frame_text(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
catalog.text(Key::SettingsLogsFolder),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
container(frame_text_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
path,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fill),
|
||||
actions,
|
||||
frame_text(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn frame_text<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
text: &'a str,
|
||||
color: iced::Color,
|
||||
) -> Element<'a, Message> {
|
||||
frame::text_role(ui, role, false, text, color)
|
||||
}
|
||||
|
||||
fn frame_text_owned<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
text: String,
|
||||
color: iced::Color,
|
||||
) -> Element<'a, Message> {
|
||||
frame::text_role_owned(ui, role, false, text, color)
|
||||
}
|
||||
|
||||
/// 描边小按钮(与设置页其他「次级动作」同款:透明底、hover 出状态层、全圆角)。
|
||||
fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content = ui.color(Role::OnSurface);
|
||||
let outline = ui.color(Role::OutlineVariant);
|
||||
button(
|
||||
container(frame_text(ui, TypeRole::LabelLarge, label, content)).align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = matches!(status, button::Status::Hovered | button::Status::Pressed)
|
||||
.then(|| {
|
||||
Background::Color(ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface))
|
||||
});
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content,
|
||||
border: Border {
|
||||
color: outline,
|
||||
width: 1.0,
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(message)
|
||||
.into()
|
||||
}
|
||||
@@ -143,6 +143,7 @@ mod frame;
|
||||
mod general;
|
||||
pub(crate) mod java;
|
||||
pub(crate) mod language_appearance;
|
||||
mod logs_row;
|
||||
mod message;
|
||||
mod state;
|
||||
pub(crate) mod update;
|
||||
|
||||
@@ -196,3 +196,48 @@ fn the_general_group_does_not_use_the_appearance_body_slot() {
|
||||
"General 的真控件走 pages::Language 入口(见 settings/mod.rs 的 GENERAL_GROUP),不是 body"
|
||||
);
|
||||
}
|
||||
|
||||
/// **M188:日志文件夹那一行**的文案必须与实现一致(用户投诉「找不到崩溃日志」的直接判据)。
|
||||
///
|
||||
/// 抓得住:①文案里的路径写错(写成 `data/logs`、`cache/game`);②滚动上限与
|
||||
/// `core::launch::launcher_log::MAX_BYTES` 脱钩(文案说 1 MiB、实现改成 4 MiB);
|
||||
/// ③旧目录被静默改成「已迁移」(那会让用户以为旧日志没了);
|
||||
/// ④某条 key 只进了英文表(中文界面上是 `⟪missing⟫`)。
|
||||
/// 抓不住:界面画出来长什么样(截图验收管那一层)。
|
||||
#[test]
|
||||
fn the_logs_row_names_the_real_paths_and_the_real_limit() {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
use unlucky_core::platform::layout::{LauncherLayout, GAME_LOG_DIR, LOGS_DIR};
|
||||
|
||||
// 实现侧的真值:分区名与上限都只从 core 来。
|
||||
assert_eq!(LOGS_DIR, "logs");
|
||||
assert_eq!(GAME_LOG_DIR, "game");
|
||||
assert_eq!(launcher_log::MAX_BYTES, 1024 * 1024, "文案说 1 MiB");
|
||||
let layout = LauncherLayout::new(std::path::Path::new("/tmp/ull-home"));
|
||||
assert_eq!(
|
||||
layout.game_logs(),
|
||||
std::path::Path::new("/tmp/ull-home/logs/game")
|
||||
);
|
||||
|
||||
for lang in Lang::ALL {
|
||||
let catalog = lang.catalog();
|
||||
let hint = catalog.text(Key::SettingsLogsFolderHint);
|
||||
for needle in [
|
||||
"launcher.log",
|
||||
"1 MiB",
|
||||
&format!("{LOGS_DIR}/{GAME_LOG_DIR}/"),
|
||||
"data/agent/sessions",
|
||||
] {
|
||||
assert!(hint.contains(needle), "{} 缺 `{needle}`:{hint}", lang.tag());
|
||||
}
|
||||
assert!(!hint.contains('⟪'), "{} 目录里缺词:{hint}", lang.tag());
|
||||
assert!(
|
||||
!catalog.text(Key::SettingsLogsFolder).contains('⟪'),
|
||||
"{} 缺「日志文件夹」:{}",
|
||||
lang.tag(),
|
||||
catalog.text(Key::SettingsLogsFolder)
|
||||
);
|
||||
assert!(!catalog.text(Key::SettingsLogsCopy).contains('⟪'));
|
||||
assert!(!catalog.text(Key::SettingsLogsUnknown).contains('⟪'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user