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 0000000..ce11eda Binary files /dev/null and b/docs/images/logs-folder-zh.png differ diff --git a/docs/logs-location.md b/docs/logs-location.md new file mode 100644 index 0000000..7eb2e30 --- /dev/null +++ b/docs/logs-location.md @@ -0,0 +1,88 @@ +# 日志在哪(M188) + +一句话:**启动器自己写的东西全在 `<数据根>/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),此时设置页显示的路径仍然正确, + 但文件不存在。