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:
worker-logplace committed 2026-11-09 00:43:45 +08:00
1 parent efc663a3cb
commit 5fd5cd47dc
47 files changed
+1371 -119

No files matched your search

@@ -27,7 +27,7 @@ pub struct CoreDiagnose {
}
impl CoreDiagnose {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
/// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
@@ -35,6 +35,13 @@ impl CoreDiagnose {
tail: DEFAULT_TAIL,
}
}
/// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧会话也能诊断。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<std::path::PathBuf>) -> Self {
self.source = self.source.with_legacy(legacy);
self
}
}
fn map_kind(kind: CoreFixKind) -> FixActionKind {
+51 -11
View File
@@ -13,35 +13,69 @@ use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use unlucky_agent::port::PortResult;
/// 一个数据根下的会话索引 + 事件流。
/// 一个数据根下的会话索引 + 事件流(可以再挂一个**只读兼容**的旧根,M188)。
#[derive(Debug, Clone)]
pub struct SessionSource {
root: PathBuf,
legacy: Option<PathBuf>,
}
impl SessionSource {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
/// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
Self {
root: root.into(),
legacy: None,
}
}
/// 索引文件路径。
/// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧的会话仍然列得出来、打得开。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<PathBuf>) -> Self {
self.legacy = Some(legacy.into());
self
}
/// 读会话时要看的根,**新根在前**。
fn roots(&self) -> Vec<&Path> {
let mut roots = vec![self.root.as_path()];
if let Some(legacy) = &self.legacy {
if legacy.is_dir() {
roots.push(legacy.as_path());
}
}
roots
}
/// 索引文件路径(新根)。
#[must_use]
pub fn index(&self) -> PathBuf {
session_index::index_path(&self.root)
}
/// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。
/// 全部会话(按 `started_at` 倒序,同 id 取最后一行;两个根合并,新根优先)。
///
/// # Errors
/// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。
pub fn entries(&self) -> PortResult<Vec<SessionIndexEntry>> {
let index = self.index();
let mut entries = session_index::read_all(&index)
.map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?;
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(entries)
let mut merged: Vec<SessionIndexEntry> = Vec::new();
for root in self.roots() {
let index = session_index::index_path(root);
let entries = session_index::read_all(&index)
.map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?;
for entry in entries {
if merged
.iter()
.any(|item| item.session_id == entry.session_id)
{
continue;
}
merged.push(entry);
}
}
merged.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(merged)
}
/// 按完整 id 或唯一前缀找会话。
@@ -85,9 +119,15 @@ impl SessionSource {
.collect())
}
/// 事件流文件路径。
/// 事件流文件路径(新根没有时回落到旧根)。
#[must_use]
pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf {
for root in self.roots() {
let path = root.join("game").join(&entry.log_path);
if path.is_file() {
return path;
}
}
self.root.join("game").join(&entry.log_path)
}
+6 -2
View File
@@ -37,8 +37,12 @@ pub fn build(ctx: &Ctx) -> Ports {
ctx.launcher_config(),
ctx.portable_launcher_config(),
)),
sessions: Arc::new(CoreSessions::new(ctx.sessions_root())),
diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())),
sessions: Arc::new(
CoreSessions::new(ctx.sessions_root()).with_legacy(ctx.legacy_sessions_root()),
),
diagnose: Arc::new(
CoreDiagnose::new(ctx.sessions_root()).with_legacy(ctx.legacy_sessions_root()),
),
game: Arc::new(CoreGame::new(
ctx.layout(),
ctx.game(),
@@ -15,13 +15,20 @@ pub struct CoreSessions {
}
impl CoreSessions {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
/// `root` = `<数据根>/.ULL/logs`(M188 起;`LauncherConfig` 在其下建 `game/`)。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
}
}
/// 再挂一个只读兼容的旧根(`<数据根>/.ULL/data/agent/sessions`,M188)。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<std::path::PathBuf>) -> Self {
self.source = self.source.with_legacy(legacy);
self
}
}
fn summary_of(
+3
View File
@@ -106,6 +106,9 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
};
let plan = snapshot::prepare(ctx, &input)?;
// M188:命令行这一路也把启动器自己的运行日志落到 `<data>/logs/launcher.log`
// (GUI 由 presenter 的 `HostEnv` 登记;两条路的落点必须是同一个)。
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
let config = LauncherConfig::new(ctx.sessions_root())
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let launcher = Launcher::new(config);
+57 -5
View File
@@ -4,6 +4,9 @@
//! - `<sessions_root>/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序);
//! - `<sessions_root>/game/<id>.jsonl`:事件流(`kind` + `payload`)。
//!
//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容:
//! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。
//!
//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。
use std::path::Path;
@@ -13,6 +16,7 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use unlucky_core::platform::layout::GAME_LOG_DIR;
use crate::cli::{DiagArgs, LogsArgs};
use crate::context::Ctx;
@@ -26,8 +30,7 @@ use crate::output::Report;
/// 索引文件读不了时返回用户错误(不存在 = 空列表)。
pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
let index = ctx.session_index();
let entries = session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
let entries = merged_entries(ctx)?;
let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect();
let rows: Vec<Value> = shown
@@ -56,6 +59,15 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
shown.len(),
index.display()
)];
// M188:旧家的会话仍然列得出来,就得说清它们从哪来——否则用户会以为「新目录里怎么没有」。
let legacy = ctx.legacy_session_index();
if legacy.is_file() {
human.push(format!(
" 旧会话目录仍然只读兼容:{}(不搬不删)",
legacy.display()
));
}
human.push(format!(" 启动器日志:{}", ctx.logs_dir().display()));
if shown.is_empty() {
human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string());
}
@@ -205,8 +217,7 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
let index = ctx.session_index();
let entries = session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
let entries = merged_entries(ctx)?;
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
return Ok(entry.clone());
}
@@ -228,7 +239,48 @@ fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
}
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
ctx.sessions_root().join("game").join(&entry.log_path)
// M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。
// 先看新根,再回落旧家;两处都没有就把新根路径交出去(错误信息据此说「该去哪找」)。
let primary = ctx
.sessions_root()
.join(GAME_LOG_DIR)
.join(&entry.log_path);
if primary.is_file() {
return primary;
}
let legacy = ctx
.legacy_sessions_root()
.join(GAME_LOG_DIR)
.join(&entry.log_path);
if legacy.is_file() {
return legacy;
}
primary
}
/// 两个索引合并后的条目(新根 `logs/` 在前,旧家只读兼容在后;同 id 以新根为准)。
fn merged_entries(ctx: &Ctx) -> Result<Vec<SessionIndexEntry>, CliError> {
let mut indexes = vec![ctx.session_index()];
let legacy = ctx.legacy_session_index();
if legacy.is_file() {
indexes.push(legacy);
}
let mut merged: Vec<SessionIndexEntry> = Vec::new();
for index in indexes {
for entry in session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?
{
if merged
.iter()
.any(|item| item.session_id == entry.session_id)
{
continue;
}
merged.push(entry);
}
}
merged.sort_by(|left, right| right.started_at.cmp(&left.started_at));
Ok(merged)
}
fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
+19 -1
View File
@@ -168,12 +168,24 @@ impl Ctx {
self.layout().profiles_config()
}
/// 会话根(`<data>/agent/sessions`,launch 的事件流与索引都在这下面)。
/// 会话根(M188 起 = `<data>/logs`;launch 的事件流与索引都在这下面)。
#[must_use]
pub fn sessions_root(&self) -> PathBuf {
self.layout().sessions_root()
}
/// 会话日志的**旧家**(`<data>/data/agent/sessions`):只读兼容,M188 起不再往里写。
#[must_use]
pub fn legacy_sessions_root(&self) -> PathBuf {
self.layout().agent_sessions()
}
/// 启动器日志目录(`<data>/logs`:`launcher.log` + `game/` 会话)。
#[must_use]
pub fn logs_dir(&self) -> PathBuf {
self.layout().logs()
}
/// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。
#[must_use]
pub fn auth_data(&self) -> PathBuf {
@@ -186,6 +198,12 @@ impl Ctx {
unlucky_core::launch::session_index::index_path(&self.sessions_root())
}
/// 旧家的会话索引(`data/agent/sessions/game/index.jsonl`):只读兼容。
#[must_use]
pub fn legacy_session_index(&self) -> PathBuf {
unlucky_core::launch::session_index::index_path(&self.legacy_sessions_root())
}
/// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。
///
/// # Errors