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
@@ -0,0 +1,120 @@
|
||||
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
|
||||
//!
|
||||
//! ## 为什么不在页面里现拼路径
|
||||
//!
|
||||
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
|
||||
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
|
||||
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
|
||||
//!
|
||||
//! 本模块只做两件事:
|
||||
//!
|
||||
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
|
||||
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
|
||||
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
|
||||
//!
|
||||
//! ## 谁来登记
|
||||
//!
|
||||
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
|
||||
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
|
||||
//! `platform::data::layout()`——同一份解析链的缓存版本。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
|
||||
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
|
||||
let home = config.parent()?.parent()?;
|
||||
Some(LauncherLayout::new(home).logs())
|
||||
}
|
||||
|
||||
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
|
||||
pub(crate) fn register(bridge: &SettingsBridge) {
|
||||
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
|
||||
}
|
||||
|
||||
/// 本进程的日志目录。
|
||||
///
|
||||
/// - 已登记且解析出来 ⇒ 那条;
|
||||
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
|
||||
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
|
||||
pub(crate) fn logs_dir() -> Option<PathBuf> {
|
||||
match LOGS_DIR.get() {
|
||||
Some(known) => known.clone(),
|
||||
None => crate::platform::data::layout().map(|layout| layout.logs()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保日志目录存在(不在就建),返回给用户看的路径。
|
||||
///
|
||||
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
|
||||
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
|
||||
}
|
||||
Ok(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
|
||||
pub(crate) fn open() -> Result<PathBuf, String> {
|
||||
let dir = logs_dir().ok_or_else(|| {
|
||||
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)".to_string()
|
||||
})?;
|
||||
let dir = prepare(&dir)?;
|
||||
crate::app::dialog::export::open_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
|
||||
///
|
||||
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
|
||||
#[test]
|
||||
fn the_log_folder_is_the_logs_partition_next_to_config() {
|
||||
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
|
||||
assert_eq!(
|
||||
logs_dir_of(&config),
|
||||
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
|
||||
);
|
||||
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
|
||||
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
|
||||
}
|
||||
|
||||
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
|
||||
///
|
||||
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
|
||||
#[test]
|
||||
fn prepare_creates_the_folder_and_reports_the_same_path() {
|
||||
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join(".ULL/logs");
|
||||
assert!(!dir.is_dir(), "夹具目录不该已经存在");
|
||||
let prepared = prepare(&dir).expect("应当建得出来");
|
||||
assert_eq!(prepared, dir);
|
||||
assert!(dir.is_dir(), "打开之前必须真的存在");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 失败文本带路径(「不许只在成功时可用」)。
|
||||
#[test]
|
||||
fn a_failure_names_the_path_it_could_not_use() {
|
||||
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
|
||||
std::fs::write(&file, b"x").expect("写夹具");
|
||||
let error = prepare(&file).expect_err("一个文件不是目录");
|
||||
assert!(error.contains(&file.display().to_string()), "{error}");
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user