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

+26 -1
View File
@@ -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
View File
@@ -163,6 +163,34 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 日志在哪
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
![设置页「通用」分组:日志文件夹一行,显示 D:/ULL-Demo/.ULL/logs,旁边是「打开目录」与「复制路径」两个按钮,下面是解释文案](docs/images/logs-folder-zh.png)
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
数据根按三种模式取值:便携是 `<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`),而且同一条判据已经常驻成发布流水线里
+4 -3
View File
@@ -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] 外观未落盘(数据根解析不出来,内存态运行)"),
}
}
}
+11 -10
View File
@@ -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()
),
+4 -3
View File
@@ -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)
}
};
+164
View File
@@ -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 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
}
}
+11 -1
View File
@@ -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:路径里的空格与 `&` 不会被当成命令分隔符。
+4
View File
@@ -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("建目录");
+120
View File
@@ -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();
}
}
+2
View File
@@ -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;
+2 -1
View File
@@ -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)
}
+3 -2
View File
@@ -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()
);
+10 -9
View File
@@ -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()
+6 -5
View File
@@ -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()
);
+2 -1
View File
@@ -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)
);
+16 -5
View File
@@ -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()),
),
);
}
+3 -2
View File
@@ -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,
+3 -2
View File
@@ -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 {
+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
+21 -1
View File
@@ -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")
+4 -5
View File
@@ -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", "新建实例"),
];
+5
View File
@@ -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",
"数据根没解析出来——从启动器所在目录重新启动试试"
),
} }
};
}
+142 -5
View File
@@ -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]
+2 -4
View File
@@ -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() {
+2 -2
View File
@@ -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,
);
+2
View File
@@ -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();
+40 -25
View File
@@ -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)]
+107
View File
@@ -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)
}
+168
View File
@@ -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();
}
+3
View File
@@ -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

+88
View File
@@ -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),此时设置页显示的路径仍然正确,
但文件不存在。