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
@@ -205,6 +205,31 @@ lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `se
|
||||
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
|
||||
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
|
||||
|
||||
### Where the logs are
|
||||
|
||||
Everything the launcher writes on its own side lives under `<data root>/logs/`. The settings page has
|
||||
a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has
|
||||
*Open folder* as well. The data root is `<exe folder>/.ULL` in portable mode, `<per-user app data>/ULL`
|
||||
in standard mode, and `<custom root>/.ULL` in custom mode — the same value the settings page displays
|
||||
as the launcher data directory.
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. |
|
||||
| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. |
|
||||
| `logs/game/<session-id>.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. |
|
||||
| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. |
|
||||
| `logs/install.log` | Install pipeline journal, one line per stage. |
|
||||
| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. |
|
||||
| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. |
|
||||
|
||||
The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`,
|
||||
`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long
|
||||
enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft
|
||||
initialises, the game never gets that far, and the honest evidence is the session JSONL plus
|
||||
`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is
|
||||
hiding.
|
||||
|
||||
### Not done yet
|
||||
|
||||
This list is the reason to believe the rest. None of it is hidden behind marketing copy.
|
||||
@@ -216,7 +241,7 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
|
||||
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
|
||||
The first observation on the live stable channel happens after `0.2.3` ships.
|
||||
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
|
||||
*Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset
|
||||
*Open folder*, *Delete*. In the settings frame: *Save*, *Reset
|
||||
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
|
||||
management from the GUI is the instance-wizard work still outstanding, and settings change as you
|
||||
edit them rather than on a Save.
|
||||
|
||||
+30
-1
@@ -163,6 +163,34 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
|
||||
构建里**不存在**这条路径。
|
||||
|
||||
### 日志在哪
|
||||
|
||||
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
|
||||
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
|
||||
|
||||

|
||||
|
||||
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
|
||||
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
|
||||
|
||||
数据根按三种模式取值:便携是 `<exe 目录>/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是
|
||||
`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。
|
||||
|
||||
| 路径 | 是什么 |
|
||||
|---|---|
|
||||
| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 |
|
||||
| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 |
|
||||
| `logs/game/<session-id>.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 |
|
||||
| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 |
|
||||
| `logs/install.log` | 安装流水:每个阶段一行。 |
|
||||
| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 |
|
||||
| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 |
|
||||
|
||||
游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、
|
||||
`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏
|
||||
弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与
|
||||
`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
|
||||
|
||||
### 还没做
|
||||
|
||||
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
|
||||
@@ -172,9 +200,10 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
|
||||
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
|
||||
观察要等 `0.2.3` 发出去之后。
|
||||
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
|
||||
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。
|
||||
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
|
||||
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
|
||||
(日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。)
|
||||
- **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编
|
||||
`linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染
|
||||
后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
|
||||
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::pages::appearance::Change;
|
||||
@@ -54,7 +55,7 @@ impl App {
|
||||
} else {
|
||||
line.push_str(" layered=skipped outcome=no-window");
|
||||
}
|
||||
eprintln!("{line}");
|
||||
diag!("{line}");
|
||||
}
|
||||
|
||||
/// 设置页改了一项外观。
|
||||
@@ -123,8 +124,8 @@ impl App {
|
||||
self.appearance_prefs = prefs;
|
||||
|
||||
match self.bridge.path() {
|
||||
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
|
||||
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
|
||||
Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
|
||||
None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iced::Task;
|
||||
@@ -51,10 +52,10 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let font_started = Instant::now();
|
||||
let resolved = fonts::resolve();
|
||||
let font_ms = font_started.elapsed();
|
||||
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
|
||||
eprintln!("{}", resolved.describe());
|
||||
diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
|
||||
diag!("{}", resolved.describe());
|
||||
if resolved.has_synthetic() {
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
|
||||
);
|
||||
}
|
||||
@@ -64,7 +65,7 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
|
||||
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
|
||||
if options.screenshot.is_some() && options.shot_flavor().is_none() {
|
||||
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||||
diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||||
}
|
||||
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
diagnostics::report_theme(&options, flavor);
|
||||
@@ -86,14 +87,14 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
}
|
||||
Some(OnFailure::Refuse) => {
|
||||
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
|
||||
options
|
||||
.screenshot
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |path| path.display().to_string())
|
||||
);
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
|
||||
);
|
||||
Err(iced::Error::GraphicsCreationFailed(error))
|
||||
@@ -192,13 +193,13 @@ fn boot_probe(options: &Options) -> Boot {
|
||||
if let Some(kind) = renderer::forced_fallback() {
|
||||
let decision = renderer::fallback_round(kind);
|
||||
std::env::set_var("ICED_BACKEND", kind.env_value());
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
|
||||
renderer::FALLBACK_ENV,
|
||||
kind.env_value(),
|
||||
kind.env_value()
|
||||
);
|
||||
eprintln!("[probe] {}", decision.describe());
|
||||
diag!("[probe] {}", decision.describe());
|
||||
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
|
||||
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
|
||||
let (probe, probe_ms) = if acceptance::enabled() {
|
||||
@@ -232,11 +233,11 @@ fn boot_probe(options: &Options) -> Boot {
|
||||
|
||||
if cfg!(feature = "gpu") {
|
||||
match probe_ms {
|
||||
Some(elapsed) => eprintln!(
|
||||
Some(elapsed) => diag!(
|
||||
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
),
|
||||
None => eprintln!(
|
||||
None => diag!(
|
||||
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
|
||||
options.backend.name()
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
|
||||
//! 只能建一次事件循环。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::renderer::Decision;
|
||||
use crate::renderer::{self, acceptance, Kind};
|
||||
|
||||
@@ -58,10 +59,10 @@ pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2]
|
||||
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
|
||||
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
|
||||
let exe = std::env::current_exe().unwrap_or_else(|err| {
|
||||
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
|
||||
diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
|
||||
std::process::exit(exit_code(None));
|
||||
});
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
|
||||
下一次的日志从 `[probe] 本次是降级重开` 开始。",
|
||||
fallback.name(),
|
||||
@@ -78,7 +79,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
|
||||
let code = match status {
|
||||
Ok(status) => exit_code(status.code()),
|
||||
Err(err) => {
|
||||
eprintln!("[gpu] 降级重开失败:{err}");
|
||||
diag!("[gpu] 降级重开失败:{err}");
|
||||
exit_code(None)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
|
||||
//!
|
||||
//! ## 为什么要有这一层
|
||||
//!
|
||||
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
|
||||
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
|
||||
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
|
||||
//!
|
||||
//! 现在这些行同时进 `launch::launcher_log`:
|
||||
//!
|
||||
//! ```text
|
||||
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
|
||||
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
|
||||
//! ```
|
||||
//!
|
||||
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
|
||||
//!
|
||||
//! ## 哪些行进日志、哪些只进 stderr
|
||||
//!
|
||||
//! 判据只有一条:**事故之后还要不要看它**。
|
||||
//!
|
||||
//! | 进日志 | 只进 stderr |
|
||||
//! |---|---|
|
||||
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | 验收与出图通道的逐帧度量(`[window]` / `[perf]` / `[caps]`——那些行由 `renderer::acceptance` 打,不在本 mission scope) |
|
||||
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 组件几何探针(`[feedback-probe]` / `[gallery]` 的断言输出,`components/**`) |
|
||||
//! | 一次性且量很小的诊断(`[appearance]` / `[corner]` 的切换、`[theme]`、`[shot]` 的通道提示) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
|
||||
//!
|
||||
//! 反过来**不许**把高频行接进来:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
|
||||
//! 逐帧写会把「事故现场」冲掉。
|
||||
|
||||
/// 行首没有 `[tag]` 时的默认 scope。
|
||||
pub const APP_SCOPE: &str = "app";
|
||||
|
||||
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
|
||||
pub(crate) fn emit(line: &str) {
|
||||
eprintln!("{line}");
|
||||
let (scope, message) = split_scope(line);
|
||||
unlucky_core::launch::launcher_log::record(scope, message);
|
||||
}
|
||||
|
||||
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
|
||||
///
|
||||
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
|
||||
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
|
||||
let Some(rest) = line.strip_prefix('[') else {
|
||||
return (APP_SCOPE, line);
|
||||
};
|
||||
let Some((scope, tail)) = rest.split_once(']') else {
|
||||
return (APP_SCOPE, line);
|
||||
};
|
||||
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
|
||||
return (APP_SCOPE, line);
|
||||
}
|
||||
(scope, tail.trim_start())
|
||||
}
|
||||
|
||||
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
|
||||
macro_rules! diag {
|
||||
($($arg:tt)*) => {{
|
||||
crate::app::diag::emit(&format!($($arg)*));
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use diag;
|
||||
|
||||
/// 串行化「会改 `launcher_log` 全局目录」的用例。
|
||||
///
|
||||
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
|
||||
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
|
||||
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
|
||||
#[cfg(test)]
|
||||
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_scope;
|
||||
|
||||
#[test]
|
||||
fn the_leading_bracket_becomes_the_scope() {
|
||||
assert_eq!(
|
||||
split_scope("[font] 求值耗时 12 ms"),
|
||||
("font", "求值耗时 12 ms")
|
||||
);
|
||||
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
|
||||
// 正文里的方括号不是标签
|
||||
assert_eq!(
|
||||
split_scope("[page] 占位动作 [x] pressed"),
|
||||
("page", "占位动作 [x] pressed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_without_a_tag_fall_back_to_the_default_scope() {
|
||||
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
|
||||
assert_eq!(split_scope("[a b] 空白不是标签"), ("app", "[a b] 空白不是标签"));
|
||||
}
|
||||
|
||||
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
|
||||
///
|
||||
/// ## 为什么要「重试」
|
||||
///
|
||||
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
|
||||
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
|
||||
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
|
||||
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
|
||||
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
|
||||
///
|
||||
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
|
||||
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
|
||||
#[test]
|
||||
fn the_launcher_log_rolls_over_and_stays_bounded() {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
|
||||
let _guard = super::LOG_STATE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建夹具目录");
|
||||
let path = dir.join(launcher_log::FILE_NAME);
|
||||
let rolled = dir.join("launcher.1.log");
|
||||
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
|
||||
|
||||
for _ in 0..50 {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&rolled);
|
||||
launcher_log::set_dir(&dir);
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue; // 这一轮被别人的 set_dir 抢先了
|
||||
}
|
||||
launcher_log::record("boot", "第一行");
|
||||
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue; // 追加落到了别人的目录,这一轮不算
|
||||
}
|
||||
|
||||
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
|
||||
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
|
||||
let size = std::fs::metadata(&path).expect("新文件").len();
|
||||
assert!(
|
||||
size < launcher_log::MAX_BYTES,
|
||||
"滚完之后新文件必须远小于上限:{size}"
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).expect("读新文件");
|
||||
assert!(text.contains("滚过之后这一行"), "{text}");
|
||||
|
||||
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
|
||||
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
|
||||
launcher_log::record("boot", "第二次滚动");
|
||||
assert!(rolled.is_file());
|
||||
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
|
||||
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
return;
|
||||
}
|
||||
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
|
||||
}
|
||||
}
|
||||
@@ -150,12 +150,22 @@ pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String {
|
||||
&[("error", "no folder on disk")],
|
||||
);
|
||||
};
|
||||
match spawn_opener(&dir) {
|
||||
match open_dir(&dir) {
|
||||
Ok(()) => String::new(),
|
||||
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。
|
||||
///
|
||||
/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。
|
||||
pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{}(目录不存在)", dir.display()));
|
||||
}
|
||||
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
|
||||
|
||||
@@ -321,6 +321,10 @@ fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
|
||||
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
|
||||
#[test]
|
||||
fn boot_facts_are_recorded_into_the_launcher_log() {
|
||||
// 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。
|
||||
let _guard = crate::app::diag::LOG_STATE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
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("建目录");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@
|
||||
mod appearance;
|
||||
mod backend;
|
||||
mod boot;
|
||||
mod diag;
|
||||
mod dialog;
|
||||
pub(crate) mod logs_folder;
|
||||
mod message;
|
||||
mod state;
|
||||
mod subscription;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
|
||||
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
@@ -32,7 +33,7 @@ pub(crate) fn log_first_frame() {
|
||||
return;
|
||||
}
|
||||
if let Some(started) = STARTED.get() {
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
|
||||
@@ -41,6 +41,9 @@ impl App {
|
||||
Some(root) => SettingsBridge::with_path(config_path(root)),
|
||||
None => SettingsBridge::open(),
|
||||
};
|
||||
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
|
||||
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
|
||||
crate::app::logs_folder::register(&bridge);
|
||||
Self::with_presenter(options, families, flavor, backend, bridge)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
|
||||
@@ -30,7 +31,7 @@ impl App {
|
||||
self.appearance.set_flavor(flavor);
|
||||
let previous = self.ui.name();
|
||||
self.rebuild_ui();
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||||
previous,
|
||||
self.ui.name()
|
||||
@@ -48,7 +49,7 @@ impl App {
|
||||
let panel_alpha = self.appearance.effective_panel_alpha();
|
||||
self.ui = match self.appearance.derived_scheme() {
|
||||
Some(scheme) => {
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
|
||||
self.appearance.palette_level()
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//!
|
||||
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Task};
|
||||
@@ -88,7 +89,7 @@ impl App {
|
||||
match hwnd {
|
||||
Some(_) => self.apply_window_alpha("boot"),
|
||||
None => {
|
||||
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
|
||||
diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
|
||||
self.log_appearance("boot", None);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +103,7 @@ impl App {
|
||||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||
};
|
||||
eprintln!("[theme] 系统主题 = {mode:?}");
|
||||
diag!("[theme] 系统主题 = {mode:?}");
|
||||
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
|
||||
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
|
||||
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
|
||||
@@ -169,7 +170,7 @@ impl App {
|
||||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||
Message::Demo(message) => {
|
||||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||||
eprintln!("[gallery] {} pressed", message.id());
|
||||
diag!("[gallery] {} pressed", message.id());
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -187,7 +188,7 @@ impl App {
|
||||
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
|
||||
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
|
||||
// 「没配置」与「配了但登录失败」。
|
||||
eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line());
|
||||
diag!("{}", unlucky_cli::commands::msa_client_id_log_line());
|
||||
|
||||
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
|
||||
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
|
||||
@@ -218,7 +219,7 @@ impl App {
|
||||
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
|
||||
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
|
||||
if let Some(route) = boot_env::env_start_route() {
|
||||
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
|
||||
diag!("[boot] ULL_START_ROUTE → {route:?}");
|
||||
self.state.navigate(route);
|
||||
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
|
||||
self.auto_selected = true;
|
||||
@@ -228,7 +229,7 @@ impl App {
|
||||
account::force_account_state(self);
|
||||
// M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。
|
||||
if let Some(mode) = dialog_fixture::apply(self) {
|
||||
eprintln!("[boot] ULL_DIALOG_FIXTURE → {mode}");
|
||||
diag!("[boot] ULL_DIALOG_FIXTURE → {mode}");
|
||||
}
|
||||
|
||||
self.auto_select();
|
||||
@@ -287,9 +288,9 @@ impl App {
|
||||
};
|
||||
match crate::shot::save(screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
eprintln!("{}", summary.describe());
|
||||
diag!("{}", summary.describe());
|
||||
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[shot] screen={} theme={} renderer={} route={:?}",
|
||||
self.options.screen.name(),
|
||||
crate::shot::flavor_slug(&self.ui),
|
||||
@@ -297,7 +298,7 @@ impl App {
|
||||
self.state.route()
|
||||
);
|
||||
}
|
||||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||||
Err(error) => diag!("[shot] 失败:{error}"),
|
||||
}
|
||||
|
||||
iced::exit()
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
|
||||
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
@@ -141,10 +142,10 @@ impl App {
|
||||
self.drain_agent();
|
||||
}
|
||||
SubmitOutcome::Failed(failure) => {
|
||||
eprintln!("[agent] 提交失败({})", failure.code());
|
||||
diag!("[agent] 提交失败({})", failure.code());
|
||||
}
|
||||
SubmitOutcome::Rejected => {
|
||||
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +205,7 @@ impl App {
|
||||
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
|
||||
if pending && !on_confirm {
|
||||
self.agent.focus = Focus::ConfirmAllow;
|
||||
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
|
||||
diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
|
||||
} else if !pending && on_confirm {
|
||||
self.agent.focus = Focus::Composer;
|
||||
self.agent.keyboard_mode = false;
|
||||
@@ -279,7 +280,7 @@ impl App {
|
||||
}
|
||||
|
||||
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
|
||||
eprintln!("[agent] rail=detached → Route::Agent");
|
||||
diag!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
|
||||
self.auto_selected = true;
|
||||
@@ -371,7 +372,7 @@ impl App {
|
||||
let _ = self.on_agent(message);
|
||||
} else {
|
||||
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[agent] Enter on focus={} handled by the composer",
|
||||
self.agent.focus.name()
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
|
||||
//! (M116 取消了 8/12/16 固定档)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::platform;
|
||||
use crate::platform::win32::corner::{self, CornerApplied};
|
||||
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
|
||||
@@ -73,7 +74,7 @@ impl App {
|
||||
"frame=? layered=?".to_owned(),
|
||||
),
|
||||
};
|
||||
eprintln!(
|
||||
diag!(
|
||||
"{}",
|
||||
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
|
||||
);
|
||||
|
||||
@@ -31,15 +31,15 @@ pub(super) fn env_start_route() -> Option<Route> {
|
||||
///
|
||||
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
|
||||
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
|
||||
/// (core 侧缓冲,最多 200 行);第一次启动时 presenter 调 `launcher_log::set_dir`,缓冲一次
|
||||
/// 刷进 `<.ULL>/logs/launcher.log`。
|
||||
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
|
||||
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
|
||||
///
|
||||
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
|
||||
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
|
||||
///
|
||||
/// 边界(写在明处):进程若一次都没启动游戏、又没触发导出,`set_dir` 就不会被调用,
|
||||
/// 这些行只留在内存里 → 那个进程不产生 `launcher.log`。要彻底解决得让 presenter 在构造
|
||||
/// `HostEnv` 时就登记目录(`host/mod.rs` 不在本 mission scope,已登记为 follow-up)。
|
||||
/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走
|
||||
/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」
|
||||
/// 的进程也会留下 `launcher.log`。
|
||||
pub(crate) fn record_boot_log(app: &super::super::state::App) {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
|
||||
@@ -58,4 +58,15 @@ pub(crate) fn record_boot_log(app: &super::super::state::App) {
|
||||
),
|
||||
);
|
||||
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
|
||||
// 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到
|
||||
// stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。
|
||||
launcher_log::record(
|
||||
"boot",
|
||||
&format!(
|
||||
"backend request={} effective={} renderer_fallback={}",
|
||||
app.options.backend.name(),
|
||||
super::super::backend::effective_backend(app.options.backend).name(),
|
||||
std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
|
||||
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
@@ -74,7 +75,7 @@ impl App {
|
||||
let reloaded =
|
||||
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
|
||||
if reloaded {
|
||||
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
|
||||
diag!("[settings] 配置文件变了,重读主题 + 外观");
|
||||
self.apply_config_theme();
|
||||
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
|
||||
self.reload_appearance();
|
||||
@@ -92,7 +93,7 @@ impl App {
|
||||
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
|
||||
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
|
||||
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
|
||||
eprintln!(
|
||||
diag!(
|
||||
"[tick] n={} frames={} refreshes={} clock={}",
|
||||
self.ticks,
|
||||
self.frames,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
|
||||
ExportRequestView,
|
||||
@@ -199,10 +200,10 @@ impl App {
|
||||
if !model.can_install() {
|
||||
// 有原因就打印出来(取证用);没有选中项时什么都不做
|
||||
for reason in model.blocker_reasons(catalog) {
|
||||
eprintln!("[content] 装入被挡下:{reason}");
|
||||
diag!("[content] 装入被挡下:{reason}");
|
||||
}
|
||||
if model.blockers.contains(&Blocker::NeedsPipeline) {
|
||||
eprintln!("[content] 整合包装入未实现(M111)");
|
||||
diag!("[content] 整合包装入未实现(M111)");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::pages;
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
@@ -94,9 +95,23 @@ impl App {
|
||||
pages::Message::Instances(message) => return self.on_instance_message(message),
|
||||
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
|
||||
pages::Message::Account(message) => return self.on_account(message),
|
||||
// M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推
|
||||
// (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。
|
||||
pages::Message::Action(pages::Action::OpenLogs) => {
|
||||
return match crate::app::logs_folder::open() {
|
||||
Ok(dir) => {
|
||||
diag!("[logs] 已打开日志文件夹 {}", dir.display());
|
||||
Task::none()
|
||||
}
|
||||
Err(error) => {
|
||||
diag!("[logs] 打开日志文件夹失败:{error}");
|
||||
iced::clipboard::write(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||
pages::Message::Action(action) => {
|
||||
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +142,7 @@ impl App {
|
||||
self.lang = LangState::of(choice);
|
||||
self.lang.log();
|
||||
if !self.bridge.persist_ui_language(choice.setting_value()) {
|
||||
eprintln!(
|
||||
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
|
||||
);
|
||||
diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
|
||||
}
|
||||
self.lang.log_cjk();
|
||||
self.view_dirty = true;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
|
||||
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
@@ -284,7 +285,7 @@ fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
|
||||
WriteError::Refused(refusal) => Notice::Refused(refusal),
|
||||
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
|
||||
WriteError::Schema(detail) => {
|
||||
eprintln!("[java] 设置写盘失败:{detail}");
|
||||
diag!("[java] 设置写盘失败:{detail}");
|
||||
Notice::WriteFailed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
|
||||
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
@@ -54,7 +55,7 @@ impl App {
|
||||
|
||||
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
|
||||
fn refuse_busy(&self, what: &str) -> Task<Message> {
|
||||
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -64,26 +65,26 @@ impl App {
|
||||
/// 文件、并把取证进程拖成死循环。
|
||||
fn restart_now(&mut self) -> Task<Message> {
|
||||
if self.options.screenshot.is_some() {
|
||||
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
|
||||
diag!("[update] 截图通道不重启(忽略「立即重启」)");
|
||||
return Task::none();
|
||||
}
|
||||
match std::env::current_exe() {
|
||||
Ok(exe) => {
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
match std::process::Command::new(&exe).args(&args).spawn() {
|
||||
Ok(child) => eprintln!(
|
||||
Ok(child) => diag!(
|
||||
"[update] 已拉起新进程 pid={}:{}",
|
||||
child.id(),
|
||||
exe.display()
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("[update] 取 current_exe 失败:{error}");
|
||||
diag!("[update] 取 current_exe 失败:{error}");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('⟪'));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,8 @@ pub const DATA_DIR: &str = "data";
|
||||
pub const CACHE_DIR: &str = "cache";
|
||||
/// 分区:启动器日志。
|
||||
pub const LOGS_DIR: &str = "logs";
|
||||
/// `logs/` 下的游戏会话目录名(事件流与 `index.jsonl`;M188)。
|
||||
pub const GAME_LOG_DIR: &str = "game";
|
||||
|
||||
/// 全局设置文件名。
|
||||
pub const LAUNCHER_CONFIG_FILE: &str = "launcher.json";
|
||||
@@ -181,7 +183,25 @@ impl LauncherLayout {
|
||||
self.data().join("auth")
|
||||
}
|
||||
|
||||
/// `data/agent/sessions/`——AI 会话 jsonl 与审计日志。
|
||||
/// 会话根(M188 起 = `logs/`)。
|
||||
///
|
||||
/// `launch::LauncherConfig` 在会话根下自己建 `game/`,所以「会话根」与「事件流目录」差一层:
|
||||
/// 根是这里,真落点是 [`Self::game_logs`]。
|
||||
#[must_use]
|
||||
pub fn sessions_root(&self) -> PathBuf {
|
||||
self.logs()
|
||||
}
|
||||
|
||||
/// `logs/game/`——游戏会话事件流(`<session-id>.jsonl`)与 `index.jsonl` 的真落点(M188)。
|
||||
#[must_use]
|
||||
pub fn game_logs(&self) -> PathBuf {
|
||||
self.logs().join(GAME_LOG_DIR)
|
||||
}
|
||||
|
||||
/// `data/agent/sessions/`——会话日志的**旧家**(M188 起只读兼容)。
|
||||
///
|
||||
/// 目录名里的 `agent` 是历史遗留:它从来只装游戏会话,与 AI 无关。M188 把新会话挪到
|
||||
/// `logs/game/`,这里**不搬不删**(用户的数据不该被偷偷移动),只是继续被读出来。
|
||||
#[must_use]
|
||||
pub fn agent_sessions(&self) -> PathBuf {
|
||||
self.data().join("agent").join("sessions")
|
||||
|
||||
@@ -24,6 +24,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"),
|
||||
("SettingsLanguage", "Language", "界面语言"),
|
||||
("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"),
|
||||
("SettingsLogsFolder", "Log folder", "日志文件夹"),
|
||||
("SettingsLogsFolderHint", "What explains a crash lives here: the launcher's own runtime log (launcher.log, rolled over at 1 MiB with one old copy kept) and one jsonl per game session under logs/game/. Sessions from older versions (data/agent/sessions) are still read, not moved.", "解释一次崩溃要看的东西都在这:启动器自己的运行日志(launcher.log,满 1 MiB 滚动一次、只留一份旧档)与每次游戏会话的 jsonl(logs/game/)。旧版本留下的会话(data/agent/sessions)仍然读得出来,不会被搬动。"),
|
||||
("SettingsLogsCopy", "Copy path", "复制路径"),
|
||||
("SettingsLogsUnknown", "Data directory not resolved — restart the launcher from its own folder", "数据根没解析出来——从启动器所在目录重新启动试试"),
|
||||
("SettingsAppearanceCorner", "Window corners", "窗口圆角"),
|
||||
("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"),
|
||||
("SettingsAppearanceCornerSquare", "Square", "直角"),
|
||||
@@ -389,9 +393,4 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"),
|
||||
("PackPathLabel", "Pack path", "整合包路径"),
|
||||
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
|
||||
("PackImportAction", "Import", "导入"),
|
||||
("PackCancel", "Cancel import", "取消导入"),
|
||||
("PackDismiss", "Dismiss", "关闭"),
|
||||
("PackTargetLabel", "Install into", "装到实例"),
|
||||
("PackTargetNew", "New instance", "新建实例"),
|
||||
];
|
||||
@@ -4,6 +4,11 @@
|
||||
//! 拼装见 `golden.rs` 的 [`super::rows`]。
|
||||
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackImportAction", "Import", "导入"),
|
||||
("PackCancel", "Cancel import", "取消导入"),
|
||||
("PackDismiss", "Dismiss", "关闭"),
|
||||
("PackTargetLabel", "Install into", "装到实例"),
|
||||
("PackTargetNew", "New instance", "新建实例"),
|
||||
("PackInstanceName", "Instance name", "实例名"),
|
||||
("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
|
||||
("PackStageProbe", "Reading the manifest", "读取清单"),
|
||||
|
||||
@@ -12,6 +12,17 @@ macro_rules! settings_general_entries {
|
||||
"Switches the interface language. Chinese text needs a Chinese font.",
|
||||
"切换界面语言。显示中文需要中文字体。"
|
||||
),
|
||||
// ── 日志文件夹(M188:用户投诉「找不到崩溃日志」的直接落点)
|
||||
SettingsLogsFolder => ("Log folder", "日志文件夹"),
|
||||
SettingsLogsFolderHint => (
|
||||
"What explains a crash lives here: the launcher's own runtime log (launcher.log, rolled over at 1 MiB with one old copy kept) and one jsonl per game session under logs/game/. Sessions from older versions (data/agent/sessions) are still read, not moved.",
|
||||
"解释一次崩溃要看的东西都在这:启动器自己的运行日志(launcher.log,满 1 MiB 滚动一次、只留一份旧档)与每次游戏会话的 jsonl(logs/game/)。旧版本留下的会话(data/agent/sessions)仍然读得出来,不会被搬动。"
|
||||
),
|
||||
SettingsLogsCopy => ("Copy path", "复制路径"),
|
||||
SettingsLogsUnknown => (
|
||||
"Data directory not resolved — restart the launcher from its own folder",
|
||||
"数据根没解析出来——从启动器所在目录重新启动试试"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,9 +65,17 @@ impl HostEnv {
|
||||
}
|
||||
|
||||
/// 由解析结果建环境。
|
||||
///
|
||||
/// **顺带做一件有副作用的事**:把本进程的日志目录登记给 `launch::launcher_log`(M188)。
|
||||
/// 这是**启动早期那几行诊断**能落到 `<.ULL>/logs/launcher.log` 的唯一时机——app 层在
|
||||
/// presenter 打开之前就在打 `[probe]` / `[font]` / `[theme]`,那些行先被 core 缓冲
|
||||
/// (上限 200 行),到这里一次刷出。此前只有「启动一次游戏」或「导出诊断包」才登记,
|
||||
/// 于是「双击 → 启动 → 崩」的进程根本不产生 launcher.log——正是用户投诉的那一半。
|
||||
pub fn from_data(data: ResolvedData) -> Self {
|
||||
let layout = data.layout();
|
||||
unlucky_core::launch::launcher_log::set_dir(layout.logs());
|
||||
Self {
|
||||
layout: data.layout(),
|
||||
layout,
|
||||
game: GameLayout::new(data.game_home.clone()),
|
||||
platform: Platform::current(),
|
||||
data,
|
||||
@@ -84,11 +92,21 @@ impl HostEnv {
|
||||
SettingStore::load(self.launcher_config(), launcher_schema())
|
||||
}
|
||||
|
||||
/// 启动会话根(`.ULL/data/agent/sessions`)。
|
||||
/// 启动会话根(M188 起 = `logs/`;`LauncherConfig` 在其下建 `game/`)。
|
||||
pub fn sessions_root(&self) -> PathBuf {
|
||||
self.layout.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话日志的**旧家**(`data/agent/sessions/`):只读兼容,M188 起不再往里写。
|
||||
pub fn legacy_sessions_root(&self) -> PathBuf {
|
||||
self.layout.agent_sessions()
|
||||
}
|
||||
|
||||
/// 启动器日志目录(`logs/`:`launcher.log` + `game/` 会话)。
|
||||
pub fn logs_dir(&self) -> PathBuf {
|
||||
self.layout.logs()
|
||||
}
|
||||
|
||||
/// 数据目录模式。
|
||||
pub fn mode(&self) -> DataMode {
|
||||
self.data.mode
|
||||
@@ -137,14 +155,133 @@ pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_core::platform::paths::MapEnv;
|
||||
|
||||
#[test]
|
||||
fn at_root_splits_launcher_and_game_homes() {
|
||||
let env = HostEnv::at_root("D:/tmp/ull-demo");
|
||||
assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL"));
|
||||
assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft"));
|
||||
let root = std::env::temp_dir().join(format!("ull-host-env-root-{}", std::process::id()));
|
||||
let env = HostEnv::at_root(&root);
|
||||
assert_eq!(env.layout.home(), root.join(".ULL"));
|
||||
assert_eq!(env.game.root(), root.join(".minecraft"));
|
||||
assert_eq!(env.mode(), DataMode::Portable);
|
||||
assert!(env.data_line().contains("portable"));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
|
||||
///
|
||||
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
|
||||
///
|
||||
/// | 模式 | 期望的 `<home>` | 期望的日志根 |
|
||||
/// |---|---|---|
|
||||
/// | portable | `<exe>/.ULL` | `<exe>/.ULL/logs` |
|
||||
/// | standard | `%APPDATA%/ULL` | `%APPDATA%/ULL/logs` |
|
||||
/// | custom | `<root>/.ULL` | `<root>/.ULL/logs` |
|
||||
///
|
||||
/// 这里只碰解析结果、不建 `HostEnv`:`from_data` 会真的去登记日志目录(那是产品行为,
|
||||
/// 单测里拿假绝对路径调它会在盘根建目录)。
|
||||
/// 变异证明:把 `LauncherLayout::logs` 改成返回 `cache/`,三条全红。
|
||||
#[test]
|
||||
fn logs_root_follows_the_three_data_modes() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let exe_dir = PathBuf::from("/games/ULL");
|
||||
// custom 模式的根必须是**绝对路径**(Windows 上 `/custom-root` 不算绝对,要带盘符)。
|
||||
let custom_root = std::env::temp_dir().join("ull-m188-custom");
|
||||
let resolver = Resolver::with_env(Platform::current(), Some(exe_dir.clone()), &env);
|
||||
|
||||
let cases = [
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Portable,
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
exe_dir.join(".ULL/logs"),
|
||||
),
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Standard,
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
PathBuf::new(),
|
||||
),
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(custom_root.clone()),
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
custom_root.join(".ULL/logs"),
|
||||
),
|
||||
];
|
||||
|
||||
for (request, expected) in cases {
|
||||
let Ok(data) = resolver.resolve(&request) else {
|
||||
// 标准模式在某些平台拿不到(没有 HOME/APPDATA):这一档只能跳过,不许假绿。
|
||||
assert_eq!(request.mode, DataMode::Standard, "{request:?}");
|
||||
continue;
|
||||
};
|
||||
let home = data.launcher_home.clone();
|
||||
let layout = data.layout();
|
||||
assert_eq!(layout.logs(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(layout.sessions_root(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(layout.game_logs(), home.join("logs/game"), "{request:?}");
|
||||
assert_eq!(
|
||||
layout.agent_sessions(),
|
||||
home.join("data/agent/sessions"),
|
||||
"{request:?}"
|
||||
);
|
||||
if !expected.as_os_str().is_empty() {
|
||||
assert_eq!(layout.logs(), expected, "{request:?}");
|
||||
}
|
||||
// 自定义模式的根在临时目录里,顺手把 `HostEnv` 那三个访问器也钉一遍——
|
||||
// 「layout 对了但 `HostEnv::logs_dir` 写死」这种假绿只有这一段抓得住。
|
||||
if request.mode == DataMode::Custom {
|
||||
let env = HostEnv::from_data(data);
|
||||
assert_eq!(env.logs_dir(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(env.sessions_root(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(
|
||||
env.legacy_sessions_root(),
|
||||
home.join("data/agent/sessions"),
|
||||
"{request:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种模式的日志根**各不相同**(防止「都指向同一个写死的目录」这种假绿)。
|
||||
#[test]
|
||||
fn the_three_modes_do_not_share_one_logs_root() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let resolver =
|
||||
Resolver::with_env(Platform::current(), Some(PathBuf::from("/games/ULL")), &env);
|
||||
let portable = resolver
|
||||
.resolve(&DataDirRequest {
|
||||
mode: DataMode::Portable,
|
||||
..DataDirRequest::default()
|
||||
})
|
||||
.expect("portable 一定解析得出");
|
||||
let custom = resolver
|
||||
.resolve(&DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(std::env::temp_dir().join("ull-m188-custom")),
|
||||
..DataDirRequest::default()
|
||||
})
|
||||
.expect("custom 有绝对路径");
|
||||
assert_ne!(portable.layout().logs(), custom.layout().logs());
|
||||
}
|
||||
|
||||
/// `HostEnv` 自己的三格(日志根 / 会话根 / 旧会话根)指着同一份解析结果。
|
||||
#[test]
|
||||
fn env_paths_come_from_one_layout() {
|
||||
let root = std::env::temp_dir().join(format!("ull-host-env-paths-{}", std::process::id()));
|
||||
let env = HostEnv::at_root(&root);
|
||||
assert_eq!(env.logs_dir(), root.join(".ULL/logs"));
|
||||
assert_eq!(env.sessions_root(), root.join(".ULL/logs"));
|
||||
assert_eq!(
|
||||
env.legacy_sessions_root(),
|
||||
root.join(".ULL/data/agent/sessions")
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,6 @@ use unlucky_core::game::install::instance::{
|
||||
};
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::{ProfileList, VersionJson};
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
use crate::dto::{
|
||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
||||
@@ -34,10 +33,9 @@ pub fn profiles(env: &HostEnv) -> ProfileList {
|
||||
read_profiles(&env.layout).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 全会话索引里「每个实例最近一次启动」的时间串。
|
||||
/// 全会话索引里「每个实例最近一次启动」的时间串(`logs/` + 旧家合并)。
|
||||
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
|
||||
let index = session_index::index_path(&env.sessions_root());
|
||||
let entries = session_index::read_all(&index).unwrap_or_default();
|
||||
let entries = super::logs::entries(env);
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
|
||||
|
||||
@@ -142,8 +142,8 @@ impl CoreHost {
|
||||
if self.tail.session().is_none() {
|
||||
self.tail.attach(
|
||||
self.env
|
||||
.sessions_root()
|
||||
.join("game")
|
||||
.layout
|
||||
.game_logs()
|
||||
.join(format!("{session_id}.jsonl")),
|
||||
&session_id,
|
||||
);
|
||||
|
||||
@@ -219,6 +219,8 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
let tx_progress = tx.clone();
|
||||
super::job::spawn("launch", move || {
|
||||
// M187:启动器自己的运行日志从这里开始有落点(早于这一拍的行由 core 缓冲接住)。
|
||||
// M188 起 presenter 在构造 `HostEnv` 时已经登记过目录(缓冲那时就刷出去了),这里是
|
||||
// 幂等的一次兜底:数据根被 `redirect.json` 改过时以最后一次为准。
|
||||
let logs_dir = env.layout.logs();
|
||||
launcher_log::set_dir(&logs_dir);
|
||||
let started_at = std::time::SystemTime::now();
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
//! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎):
|
||||
//! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。
|
||||
//! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。
|
||||
//!
|
||||
//! ## 会话日志在哪(M188)
|
||||
//!
|
||||
//! | 位置 | 角色 |
|
||||
//! |---|---|
|
||||
//! | `<.ULL>/logs/game/<session-id>.jsonl` + `index.jsonl` | **新会话写这里** |
|
||||
//! | `<.ULL>/data/agent/sessions/game/…` | 旧家:**只读兼容**,不搬不删 |
|
||||
//!
|
||||
//! 两个根的合并、落点记录都在 [`location`] 里;本文件只做「读出来 → DTO」的翻译。
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -11,7 +20,8 @@ use serde_json::Value;
|
||||
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::launch::session_index::SessionIndexEntry;
|
||||
use unlucky_core::platform::layout::GAME_LOG_DIR;
|
||||
|
||||
use crate::dto::{
|
||||
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
||||
@@ -19,25 +29,17 @@ use crate::dto::{
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
#[path = "logs/location.rs"]
|
||||
mod location;
|
||||
|
||||
pub use location::{entries, legacy_root, note_location, root, sessions};
|
||||
|
||||
/// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。
|
||||
pub const DEFAULT_TAIL: usize = 2000;
|
||||
|
||||
/// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。
|
||||
pub const MAX_RENDERED_LINES: usize = 2_000;
|
||||
|
||||
/// 会话索引目录(`.ULL/data/agent/sessions`)。
|
||||
pub fn root(env: &HostEnv) -> PathBuf {
|
||||
env.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话列表(新→旧)。
|
||||
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
|
||||
let index = session_index::index_path(&root(env));
|
||||
let mut entries = session_index::read_all(&index).unwrap_or_default();
|
||||
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
entries.iter().map(session_of).collect()
|
||||
}
|
||||
|
||||
/// 索引条目 → 左栏会话行。
|
||||
pub fn session_of(entry: &SessionIndexEntry) -> LogSession {
|
||||
LogSession {
|
||||
@@ -78,15 +80,26 @@ pub fn display_time(started_at: &str) -> String {
|
||||
format!("{date} {time}")
|
||||
}
|
||||
|
||||
/// 事件流文件路径(`<sessions>/game/<log_path>`)。
|
||||
/// 事件流文件路径(`<logs>/game/<log_path>`;新根没有时回落到旧家)。
|
||||
///
|
||||
/// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。
|
||||
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
|
||||
root(env).join("game").join(&entry.log_path)
|
||||
let primary = root(env).join(GAME_LOG_DIR).join(&entry.log_path);
|
||||
if primary.is_file() {
|
||||
return primary;
|
||||
}
|
||||
if let Some(legacy) = legacy_root(env) {
|
||||
let fallback = legacy.join(GAME_LOG_DIR).join(&entry.log_path);
|
||||
if fallback.is_file() {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
primary
|
||||
}
|
||||
|
||||
/// 会话 id 或唯一前缀 → 索引条目。
|
||||
/// 会话 id 或唯一前缀 → 索引条目(两个根的合并视图)。
|
||||
fn find(env: &HostEnv, key: &str) -> Option<SessionIndexEntry> {
|
||||
let index = session_index::index_path(&root(env));
|
||||
let entries = session_index::read_all(&index).ok()?;
|
||||
let entries = entries(env);
|
||||
if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Some(exact.clone());
|
||||
}
|
||||
@@ -328,13 +341,15 @@ impl Tail {
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。
|
||||
/// 会话是否已收尾(任一索引里出现 `ended_at` 或文件停止增长超过一拍)。
|
||||
pub fn session_finished(env: &HostEnv, session: &str) -> bool {
|
||||
let index = session_index::index_path(&root(env));
|
||||
session_index::read_all(&index)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
|
||||
location::roots(env).into_iter().any(|dir| {
|
||||
let index = unlucky_core::launch::session_index::index_path(&dir);
|
||||
unlucky_core::launch::session_index::read_all(&index)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|entry| entry.session_id == session && !entry.ended_at.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 会话日志的**落点**:新家 `logs/`、旧家 `data/agent/sessions/`,以及两者合并的读法(M188)。
|
||||
//!
|
||||
//! ## 为什么要搬
|
||||
//!
|
||||
//! 旧落点是 `<.ULL>/data/agent/sessions/game/`:目录名里的 `agent` 是历史遗留(它从来只装**游戏**
|
||||
//! 会话,与 AI 无关),而 `data/` 分区是「可重建但优先保留」的状态数据。用户投诉原话是「我们的
|
||||
//! 启动日志放在哪?…他找半天没找到崩溃日志」——把日志放在 `data/agent/…` 底下,找不着是必然的。
|
||||
//!
|
||||
//! 新落点 `<.ULL>/logs/game/`:与启动器自己的 `launcher.log` 同一个分区、同一个顶层目录,
|
||||
//! 「打开日志文件夹」一次就能看见两者。
|
||||
//!
|
||||
//! ## 旧数据怎么办:**不搬**
|
||||
//!
|
||||
//! 搬动用户已有数据是不可逆动作(中途失败留半份会话、崩溃弹窗的会话关联会断),收益只是目录好看。
|
||||
//! 所以旧目录**原样留着、继续被读**:本模块的 [`entries`] 是两个根的合并视图,同 id 以新根为准。
|
||||
//! 「我们没动你的数据」这件事写进 [`note_location`] 的记录里,事后可查。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||
use unlucky_core::platform::layout::GAME_LOG_DIR;
|
||||
|
||||
use crate::dto::LogSession;
|
||||
|
||||
use super::{session_of, HostEnv};
|
||||
|
||||
/// 会话日志落点记录的文件名(`<logs>/session-log-location.json`,见 [`note_location`])。
|
||||
pub const SESSION_LOCATION_FILE: &str = "session-log-location.json";
|
||||
|
||||
/// 会话根(**新会话写在这里**:`logs/`;`launch::LauncherConfig` 在其下建 `game/`)。
|
||||
pub fn root(env: &HostEnv) -> PathBuf {
|
||||
env.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话日志的**旧家**(`data/agent/sessions/`):M188 起只读兼容。
|
||||
///
|
||||
/// 目录不存在时给 `None`——「兼容」不等于「凭空造一个旧目录出来」。
|
||||
pub fn legacy_root(env: &HostEnv) -> Option<PathBuf> {
|
||||
let dir = env.legacy_sessions_root();
|
||||
dir.is_dir().then_some(dir)
|
||||
}
|
||||
|
||||
/// 读会话要看的根,**新根在前**(同 id 撞车时新根赢)。
|
||||
pub(super) fn roots(env: &HostEnv) -> Vec<PathBuf> {
|
||||
let mut roots = vec![root(env)];
|
||||
roots.extend(legacy_root(env));
|
||||
roots
|
||||
}
|
||||
|
||||
/// 全部索引条目(两个根合并;同 id 以**先出现的**为准,即 `logs/` 那条)。
|
||||
///
|
||||
/// 为什么两个根都读:用户已有的会话在 `data/agent/sessions/` 下,M188 只改「新会话写到哪」,
|
||||
/// 不改「旧会话还能不能看」——否则崩溃弹窗里的会话关联会突然断掉。
|
||||
pub fn entries(env: &HostEnv) -> Vec<SessionIndexEntry> {
|
||||
let mut merged: Vec<SessionIndexEntry> = Vec::new();
|
||||
for dir in roots(env) {
|
||||
let index = session_index::index_path(&dir);
|
||||
for entry in session_index::read_all(&index).unwrap_or_default() {
|
||||
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));
|
||||
merged
|
||||
}
|
||||
|
||||
/// 会话列表(新→旧;`logs/` 与旧家合并)。
|
||||
pub fn sessions(env: &HostEnv) -> Vec<LogSession> {
|
||||
entries(env).iter().map(session_of).collect()
|
||||
}
|
||||
|
||||
/// 会话日志「换了个家」的记录:迁了什么、什么时候、旧路径还在不在。
|
||||
///
|
||||
/// ```json
|
||||
/// {"v":1,"at":"…","sessions_root":"<home>/logs","legacy_root":"<home>/data/agent/sessions",
|
||||
/// "legacy_present":true,"legacy_files":3,"moved":0,"policy":"read-only-compat"}
|
||||
/// ```
|
||||
///
|
||||
/// best-effort:写不进去就算了(日志目录不可写时不该让启动器起不来)。
|
||||
pub fn note_location(env: &HostEnv) -> Option<PathBuf> {
|
||||
let legacy = env.legacy_sessions_root();
|
||||
let legacy_present = legacy.is_dir();
|
||||
let legacy_files = std::fs::read_dir(legacy.join(GAME_LOG_DIR))
|
||||
.map(|items| items.filter_map(Result::ok).count())
|
||||
.unwrap_or(0);
|
||||
let body = serde_json::json!({
|
||||
"v": 1,
|
||||
"at": unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()),
|
||||
"sessions_root": root(env).display().to_string(),
|
||||
"legacy_root": legacy.display().to_string(),
|
||||
"legacy_present": legacy_present,
|
||||
"legacy_files": legacy_files,
|
||||
"moved": 0,
|
||||
"policy": "read-only-compat",
|
||||
});
|
||||
let path = env.logs_dir().join(SESSION_LOCATION_FILE);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok()?;
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&body).ok()?).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! host/logs.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
#[test]
|
||||
fn time_display_and_outcomes() {
|
||||
@@ -84,3 +85,170 @@ fn confidence_percent_is_rounded_and_clamped() {
|
||||
assert_eq!(confidence_percent(-1.0), 0);
|
||||
assert_eq!(confidence_percent(2.0), 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- M188:会话日志搬家
|
||||
|
||||
/// 一个临时数据根(`<root>/.ULL` + `<root>/.minecraft`)。
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m188-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
root
|
||||
}
|
||||
|
||||
fn sample_entry(id: &str, file: &str, started_at: &str) -> SessionIndexEntry {
|
||||
SessionIndexEntry {
|
||||
v: 1,
|
||||
session_id: id.to_string(),
|
||||
instance_id: "inst".to_string(),
|
||||
instance_name: "实例".to_string(),
|
||||
game_version: "1.20.1".to_string(),
|
||||
started_at: started_at.to_string(),
|
||||
ended_at: String::new(),
|
||||
duration_ms: 0,
|
||||
exit_code: Some(0),
|
||||
exit_type: Some("normal".to_string()),
|
||||
silent_failure: false,
|
||||
window_detected: true,
|
||||
event_count: 1,
|
||||
log_path: file.to_string(),
|
||||
crash_found: false,
|
||||
diagnosis_top: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 守门①前半:**旧家的会话仍然列得出来、打得开**(只读兼容)。
|
||||
///
|
||||
/// 变异证明:
|
||||
/// - 把 [`entries`] 改成只读 `root(env)`(新根)⇒ 列表里没有 `old-session`,本条红;
|
||||
/// - 把 `event_path` 的新旧回落删掉(只认新根)⇒ `open` 拿到 0 行,本条红。
|
||||
#[test]
|
||||
fn legacy_sessions_are_still_listed_and_openable() {
|
||||
let root = temp_root("legacy");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let legacy_game = env.legacy_sessions_root().join("game");
|
||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||
let entry = sample_entry("old-session", "old-session.jsonl", "2026-11-01T10:00:00.000Z");
|
||||
session_index::append(
|
||||
&session_index::index_path(&env.legacy_sessions_root()),
|
||||
&entry,
|
||||
)
|
||||
.expect("写旧索引");
|
||||
std::fs::write(
|
||||
legacy_game.join("old-session.jsonl"),
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"旧会话这一行\"}}\n",
|
||||
)
|
||||
.expect("写旧事件流");
|
||||
|
||||
let listed = sessions(&env);
|
||||
assert!(
|
||||
listed.iter().any(|session| session.id == "old-session"),
|
||||
"旧家的会话没有出现在列表里:{listed:?}"
|
||||
);
|
||||
assert_eq!(event_path(&env, &entry), legacy_game.join("old-session.jsonl"));
|
||||
let (lines, _) = open(&env, "old-session");
|
||||
assert!(
|
||||
lines.iter().any(|line| line.text.contains("旧会话这一行")),
|
||||
"旧家的事件流没读出来:{lines:?}"
|
||||
);
|
||||
// 「兼容」不等于「复制一份到新根」:旧文件原地不动、新根里没有它。
|
||||
assert!(legacy_game.join("old-session.jsonl").is_file());
|
||||
assert!(!root.join(".ULL/logs/game/old-session.jsonl").is_file());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 守门①后半:**新会话写在 `logs/game/`**,索引与事件流都在那儿。
|
||||
///
|
||||
/// 变异证明:把 `HostEnv::sessions_root` 改回 `layout.agent_sessions()` ⇒ 第一条断言红;
|
||||
/// 把 [`entries`] 改成只读旧家 ⇒ 列表里没有 `fresh`,本条红。
|
||||
#[test]
|
||||
fn new_sessions_are_written_under_logs_game() {
|
||||
let root = temp_root("newhome");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let index = session_index::index_path(&env.sessions_root());
|
||||
assert_eq!(index, root.join(".ULL/logs/game/index.jsonl"));
|
||||
|
||||
let entry = sample_entry("fresh", "fresh.jsonl", "2026-11-08T09:00:00.000Z");
|
||||
session_index::append(&index, &entry).expect("写新索引");
|
||||
let file = env.sessions_root().join("game/fresh.jsonl");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"新会话\"}}\n",
|
||||
)
|
||||
.expect("写新事件流");
|
||||
|
||||
let listed = sessions(&env);
|
||||
assert_eq!(
|
||||
listed.first().map(|session| session.id.as_str()),
|
||||
Some("fresh"),
|
||||
"{listed:?}"
|
||||
);
|
||||
assert_eq!(event_path(&env, &entry), file);
|
||||
// 旧目录不许被凭空造出来(我们没搬东西,也没义务建一个旧家)。
|
||||
assert!(!env.legacy_sessions_root().exists());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 同一个 id 同时出现在两个根时**新根赢**(用户在新位置又跑了一局同名会话)。
|
||||
#[test]
|
||||
fn the_new_root_wins_when_both_indexes_know_the_same_id() {
|
||||
let root = temp_root("both");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let legacy_game = env.legacy_sessions_root().join("game");
|
||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||
let old = sample_entry("same", "same.jsonl", "2026-10-01T00:00:00.000Z");
|
||||
session_index::append(&session_index::index_path(&env.legacy_sessions_root()), &old)
|
||||
.expect("写旧索引");
|
||||
std::fs::write(
|
||||
legacy_game.join("same.jsonl"),
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"旧\"}}\n",
|
||||
)
|
||||
.expect("写旧事件流");
|
||||
|
||||
let fresh = sample_entry("same", "same.jsonl", "2026-11-08T00:00:00.000Z");
|
||||
session_index::append(&session_index::index_path(&env.sessions_root()), &fresh)
|
||||
.expect("写新索引");
|
||||
let new_file = env.sessions_root().join("game/same.jsonl");
|
||||
std::fs::write(
|
||||
&new_file,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"新\"}}\n",
|
||||
)
|
||||
.expect("写新事件流");
|
||||
|
||||
let entries = super::entries(&env);
|
||||
let hits: Vec<&SessionIndexEntry> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.session_id == "same")
|
||||
.collect();
|
||||
assert_eq!(hits.len(), 1, "同一个 id 只能出现一次:{hits:?}");
|
||||
assert_eq!(hits[0].started_at, fresh.started_at, "新根那条应当赢");
|
||||
assert_eq!(event_path(&env, &fresh), new_file);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 落点记录:迁了什么(没有)、什么时候、旧路径还在不在。
|
||||
#[test]
|
||||
fn the_location_receipt_names_both_roots_and_the_policy() {
|
||||
let root = temp_root("receipt");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let legacy_game = env.legacy_sessions_root().join("game");
|
||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||
std::fs::write(legacy_game.join("a.jsonl"), "{}\n").expect("写旧文件");
|
||||
|
||||
let path = note_location(&env).expect("记录应当写得出来");
|
||||
assert_eq!(path, env.logs_dir().join(super::location::SESSION_LOCATION_FILE));
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&path).expect("读记录")).expect("记录是 JSON");
|
||||
assert_eq!(value["policy"], "read-only-compat");
|
||||
assert_eq!(value["moved"], 0, "我们一个文件都没搬");
|
||||
assert_eq!(value["legacy_present"], true);
|
||||
assert_eq!(value["legacy_files"], 1);
|
||||
assert_eq!(
|
||||
value["sessions_root"],
|
||||
env.sessions_root().display().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
value["legacy_root"],
|
||||
env.legacy_sessions_root().display().to_string()
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -130,6 +130,9 @@ impl CoreHost {
|
||||
fn build(env: HostEnv, refresh: bool) -> Self {
|
||||
let (tx, rx) = job::channel();
|
||||
game::ensure_root(&env);
|
||||
// M188:把「会话日志换了家」这件事记一笔(旧目录还在不在、有几个文件、搬了什么)。
|
||||
// 策略是**不搬**,所以这份记录是「我们没动你的数据」的唯一凭据。
|
||||
logs::note_location(&env);
|
||||
// M159:更新状态要在构造时就有一份(提示条/设置页都读它),静默检查在 `refresh_all`
|
||||
// 里按开关派发——`headless` 走 `refresh=false`,于是单测不会发任何网络请求。
|
||||
let update = update_state::Runtime::for_env(&env);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1,88 @@
|
||||
# 日志在哪(M188)
|
||||
|
||||
一句话:**启动器自己写的东西全在 `<数据根>/logs/` 一个目录里**,游戏自己的东西在实例运行目录里。
|
||||
设置页那行「日志文件夹」显示的就是前者的绝对路径,旁边有 *打开文件夹* 与 *复制路径*;日志页页头
|
||||
也有 *打开文件夹*。两个入口走同一个落点(`crates/app-iced/src/app/logs_folder.rs`),失败时输出的
|
||||
文本带完整路径。
|
||||
|
||||
## 1. 三个数据模式下的绝对路径
|
||||
|
||||
`<数据根>` 由 `core::platform::data_dir` 的三模式解析(便携 / 标准 / 自定义):
|
||||
|
||||
| 模式 | 启动器数据目录(= `<数据根>`) | 日志根 |
|
||||
|---|---|---|
|
||||
| portable | `<exe 目录>/.ULL` | `<exe 目录>/.ULL/logs` |
|
||||
| standard | Windows `%APPDATA%\ULL` · macOS `~/Library/Application Support/ULL` · Linux `$XDG_DATA_HOME/ULL`(缺省 `~/.local/share/ULL`) | `<上者>/logs` |
|
||||
| custom | `<自定义根>/.ULL` | `<自定义根>/.ULL/logs` |
|
||||
|
||||
GUI 的 `--data-dir <dir>` 是 custom 模式的一种入口:那时日志根是 `<dir>/.ULL/logs`。设置页显示的
|
||||
路径与 `launcher.log` 的实际落点取的是**同一份解析结果**(`SettingsBridge` → `<home>/config/launcher.json`
|
||||
→ `<home>`),所以两者不会各说各话。
|
||||
|
||||
## 2. 目录里有什么
|
||||
|
||||
| 路径 | 写者 | 是什么 |
|
||||
|---|---|---|
|
||||
| `logs/launcher.log` | `core::launch::launcher_log` | 启动器自己的运行日志:启动拍(主题 / 语言 / 路由 / 窗口)、后端探测与降级、字体链、每一次启动的命令行事实 / spawn 结果 / 退出码、打开日志目录等用户动作的结果。 |
|
||||
| `logs/launcher.1.log` | 同上(滚动) | 上一份日志。**只留一份**历史。 |
|
||||
| `logs/game/<session-id>.jsonl` | `core::launch::session` | 每次启动一份 JSONL 事件流(命令行已脱敏、每行 stdout/stderr、退出、诊断)。 |
|
||||
| `logs/game/index.jsonl` | `core::launch::session_index` | 一会话一行摘要的索引,日志页左栏读它。 |
|
||||
| `logs/install.log` | `presenter::host::install_journal` | 安装流水,每阶段一行。 |
|
||||
| `logs/session-log-location.json` | `presenter::host::logs::note_location` | 会话日志现在在哪 / 以前在哪 / 搬没搬(`moved: 0`),每次启动重写。 |
|
||||
| `data/agent/sessions/game/…` | **旧版本** | 只读兼容,见 §4。 |
|
||||
|
||||
### 上限与滚动
|
||||
|
||||
- 单文件上限 `launcher_log::MAX_BYTES = 1 MiB`;每次追加前检查,超过就把 `launcher.log` 改名成
|
||||
`launcher.1.log`(旧的那份被替换),新文件从 0 开始。
|
||||
- 保留份数:**1**。所以 `logs/` 里启动器日志的稳态占用 ≤ 约 2 MiB(+ 同目录的会话 jsonl)。
|
||||
- 为什么不做按日期滚动:事故复现往往在「刚刚那一次」,份数比历史长度更重要;而无限份数会让「导出
|
||||
诊断包」变成搬砖。
|
||||
|
||||
## 3. 哪些行进 launcher.log、哪些只进 stderr
|
||||
|
||||
判据只有一条:**事故之后还要不要看它**。实现是 `crates/app-iced/src/app/diag.rs` 的 `diag!` 宏
|
||||
(stderr + 文件双写,行首 `[tag]` 就是日志里的 scope)。
|
||||
|
||||
| 进 launcher.log(`crates/app-iced/src/app/**` 里打的行) | 只进 stderr(本 mission scope 之外的行) |
|
||||
|---|---|
|
||||
| 启动编排:`[probe]`(降级重开 / 探测耗时)、`[font]`、`[theme]`、`[appearance]`、`[gpu]` 的降级与拒绝降级、`[boot]`(含后端 request/effective)、`[shot]` 的通道提示 | `renderer::acceptance` 的验收度量:`[window]` / `[perf]` / `[caps]` / `[theme] source=…` |
|
||||
| 用户动作的结果:`[logs]`(打开日志文件夹成功/失败)、`[settings]` 写盘失败、`[i18n]` 语言没落盘、`[update]` 的忽略/重启、`[agent]` / `[content]` / `[page]` 的落点提示 | `renderer` 自己的 `[gpu] …初始化失败`、`[gpu] iced_wgpu: Selected`、`[probe] request=…`(`probe.rs`)、`[corner]`(`platform/win32/corner.rs`) |
|
||||
| 一次性、量很小的诊断(每次启动几行到几十行) | 组件几何探针与测试:`[feedback-probe]` / `[gallery]` / `[probe] divider…` |
|
||||
|
||||
不允许把高频行接进来:1 MiB 的上限就是为了保住「事故现场」,逐帧写会把它冲掉。
|
||||
|
||||
**已知边界(M188 没做,已登记)**:`renderer/**`、`probe.rs`、`platform/win32/corner.rs` 里的行
|
||||
仍然只走 stderr——它们各自属于别的 mission 的 scope。要全线接通,得把这些文件的 `eprintln!`
|
||||
也换成 `crate::app::diag::diag!`(或把 `emit` 提成 lib 级函数)。
|
||||
|
||||
## 4. 会话日志搬家:策略是**不搬**
|
||||
|
||||
M188 把新会话从 `data/agent/sessions/game/` 挪到 `logs/game/`(目录名里的 `agent` 是历史遗留,
|
||||
它从来只装游戏会话)。旧目录的处理方式:
|
||||
|
||||
- **原样保留、继续读**:`presenter::host::logs` 与 `cli` 的会话读取都是两个根合并,同一个
|
||||
`session_id` 同时存在时以 `logs/` 那条为准;
|
||||
- **不搬、不删**:搬运用户已有数据是不可逆动作,中途失败会留下半份会话,崩溃弹窗里的会话关联也会断;
|
||||
- **留记录**:`logs/session-log-location.json` 写明两个根、旧根在不在、旧根里几个文件、`moved: 0`、
|
||||
`policy: "read-only-compat"`。
|
||||
|
||||
## 5. 游戏自己的日志(不归启动器写)
|
||||
|
||||
在实例的运行目录里(隔离实例是 `<游戏目录>/versions/<隔离名>/…`,共享实例直接是 `.minecraft`):
|
||||
|
||||
- `logs/latest.log`、`logs/<日期>-<序号>.log.gz` —— 游戏自己的日志;
|
||||
- `crash-reports/crash-*.txt` —— **只在 JVM 活到崩溃处理器那一步时才有**;
|
||||
- `hs_err_pid*.log` —— JVM 自己崩(native crash)时才写。
|
||||
|
||||
模组 / 缺库 / Java 选错把游戏弄死在 Minecraft 初始化之前时,上面三个都不会出现——那不是启动器
|
||||
丢了日志,而是游戏没机会写。那种时候的证据是 `logs/game/<session-id>.jsonl` 与 `logs/launcher.log`。
|
||||
|
||||
## 6. 没验的部分
|
||||
|
||||
- **真机「双击启动后日志确实在」**:本轮只在开发机上用 `cargo test` 与本地进程验证,Windows 桌面
|
||||
双击启动需要用户实测。
|
||||
- **macOS / Linux 的日志根**:路径由同一条解析链给出(`standard_launcher_home` 有单测覆盖三个平台),
|
||||
但「Mac 上点打开文件夹真的会开 Finder」「Linux 上没有 `xdg-open` 时会怎样」没有真机验证。
|
||||
- **只读数据目录**:日志写不进去时按设计静默放过(best-effort),此时设置页显示的路径仍然正确,
|
||||
但文件不存在。
|
||||
Reference in new issue
Block a user