From 5fd5cd47dc4afb38373ef900fc8261a93f165bee Mon Sep 17 00:00:00 2001 From: worker-logplace Date: Mon, 9 Nov 2026 00:43:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(m188):=20launcher.log=20=E6=97=A9=E8=90=BD?= =?UTF-8?q?=E7=9B=98=20+=20=E4=BC=9A=E8=AF=9D=E6=97=A5=E5=BF=97=E6=90=AC?= =?UTF-8?q?=E5=88=B0=20logs/game=EF=BC=88=E6=97=A7=E5=AE=B6=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=85=BC=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 启动器运行日志(.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 写死成便携落点变红。 --- README.md | 27 ++- README.zh-CN.md | 31 +++- crates/app-iced/src/app/appearance.rs | 7 +- crates/app-iced/src/app/boot.rs | 21 +-- crates/app-iced/src/app/boot/fallback.rs | 7 +- crates/app-iced/src/app/diag.rs | 164 +++++++++++++++++ crates/app-iced/src/app/dialog/export.rs | 12 +- crates/app-iced/src/app/dialog/tests.rs | 4 + crates/app-iced/src/app/logs_folder.rs | 120 +++++++++++++ crates/app-iced/src/app/mod.rs | 2 + crates/app-iced/src/app/state/boot_probe.rs | 3 +- crates/app-iced/src/app/state/construct.rs | 3 + crates/app-iced/src/app/state/theme.rs | 5 +- crates/app-iced/src/app/update.rs | 19 +- crates/app-iced/src/app/update/agent.rs | 11 +- crates/app-iced/src/app/update/appearance.rs | 3 +- crates/app-iced/src/app/update/boot_env.rs | 21 ++- crates/app-iced/src/app/update/clock.rs | 5 +- crates/app-iced/src/app/update/content.rs | 5 +- .../app-iced/src/app/update/page_messages.rs | 21 ++- .../app-iced/src/app/update/settings/java.rs | 3 +- .../app-iced/src/app/update/update_action.rs | 11 +- crates/app-iced/src/pages/settings/general.rs | 5 + .../app-iced/src/pages/settings/logs_row.rs | 124 +++++++++++++ crates/app-iced/src/pages/settings/mod.rs | 1 + .../pages/settings/tests/general_section.rs | 45 +++++ .../cli/src/commands/agent/ports/diagnose.rs | 9 +- crates/cli/src/commands/agent/ports/events.rs | 62 +++++-- crates/cli/src/commands/agent/ports/mod.rs | 8 +- .../cli/src/commands/agent/ports/session.rs | 9 +- crates/cli/src/commands/launch.rs | 3 + crates/cli/src/commands/sessions.rs | 62 ++++++- crates/cli/src/context.rs | 20 ++- crates/core/src/platform/layout.rs | 22 ++- crates/i18n/src/catalog/golden/tail.rs | 9 +- crates/i18n/src/catalog/golden/tail2.rs | 5 + crates/i18n/src/catalog/settings/general.rs | 11 ++ crates/presenter/src/host/env.rs | 147 ++++++++++++++- crates/presenter/src/host/game.rs | 6 +- crates/presenter/src/host/handle.rs | 4 +- crates/presenter/src/host/launch.rs | 2 + crates/presenter/src/host/logs.rs | 65 ++++--- crates/presenter/src/host/logs/location.rs | 107 +++++++++++ crates/presenter/src/host/logs_tests.rs | 168 ++++++++++++++++++ crates/presenter/src/host/mod.rs | 3 + docs/images/logs-folder-zh.png | Bin 0 -> 118942 bytes docs/logs-location.md | 88 +++++++++ 47 files changed, 1371 insertions(+), 119 deletions(-) create mode 100644 crates/app-iced/src/app/diag.rs create mode 100644 crates/app-iced/src/app/logs_folder.rs create mode 100644 crates/app-iced/src/pages/settings/logs_row.rs create mode 100644 crates/presenter/src/host/logs/location.rs create mode 100644 docs/images/logs-folder-zh.png create mode 100644 docs/logs-location.md diff --git a/README.md b/README.md index 1e630cf..386373a 100644 --- a/README.md +++ b/README.md @@ -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 `/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 `/.ULL` in portable mode, `/ULL` +in standard mode, and `/.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/.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. diff --git a/README.zh-CN.md b/README.zh-CN.md index da62a9b..e931918 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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) + +*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同), +打开失败时这一行照样显示,右按钮把路径送进剪贴板。* + +数据根按三种模式取值:便携是 `/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是 +`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。 + +| 路径 | 是什么 | +|---|---| +| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 | +| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 | +| `logs/game/.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`),而且同一条判据已经常驻成发布流水线里 diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs index 68fe4c7..385d4ae 100644 --- a/crates/app-iced/src/app/appearance.rs +++ b/crates/app-iced/src/app/appearance.rs @@ -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] 外观未落盘(数据根解析不出来,内存态运行)"), } } } diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 38161ed..82f8362 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -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() ), diff --git a/crates/app-iced/src/app/boot/fallback.rs b/crates/app-iced/src/app/boot/fallback.rs index ef20a28..d373f0a 100644 --- a/crates/app-iced/src/app/boot/fallback.rs +++ b/crates/app-iced/src/app/boot/fallback.rs @@ -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) } }; diff --git a/crates/app-iced/src/app/diag.rs b/crates/app-iced/src/app/diag.rs new file mode 100644 index 0000000..ade7352 --- /dev/null +++ b/crates/app-iced/src/app/diag.rs @@ -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 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来"); + } +} diff --git a/crates/app-iced/src/app/dialog/export.rs b/crates/app-iced/src/app/dialog/export.rs index 0ce34a1..13b83a8 100644 --- a/crates/app-iced/src/app/dialog/export.rs +++ b/crates/app-iced/src/app/dialog/export.rs @@ -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:路径里的空格与 `&` 不会被当成命令分隔符。 diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 7002088..41c379a 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -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("建目录"); diff --git a/crates/app-iced/src/app/logs_folder.rs b/crates/app-iced/src/app/logs_folder.rs new file mode 100644 index 0000000..eba9a6e --- /dev/null +++ b/crates/app-iced/src/app/logs_folder.rs @@ -0,0 +1,120 @@ +//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。 +//! +//! ## 为什么不在页面里现拼路径 +//! +//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义) +//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`), +//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。 +//! +//! 本模块只做两件事: +//! +//! 1. 从设置桥给定的 `config/launcher.json` 反推 ``,再用 `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> = OnceLock::new(); + +/// `/config/launcher.json` → `/logs`(**只有**这一条形状,别处不许手拼)。 +pub(crate) fn logs_dir_of(config: &Path) -> Option { + 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 { + match LOGS_DIR.get() { + Some(known) => known.clone(), + None => crate::platform::data::layout().map(|layout| layout.logs()), + } +} + +/// 确保日志目录存在(不在就建),返回给用户看的路径。 +/// +/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。 +pub(crate) fn prepare(dir: &Path) -> Result { + 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 { + 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::*; + + /// 落点形状:`/config/launcher.json` → `/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 那一层就没有 `` 可言:宁可不猜。 + 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(); + } +} diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index 4a0be42..dec5277 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -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; diff --git a/crates/app-iced/src/app/state/boot_probe.rs b/crates/app-iced/src/app/state/boot_probe.rs index a5852ed..94d6e0d 100644 --- a/crates/app-iced/src/app/state/boot_probe.rs +++ b/crates/app-iced/src/app/state/boot_probe.rs @@ -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 ); diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index b6f4a2e..1b4ba1a 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -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) } diff --git a/crates/app-iced/src/app/state/theme.rs b/crates/app-iced/src/app/state/theme.rs index 6ecb723..7094a52 100644 --- a/crates/app-iced/src/app/state/theme.rs +++ b/crates/app-iced/src/app/state/theme.rs @@ -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() ); diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 8762ca9..cca731e 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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() diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs index 8f32caa..79b5d16 100644 --- a/crates/app-iced/src/app/update/agent.rs +++ b/crates/app-iced/src/app/update/agent.rs @@ -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() ); diff --git a/crates/app-iced/src/app/update/appearance.rs b/crates/app-iced/src/app/update/appearance.rs index e95f6f1..9031a18 100644 --- a/crates/app-iced/src/app/update/appearance.rs +++ b/crates/app-iced/src/app/update/appearance.rs @@ -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) ); diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index 31e0419..2e77b4e 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -31,15 +31,15 @@ pub(super) fn env_start_route() -> Option { /// /// `.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()), + ), + ); } diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 31f9aed..bf79ccd 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -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, diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 42668b8..4d30049 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -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; } diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index afe52b9..992de11 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -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; diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index 7747a6f..3ebc6c7 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -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 } } diff --git a/crates/app-iced/src/app/update/update_action.rs b/crates/app-iced/src/app/update/update_action.rs index a277a4c..7c0d47a 100644 --- a/crates/app-iced/src/app/update/update_action.rs +++ b/crates/app-iced/src/app/update/update_action.rs @@ -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 { - eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」"); + diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」"); Task::none() } @@ -64,26 +65,26 @@ impl App { /// 文件、并把取证进程拖成死循环。 fn restart_now(&mut self) -> Task { if self.options.screenshot.is_some() { - eprintln!("[update] 截图通道不重启(忽略「立即重启」)"); + diag!("[update] 截图通道不重启(忽略「立即重启」)"); return Task::none(); } match std::env::current_exe() { Ok(exe) => { let args: Vec = 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(); } } diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index e5fb202..4757b56 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -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), diff --git a/crates/app-iced/src/pages/settings/logs_row.rs b/crates/app-iced/src/pages/settings/logs_row.rs new file mode 100644 index 0000000..83b66ed --- /dev/null +++ b/crates/app-iced/src/pages/settings/logs_row.rs @@ -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() +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 92838db..4e17bf3 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -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; diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 702a9e0..ad7894e 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -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('⟪')); + } +} diff --git a/crates/cli/src/commands/agent/ports/diagnose.rs b/crates/cli/src/commands/agent/ports/diagnose.rs index 0143f0e..0f9b4e5 100644 --- a/crates/cli/src/commands/agent/ports/diagnose.rs +++ b/crates/cli/src/commands/agent/ports/diagnose.rs @@ -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) -> 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) -> Self { + self.source = self.source.with_legacy(legacy); + self + } } fn map_kind(kind: CoreFixKind) -> FixActionKind { diff --git a/crates/cli/src/commands/agent/ports/events.rs b/crates/cli/src/commands/agent/ports/events.rs index d313200..86f249d 100644 --- a/crates/cli/src/commands/agent/ports/events.rs +++ b/crates/cli/src/commands/agent/ports/events.rs @@ -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, } impl SessionSource { - /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + /// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。 #[must_use] pub fn new(root: impl Into) -> 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) -> 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> { - 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 = 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) } diff --git a/crates/cli/src/commands/agent/ports/mod.rs b/crates/cli/src/commands/agent/ports/mod.rs index 195b067..719a845 100644 --- a/crates/cli/src/commands/agent/ports/mod.rs +++ b/crates/cli/src/commands/agent/ports/mod.rs @@ -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(), diff --git a/crates/cli/src/commands/agent/ports/session.rs b/crates/cli/src/commands/agent/ports/session.rs index 773fc85..ae03cd9 100644 --- a/crates/cli/src/commands/agent/ports/session.rs +++ b/crates/cli/src/commands/agent/ports/session.rs @@ -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) -> Self { Self { source: SessionSource::new(root), } } + + /// 再挂一个只读兼容的旧根(`<数据根>/.ULL/data/agent/sessions`,M188)。 + #[must_use] + pub fn with_legacy(mut self, legacy: impl Into) -> Self { + self.source = self.source.with_legacy(legacy); + self + } } fn summary_of( diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 8340651..ba7e80a 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -106,6 +106,9 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result { }; let plan = snapshot::prepare(ctx, &input)?; + // M188:命令行这一路也把启动器自己的运行日志落到 `/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); diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs index e8865c0..de26875 100644 --- a/crates/cli/src/commands/sessions.rs +++ b/crates/cli/src/commands/sessions.rs @@ -4,6 +4,9 @@ //! - `/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序); //! - `/game/.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 { 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 = shown @@ -56,6 +59,15 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result { 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 { fn find_entry(ctx: &Ctx, key: &str) -> Result { 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 { } 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, 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 = 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, CliError> { diff --git a/crates/cli/src/context.rs b/crates/cli/src/context.rs index 1ae4b90..3c46d49 100644 --- a/crates/cli/src/context.rs +++ b/crates/cli/src/context.rs @@ -168,12 +168,24 @@ impl Ctx { self.layout().profiles_config() } - /// 会话根(`/agent/sessions`,launch 的事件流与索引都在这下面)。 + /// 会话根(M188 起 = `/logs`;launch 的事件流与索引都在这下面)。 #[must_use] pub fn sessions_root(&self) -> PathBuf { + self.layout().sessions_root() + } + + /// 会话日志的**旧家**(`/data/agent/sessions`):只读兼容,M188 起不再往里写。 + #[must_use] + pub fn legacy_sessions_root(&self) -> PathBuf { self.layout().agent_sessions() } + /// 启动器日志目录(`/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 diff --git a/crates/core/src/platform/layout.rs b/crates/core/src/platform/layout.rs index 146a011..bb05618 100644 --- a/crates/core/src/platform/layout.rs +++ b/crates/core/src/platform/layout.rs @@ -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/`——游戏会话事件流(`.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") diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index f80c3a0..6560f69 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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", "新建实例"), ]; diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index fab5ef2..a7570c7 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -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", "读取清单"), diff --git a/crates/i18n/src/catalog/settings/general.rs b/crates/i18n/src/catalog/settings/general.rs index ba523cf..bfab316 100644 --- a/crates/i18n/src/catalog/settings/general.rs +++ b/crates/i18n/src/catalog/settings/general.rs @@ -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", + "数据根没解析出来——从启动器所在目录重新启动试试" + ), } } }; } diff --git a/crates/presenter/src/host/env.rs b/crates/presenter/src/host/env.rs index a1b6fd7..28b22f6 100644 --- a/crates/presenter/src/host/env.rs +++ b/crates/presenter/src/host/env.rs @@ -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`): + /// + /// | 模式 | 期望的 `` | 期望的日志根 | + /// |---|---|---| + /// | portable | `/.ULL` | `/.ULL/logs` | + /// | standard | `%APPDATA%/ULL` | `%APPDATA%/ULL/logs` | + /// | custom | `/.ULL` | `/.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] diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index c7df977..1a90fa3 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -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 { - 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 = BTreeMap::new(); for entry in entries { if entry.instance_id.is_empty() || entry.started_at.is_empty() { diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 102f2b4..1b81354 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -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, ); diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 3362fd9..9408799 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -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(); diff --git a/crates/presenter/src/host/logs.rs b/crates/presenter/src/host/logs.rs index f9fd901..3b575a5 100644 --- a/crates/presenter/src/host/logs.rs +++ b/crates/presenter/src/host/logs.rs @@ -3,6 +3,15 @@ //! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎): //! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。 //! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。 +//! +//! ## 会话日志在哪(M188) +//! +//! | 位置 | 角色 | +//! |---|---| +//! | `<.ULL>/logs/game/.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 { - 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}") } -/// 事件流文件路径(`/game/`)。 +/// 事件流文件路径(`/game/`;新根没有时回落到旧家)。 +/// +/// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。 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 { - 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)] diff --git a/crates/presenter/src/host/logs/location.rs b/crates/presenter/src/host/logs/location.rs new file mode 100644 index 0000000..240c79a --- /dev/null +++ b/crates/presenter/src/host/logs/location.rs @@ -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}; + +/// 会话日志落点记录的文件名(`/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 { + let dir = env.legacy_sessions_root(); + dir.is_dir().then_some(dir) +} + +/// 读会话要看的根,**新根在前**(同 id 撞车时新根赢)。 +pub(super) fn roots(env: &HostEnv) -> Vec { + let mut roots = vec![root(env)]; + roots.extend(legacy_root(env)); + roots +} + +/// 全部索引条目(两个根合并;同 id 以**先出现的**为准,即 `logs/` 那条)。 +/// +/// 为什么两个根都读:用户已有的会话在 `data/agent/sessions/` 下,M188 只改「新会话写到哪」, +/// 不改「旧会话还能不能看」——否则崩溃弹窗里的会话关联会突然断掉。 +pub fn entries(env: &HostEnv) -> Vec { + let mut merged: Vec = 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 { + entries(env).iter().map(session_of).collect() +} + +/// 会话日志「换了个家」的记录:迁了什么、什么时候、旧路径还在不在。 +/// +/// ```json +/// {"v":1,"at":"…","sessions_root":"/logs","legacy_root":"/data/agent/sessions", +/// "legacy_present":true,"legacy_files":3,"moved":0,"policy":"read-only-compat"} +/// ``` +/// +/// best-effort:写不进去就算了(日志目录不可写时不该让启动器起不来)。 +pub fn note_location(env: &HostEnv) -> Option { + 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) +} diff --git a/crates/presenter/src/host/logs_tests.rs b/crates/presenter/src/host/logs_tests.rs index ccbb6a3..e83c5a7 100644 --- a/crates/presenter/src/host/logs_tests.rs +++ b/crates/presenter/src/host/logs_tests.rs @@ -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:会话日志搬家 + +/// 一个临时数据根(`/.ULL` + `/.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(); +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index c830ebd..94f4aef 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -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); diff --git a/docs/images/logs-folder-zh.png b/docs/images/logs-folder-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..ce11eda94de5aa2e0909e03ecf80fd4d84798d10 GIT binary patch literal 118942 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 P_x6/logs/` 一个目录里**,游戏自己的东西在实例运行目录里。 +设置页那行「日志文件夹」显示的就是前者的绝对路径,旁边有 *打开文件夹* 与 *复制路径*;日志页页头 +也有 *打开文件夹*。两个入口走同一个落点(`crates/app-iced/src/app/logs_folder.rs`),失败时输出的 +文本带完整路径。 + +## 1. 三个数据模式下的绝对路径 + +`<数据根>` 由 `core::platform::data_dir` 的三模式解析(便携 / 标准 / 自定义): + +| 模式 | 启动器数据目录(= `<数据根>`) | 日志根 | +|---|---|---| +| portable | `/.ULL` | `/.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 ` 是 custom 模式的一种入口:那时日志根是 `/.ULL/logs`。设置页显示的 +路径与 `launcher.log` 的实际落点取的是**同一份解析结果**(`SettingsBridge` → `/config/launcher.json` +→ ``),所以两者不会各说各话。 + +## 2. 目录里有什么 + +| 路径 | 写者 | 是什么 | +|---|---|---| +| `logs/launcher.log` | `core::launch::launcher_log` | 启动器自己的运行日志:启动拍(主题 / 语言 / 路由 / 窗口)、后端探测与降级、字体链、每一次启动的命令行事实 / spawn 结果 / 退出码、打开日志目录等用户动作的结果。 | +| `logs/launcher.1.log` | 同上(滚动) | 上一份日志。**只留一份**历史。 | +| `logs/game/.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/.jsonl` 与 `logs/launcher.log`。 + +## 6. 没验的部分 + +- **真机「双击启动后日志确实在」**:本轮只在开发机上用 `cargo test` 与本地进程验证,Windows 桌面 + 双击启动需要用户实测。 +- **macOS / Linux 的日志根**:路径由同一条解析链给出(`standard_launcher_home` 有单测覆盖三个平台), + 但「Mac 上点打开文件夹真的会开 Finder」「Linux 上没有 `xdg-open` 时会怎样」没有真机验证。 +- **只读数据目录**:日志写不进去时按设计静默放过(best-effort),此时设置页显示的路径仍然正确, + 但文件不存在。