Files
Unlucky-Launcher/crates/app-iced/src/app/logs_folder.rs
T
worker-logplace 5fd5cd47dc 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 写死成便携落点变红。
2026-11-09 00:43:45 +08:00

121 lines
5.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「日志文件夹」在 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();
}
}