Merge branch 'feat/m188-logplace-launcher-runtime-log-and-g'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 01:14:41 +08:00
commit edd12b38ab
51 files changed
+1435 -123

No files matched your search

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

After

Width:  |  Height:  |  Size: 116 KiB

+94
View File
@@ -0,0 +1,94 @@
# 日志在哪(M188)
一句话:**启动器自己写的东西全在 `<数据根>/logs/` 一个目录里**,游戏自己的东西在实例运行目录里。
设置页那行「日志文件夹」显示的就是前者的绝对路径,旁边有 *打开文件夹* 与 *复制路径*;日志页页头
也有 *打开文件夹*。两个入口走同一个落点(`crates/app-iced/src/app/logs_folder.rs`),失败时输出的
文本带完整路径。
## 1. 三个数据模式下的绝对路径
`<数据根>` 由 `core::platform::data_dir` 的三模式解析(便携 / 标准 / 自定义):
| 模式 | 启动器数据目录(= `<数据根>`) | 日志根 |
|---|---|---|
| portable | `<exe 目录>/.ULL` | `<exe 目录>/.ULL/logs` |
| standard | Windows `%APPDATA%\ULL` · macOS `~/Library/Application Support/ULL` · Linux `$XDG_DATA_HOME/ULL`(缺省 `~/.local/share/ULL`) | `<上者>/logs` |
| custom | `<自定义根>/.ULL` | `<自定义根>/.ULL/logs` |
GUI 的 `--data-dir <dir>` 是 custom 模式的一种入口:那时日志根是 `<dir>/.ULL/logs`。设置页显示的
路径与 `launcher.log` 的实际落点取的是**同一份解析结果**(`SettingsBridge` → `<home>/config/launcher.json`
→ `<home>`),所以两者不会各说各话。
## 2. 目录里有什么
| 路径 | 写者 | 是什么 |
|---|---|---|
| `logs/launcher.log` | `core::launch::launcher_log` | 启动器自己的运行日志:启动拍(主题 / 语言 / 路由 / 窗口)、后端探测与降级、字体链、每一次启动的命令行事实 / spawn 结果 / 退出码、打开日志目录等用户动作的结果。 |
| `logs/launcher.1.log` | 同上(滚动) | 上一份日志。**只留一份**历史。 |
| `logs/game/<session-id>.jsonl` | `core::launch::session` | 每次启动一份 JSONL 事件流(命令行已脱敏、每行 stdout/stderr、退出、诊断)。 |
| `logs/game/index.jsonl` | `core::launch::session_index` | 一会话一行摘要的索引,日志页左栏读它。 |
| `logs/install.log` | `presenter::host::install_journal` | 安装流水,每阶段一行。 |
| `logs/session-log-location.json` | `presenter::host::logs::note_location` | 会话日志现在在哪 / 以前在哪 / 搬没搬(`moved: 0`),每次启动重写。 |
| `data/agent/sessions/game/…` | **旧版本** | 只读兼容,见 §4。 |
### 上限与滚动
- 单文件上限 `launcher_log::MAX_BYTES = 1 MiB`;每次追加前检查,超过就把 `launcher.log` 改名成
`launcher.1.log`(旧的那份被替换),新文件从 0 开始。
- 保留份数:**1**。所以 `logs/` 里启动器日志的稳态占用 ≤ 约 2 MiB(+ 同目录的会话 jsonl)。
- 为什么不做按日期滚动:事故复现往往在「刚刚那一次」,份数比历史长度更重要;而无限份数会让「导出
诊断包」变成搬砖。
## 3. 哪些行进 launcher.log、哪些只进 stderr
判据(复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接**。实现是
`crates/app-iced/src/app/diag.rs` 的 `diag!` 宏(stderr + 文件双写,行首 `[tag]` 就是日志里的 scope)。
| 进 launcher.log | 只进 stderr |
|---|---|
| 启动编排:`[probe]`(`request=… -> ICED_BACKEND=…`、降级重开、探测耗时)、`[font]`、`[theme]`、`[appearance]`、`[gpu]` 的降级与拒绝降级、`[boot]`(含后端 request/effective)、`[shot]` 的通道提示 | 验收/出图矩阵的**逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=…`(`renderer::acceptance/**`,一次启动上百行,会冲掉事故现场) |
| **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` 与 `[gpu] 失败详情(具名)`(`renderer::report_failure`,复审 P2-1)、`[gpu] <target>: …Selected: AdapterInfo…`(`renderer/log_bridge.rs` 转出的 wgpu 日志)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]`、gallery 页面里的 `[gallery]`) |
| 用户动作的结果:`[logs]`(打开日志文件夹成功/失败)、`[settings]` 写盘失败、`[i18n]` 语言没落盘、`[update]` 的忽略/重启、`[agent]` / `[content]` / `[page]` 的落点提示 | 每条日志行的正文里再套的括号(不是标签) |
| 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒,约 4.8 小时才滚一次)、`[corner]`(`app/update/appearance.rs` 打,`platform/win32/corner.rs` 只负责 `format_line`)、`[appearance]` / `[theme]` 的切换 | — |
`[corner]` 到底在哪一侧,看**打印点**:唯一打印点是 `app/update/appearance.rs`(走 `diag!`),
`platform/win32/corner.rs::format_line` 只拼字符串、不打印 ⇒ 它**进** launcher.log。
## 3.1 还没接进日志的行(本 mission 之外)
- `renderer::acceptance/**` 的 `[window]` / `[perf]` / `[caps]`:逐帧/矩阵取证,按上面的判据**故意不接**。
- `components/**` 的几何探针(`[feedback-probe]` / `[gallery]`):测试通道,不进产品日志。
(`renderer/mod.rs` 的 `report_failure`、`renderer/log_bridge.rs`、`renderer/preflight.rs`、
`probe.rs:apply_with_preflight` 已在 M188 复审后接上。)
## 4. 会话日志搬家:策略是**不搬**
M188 把新会话从 `data/agent/sessions/game/` 挪到 `logs/game/`(目录名里的 `agent` 是历史遗留,
它从来只装游戏会话)。旧目录的处理方式:
- **原样保留、继续读**:`presenter::host::logs` 与 `cli` 的会话读取都是两个根合并,同一个
`session_id` 同时存在时以 `logs/` 那条为准;
- **不搬、不删**:搬运用户已有数据是不可逆动作,中途失败会留下半份会话,崩溃弹窗里的会话关联也会断;
- **留记录**:`logs/session-log-location.json` 写明两个根、旧根在不在、旧根里几个文件、`moved: 0`、
`policy: "read-only-compat"`。
## 5. 游戏自己的日志(不归启动器写)
在实例的运行目录里(隔离实例是 `<游戏目录>/versions/<隔离名>/…`,共享实例直接是 `.minecraft`):
- `logs/latest.log`、`logs/<日期>-<序号>.log.gz` —— 游戏自己的日志;
- `crash-reports/crash-*.txt` —— **只在 JVM 活到崩溃处理器那一步时才有**;
- `hs_err_pid*.log` —— JVM 自己崩(native crash)时才写。
模组 / 缺库 / Java 选错把游戏弄死在 Minecraft 初始化之前时,上面三个都不会出现——那不是启动器
丢了日志,而是游戏没机会写。那种时候的证据是 `logs/game/<session-id>.jsonl` 与 `logs/launcher.log`。
## 6. 没验的部分
- **真机「双击启动后日志确实在」**:本轮只在开发机上用 `cargo test` 与本地进程验证,Windows 桌面
双击启动需要用户实测。
- **macOS / Linux 的日志根**:路径由同一条解析链给出(`standard_launcher_home` 有单测覆盖三个平台),
但「Mac 上点打开文件夹真的会开 Finder」「Linux 上没有 `xdg-open` 时会怎样」没有真机验证。
- **只读数据目录**:日志写不进去时按设计静默放过(best-effort),此时设置页显示的路径仍然正确,
但文件不存在。