From 0451b3f9721f7293ab0f597189cf4b966961c4de Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 14:50:15 +0800 Subject: [PATCH 01/33] =?UTF-8?q?fix(m182):=20=E9=9D=9E=20Windows=20?= =?UTF-8?q?=E5=9C=86=E8=A7=92=E5=88=A4=E6=8D=AE=E5=9B=9E=E7=AD=94=E3=80=8C?= =?UTF-8?q?=E7=94=B1=E8=B0=81=E8=B4=9F=E8=B4=A3=E3=80=8D=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E7=BB=99=20macOS=20=E7=9B=B4=E8=A7=92=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E7=BA=A7=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户第三轮 Mac 验收看到四角直角 + `source=square os=unknown`。原来非 Windows 一律 落到 `UnknownOs`,把「Windows 版本读不出来」与「这台根本不是 Windows」糊成同一格。 - `platform/host.rs`:新增 `HostOs`——平台族是一个**值**而不是散落的 `cfg`。 这样三行真值表能在本机(只有 Windows)一次验完,`cfg` 只留在真要 FFI 的地方。 - `plan_corner` 拆成 `plan_corner_for(policy, host, os, backend)` + 保持原签名的薄包装 (`app/update/appearance.rs` 在 scope 外,调用点一字未改)。 - `CornerSource` 加第三档 `Compositor`(跟随合成器,我们既不下发也不自绘); `CornerExclusion` 加 `borderless`(macOS)、`client-side`(Linux)、`unsupported`。 - 日志加 `host=`,非 Windows 的 `os=` 由 `unknown` 改 `n/a`(那栏是 Windows 版本, 在 macOS/Linux 上不是「没读到」而是「跟本机无关」)。 - `platform/macos/mod.rs`:macOS 为什么是直角的**三环证据**(我们的 `decorations: false` → winit `Borderless`(无 `Titled`)→ 窗口服务器只裁 titled 窗;winit macOS 后端 `cornerRadius` 命中 0),以及「迁移原生标题栏」的未实现配方与阻塞点(iced 0.14 未转发 winit 的 `titlebar_buttons_hidden`,缩放键按不掉)。 - `platform/linux/mod.rs`:xdg-shell 无圆角概念、wl_surface 区域只能是矩形、XShape 只能 切硬边 ⇒ CSD 窗没人替我们裁,D46 排除自绘 ⇒ 直角。没把 DWM 判据搬过去(单测钉着 「非 Windows 不许出现 dwm」)。 - `design/shape.rs`:删掉「macOS 走 `layer.cornerRadius`」这句过期描述(D46 不许自绘裁切)。 - `dwm.rs` 的内联测试挪到 `dwm/tests.rs`(原文件 398 行,加第三档映射会顶破 400 行红线)。 验证:cargo fmt --check / clippy -D warnings / `cargo test -p unlucky-app-iced --lib` 684 passed;三次定向变异各自变红(macOS 行改回 Square/UnknownOs → 4 红;非 Windows `os=` 改回 unknown → 1 红;Linux 原因码复制成 borderless → 4 红)。 Windows 侧无语义改动;`cargo test --lib m116 -- --ignored --nocapture` 复跑读回仍是 `pref=2 readback=? read_hr=0x80070057`(写入被接受、回读不可用),**不作为「圆角已生效」的证据**。 --- crates/app-iced/src/app/boot/diagnostics.rs | 5 + crates/app-iced/src/platform/host.rs | 104 ++++++++++ crates/app-iced/src/platform/linux/mod.rs | 43 ++++ crates/app-iced/src/platform/macos/mod.rs | 93 +++++++++ crates/app-iced/src/platform/mod.rs | 36 +++- crates/app-iced/src/platform/win32/corner.rs | 185 ++++++++++++----- .../src/platform/win32/corner/tests.rs | 95 +++++---- .../platform/win32/corner/tests/platforms.rs | 188 ++++++++++++++++++ .../src/platform/win32/transparency/dwm.rs | 94 +-------- .../platform/win32/transparency/dwm/tests.rs | 124 ++++++++++++ crates/design/src/shape.rs | 18 +- 11 files changed, 793 insertions(+), 192 deletions(-) create mode 100644 crates/app-iced/src/platform/host.rs create mode 100644 crates/app-iced/src/platform/linux/mod.rs create mode 100644 crates/app-iced/src/platform/macos/mod.rs create mode 100644 crates/app-iced/src/platform/win32/corner/tests/platforms.rs create mode 100644 crates/app-iced/src/platform/win32/transparency/dwm/tests.rs diff --git a/crates/app-iced/src/app/boot/diagnostics.rs b/crates/app-iced/src/app/boot/diagnostics.rs index 071fb7b..aeba49c 100644 --- a/crates/app-iced/src/app/boot/diagnostics.rs +++ b/crates/app-iced/src/app/boot/diagnostics.rs @@ -35,6 +35,11 @@ pub(super) fn window_settings(size: (f32, f32)) -> window::Settings { size: iced::Size::new(size.0, size.1), min_size: Some(MIN_SIZE), // 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。 + // + // M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false` + // 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗 + // 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里, + // 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。 decorations: false, resizable: true, // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式 diff --git a/crates/app-iced/src/platform/host.rs b/crates/app-iced/src/platform/host.rs new file mode 100644 index 0000000..882149d --- /dev/null +++ b/crates/app-iced/src/platform/host.rs @@ -0,0 +1,104 @@ +//! 宿主平台族(M182):窗口外框判据的**输入值**,而不是散落各处的 `#[cfg]`。 +//! +//! ## 为什么平台要是一个值 +//! +//! 圆角判据([`super::win32::corner::plan_corner_for`])必须能表达「Windows / macOS / Linux +//! 各自怎么办」三行。写成 `#[cfg(target_os = …)]` 的话,在 Windows 上编译时另外两行**根本不存在**, +//! 判据就永远只能在一台机器上验三分之一——而 M182 要的恰恰是那两行。 +//! +//! 把平台变成值以后:真值表可以在**本机**跑满三行,`cfg` 只留在真正需要 FFI 的地方 +//! (那边本来就有,且只影响「能不能调」,不影响「判据是什么」)。这与 AGENTS.md §7 +//! 「平台差异判断收敛到底层,不许在各模块里散写 `cfg`」是同一条纪律。 +//! +//! ## 取值来源 +//! +//! [`HostOs::current`] 读 `std::env::consts::OS`(编译期常量,运行期不会变)。 +//! 它**不**做任何环境探测:本仓库里「探测」是 [`super::win32::transparency`] 与 +//! `probe_windows_version` 的事,这里只回答「我编在哪个平台上」。 + +/// 宿主平台族(判据输入)。 +/// +/// 只分到判据真正会用到的粒度:Windows 要问 DWM、macOS 要交给窗口服务器、 +/// Linux 没有可问的对象、其余平台(FreeBSD / Android / wasm …)一律不承诺。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostOs { + /// Windows(10 / 11 / Server 同族)。 + Windows, + /// macOS。 + MacOs, + /// Linux(X11 与 Wayland 都是它:判据不看后端,看的是有没有可问的对象)。 + Linux, + /// 其余目标(FreeBSD / Android / wasm32 …):不承诺圆角。 + Other, +} + +impl HostOs { + /// 当前编译目标所属的平台族。 + #[must_use] + pub fn current() -> Self { + Self::from_os_str(std::env::consts::OS) + } + + /// [`std::env::consts::OS`] 的字符串 → 平台族(纯函数,测试直接钉它)。 + /// + /// 只认 `"windows"` / `"macos"` / `"linux"` 三个字面量,其余(含未来新平台)落 [`Self::Other`]: + /// 猜错的代价是「多给它一个圆角承诺」,而不认识的平台本来就没有承诺可言。 + #[must_use] + pub fn from_os_str(os: &str) -> Self { + match os { + "windows" => Self::Windows, + "macos" => Self::MacOs, + "linux" => Self::Linux, + _ => Self::Other, + } + } + + /// 日志取值(`host=windows|macos|linux|other`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Windows => "windows", + Self::MacOs => "macos", + Self::Linux => "linux", + Self::Other => "other", + } + } +} + +#[cfg(test)] +mod tests { + use super::HostOs; + + /// **抓得住**:三个字面量任一个拼错(`"macos"` 写成 `"mac-os"` / `"darwin"`)—— + /// 那会让 macOS 静默掉进 `Other` 从而「不承诺圆角」,正是 M182 要修的那类静默。 + /// **抓不住**:`std::env::consts::OS` 在真机上到底是哪个值(那是编译器给的常量,本测试只钉映射)。 + #[test] + fn the_three_supported_families_are_recognized_verbatim() { + assert_eq!(HostOs::from_os_str("windows"), HostOs::Windows); + assert_eq!(HostOs::from_os_str("macos"), HostOs::MacOs); + assert_eq!(HostOs::from_os_str("linux"), HostOs::Linux); + assert_eq!(HostOs::Windows.as_str(), "windows"); + assert_eq!(HostOs::MacOs.as_str(), "macos"); + assert_eq!(HostOs::Linux.as_str(), "linux"); + } + + /// 不认识的目标一律 `Other`(不承诺),且**不许**退化成 Windows/macOS 的承诺。 + #[test] + fn anything_else_falls_to_other_instead_of_guessing() { + for os in ["freebsd", "netbsd", "android", "ios", "emscripten", ""] { + assert_eq!(HostOs::from_os_str(os), HostOs::Other, "{os}"); + } + assert_eq!(HostOs::Other.as_str(), "other"); + } + + /// `current()` 必须与编译目标的 `cfg` 一致——这是**本机唯一**能自查「值与 cfg 没跑偏」的地方。 + #[test] + fn current_matches_the_compilation_target() { + #[cfg(target_os = "windows")] + assert_eq!(HostOs::current(), HostOs::Windows); + #[cfg(target_os = "macos")] + assert_eq!(HostOs::current(), HostOs::MacOs); + #[cfg(target_os = "linux")] + assert_eq!(HostOs::current(), HostOs::Linux); + } +} diff --git a/crates/app-iced/src/platform/linux/mod.rs b/crates/app-iced/src/platform/linux/mod.rs new file mode 100644 index 0000000..e3f42d0 --- /dev/null +++ b/crates/app-iced/src/platform/linux/mod.rs @@ -0,0 +1,43 @@ +//! Linux(X11 / Wayland)的窗口圆角(M182):**谁负责、我们为什么不画**。 +//! +//! 用户定的规则是「非 Windows 按窗口合成器的默认规则来」。在 Linux 上这句话要落到具体机制, +//! 否则很容易变成「把 Windows 那套 DWM 判据搬过来假装能用」——**不许**那样写(M182 任务书)。 +//! +//! ## 事实层:协议里没有「圆角」这个概念 +//! +//! | 层 | 有什么 | 依据级别 | +//! |---|---|---| +//! | `xdg-shell`(Wayland) | 只有 `wl_surface.set_opaque_region` / `set_input_region`(**矩形集合**)、`set_buffer_transform`、圆角半径之类一概没有 | A 级(协议规范) | +//! | X11 | 形状扩展(`XShape`)能按矩形并集切出非矩形窗口,但**只能切硬边**(无抗锯齿);要透明圆角得配 ARGB visual + 合成器 | A 级(X 扩展规范) | +//! | 装饰归属 | 谁装饰谁负责:合成器画(SSD)由合成器圆,客户端画(CSD)由客户端圆。`zxdg_decoration_manager_v1` **很多合成器即使被请求也不画**(D35 已记) | A 级协议 + D35 实测/推断 | +//! +//! 我们是 **CSD**:`decorations: false`(`app/boot/diagnostics.rs`),自绘标题栏 + 自绘三键。 +//! 所以「圆角该由谁画」的答案是**我们**——而 D46 第 4 条明确要求不自绘裁切, +//! 于是唯一的诚实结果是**直角**,并把这句结论写进日志: +//! `source=compositor excluded_reason=client-side`(见 [`crate::platform::win32::corner`])。 +//! +//! ## 为什么不自绘(不是「懒得做」) +//! +//! 1. 自绘圆角 = 把内容层裁成圆角。我们的内容层含**自绘三键 + 边缘缩放热区**,裁掉圆角就会 +//! 一起裁掉热区(这条在 M116 已被 tower 采纳)。 +//! 2. Wayland 上「表面画圆角 + ARGB 缓冲」在合成器侧确实能透出桌面,但需要整窗 `transparent` +//! ——那是 D36 的整窗 α 管线,且会与 Linux 上「没有 `_NET_WM_CM_S0` 合成器时透明变黑」的 +//! 既有结论打架。 +//! 3. X11 无合成器时透明不可用;只剩 `XShape` 硬边圆角——像素级毛边,比直角更难看。 +//! +//! ## 合成器差异(**推断级,本机无真机,未验**) +//! +//! - 浮动式合成器(KWin / Mutter)对自己的 SSD 窗做圆角,对 CSD 窗**通常不裁**(客户端的 surface +//! 原样合成);但个别主题会给无边框窗额外加重绘制的阴影/边框——那种圆角是**合成器画的**, +//! 不是我们画的。判别口径:日志 `source=compositor`,而像素上的圆角随主题/合成器变化。 +//! - 平铺式(sway / niri / Hyprland / river)连边框都按自己的配置画,更不会替 CSD 窗裁圆角; +//! D35/D37 还记着「sway 默认下已经出现合成器 SSD + 我们自绘的双标题栏」这条现状缺陷。 +//! +//! 这些行为**没有任何一条**能用协议查询,也没有一条能由应用侧读取——所以判据里**不写** +//! 「Linux 会/不会圆角」,只写「决定权在合成器,而 CSD 窗它不裁」。 +//! +//! ## 本轮明确未验 +//! +//! - 真实合成器上的四角观感(无 Linux 真机;CI 的 Linux 冒烟只验「能起来 + 出图不是全黑」); +//! - KWin / Mutter / Hyprland 是否会对我们的 CSD 窗做额外装饰; +//! - 高 DPI 下若将来改为自绘,半径是否需要缩放(本轮不涉及:半径不由我们定)。 diff --git a/crates/app-iced/src/platform/macos/mod.rs b/crates/app-iced/src/platform/macos/mod.rs new file mode 100644 index 0000000..816878c --- /dev/null +++ b/crates/app-iced/src/platform/macos/mod.rs @@ -0,0 +1,93 @@ +//! macOS 的窗口外框(M182):**本轮不实现**,这里只有结论、证据与后续配方。 +//! +//! 用户第三轮 Mac 真机验收的现象:四角是直角,日志 `[corner] … source=square os=unknown`。 +//! 「macOS 不支持圆角」是**错的**——macOS 的窗口四角本来就有系统圆角,只是我们交上去的窗口 +//! 不在会被裁的那一类里。三环链条如下,每一环都能在本机复核(读锁文件里的依赖源码即可): +//! +//! ## 第 1 环:我们交的是什么窗 +//! +//! `crates/app-iced/src/app/boot/diagnostics.rs` 的 `window_settings()` 给的是 +//! `decorations: false`(M93 自绘三键)。那是**唯一产地**,`[window]` / `[caps]` 两行 +//! 验收日志也从它取字段。 +//! +//! ## 第 2 环:winit 把 `decorations: false` 翻成什么 +//! +//! winit 0.30.13 `src/platform_impl/macos/window_delegate.rs:540-552`: +//! +//! ```text +//! let mut masks = if (!attrs.decorations && screen.is_none()) || titlebar_hidden { +//! // Resizable without a titlebar or borders +//! NSWindowStyleMask::Borderless | NSWindowStyleMask::Resizable | NSWindowStyleMask::Miniaturizable +//! } else { +//! // default case, resizable window with titlebar and titlebar buttons +//! NSWindowStyleMask::Closable | Miniaturizable | Resizable | Titled +//! }; +//! ``` +//! +//! ⇒ `decorations: false` 拿到的是 **`Borderless`**,**没有 `Titled`**。 +//! +//! ## 第 3 环:窗口服务器只给 `Titled` 窗做圆角裁剪 +//! +//! 这是 AppKit 的标准窗口行为(A 级外部事实:标准窗口的圆角由窗口服务器连同阴影一起画, +//! 无边框窗口不参与)。本仓库**没有**任何一行代码能影响它:winit 的 macOS 后端里 +//! `cornerRadius` / `CornerRadius` 命中数为 **0**(`src/platform_impl/macos/` 内四命中全是 +//! 「屏幕/视图左上角」的注释),也就是说圆角从来没有一行业是我们调的。 +//! +//! 反向印证:用户第三轮实测(本机窗口就是 borderless)拍到四角直角,与第 3 环一致。 +//! +//! 判据一侧的落点:`plan_corner_for(MacOs, …)` 现在返回 +//! `source=compositor excluded_reason=borderless`(见 [`crate::platform::win32::corner`]), +//! 而不是原先含糊的 `source=square os=unknown`。 +//! +//! ## 为什么**不**用 objc 给 `contentView.layer` 设 `cornerRadius`(M182 任务书原路线,已否) +//! +//! 1. **违反 D46 第 4 条**「macOS / X11 / Wayland 按合成器默认、**不主动自绘**」: +//! 把内容层裁成圆角就是自绘裁切,同一份决策日志里已经否决过这条(还附带 +//! 「会把标题栏三键与边缘缩放热区一起裁掉」的问题)。 +//! 2. **撞 `unsafe` 铁律**:根 `Cargo.toml` 的 `[workspace.lints.rust] unsafe_code = "deny"`, +//! 且 `guards.yml` 的 `unsafe-scope` 作业要求「带 `allow(unsafe_code)` 的文件集合**恰好等于** +//! `crates/app-iced/src/platform/win32/transparency.rs`」——新增一个 objc FFI 文件会让该作业 +//! 直接红,而那个工作流不在 M182 的改动范围内。 +//! 3. **拿不到 `NSWindow`**:`app/window_alpha.rs` 的 `probe_hwnd` 在非 Windows 恒返回 `None`, +//! 于是 `App::apply_window_corner` 走 `NoWindow` 分支——macOS 上那条下发链**从来没被调用过**。 +//! 要接线得改 `app/window_alpha.rs` + `app/update.rs`(M182 范围之外,且 M168/M178 正在改 +//! `app/**`)。 +//! 4. **本机编不了**:macOS 分支要靠 macOS 工具链,Windows 上连类型检查都跑不到。 +//! +//! ## 后续口径(**未实现**,需另立 mission + Mac 真机第 4 轮验收) +//! +//! 目标:把外框交还给窗口服务器当标准窗口,由它按自己的规则圆角(= 用户定的 +//! 「其他系统按照窗口合成器的默认规则来」): +//! +//! ```text +//! decorations: true, +//! platform_specific: { title_hidden: true, titlebar_transparent: true, fullsize_content_view: true } +//! ``` +//! +//! - 三个字段 iced 0.14 已暴露(`iced_core-0.14.0/src/window/settings/macos.rs`), +//! iced_winit 1:1 转发(`iced_winit-0.14.1/src/conversion.rs:125-137` 的 +//! `#[cfg(target_os = "macos")]` 块); +//! `FullSizeContentView` 让内容铺满整窗(不留原生标题栏高度),窗口服务器把整窗 +//! ——**连同我们的背景图**——裁成圆角,不会出现「外框圆了、里面还是方块」。 +//! - **必须同时隐藏我们自绘的三键**(`crates/app-iced/src/shell/mod.rs`),否则屏幕上会同时存在 +//! 左上角原生三色灯与右上角自绘三键两套控件——那不是「跟随合成器」,是两套控件打架。 +//! - **已知阻塞点(真做之前必须先解掉)**:winit 有 `with_titlebar_buttons_hidden` +//! (`src/platform/macos.rs:290`,能一次藏掉全部三色灯),但 **iced 0.14 的 +//! `PlatformSpecific` 没有转发它**;而 `WindowExtMacOS` 也没有运行期隐藏按钮的接口 +//! (只有 `set_has_shadow` / `set_document_edited` 等)。三条出路:① 给 iced 上游提转发 +//! (最干净,但要等版本);② 仅用 `closeable: false, minimizable: false` 藏掉关闭/最小化 +//! 两键——**缩放键藏不掉**,会留一个孤零零的绿灯;③ 拿到 `NSWindow` 后手动 `setHidden:` +//! (回到 objc + `unsafe`,见上面的第 2、3 条)。 +//! - **验收日志会一起变**:`renderer::acceptance::visual` 从 `decorations` 推 +//! `controls=self-drawn|native`,一旦改成 `true`,那行会写 `controls=native`,而我们当时 +//! 仍在自绘三键(除非同一次改动把三键撤掉)⇒ 那条改动必须**同一个 mission 内**一起做。 +//! - **验证路径**:本机(Windows)拿不到任何 macOS 像素;只能靠 +//! `ci.yml` 的 `runtime-smoke-macos` 作业(dispatch 触发、`continue-on-error`)看**编不编得过**, +//! 视觉效果必须等**第 4 轮 Mac 真机验收图**或用户手动确认。 +//! +//! ## 本轮明确未验 +//! +//! - macOS 圆的半径、平滑度、与系统其它窗口是否一致(无真机); +//! - 高 DPI(Retina)下系统圆角的视觉半径(由系统决定,我们读不到,也不需要缩放——这一档 +//! 本来就「不归我们管」,见 [`crate::platform::win32::corner`] 的 `radius=system`); +//! - 原生三色灯的最终观感与「是否保留自绘三键」的产品取舍。 diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index e4e1e3f..a2e04a4 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -1,29 +1,47 @@ -//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点, -//! 以及系统语言真值源,三件事分居三处。 +//! 平台相关的窗口能力(P4 / D36 / M116 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点, +//! 以及系统语言真值源,四件事分居四处。 //! //! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; -//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 → -//! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明; +//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`] / [`plan_corner_for`](M116 / M182): +//! 用户策略 × 宿主平台 × 渲染档 → 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明; //! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` + //! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`); //! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 //! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示 -//! 适配器)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 + 没测试的观感」这条 -//! 诚实的分界。 +//! 适配器、无 Mac 真机)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 + +//! 没测试的观感」这条诚实的分界。 +//! +//! ## 平台分册(M182:非 Windows 不再是「未知」一格) +//! +//! | 模块 | 里面有什么 | 有没有 FFI | +//! |---|---|---| +//! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 | +//! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) | +//! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) | +//! | [`win32`] | Windows 的实现与探针(DWM / 分层 / 只此一处的 `unsafe`) | **有**(唯一落点) | +//! +//! 两个新平台模块**只有文档、没有代码**:M182 的裁决是不实现(macOS 要动手就得先迁移原生标题栏, +//! 那会引入两套窗口控件,必须连着撤掉自绘三键 + 真机验收一起做)。把结论写在这里而不是写进报告, +//! 是因为报告会随会话消失,而下一轮动这块的人一定先读代码。 use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; use crate::Backend; +pub mod host; +pub mod linux; pub mod locale; +pub mod macos; pub mod win32; -/// 窗口圆角的**判定**(M116)住在 `win32::corner`:判据是 Windows 版本与 DWM 能力, -/// 非 Windows 平台在这里恒得「直角」(平台没有可承诺的系统圆角)。 -pub use win32::corner::{plan_corner, CornerExclusion, CornerPlan, CornerSource}; +/// 窗口圆角的**判定**(M116 / M182)住在 `win32::corner`:判据是用户策略 × 宿主平台 × 渲染档。 +/// 非 Windows 的两档(macOS = 合成器不裁 borderless 窗、Linux = CSD 没人裁)在 +/// [`macos`] / [`linux`] 两个模块里逐条给了证据。 +pub use host::HostOs; +pub use win32::corner::{plan_corner, plan_corner_for, CornerExclusion, CornerPlan, CornerSource}; /// 本机 Windows 版本(`None` = 非 Windows / 读不到)。**进程内只探测一次**。 /// diff --git a/crates/app-iced/src/platform/win32/corner.rs b/crates/app-iced/src/platform/win32/corner.rs index b4a8c87..4e53c10 100644 --- a/crates/app-iced/src/platform/win32/corner.rs +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -1,22 +1,24 @@ -//! M116:窗口外框圆角的**判据**(纯函数,零 `unsafe`、零 FFI、零环境读取)。 +//! M116 / M182:窗口外框圆角的**判据**(纯函数,零 `unsafe`、零 FFI、零环境读取)。 //! //! ## 判据为什么单独拎出来 //! -//! 「能不能圆」= 用户策略 × 系统版本 × 渲染档,这三件事**可以被断言证明**;而 DWM 的**视觉** -//! 结果在本仓库的验证环境(RDP 断连、抓屏失效)上没有参考价值。把可测的那一半独立出来, -//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`] +//! 「能不能圆」= 用户策略 × 宿主平台 × 渲染档,这几件事**可以被断言证明**;而合成器的**视觉** +//! 结果在本仓库的验证环境(RDP 断连、抓屏失效、无 Mac 真机)上没有参考价值。把可测的那一半独立 +//! 出来,才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`] //! 同一套分法)。 //! //! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。 //! -//! ## 平台矩阵(哪些是实测、哪些是推断) +//! ## 平台矩阵(M182 修订:非 Windows 不再是「未知」一格) //! -//! | 平台 | 我们做什么 | 依据级别 | -//! |---|---|---| -//! | Win11 / Server 2025(build ≥ 22000) | 下发 `DWMWCP_ROUND`(**请求**系统圆角) | 写入被接受 = **本机 API 实测**;「圆没圆 / 边缘什么样」= **未验**(无像素、回读不可用) | -//! | Win10 及更早(build < 22000) | 直角;不做 `SetWindowRgn` 毛边默认 | 「该属性不存在」= **外部文档证据**;本机没有 Win10 | -//! | macOS | 不主动加,交给系统 | **推断**(未真机验) | -//! | Linux X11 / Wayland | 不承诺,直角即如实结果 | **推断**(未真机验) | +//! | 平台 | `source` | 谁画圆角 | 依据级别 | +//! |---|---|---|---| +//! | Win11 / Server 2025(build ≥ 22000) | `dwm` | DWM(我们只下发偏好) | 写入被接受 = **本机 API 实测**;「圆没圆」= **未验**(Server 2025 上实测无圆角,见下) | +//! | Win10 及更早(build < 22000) | `square` + `os-build` / `cpu-tier` | 没人 | 「该属性不存在」= **外部文档证据**;本机没有 Win10 | +//! | Windows 版本读不到 | `square` + `unknown-os` | 没人 | 探测失败,不猜 | +//! | macOS | `compositor` + `borderless` | 本该窗口服务器,但**它只裁 titled 窗**,我们是 borderless ⇒ 没人 | **源码级证据**(见 [`crate::platform::macos`]) | +//! | Linux X11 / Wayland | `compositor` + `client-side` | 本该合成器,但协议里没有圆角这回事,CSD 窗没人裁 | **规范级证据**(见 [`crate::platform::linux`]) | +//! | 其余(FreeBSD / Android / wasm…) | `square` + `unsupported` | 没人 | 不承诺 | //! //! ⚠️ **「Win11 上我们的窗口一定有圆角、边缘平滑」这句话本分支的证据不支持**,别写回去。 //! 本机(Server 2025 build 26100、RDP 会话断开)测到的是: @@ -28,63 +30,74 @@ //! ⚠️ **第 ③ 条不能拿裸 popup 的读数去解释产品窗口**(M116 r4 复审订正):产品窗口 //! **不是** `WS_POPUP`(winit 只在带 owner 的弹出窗才加它;无边框观感来自 `WM_NCCALCSIZE`), //! 它带 `WS_CAPTION|WS_THICKFRAME` ⇒ 按同一张表应得 `+0,−7`,与 `decorated` 同族。 -//! 探针里 `product` / `product+alpha` 两行量的就是这个真实形状。 //! //! ④ **整窗 α 会关掉圆角**(tower 裁定 A / D47:整窗透明度优先于 DWM 圆角):α < 1 时我们补 //! `WS_EX_LAYERED`,此时系统会忽略圆角请求——请求照样发出(日志里 `pref=` 有值), //! 只是不生效;α 回到 1.0 就恢复。日志用 `suppressed_by=window_alpha` 记这一条**观测事实**。 //! //! 「圆角长什么样」归真机验收,证据目录见 `DevDocs/artifacts/m116/`。 -//! -//! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。 -//! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。 use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; +use crate::platform::host::HostOs; use crate::Backend; -/// 一次「圆角该从哪来」的判定结果(M116)。 +/// 一次「圆角该从哪来」的判定结果(M116 / M182)。 /// -/// 只有两种来源,且**互相排斥**: +/// 三档**互斥**,回答的是同一个问题:「这个窗口的四角由谁负责」。 /// -/// | 来源 | 谁画圆角 | 我们做什么 | +/// | 来源 | 谁负责 | 我们做什么 | /// |---|---|---| -/// | [`CornerSource::Dwm`] | 系统合成器(是否真画、半径多少都不由我们定,也读不到) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` | -/// | [`CornerSource::Square`] | 没人 | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 | +/// | [`CornerSource::Dwm`] | Windows 的 DWM(半径不由我们定,也读不到) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` | +/// | [`CornerSource::Compositor`] | 平台的窗口合成器 / 窗口服务器**按它自己的规则**(M182 新增) | **什么都不做**:不下发偏好、不自绘裁切(D46) | +/// | [`CornerSource::Square`] | 没人 | Windows 上显式下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 | /// -/// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定): +/// **「合成器」这一档不等于「一定有圆角」**:它只说「圆角的决定权不在我们手里」。落到这一档 +/// 之后到底圆不圆,由 `excluded` 那一栏如实交代(macOS = `borderless`、Linux = `client-side`)。 +/// 把它写成「跟随合成器 ⇒ 圆角」正是 M182 要修的误读。 +/// +/// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定,D46 第 4 条): /// 窗口是 `decorations=false` + 自绘标题栏 + 整窗 `LWA_ALPHA`,自绘圆角意味着把内容层裁成圆角, /// 那会把标题栏三键与边缘缩放热区一起裁掉;系统也不暴露圆角半径(无 API 可读), -/// 「固定 8/12/16」只是凭空维护一个数字。所以非 DWM 平台**就是直角**,并且日志里说明原因。 +/// 「固定 8/12/16」只是凭空维护一个数字。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CornerSource { /// DWM 系统圆角(Win11 / Server 2025 内核,build ≥ [`WIN11_MIN_BUILD`])。 Dwm, + /// 平台跟随合成器(M182):圆角归属交给窗口合成器 / 窗口服务器,我们既不下发也不自绘。 + Compositor, /// 直角(用户选择、系统不支持、或该平台不承诺)。 Square, } impl CornerSource { - /// 日志取值(`corner=… source=dwm|square`)。 + /// 日志取值(`corner=… source=dwm|compositor|square`)。 #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Dwm => "dwm", + Self::Compositor => "compositor", Self::Square => "square", } } - /// 是否要求 DWM 真去画圆角。 + /// 是否要求 DWM 真去画圆角(唯一需要下发 FFI 的那一档)。 #[must_use] pub const fn is_dwm(self) -> bool { matches!(self, Self::Dwm) } + + /// 是否把圆角归属交给合成器(M182:只在非 Windows 上可能为真)。 + #[must_use] + pub const fn is_compositor(self) -> bool { + matches!(self, Self::Compositor) + } } -/// 为什么**不是** DWM 圆角(`None` = 就是 DWM 圆角)。日志里逐字打出来。 +/// 为什么**没有**圆角(`None` = 就是 DWM 圆角)。日志里逐字打出来。 /// /// 为什么要这个枚举而不是一个 `bool`:用户报的现象是「别的窗口都有圆角,只有我们的没有」, -/// 于是「没圆角」至少有四种完全不同的原因,混成一个 `square` 以后没人能分辨 +/// 于是「没圆角」至少有六种完全不同的原因,混成一个 `square` 以后没人能分辨 /// 「没生效」与「生效了但看不见」(M116 任务书原话)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CornerExclusion { @@ -92,11 +105,21 @@ pub enum CornerExclusion { UserSquare, /// 系统太老:Win10 及更早(build < [`WIN11_MIN_BUILD`])——DWM 没有这个偏好属性。 OsBuild, - /// 版本探测不出来(非 Windows / 注册表读不到 / 有东西在骗版本号)。 + /// Windows 版本探测不出来(注册表读不到 / 有东西在骗版本号)。**只在 Windows 上出现**。 UnknownOs, /// 非 DWM 平台上的软件渲染档:圆角只能靠自绘/`SetWindowRgn` 毛边路,D36 的 /// 「CPU 档不碰合成技巧」优先,所以退回直角。 CpuTier, + /// macOS(M182):窗口是 `decorations=false` ⇒ winit 给 `NSWindowStyleMask::Borderless` + /// (**没有 `Titled`**)⇒ 窗口服务器不给这类窗口做圆角裁剪。不是「系统不支持圆角」, + /// 是「我们交上去的形状不在会被裁的那一类里」。证据见 [`crate::platform::macos`]。 + BorderlessFrame, + /// Linux(M182):`xdg-shell` / X11 协议里都没有「圆角半径」这种东西,CSD 无边框窗 + /// 合成器不会替我们裁(谁装饰谁负责,而我们自己声明了 CSD)。D46 又排除自绘 ⇒ 直角。 + /// 证据与各合成器差异见 [`crate::platform::linux`]。 + ClientSideFrame, + /// 不认识的平台(FreeBSD / Android / wasm …):不承诺。 + Unsupported, } impl CornerExclusion { @@ -108,34 +131,49 @@ impl CornerExclusion { Self::OsBuild => "os-build", Self::UnknownOs => "unknown-os", Self::CpuTier => "cpu-tier", + Self::BorderlessFrame => "borderless", + Self::ClientSideFrame => "client-side", + Self::Unsupported => "unsupported", } } } -/// 判定结果:来源 + 排除原因 + 平台版本(日志与测试共用)。 +/// 判定结果:来源 + 排除原因 + 宿主平台 + Windows 版本(日志与测试共用)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CornerPlan { /// 圆角从哪来。 pub source: CornerSource, - /// `None` = 走 DWM;`Some(..)` = 走直角,并说明为什么。 + /// `None` = 走合成器/DWM;`Some(..)` = 这一档没有圆角,并说明为什么。 pub excluded: Option, - /// 探测到的 Windows 版本(`None` = 不是 Windows / 读不到)。 + /// 宿主平台族(M182:判据的输入,日志里也打出来)。 + pub host: HostOs, + /// 探测到的 Windows 版本(`None` = 非 Windows / 读不到)。 pub os: Option, } impl CornerPlan { - /// 日志行:`corner=policy=system source=dwm os=10.0.26100 excluded_reason=none radius=system`。 + /// 日志行:`corner=policy=system host=macos source=compositor os=n/a excluded_reason=borderless radius=system`。 /// - /// **`radius=system` 是刻意的**:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 + /// **`radius=system` 是刻意的**:系统不暴露圆角半径,我们也不自己定一个数字 /// (用户定案取消了 8/12/16 档),所以这一栏永远只说「系统决定」。 + /// + /// **`host=` 是 M182 加的**:用户看到的那行 `source=square os=unknown` 之所以难判断, + /// 就是因为「未知」同时被 Windows 探测失败和「这台根本不是 Windows」共用。有了 `host=` + /// 与 `excluded_reason=borderless|client-side`,同一行就能自己解释完。 + /// + /// **非 Windows 的 `os=n/a`**:那一栏装的是 Windows 版本,在 macOS / Linux 上不是「没读到」, + /// 而是「跟本机无关」——写 `unknown` 会让人去找一个根本不存在的注册表项。 #[must_use] pub fn log_line(&self, policy: WindowCorner) -> String { - let os = self - .os - .map_or_else(|| "unknown".to_owned(), |os| os.version_string()); + let os = match (self.host, self.os) { + (HostOs::Windows, Some(os)) => os.version_string(), + (HostOs::Windows, None) => "unknown".to_owned(), + _ => "n/a".to_owned(), + }; format!( - "corner=policy={} source={} os={os} excluded_reason={} radius=system", + "corner=policy={} host={} source={} os={os} excluded_reason={} radius=system", policy.as_str(), + self.host.as_str(), self.source.as_str(), self.excluded.map_or("none", CornerExclusion::as_str), ) @@ -144,15 +182,29 @@ impl CornerPlan { /// 判定「这一次窗口该不该有系统圆角」(纯函数,不调 FFI、不读环境)。 /// -/// 真值表(`backend` 只影响**非 DWM** 平台,见下): +/// 生产入口:平台取 [`HostOs::current`](编译期常量),Windows 版本由调用方探测后传进来。 +/// 想验另外两行真值表时用 [`plan_corner_for`]——那是同一个判据,只是平台可以由测试喂。 +#[must_use] +pub fn plan_corner( + policy: WindowCorner, + os: Option, + backend: Backend, +) -> CornerPlan { + plan_corner_for(policy, HostOs::current(), os, backend) +} + +/// 真值表(`os` / `backend` 只对 Windows 行有意义): /// -/// | `policy` | `os.build` | backend | `source` | `excluded` | -/// |---|---|---|---|---| -/// | `Square` | 任意 | 任意 | `Square` | `UserSquare` | -/// | `System` | `None` | 任意 | `Square` | `UnknownOs` | -/// | `System` | `< 22000` | GPU/Auto | `Square` | `OsBuild` | -/// | `System` | `< 22000` | CPU | `Square` | `CpuTier` | -/// | `System` | `>= 22000` | 任意 | `Dwm` | `None` | +/// | `policy` | `host` | `os.build` | backend | `source` | `excluded` | +/// |---|---|---|---|---|---| +/// | `Square` | 任意 | 任意 | 任意 | `Square` | `UserSquare` | +/// | `System` | Windows | `None` | 任意 | `Square` | `UnknownOs` | +/// | `System` | Windows | `< 22000` | GPU/Auto | `Square` | `OsBuild` | +/// | `System` | Windows | `< 22000` | CPU | `Square` | `CpuTier` | +/// | `System` | Windows | `>= 22000` | 任意 | `Dwm` | `None` | +/// | `System` | MacOs | 忽略 | 任意 | `Compositor` | `BorderlessFrame` | +/// | `System` | Linux | 忽略 | 任意 | `Compositor` | `ClientSideFrame` | +/// | `System` | Other | 忽略 | 任意 | `Square` | `Unsupported` | /// /// 为什么 CPU 档在 Win11 上**照样**用 DWM 圆角(**tower 2026-11-03 裁定,按此执行**): /// D36 的 CPU 档约束针对的是**我们自己的渲染路径**(面板 α / 玻璃 / 模糊要关、不许拿透明叠加 @@ -162,39 +214,64 @@ impl CornerPlan { /// 反过来在非 DWM 平台上,圆角只剩「自绘裁切 / `SetWindowRgn` 毛边」这两条**我们自己的** /// 合成技巧,那才是 D36 要拦的东西 ⇒ 那一格落 `cpu-tier` 而不是 `os-build` /// (同一个直角,**原因不同**,日志里可分辨)。 +/// +/// macOS / Linux 两行**不看 `backend`**(M182):我们根本不下发任何东西,也就没有「渲染档 +/// 要不要让步」这回事——CPU 档与 GPU 档在这两个平台上得到同一份判定,这是事实而不是遗漏。 #[must_use] -pub fn plan_corner( +pub fn plan_corner_for( policy: WindowCorner, + host: HostOs, os: Option, backend: Backend, ) -> CornerPlan { let square = |excluded| CornerPlan { source: CornerSource::Square, excluded: Some(excluded), + host, os, }; if policy == WindowCorner::Square { return square(CornerExclusion::UserSquare); } - match os { - None => square(CornerExclusion::UnknownOs), - Some(os) if os.build < WIN11_MIN_BUILD => match backend { - Backend::Cpu => square(CornerExclusion::CpuTier), - Backend::Gpu | Backend::Auto => square(CornerExclusion::OsBuild), + match host { + // Windows:能不能圆是 DWM 的属性与版本问题,得问。 + HostOs::Windows => match os { + None => square(CornerExclusion::UnknownOs), + Some(os) if os.build < WIN11_MIN_BUILD => match backend { + Backend::Cpu => square(CornerExclusion::CpuTier), + Backend::Gpu | Backend::Auto => square(CornerExclusion::OsBuild), + }, + Some(_) => CornerPlan { + source: CornerSource::Dwm, + excluded: None, + host, + os, + }, }, - Some(_) => CornerPlan { - source: CornerSource::Dwm, - excluded: None, + // macOS:圆角归窗口服务器,但我们交的是 borderless 窗(`decorations: false`)⇒ 它不裁。 + // 迁移成原生标题栏是另一件需要真机验收的事,配方见 `platform::macos`。 + HostOs::MacOs => CornerPlan { + source: CornerSource::Compositor, + excluded: Some(CornerExclusion::BorderlessFrame), + host, os, }, + // Linux:协议里没有圆角,CSD 窗由客户端装饰 ⇒ 没人替我们裁,自绘被 D46 排除。 + HostOs::Linux => CornerPlan { + source: CornerSource::Compositor, + excluded: Some(CornerExclusion::ClientSideFrame), + host, + os, + }, + HostOs::Other => square(CornerExclusion::Unsupported), } } /// 产品日志行的**唯一格式化落点**(纯函数:把四段文本拼成一个固定形状)。 /// /// ```text -/// [corner] corner=policy=system source=dwm os=10.0.26100 excluded_reason=none \ +/// [corner] corner=policy=system host=windows source=dwm os=10.0.26100 excluded_reason=none \ /// radius=system pref=2 readback=? read_hr=0x80070057 frame=overlapped layered=on \ /// suppressed_by=window_alpha /// ``` diff --git a/crates/app-iced/src/platform/win32/corner/tests.rs b/crates/app-iced/src/platform/win32/corner/tests.rs index 796db2d..68f57d4 100644 --- a/crates/app-iced/src/platform/win32/corner/tests.rs +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -2,11 +2,17 @@ //! 而日志行的形状断言本身就有几十行)。 //! //! 判据与真实读数的出处:`DevDocs/artifacts/m116/probe-table.md`。 +//! +//! M182 起这份真值表**覆盖三行平台**:Windows / macOS / Linux。这是 `HostOs` 作为**参数** +//! (而不是 `#[cfg]`)的直接收益——本机只有 Windows,但另外两行的判据照样能被断言证明。 +//! 「非 Windows」那一组断言住在 [`platforms`](本文件守 400 行上限)。 -use super::{format_line, plan_corner, CornerExclusion, CornerPlan, CornerSource}; +use super::{format_line, plan_corner, CornerPlan, CornerSource}; use crate::Backend; use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; +mod platforms; + /// 本机实测版本(Server 2025 / build 26100)。 fn win11() -> WindowsVersion { WindowsVersion::new(10, 0, 26_100) @@ -17,14 +23,18 @@ fn win10() -> WindowsVersion { WindowsVersion::new(10, 0, 19_045) } -/// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`os`/`backend` 任一条判反都会红)。 +/// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`host`/`os`/`backend` 任一条判反都会红)。 /// **抓不住**:DWM 到底有没有真画圆角(那要像素,见 `platform/win32` 的探针输出)。 #[test] -fn truth_table_policy_os_backend() { +fn truth_table_policy_host_os_backend() { + use super::{plan_corner_for, CornerExclusion}; + use crate::platform::host::HostOs; + let cases = [ - // policy=Square:任何系统/后端都直角,原因是用户选的。 + // policy=Square:任何平台/系统/后端都直角,原因是用户选的。 ( WindowCorner::Square, + HostOs::Windows, Some(win11()), Backend::Gpu, CornerSource::Square, @@ -32,21 +42,16 @@ fn truth_table_policy_os_backend() { ), ( WindowCorner::Square, - Some(win10()), + HostOs::Linux, + None, Backend::Cpu, CornerSource::Square, Some(CornerExclusion::UserSquare), ), - ( - WindowCorner::Square, - None, - Backend::Auto, - CornerSource::Square, - Some(CornerExclusion::UserSquare), - ), - // policy=System + 探测不出系统:不许猜「Win11」。 + // policy=System + Windows 上探测不出系统:不许猜「Win11」。 ( WindowCorner::System, + HostOs::Windows, None, Backend::Gpu, CornerSource::Square, @@ -55,6 +60,7 @@ fn truth_table_policy_os_backend() { // Win10:直角,GPU 档原因是版本、CPU 档原因是渲染档(同一个直角,原因不同)。 ( WindowCorner::System, + HostOs::Windows, Some(win10()), Backend::Gpu, CornerSource::Square, @@ -62,6 +68,7 @@ fn truth_table_policy_os_backend() { ), ( WindowCorner::System, + HostOs::Windows, Some(win10()), Backend::Auto, CornerSource::Square, @@ -69,6 +76,7 @@ fn truth_table_policy_os_backend() { ), ( WindowCorner::System, + HostOs::Windows, Some(win10()), Backend::Cpu, CornerSource::Square, @@ -77,6 +85,7 @@ fn truth_table_policy_os_backend() { // Win11 / Server 2025:三个后端都用 DWM(D36 只管我们自己的合成技巧)。 ( WindowCorner::System, + HostOs::Windows, Some(win11()), Backend::Gpu, CornerSource::Dwm, @@ -84,29 +93,54 @@ fn truth_table_policy_os_backend() { ), ( WindowCorner::System, + HostOs::Windows, Some(win11()), Backend::Cpu, CornerSource::Dwm, None, ), + // macOS(M182):圆角归窗口服务器,但我们交的是 borderless 窗 ⇒ 它不裁。 ( WindowCorner::System, - Some(win11()), - Backend::Auto, - CornerSource::Dwm, + HostOs::MacOs, None, + Backend::Gpu, + CornerSource::Compositor, + Some(CornerExclusion::BorderlessFrame), + ), + // Linux(M182):CSD 窗没人裁,自绘被 D46 排除。 + ( + WindowCorner::System, + HostOs::Linux, + None, + Backend::Gpu, + CornerSource::Compositor, + Some(CornerExclusion::ClientSideFrame), + ), + // 不认识的平台:不承诺。 + ( + WindowCorner::System, + HostOs::Other, + None, + Backend::Gpu, + CornerSource::Square, + Some(CornerExclusion::Unsupported), ), ]; - for (policy, os, backend, want_source, want_excluded) in cases { - let plan = plan_corner(policy, os, backend); + for (policy, host, os, backend, want_source, want_excluded) in cases { + let plan = plan_corner_for(policy, host, os, backend); assert_eq!( plan.source, want_source, - "policy={policy:?} os={os:?} backend={backend:?}" + "policy={policy:?} host={host:?} os={os:?} backend={backend:?}" ); assert_eq!( plan.excluded, want_excluded, - "policy={policy:?} os={os:?} backend={backend:?} 的原因码" + "policy={policy:?} host={host:?} os={os:?} backend={backend:?} 的原因码" + ); + assert_eq!( + plan.host, host, + "判定必须把输入的平台原样带回来(日志要用)" ); } } @@ -130,15 +164,16 @@ fn the_win11_boundary_is_inclusive() { ); } -/// 日志行的形状:四个字段都在,且 `excluded_reason=none` 与 `reason=user` 不许混。 +/// 日志行的形状:五个字段都在,且 `excluded_reason=none` 与 `reason=user` 不许混。 /// /// 为什么要钉这一条:这行是「后来的人分辨没生效 / 生效了看不见」的唯一依据(任务书原话), -/// 少一栏就等于把这条线索丢掉。 +/// 少一栏就等于把这条线索丢掉。M182 新增的 `host=` 与「非 Windows 的 `os=n/a`」在这里钉死。 #[test] fn the_log_line_carries_every_field() { let dwm = plan_corner(WindowCorner::System, Some(win11()), Backend::Gpu) .log_line(WindowCorner::System); assert!(dwm.starts_with("corner=policy=system"), "{dwm}"); + assert!(dwm.contains("host=windows"), "{dwm}"); assert!(dwm.contains("source=dwm"), "{dwm}"); assert!(dwm.contains("os=10.0.26100"), "{dwm}"); assert!(dwm.contains("excluded_reason=none"), "{dwm}"); @@ -178,7 +213,7 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { assert_eq!( line, concat!( - "[corner] boot corner=policy=system source=dwm os=10.0.26100 ", + "[corner] boot corner=policy=system host=windows source=dwm os=10.0.26100 ", "excluded_reason=none radius=system pref=2 readback=? ", "read_hr=0x80070057 frame=overlapped layered=on ", "suppressed_by=window_alpha" @@ -195,7 +230,7 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { None, ); assert!( - square.contains("corner=policy=square source=square"), + square.contains("corner=policy=square host=windows source=square"), "{square}" ); assert!(square.contains("excluded_reason=user"), "{square}"); @@ -214,14 +249,6 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { let cpu = plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System); assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}"); - - // 探测不到版本(macOS / X11 / Wayland 落到同一格):`unknown-os`,不许猜 Win11。 - let unknown = - plan_corner(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System); - assert!( - unknown.contains("os=unknown") && unknown.contains("excluded_reason=unknown-os"), - "{unknown}" - ); } /// **同一策略 N 次只下发 / 只记 1 行**(M116 r1 复审 P2-4 的判据)。 @@ -235,9 +262,7 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { /// 抓不住:真进程里到底打了几行(`eprintln!` 在 `App` 里,本测试只钉判定)。 #[test] fn the_same_policy_is_dispatched_only_once() { - use super::{needs_redispatch, plan_corner, CornerApplied}; - use crate::Backend; - use unlucky_presenter::prefs::{WindowCorner, WindowsVersion}; + use super::{needs_redispatch, CornerApplied}; let os = WindowsVersion::new(10, 0, 26_100); let plan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); diff --git a/crates/app-iced/src/platform/win32/corner/tests/platforms.rs b/crates/app-iced/src/platform/win32/corner/tests/platforms.rs new file mode 100644 index 0000000..7c900c8 --- /dev/null +++ b/crates/app-iced/src/platform/win32/corner/tests/platforms.rs @@ -0,0 +1,188 @@ +//! 「非 Windows」那一组断言(M182):`HostOs` 是判据的**参数**,所以本机(Windows)也能验。 +//! +//! 为什么单独成文件:`corner/tests.rs` 已经贴着 400 行上限(M182 加进来的三行平台断言 +//! 正好是压垮它的那部分)。 + +use super::{win10, win11}; +use crate::platform::host::HostOs; +use crate::platform::win32::corner::{plan_corner_for, CornerExclusion, CornerSource}; +use crate::Backend; +use unlucky_presenter::prefs::WindowCorner; + +const ALL_HOSTS: [HostOs; 4] = [HostOs::Windows, HostOs::MacOs, HostOs::Linux, HostOs::Other]; +const ALL_BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto]; + +/// **M182 的核心断言**:`os = None` 不再等于「未知平台」。 +/// +/// 同一个 `(System, None, Gpu)` 三元组,在 Windows 上是「版本读不出来」(`unknown-os`), +/// 在 macOS / Linux 上是「这一栏跟本机无关」(`borderless` / `client-side`)。把三行写成 +/// 同一格就是用户看到的那句 `source=square os=unknown`——它同时糊掉了「探测失败」与 +/// 「这台根本不是 Windows」两件事。 +/// +/// **抓得住**:谁把非 Windows 两行改回 `Square`/`UnknownOs`(或把 `host` 从判据里摘掉)。 +#[test] +fn a_missing_windows_version_does_not_mean_unknown_platform() { + let rows: Vec<_> = [HostOs::Windows, HostOs::MacOs, HostOs::Linux] + .into_iter() + .map(|host| { + let plan = plan_corner_for(WindowCorner::System, host, None, Backend::Gpu); + (host, plan.source, plan.excluded) + }) + .collect(); + + assert_eq!( + rows, + vec![ + ( + HostOs::Windows, + CornerSource::Square, + Some(CornerExclusion::UnknownOs) + ), + ( + HostOs::MacOs, + CornerSource::Compositor, + Some(CornerExclusion::BorderlessFrame) + ), + ( + HostOs::Linux, + CornerSource::Compositor, + Some(CornerExclusion::ClientSideFrame) + ), + ] + ); +} + +/// 非 Windows 的日志行必须自己解释完:有 `host=`、`os=n/a`(不是 `unknown`)、原因码说人话。 +/// +/// **抓得住**:把 `os=` 在非 Windows 上也写成 `unknown`——那会让人去找一个不存在的注册表项。 +#[test] +fn the_non_windows_log_line_explains_itself() { + let mac = plan_corner_for(WindowCorner::System, HostOs::MacOs, None, Backend::Gpu) + .log_line(WindowCorner::System); + assert!(mac.contains("host=macos"), "{mac}"); + assert!(mac.contains("os=n/a"), "{mac}"); + assert!(!mac.contains("os=unknown"), "{mac}"); + assert!(mac.contains("source=compositor"), "{mac}"); + assert!(mac.contains("excluded_reason=borderless"), "{mac}"); + + let linux = plan_corner_for(WindowCorner::System, HostOs::Linux, None, Backend::Cpu) + .log_line(WindowCorner::System); + assert!(linux.contains("host=linux"), "{linux}"); + assert!(linux.contains("os=n/a"), "{linux}"); + assert!(linux.contains("excluded_reason=client-side"), "{linux}"); + + // Windows 上 `os=unknown` 仍要保留它本来的意思:真的没探测到版本。 + let win = plan_corner_for(WindowCorner::System, HostOs::Windows, None, Backend::Gpu) + .log_line(WindowCorner::System); + assert!( + win.contains("host=windows") && win.contains("os=unknown"), + "{win}" + ); +} + +/// macOS / Linux 两行**不看渲染档**:我们什么都不下发,也就没有「CPU 档要不要让步」这回事。 +/// +/// **抓得住**:有人按 Windows 的形状给这两行加 `backend` 分支(那会变成凭空造的降级)。 +#[test] +fn the_non_windows_rows_ignore_the_backend() { + for host in [HostOs::MacOs, HostOs::Linux] { + let plans: Vec<_> = ALL_BACKENDS + .into_iter() + .map(|backend| plan_corner_for(WindowCorner::System, host, None, backend)) + .collect(); + for plan in &plans { + assert_eq!( + (plan.source, plan.excluded), + (plans[0].source, plans[0].excluded), + "{host:?}:三个渲染档必须得到同一份判定(实际 {plan:?})" + ); + } + } +} + +/// macOS 与 Linux **不许共用同一个原因码**:两边的机制不同(缺 `Titled` 位 ≠ 协议里没有圆角)。 +/// +/// **抓得住**:复制粘贴把 Linux 行也标成 `borderless`(那会让以后看日志的人去研究 +/// `NSWindowStyleMask`,而 Linux 上根本没有这个东西)。 +#[test] +fn macos_and_linux_report_different_reasons() { + let mac = plan_corner_for(WindowCorner::System, HostOs::MacOs, None, Backend::Gpu); + let linux = plan_corner_for(WindowCorner::System, HostOs::Linux, None, Backend::Gpu); + assert_ne!(mac.excluded, linux.excluded); + assert_eq!(mac.excluded, Some(CornerExclusion::BorderlessFrame)); + assert_eq!(linux.excluded, Some(CornerExclusion::ClientSideFrame)); + // 两行都不是「我们要求 DWM 画」,而是「决定权在合成器」(`is_compositor` / `is_dwm` + // 这两个小判据的用处就在这里:以后加平台时先问一句「这一档算不算合成器档」)。 + assert!(mac.source.is_compositor() && linux.source.is_compositor()); + assert!(!mac.source.is_dwm() && !linux.source.is_dwm()); +} + +/// `Compositor` 这一档**不许**在 Windows 上出现;`Dwm` 也**不许**在非 Windows 上出现。 +/// +/// **抓得住**:「非 Windows 一律 compositor」写成「一律不 square」这类图省事的判定, +/// 或者反过来把 Windows 的 DWM 判据搬去 Linux 假装能用(M182 任务书点名禁止)。 +#[test] +fn each_source_stays_on_its_own_platforms() { + for os in [None, Some(win10()), Some(win11())] { + for backend in ALL_BACKENDS { + let win = plan_corner_for(WindowCorner::System, HostOs::Windows, os, backend); + assert_ne!( + win.source, + CornerSource::Compositor, + "Windows 上不许出现 compositor 档:{win:?}" + ); + } + } + for host in [HostOs::MacOs, HostOs::Linux, HostOs::Other] { + for backend in ALL_BACKENDS { + // 非 Windows 传一个「看起来像 Win11」的版本号进去:判据**不许**被它带偏。 + let plan = plan_corner_for(WindowCorner::System, host, Some(win11()), backend); + assert_ne!( + plan.source, + CornerSource::Dwm, + "{host:?} 上没有 DWM:{plan:?}" + ); + } + } +} + +/// `source` 与 `excluded` 不许互相撒谎:说「从合成器来」就必须说清为什么没来。 +/// +/// **抓得住**:把 macOS/Linux 写成 `source=compositor excluded_reason=none`——那行的字面意思是 +/// 「已经按合成器圆了」,而用户屏幕上仍是直角(正是 M182 要修的那种误读)。 +#[test] +fn the_source_never_claims_a_corner_that_the_exclusion_denies() { + for policy in [WindowCorner::System, WindowCorner::Square] { + for host in ALL_HOSTS { + for os in [None, Some(win10()), Some(win11())] { + for backend in ALL_BACKENDS { + let plan = plan_corner_for(policy, host, os, backend); + match plan.source { + // 真下发了 DWM 偏好时不该有原因码。 + CornerSource::Dwm => assert_eq!(plan.excluded, None, "{plan:?}"), + // 合成器档在当前实现里**永远**带原因码(没有任何平台真拿到系统圆角)。 + CornerSource::Compositor => assert!( + plan.excluded.is_some(), + "source=compositor 必须交代原因:{plan:?}" + ), + CornerSource::Square => assert!(plan.excluded.is_some(), "{plan:?}"), + } + } + } + } + } +} + +/// 用户选「直角」必须在**每一个**平台上都赢(包括将来真的迁移了标题栏的 macOS)。 +/// +/// **抓得住**:把 `policy == Square` 的短路写在 `match host` 之后或写进某个平台分支里。 +#[test] +fn the_user_square_choice_wins_on_every_platform() { + for host in ALL_HOSTS { + for backend in ALL_BACKENDS { + let plan = plan_corner_for(WindowCorner::Square, host, Some(win11()), backend); + assert_eq!(plan.source, CornerSource::Square, "{host:?} {backend:?}"); + assert_eq!(plan.excluded, Some(CornerExclusion::UserSquare)); + } + } +} diff --git a/crates/app-iced/src/platform/win32/transparency/dwm.rs b/crates/app-iced/src/platform/win32/transparency/dwm.rs index a8707b2..8637977 100644 --- a/crates/app-iced/src/platform/win32/transparency/dwm.rs +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -102,11 +102,16 @@ const E_INVALIDARG: u32 = 0x8007_0057; /// `Dwm` → [`DWMWCP_ROUND`];`Square` → [`DWMWCP_DONOTROUND`]。 /// 刻意**不用**默认值(`0`):那在 Win11 上等于「请 DWM 自己决定」,而无边框窗口 /// 到底算不算「有框」由 DWM 的启发式决定——那正是我们要么拿到、要么明确放弃的东西。 +/// +/// M182 的 `Compositor` 档(跟随合成器)在 Windows 上**不可达**(`plan_corner_for` 不产出它, +/// 单测钉着这一点);真要出现,正确的落点是默认值 `0`(= 让系统自己决定), +/// 而不是被当成「强制直角」——否则 `source=compositor` 那行日志会与下发值自相矛盾。 #[must_use] pub const fn preference_for(source: super::super::corner::CornerSource) -> u32 { use super::super::corner::CornerSource; match source { CornerSource::Dwm => DWMWCP_ROUND, + CornerSource::Compositor => 0, CornerSource::Square => DWMWCP_DONOTROUND, } } @@ -282,94 +287,7 @@ pub fn style_summary(hwnd: isize) -> String { } #[cfg(test)] -mod tests { - use super::{ - apply_corner, corner_preference_hr, preference_for, CornerOutcome, - DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_DONOTROUND, - DWMWCP_ROUND, - }; - use crate::platform::win32::corner::{CornerPlan, CornerSource}; - use unlucky_presenter::prefs::WindowsVersion; - - /// **抓得住**:属性号写错(34 写成 33 / 37 写成 36 —— 这两个都在 Win11 上存在,改错不会崩, - /// 只会静默读出别的东西)、两个偏好值互换。 - /// **抓不住**:这些值在真窗口上是否真的生效(那要探针读数,见 `probe` 与报告)。 - #[test] - fn attribute_numbers_follow_the_win32_header() { - assert_eq!(DWMWA_WINDOW_CORNER_PREFERENCE, 34); - assert_eq!(DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, 37); - assert_eq!(DWMWCP_ROUND, 2); - assert_eq!(DWMWCP_DONOTROUND, 1); - } - - /// **抓得住**:`preference_for` 把圆角/直角映射反了(这一格反了就是「设置里选直角反而更圆」)。 - #[test] - fn the_preference_mapping_cannot_be_swapped() { - assert_eq!(preference_for(CornerSource::Dwm), DWMWCP_ROUND); - assert_eq!(preference_for(CornerSource::Square), DWMWCP_DONOTROUND); - } - - /// 无效句柄:**不下发**、也不 panic。句柄 0 走 `NoWindow`(还没拿到窗口 ≠ 平台不支持)。 - #[test] - fn a_missing_window_is_reported_not_guessed() { - let plan = CornerPlan { - source: CornerSource::Dwm, - excluded: None, - os: Some(WindowsVersion::new(10, 0, 26_100)), - }; - assert_eq!(apply_corner(0, &plan), CornerOutcome::NoWindow); - assert_eq!(corner_preference_hr(0).0, None); - // 1 号句柄永远无效:非 Windows 上是「平台不支持」,Windows 上是 DWM 的失败码, - // 两种都不许表现成「成功」。 - let outcome = apply_corner(1, &plan); - assert!( - matches!( - outcome, - CornerOutcome::Failed { .. } - | CornerOutcome::Unsupported { .. } - | CornerOutcome::UnsupportedPlatform - ), - "无效句柄应报失败,实际 {outcome:?}" - ); - } - - /// 日志尾巴三态可区分:设了 / 设了但读不回 / 属性不支持。 - #[test] - fn the_log_tail_separates_applied_from_unsupported() { - let applied = CornerOutcome::Applied { - preference: DWMWCP_ROUND, - readback: Some(DWMWCP_ROUND), - read_hresult: 0, - }; - assert!( - applied.describe().starts_with("pref=2 readback=2"), - "{applied:?}" - ); - - let blind = CornerOutcome::Applied { - preference: DWMWCP_ROUND, - readback: None, - read_hresult: 0x8000_4005, - }; - assert!( - blind.describe().contains("readback=?"), - "{}", - blind.describe() - ); - - let old = CornerOutcome::Unsupported { - hresult: 0x8007_0057, - }; - assert!( - old.describe().contains("attribute unsupported"), - "{}", - old.describe() - ); - assert!(CornerOutcome::UnsupportedPlatform - .describe() - .contains("platform=unsupported")); - } -} +mod tests; /// **抓得住**:`frame_label` 把 `WS_POPUP` 与 `WS_OVERLAPPEDWINDOW` 判反(那会让日志把 /// 「我们自己的窗口」写成「普通窗口」,这行日志的全部价值就是分辨这个)。 diff --git a/crates/app-iced/src/platform/win32/transparency/dwm/tests.rs b/crates/app-iced/src/platform/win32/transparency/dwm/tests.rs new file mode 100644 index 0000000..1550d5a --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/dwm/tests.rs @@ -0,0 +1,124 @@ +//! `transparency::dwm` 的单测(M182 从 `dwm.rs` 内联挪出来:那个文件贴着 400 行上限, +//! 而 M182 要往 `preference_for` 里加第三档映射)。 +//! +//! 挪动是**逐行照搬** + M182 的新断言,没有重写判据。 + +use super::{ + apply_corner, corner_preference_hr, preference_for, CornerOutcome, + DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_DONOTROUND, + DWMWCP_ROUND, +}; +use crate::platform::host::HostOs; +use crate::platform::win32::corner::{CornerPlan, CornerSource}; +use unlucky_presenter::prefs::WindowsVersion; + +/// **抓得住**:属性号写错(34 写成 33 / 37 写成 36 —— 这两个都在 Win11 上存在,改错不会崩, +/// 只会静默读出别的东西)、两个偏好值互换。 +/// **抓不住**:这些值在真窗口上是否真的生效(那要探针读数,见 `probe` 与报告)。 +#[test] +fn attribute_numbers_follow_the_win32_header() { + assert_eq!(DWMWA_WINDOW_CORNER_PREFERENCE, 34); + assert_eq!(DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, 37); + assert_eq!(DWMWCP_ROUND, 2); + assert_eq!(DWMWCP_DONOTROUND, 1); +} + +/// **抓得住**:`preference_for` 把圆角/直角映射反了(这一格反了就是「设置里选直角反而更圆」)。 +#[test] +fn the_preference_mapping_cannot_be_swapped() { + assert_eq!(preference_for(CornerSource::Dwm), DWMWCP_ROUND); + assert_eq!(preference_for(CornerSource::Square), DWMWCP_DONOTROUND); +} + +/// M182 的第三档:`Compositor`(跟随合成器)在 Windows 上**不会出现**(见 +/// `plan_corner_for` 的真值表),所以这里钉两件事——① match 必须写全、不许 panic; +/// ② 真要出现时它落到 `DWMWCP_DEFAULT`(`0` = 让 DWM 自己决定),而不是被偷偷当成直角。 +/// +/// **抓得住**:有人把这一臂写成 `DWMWCP_DONOTROUND`(那就成了「说跟随合成器、实际强制直角」, +/// 与 `source=compositor` 那行日志自相矛盾)。 +#[test] +fn the_compositor_tier_maps_to_letting_the_system_decide() { + assert_eq!( + preference_for(CornerSource::Compositor), + 0, + "Compositor 档必须落到 DWMWCP_DEFAULT(0),不是 DONOTROUND" + ); + // 且它在 Windows 的判据里不可达(真值表不会产出它)。 + for os in [None, Some(WindowsVersion::new(10, 0, 26_100))] { + for backend in [ + crate::Backend::Gpu, + crate::Backend::Cpu, + crate::Backend::Auto, + ] { + let plan = crate::platform::win32::corner::plan_corner_for( + unlucky_presenter::prefs::WindowCorner::System, + HostOs::Windows, + os, + backend, + ); + assert_ne!(plan.source, CornerSource::Compositor, "{plan:?}"); + } + } +} + +/// 无效句柄:**不下发**、也不 panic。句柄 0 走 `NoWindow`(还没拿到窗口 ≠ 平台不支持)。 +#[test] +fn a_missing_window_is_reported_not_guessed() { + let plan = CornerPlan { + source: CornerSource::Dwm, + excluded: None, + host: HostOs::Windows, + os: Some(WindowsVersion::new(10, 0, 26_100)), + }; + assert_eq!(apply_corner(0, &plan), CornerOutcome::NoWindow); + assert_eq!(corner_preference_hr(0).0, None); + // 1 号句柄永远无效:非 Windows 上是「平台不支持」,Windows 上是 DWM 的失败码, + // 两种都不许表现成「成功」。 + let outcome = apply_corner(1, &plan); + assert!( + matches!( + outcome, + CornerOutcome::Failed { .. } + | CornerOutcome::Unsupported { .. } + | CornerOutcome::UnsupportedPlatform + ), + "无效句柄应报失败,实际 {outcome:?}" + ); +} + +/// 日志尾巴三态可区分:设了 / 设了但读不回 / 属性不支持。 +#[test] +fn the_log_tail_separates_applied_from_unsupported() { + let applied = CornerOutcome::Applied { + preference: DWMWCP_ROUND, + readback: Some(DWMWCP_ROUND), + read_hresult: 0, + }; + assert!( + applied.describe().starts_with("pref=2 readback=2"), + "{applied:?}" + ); + + let blind = CornerOutcome::Applied { + preference: DWMWCP_ROUND, + readback: None, + read_hresult: 0x8000_4005, + }; + assert!( + blind.describe().contains("readback=?"), + "{}", + blind.describe() + ); + + let old = CornerOutcome::Unsupported { + hresult: 0x8007_0057, + }; + assert!( + old.describe().contains("attribute unsupported"), + "{}", + old.describe() + ); + assert!(CornerOutcome::UnsupportedPlatform + .describe() + .contains("platform=unsupported")); +} diff --git a/crates/design/src/shape.rs b/crates/design/src/shape.rs index fe2e4a1..238ecbf 100644 --- a/crates/design/src/shape.rs +++ b/crates/design/src/shape.rs @@ -14,13 +14,19 @@ //! 一个元素该查哪一层由 [`radius_layer_for`] 判定(按尺寸,唯一入口);微控件层取值走 //! [`ComponentRadius::radius_px`],**尺寸超界时返回 `None`**——这不是文档约定,是签名保证。 //! -//! ## 窗口**外框**圆角不在这里(M116) +//! ## 窗口**外框**圆角不在这里(M116 / M182) //! -//! `ShapeScale` 管的是**我们画的东西**:卡片、按钮、浮层、面板。窗口的最外层圆角由操作系统 -//! 决定(Win11 / Server 2025 走 DWM 合成器,macOS 走 `layer.cornerRadius`,X11/Wayland 看合成器), -//! 系统**不暴露**那个半径值,我们也不自己定一个数字(用户定案:不做 8/12/16 档)。 -//! 所以窗口圆角没有令牌:判据在 `unlucky-app-iced` 的 `platform::plan_corner`, -//! 落点在 `platform/win32/transparency`。 +//! `ShapeScale` 管的是**我们画的东西**:卡片、按钮、浮层、面板。窗口的最外层圆角由平台决定, +//! 系统**不暴露**那个半径值,我们也不自己定一个数字(用户定案:不做 8/12/16 档;D46 也不许 +//! 自绘裁切)。三档平台的机制各不相同(M182 已逐条取证): +//! +//! - Win11 / Server 2025:下发给 DWM;(Win10 及更早没有这个属性) +//! - macOS:窗口服务器只给 **titled** 窗裁圆角,我们是 `decorations: false` 的 borderless 窗 +//! ⇒ 不裁(`platform::macos` 里有三环证据与后续口径); +//! - X11 / Wayland:协议里没有圆角这回事,CSD 窗由客户端装饰 ⇒ 没人裁(`platform::linux`)。 +//! +//! 所以窗口圆角没有令牌:判据在 `unlucky-app-iced` 的 `platform::plan_corner` / +//! `platform::plan_corner_for`,Windows 的落点在 `platform/win32/transparency`。 //! //! 反过来说:**别**为了「让窗口看起来是圆的」去给根容器加 `ShapeScale::ExtraLarge`——那会把 //! 四角裁掉内容(背景图/标题栏底色露出方角),而外框仍然是方的。 From 28762ec26869eeee1b70a7510f4ade62dffcdba7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 15:18:23 +0800 Subject: [PATCH 02/33] =?UTF-8?q?fix(m182):=20=E5=88=A4=E6=8D=AE=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=80=E5=BE=8B=E6=98=BE=E5=BC=8F=E4=BC=A0=20HostOs?= =?UTF-8?q?=EF=BC=8Cubuntu=20=E9=97=A8=E7=A6=81=E5=9C=A8=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E9=87=8C=E5=89=8D=E5=90=8E=E5=AF=B9=E7=85=A7=E4=B8=BA=E7=BB=BF?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E5=AE=A1=20P1-1=20/=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P1-1 实测:`corner/tests.rs` 里三条用例走宿主绑定的 `plan_corner()` 却断言 Windows 的结果——本机(Windows)绿,合进 main 后 `ubuntu-latest` 的 test job 会当场红。已修: - 三条改为 `on_windows(...)` → `plan_corner_for(policy, HostOs::Windows, …)`: `the_win11_boundary_is_inclusive`、`the_log_line_carries_every_field`、 `the_log_line_shape_is_pinned_with_the_measured_readings`; 去重那条(`the_same_policy_is_dispatched_only_once`)也一并显式传 Windows。 - 宿主绑定的 `plan_corner()` 现在只剩**产品路径**(`app/update/appearance.rs:51`)与 **一条**用例 `the_production_entry_forwards_to_the_pure_judgement`——后者两边都用同一个 `HostOs::current()`,所以任何宿主都成立,它证明的是「入口确实转发」,不是某个平台的结果。 - m116 探针(`cfg(windows)` + `#[ignore]`)也改成显式 `HostOs::Windows`:那是真窗口实验, 不该让「这台机器是 Windows」成为隐式前提。产物那一行逐字不变。 复审 P2-1:`unknown-os` 此前没有任何断言。新增表测试 `every_exclusion_code_is_a_stable_non_empty_string`,把七个原因码逐个钉住(含 `unknown-os`), 并断言非空、无空白、两两不重复、总数 7。另在 `the_log_line_carries_every_field` 里补 `host=windows` + `os=unknown` + `excluded_reason=unknown-os` 同现。 证据(`packaging/linux-check-docker.sh`,容器内 ubuntu 真实复跑,非推理): - 修前(把 tests.rs 回退到 0451b3f):`test -p unlucky-app-iced --lib` → **3 failed** (正好是复审点名的三条),exit=1; - 修后同一命令:**685 passed / 0 failed**,exit=0; - 最终树跑完整两条 CI 门禁:`cargo clippy --workspace --all-targets -- -D warnings` exit=0、 `cargo test --workspace --all-targets --no-fail-fast` exit=0(53 个测试二进制,合计 passed=2672 / failed=0)。 本机(Windows)同树:clippy exit=0、`test -p unlucky-app-iced --lib` 686 passed、fmt exit=0。 `decorations` 取值未动(分支 vs 基点仅新增注释行);非 Windows 的 `os=` 仍为 `n/a`, Windows 上「真的读不到版本」仍叫 `os=unknown`,但同一行有 `host=windows` + `excluded_reason=unknown-os` 自解释。 --- .../src/platform/win32/corner/tests.rs | 118 ++++++++++++++---- .../src/platform/win32/transparency/tests.rs | 20 ++- 2 files changed, 114 insertions(+), 24 deletions(-) diff --git a/crates/app-iced/src/platform/win32/corner/tests.rs b/crates/app-iced/src/platform/win32/corner/tests.rs index 68f57d4..1a8acbb 100644 --- a/crates/app-iced/src/platform/win32/corner/tests.rs +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -3,11 +3,20 @@ //! //! 判据与真实读数的出处:`DevDocs/artifacts/m116/probe-table.md`。 //! -//! M182 起这份真值表**覆盖三行平台**:Windows / macOS / Linux。这是 `HostOs` 作为**参数** -//! (而不是 `#[cfg]`)的直接收益——本机只有 Windows,但另外两行的判据照样能被断言证明。 +//! ## M182 的两条纪律(都来自复审 P1-1,别再退回原样) +//! +//! 1. **判据断言一律走 [`plan_corner_for`],平台显式传值**。`plan_corner` 是**产品入口**, +//! 它读的是 `HostOs::current()`——同一个测试在 Windows 上绿、在 ubuntu 上就可能红。 +//! M182 首轮复审实测:本机(Windows)384+ 条全绿,而其中三条断言的是 Windows 的结果, +//! 合进 main 后 `ubuntu-latest` 的 `test` job 会当场红。 +//! 2. **`plan_corner` 只留一条用例**,且那条必须**与宿主无关**: +//! [`the_production_entry_forwards_to_the_pure_judgement`] 两边都用同一个 `HostOs::current()`, +//! 所以它在任何平台上都成立,只证「产品入口确实转发到纯函数」。 +//! //! 「非 Windows」那一组断言住在 [`platforms`](本文件守 400 行上限)。 -use super::{format_line, plan_corner, CornerPlan, CornerSource}; +use super::{format_line, plan_corner, plan_corner_for, CornerExclusion, CornerPlan, CornerSource}; +use crate::platform::host::HostOs; use crate::Backend; use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; @@ -23,13 +32,18 @@ fn win10() -> WindowsVersion { WindowsVersion::new(10, 0, 19_045) } +/// 判据断言用的固定宿主:**永远是 Windows**,与本机实际跑在哪个系统上无关。 +/// +/// 为什么要有这个包装而不是直接写 `HostOs::Windows`:写一次名字就够了,以后想给 Linux / +/// macOS 也加 Windows 专属断言时,改这一处即可。 +fn on_windows(policy: WindowCorner, os: Option, backend: Backend) -> CornerPlan { + plan_corner_for(policy, HostOs::Windows, os, backend) +} + /// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`host`/`os`/`backend` 任一条判反都会红)。 /// **抓不住**:DWM 到底有没有真画圆角(那要像素,见 `platform/win32` 的探针输出)。 #[test] fn truth_table_policy_host_os_backend() { - use super::{plan_corner_for, CornerExclusion}; - use crate::platform::host::HostOs; - let cases = [ // policy=Square:任何平台/系统/后端都直角,原因是用户选的。 ( @@ -153,12 +167,12 @@ fn truth_table_policy_host_os_backend() { fn the_win11_boundary_is_inclusive() { let at = WindowsVersion::new(10, 0, WIN11_MIN_BUILD); assert_eq!( - plan_corner(WindowCorner::System, Some(at), Backend::Gpu).source, + on_windows(WindowCorner::System, Some(at), Backend::Gpu).source, CornerSource::Dwm ); let below = WindowsVersion::new(10, 0, WIN11_MIN_BUILD - 1); assert_eq!( - plan_corner(WindowCorner::System, Some(below), Backend::Gpu).source, + on_windows(WindowCorner::System, Some(below), Backend::Gpu).source, CornerSource::Square, "build 21999 = Win10 22H2 家族末尾,不能算 Win11" ); @@ -170,7 +184,7 @@ fn the_win11_boundary_is_inclusive() { /// 少一栏就等于把这条线索丢掉。M182 新增的 `host=` 与「非 Windows 的 `os=n/a`」在这里钉死。 #[test] fn the_log_line_carries_every_field() { - let dwm = plan_corner(WindowCorner::System, Some(win11()), Backend::Gpu) + let dwm = on_windows(WindowCorner::System, Some(win11()), Backend::Gpu) .log_line(WindowCorner::System); assert!(dwm.starts_with("corner=policy=system"), "{dwm}"); assert!(dwm.contains("host=windows"), "{dwm}"); @@ -179,15 +193,17 @@ fn the_log_line_carries_every_field() { assert!(dwm.contains("excluded_reason=none"), "{dwm}"); assert!(dwm.contains("radius=system"), "{dwm}"); - let square = plan_corner(WindowCorner::Square, Some(win11()), Backend::Gpu) + let square = on_windows(WindowCorner::Square, Some(win11()), Backend::Gpu) .log_line(WindowCorner::Square); assert!(square.contains("source=square"), "{square}"); assert!(square.contains("excluded_reason=user"), "{square}"); - let unknown: CornerPlan = plan_corner(WindowCorner::System, None, Backend::Gpu); - assert!(unknown - .log_line(WindowCorner::System) - .contains("os=unknown")); + // Windows 上「版本读不到」:`os=unknown` **配上** `host=windows` 才是完整的真话。 + let unknown = + on_windows(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System); + assert!(unknown.contains("host=windows"), "{unknown}"); + assert!(unknown.contains("os=unknown"), "{unknown}"); + assert!(unknown.contains("excluded_reason=unknown-os"), "{unknown}"); } /// **日志行的形状**,用 M116 探针窗口的**真实读数**构造(`DevDocs/artifacts/m116/probe-table.md`)。 @@ -201,7 +217,7 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { // 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;产品形状是 `overlapped`, // 而 `layered=on` 只可能来自整窗 α(我们的 `WS_EX_LAYERED`) ⇒ 带 `suppressed_by=`。 let os = WindowsVersion::new(10, 0, 26_100); - let plan: CornerPlan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); + let plan: CornerPlan = on_windows(WindowCorner::System, Some(os), Backend::Gpu); let line = format_line( "boot", &plan, @@ -223,7 +239,7 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { // 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。 let square = format_line( "change", - &plan_corner(WindowCorner::Square, Some(os), Backend::Gpu), + &on_windows(WindowCorner::Square, Some(os), Backend::Gpu), WindowCorner::Square, "pref=1 readback=? read_hr=0x80070057", "frame=overlapped layered=off", @@ -241,20 +257,80 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { // Win10:原因码是 `os-build`(GPU 档)/ `cpu-tier`(CPU 档),来源仍是 square。 let win10 = WindowsVersion::new(10, 0, 19_045); let os_build = - plan_corner(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System); + on_windows(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System); assert!( os_build.contains("source=square") && os_build.contains("excluded_reason=os-build"), "{os_build}" ); let cpu = - plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System); + on_windows(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System); assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}"); } +/// **产品入口只有一条用例**,且它**与宿主无关**(复审 P1-1 的要求)。 +/// +/// 断言的是「`plan_corner` 就是 `plan_corner_for(HostOs::current(), …)`」:两边喂同一个 +/// `HostOs::current()`,所以在 Windows / ubuntu / macOS 上恒成立——它证明的是**转发**, +/// 不是某个平台的结果。任何一个平台的结果都由 [`truth_table_policy_host_os_backend`] +/// 显式传 `HostOs` 钉住。 +/// +/// **抓得住**:`plan_corner` 里偷偷改成写死的 `HostOs::Windows`(那样 ubuntu 上这条会红)、 +/// 或者干脆不再转发(判据分叉)。 +#[test] +fn the_production_entry_forwards_to_the_pure_judgement() { + for policy in [WindowCorner::System, WindowCorner::Square] { + for os in [None, Some(win10()), Some(win11())] { + for backend in [Backend::Gpu, Backend::Cpu, Backend::Auto] { + let production = plan_corner(policy, os, backend); + assert_eq!( + production, + plan_corner_for(policy, HostOs::current(), os, backend), + "plan_corner 必须原样转发到纯函数(policy={policy:?} os={os:?} backend={backend:?})" + ); + assert_eq!(production.host, HostOs::current()); + } + } + } +} + +/// 每个原因码都是一个**非空、无空格、互不重复**的稳定串(复审 P2-1:`unknown-os` 此前没有任何断言)。 +/// +/// 为什么值得一条表测试:这些串是产品日志里 `excluded_reason=` 的唯一取值,而它们只在 +/// **构建这样一张表**的时候才会被逐个过一遍——散在断言里时,`unknown-os` 这种「只在一台 +/// Windows 机器、且版本探测失败时才出现」的值最容易一直没有断言(复审就是这么抓到的)。 +/// +/// **抓得住**:两个原因码共用一个串(日志再也分不清原因)、串里混进空格(那一栏会被截断)、 +/// 或者某一条被改成空串。 +#[test] +fn every_exclusion_code_is_a_stable_non_empty_string() { + use std::collections::BTreeSet; + + let codes = [ + (CornerExclusion::UserSquare, "user"), + (CornerExclusion::OsBuild, "os-build"), + (CornerExclusion::UnknownOs, "unknown-os"), + (CornerExclusion::CpuTier, "cpu-tier"), + (CornerExclusion::BorderlessFrame, "borderless"), + (CornerExclusion::ClientSideFrame, "client-side"), + (CornerExclusion::Unsupported, "unsupported"), + ]; + + let mut seen = BTreeSet::new(); + for (reason, code) in codes { + assert_eq!(reason.as_str(), code, "{reason:?} 的日志取值"); + assert!( + !code.is_empty() && !code.contains(char::is_whitespace), + "原因码必须是单个词(日志按空格分栏):{code:?}" + ); + assert!(seen.insert(code), "两个原因码共用了同一个串:{code}"); + } + assert_eq!(seen.len(), 7, "目前共七个原因码,少一个就是漏了断言"); +} + /// **同一策略 N 次只下发 / 只记 1 行**(M116 r1 复审 P2-4 的判据)。 /// /// 为什么要有这条:设置页的整窗 α / 面板 α / 暗化滑条每动一下都会触发一次外观变更, -/// 而圆角与它们无关。没有去重时拖一次滑条会刷出一串 `[corner] change …`, +/// 而圆角与它们无关。没有去重时拖一次滑条就会刷出一串 `[corner] change …`, /// 把「分辨有没有生效」的那唯一一行日志淹掉。 /// /// 抓得住:把去重删掉(`needs_redispatch` 恒 `true` 时这里会数到 N)、或指纹里漏掉某一维 @@ -265,7 +341,7 @@ fn the_same_policy_is_dispatched_only_once() { use super::{needs_redispatch, CornerApplied}; let os = WindowsVersion::new(10, 0, 26_100); - let plan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); + let plan = on_windows(WindowCorner::System, Some(os), Backend::Gpu); // 模拟「滑条被拖动 20 次」:每次都是同一个策略、同一个系统、句柄一直在。 let mut last: Option = None; @@ -280,7 +356,7 @@ fn the_same_policy_is_dispatched_only_once() { assert_eq!(dispatches, 1, "同一策略连按 20 次只该下发/记 1 次"); // 换档必须重下。 - let square = plan_corner(WindowCorner::Square, Some(os), Backend::Gpu); + let square = on_windows(WindowCorner::Square, Some(os), Backend::Gpu); assert!(needs_redispatch( last.as_ref(), &CornerApplied::new(WindowCorner::Square, &square, true, false) diff --git a/crates/app-iced/src/platform/win32/transparency/tests.rs b/crates/app-iced/src/platform/win32/transparency/tests.rs index 4f55898..fe4f70a 100644 --- a/crates/app-iced/src/platform/win32/transparency/tests.rs +++ b/crates/app-iced/src/platform/win32/transparency/tests.rs @@ -194,11 +194,20 @@ fn dword_error_codes_and_hresults_are_printed_differently() { #[test] #[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"] fn m116_corner_log_line_on_real_windows() { - use crate::platform::win32::corner::plan_corner; + use crate::platform::host::HostOs; + use crate::platform::win32::corner::plan_corner_for; use unlucky_presenter::prefs::WindowCorner; + // 平台**显式传 Windows**:这条探针本身就是 `#[cfg(target_os = "windows")]` 的真窗口实验, + // 用宿主绑定的 `plan_corner` 只会让「这台机器是 Windows」变成一个隐式前提(复审 P1-1 的 + // 同类问题:本机绿、别的宿主红)。判据与产品完全同一份,产物那一行逐字不变。 let os = crate::platform::current_windows_version(); - let plan = plan_corner(WindowCorner::System, os, crate::Backend::Gpu); + let plan = plan_corner_for( + WindowCorner::System, + HostOs::Windows, + os, + crate::Backend::Gpu, + ); // 直接遍历 `controlled_experiment` 的**同一份**行定义(含 `product` / `product+alpha`), // 免得这里另抄一份样式表、跟探针悄悄分叉。 for row in super::controlled_experiment() { @@ -225,7 +234,12 @@ fn m116_corner_log_line_on_real_windows() { } // 设置里选「直角」时的取值(`excluded_reason` 必须变成 `user`)。 - let square = plan_corner(WindowCorner::Square, os, crate::Backend::Gpu); + let square = plan_corner_for( + WindowCorner::Square, + HostOs::Windows, + os, + crate::Backend::Gpu, + ); println!( "[corner] policy=square {}", square.log_line(WindowCorner::Square) From df331f6a6098f897e827a5502e6fbe89d693ae7e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 19:52:04 +0800 Subject: [PATCH 03/33] =?UTF-8?q?feat(m175):=20Java=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=8E=A5=E7=9C=9F=E8=BF=90=E8=A1=8C=E6=97=B6=EF=BC=88?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=20/=20=E6=89=8B=E5=8A=A8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=20/=20=E9=BB=98=E8=AE=A4=20JDK=20/=20=E5=86=85=E5=AD=98=20/=20?= =?UTF-8?q?JVM=20=E5=8F=82=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测四条抱怨逐条对应: - 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 + 注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消 - 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘 - 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回 - 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version), 设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否) core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与 取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。 i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。 复审 r1(p2-5items)全修: - P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动 - P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key), 不再把 Rust 枚举名当人话 - P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在) - P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串 - P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间 不许起第二个(界面显示「正在取消…」) 复审 r2(p2-2items)全修: - P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口 - P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫, 配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响 - 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、 core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、 默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息) --- crates/app-iced/src/app/state/construct.rs | 2 + crates/app-iced/src/app/subscription.rs | 2 + crates/app-iced/src/app/update/clock.rs | 5 + .../app-iced/src/app/update/settings/java.rs | 290 ++++++++++++- .../app-iced/src/app/update/settings/tests.rs | 12 +- .../src/app/update/settings/tests/java.rs | 163 ++++++++ .../app-iced/src/components/selection/chip.rs | 16 + crates/app-iced/src/pages/settings/java.rs | 94 +++-- .../src/pages/settings/java/fields.rs | 246 +++++++++++ .../app-iced/src/pages/settings/java/row.rs | 191 +++++++++ .../app-iced/src/pages/settings/java/seed.rs | 75 ++++ .../app-iced/src/pages/settings/java/state.rs | 374 +++++++++++++++++ .../app-iced/src/pages/settings/java/store.rs | 393 ++++++++++++++++++ .../app-iced/src/pages/settings/java/tests.rs | 242 +++++++++++ .../src/pages/settings/java/tests/cmdline.rs | 262 ++++++++++++ .../src/pages/settings/java/tests/runtime.rs | 318 ++++++++++++++ .../app-iced/src/pages/settings/java/view.rs | 284 +++++++++++++ crates/app-iced/src/pages/settings/mod.rs | 21 +- .../src/pages/settings/tests/java_section.rs | 52 +-- .../app-iced/src/pages/settings/tests/mod.rs | 20 +- crates/core/src/game/java/manual.rs | 324 +++++++++++++++ crates/core/src/game/java/mod.rs | 3 + crates/core/src/game/java/proc.rs | 169 ++++++++ crates/core/src/game/java/staged.rs | 347 ++++++++++++++++ crates/i18n/src/catalog/golden/tail.rs | 59 +++ crates/i18n/src/catalog/settings/java.rs | 151 ++++++- 26 files changed, 4030 insertions(+), 85 deletions(-) create mode 100644 crates/app-iced/src/app/update/settings/tests/java.rs create mode 100644 crates/app-iced/src/pages/settings/java/fields.rs create mode 100644 crates/app-iced/src/pages/settings/java/row.rs create mode 100644 crates/app-iced/src/pages/settings/java/seed.rs create mode 100644 crates/app-iced/src/pages/settings/java/state.rs create mode 100644 crates/app-iced/src/pages/settings/java/store.rs create mode 100644 crates/app-iced/src/pages/settings/java/tests.rs create mode 100644 crates/app-iced/src/pages/settings/java/tests/cmdline.rs create mode 100644 crates/app-iced/src/pages/settings/java/tests/runtime.rs create mode 100644 crates/app-iced/src/pages/settings/java/view.rs create mode 100644 crates/core/src/game/java/manual.rs create mode 100644 crates/core/src/game/java/proc.rs create mode 100644 crates/core/src/game/java/staged.rs diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index cd262a7..231d64b 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -151,6 +151,8 @@ impl App { app.rebuild_ui(); // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 app.hydrate_ai_settings(); + // M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。 + app.hydrate_java_settings(); // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 app.lang.log(); diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 15393d6..1511ebb 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -87,6 +87,8 @@ impl App { self.has_screenshot() || !self.state.toasts.is_empty() || !self.state.running().is_empty() + // M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。 + || self.settings.java.scanning.is_some() || self .watch_until .is_some_and(|deadline| Instant::now() < deadline) diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 3a7971e..31f9aed 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -49,6 +49,9 @@ impl App { self.font_seen = font_status; self.view_dirty = true; } + // Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面, + // 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。 + self.hydrate_java_scan(); // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s, // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); @@ -77,6 +80,8 @@ impl App { self.reload_appearance(); // M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。 self.hydrate_ai_settings(); + // M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。 + self.hydrate_java_settings(); } if self.view_dirty { diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index a8f3fe7..7747a6f 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -1,33 +1,291 @@ -//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。 +//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。 //! -//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个 -//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。 +//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在 +//! `pages/settings/java/{state,store}.rs`。本文件只有四件事: //! -//! 消息进来的通道有两条,都不经过共享文件: -//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`; -//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))` -//! (`app::Message::Page` 是既有通道,不用加新变体)。 +//! | 消息 | 做什么 | +//! |---|---| +//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) | +//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 | +//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) | +//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` | //! -//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线 -//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的 -//! `the_group_dispatch_has_no_catch_all_arm` 会红。 +//! ## 为什么「设为默认」不攒到「保存」里 +//! +//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致), +//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到 +//! 界面还标着旧的默认值,只会以为没生效。 +//! +//! ## 版本门槛(「为什么选它」那句话的数据) +//! +//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。 +//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。 use super::super::*; +use crate::pages::settings::java::{Context, Layer, Notice, Requirement}; + impl App { /// Java 分组的一条消息。 pub(super) fn on_settings_java( &mut self, message: pages::settings::java::Message, ) -> Task { + use pages::settings::java::Message; + match message { - // M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 - // 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 - #[cfg(test)] - pages::settings::java::Message::Ping => { - self.view_dirty = true; - Task::none() + Message::Scan => return self.start_java_scan(), + Message::CancelScan => { + self.settings.java.scan.cancel(); + self.hydrate_java_scan(); } + Message::AddDraft(value) => { + self.settings.java.add_draft = value; + self.settings.java.notice = None; + self.view_dirty = true; + } + Message::Add => self.add_java_manual(), + Message::SelectRuntime(binary) => self.select_java_default(&binary), + Message::EditMemory(value) => { + self.settings.java.memory = value; + self.touch_java(); + } + Message::EditJvm(value) => { + self.settings.java.jvm = value; + self.touch_java(); + } + Message::Save => self.save_java_settings(), + } + + Task::none() + } + + /// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。 + fn touch_java(&mut self) { + self.settings.java.dirty = true; + self.settings.java.notice = None; + self.view_dirty = true; + } + + /// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。 + /// + /// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs` + /// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。 + /// + /// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已 + /// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。 + /// + /// 截图通道的两档:`ULL_JAVA_STATE=` 用种子(D30 可复现); + /// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底 + /// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。 + pub(in crate::app) fn hydrate_java_settings(&mut self) { + let seeded = self.options.screenshot.is_some(); + let mut live = false; + if seeded { + let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string()); + if raw.trim() == "live" { + live = true; + } else { + if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) { + self.settings.java = state; + } + return; + } + } + let Some(config) = pages::settings::java::store::config_of(&self.bridge) else { + self.settings.java = pages::settings::java::State::default(); + return; + }; + let snapshot = pages::settings::java::store::load(&config); + self.settings.java.manual = pages::settings::java::store::load_manual(&config); + let changed = self.settings.java.hydrate(snapshot); + let scan_changed = self.hydrate_java_scan(); + self.settings.java.context = self.java_context(); + if changed || scan_changed { + self.view_dirty = true; + } + // 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起** + // (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由 + // [`App::auto_scan_java_if_due`] 承载,测试直接调它。 + if !cfg!(test) { + self.auto_scan_java_if_due(seeded && !live); + } + } + + /// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。 + /// + /// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了, + /// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。 + pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool { + let report = self.settings.java.scan.report(); + if pages::settings::java::store::auto_scan_due(&report, screenshot) { + let _ = self.start_java_scan(); + return true; + } + false + } + + /// 把后台扫描的快照灌进状态。返回是否变了。 + pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool { + let report = self.settings.java.scan.report(); + let changed = self.settings.java.apply_scan(&report); + if changed { + self.view_dirty = true; + } + changed + } + + /// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。 + fn start_java_scan(&mut self) -> Task { + let Some(config) = pages::settings::java::store::config_of(&self.bridge) else { + self.settings.java.notice = Some(Notice::NoStorage); + self.view_dirty = true; + return Task::none(); + }; + let manual = pages::settings::java::store::load_manual(&config) + .into_iter() + .map(std::path::PathBuf::from) + .collect::>(); + let roots = pages::settings::java::store::managed_roots(&config); + self.settings.java.notice = None; + if self.settings.java.scan.start(roots, manual, true) { + self.hydrate_java_scan(); + } + self.view_dirty = true; + Task::none() + } + + /// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。 + fn add_java_manual(&mut self) { + let Some(config) = pages::settings::java::store::config_of(&self.bridge) else { + self.settings.java.notice = Some(Notice::NoStorage); + self.view_dirty = true; + return; + }; + let draft = self.settings.java.add_draft.clone(); + match pages::settings::java::store::add_manual(&config, &draft) { + Ok((binary, row)) => { + self.settings.java.manual = pages::settings::java::store::load_manual(&config); + if !self + .settings + .java + .runtimes + .iter() + .any(|known| pages::settings::java::same_path(&known.binary, &binary)) + { + self.settings.java.runtimes.push(row); + self.settings + .java + .runtimes + .sort_by_key(|runtime| runtime.major); + } + self.settings.java.add_draft = String::new(); + // 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉: + // 否则列表里明明有一行,状态行却写着「还没扫描过」。 + self.settings.java.scanned = true; + self.settings.java.notice = Some(Notice::Added(binary)); + } + Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)), + } + self.view_dirty = true; + } + + /// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。 + fn select_java_default(&mut self, binary: &str) { + let Some(config) = pages::settings::java::store::config_of(&self.bridge) else { + self.settings.java.notice = Some(Notice::NoStorage); + self.view_dirty = true; + return; + }; + match pages::settings::java::store::set_selected(&config, binary) { + Ok(snapshot) => { + // **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM + // 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新 + // `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。 + self.settings.java.hydrate(snapshot); + self.settings.java.context = self.java_context(); + self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string())); + } + Err(error) => self.settings.java.notice = Some(write_notice(error)), + } + self.view_dirty = true; + } + + /// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。 + fn save_java_settings(&mut self) { + let state = &self.settings.java; + if let Err(refusal) = state.validate() { + self.settings.java.notice = Some(Notice::Refused(refusal)); + self.view_dirty = true; + return; + } + let Some(config) = pages::settings::java::store::config_of(&self.bridge) else { + self.settings.java.notice = Some(Notice::NoStorage); + self.view_dirty = true; + return; + }; + match pages::settings::java::store::save(&config, &self.settings.java) { + Ok(snapshot) => { + // 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。 + self.settings.java.dirty = false; + self.settings.java.hydrate(snapshot); + self.settings.java.context = self.java_context(); + self.settings.java.notice = Some(Notice::Saved); + } + Err(error) => self.settings.java.notice = Some(write_notice(error)), + } + self.view_dirty = true; + } + + /// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。 + /// + /// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文 + /// (页面就不画门槛那一行,也不假装有要求)。 + fn java_context(&self) -> Context { + let tiles = self.state.tiles(); + let routed = match self.state.route() { + unlucky_presenter::router::Route::Detail(id) => { + tiles.iter().find(|cell| &cell.instance.id == id) + } + _ => None, + }; + let Some(cell) = routed.or_else(|| tiles.first()) else { + return Context::default(); + }; + let instance = cell.instance; + let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version); + Context { + required: Some(Requirement { + instance: instance.name.clone(), + version: instance.mc_version.clone(), + minimum: requirement.minimum, + maximum: requirement.maximum, + }), + layer: Some(match instance.launch.memory_mb { + Some(mb) => Layer::Instance { + instance: instance.name.clone(), + mb, + }, + None => Layer::Global, + }), + effective_memory_mb: Some(instance.launch.memory_effective_mb), + } + } +} + +/// 写盘失败 → 回执:三档都走 i18n key。 +/// +/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**), +/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志, +/// 界面上给一句页面自己的「写不进去」。 +fn write_notice(error: pages::settings::java::store::WriteError) -> Notice { + use pages::settings::java::store::WriteError; + match error { + WriteError::Refused(refusal) => Notice::Refused(refusal), + WriteError::Unwritable(reason) => Notice::Unwritable(reason), + WriteError::Schema(detail) => { + eprintln!("[java] 设置写盘失败:{detail}"); + Notice::WriteFailed } } } diff --git a/crates/app-iced/src/app/update/settings/tests.rs b/crates/app-iced/src/app/update/settings/tests.rs index 248897f..8d42d94 100644 --- a/crates/app-iced/src/app/update/settings/tests.rs +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() { let mut app = headless(); app.view_dirty = false; + // M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗 + // 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。 let _ = app.update(Message::Page(pages::Message::Settings( - pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping), + pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory( + "4096".to_string(), + )), ))); assert!( @@ -269,7 +273,7 @@ fn a_group_message_reaches_its_handler_through_the_funnel() { // --------------------------------------------------------------- M174:助手配置的活路径 /// 一个指向临时目录的设置桥(真文件,不是内存态)。 -fn bridged_app(label: &str) -> (App, std::path::PathBuf) { +pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) { let dir = std::env::temp_dir().join(format!( "ull-ai-app-{label}-{}-{}", std::process::id(), @@ -386,3 +390,7 @@ fn testing_without_configuration_does_nothing() { ); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); } + +/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。 +#[path = "tests/java.rs"] +mod java; diff --git a/crates/app-iced/src/app/update/settings/tests/java.rs b/crates/app-iced/src/app/update/settings/tests/java.rs new file mode 100644 index 0000000..9ff9e55 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/tests/java.rs @@ -0,0 +1,163 @@ +//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。 +//! +//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线 +//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。 + +use super::bridged_app; +use crate::app::Message; +use crate::pages; + +// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿 + +/// 一条 Java 分组消息 → 页面消息。 +fn java(message: pages::settings::java::Message) -> Message { + Message::Page(pages::Message::Settings( + pages::settings::GroupMessage::Java(message), + )) +} + +/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。 +/// +/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`, +/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉, +/// 一个字节都没落盘、也没有任何提示。 +/// +/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、 +/// JVM 回到空、`dirty` 变假)。 +#[test] +fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() { + let (mut app, path) = bridged_app("java-select"); + + let _ = app.update(java(pages::settings::java::Message::EditMemory( + "6144".to_string(), + ))); + let _ = app.update(java(pages::settings::java::Message::EditJvm( + "-XX:+UseG1GC".to_string(), + ))); + assert!(app.settings.java.dirty, "改过草稿就该是脏的"); + assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变"); + + let _ = app.update(java(pages::settings::java::Message::SelectRuntime( + "C:/jdk-21/bin/java.exe".to_string(), + ))); + + assert_eq!( + app.settings.java.selected, "C:/jdk-21/bin/java.exe", + "设为默认必须真的写进去" + ); + assert_eq!( + app.settings.java.memory, "6144", + "内存草稿被「设为默认」清掉了(P2-1 的数据丢失)" + ); + assert_eq!( + app.settings.java.jvm, "-XX:+UseG1GC", + "JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)" + ); + assert!( + app.settings.java.dirty, + "内存 / JVM 参数还没保存,不许标成已保存" + ); + assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来 + +static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +/// 一个会一直卡住的扫描作业(直到被取消)。 +fn held_job( + _candidates: &[unlucky_core::game::java::staged::Candidate], + cancelled: &dyn Fn() -> bool, + _progress: &mut dyn FnMut(usize, usize), +) -> Vec { + while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Vec::new() +} + +/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。 +/// +/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是 +/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次 +/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。 +/// +/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due` +/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。 +#[test] +fn a_cancelled_scan_is_not_started_again_by_a_config_reload() { + use std::sync::atomic::Ordering; + use std::time::{Duration, Instant}; + + HOLD.store(false, Ordering::SeqCst); + let (mut app, path) = bridged_app("java-cancel"); + + // 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成 + // cancelled=true / finished=false —— 正是复审指认的那个形状。 + let slot = app.settings.java.scan.clone(); + assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job)); + slot.cancel(); + let deadline = Instant::now() + Duration::from_secs(5); + while slot.report().running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!slot.report().running, "取消之后线程必须退出"); + assert!(slot.report().cancelled); + assert!(!slot.report().finished); + + // 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。 + app.hydrate_java_scan(); + assert!(app.settings.java.cancelled, "状态行要留着「已取消」"); + assert!(app.settings.java.scanning.is_none()); + + let started = app.auto_scan_java_if_due(false); + assert!( + !started, + "取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)" + ); + assert!( + !slot.report().running, + "不许起第二个扫描线程:{:?}", + slot.report() + ); + app.hydrate_java_scan(); + assert!( + app.settings.java.scanning.is_none(), + "状态行不许回到「正在扫描」" + ); + assert!( + app.settings.java.cancelled, + "取消标记要保持到下一次手动扫描为止" + ); + + HOLD.store(true, Ordering::SeqCst); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出 + +/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。 +/// +/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError` +/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是 +/// `Not saved: IO 失败 @ …`。 +/// +/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。 +#[test] +fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() { + let (mut app, path) = bridged_app("java-schema"); + + // `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度, + // 所以这一条会走到 `store.set` 的 schema 拒绝那一档。 + let too_long = "-D".to_string() + &"x".repeat(2000); + let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long))); + let _ = app.update(java(pages::settings::java::Message::Save)); + + assert_eq!( + app.settings.java.notice, + Some(pages::settings::java::Notice::WriteFailed), + "schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} diff --git a/crates/app-iced/src/components/selection/chip.rs b/crates/app-iced/src/components/selection/chip.rs index e84353d..fc149a1 100644 --- a/crates/app-iced/src/components/selection/chip.rs +++ b/crates/app-iced/src/components/selection/chip.rs @@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> { Self::new(label, ChipVariant::Input, false, on_press) } + /// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。 + /// + /// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**: + /// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。 + pub fn badge(label: &'a str) -> Self { + Self { + label, + variant: ChipVariant::Filter, + selected: true, + leading: None, + trailing: None, + on_press: None, + state: None, + } + } + /// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。 /// /// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的 diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs index dac13c9..ff6341a 100644 --- a/crates/app-iced/src/pages/settings/java.rs +++ b/crates/app-iced/src/pages/settings/java.rs @@ -1,44 +1,84 @@ -//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。 +//! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。 //! -//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件), -//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。 +//! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里: //! -//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架; -//! Java 分组讲的是运行时安装位置,用目录图标语义也对得上。 +//! | 这里 | 内容 | +//! |---|---| +//! | [`GROUP`] | 左列名 / 图标(正文走 [`view::body`],`body` 槽位保持 `None`) | +//! | [`JAVA_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update / Assistant 同一条**按下标分发**:本组要 `Lang`,`Group.body` 的签名里没有它) | +//! | [`Message`] | 本线的消息口(落点 `app/update/settings/java.rs`) | +//! +//! ## M101 的演示行去哪了 +//! +//! M101 留的 `rows: &[("Runtime","Temurin 21.0.4"), …]` 与绊线测试 +//! `the_java_group_is_still_demo_rows`(断言 `body.is_none()`)在 M175 一起删掉了: +//! 那三个值是**写死的演示字符串**,用户实测把它们当成了真检测结果 +//! (「它默认显示 Temurin 21.0.4 我甚至不能确定它是否是一个硬编码的值」)。 +//! 现在的真值来自三段真读盘:`core::game::java::staged`(扫描)、`core::game::java::manual` +//! (手动路径严格探测)、`config/launcher.json`(`java.*` 三键)。 +//! +//! `rows` 仍留成空切片:它是 `Group` 的字段(另外五组在用),而本组的 `body` 分发永远命中 +//! [`view::body`],空切片不会被画出来——宁可空着,也不留一句会撒谎的演示值。 + +mod fields; +mod row; +mod seed; +mod state; +pub(crate) mod store; +pub(super) mod view; + +#[cfg(test)] +mod tests; + +pub use state::{same_path, Context, Layer, Notice, Requirement, State}; + +/// 测试里的注入作业要写 `Vec`(App 层在生产路径上只用 `State`,不需要这个名字)。 +#[cfg(test)] +pub use row::RuntimeRow; use unlucky_design::icons::IconKind; use super::Group; +/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。 +/// +/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。 +pub(crate) const JAVA_GROUP: usize = 2; + /// Java 分组。 pub(super) const GROUP: Group = Group { name: "Java", icon: IconKind::Folder, - rows: &[ - ("Runtime", "Temurin 21.0.4"), - ("Default memory", "4 GiB"), - ("JVM arguments", "Automatic"), - ], + // 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。 + rows: &[], body: None, }; -/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。 -/// -/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段, -/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。 -#[derive(Debug, Default)] -pub struct State {} - -/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。 -/// -/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认 -/// [`GroupMessage`](super::GroupMessage))。 +/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。 #[derive(Debug, Clone)] pub enum Message { - /// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 → - /// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。 - /// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的 - /// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。 - #[cfg(test)] - Ping, + /// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来) + Scan, + /// 取消正在跑的扫描 + CancelScan, + /// 手动路径输入框 + AddDraft(String), + /// 提交手动添加(严格校验:真跑 `java -version`) + Add, + /// 把某个运行时设为默认(写 `java.selected_path`) + SelectRuntime(String), + /// 默认内存草稿(MB 文本) + EditMemory(String), + /// JVM 额外参数草稿 + EditJvm(String), + /// 保存内存 + JVM 参数(校验 + 落盘 + 回读) + Save, +} + +impl Message { + /// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::Java`)。 + #[must_use] + pub fn into_group(self) -> super::GroupMessage { + super::GroupMessage::Java(self) + } } diff --git a/crates/app-iced/src/pages/settings/java/fields.rs b/crates/app-iced/src/pages/settings/java/fields.rs new file mode 100644 index 0000000..66d9ad9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/fields.rs @@ -0,0 +1,246 @@ +//! 「Java」分组的**编辑区**(M175):手动路径 / 默认内存 / JVM 参数 / 保存与回执。 +//! +//! 从 `view.rs` 拆出来的(那个文件加了守卫之后贴到 438 行,越过 400 行硬线):这里全是 +//! 「用户能改的东西」,`view.rs` 只剩「扫到了什么」与版面。两半共用 `view::msg` 这一个消息口, +//! 所以「一条控件对应哪条消息」不会各写一遍。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::inputs::{text_field, Field, FieldVariant}; +use crate::pages::frame::text_role_owned; +use crate::pages::Message; +use crate::theme::Ui; + +use super::state::{Layer, Notice, Refusal, State, MAX_MEMORY_MB, MIN_MEMORY_MB}; +use super::view::msg; +use super::Message as JavaMessage; + +/// 手动添加一行:输入框 + 「添加」按钮 + 原因。 +pub(super) fn add_row<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> { + let field = Field::new(&state.add_draft) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::SettingsJavaAdd)) + .placeholder(catalog.text(Key::SettingsJavaAddPlaceholder)) + .supporting(catalog.text(Key::SettingsJavaAddHint)); + let add = if state.add_draft.trim().is_empty() || !state.storage { + buttons::outlined( + ui, + Button::disabled(catalog.text(Key::SettingsJavaAddAction)), + ) + } else { + buttons::outlined( + ui, + Button::new( + catalog.text(Key::SettingsJavaAddAction), + msg(JavaMessage::Add), + ), + ) + }; + row![ + container(text_field(ui, field, |value| msg(JavaMessage::AddDraft( + value + )))) + .width(Length::Fill), + add, + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 默认内存:数字框 + 范围 + 「生效值来自哪一层」。 +pub(super) fn memory_field<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &'a State, +) -> Element<'a, Message> { + let range = catalog.render( + Key::SettingsJavaMemoryHint, + &[ + ("min", MIN_MEMORY_MB.to_string().as_str()), + ("max", MAX_MEMORY_MB.to_string().as_str()), + ], + ); + let field = Field::new(&state.memory) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::SettingsJavaMemory)); + // 校验不过就把输入框本身标红(不只是下面那行字):用户一眼看到「是这个框有问题」。 + let field = match state.validate() { + Err(Refusal::MemoryEmpty | Refusal::MemoryNotNumber | Refusal::MemoryRange) => { + field.error() + } + _ => field, + }; + let effective = match state.layer() { + Layer::Global => catalog.render( + Key::SettingsJavaLayerGlobal, + &[("mb", state.saved_memory.to_string().as_str())], + ), + Layer::Instance { instance, mb } => catalog.render( + Key::SettingsJavaLayerInstance, + &[ + ("mb", mb.to_string().as_str()), + ("instance", instance.as_str()), + ], + ), + }; + column![ + text_field(ui, field, |value| msg(JavaMessage::EditMemory(value))), + text_role_owned( + ui, + TypeRole::BodySmall, + false, + effective, + ui.color(Role::OnSurfaceVariant), + ), + text_role_owned( + ui, + TypeRole::BodySmall, + false, + range, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// JVM 额外参数:文本框 + 「会拼到每次启动」的说明。 +pub(super) fn jvm_field<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &'a State, +) -> Element<'a, Message> { + let field = Field::new(&state.jvm) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::SettingsJavaJvm)) + .placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder)) + .supporting(catalog.text(Key::SettingsJavaJvmHint)); + let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) { + field.error() + } else { + field + }; + // 已保存值:空的时候说「还没有保存任何额外参数」,而不是让「已保存的值:」后面空着。 + let saved = if state.saved_jvm.trim().is_empty() { + catalog.text(Key::SettingsJavaJvmSavedNone).to_string() + } else { + catalog.render( + Key::SettingsJavaJvmSaved, + &[("value", state.saved_jvm.as_str())], + ) + }; + column![ + text_field(ui, field, |value| msg(JavaMessage::EditJvm(value))), + text_role_owned( + ui, + TypeRole::BodySmall, + false, + saved, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +pub(super) fn actions_row<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &'a State, +) -> Element<'a, Message> { + let label = catalog.text(Key::SettingsJavaSave); + if state.can_save() { + buttons::filled(ui, Button::new(label, msg(JavaMessage::Save))) + } else { + buttons::filled(ui, Button::disabled(label)) + } +} + +/// 按钮为什么灰着 / 下一步做什么——**永远说清**。 +pub(super) fn hint_line<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &'a State, +) -> Element<'a, Message> { + let (text, error) = if !state.storage { + (catalog.text(Key::SettingsJavaNoStorage).to_string(), true) + } else if state.dirty { + (catalog.text(Key::SettingsJavaUnsaved).to_string(), false) + } else { + (String::new(), false) + }; + text_role_owned( + ui, + TypeRole::BodySmall, + false, + text, + ui.color(if error { + Role::Error + } else { + Role::OnSurfaceVariant + }), + ) +} + +pub(super) fn notice_line<'a>( + ui: &'a Ui, + catalog: Catalog, + notice: &Notice, +) -> Element<'a, Message> { + let (text, error) = match notice { + Notice::Saved => (catalog.text(Key::SettingsJavaSaved).to_string(), false), + Notice::Refused(reason) => ( + catalog.render( + Key::SettingsJavaRefused, + &[("reason", catalog.text(refusal_key(*reason)))], + ), + true, + ), + Notice::Unwritable(reason) => ( + catalog.render( + Key::SettingsJavaRefused, + &[("reason", catalog.text(reason.label()))], + ), + true, + ), + Notice::WriteFailed => ( + catalog.render( + Key::SettingsJavaRefused, + &[("reason", catalog.text(Key::SettingsJavaReasonWriteFailed))], + ), + true, + ), + Notice::NoStorage => (catalog.text(Key::SettingsJavaNoStorage).to_string(), true), + Notice::DefaultSet(path) => ( + catalog.render(Key::SettingsJavaDefaultSet, &[("path", path.as_str())]), + false, + ), + Notice::Added(path) => ( + catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]), + false, + ), + }; + text_role_owned( + ui, + TypeRole::BodySmall, + false, + text, + ui.color(if error { Role::Error } else { Role::OnSurface }), + ) +} + +/// 拒绝原因 → 文案 key(`Refusal::label` 已是这张表,这里只是给 `match` 一个名字)。 +pub(super) fn refusal_key(refusal: Refusal) -> Key { + refusal.label() +} diff --git a/crates/app-iced/src/pages/settings/java/row.rs b/crates/app-iced/src/pages/settings/java/row.rs new file mode 100644 index 0000000..42f41eb --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/row.rs @@ -0,0 +1,191 @@ +//! 「Java」分组的一行运行时(M175):路径 / 版本 / 厂商 / 来源 + 上屏用的截断。 +//! +//! 从 `state.rs` 拆出来的(那个文件加了上下文与校验之后到 424 行,越过 400 行硬线): +//! 这里只放「一行长什么样」,状态与校验仍在 `state.rs`。 + +use unlucky_i18n::Key; + +/// 一个扫到的 / 手动加的运行时(页面要的那几格)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeRow { + /// `bin/java[.exe]` 的绝对路径(也是 `java.selected_path` 的取值)。 + pub binary: String, + /// `java -version` 的首行(探测失败时为空串)。 + pub version: String, + /// 主版本号(探测不出为 `None`)。 + pub major: Option, + /// 发行方(认不出为 `None`)。 + pub vendor: Option, + /// 来源 id(`managed` / `env` / `path` / `system` / `manual`)。 + pub source: &'static str, +} + +impl RuntimeRow { + /// 版本号:**能区分 `17.0.13` 与 `17.0.9`** 的那一位,而不是只给大版本(r1 复审 P2-4)。 + /// + /// 三层取法,逐层降级(不许因为解析不出来就只显示 `17`): + /// 1. `-version` 首行引号里的版本号(`openjdk version "21.0.11" …` → `21.0.11`); + /// 2. 无引号形态里的第一个版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`); + /// 3. 整行原样(原始版本串比一个大版本号有用); + /// 4. 连首行都没有(探测失败)→ 大版本;再没有 → `?`。 + #[must_use] + pub fn version_label(&self) -> String { + if let Some(quoted) = quoted_version(&self.version) { + return quoted; + } + if let Some(bare) = bare_version(&self.version) { + return bare; + } + let line = self.version.trim(); + if !line.is_empty() { + return line.to_string(); + } + self.major + .map_or_else(|| "?".to_string(), |major| major.to_string()) + } + + /// 版本一句话:`Java 21.0.11 · Microsoft`。 + #[must_use] + pub fn summary(&self) -> String { + let version = self.version_label(); + match &self.vendor { + Some(vendor) => format!("Java {version} · {vendor}"), + None => format!("Java {version}"), + } + } + + /// JDK 安装目录(`…/bin/java.exe` 的上一级再上一级)。 + /// + /// 上屏用它而不是整条可执行文件路径:424 宽的正文列放不下一条 `C:\Program Files\…\bin\java.exe`, + /// 而用户认的是 JDK 目录(也是他手动添加时会粘的那一段)。 + #[must_use] + pub fn home(&self) -> String { + let path = std::path::Path::new(self.binary.trim()); + let parent = path.parent(); + match parent.filter(|dir| { + dir.file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("bin")) + }) { + Some(bin) => bin + .parent() + .map_or_else(|| self.binary.clone(), |home| home.display().to_string()), + None => parent.map_or_else(|| self.binary.clone(), |dir| dir.display().to_string()), + } + } + + /// 上屏用的目录:过长时保留**尾部**两段,前面用 `…` 收掉。 + /// + /// 为什么必须截:无空格的 Windows 路径在 iced 的文本排版里不可断行,一条 60 字符的路径会直接 + /// 盖住右边的「设为默认」chip(M175 出图时实测到了这个重叠)。保尾部而不是开头,因为磁盘名 + /// (`C:`)谁都知道,而 `…\Java\temurin\17.0.13` 才是能区分两条 JDK 的那一段。 + #[must_use] + pub fn display_home(&self) -> String { + const LIMIT: usize = 39; + let home = self.home(); + if home.chars().count() <= LIMIT { + return home; + } + let tail: String = home + .chars() + .rev() + .take(LIMIT - 1) + .collect::>() + .into_iter() + .rev() + .collect(); + // 从第一个分隔符之后开始(别让 `…` 后面接着半截目录名)。 + let tail = tail + .find(['\\', '/']) + .map_or(tail.clone(), |at| tail[at + 1..].to_string()); + format!("…{}{}", std::path::MAIN_SEPARATOR, tail) + } + + /// 来源文案 key。 + #[must_use] + pub fn source_key(&self) -> Key { + match self.source { + "managed" => Key::SettingsJavaSourceManaged, + "env" => Key::SettingsJavaSourceEnv, + "path" => Key::SettingsJavaSourcePath, + "manual" => Key::SettingsJavaSourceManual, + _ => Key::SettingsJavaSourceSystem, + } + } +} + +/// 引号里的版本号(`openjdk version "21.0.11" 2025-10-21 LTS` → `21.0.11`)。 +fn quoted_version(line: &str) -> Option { + let start = line.find('"')? + 1; + let rest = &line[start..]; + let end = rest.find('"')?; + let value = rest[..end].trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +/// 无引号形态里的版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`)。 +fn bare_version(line: &str) -> Option { + line.split_whitespace().find_map(|token| { + let candidate = token.trim_matches([',', '(', ')', '"']); + let mut parts = candidate.split('.'); + let first = parts.next()?; + let looks_like_version = first.chars().all(|c| c.is_ascii_digit()) + && !first.is_empty() + && parts.next().is_some(); + looks_like_version.then(|| candidate.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::RuntimeRow; + + fn row(version: &str, major: Option) -> RuntimeRow { + RuntimeRow { + binary: "C:/jdk/bin/java.exe".to_string(), + version: version.to_string(), + major, + vendor: Some("Microsoft".to_string()), + source: "system", + } + } + + /// 版本号必须能区分同一大版本的两份 JDK(`17.0.13` vs `17.0.9`),不许只给 `17`。 + /// + /// 变异证明:把 `version_label` 换回「只用 `major`」,这条立刻红。 + #[test] + fn the_version_label_keeps_the_minor_digits() { + assert_eq!( + row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).version_label(), + "17.0.13" + ); + assert_eq!( + row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).version_label(), + "17.0.9" + ); + assert_ne!( + row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).summary(), + row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).summary(), + "两份同大版本 JDK 的摘要必须不同" + ); + assert_eq!( + row("openjdk 25.0.3 2026-04-21", Some(25)).version_label(), + "25.0.3", + "无引号形态也要认" + ); + assert_eq!( + row("something unexpected 21", Some(21)).version_label(), + "something unexpected 21", + "解析不出来时给原始版本串,不给光秃秃的大版本" + ); + assert_eq!(row("", Some(21)).version_label(), "21"); + assert_eq!(row("", None).version_label(), "?"); + } + + #[test] + fn the_summary_carries_version_and_vendor() { + assert_eq!( + row("openjdk version \"21.0.11\" 2025-10-21 LTS", Some(21)).summary(), + "Java 21.0.11 · Microsoft" + ); + } +} diff --git a/crates/app-iced/src/pages/settings/java/seed.rs b/crates/app-iced/src/pages/settings/java/seed.rs new file mode 100644 index 0000000..4f59203 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/seed.rs @@ -0,0 +1,75 @@ +//! 「Java」分组的截图种子(M175,`ULL_JAVA_STATE`):**只给 `--screenshot` 用**。 +//! +//! 三档对应任务书要的三张图:未安装 Java 的空态 / 检测到两个 JDK 并选中其一 / 内存与 JVM 参数 +//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出 +//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。 +//! +//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程, +//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现; +//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。 + +use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State}; + +/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用)。 +/// +/// 三档对应三张图:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 +/// JVM 参数、还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。 +#[must_use] +pub fn build(raw: &str) -> Option { + let mut state = State { + storage: true, + scanned: true, + ..State::default() + }; + match raw.trim() { + "empty" => { + state.memory = "2048".to_string(); + state.saved_memory = 2048; + } + "two" => { + state.runtimes = vec![ + RuntimeRow { + binary: "C:/Program Files/Eclipse Adoptium/jdk-17.0.13/bin/java.exe" + .to_string(), + version: "openjdk version \"17.0.13\" 2024-10-15".to_string(), + major: Some(17), + vendor: Some("Temurin".to_string()), + source: "system", + }, + RuntimeRow { + binary: "C:/Program Files/Microsoft/jdk-21.0.4/bin/java.exe".to_string(), + version: "openjdk version \"21.0.4\" 2024-07-16 LTS".to_string(), + major: Some(21), + vendor: Some("Microsoft".to_string()), + source: "system", + }, + ]; + state.selected = state.runtimes[1].binary.clone(); + state.manual = vec!["D:/jdk/zulu-21".to_string()]; + state.memory = "4096".to_string(); + state.saved_memory = 4096; + state.jvm = "-XX:+UseG1GC".to_string(); + state.saved_jvm = "-XX:+UseG1GC".to_string(); + state.context = Context { + required: Some(Requirement { + instance: "生存服".to_string(), + version: "1.20.1".to_string(), + minimum: 17, + maximum: None, + }), + layer: Some(Layer::Global), + effective_memory_mb: Some(4096), + }; + } + "editing" => { + let mut seeded = build("two")?; + seeded.memory = "128".to_string(); + seeded.jvm = "-XX:+UseG1GC -Xss\"512k".to_string(); + seeded.dirty = true; + seeded.notice = Some(Notice::Refused(Refusal::MemoryRange)); + return Some(seeded); + } + _ => return None, + } + Some(state) +} diff --git a/crates/app-iced/src/pages/settings/java/state.rs b/crates/app-iced/src/pages/settings/java/state.rs new file mode 100644 index 0000000..62e1bb9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/state.rs @@ -0,0 +1,374 @@ +//! 「Java」分组的运行期状态(M175):扫描结论 + 手动名单 + 两个可编辑项。 +//! +//! 状态住在 `pages::settings::Groups.java`([`State`]),由 `App` 持有。三条边界: +//! +//! | 东西 | 住哪 | 为什么 | +//! |---|---|---| +//! | 扫描结论 / 进度 | 本结构(由后台线程的快照 1 拍一拍灌进来) | 用户切走再回来不该重扫 | +//! | 手动加的路径名单 | 本结构 + 一个纯文本文件(`store::manual_file`) | 它是「我加过哪些」的记忆,不是启动策略 | +//! | `java.selected_path` / `default_memory_mb` / `extra_args` | **`config/launcher.json`** | 真值只有一份,读回也读它 | +//! +//! ## 为什么内存草稿要记两层(`memory` 文本 + `saved_memory` 数值) +//! +//! 输入框里的是**文本**(用户可能刚敲了半截「819」),而「现在生效多少」是**落盘的真值**。 +//! 两者混成一个字段就会重演「显示的就是生效的」这种假象:改到一半的数字不是生效值。 +//! 于是草稿只影响「有未保存改动」与「保存按钮能不能点」,生效值一律来自 `saved_*`(落盘后读回的)。 +//! +//! ## 版本门槛从哪来 +//! +//! [`Context::required`] 由 **app 层**(`app/update/settings/java.rs`)算好塞进来:它要读实例快照 +//! (`requirement_for(mc_version)`),而页面渲染器只拿得到 [`super::States`]。 +//! 页面自己不去读盘、也不复制版本表。 + +pub use super::row::RuntimeRow; + +use unlucky_i18n::Key; + +/// `java.default_memory_mb` 的下限(MB)。 +/// +/// **必须**等于 `core::setting::keys` 里那个键登记的下限(512):UI 放行一个 schema 会拒的值, +/// 用户点保存只会得到一条「schema 拒绝」的错。一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。 +pub const MIN_MEMORY_MB: u32 = 512; +/// `java.default_memory_mb` 的上限(MB)。 +pub const MAX_MEMORY_MB: u32 = 65536; +/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源, +/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。 +pub const DEFAULT_MEMORY_MB: u32 = 2048; + +/// 扫描进度(`None` = 没在扫)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Progress { + /// 已探测的候选数 + pub done: usize, + /// 候选总数 + pub total: usize, +} + +/// 当前实例对 Java 的版本要求(app 层算好塞进来)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Requirement { + /// 实例名(「为什么选它」那句话里要指名道姓) + pub instance: String, + /// 实例绑的版本 id(`1.20.1`) + pub version: String, + /// 最低主版本 + pub minimum: u32, + /// 最高主版本(老版本才有;`None` = 不封顶) + pub maximum: Option, +} + +impl Requirement { + /// 某个大版本满不满足。 + #[must_use] + pub fn satisfied_by(&self, major: u32) -> bool { + major >= self.minimum && self.maximum.is_none_or(|max| major <= max) + } +} + +/// 生效内存来自哪一层。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Layer { + /// 全局 `java.default_memory_mb` + Global, + /// 某个实例的 `launch.memoryMb` 覆盖了全局 + Instance { + /// 实例名 + instance: String, + /// 实例层的值 + mb: u32, + }, +} + +/// 上下文(app 层每次 hydrate 重算)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Context { + /// 当前实例的版本要求(没有实例 ⇒ `None`) + pub required: Option, + /// 当前生效内存来自哪一层 + pub layer: Option, + /// 当前生效内存(三级回落之后的数值) + pub effective_memory_mb: Option, +} + +/// 保存 / 操作的回执。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Notice { + /// 已落盘并读回 + Saved, + /// 本地校验拒绝(没写盘) + Refused(Refusal), + /// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key,不拼字符串) + Unwritable(Unwritable), + /// 写盘失败(schema / 磁盘):core 的 `Display` 是中文,不能直出给英文界面, + /// 所以这里只带一个页面自己的 key;原始句子进 stderr 日志。 + WriteFailed, + /// 没有可写文件(内存态运行) + NoStorage, + /// 默认 JDK 已写进 `java.selected_path` + DefaultSet(String), + /// 手动路径已加入名单 + Added(String), +} + +/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unwritable { + /// 没有任何脏键(我们以为改了、其实没有) + NotDirty, + /// 文件只读(被更新版本的启动器写过 / 被标记不可信) + ReadOnly, + /// 文件不可信(加载时就降级成只读) + Unreadable, +} + +impl Unwritable { + /// 文案 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::NotDirty => Key::SettingsJavaSkipNotDirty, + Self::ReadOnly => Key::SettingsJavaSkipReadOnly, + Self::Unreadable => Key::SettingsJavaSkipUnreadable, + } + } +} + +/// 本地校验的拒绝原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// 内存草稿为空 + MemoryEmpty, + /// 内存草稿不是整数 + MemoryNotNumber, + /// 内存越界 + MemoryRange, + /// JVM 参数里的引号没配成对 + JvmQuotes, + /// 手动路径为空 + AddEmpty, + /// 手动路径在盘上找不到 + AddMissing, + /// 有这个东西,但不是 java 可执行文件 + AddNotJava, + /// 跑不出 `-version` + AddNoVersion, + /// 路径太长(粘了一整段日志) + AddTooLong, + /// 名单文件写不进去(磁盘 / 权限) + AddWriteFailed, + /// 已经加过了 + AddDuplicate, +} + +impl Refusal { + /// 文案 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::MemoryEmpty => Key::SettingsJavaReasonMemoryEmpty, + Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber, + Self::MemoryRange => Key::SettingsJavaReasonMemoryRange, + Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes, + Self::AddEmpty => Key::SettingsJavaReasonAddEmpty, + Self::AddMissing => Key::SettingsJavaReasonAddMissing, + Self::AddNotJava => Key::SettingsJavaReasonAddNotJava, + Self::AddNoVersion => Key::SettingsJavaReasonAddNoVersion, + Self::AddTooLong => Key::SettingsJavaReasonAddTooLong, + Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed, + Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate, + } + } +} + +/// 「Java」分组的运行期状态。 +#[derive(Debug)] +pub struct State { + /// 扫到的运行时(顺序 = 大版本升序,探测不到主版本的排最后) + pub runtimes: Vec, + /// 至少完整扫过一次(区分「未检测到」与「还没扫」) + pub scanned: bool, + /// 扫描进度 + pub scanning: Option, + /// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫) + pub cancelled: bool, + /// 扫描状态槽(快照 + 取消位;线程与界面共享同一份) + pub scan: super::store::ScanSlot, + /// 用户手动加的路径(原样,持久化在 `manual_file`) + pub manual: Vec, + /// `java.selected_path` 的真值(空 = 没指定,启动时自动挑) + pub selected: String, + /// 内存草稿(MB 文本) + pub memory: String, + /// 落盘的内存真值(MB) + pub saved_memory: u32, + /// JVM 参数草稿 + pub jvm: String, + /// 落盘的 JVM 参数真值 + pub saved_jvm: String, + /// 手动路径输入框 + pub add_draft: String, + /// 有未保存的改动 + pub dirty: bool, + /// 这一次运行有没有可写的设置文件 + pub storage: bool, + /// 上一次操作的回执 + pub notice: Option, + /// 当前实例的上下文(版本门槛 / 生效内存层级) + pub context: Context, +} + +impl Default for State { + fn default() -> Self { + Self { + runtimes: Vec::new(), + scanned: false, + scanning: None, + cancelled: false, + scan: super::store::ScanSlot::default(), + manual: Vec::new(), + selected: String::new(), + memory: String::new(), + saved_memory: DEFAULT_MEMORY_MB, + jvm: String::new(), + saved_jvm: String::new(), + add_draft: String::new(), + dirty: false, + storage: false, + notice: None, + context: Context::default(), + } + } +} + +/// 从 `launcher.json` 读回来的三键真值。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Snapshot { + /// `java.selected_path` + pub selected: String, + /// `java.default_memory_mb` + pub memory_mb: u32, + /// `java.extra_args` + pub jvm: String, +} + +impl State { + /// 用真值刷新(**脏草稿不覆盖**,否则外部一改文件就冲掉用户正在输入的内容)。 + /// + /// 返回是否改了「生效值」那一半(调用方据此决定要不要重建视图模型)。 + pub fn hydrate(&mut self, snapshot: Snapshot) -> bool { + self.storage = true; + let Snapshot { + selected, + memory_mb, + jvm, + } = snapshot; + let changed = + self.selected != selected || self.saved_memory != memory_mb || self.saved_jvm != jvm; + self.selected = selected; + self.saved_memory = memory_mb; + if !self.dirty { + self.memory = memory_mb.to_string(); + self.jvm = jvm.clone(); + } + self.saved_jvm = jvm; + changed + } + + /// 灌一次扫描结论(后台线程的快照)。返回是否变了。 + pub fn apply_scan(&mut self, report: &super::store::ScanReport) -> bool { + let scanning = report.running.then_some(Progress { + done: report.done, + total: report.total, + }); + let changed = self.scanning != scanning + || self.cancelled != report.cancelled + || (report.finished && !self.scanned) + || (report.finished && self.runtimes != report.runtimes); + self.scanning = scanning; + self.cancelled = report.cancelled; + if report.finished { + self.scanned = true; + self.runtimes = report.runtimes.clone(); + } + changed + } + + /// 某个大版本够不够当前实例用。 + #[must_use] + pub fn satisfied(&self, major: Option) -> Option { + let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else { + return None; + }; + Some(required.satisfied_by(major)) + } + + /// 保存前的本地校验(过不了就不写盘)。 + /// + /// # Errors + /// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。 + pub fn validate(&self) -> Result { + let text = self.memory.trim(); + if text.is_empty() { + return Err(Refusal::MemoryEmpty); + } + let Ok(mb) = text.parse::() else { + return Err(Refusal::MemoryNotNumber); + }; + if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&mb) { + return Err(Refusal::MemoryRange); + } + if unbalanced_quotes(&self.jvm) { + return Err(Refusal::JvmQuotes); + } + Ok(mb) + } + + /// 界面上「现在生效多少」的那句话用哪一层(没有上下文时按全局)。 + #[must_use] + pub fn layer(&self) -> Layer { + self.context.layer.clone().unwrap_or(Layer::Global) + } + + /// 现在能不能点保存。 + #[must_use] + pub fn can_save(&self) -> bool { + self.storage && self.dirty + } + + /// 当前默认运行时那一行(`selected` 指向的)。 + #[must_use] + pub fn default_row(&self) -> Option<&RuntimeRow> { + self.runtimes + .iter() + .find(|row| same_path(&row.binary, &self.selected)) + } + + /// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用;三档的实现在 `java/seed.rs`)。 + /// + /// 三档:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 JVM 参数、 + /// 还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。 + #[must_use] + pub fn screenshot_seed(raw: &str) -> Option { + super::seed::build(raw) + } +} + +/// 两条路径是不是同一个(`/` 与 `\` 视为同一分隔符;Windows 折大小写)。 +#[must_use] +pub fn same_path(left: &str, right: &str) -> bool { + let key = |text: &str| { + let text = text.replace('\\', "/"); + if cfg!(windows) { + text.to_ascii_lowercase() + } else { + text + } + }; + !left.trim().is_empty() && key(left.trim()) == key(right.trim()) +} + +/// 引号(`"` 与 `'`)数量是不是奇数——奇数就意味着用户漏了一半。 +#[must_use] +pub fn unbalanced_quotes(text: &str) -> bool { + text.matches('"').count() % 2 == 1 || text.matches('\'').count() % 2 == 1 +} diff --git a/crates/app-iced/src/pages/settings/java/store.rs b/crates/app-iced/src/pages/settings/java/store.rs new file mode 100644 index 0000000..b5a795d --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/store.rs @@ -0,0 +1,393 @@ +//! 「Java」分组的真读写(M175):设置三键 + 手动名单 + 后台扫描。 +//! +//! ## 写盘走 `SettingStore`,与 `ai/store.rs` 同一条通路 +//! +//! `java.selected_path` / `java.default_memory_mb` / `java.extra_args` 都是 +//! `config/launcher.json` 里的 schema 键(`core::setting::keys` 已登记,本 mission 不动 schema)。 +//! 于是这里只做「读一份 → 改几个键 → 存 → **再从磁盘读回来**」:`save()` 有 `Skipped` 一档 +//! (只读 / 文件被更新版本写过 / 没有脏键),把它当成功就会出现「界面说已保存、重启回旧值」。 +//! 失败一律走 [`WriteError`]:**要么是页面自己的拒绝原因(i18n key),要么是 core 给的原始句子**, +//! 不再把 Rust 枚举名(`"MemoryRange"`)当人话往上抛(M175 r1 复审 P2-2)。 +//! +//! ## 扫描为什么是「进程级快照 + 后台线程」,不是 `Task::perform` +//! +//! 与 `fonts::install` 同一条理由(那里记了完整推导):本 crate 的执行器是 `thread-pool`, +//! 而扫描要的是**进度(3/7)+ 取消 + 不卡 UI**。快照方案的代价是一把短锁 + 1 拍一次比较, +//! 好处是窗口关掉之后不会往死掉的 channel 里写。界面的刷新节奏见 `app/update/clock.rs` +//! 与 `app/subscription.rs`(扫描期间按帧跑,扫描结束回到 1 Hz)。 +//! +//! ### 三条与「取消」有关的硬约束(M175 r1 复审 P2-5) +//! +//! 1. **每个候选都有超时**:卡住的 `java -version` 由 `core::game::java::manual` 的 +//! `run_with_timeout` kill 掉,扫描线程不会永远不结束; +//! 2. **取消打断的是「正在跑的那一个」**:取消位一路传进探测函数,不必等它自己结束; +//! 3. **同时只允许一个扫描线程**:[`ScanReport::running`] 的含义就是「线程还活着」, +//! 取消**不**翻这个位(只置 `cancelled`),于是取消到线程真退出之间点「立即扫描」 +//! 不会起第二个线程——界面上那一小段显示的是「正在取消…」。 +//! +//! ## 手动名单为什么是纯文本文件 +//! +//! 它记的是「我手动加过哪些路径」,不是启动策略(真正决定用哪个 Java 的是 `java.selected_path`)。 +//! 放进 `launcher.json` 要新登记一个 schema 键并推 minor(会让旧版本整份文件变只读), +//! 代价远大于收益;而 `core::game::java::manual` 的读写是纯文本、一行一个,坏了顶多丢名单。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use unlucky_core::game::java::{discover, manual, staged, JavaRuntime}; +use unlucky_core::platform::paths::{Platform, ProcessEnv}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::{SaveOutcome, SettingStore}; + +use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable}; + +/// 扫描结果快照(后台线程写、界面读)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScanReport { + /// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」) + pub running: bool, + /// 已探测 + pub done: usize, + /// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`) + pub total: usize, + /// 至少完整扫完过一次 + pub finished: bool, + /// 用户按过取消(线程还活着时是 `true`) + pub cancelled: bool, + /// 扫描结论(`finished` 且没被取消时有效) + pub runtimes: Vec, +} + +/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。 +pub type Job = + fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec; + +/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。 +/// +/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处—— +/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例 +/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。 +/// +/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。 +#[derive(Debug, Clone, Default)] +pub struct ScanSlot { + inner: std::sync::Arc, +} + +#[derive(Debug, Default)] +struct ScanInner { + report: Mutex, + cancel: AtomicBool, +} + +impl ScanSlot { + /// 当前快照(界面每拍读一次;锁只在这几行里持有)。 + #[must_use] + pub fn report(&self) -> ScanReport { + self.inner + .report + .lock() + .map(|report| report.clone()) + .unwrap_or_default() + } + + /// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。 + /// + /// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的 + /// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。 + pub fn cancel(&self) { + self.inner.cancel.store(true, Ordering::SeqCst); + if let Ok(mut report) = self.inner.report.lock() { + if report.running { + report.cancelled = true; + } + } + } + + /// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。 + pub fn start(&self, roots: Vec, manual: Vec, system: bool) -> bool { + self.start_with(roots, manual, system, run_staged) + } + + /// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。 + pub fn start_with( + &self, + roots: Vec, + manual: Vec, + system: bool, + job: Job, + ) -> bool { + { + let Ok(mut report) = self.inner.report.lock() else { + return false; + }; + if report.running { + return false; + } + // 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。 + *report = ScanReport { + running: true, + ..ScanReport::default() + }; + self.inner.cancel.store(false, Ordering::SeqCst); + } + + let slot = self.clone(); + std::thread::spawn(move || { + // 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段, + // 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。 + let candidates = staged::candidates(&roots, &manual, system); + if let Ok(mut report) = slot.inner.report.lock() { + report.total = candidates.len(); + } + let cancelled = || slot.inner.cancel.load(Ordering::SeqCst); + let mut progress = |done: usize, _total: usize| { + if let Ok(mut report) = slot.inner.report.lock() { + report.done = done; + } + }; + let runtimes = job(&candidates, &cancelled, &mut progress); + if let Ok(mut report) = slot.inner.report.lock() { + report.running = false; + report.done = candidates.len(); + if !report.cancelled { + report.finished = true; + report.runtimes = runtimes; + } + } + }); + true + } +} + +/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。 +fn run_staged( + candidates: &[staged::Candidate], + cancelled: &dyn Fn() -> bool, + progress: &mut dyn FnMut(usize, usize), +) -> Vec { + let found = staged::scan( + candidates, + |done, total| { + progress(done, total); + true + }, + cancelled, + ); + rows(&found, candidates) +} + +/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。 +fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec { + found + .iter() + .map(|runtime| RuntimeRow { + binary: runtime.binary.display().to_string(), + version: runtime.version.clone().unwrap_or_default(), + major: runtime.major, + vendor: runtime.vendor.clone(), + source: candidates + .iter() + .find(|candidate| candidate.binary == runtime.binary) + .map_or("system", |candidate| candidate.source.id()), + }) + .collect() +} + +/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。 +/// +/// 四条都要成立:①不是截图通道(D30:同一份代码出同一张图,截图不许读本机装了什么); +/// ②没有在扫;③**没被取消过**(一次取消 = 本进程不再自动扫:被取消的扫描 `finished` 仍是 +/// `false`,而 `launcher.json` 每改一次(存内存、改主题、改语言、存 AI 配置)心跳就会走一次 +/// `hydrate_java_settings`,不看这一条就会把用户的取消无声撤销);④还没扫完过(一次就够—— +/// 用户手动点「立即扫描」走的是 `start`,不看这里)。 +/// +/// 单独抽出来是因为它是「用户实测的头一条抱怨」(设置页从来自动检测本机 Java)的开关: +/// 判据写在这里,`tests/cmdline.rs` 逐档断言,改坏立刻红。 +#[must_use] +pub fn auto_scan_due(report: &ScanReport, screenshot: bool) -> bool { + !screenshot && !report.running && !report.cancelled && !report.finished +} + +/// 托管安装根(`/data/java-managed` + `java.installRoot` + 标准落点)。 +/// +/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式 +/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。 +#[must_use] +pub fn managed_roots(config: &Path) -> Vec { + let Some(home) = config.parent().and_then(Path::parent) else { + return Vec::new(); + }; + let layout = unlucky_core::platform::layout::LauncherLayout::new(home); + let override_root = SettingStore::load(config, launcher_schema()) + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from); + unlucky_core::game::java::location::scan_roots( + unlucky_core::platform::data_dir::DataMode::Portable, + &layout, + override_root.as_deref(), + Platform::current(), + &ProcessEnv, + ) +} + +/// 手动名单文件(与 `launcher.json` 同一个 `config/`)。 +#[must_use] +pub fn manual_file(config: &Path) -> PathBuf { + config.parent().map_or_else( + || PathBuf::from("java-extra-paths.txt"), + |dir| dir.join("java-extra-paths.txt"), + ) +} + +/// 读三键真值。 +#[must_use] +pub fn load(config: &Path) -> Snapshot { + snapshot(SettingStore::load(config, launcher_schema()).doc()) +} + +/// 写盘失败的三种来源(都是「没写进去」,但对用户要说的不是同一句话)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteError { + /// 页面自己的校验拒绝(i18n key) + Refused(Refusal), + /// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key) + Unwritable(Unwritable), + /// schema / 磁盘给的原始句子(core 的文案,不属于本页) + Schema(String), +} + +/// 保存内存 + JVM 参数,返回**落盘后读回来**的真值。 +/// +/// # Errors +/// 本地校验不过([`WriteError::Refused`])、文件只读/不可信([`WriteError::Unwritable`]) +/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。 +pub fn save(config: &Path, state: &State) -> Result { + let mb = state.validate().map_err(WriteError::Refused)?; + let mut store = SettingStore::load(config, launcher_schema()); + store + .set("java.default_memory_mb", i64::from(mb).into()) + .and_then(|()| store.set("java.extra_args", state.jvm.trim().to_string().into())) + .map_err(|error| WriteError::Schema(format!("{error}")))?; + finish(store, config) +} + +/// 写 `java.selected_path`(选定默认 JDK),返回落盘后读回来的真值。 +/// +/// # Errors +/// 文件只读/不可信或 schema 拒绝时返回原因。 +pub fn set_selected(config: &Path, binary: &str) -> Result { + let mut store = SettingStore::load(config, launcher_schema()); + store + .set("java.selected_path", binary.to_string().into()) + .map_err(|error| WriteError::Schema(format!("{error}")))?; + finish(store, config) +} + +/// 落盘 + 判定 + 读回(`save` / `set_selected` 共用:只认 `Saved` 一档)。 +fn finish(mut store: SettingStore, config: &Path) -> Result { + match store.save() { + Ok(SaveOutcome::Saved(_)) => Ok(load(config)), + Ok(SaveOutcome::Skipped(reason)) => Err(WriteError::Unwritable(unwritable(reason))), + Err(error) => Err(WriteError::Schema(format!("{error}"))), + } +} + +/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。 +fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable { + use unlucky_core::setting::store::SkipReason; + match reason { + SkipReason::NotDirty => Unwritable::NotDirty, + SkipReason::ReadOnly => Unwritable::ReadOnly, + SkipReason::Unreadable => Unwritable::Unreadable, + } +} + +/// 文档 → 快照。 +fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot { + Snapshot { + selected: doc + .effective_str("java.selected_path") + .unwrap_or_default() + .trim() + .to_string(), + memory_mb: doc + .effective_i64("java.default_memory_mb") + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(super::state::DEFAULT_MEMORY_MB), + jvm: doc + .effective_str("java.extra_args") + .unwrap_or_default() + .to_string(), + } +} + +/// 读手动名单。 +#[must_use] +pub fn load_manual(config: &Path) -> Vec { + manual::load_paths(&manual_file(config)) + .iter() + .map(|path| path.display().to_string()) + .collect() +} + +/// 手动加一个路径:严格校验(真跑 `java -version`)→ 进名单 → 落盘。 +/// +/// 拒绝时**什么都不写**(名单不变、设置不变)——这是「非法路径不污染设置」那条守卫的落点。 +/// +/// # Errors +/// 校验不过(见 [`Refusal`])或名单写盘失败时返回原因。 +pub fn add_manual(config: &Path, input: &str) -> Result<(String, RuntimeRow), Refusal> { + add_manual_with(config, input, &manual::probe_manual) +} + +/// 同 [`add_manual`],但探测函数可注入(测试用:不依赖本机装没装 JDK)。 +/// +/// # Errors +/// 探测被拒或名单写盘失败时返回原因。 +pub fn add_manual_with( + config: &Path, + input: &str, + probe: &dyn Fn(&str) -> Result, +) -> Result<(String, RuntimeRow), Refusal> { + let runtime = probe(input.trim()).map_err(failure_refusal)?; + let binary = runtime.binary.display().to_string(); + let mut paths = manual::load_paths(&manual_file(config)); + let key = discover::path_key(&runtime.binary); + if paths.iter().any(|known| discover::path_key(known) == key) { + return Err(Refusal::AddDuplicate); + } + paths.push(runtime.binary.clone()); + manual::save_paths(&manual_file(config), &paths).map_err(|_| Refusal::AddWriteFailed)?; + Ok(( + binary.clone(), + RuntimeRow { + binary, + version: runtime.version.clone().unwrap_or_default(), + major: runtime.major, + vendor: runtime.vendor.clone(), + source: "manual", + }, + )) +} + +/// `core` 的拒绝原因 → 页面原因。 +fn failure_refusal(failure: manual::ManualFailure) -> Refusal { + match failure { + manual::ManualFailure::Empty => Refusal::AddEmpty, + manual::ManualFailure::TooLong => Refusal::AddTooLong, + manual::ManualFailure::Missing => Refusal::AddMissing, + manual::ManualFailure::NotJava => Refusal::AddNotJava, + manual::ManualFailure::NoVersion => Refusal::AddNoVersion, + } +} + +/// 设置文件路径(数据根解析不出来时没有)。 +#[must_use] +pub fn config_of(bridge: &unlucky_presenter::settings_bridge::SettingsBridge) -> Option { + bridge.path().map(Path::to_path_buf) +} diff --git a/crates/app-iced/src/pages/settings/java/tests.rs b/crates/app-iced/src/pages/settings/java/tests.rs new file mode 100644 index 0000000..5df6fe9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests.rs @@ -0,0 +1,242 @@ +//! 「Java」分组的真数据源断言(M175):扫描 / 手动路径 / 内存 / JVM 参数 / 真命令行。 +//! +//! 每一条都**能变红**(报告的「变异证明」逐条给了改哪一行会红)。四条守卫: +//! +//! | 守卫 | 断言 | 改回什么会红 | +//! |---|---|---| +//! | 列表来自真扫盘 | [`the_runtime_list_comes_from_the_disk`] | 把行换成写死的字符串 / 不读候选表 | +//! | 非法路径不污染设置 | [`an_invalid_manual_path_is_refused_and_writes_nothing`] | 删掉 `manual::probe_manual` 的校验 | +//! | 内存越界被拒 | [`the_memory_bounds_are_enforced_before_writing`] | 删掉范围判断 / 允许非数字 | +//! | 内存真进命令行 | [`the_saved_memory_reaches_the_real_command_line`] | 把 `-Xmx` 的来源改回常量 | +//! +//! 前三条不依赖本机装没装 JDK(假的 `bin/java` 空文件就够:它扫得到、但答不出版本), +//! 第四条(`tests/cmdline.rs`)走 core 的**真组装链**(`launch::args_table::assemble`)—— +//! 不是读配置回显。落盘 / 读回 / 版本门槛 / 截图种子也在那个文件里(同一族「真值」断言)。 + +#[path = "tests/cmdline.rs"] +mod cmdline; + +use std::path::{Path, PathBuf}; + +use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB}; +use super::store; + +/// 每个用例一个临时数据根(`/config/launcher.json` 的形状与真实数据根一致)。 +pub(super) fn root(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-page-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("config")).expect("config"); + dir +} + +pub(super) fn config(dir: &Path) -> PathBuf { + dir.join("config").join("launcher.json") +} + +/// 造一个「看起来像 JDK」的目录:`bin/java` 是空文件(扫得到、但 `-version` 答不出)。 +pub(super) fn fake_jdk(dir: &Path, name: &str) -> PathBuf { + let jdk = dir.join(name); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + std::fs::write(jdk.join("bin").join(java_bin()), b"").expect("写 java"); + jdk +} + +pub(super) fn java_bin() -> &'static str { + if cfg!(windows) { + "java.exe" + } else { + "java" + } +} + +// --------------------------------------------------------------- 守卫①:列表来自真扫盘 + +/// 扫描列表必须来自**盘上真有的目录**:造两个 → 两项;删一个 → 一项。 +/// +/// 变异证明:把 `store::start` 里的候选表换成一个写死的两行常量,删目录之后仍是两项 ⇒ 红。 +#[test] +fn the_runtime_list_comes_from_the_disk() { + let dir = root("scan"); + let one = fake_jdk(&dir, "jdk-a"); + let two = fake_jdk(&dir, "jdk-b"); + + let found = + unlucky_core::game::java::staged::scan_roots(&[], &[one.clone(), two.clone()], false); + assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}"); + + std::fs::remove_dir_all(&one).expect("删 jdk-a"); + let after = unlucky_core::game::java::staged::scan_roots(&[], &[one, two], false); + assert_eq!(after.len(), 1, "删掉一个之后必须少一项:{after:?}"); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// 扫描结论灌进页面状态之后,**行数与路径**都来自那次扫描(不是常量)。 +#[test] +fn applying_a_scan_report_replaces_every_row() { + let mut state = State::default(); + let report = store::ScanReport { + running: false, + done: 2, + total: 2, + finished: true, + cancelled: false, + runtimes: vec![ + RuntimeRow { + binary: "C:/jdk-a/bin/java.exe".to_string(), + version: String::new(), + major: None, + vendor: None, + source: "manual", + }, + RuntimeRow { + binary: "C:/jdk-b/bin/java.exe".to_string(), + version: String::new(), + major: Some(21), + vendor: Some("Temurin".to_string()), + source: "system", + }, + ], + }; + assert!(state.apply_scan(&report), "第一次灌结论必须报告「变了」"); + assert_eq!(state.runtimes.len(), 2); + assert!(state.scanned, "扫完之后「未检测到」与「还没扫」必须能分开"); + assert_eq!(state.runtimes[1].summary(), "Java 21 · Temurin"); +} + +// --------------------------------------------------------------- 守卫②:非法路径不污染设置 + +/// 非法路径被拒、**设置与名单一个字节都不写**。 +/// +/// 变异证明:删掉 `store::add_manual` 里的 `manual::probe_manual`(改成只看 `is_dir()`), +/// 「不存在的路径」就会被加进名单 ⇒ 红。 +#[test] +fn an_invalid_manual_path_is_refused_and_writes_nothing() { + let dir = root("manual"); + let file = config(&dir); + + assert_eq!( + store::add_manual(&file, "").err(), + Some(Refusal::AddEmpty), + "空输入要被拒" + ); + assert_eq!( + store::add_manual(&file, "Z:/definitely/not/here").err(), + Some(Refusal::AddMissing), + "不存在的路径要被拒" + ); + let empty = dir.join("empty-jdk"); + std::fs::create_dir_all(&empty).expect("空目录"); + assert_eq!( + store::add_manual(&file, &empty.display().to_string()).err(), + Some(Refusal::AddNotJava), + "目录里没有 bin/java 要被拒" + ); + let broken = fake_jdk(&dir, "broken-jdk"); + assert_eq!( + store::add_manual(&file, &broken.display().to_string()).err(), + Some(Refusal::AddNoVersion), + "跑不出 java -version 要被拒" + ); + + assert!( + store::load_manual(&file).is_empty(), + "被拒的路径不许进名单:{:?}", + store::load_manual(&file) + ); + let snapshot = store::load(&file); + assert!( + snapshot.selected.is_empty(), + "被拒的路径不许污染 java.selected_path:{snapshot:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------- 守卫③:内存越界被拒 + +/// 内存必须落在 schema 登记的区间里,非数字也要拒,而且**不写盘**。 +/// +/// 变异证明:删掉 `State::validate` 里的范围判断(只留 `parse`),128 与 999999 会落盘 ⇒ 红; +/// 把 `parse::` 换成 `unwrap_or(2048)`,`"abc"` 不再报错 ⇒ 红。 +#[test] +fn the_memory_bounds_are_enforced_before_writing() { + let mut state = State { + storage: true, + ..State::default() + }; + + state.memory = (MIN_MEMORY_MB - 1).to_string(); + assert_eq!( + state.validate().err(), + Some(Refusal::MemoryRange), + "低于下限" + ); + state.memory = (MAX_MEMORY_MB + 1).to_string(); + assert_eq!( + state.validate().err(), + Some(Refusal::MemoryRange), + "高于上限" + ); + state.memory = "abc".to_string(); + assert_eq!( + state.validate().err(), + Some(Refusal::MemoryNotNumber), + "非数字" + ); + state.memory = " ".to_string(); + assert_eq!(state.validate().err(), Some(Refusal::MemoryEmpty), "空"); + state.memory = "4096".to_string(); + assert!(state.validate().is_ok()); + state.jvm = "-XX:+UseG1GC".to_string(); + assert!(state.validate().is_ok()); + state.jvm = "-Xss\"512k".to_string(); + assert_eq!( + state.validate().err(), + Some(Refusal::JvmQuotes), + "引号没配对" + ); + + // 真的不写盘:一次越界保存之后,文件里还是出厂默认值。 + let dir = root("memory"); + let file = config(&dir); + let state = State { + storage: true, + memory: "128".to_string(), + ..State::default() + }; + assert!(store::save(&file, &state).is_err(), "越界必须写不进去"); + assert_eq!( + store::load(&file).memory_mb, + super::state::DEFAULT_MEMORY_MB, + "被拒的保存不许落盘" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 两个数字常量必须与 schema 登记的一致(否则 UI 放行的值会被 schema 拒)。 +/// +/// 变异证明:把 `MIN_MEMORY_MB` 改成 256(core 的 `launch_overrides` 那一档),这条立刻红。 +#[test] +fn the_memory_bounds_mirror_the_schema() { + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::spec::ValueKind; + let schema = launcher_schema(); + let kind = schema + .spec("java.default_memory_mb") + .map(|spec| &spec.kind) + .expect("schema 里必须有 java.default_memory_mb"); + let ValueKind::Int { min, max } = kind else { + panic!("java.default_memory_mb 应该是 Int:{kind:?}"); + }; + assert_eq!(*min, i64::from(MIN_MEMORY_MB)); + assert_eq!(*max, i64::from(MAX_MEMORY_MB)); +} +#[path = "tests/runtime.rs"] +mod runtime; diff --git a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs new file mode 100644 index 0000000..882bcc2 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs @@ -0,0 +1,262 @@ +//! `tests.rs` 的后半:真命令行 / 落盘读回 / 版本门槛 / 截图种子。 +//! +//! 拆出来的理由:`tests.rs` 加上守卫之后 434 行,越过 400 行硬线;这一半是「真值走完整条生产链」 +//! 的一族(`launcher.json` → 组装链 / 重启后读回),与前半的「输入校验」不是一件事。 +//! 公共的临时目录与假 JDK 构造在父模块(`super::root` / `super::fake_jdk`)。 + +use std::path::Path; + +use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State}; +use super::super::store; +use super::{config, fake_jdk, root}; + +// --------------------------------------------------------------- 守卫④:内存真进命令行 + +/// 写进 `java.default_memory_mb` 的值必须出现在**真组装出来的 JVM 参数**里。 +/// +/// 走的是生产代码那条链:`launcher.json` → `GlobalLaunchDefaults` → `launch_overrides::resolve` +/// → `launch::overrides::apply` → `launch::args_table::assemble`(`ull launch --dry-run` 用的是同一条)。 +/// 不是读配置回显:断言的是 `-Xmx<新值>m` 这个 argv 元素。 +/// +/// 变异证明:把 `store::save` 里写 `java.default_memory_mb` 的那一行删掉(或写死 2048), +/// 这条立刻红(argv 里还是 `-Xmx2048m`)。 +#[test] +fn the_saved_memory_reaches_the_real_command_line() { + let dir = root("cmdline"); + let file = config(&dir); + let state = State { + storage: true, + memory: "6144".to_string(), + jvm: "-Dfile.encoding=UTF-8".to_string(), + ..State::default() + }; + assert!(state.validate().is_ok()); + let saved = store::save(&file, &state).expect("保存应成功"); + assert_eq!(saved.memory_mb, 6144, "落盘后读回的值"); + assert_eq!(saved.jvm, "-Dfile.encoding=UTF-8"); + + let args = real_argv(&file); + assert!( + args.iter().any(|arg| arg == "-Xmx6144m"), + "内存字段必须长成 -Xmx6144m:{args:?}" + ); + assert!( + args.iter().any(|arg| arg == "-Dfile.encoding=UTF-8"), + "java.extra_args 必须进命令行:{args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "-Xmx2048m"), + "出厂默认值不许还在里面:{args:?}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 用生产链组装一次命令行(`launch.json` 之外什么都不读)。 +fn real_argv(config: &Path) -> Vec { + use std::path::PathBuf; + + use unlucky_core::game::launch_overrides::{resolve, GlobalLaunchDefaults}; + use unlucky_core::launch::args_table::assemble; + use unlucky_core::launch::overrides; + use unlucky_core::launch::platform::{OsFamily, PlatformCtx}; + use unlucky_core::launch::request::{ + InstanceInfo, JavaSelection, LaunchDirs, ManifestArgs, MemoryOptions, + }; + use unlucky_core::launch::request::{ + LaunchRequest, LauncherInfo, Log4jOptions, OptimizationOptions, + }; + + let snapshot = store::load(config); + let resolved = resolve( + None, + &GlobalLaunchDefaults { + jvm_args: Some(snapshot.jvm.clone()), + memory_mb: Some(snapshot.memory_mb), + ..GlobalLaunchDefaults::default() + }, + ); + let mut request = LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: "1.20.1".to_string(), + version: unlucky_core::launch::game_version::GameVersion::parse("1.20.1"), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.2.3".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 21, 64), + memory: MemoryOptions { + min_mb: None, + max_mb: None, + }, + manifest: ManifestArgs { + main_class: Some("net.minecraft.client.main.Main".to_string()), + ..ManifestArgs::default() + }, + dirs: LaunchDirs { + game_dir: PathBuf::from("C:/mc"), + primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"), + ..LaunchDirs::default() + }, + classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], + log4j: Log4jOptions { + enabled: false, + ..Log4jOptions::default() + }, + optimization: OptimizationOptions { + enabled: false, + physical_memory_mb: None, + }, + language: "zh-CN".to_string(), + ..LaunchRequest::default() + }; + overrides::apply(&mut request, &resolved); + assemble(&request, &PlatformCtx::new(OsFamily::Windows, "x86_64")) + .expect("组装应成功") + .args +} + +// --------------------------------------------------------------- 落盘 / 读回 / 门槛 + +/// 三键都能落盘、能在「重启后」读回来(新的 `State` + `hydrate` 就是重启的等价物)。 +#[test] +fn the_settings_survive_a_restart() { + let dir = root("restart"); + let file = config(&dir); + let state = State { + storage: true, + memory: "8192".to_string(), + jvm: "-XX:+UseG1GC".to_string(), + ..State::default() + }; + store::save(&file, &state).expect("保存"); + let selected = store::set_selected(&file, "C:/jdk-17/bin/java.exe").expect("选默认"); + + // 「重启」:全新的 State 从盘上灌一次。 + let mut reborn = State::default(); + assert!(reborn.hydrate(store::load(&file))); + assert_eq!(reborn.memory, "8192"); + assert_eq!(reborn.saved_memory, 8192); + assert_eq!(reborn.jvm, "-XX:+UseG1GC"); + assert_eq!(reborn.selected, "C:/jdk-17/bin/java.exe"); + assert_eq!(selected.selected, "C:/jdk-17/bin/java.exe"); + + // 手动名单也在(与设置同一个 config/ 目录下的纯文本)。 + let jdk = fake_jdk(&dir, "keep-me"); + std::fs::write(store::manual_file(&file), format!("{}\n", jdk.display())).expect("写名单"); + assert_eq!(store::load_manual(&file).len(), 1); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 「为什么选它」那一行:版本门槛来自实例,选中的运行时够不够要能算出来。 +#[test] +fn the_requirement_explains_why_a_runtime_fits_or_not() { + let mut state = State { + scanned: true, + runtimes: vec![ + RuntimeRow { + binary: "C:/jdk-8/bin/java.exe".to_string(), + version: String::new(), + major: Some(8), + vendor: None, + source: "system", + }, + RuntimeRow { + binary: "C:/jdk-21/bin/java.exe".to_string(), + version: String::new(), + major: Some(21), + vendor: None, + source: "system", + }, + ], + selected: "C:/jdk-8/bin/java.exe".to_string(), + ..State::default() + }; + assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论"); + + state.context.required = Some(Requirement { + instance: "生存服".to_string(), + version: "1.20.1".to_string(), + minimum: 17, + maximum: None, + }); + assert_eq!(state.satisfied(Some(8)), Some(false)); + assert_eq!(state.satisfied(Some(21)), Some(true)); + assert_eq!( + state.default_row().map(|row| row.binary.clone()), + Some("C:/jdk-8/bin/java.exe".to_string()), + "默认那一行按 selected 找出来" + ); + + state.context.layer = Some(Layer::Instance { + instance: "生存服".to_string(), + mb: 8192, + }); + assert!(matches!(state.layer(), Layer::Instance { mb: 8192, .. })); +} + +/// 截图种子:三档都得给得出来(`--screenshot` 的取证开关)。 +#[test] +fn the_screenshot_seeds_cover_the_three_states() { + let empty = State::screenshot_seed("empty").expect("empty 档"); + assert!(empty.runtimes.is_empty() && empty.scanned); + + let two = State::screenshot_seed("two").expect("two 档"); + assert_eq!(two.runtimes.len(), 2); + assert!(two.default_row().is_some(), "两个 JDK 里要选中其一"); + + let editing = State::screenshot_seed("editing").expect("editing 档"); + assert!(editing.dirty, "编辑态要有未保存改动"); + assert!(matches!(editing.notice, Some(Notice::Refused(_)))); + assert!(State::screenshot_seed("nonsense").is_none()); +} + +/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。 +/// +/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描); +/// 改成恒 `false`,第一档红(用户实测的头一条抱怨就是「设置页从来自动检测」)。 +#[test] +fn the_auto_scan_predicate_runs_once_and_never_in_screenshots() { + let fresh = store::ScanReport::default(); + assert!( + store::auto_scan_due(&fresh, false), + "首屏(没扫过、没在扫)应该自动扫一次" + ); + assert!( + !store::auto_scan_due(&fresh, true), + "截图通道不许读本机装了什么" + ); + + let running = store::ScanReport { + running: true, + ..store::ScanReport::default() + }; + assert!(!store::auto_scan_due(&running, false), "正在扫就不重入"); + + let finished = store::ScanReport { + finished: true, + ..store::ScanReport::default() + }; + assert!( + !store::auto_scan_due(&finished, false), + "扫过一次就不再自动扫(手动「立即扫描」仍可再扫)" + ); + + // 被取消过的扫描:`finished` 仍是 false(结论作废),但**不许**自动重来——否则 + // `launcher.json` 每变一次,心跳就会把用户刚做的取消无声撤销(r2 复审 P2-2)。 + let cancelled = store::ScanReport { + cancelled: true, + ..store::ScanReport::default() + }; + assert!( + !store::auto_scan_due(&cancelled, false), + "取消过一次之后,本进程不再自动扫描" + ); + // 手动「立即扫描」走的是 `ScanSlot::start`,不看这个判据 ⇒ 取消不是永久锁死。 + assert!( + store::auto_scan_due(&store::ScanReport::default(), false), + "全新进程(没扫过、没取消)仍然自动扫一次" + ); +} diff --git a/crates/app-iced/src/pages/settings/java/tests/runtime.rs b/crates/app-iced/src/pages/settings/java/tests/runtime.rs new file mode 100644 index 0000000..c74294f --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/runtime.rs @@ -0,0 +1,318 @@ +//! `tests.rs` 的第三块(M175 r2):手动名单的落盘 / 扫描槽的并发与取消 / 错误文案的 i18n。 +//! +//! 守卫①~④(列表来自真扫盘、非法路径被拒、内存越界、内存真进命令行)在 `tests.rs` 与 +//! `tests/cmdline.rs`;这一块是 r1 复审补的三条(P2-3 / P2-5 / P2-2)。 + +use super::super::state::{Refusal, RuntimeRow, State, Unwritable}; +use super::super::store; +use super::{config, fake_jdk, java_bin, root}; + +// --------------------------------------------------------------- 守卫⑤:手动名单真落盘(P2-3) + +/// 手动加一条合法路径 → **从盘上新读一份** → 名单里有它;「重启」之后仍在。 +/// +/// 为什么用注入的探测函数:`probe_manual` 要真跑 `java -version`,用例不能依赖本机装没装 JDK; +/// 但**落盘那一段走的是生产代码**(`manual::save_paths` + `manual::load_paths`),所以这条 +/// 是「持久化」的正面断言,而不是「内存里的 vec 有它」。 +/// +/// 变异证明:删掉 `store::add_manual_with` 里的 `manual::save_paths(...)` 那一行 ⇒ 这条红 +/// (`load_manual` 从盘上读回 0 条);把去重那段删掉 ⇒ 末尾那条 `AddDuplicate` 断言红。 +#[test] +fn a_manual_path_is_written_to_disk_and_survives_a_restart() { + use unlucky_core::game::java::{JavaOrigin, JavaRuntime}; + + let dir = root("manual-ok"); + let file = config(&dir); + let jdk = fake_jdk(&dir, "manual-jdk"); + let binary = jdk.join("bin").join(java_bin()); + let runtime = JavaRuntime { + binary: binary.clone(), + origin: JavaOrigin::System, + version: Some("openjdk version \"21.0.4\" 2024-07-16 LTS".to_string()), + major: Some(21), + bits: 64, + vendor: Some("Temurin".to_string()), + }; + let probe = |_input: &str| Ok(runtime.clone()); + + // 刚加之前:盘上什么都没有。 + assert!(store::load_manual(&file).is_empty()); + + let (added, row) = store::add_manual_with(&file, &jdk.display().to_string(), &probe) + .expect("合法路径必须加得进去"); + assert_eq!(added, binary.display().to_string()); + assert_eq!(row.major, Some(21)); + + // **从盘上新读一份**(不是看内存里的 vec)。 + let listed = store::load_manual(&file); + assert_eq!(listed.len(), 1, "名单必须真的写到了盘上:{listed:?}"); + assert_eq!(listed[0], binary.display().to_string()); + assert!( + store::manual_file(&file).is_file(), + "名单文件必须真的存在:{:?}", + store::manual_file(&file) + ); + + // 「重启」:一个全新的 State 从盘上灌,名单还在。 + let reborn = State { + manual: store::load_manual(&file), + ..State::default() + }; + assert_eq!(reborn.manual.len(), 1); + + // 再加同一份 ⇒ 重复(去重也走盘上的名单)。 + assert_eq!( + store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(), + Some(Refusal::AddDuplicate) + ); + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------- 守卫⑥:扫描只许一个线程(P2-5) + +static JOB_LIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static JOB_PEAK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static JOB_RELEASE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// 一个「卡住」的扫描作业:直到被取消或 `JOB_RELEASE` 放开才返回。 +fn blocking_job( + _candidates: &[unlucky_core::game::java::staged::Candidate], + cancelled: &dyn Fn() -> bool, + _progress: &mut dyn FnMut(usize, usize), +) -> Vec { + use std::sync::atomic::Ordering; + let live = JOB_LIVE.fetch_add(1, Ordering::SeqCst) + 1; + JOB_PEAK.fetch_max(live, Ordering::SeqCst); + while !JOB_RELEASE.load(Ordering::SeqCst) && !cancelled() { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + JOB_LIVE.fetch_sub(1, Ordering::SeqCst); + Vec::new() +} + +/// 扫描进行中点「立即扫描」不许起第二个线程;「取消」必须让线程**真的退出**(之后才允许再扫)。 +/// +/// 变异证明:把 `start_with` 开头那道 `if report.running { return false }` 删掉 ⇒ 第二次 +/// `start_with` 返回 `true`、`JOB_PEAK` 变成 2,这条红;把 `cancel()` 里置 `cancelled` 那两行 +/// 删掉 ⇒ 作业永远看不到取消、`report().running` 不退,超时断言红。 +#[test] +fn only_one_scan_thread_can_run_and_cancel_finishes_it() { + use std::sync::atomic::Ordering; + use std::time::{Duration, Instant}; + + JOB_RELEASE.store(false, Ordering::SeqCst); + JOB_PEAK.store(0, Ordering::SeqCst); + let slot = store::ScanSlot::default(); + + assert!( + slot.start_with(Vec::new(), Vec::new(), false, blocking_job), + "第一次扫描应该能起来" + ); + assert!( + !slot.start_with(Vec::new(), Vec::new(), false, blocking_job), + "扫描进行中不许起第二个线程" + ); + assert!(slot.report().running, "线程活着时 running 必须为真"); + + slot.cancel(); + assert!(slot.report().cancelled, "取消之后要标记 cancelled"); + let deadline = Instant::now() + Duration::from_secs(5); + while slot.report().running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + !slot.report().running, + "取消必须让扫描线程在预算内真的退出(不是只把界面翻成「已取消」)" + ); + assert!(!slot.report().finished, "被取消的扫描不许把结论当真"); + assert_eq!( + JOB_PEAK.load(Ordering::SeqCst), + 1, + "任何时刻只有一个扫描线程" + ); + + // 线程真退出之后允许重新扫(这就是「取消 ≠ 永久锁死」)。 + JOB_RELEASE.store(true, Ordering::SeqCst); + assert!( + slot.start_with(Vec::new(), Vec::new(), false, blocking_job), + "上一个线程退出之后应该能重新扫" + ); + let deadline = Instant::now() + Duration::from_secs(5); + while slot.report().running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(slot.report().finished, "这次应该正常扫完"); + JOB_RELEASE.store(false, Ordering::SeqCst); +} + +// --------------------------------------------------------------- 守卫⑦:错误文案是 i18n 的(P2-2) + +/// 用户可见的失败原因**全部**来自 catalog:三档「写不进去」各有各的一句,手动路径的 +/// 「太长 / 写不进名单」也不再顶替「路径是空的 / 路径上没有东西」。 +/// +/// 抓得住:①三档文案串台(只读说成「没有改动」);②新加的原因复用旧 key;③把中文硬编码回 +/// `store.rs`(源码扫描那一段)。 +/// 抓不住:译文翻得好不好(那是金表与人工的事)。 +/// +/// 变异证明:`Unwritable::ReadOnly` 的 key 改成 `SettingsJavaSkipNotDirty` ⇒ 这条红; +/// 把 `store.rs` 里加回一句硬编码中文 ⇒ 源码扫描那段红。 +#[test] +fn the_unwritable_reasons_have_distinct_text() { + use unlucky_i18n::{Key, Lang}; + + let en = Lang::En.catalog(); + let zh = Lang::ZhCn.catalog(); + let reasons = [ + Unwritable::NotDirty, + Unwritable::ReadOnly, + Unwritable::Unreadable, + ]; + + for reason in reasons { + assert_ne!( + en.text(reason.label()), + zh.text(reason.label()), + "{} 未翻译", + reason.label().name() + ); + } + for (index, left) in reasons.iter().enumerate() { + for right in reasons.iter().skip(index + 1) { + assert_ne!( + en.text(left.label()), + en.text(right.label()), + "「写不进去」的两档用了同一句文案:{} / {}", + left.label().name(), + right.label().name() + ); + } + } + + // 手动路径的两档新原因不许顶替旧原因(P2-2 的第三条与第四条)。 + assert_eq!( + Refusal::AddTooLong.label(), + Key::SettingsJavaReasonAddTooLong + ); + assert_eq!( + Refusal::AddWriteFailed.label(), + Key::SettingsJavaReasonAddWriteFailed + ); + assert_ne!(Refusal::AddTooLong.label(), Refusal::AddEmpty.label()); + assert_ne!(Refusal::AddWriteFailed.label(), Refusal::AddMissing.label()); + + // 源码扫描:`store.rs` 里不许再有「把 Rust 枚举名或中文字符串当人话」的写法。 + let source = include_str!("../store.rs"); + assert!( + !source.contains("format!(\"{refusal:?}\")"), + "不许把 Rust 枚举名(Refusal 的 Debug 串)当用户文案" + ); + for hardcoded in ["没有改动", "配置文件只读", "配置文件不可信"] { + // 只认**字符串字面量**(带引号):文档注释里出现这几个词是正常的说明。 + let literal = format!("\"{hardcoded}\""); + assert!( + !source.contains(&literal), + "store.rs 里还有硬编码中文字符串:{literal}" + ); + } +} + +/// `core` 的每种拒绝原因都要落到**自己的**页面原因上(P2-2 的第三、四条)。 +/// +/// 抓得住:`ManualFailure::TooLong` 被写成 `Refusal::AddEmpty`(界面会对一条 600 字符的路径说 +/// 「路径是空的」)、名单写盘失败被写成 `Refusal::AddMissing`(磁盘写不进去却说「路径上没有东西」)。 +/// 抓不住:文案翻得好不好(那是金表与人工的事)。 +/// +/// 变异证明:`failure_refusal` 里 `TooLong => AddTooLong` 改成 `AddEmpty` ⇒ 本条红; +/// 写盘失败那行 `map_err(|_| AddWriteFailed)` 改成 `AddMissing` ⇒ 本条红。 +#[test] +fn every_manual_failure_maps_to_its_own_reason() { + use unlucky_core::game::java::manual::ManualFailure; + + let dir = root("manual-map"); + let file = config(&dir); + for (failure, expected) in [ + (ManualFailure::Empty, Refusal::AddEmpty), + (ManualFailure::TooLong, Refusal::AddTooLong), + (ManualFailure::Missing, Refusal::AddMissing), + (ManualFailure::NotJava, Refusal::AddNotJava), + (ManualFailure::NoVersion, Refusal::AddNoVersion), + ] { + let probe = move |_input: &str| Err(failure); + assert_eq!( + store::add_manual_with(&file, "whatever", &probe).err(), + Some(expected), + "{failure:?} 应该映射到 {expected:?}" + ); + } + // 一个都没写进去。 + assert!(store::load_manual(&file).is_empty()); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 名单文件写不进去(父路径是个文件)时,报的是「写不进名单」而不是「路径上没有东西」。 +#[test] +fn a_failed_list_write_reports_the_write_not_the_path() { + use unlucky_core::game::java::{JavaOrigin, JavaRuntime}; + + let dir = root("manual-write-fail"); + // `/config` 做成**文件**:`manual_file` 的父目录建不出来 ⇒ save_paths 失败。 + // (`root()` 会先建好 `config/`,所以这里先删掉再放一个同名文件。) + let blocker = dir.join("config"); + std::fs::remove_dir_all(&blocker).expect("删掉空 config 目录"); + std::fs::write(&blocker, b"not a directory").expect("写占位文件"); + let file = blocker.join("launcher.json"); + let jdk = fake_jdk(&dir, "manual-jdk"); + let binary = jdk.join("bin").join(java_bin()); + let probe = |_input: &str| { + Ok(JavaRuntime { + binary: binary.clone(), + origin: JavaOrigin::System, + version: Some("openjdk version \"17.0.13\" 2024-10-15".to_string()), + major: Some(17), + bits: 64, + vendor: Some("Temurin".to_string()), + }) + }; + assert_eq!( + store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(), + Some(Refusal::AddWriteFailed), + "磁盘写不进去要说「写不进名单」,不能说「路径不存在」" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------- r2 P2-1:空态不许指向不存在的页 + +/// 空态给的那条「还有别的办法」必须是**真的存在**的入口。 +/// +/// 现场(r2 复审 P2-1):原文写「或者去 Java 页装一个」,而 GUI 里根本没有 Java 页 +/// (`presenter::router::Route` 只有 Instances / Download / Content / Logs / Settings / +/// Accounts / Detail / Agent,`spawn_install` 在 `crates/app-iced/src` 里零消费者)。 +/// 于是「一个 JDK 都没装」的用户——正是这个 mission 要救的那位——被指到一个不存在的入口。 +/// +/// 抓得住:文案被改回「Java 页 / the Java page」,或把命令行入口那半句删掉。 +/// 抓不住:命令行的措辞好不好读(那是人工的事)。 +/// +/// 变异证明:把 `SettingsJavaEmptyBody` 改回「或者去 Java 页装一个」⇒ 本条红。 +#[test] +fn the_empty_state_points_at_a_real_entry() { + use unlucky_i18n::Lang; + + for lang in [Lang::En, Lang::ZhCn] { + let text = lang + .catalog() + .text(unlucky_i18n::Key::SettingsJavaEmptyBody); + assert!( + text.contains("ull java install"), + "{}:空态要给一条真能走的路(命令行)——{text}", + lang.tag() + ); + for dead in ["Java page", "Java 页"] { + assert!( + !text.contains(dead), + "{}:空态指向了不存在的入口「{dead}」——{text}", + lang.tag() + ); + } + } +} diff --git a/crates/app-iced/src/pages/settings/java/view.rs b/crates/app-iced/src/pages/settings/java/view.rs new file mode 100644 index 0000000..944c022 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/view.rs @@ -0,0 +1,284 @@ +//! 「Java」分组的一张卡片(M175)。 +//! +//! ## 版面(自上而下) +//! +//! | 行 | 内容 | 数值 / 证据 | +//! |---|---|---| +//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 | +//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip | +//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] | +//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 | +//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) | +//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` | +//! | 动作 | 保存 | 灰掉时下面是原因 | +//! +//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发 +//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key, Lang}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::selection::{chip, Chip}; +use crate::pages::frame::{card, divider, text_role_owned}; +use crate::pages::Message; +use crate::theme::Ui; + +use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line}; +use super::state::{Progress, RuntimeRow, State}; +use super::Message as JavaMessage; +use crate::pages::settings::{GroupMessage, States}; + +/// 一条本组消息 → 页面消息。 +pub(super) fn msg(message: JavaMessage) -> Message { + Message::Settings(GroupMessage::Java(message)) +} + +/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。 +pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> { + body_of(ui, &states.groups.java, lang) +} + +fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { + let catalog = lang.catalog(); + let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + stacked = stacked.push(status_row(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(runtime_list(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(add_row(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(memory_field(ui, catalog, state)); + stacked = stacked.push(jvm_field(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(actions_row(ui, catalog, state)); + stacked = stacked.push(hint_line(ui, catalog, state)); + + if let Some(notice) = &state.notice { + stacked = stacked.push(notice_line(ui, catalog, notice)); + } + + card(ui, stacked) +} + +/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。 +/// +/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**, +/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。 +fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { + let (text, scanning) = match (state.scanning, state.scanned) { + (Some(_), _) if state.cancelled => { + (catalog.text(Key::SettingsJavaCancelling).to_string(), true) + } + // 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。 + (Some(Progress { total: 0, .. }), _) => ( + catalog.text(Key::SettingsJavaScanningStart).to_string(), + true, + ), + (Some(Progress { done, total }), _) => ( + catalog.render( + Key::SettingsJavaScanning, + &[ + ("done", done.to_string().as_str()), + ("total", total.to_string().as_str()), + ], + ), + true, + ), + (None, true) if state.runtimes.is_empty() => { + (catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false) + } + (None, true) => ( + catalog.render( + Key::SettingsJavaScanDone, + &[("count", state.runtimes.len().to_string().as_str())], + ), + false, + ), + (None, false) => ( + catalog.text(Key::SettingsJavaNeverScanned).to_string(), + false, + ), + }; + + let scan_label = catalog.text(Key::SettingsJavaScan); + let scan = if scanning || !state.storage { + buttons::filled(ui, Button::disabled(scan_label)) + } else { + buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan))) + }; + // 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。 + let cancel = buttons::tonal( + ui, + if scanning && !state.cancelled { + Button::new( + catalog.text(Key::SettingsJavaCancel), + msg(JavaMessage::CancelScan), + ) + } else { + Button::disabled(catalog.text(Key::SettingsJavaCancel)) + }, + ); + + // 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。 + let cancelled = (state.cancelled && state.scanning.is_none()).then(|| { + text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::SettingsJavaCancelled).to_string(), + ui.color(Role::OnSurfaceVariant), + ) + }); + + let mut block = column![row![ + container(text_role_owned( + ui, + TypeRole::BodyMedium, + true, + text, + ui.color(if state.scanning.is_some() { + Role::Primary + } else { + Role::OnSurface + }), + )) + .width(Length::Fill), + scan, + cancel, + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center),] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if let Some(line) = cancelled { + block = block.push(line); + } + block.into() +} + +/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。 +fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> { + let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + if state.runtimes.is_empty() { + let body = if state.scanned { + catalog.text(Key::SettingsJavaEmptyBody) + } else { + catalog.text(Key::SettingsJavaNeverScannedBody) + }; + return text_role_owned( + ui, + TypeRole::BodySmall, + false, + body.to_string(), + ui.color(Role::OnSurfaceVariant), + ); + } + + for runtime in &state.runtimes { + list = list.push(runtime_row(ui, catalog, state, runtime)); + } + list.into() +} + +fn runtime_row<'a>( + ui: &'a Ui, + catalog: Catalog, + state: &'a State, + runtime: &'a RuntimeRow, +) -> Element<'a, Message> { + let is_default = super::state::same_path(&runtime.binary, &state.selected); + let satisfied = state.satisfied(runtime.major); + + let (verdict, verdict_color) = match satisfied { + Some(true) => ( + catalog.text(Key::SettingsJavaFits).to_string(), + ui.color(Role::Primary), + ), + Some(false) => { + let required = state + .context + .required + .as_ref() + .map(|required| required.minimum.to_string()) + .unwrap_or_default(); + ( + catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]), + ui.color(Role::Error), + ) + } + None => (String::new(), ui.color(Role::OnSurfaceVariant)), + }; + + let action = if is_default { + chip( + ui, + // 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉 + // 「禁用态 chip 上还挂着一条真消息」的残留。 + Chip::badge(catalog.text(Key::SettingsJavaDefault)), + ) + } else { + chip( + ui, + Chip::filter( + catalog.text(Key::SettingsJavaSetDefault), + false, + msg(JavaMessage::SelectRuntime(runtime.binary.clone())), + ), + ) + }; + + let mut block = column![row![ + container( + column![ + text_role_owned( + ui, + TypeRole::BodyMedium, + is_default, + runtime.display_home(), + ui.color(Role::OnSurface), + ), + text_role_owned( + ui, + TypeRole::BodySmall, + false, + format!( + "{} · {}", + runtime.summary(), + catalog.text(runtime.source_key()) + ), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ) + .width(Length::Fill), + action, + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center)] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if !verdict.is_empty() { + block = block.push(text_role_owned( + ui, + TypeRole::BodySmall, + false, + verdict, + verdict_color, + )); + } + block.into() +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 546db1b..e96fcdb 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -271,6 +271,12 @@ const UPDATE_GROUP: usize = update::UPDATE_GROUP; /// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。 const AI_GROUP: usize = ai_about::AI_GROUP; +/// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。 +/// +/// M175 起这一组也按下标分发:它要 `Lang`(中英文案)并且要读后台扫描的快照 +/// (扫描线程不碰 `App`,结论由心跳灌进 `Groups.java`,页面只从 [`States`] 里取)。 +const JAVA_GROUP: usize = java::JAVA_GROUP; + /// 整页。 /// /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 @@ -307,9 +313,15 @@ pub fn view<'a>( /// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), /// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 /// -/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 -/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 -/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 +/// **分发点(两条路,M106 起就并存)**: +/// +/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回 +/// 演示行),`None` 直接画演示行([`frame::demo_card`]); +/// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang` +/// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body` +/// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。 +/// +/// 除此之外这里不认识任何一组的字段:分组名只作为 `GROUPS` 元素的 id 出现。 fn form<'a>( ui: &'a Ui, only: Option, @@ -339,6 +351,9 @@ fn form<'a>( } else if index == AI_GROUP { // M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。 ai::view::body(ui, states, language.effective) + } else if index == JAVA_GROUP { + // M175:Java 组要 i18n 文案 + 扫描快照,同样按下标分发(`GROUP.body` 保持 `None`)。 + java::view::body(ui, states, language.effective) } else { group .body diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index 0dbcef4..18ca878 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -1,33 +1,30 @@ -//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有, -//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。 +//! 「Java」分组自己的断言(M100 起归 java 线;M175 重写)。 //! -//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线, -//! 见 `tests/mod.rs` 的 `every_line_can_be_wired_alone` 说明): -//! 1. `settings/java.rs`(`body = Some(java::body)` + 渲染器); -//! 2. 本文件 `the_java_group_is_still_demo_rows`(把 `is_none()` 改成 `is_some()`)。 +//! M101 的三条里有两条在 M175 换掉了: //! -//! download / general 同型。 +//! | 旧断言 | M175 之后 | +//! |---|---| +//! | `the_java_group_shows_runtime_memory_and_jvm_arguments`(逐字钉住 `Temurin 21.0.4` / `4 GiB`) | 换成「演示行必须是空的」——那三行假数据正是用户实测投诉的源头 | +//! | `the_java_group_is_still_demo_rows`(断言 `body.is_none()`) | 保留(`body` 仍是 `None`:本组走按下标分发,见 `settings/java.rs` 的文件头) | +//! | `the_java_group_keeps_the_folder_icon_from_the_visual_review` | 不动 | //! -//! 判据(M100 交付里正面回答的那条):给 java 加一条**新断言** = **只改本文件**。 +//! 真数据源的断言在 `pages/settings/java/tests.rs`(扫描 / 校验 / 落盘 / 真命令行), +//! 这里只守「左列那一格」——两处的分工与 M100 的 section 划分一致。 use super::super::java; use unlucky_design::icons::IconKind; -/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 +/// 这一组**不再有演示行**:三个假值(运行时 / 内存 / JVM 参数)已经换成真控件。 /// -/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); -/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。 -/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。 +/// 抓得住:谁把演示行加回来(那会让 harness 路径又画出一份「看起来像真检测」的假数据); +/// 也抓得住「接真控件却把 `rows` 留着」这种半吊子状态。 +/// 抓不住:真控件里画了什么(那是 `pages/settings/java/tests.rs` 的事)。 #[test] -fn the_java_group_shows_runtime_memory_and_jvm_arguments() { - let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec(); - assert_eq!( - rows, - vec![ - ("Runtime", "Temurin 21.0.4"), - ("Default memory", "4 GiB"), - ("JVM arguments", "Automatic"), - ] +fn the_java_group_has_no_demo_rows_left() { + assert!( + java::GROUP.rows.is_empty(), + "Java 组不该再有演示行(真值来自扫描 / 手动路径 / launcher.json):{:?}", + java::GROUP.rows ); } @@ -41,17 +38,14 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() { assert_eq!(java::GROUP.icon, IconKind::Folder); } -/// **绊线**:这一组还没接真控件,画的是演示行。 +/// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。 /// -/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 -/// 抓不住:界面上真控件长什么样。 -/// -/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去 -/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。 +/// 抓得住:谁把 `body: Some(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。 +/// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。 #[test] -fn the_java_group_is_still_demo_rows() { +fn the_java_group_is_dispatched_by_index_not_by_body() { assert!( java::GROUP.body.is_none(), - "Java 还没接真控件:接了就改本断言" + "Java 组走按下标分发(要 Lang),body 必须保持 None" ); } diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 2e1f4e3..57c7965 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -88,18 +88,30 @@ fn narrow_window_stacks_the_groups_instead_of_the_side_nav() { // --------------------------------------------------------------- 注册表(四组一起看) -/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。 +/// 守的是**注册表的结构不变量**:每组都有名字、图标,且「有真控件分发」或「有演示行」至少占一样。 /// -/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组, -/// 而「新增一组忘了填 rows」正是这条要抓的。 +/// 下放不了:它遍历的是 `GROUPS`,单组文件拿不到其它组,而「新增一组忘了填 rows、又没有分发」 +/// 正是这条要抓的。 +/// +/// M175 起 Java 组的 `rows` 是**空**的:它那三行演示值(`Temurin 21.0.4` / `4 GiB` / +/// `Automatic`)正是用户实测投诉的假数据源,真控件接上之后演示行就删干净了。所以判据从 +/// 「必须有 rows」放宽成「必须有 rows **或** 有别的分发路径」——按下标分发的那几组 +/// (general / update / assistant / java)在 `settings/mod.rs` 的 `form` 里各有 `else if`, +/// 本断言只对「既没有 rows 又不在这张名单里」的组报错。 #[test] fn every_group_has_a_name_icon_and_rows() { assert!(GROUPS.len() >= 3); assert!(SELECTED < GROUPS.len()); + // 按下标分发(`settings/mod.rs` 的 `form`):这些组的正文不在 `Group.rows` 里。 + const INDEX_DISPATCHED: [&str; 4] = ["General", "Updates", "Assistant", "Java"]; for group in &GROUPS { assert!(!group.name.is_empty()); assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name); - assert!(!group.rows.is_empty(), "{} 没有设置行", group.name); + assert!( + !group.rows.is_empty() || INDEX_DISPATCHED.contains(&group.name), + "{} 既没有设置行,也不在按下标分发的名单里", + group.name + ); for (label, value) in group.rows { assert!(!label.is_empty() && !value.is_empty()); } diff --git a/crates/core/src/game/java/manual.rs b/crates/core/src/game/java/manual.rs new file mode 100644 index 0000000..7bb390e --- /dev/null +++ b/crates/core/src/game/java/manual.rs @@ -0,0 +1,324 @@ +//! 用户手动指定的 JDK 路径(M175):校验、厂商识别、名单持久化。 +//! +//! 场景:MSI 装的 JDK 落在 `%ProgramFiles%` 之外的盘符上、或者用户自己解压的一份 JDK —— +//! [`super::discover`] 的标准位置扫描看不见它们,用户只能在设置页里**手动指一个路径**。 +//! +//! # 三条口径 +//! +//! 1. **收目录也收可执行文件**:两样都是用户会粘的东西,统一折成 `bin/java[.exe]`; +//! 2. **校验就是真跑一次 `java -version`**:目录存在、里面有 `bin/java` 都不算数—— +//! 一份放错的 `java`(空文件、别的平台的可执行文件)必须在这里被拒,而不是等启动时崩; +//! 3. **这份名单是 UI 的持久化,不是启动策略**:真正「用哪个 Java 启动」仍由 +//! `java.selected_path`(core 的 schema 键)决定,名单只是「我手动加过哪些」的记忆。 +//! +//! 名单落一个纯文本文件(一行一个路径):它是 UI 侧的登记表,不参与 schema 校验, +//! 也不该借道 `launcher.json`(那条路要新键 ⇒ 动 schema 版本与迁移,代价远大于收益)。 + +use std::path::{Path, PathBuf}; + +use super::extract::java_binary_name; +use super::probe::{self, JavaOrigin, JavaRuntime}; + +/// 用户可粘路径的长度上限(防手滑粘进来一整段日志)。 +pub const MAX_PATH_CHARS: usize = 512; + +/// 手动路径被拒的原因(稳定 id 给 UI 选文案;不做本地化)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManualFailure { + /// 空串 / 全空白 + Empty, + /// 超过 [`MAX_PATH_CHARS`] + TooLong, + /// 盘上没这个文件或目录 + Missing, + /// 有这个东西,但里面没有 `bin/java[.exe]`(或给的不是一个 java 可执行文件) + NotJava, + /// 有 `java`,但 `java -version` 跑不起来 / 读不出版本号 + NoVersion, +} + +impl ManualFailure { + /// 稳定 id(UI 文案 key 的后缀,见 `pages/settings/java`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::TooLong => "too-long", + Self::Missing => "missing", + Self::NotJava => "not-java", + Self::NoVersion => "no-version", + } + } +} + +/// 用户给的路径 → `java` 可执行文件(不探测,只看文件系统)。 +/// +/// # Errors +/// 空 / 太长 / 不存在 / 不是 java 可执行文件时返回原因。 +pub fn resolve_binary(input: &str) -> Result { + let text = input.trim(); + if text.is_empty() { + return Err(ManualFailure::Empty); + } + if text.chars().count() > MAX_PATH_CHARS { + return Err(ManualFailure::TooLong); + } + let path = Path::new(text); + if path.is_file() { + return looks_like_java(path) + .then(|| path.to_path_buf()) + .ok_or(ManualFailure::NotJava); + } + if path.is_dir() { + let binary = path.join("bin").join(java_binary_name()); + return binary + .is_file() + .then_some(binary) + .ok_or(ManualFailure::NotJava); + } + Err(ManualFailure::Missing) +} + +/// 文件名是不是 `java` / `javaw`(Windows 带 `.exe`;POSIX 不区分大小写属于宽容)。 +fn looks_like_java(path: &Path) -> bool { + path.file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw")) +} + +/// 跑一次 `java -version`,同时把**原始输出**交回来(厂商要从整段输出里认,不能只看首行)。 +/// +/// 为什么需要原始输出:`-version` 的第一行只有 `openjdk version "21.0.11"`,发行方印在**第二行** +/// (`OpenJDK Runtime Environment Microsoft-11230047` / `… Temurin-21.0.4+7`)。只拿首行去认厂商, +/// Microsoft / Temurin 的 JDK 会一律显示成 `OpenJDK`——M175 出图时实测到了这个错。 +#[must_use] +pub fn probe_with_output(binary: &Path, origin: JavaOrigin) -> (JavaRuntime, String) { + probe_with_timeout(binary, origin, super::proc::PROBE_TIMEOUT, &|| false) +} + +/// 同 [`probe_with_output`],但带**超时与取消**(扫描线程用这一条)。 +#[must_use] +pub fn probe_with_timeout( + binary: &Path, + origin: JavaOrigin, + timeout: std::time::Duration, + cancelled: &dyn Fn() -> bool, +) -> (JavaRuntime, String) { + let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); + let mut command = std::process::Command::new(binary); + command.arg("-version"); + let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else { + return (runtime, String::new()); + }; + let mut text = String::from_utf8_lossy(&output.stderr).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if let Some((line, major)) = probe::parse_version_output(&text) { + runtime.major = Some(major); + runtime.version = Some(line); + runtime.bits = probe::bits_of(&text); + } + runtime.vendor = vendor_of(&text); + (runtime, text) +} + +/// 严格探测:必须真跑得起来 `-version`。 +/// +/// 与 [`probe::probe`] 的差别只有一个:后者「探测不出也返回一个 runtime」,这里探测不出就是**拒绝**。 +/// 手动添加这条路上,一个答不出话的 `java` 就是无效输入,不能进名单。 +/// +/// # Errors +/// 校验不过([`resolve_binary`])或 `-version` 读不出版本号时返回原因。 +pub fn probe_manual(input: &str) -> Result { + let binary = resolve_binary(input)?; + let (runtime, _output) = probe_with_output(&binary, JavaOrigin::System); + if runtime.major.is_none() { + return Err(ManualFailure::NoVersion); + } + Ok(runtime) +} + +/// `java -version` 输出里的发行方(认不出返回 `None`,不猜)。 +/// +/// 为什么从输出里认而不是从安装目录名认:用户手动指的路径跟目录命名毫无关系, +/// 而 `java -version` 的首行/次行本来就把发行方印出来了 +/// (`OpenJDK Runtime Environment Temurin-21.0.4+7`、`Java(TM) SE Runtime Environment`)。 +#[must_use] +pub fn vendor_of(text: &str) -> Option { + let lowered = text.to_ascii_lowercase(); + // 顺序即判据:「Temurin 构建的 OpenJDK」也能匹配到 openjdk,所以具体品牌必须排在前面。 + const MARKERS: &[(&str, &str)] = &[ + ("temurin", "Temurin"), + ("adoptium", "Temurin"), + ("microsoft", "Microsoft"), + ("zulu", "Zulu"), + ("azul", "Zulu"), + ("corretto", "Corretto"), + ("semeru", "Semeru"), + ("graalvm", "GraalVM"), + ("sapmachine", "SapMachine"), + ("oracle", "Oracle"), + // 老 Oracle / HotSpot 的 `-version` 不提 "Oracle"(Java 8 就是 `Java(TM) SE Runtime + // Environment` + `Java HotSpot(TM) 64-Bit Server VM`),这两条是那一代的唯一指纹。 + ("se runtime", "Oracle"), + ("hotspot", "Oracle"), + ("openjdk", "OpenJDK"), + ]; + MARKERS + .iter() + .find(|(needle, _)| lowered.contains(needle)) + .map(|(_, display)| (*display).to_string()) +} + +/// 读名单(文件不存在 / 读不动 ⇒ 空名单,不报错)。 +/// +/// 去掉空行、去重(按 [`probe::discover::path_key`](super::discover::path_key) 的同一套键), +/// 顺序保持文件里的顺序(用户添加的先后)。 +#[must_use] +pub fn load_paths(file: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(file) else { + return Vec::new(); + }; + let mut paths: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let path = PathBuf::from(line); + if !paths + .iter() + .any(|known| super::discover::path_key(known) == super::discover::path_key(&path)) + { + paths.push(path); + } + } + paths +} + +/// 写名单(一行一个,末尾换行;父目录不存在就建)。 +/// +/// # Errors +/// 目录建不出来或写盘失败时返回 IO 错误。 +pub fn save_paths(file: &Path, paths: &[PathBuf]) -> std::io::Result<()> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let mut text = String::new(); + for path in paths { + text.push_str(&path.display().to_string()); + text.push('\n'); + } + std::fs::write(file, text) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-manual-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("临时目录"); + dir + } + + #[test] + fn recognises_vendors_from_real_version_lines() { + assert_eq!( + vendor_of("OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)"), + Some("Temurin".to_string()) + ); + assert_eq!( + vendor_of("OpenJDK 64-Bit Server VM Microsoft-8321 (build 17.0.13+11)"), + Some("Microsoft".to_string()) + ); + assert_eq!( + vendor_of("openjdk version \"17.0.9\" 2023-10-17\nOpenJDK Runtime Environment Zulu17.46+19-CA"), + Some("Zulu".to_string()) + ); + assert_eq!( + vendor_of( + "java version \"1.8.0_402\"\nJava(TM) SE Runtime Environment (build 1.8.0_402-b06)" + ), + Some("Oracle".to_string()) + ); + assert_eq!(vendor_of("nothing useful here"), None); + } + + #[test] + fn accepts_a_directory_and_a_binary_but_rejects_anything_else() { + let dir = temp("resolve"); + let jdk = dir.join("jdk-21"); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + let binary = jdk.join("bin").join(java_binary_name()); + std::fs::write(&binary, b"").expect("写 java"); + + assert_eq!( + resolve_binary(&jdk.display().to_string()), + Ok(binary.clone()) + ); + assert_eq!(resolve_binary(&binary.display().to_string()), Ok(binary)); + assert_eq!(resolve_binary(" "), Err(ManualFailure::Empty)); + assert_eq!( + resolve_binary(&dir.join("nope").display().to_string()), + Err(ManualFailure::Missing) + ); + let empty = dir.join("empty"); + std::fs::create_dir_all(&empty).expect("empty"); + assert_eq!( + resolve_binary(&empty.display().to_string()), + Err(ManualFailure::NotJava) + ); + // 目录里有一个**不叫 java 的**可执行文件:也不算。 + let text = dir.join("notes.txt"); + std::fs::write(&text, b"hi").expect("写文本"); + assert_eq!( + resolve_binary(&text.display().to_string()), + Err(ManualFailure::NotJava) + ); + let long = "a".repeat(MAX_PATH_CHARS + 1); + assert_eq!(resolve_binary(&long), Err(ManualFailure::TooLong)); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_binary_that_cannot_answer_version_is_refused() { + let dir = temp("noversion"); + let jdk = dir.join("jdk"); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + // 空文件:解析不出版本号 ⇒ 拒绝(这条正是「文件存在 ≠ 能用」的判据)。 + std::fs::write(jdk.join("bin").join(java_binary_name()), b"").expect("写 java"); + assert_eq!( + probe_manual(&jdk.display().to_string()).err(), + Some(ManualFailure::NoVersion) + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_list_round_trips_and_dedupes() { + let dir = temp("list"); + let file = dir.join("config").join("java-extra-paths.txt"); + let paths = vec![ + PathBuf::from("C:/jdk/17"), + PathBuf::from("C:/jdk/21"), + PathBuf::from("C:\\jdk\\17"), + ]; + save_paths(&file, &paths).expect("写名单"); + // 第三条是第一条的另一种写法:读回来只留一条(与扫描去重同一套键)。 + assert_eq!(load_paths(&file).len(), 2); + assert_eq!(load_paths(&file)[0], PathBuf::from("C:/jdk/17")); + + let missing = dir.join("nope.txt"); + assert!(load_paths(&missing).is_empty(), "缺文件是空名单,不是错误"); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs index dd2aad7..1b93f57 100644 --- a/crates/core/src/game/java/mod.rs +++ b/crates/core/src/game/java/mod.rs @@ -45,14 +45,17 @@ pub mod error; pub mod extract; pub mod install; pub mod location; +pub mod manual; pub mod mapping; pub mod microsoft; pub mod net; pub mod oracle; pub mod probe; +pub mod proc; pub mod release; pub mod select; pub mod source; +pub mod staged; pub mod target; pub mod termux; pub mod vendor; diff --git a/crates/core/src/game/java/proc.rs b/crates/core/src/game/java/proc.rs new file mode 100644 index 0000000..beeb35d --- /dev/null +++ b/crates/core/src/game/java/proc.rs @@ -0,0 +1,169 @@ +//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。 +//! +//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不 +//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。 + +use std::time::Duration; + +/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。 +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。 +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。 +/// +/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。 +/// +/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死, +/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。 +#[must_use] +pub fn run_with_timeout( + mut command: std::process::Command, + timeout: Duration, + cancelled: &dyn Fn() -> bool, +) -> Option { + use std::process::Stdio; + + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().ok()?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if cancelled() || started.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + child.wait_with_output().ok() +} + +#[cfg(test)] +mod tests { + use super::run_with_timeout; + + #[test] + fn a_hung_probe_is_killed_after_the_timeout() { + // 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。 + let started = std::time::Instant::now(); + let output = run_with_timeout( + sleeping_command(30), + std::time::Duration::from_millis(400), + &|| false, + ); + assert!(output.is_none(), "超时必须判成「没有结论」"); + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(5), + "超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}" + ); + } + + #[test] + fn a_cancel_interrupts_the_probe_that_is_already_running() { + // 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。 + let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let tripper = flag.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + tripper.store(true, std::sync::atomic::Ordering::SeqCst); + }); + let started = std::time::Instant::now(); + let output = run_with_timeout( + sleeping_command(30), + std::time::Duration::from_secs(30), + &|| flag.load(std::sync::atomic::Ordering::SeqCst), + ); + assert!(output.is_none(), "被取消的探测没有结论"); + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "取消必须打断正在跑的那个探测,而不是等它自己结束" + ); + } + + /// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。 + /// + /// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()` + /// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。 + /// + /// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把 + /// 标记文件写出来 ⇒ 本条红。 + #[test] + fn a_timed_out_child_is_really_killed() { + let dir = std::env::temp_dir().join(format!( + "ull-proc-kill-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("临时目录"); + let marker = dir.join("survived.txt"); + let _ = std::fs::remove_file(&marker); + + let output = run_with_timeout( + marker_command(&marker, 4), + std::time::Duration::from_millis(300), + &|| false, + ); + assert!(output.is_none(), "超时没有结论"); + // 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。 + std::thread::sleep(std::time::Duration::from_secs(6)); + assert!( + !marker.exists(), + "子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。 + fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command { + let target = marker.display().to_string(); + if cfg!(windows) { + // PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的 + // 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用 + // PowerShell 的单引号,外层只剩 Rust 加的那一层。 + let mut command = std::process::Command::new("powershell"); + command.args([ + "-NoProfile", + "-Command", + &format!( + "Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive" + ), + ]); + command + } else { + let mut command = std::process::Command::new("sh"); + command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]); + command + } + } + + /// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。 + fn sleeping_command(seconds: u32) -> std::process::Command { + if cfg!(windows) { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]); + command + } else { + let mut command = std::process::Command::new("sleep"); + command.arg(seconds.to_string()); + command + } + } +} diff --git a/crates/core/src/game/java/staged.rs b/crates/core/src/game/java/staged.rs new file mode 100644 index 0000000..c4e7f86 --- /dev/null +++ b/crates/core/src/game/java/staged.rs @@ -0,0 +1,347 @@ +//! 带进度与取消的 Java 扫描(M175):**先收集候选、再逐个探测**。 +//! +//! 为什么不能直接调 [`super::discover::candidates_in_roots`]:那条路把「收集」与「探测」揉在一起, +//! 一个 JDK 跑一次 `java -version`(几十到几百毫秒),一台装了七八个 JDK 的机器上整段要几秒。 +//! 设置页要的是**能显示 3/7、能中途取消、且不卡住 UI**——于是这里把两段拆开: +//! +//! ```text +//! candidates(roots, manual, system) 纯文件系统,快、无子进程 +//! → scan(&candidates, on_progress, cancelled) 逐个探测;任一回调说停就停 +//! ``` +//! +//! 判据与 [`super::discover`] 一致(同一套去重键、同一套标准位置),差别只有「可观测」这一层—— +//! 所以 `ull java list` 与设置页看到的是同一批 JDK。 + +use std::path::PathBuf; + +use super::discover::{self, path_key}; +use super::extract::{java_binary_name, read_stamp}; +use super::probe::{self, JavaOrigin, JavaRuntime}; +use super::vendor::JdkVendor; + +/// 候选来源(比 [`JavaOrigin`] 多一个「用户手动加的」,UI 也按它分组)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// 托管目录(`ull java install` 装的) + Managed, + /// `JAVA_HOME` + JavaHome, + /// `PATH` 上的 `java` + Path, + /// 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表 / `/usr/lib/jvm`) + System, + /// 用户在设置页手动指的路径 + Manual, +} + +impl Source { + /// 稳定 id(DTO / UI 文案选档用)。 + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::JavaHome => "env", + Self::Path => "path", + Self::System => "system", + Self::Manual => "manual", + } + } + + /// 展示优先级(小者先;与 [`JavaOrigin::rank`] 同序,手动路径排在系统安装之前—— + /// 用户亲手指的是明确的意图)。 + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Managed => 1, + Self::JavaHome => 2, + Self::Path => 3, + Self::Manual => 4, + Self::System => 5, + } + } + + /// 探测时用的 [`JavaOrigin`](`JavaOrigin` 没有「手动」这一档,用 `System` 承接下来即可: + /// 展示走本枚举的 [`Source::id`],不看 `JavaRuntime.origin`)。 + #[must_use] + pub const fn origin(self) -> JavaOrigin { + match self { + Self::Managed => JavaOrigin::Managed, + Self::JavaHome => JavaOrigin::JavaHome, + Self::Path => JavaOrigin::Path, + Self::System | Self::Manual => JavaOrigin::System, + } + } +} + +/// 一个待探测的候选。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// `bin/java[.exe]` 的绝对路径 + pub binary: PathBuf, + /// 来源 + pub source: Source, +} + +/// 收集候选(**不探测**,所以很快;目录不存在一律跳过)。 +/// +/// `system = false` 用于测试与「只看托管区」的场景:它跳过 `JAVA_HOME`/`PATH`/标准位置, +/// 于是同一份输入在任何机器上得到同一份候选表(不依赖本机装了什么)。 +#[must_use] +pub fn candidates(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec { + let mut out: Vec = Vec::new(); + for root in managed_roots { + out.extend(managed_in(root)); + } + // 手动路径:目录 → `bin/java`;文件原样(用户在输入框里粘过两种形态,都认)。 + for path in manual { + let binary = if path.is_dir() { + path.join("bin").join(java_binary_name()) + } else { + path.clone() + }; + if binary.is_file() { + out.push(Candidate { + binary, + source: Source::Manual, + }); + } + } + if system { + out.extend(env_candidates()); + out.extend( + discover::scan_java_binaries() + .into_iter() + .map(|binary| Candidate { + binary, + source: Source::System, + }), + ); + } + dedupe(out) +} + +/// 托管安装根里的候选(两种布局都认,**必须有戳记**——没戳记的目录可能是用户塞的别的东西)。 +fn managed_in(root: &std::path::Path) -> Vec { + let mut found = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return found; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if !path.is_dir() || name.starts_with('.') { + continue; + } + if let Some(binary) = stamped_binary(&path) { + found.push(Candidate { + binary, + source: Source::Managed, + }); + continue; + } + // 两层布局 `/`:厂商名取自父目录名。 + if JdkVendor::parse(&name).is_some() { + let Ok(children) = std::fs::read_dir(&path) else { + continue; + }; + for child in children.flatten() { + if let Some(binary) = stamped_binary(&child.path()) { + found.push(Candidate { + binary, + source: Source::Managed, + }); + } + } + } + } + found +} + +/// 带戳记、且 `bin/java[.exe]` 在盘上的托管目录。 +fn stamped_binary(dir: &std::path::Path) -> Option { + if !dir.is_dir() || read_stamp(dir).is_none() { + return None; + } + let binary = dir.join("bin").join(java_binary_name()); + binary.is_file().then_some(binary) +} + +/// `JAVA_HOME` 与 `PATH`(与 [`discover::candidates_in_roots`] 同一口径)。 +fn env_candidates() -> Vec { + let mut found = Vec::new(); + if let Some(home) = std::env::var_os("JAVA_HOME") { + let binary = PathBuf::from(home).join("bin").join(java_binary_name()); + if binary.is_file() { + found.push(Candidate { + binary, + source: Source::JavaHome, + }); + } + } + if let Some(binary) = probe::resolve_on_path(java_binary_name()) { + found.push(Candidate { + binary, + source: Source::Path, + }); + } + found +} + +/// 按路径去重,保留**先出现**的(调用方按优先级排好序)。 +fn dedupe(candidates: Vec) -> Vec { + let mut seen: Vec = Vec::with_capacity(candidates.len()); + let mut kept = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let key = path_key(&candidate.binary); + if seen.contains(&key) { + continue; + } + seen.push(key); + kept.push(candidate); + } + kept +} + +/// 逐个探测。`on_progress(done, total)` 返回 `false` ⇒ 立刻停(取消),已探测的照样返回。 +/// +/// `cancelled()` 在一次探测**进行中**也会被查询:卡住的候选会被 [`super::proc::PROBE_TIMEOUT`] +/// 或取消打断,而不是把扫描线程钉死在一个 `-version` 上(见 [`super::proc::run_with_timeout`])。 +/// +/// 返回的列表按**大版本升序、同版本按路径**排序,探测不出主版本的排在最后 +/// (与 [`super::probe::discover_managed`] 的排序口径一致,用户看到的第一行不该是「版本未知」)。 +#[must_use] +pub fn scan( + candidates: &[Candidate], + mut on_progress: F, + cancelled: &dyn Fn() -> bool, +) -> Vec +where + F: FnMut(usize, usize) -> bool, +{ + let total = candidates.len(); + let mut found: Vec = Vec::with_capacity(total); + for (index, candidate) in candidates.iter().enumerate() { + if cancelled() || !on_progress(index, total) { + break; + } + // 发行方要从**整段** `-version` 输出里认(首行只有 `openjdk version "…"`,品牌在第二行)。 + // 超时与取消都交给这一层:卡住的子进程会被 kill,取消不必等它自己结束。 + let (runtime, _output) = super::manual::probe_with_timeout( + &candidate.binary, + candidate.source.origin(), + super::proc::PROBE_TIMEOUT, + cancelled, + ); + found.push(runtime); + } + found.sort_by(|left, right| { + left.major + .unwrap_or(u32::MAX) + .cmp(&right.major.unwrap_or(u32::MAX)) + .then_with(|| left.binary.cmp(&right.binary)) + }); + found +} + +/// 候选 + 扫描一步到位(不与 UI 打交道的调用方用;设置页走两段式)。 +#[must_use] +pub fn scan_roots(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec { + let list = candidates(managed_roots, manual, system); + scan(&list, |_, _| true, &|| false) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-staged-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("临时目录"); + dir + } + + /// 造一个「看起来像 JDK」的目录(`bin/java` 是空文件 ⇒ 探测出「版本未知」, + /// 但它确实被扫到了——这正是「列表来自真实扫描」要的证据)。 + fn fake_jdk(root: &std::path::Path, name: &str) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("bin")).expect("bin"); + std::fs::write(dir.join("bin").join(java_binary_name()), b"").expect("写 java"); + dir + } + + #[test] + fn the_list_comes_from_the_disk_not_from_a_constant() { + let root = temp("list"); + let one = fake_jdk(&root, "jdk-a"); + let two = fake_jdk(&root, "jdk-b"); + + let found = scan_roots(&[], &[one.clone(), two.clone()], false); + assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}"); + assert!(found + .iter() + .any(|runtime| runtime.binary == one.join("bin").join(java_binary_name()))); + + // 删掉一个:列表必须少一项(证明它读的是盘,不是写死的字符串)。 + std::fs::remove_dir_all(&one).expect("删 jdk-a"); + let after = scan_roots(&[], &[one, two], false); + assert_eq!(after.len(), 1, "删掉一个 JDK 之后列表必须少一项:{after:?}"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_managed_directory_without_a_stamp_is_not_claimed() { + let root = temp("stamp"); + // 长得像托管目录,但没有戳记 ⇒ 不是我们装的,不去猜。 + fake_jdk(&root, "jdk-21-temurin"); + let found = scan_roots(std::slice::from_ref(&root), &[], false); + assert!(found.is_empty(), "无戳记目录不该被认领:{found:?}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn progress_reports_every_candidate_and_cancel_stops_early() { + let root = temp("progress"); + let paths: Vec = (0..3) + .map(|n| fake_jdk(&root, &format!("jdk-{n}"))) + .collect(); + let list = candidates(&[], &paths, false); + assert_eq!(list.len(), 3); + + let mut seen = Vec::new(); + let all = scan( + &list, + |done, total| { + seen.push((done, total)); + true + }, + &|| false, + ); + assert_eq!(seen, vec![(0, 3), (1, 3), (2, 3)], "每个候选都要报一次进度"); + assert_eq!(all.len(), 3); + + let stopped = scan(&list, |done, _| done < 1, &|| false); + assert_eq!(stopped.len(), 1, "取消之后只留已探测的那一项"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn candidates_dedupe_the_same_jdk_written_two_ways() { + let root = temp("dedupe"); + let jdk = fake_jdk(&root, "jdk-x"); + let binary = jdk.join("bin").join(java_binary_name()); + // 同一份 JDK 用「目录」和「可执行文件」两种写法各登记一次。 + let list = candidates(&[], &[jdk, binary], false); + assert_eq!(list.len(), 1, "同一路径只列一次:{list:?}"); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index a8a89d3..8972de0 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -29,6 +29,65 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCornerSquare", "Square", "直角"), ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), + // M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。 + // 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、 + // `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在 + // `SettingsAppearanceCornerSuspended` 与 `SettingsGroupAssistant` 之间(顺序 = `Key::ALL`)。 + ("SettingsJavaScan", "Scan now", "立即扫描"), + ("SettingsJavaScanningStart", "Scanning…", "正在扫描…"), + ("SettingsJavaScanning", "Scanning… {done}/{total}", "正在扫描… {done}/{total}"), + ("SettingsJavaCancelling", "Cancelling…", "正在取消…"), + ("SettingsJavaCancel", "Cancel", "取消"), + ("SettingsJavaCancelled", "The scan was cancelled; the list below is from the previous run.", "扫描已取消;下面的列表还是上一次的结果。"), + ("SettingsJavaScanDone", "{count} JDK found", "找到 {count} 个 JDK"), + ("SettingsJavaNeverScanned", "Not scanned yet", "还没扫描过"), + ("SettingsJavaNeverScannedBody", "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"), + ("SettingsJavaEmptyTitle", "No Java runtime found", "未检测到 Java 运行时"), + ("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"), + ("SettingsJavaDefault", "Default", "默认"), + ("SettingsJavaSetDefault", "Use as default", "设为默认"), + ("SettingsJavaFits", "Meets the current instance requirement", "满足当前实例的要求"), + ("SettingsJavaTooOld", "Below the requirement: this instance needs Java {minimum}+", "低于要求:当前实例需要 Java {minimum} 及以上"), + ("SettingsJavaDefaultSet", "Default JDK is now {path}", "默认 JDK 已设为 {path}"), + ("SettingsJavaSourceManaged", "installed by this launcher", "本启动器安装"), + ("SettingsJavaSourceEnv", "JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"), + ("SettingsJavaSourcePath", "on PATH", "PATH 上"), + ("SettingsJavaSourceSystem", "system install", "系统安装"), + ("SettingsJavaSourceManual", "added by you", "手动添加"), + ("SettingsJavaAdd", "Add a JDK path", "手动添加 JDK 路径"), + ("SettingsJavaAddPlaceholder", "C:\\Program Files\\Java\\jdk-21", "例:C:\\Program Files\\Java\\jdk-21"), + ("SettingsJavaAddHint", "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"), + ("SettingsJavaAddAction", "Add", "添加"), + ("SettingsJavaAdded", "Added {path}", "已添加 {path}"), + ("SettingsJavaMemory", "Default memory (MB)", "默认内存(MB)"), + ("SettingsJavaMemoryHint", "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"), + ("SettingsJavaLayerGlobal", "In effect: {mb} MB, from the global default.", "生效:{mb} MB,来自全局默认值。"), + ("SettingsJavaLayerInstance", "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"), + ("SettingsJavaJvm", "Extra JVM arguments", "额外 JVM 参数"), + ("SettingsJavaJvmPlaceholder", "-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"), + ("SettingsJavaJvmHint", "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"), + ("SettingsJavaJvmSaved", "Saved value: {value}", "已保存的值:{value}"), + ("SettingsJavaJvmSavedNone", "No extra JVM arguments are saved yet.", "还没有保存任何额外参数。"), + ("SettingsJavaSave", "Save", "保存"), + ("SettingsJavaSaved", "Saved and read back from disk. The next launch uses the new value.", "已保存,并从磁盘读回确认。下次启动就用新值。"), + ("SettingsJavaUnsaved", "There are unsaved changes.", "有未保存的改动。"), + ("SettingsJavaRefused", "Not saved: {reason}", "未保存:{reason}"), + ("SettingsJavaSkipNotDirty", "there was nothing to change, so no file was written", "没有任何改动,因此没有写文件"), + ("SettingsJavaSkipReadOnly", "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", "配置文件只读:它被更新版本的启动器写过,或被标记为不可信"), + ("SettingsJavaSkipUnreadable", "the settings file is untrusted, so it is treated as read-only", "配置文件不可信,因此按只读处理"), + ("SettingsJavaNoStorage", "No settings file is available in this run, so nothing can be scanned or saved.", "这一次运行没有可用的设置文件,扫描与保存都做不了。"), + ("SettingsJavaReasonMemoryEmpty", "the memory field is empty", "内存没填"), + ("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"), + ("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"), + ("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"), + ("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"), + ("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"), + ("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"), + ("SettingsJavaReasonAddNoVersion", "java -version did not answer there, so it is not a usable JDK", "在那里跑不出 java -version,不是一份能用的 JDK"), + ("SettingsJavaReasonAddTooLong", "the path is too long to be a JDK location", "路径太长,不像是一个 JDK 位置"), + ("SettingsJavaReasonAddWriteFailed", "the JDK is fine, but the list file could not be written (disk or permission)", "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"), + ("SettingsJavaReasonAddDuplicate", "that JDK is already in the list", "这份 JDK 已经在列表里了"), + ("SettingsJavaReasonWriteFailed", "the settings file could not be written (disk full, no permission, or the value was rejected)", "设置写不进去(磁盘满、没权限,或这个值被拒绝)"), // M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。 // 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、 // `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与 diff --git a/crates/i18n/src/catalog/settings/java.rs b/crates/i18n/src/catalog/settings/java.rs index db75b75..40e0c53 100644 --- a/crates/i18n/src/catalog/settings/java.rs +++ b/crates/i18n/src/catalog/settings/java.rs @@ -1,11 +1,154 @@ -//! 设置页「Java」分组文案:**预留**(页面侧的真控件还没接)。 +//! 设置页「Java」分组文案(M175):扫描 / 运行时列表 / 默认 JDK / 内存 / JVM 参数。 //! -//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 -//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在 +//! `settings_appearance_entries` 之后、`settings_download_entries` 之前,所以金表那批行要插在 +//! `SettingsAppearanceCornerSuspended` 之后、`SettingsGroupAssistant`(M174 的助手域)之前 +//! ——**顺序即 `Key::ALL`**。 +//! +//! 三条口径: +//! +//! 1. 技术名词不翻:`JDK` / `JVM` / `-version` / `bin/java` / `java.selected_path` 原样; +//! 2. **不许出现写死的版本号或数字**:本组以前那三行(`Temurin 21.0.4` / `4 GiB`)正是被用户 +//! 当成假数据的那三条,现在所有数值都由页面从真读盘的结果里格式化进来; +//! 3. 拒绝原因一句一条(空 / 不存在 / 不是 java / 跑不出 -version / 重复),合成一句 +//! 「路径无效」等于让用户自己猜。 macro_rules! settings_java_entries { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + // ── 扫描(状态 / 进度 / 取消) + SettingsJavaScan => ("Scan now", "立即扫描"), + SettingsJavaScanningStart => ("Scanning…", "正在扫描…"), + SettingsJavaScanning => ("Scanning… {done}/{total}", "正在扫描… {done}/{total}"), + SettingsJavaCancelling => ("Cancelling…", "正在取消…"), + SettingsJavaCancel => ("Cancel", "取消"), + SettingsJavaCancelled => ( + "The scan was cancelled; the list below is from the previous run.", + "扫描已取消;下面的列表还是上一次的结果。" + ), + SettingsJavaScanDone => ("{count} JDK found", "找到 {count} 个 JDK"), + SettingsJavaNeverScanned => ("Not scanned yet", "还没扫描过"), + SettingsJavaNeverScannedBody => ( + "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", + "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。" + ), + SettingsJavaEmptyTitle => ("No Java runtime found", "未检测到 Java 运行时"), + SettingsJavaEmptyBody => ( + "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", + "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。" + ), + // ── 运行时列表 + SettingsJavaDefault => ("Default", "默认"), + SettingsJavaSetDefault => ("Use as default", "设为默认"), + SettingsJavaFits => ("Meets the current instance requirement", "满足当前实例的要求"), + SettingsJavaTooOld => ( + "Below the requirement: this instance needs Java {minimum}+", + "低于要求:当前实例需要 Java {minimum} 及以上" + ), + SettingsJavaDefaultSet => ("Default JDK is now {path}", "默认 JDK 已设为 {path}"), + SettingsJavaSourceManaged => ("installed by this launcher", "本启动器安装"), + SettingsJavaSourceEnv => ("JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"), + SettingsJavaSourcePath => ("on PATH", "PATH 上"), + SettingsJavaSourceSystem => ("system install", "系统安装"), + SettingsJavaSourceManual => ("added by you", "手动添加"), + // ── 手动添加 + SettingsJavaAdd => ("Add a JDK path", "手动添加 JDK 路径"), + SettingsJavaAddPlaceholder => ( + "C:\\Program Files\\Java\\jdk-21", + "例:C:\\Program Files\\Java\\jdk-21" + ), + SettingsJavaAddHint => ( + "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", + "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。" + ), + SettingsJavaAddAction => ("Add", "添加"), + SettingsJavaAdded => ("Added {path}", "已添加 {path}"), + // ── 内存 + SettingsJavaMemory => ("Default memory (MB)", "默认内存(MB)"), + SettingsJavaMemoryHint => ( + "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", + "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。" + ), + SettingsJavaLayerGlobal => ( + "In effect: {mb} MB, from the global default.", + "生效:{mb} MB,来自全局默认值。" + ), + SettingsJavaLayerInstance => ( + "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", + "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。" + ), + // ── JVM 参数 + SettingsJavaJvm => ("Extra JVM arguments", "额外 JVM 参数"), + SettingsJavaJvmPlaceholder => ("-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"), + SettingsJavaJvmHint => ( + "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", + "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。" + ), + SettingsJavaJvmSaved => ("Saved value: {value}", "已保存的值:{value}"), + SettingsJavaJvmSavedNone => ( + "No extra JVM arguments are saved yet.", + "还没有保存任何额外参数。" + ), + // ── 保存与回执 + SettingsJavaSave => ("Save", "保存"), + SettingsJavaSaved => ( + "Saved and read back from disk. The next launch uses the new value.", + "已保存,并从磁盘读回确认。下次启动就用新值。" + ), + SettingsJavaUnsaved => ("There are unsaved changes.", "有未保存的改动。"), + SettingsJavaRefused => ("Not saved: {reason}", "未保存:{reason}"), + SettingsJavaSkipNotDirty => ( + "there was nothing to change, so no file was written", + "没有任何改动,因此没有写文件" + ), + SettingsJavaSkipReadOnly => ( + "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", + "配置文件只读:它被更新版本的启动器写过,或被标记为不可信" + ), + SettingsJavaSkipUnreadable => ( + "the settings file is untrusted, so it is treated as read-only", + "配置文件不可信,因此按只读处理" + ), + SettingsJavaNoStorage => ( + "No settings file is available in this run, so nothing can be scanned or saved.", + "这一次运行没有可用的设置文件,扫描与保存都做不了。" + ), + // ── 拒绝原因(一条一句) + SettingsJavaReasonMemoryEmpty => ("the memory field is empty", "内存没填"), + SettingsJavaReasonMemoryNotNumber => ("the memory must be a whole number of MB", "内存必须是整数 MB"), + SettingsJavaReasonMemoryRange => ( + "the memory is outside the allowed range", + "内存超出允许范围" + ), + SettingsJavaReasonJvmQuotes => ( + "the JVM arguments have an unclosed quote", + "JVM 参数里有没配对的引号" + ), + SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"), + SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"), + SettingsJavaReasonAddNotJava => ( + "there is no java executable at that path", + "那个路径里没有 java 可执行文件" + ), + SettingsJavaReasonAddNoVersion => ( + "java -version did not answer there, so it is not a usable JDK", + "在那里跑不出 java -version,不是一份能用的 JDK" + ), + SettingsJavaReasonAddTooLong => ( + "the path is too long to be a JDK location", + "路径太长,不像是一个 JDK 位置" + ), + SettingsJavaReasonAddWriteFailed => ( + "the JDK is fine, but the list file could not be written (disk or permission)", + "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)" + ), + SettingsJavaReasonAddDuplicate => ("that JDK is already in the list", "这份 JDK 已经在列表里了"), + SettingsJavaReasonWriteFailed => ( + "the settings file could not be written (disk full, no permission, or the value was rejected)", + "设置写不进去(磁盘满、没权限,或这个值被拒绝)" + ), + } } }; } From f82bd395f8f2e221e26ae91b5d7ee1fcf3711a7e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 11:14:23 +0800 Subject: [PATCH 04/33] =?UTF-8?q?fix(msa):=20=E5=BE=AE=E8=BD=AF=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=BB=98=E8=AE=A4=E8=B5=B0=E8=AE=BE=E5=A4=87=E7=A0=81?= =?UTF-8?q?=EF=BC=8C=E6=B5=8F=E8=A7=88=E5=99=A8=E6=B5=81=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=80=89=E5=8A=A0=E9=80=9F=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方产物上点「微软登录」必失败:GUI 只接了授权码流(随机端口回环回调),而 Azure 应用 注册当初是为 device-code 建的,重定向 URI 一项都没登记,于是授权端点当场回 `invalid_request: ... 'redirect_uri' is not valid`,浏览器里只剩一张 AAD 原始错误页, 启动器干等 5 分钟超时。设备码流本身是好的(CLI `accounts add msa` 一直能用)。 - core:新增 `preflight`(回环重定向 URI 注册预检)与 `login`(浏览器优先 + 自动回退设备码)。 预检拿「即将使用的 redirect_uri」(端口不参与匹配,微软官方文档 *Localhost exceptions* 原文已引进模块文档)先问一次授权端点;没登记 / 问不出来就地跳过,**一个监听口都不开**。 预检通过才真绑端口走授权码;授权码失败(超时/被拒/绑不上)自动回退设备码并带上结构化 原因码;用户取消(`Canceled`)原样上抛,不偷偷换流。设备码是默认,零监听口。 - presenter:`start_login` = 设备码(默认);`start_browser_login` = 浏览器优先 + 回退, 回退原因码写进 `DeviceLoginView.browser_note`(原文只进日志)。 - i18n:三条回退文案(含「怎么修:把 http://localhost/auth 加进重定向 URI,端口不参与匹配」)。 - app-iced:登录面板主按钮改成设备码、浏览器降级为次按钮;重登也走设备码;设备码面板与失败 面板都能画回退说明。倒计时文案改由视图模型持有(借用渲染),顺带修掉中文倒计时整行不显示。 - 守卫(都能变红):①授权端点回 invalid_request 时确实回退设备码且原因是结构化码; ②未登记回环 / 默认设备码路 `listeners_opened == 0`;③回环登记了仍能走授权码(不焊死加速路); ④用户取消不被偷偷换流;⑤client id 仍只能构建期注入(扫 `auth/microsoft/` 源码无 UUID 字面量)。 --- crates/app-iced/src/app/update/account.rs | 22 +- .../app-iced/src/app/update/account/tests.rs | 4 +- crates/app-iced/src/pages/account.rs | 3 + crates/app-iced/src/pages/account/login.rs | 118 +++++--- crates/app-iced/src/pages/account/model.rs | 19 +- crates/app-iced/src/pages/account/panels.rs | 49 +++- crates/app-iced/src/pages/account/tests.rs | 13 +- .../app-iced/src/pages/account/tests_msa.rs | 87 ++++++ .../app-iced/src/pages/account/tests_text.rs | 27 +- crates/core/src/auth/microsoft/authcode.rs | 45 +++ crates/core/src/auth/microsoft/client_id.rs | 98 +++++++ crates/core/src/auth/microsoft/login.rs | 191 +++++++++++++ crates/core/src/auth/microsoft/mod.rs | 58 +++- crates/core/src/auth/microsoft/preflight.rs | 245 ++++++++++++++++ crates/core/tests/auth/main.rs | 2 + crates/core/tests/auth/microsoft_msalogin.rs | 267 ++++++++++++++++++ crates/i18n/src/catalog/account.rs | 59 ++++ crates/i18n/src/catalog/golden/tail2.rs | 3 + crates/presenter/src/dto/accounts.rs | 47 +-- crates/presenter/src/dto/accounts/failure.rs | 28 +- crates/presenter/src/dto/mod.rs | 4 +- crates/presenter/src/host/accounts.rs | 84 ++++-- crates/presenter/src/host/accounts/tests.rs | 31 ++ 23 files changed, 1373 insertions(+), 131 deletions(-) create mode 100644 crates/app-iced/src/pages/account/tests_msa.rs create mode 100644 crates/core/src/auth/microsoft/login.rs create mode 100644 crates/core/src/auth/microsoft/preflight.rs create mode 100644 crates/core/tests/auth/microsoft_msalogin.rs diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index c2a8467..b6f2f18 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -95,7 +95,7 @@ impl App { self.dispatch(AppCommand::AddAuthlibAccount(request)); } } - Panel::Microsoft => return self.on_account(Account::StartBrowser), + Panel::Microsoft => return self.on_account(Account::StartDevice), Panel::None => {} }, Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), @@ -136,7 +136,8 @@ impl App { match account.kind { AccountKind::Microsoft => { self.account_form.panel = Panel::Microsoft; - self.dispatch(AppCommand::StartBrowserLogin); + // M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。 + self.dispatch(AppCommand::StartDeviceLogin); } AccountKind::Authlib => { self.account_form.open(Track::Authlib); @@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) { app.state.device_login_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600)); } + // M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。 + // + // 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。 + // 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条 + // (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。 + "msa-fallback" => { + app.account_form.panel = crate::pages::account::Panel::Microsoft; + app.state.apply_device_login( + unlucky_presenter::dto::DeviceLoginView::waiting( + "ABCD-EFGH", + "https://www.microsoft.com/link", + 900, + "", + ) + .with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered), + ); + } // 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)` "authlib-failed" => { app.account_form.panel = crate::pages::account::Panel::Authlib; diff --git a/crates/app-iced/src/app/update/account/tests.rs b/crates/app-iced/src/app/update/account/tests.rs index d74492b..4d43d37 100644 --- a/crates/app-iced/src/app/update/account/tests.rs +++ b/crates/app-iced/src/app/update/account/tests.rs @@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() { let _ = app.on_account(Account::StartDevice); match &app.account.device { DevicePanel::Code { - code, remaining, .. + code, countdown, .. } => { assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); - assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); + assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)"); } other => panic!("进行中状态必须可见,实际 {other:?}"), } diff --git a/crates/app-iced/src/pages/account.rs b/crates/app-iced/src/pages/account.rs index a7ef912..d016ea6 100644 --- a/crates/app-iced/src/pages/account.rs +++ b/crates/app-iced/src/pages/account.rs @@ -38,6 +38,9 @@ pub mod widgets; #[cfg(test)] mod tests; #[cfg(test)] +#[path = "account/tests_msa.rs"] +mod tests_msa; +#[cfg(test)] #[path = "account/tests_text.rs"] mod tests_text; diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index 6d6c718..b967c34 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -4,7 +4,14 @@ //! //! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, //! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、 -//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 +//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 +//! +//! ## 文案一律**借用模型里的串**(M178) +//! +//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n), +//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`: +//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白), +//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。 use iced::widget::{column, row}; use iced::{Alignment, Element}; @@ -13,7 +20,7 @@ use unlucky_design::color::Role; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; -use unlucky_presenter::dto::LoginFailure; +use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure}; use crate::app::components::buttons::{self, Button}; use crate::pages::frame; @@ -36,34 +43,37 @@ pub fn panel<'a>( DevicePanel::Code { code, link, - remaining, + countdown, expired, - } => device_code(ui, code, link, *remaining, *expired, catalog), + note, + } => device_code(ui, code, link, countdown, *expired, *note, catalog), DevicePanel::Browser { link, - remaining, + countdown, expired, - } => browser(ui, link, *remaining, *expired, catalog), - DevicePanel::Failed { failure } => failed(ui, *failure, catalog), + } => browser(ui, link, countdown, *expired, catalog), + DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog), DevicePanel::Succeeded { name } => succeeded(ui, name, catalog), } } -/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。 +/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方 +/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`, +/// 没登记时在预检处直接回退设备码)。 fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { let body = column![row![ buttons::filled( ui, Button::new( - catalog.text(Key::AccountDeviceBrowser), - Account::StartBrowser.into_page(), + catalog.text(Key::AccountDeviceUseCode), + Account::StartDevice.into_page(), ) ), buttons::outlined( ui, Button::new( - catalog.text(Key::AccountDeviceUseCode), - Account::StartDevice.into_page(), + catalog.text(Key::AccountDeviceBrowser), + Account::StartBrowser.into_page(), ) ), buttons::text( @@ -105,13 +115,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { frame::card(ui, body) } -/// 设备码等待态:**码 + 链接 + 倒计时**。 +/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。 fn device_code<'a>( ui: &'a Ui, code: &'a str, link: &'a str, - remaining: Option, + countdown: &'a str, expired: bool, + note: Option, catalog: Catalog, ) -> Element<'a, PageMessage> { let mut actions = row![ @@ -150,9 +161,7 @@ fn device_code<'a>( ), )); - let countdown = countdown_line(ui, remaining, expired, catalog); - - let body = column![ + let mut body = column![ frame::text_role( ui, TypeRole::LabelMedium, @@ -174,7 +183,7 @@ fn device_code<'a>( link, ui.color(Role::OnSurfaceVariant), ), - countdown, + countdown_line(ui, countdown, expired), frame::text_role( ui, TypeRole::BodySmall, @@ -182,17 +191,21 @@ fn device_code<'a>( catalog.text(Key::AccountDeviceHint), ui.color(Role::OnSurfaceVariant), ), - actions, ] .spacing(ui.space(Space::X3)); - frame::card(ui, body) + // 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」, + // 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。 + if let Some(text) = note_text(ui, note, catalog) { + body = body.push(text); + } + frame::card(ui, body.push(actions)) } /// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。 fn browser<'a>( ui: &'a Ui, link: &'a str, - remaining: Option, + countdown: &'a str, expired: bool, catalog: Catalog, ) -> Element<'a, PageMessage> { @@ -237,7 +250,7 @@ fn browser<'a>( link, ui.color(Role::OnSurfaceVariant), ), - countdown_line(ui, remaining, expired, catalog), + countdown_line(ui, countdown, expired), actions, ] .spacing(ui.space(Space::X3)); @@ -245,29 +258,13 @@ fn browser<'a>( } /// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。 -fn countdown_line<'a>( - ui: &'a Ui, - remaining: Option, - expired: bool, - catalog: Catalog, -) -> Element<'a, PageMessage> { +/// +/// 文案由模型拼好(`panels::device_panel` 用 catalog 渲染 `{time}`),这里只决定颜色与强调。 +fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> { if expired { - return frame::text_role( - ui, - TypeRole::LabelMedium, - true, - catalog.text(Key::AccountDeviceExpired), - ui.color(Role::Error), - ); + return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error)); } - let text = remaining.map_or_else(String::new, |secs| { - // mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式 - // (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。 - let clock = format!("{:02}:{:02}", secs / 60, secs % 60); - catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]) - }); - // 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`) - frame::text_role_owned( + frame::text_role( ui, TypeRole::LabelMedium, false, @@ -276,16 +273,43 @@ fn countdown_line<'a>( ) } +/// 回退说明那一行(`None` = 没有这回事)。 +fn note_text<'a>( + ui: &'a Ui, + note: Option, + catalog: Catalog, +) -> Option> { + let note = note?; + // 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。 + Some(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(super::model::note_key(note)), + ui.color(Role::OnSurfaceVariant), + )) +} + /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// -/// 三点修订叠在这里: +/// 四点修订叠在这里: /// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; /// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, /// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); /// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 -/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。 -fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> { - let body = catalog.text(failure_body_key(failure)).to_string(); +/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错; +/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。 +fn failed<'a>( + ui: &'a Ui, + failure: LoginFailure, + note: Option, + catalog: Catalog, +) -> Element<'a, PageMessage> { + let mut body = catalog.text(failure_body_key(failure)).to_string(); + if let Some(note) = note { + body.push_str("\n\n"); + body.push_str(catalog.text(super::model::note_key(note))); + } let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); let mut actions: Vec> = Vec::new(); if !matches!(failure, LoginFailure::MsaNotConfigured) { diff --git a/crates/app-iced/src/pages/account/model.rs b/crates/app-iced/src/pages/account/model.rs index 03d6eea..36d21eb 100644 --- a/crates/app-iced/src/pages/account/model.rs +++ b/crates/app-iced/src/pages/account/model.rs @@ -7,7 +7,9 @@ use iced::widget::image; use unlucky_i18n::{Key, Lang}; -use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView}; +use unlucky_presenter::dto::{ + AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote, +}; use unlucky_presenter::state::AppState; use super::avatar::{Avatar, Cache}; @@ -164,6 +166,18 @@ impl Form { } } +/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。 +/// +/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。 +#[must_use] +pub const fn note_key(note: BrowserLoginNote) -> Key { + match note { + BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered, + BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified, + BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed, + } +} + /// 列表行 / 当前账户卡片用的数据。 #[derive(Debug, Clone, PartialEq)] pub struct Row { @@ -253,7 +267,8 @@ impl Page { .map(|account| row_of(account, avatars, lang)) .collect(); let active = rows.iter().position(|row| row.active); - let device = device_panel(&state.device_login, state.device_code_remaining()); + let remaining = state.device_code_remaining(); + let device = device_panel(&state.device_login, remaining, lang.catalog()); // 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、 // 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。 let panel = if matches!(device, DevicePanel::Hidden) { diff --git a/crates/app-iced/src/pages/account/panels.rs b/crates/app-iced/src/pages/account/panels.rs index 5feb6b9..b956329 100644 --- a/crates/app-iced/src/pages/account/panels.rs +++ b/crates/app-iced/src/pages/account/panels.rs @@ -10,7 +10,10 @@ //! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 //! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。 -use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure}; +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure, +}; /// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,17 +28,23 @@ pub enum DevicePanel { code: String, /// 验证链接 link: String, - /// 剩余秒数 - remaining: Option, - /// 是否已过期 + /// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」) + /// + /// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径 + /// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、 + /// 视图用 `&'a str` 借用,两边都不依赖那条路径。 + countdown: String, + /// 是否已过期(决定颜色与「重试」按钮) expired: bool, + /// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事) + note: Option, }, /// 授权码流:等浏览器回调 Browser { /// 授权链接(可复制) link: String, - /// 剩余秒数 - remaining: Option, + /// 倒计时那行已渲染好的文案(同上) + countdown: String, /// 是否已过期 expired: bool, }, @@ -43,6 +52,8 @@ pub enum DevicePanel { Failed { /// 原因码(页面据此选 i18n) failure: LoginFailure, + /// 回退说明(浏览器登录没走成之后设备码也失败了;M178) + note: Option, }, /// 成功 Succeeded { @@ -76,8 +87,13 @@ pub enum AuthlibPanel { } /// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。 -pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> DevicePanel { +pub(super) fn device_panel( + view: &DeviceLoginView, + remaining: Option, + catalog: Catalog, +) -> DevicePanel { let expired = remaining == Some(0); + let countdown = countdown_text(remaining, expired, catalog); match view.phase { DeviceLoginPhase::Idle => DevicePanel::Hidden, DeviceLoginPhase::Requesting => DevicePanel::Requesting, @@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De if view.user_code.trim().is_empty() { DevicePanel::Browser { link: view.verification_uri.clone(), - remaining, + countdown, expired, } } else { DevicePanel::Code { code: view.user_code.clone(), link: view.qr_link.clone(), - remaining, + countdown, expired, + note: view.browser_note, } } } DeviceLoginPhase::Failed => DevicePanel::Failed { failure: view.failure.unwrap_or(LoginFailure::Other), + note: view.browser_note, }, DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { name: view.account_name.clone(), @@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De } } +/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。 +/// +/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。 +fn countdown_text(remaining: Option, expired: bool, catalog: Catalog) -> String { + if expired { + return catalog.text(Key::AccountDeviceExpired).to_string(); + } + remaining.map_or_else(String::new, |secs| { + let clock = format!("{:02}:{:02}", secs / 60, secs % 60); + catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]) + }) +} + /// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。 pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) { match view.phase { diff --git a/crates/app-iced/src/pages/account/tests.rs b/crates/app-iced/src/pages/account/tests.rs index 92b0651..70d6b91 100644 --- a/crates/app-iced/src/pages/account/tests.rs +++ b/crates/app-iced/src/pages/account/tests.rs @@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView { } } -fn build(state: &AppState, form: &Form) -> Page { +pub(super) fn build(state: &AppState, form: &Form) -> Page { // 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。 // 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。 Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0) @@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() { match &page.device { DevicePanel::Code { code, - remaining, + countdown, expired, .. } => { assert_eq!(code, "ABCD-EFGH"); - assert_eq!(*remaining, Some(900)); + assert!( + countdown.contains("15:00"), + "倒计时要画成 mm:ss:{countdown}" + ); assert!(!expired); } other => panic!("等待态应该是设备码面板,实际 {other:?}"), @@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() { let page = build(&state, &Form::new()); match &page.device { DevicePanel::Code { - remaining, expired, .. + countdown, expired, .. } => { - assert_eq!(*remaining, Some(0)); assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)"); + assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行"); } other => panic!("过期态仍是设备码面板,实际 {other:?}"), } diff --git a/crates/app-iced/src/pages/account/tests_msa.rs b/crates/app-iced/src/pages/account/tests_msa.rs new file mode 100644 index 0000000..c5f4987 --- /dev/null +++ b/crates/app-iced/src/pages/account/tests_msa.rs @@ -0,0 +1,87 @@ +//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。 +//! +//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是 +//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口) +//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。 + +use unlucky_presenter::dto::{ + AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure, +}; +use unlucky_presenter::state::AppState; + +use super::model::{DevicePanel, Form}; +use super::tests::build; +use super::tests_text::{has_cjk, on_screen}; + +/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的** +/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。 +/// +/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么 +/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。 +/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。 +#[test] +fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged( + DeviceLoginView { + message: "invalid_request: The provided value for the input parameter 'redirect_uri' \ +is not valid. The expected value is a URI which matches a redirect URI registered for this client \ +application." + .to_string(), + ..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "") + } + .with_browser_note(BrowserLoginNote::RedirectNotRegistered), + ))); + let page = build(&state, &Form::new()); + + // 1. 面板态带着说明码(不是只剩一个用户码) + match &page.device { + DevicePanel::Code { note, code, .. } => { + assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered)); + assert_eq!(code, "ABCD-EFGH"); + } + other => panic!("回退后仍是设备码面板,实际 {other:?}"), + } + + // 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文 + let text = on_screen(&page); + assert!(!has_cjk(&text), "英文界面不许出现中文:{text}"); + assert!( + text.contains("http://localhost/auth"), + "提示里要给出可照抄的重定向 URI:{text}" + ); + assert!( + text.to_lowercase().contains("redirect uri"), + "提示里要点名「重定向 URI」这一项:{text}" + ); + assert!( + !text.contains("invalid_request"), + "原始报文不许上屏:{text}" + ); + assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}"); + assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}"); +} + +/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。 +#[test] +fn a_failed_device_login_keeps_the_fallback_note() { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged( + DeviceLoginView::failed_as( + LoginFailure::Unreachable, + "连不上 login.microsoftonline.com", + ) + .with_browser_note(BrowserLoginNote::PreflightUnavailable), + ))); + let page = build(&state, &Form::new()); + match &page.device { + DevicePanel::Failed { failure, note } => { + assert_eq!(*failure, LoginFailure::Unreachable); + assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable)); + } + other => panic!("应是失败面板,实际 {other:?}"), + } + let text = on_screen(&page); + assert!(!has_cjk(&text), "{text}"); + assert!(text.contains("unreachable"), "失败码要画出来:{text}"); +} diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index d5fbd57..5e73a7a 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -60,23 +60,32 @@ pub(super) fn on_screen(page: &Page) -> String { DevicePanel::Code { code, link, - remaining, - expired, + countdown, + note, + .. } => { out.push_str(code); out.push_str(link); - if *expired { - out.push_str(catalog.text(Key::AccountDeviceExpired)); - } else if let Some(secs) = remaining { - let clock = format!("{:02}:{:02}", secs / 60, secs % 60); - out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])); + // 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。 + out.push_str(countdown); + // M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。 + if let Some(note) = note { + out.push_str(catalog.text(super::model::note_key(*note))); } } - DevicePanel::Browser { link, .. } => out.push_str(link), - DevicePanel::Failed { failure } => { + DevicePanel::Browser { + link, countdown, .. + } => { + out.push_str(link); + out.push_str(countdown); + } + DevicePanel::Failed { failure, note } => { out.push_str(catalog.text(failure_title_key(*failure))); out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); + if let Some(note) = note { + out.push_str(catalog.text(super::model::note_key(*note))); + } } DevicePanel::Succeeded { name } => { out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)])); diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs index 6757829..16ce8f5 100644 --- a/crates/core/src/auth/microsoft/authcode.rs +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -29,7 +29,9 @@ use super::chain::complete_chain; use super::endpoints::MicrosoftEndpoints; use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE}; use super::now_ms; +use super::preflight::{self, RedirectRegistration}; use super::session::MicrosoftSession; +use super::{BrowserFallback, BrowserFallbackReason}; /// 授权码 grant 类型。 pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code"; @@ -194,6 +196,49 @@ where complete_chain(client, endpoints, tokens, now_ms()).await } +/// 一次「浏览器优先」尝试的结果(M178)。 +pub(crate) enum AuthCodeAttempt { + /// 授权码流走通(凭据已换到,整链完成)。 + LoggedIn(MicrosoftSession), + /// **没去试**:预检说这个回环 URI 没登记(或问不出来)。监听端口一次都没开。 + Skipped(BrowserFallback), +} + +/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。 +/// +/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(守卫断言 +/// [`super::callback::bind_attempts`] 不涨),也就不会出现「浏览器里一张 AAD 错误页 + +/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。 +pub(crate) async fn login_checked( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + present: F, +) -> AuthResult +where + F: FnOnce(&AuthCodePrompt), +{ + let probe = RedirectUri::new(preflight::PROBE_PORT); + match preflight::check(client, endpoints, client_id, &probe).await { + RedirectRegistration::Registered => {} + RedirectRegistration::NotRegistered { detail } => { + return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new( + BrowserFallbackReason::RedirectNotRegistered, + detail, + ))) + } + RedirectRegistration::Unknown { detail } => { + return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new( + BrowserFallbackReason::PreflightUnavailable, + detail, + ))) + } + } + let session = login(client, endpoints, client_id, policy, present).await?; + Ok(AuthCodeAttempt::LoggedIn(session)) +} + /// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。 /// /// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少 diff --git a/crates/core/src/auth/microsoft/client_id.rs b/crates/core/src/auth/microsoft/client_id.rs index 9a5a648..a8ebda7 100644 --- a/crates/core/src/auth/microsoft/client_id.rs +++ b/crates/core/src/auth/microsoft/client_id.rs @@ -252,4 +252,102 @@ mod tests { assert!(resolve_client_id().is_none()); } } + + /// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。 + /// + /// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。 + /// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的 + /// `packaging/check_release_semantics.py` 与 Actions 日志看)。 + #[test] + fn no_client_id_is_hardcoded_in_the_microsoft_sources() { + // 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。 + let manifest = env!("CARGO_MANIFEST_DIR"); + let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs")) + .expect("读 client_id.rs"); + assert!( + source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"), + "client id 的构建期注入点不见了" + ); + // 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量 + // (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。 + // 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的 + // 测试向量,与 client id 无关。 + let mut offenders = Vec::new(); + let mut stack = vec![format!("{manifest}/src/auth/microsoft")]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path.display().to_string()); + continue; + } + if path.extension().is_some_and(|ext| ext == "rs") { + let text = std::fs::read_to_string(&path).unwrap_or_default(); + if contains_uuid_literal(&text) { + offenders.push(path.display().to_string()); + } + } + } + } + assert!( + offenders.is_empty(), + "这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}" + ); + } + + /// 源码里有没有 `8-4-4-4-12` 的十六进制字面量(Azure 客户端 id 的形状)。 + fn contains_uuid_literal(text: &str) -> bool { + fn hex(value: &[u8]) -> bool { + !value.is_empty() && value.iter().all(u8::is_ascii_hexdigit) + } + let bytes = text.as_bytes(); + let dashes: Vec = bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'-') + .map(|(index, _)| index) + .collect(); + dashes.windows(4).any(|window| { + let (first, last) = (window[0], window[3]); + let gaps = [ + window[1] - first, + window[2] - window[1], + window[3] - window[2], + ]; + // 4-4-4-12 的段间距(连字符本身没算进 gap):5-5-5-13。 + if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 { + return false; + } + hex(&bytes[first - 8..first]) + && hex(&bytes[first + 1..window[1]]) + && hex(&bytes[window[1] + 1..window[2]]) + && hex(&bytes[window[2] + 1..last]) + && hex(&bytes[last + 1..last + 13]) + }) + } + + /// 判据自检:`contains_uuid_literal` 认得出 UUID,也不会把普通代码当 UUID。 + /// + /// 没有这条,「源码里没有 UUID」可能因为判据本身坏掉而永远为真(假绿)。 + /// 样本**运行时拼**:字面量写在这里会被上面的扫描抓成 offender(自指陷阱)。 + #[test] + fn the_uuid_literal_detector_bites() { + let sample = format!( + "\"{}-{}-{}-{}-{}\"", + "83fd9b2f", "da89", "41f7", "b41b", "13742a73841c" + ); + assert!(contains_uuid_literal(&sample), "{sample}"); + assert!(!contains_uuid_literal("// 0000-test-client")); + assert!(!contains_uuid_literal("a-b-c-d")); + assert!(!contains_uuid_literal( + "http://localhost/auth?client_id=not-a-uuid" + )); + // 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。 + assert!(!contains_uuid_literal( + "abcdefgh-1234-5678-9012-12345678901" + )); + } } diff --git a/crates/core/src/auth/microsoft/login.rs b/crates/core/src/auth/microsoft/login.rs new file mode 100644 index 0000000..d474e2e --- /dev/null +++ b/crates/core/src/auth/microsoft/login.rs @@ -0,0 +1,191 @@ +//! 「浏览器优先、失败自动回退设备码」(M178)——GUI 那条可选加速路的编排。 +//! +//! ## 为什么这条流必须自己会退 +//! +//! 授权码(浏览器)流把用户送回 `http://localhost:{port}/auth`。随机端口只有在「回环 URI +//! 的端口不参与匹配」成立时才可能被接受——微软官方文档 +//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url), +//! *Localhost exceptions*)原文: +//! +//! > "Due to ephemeral port ranges often required by native applications, the port component +//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI." +//! +//! 也就是说**注册一次 `http://localhost/auth` 就能接住任意端口**;但本项目这个注册当初是为 +//! device-code 建的,重定向 URI 一项都没登记(`DevDocs/ops/azure-msa-app.md`)。没登记时 +//! 授权端点当场回 `invalid_request ... 'redirect_uri' is not valid`,**而且不会**再跳回本地 +//! 回调——用户只看到一张 AAD 原始错误页,启动器干等到超时。这正是 2026-11-08 官方产物上 +//! 「微软登录整体不可用」的根因。 +//! +//! 于是这里的分工是:设备码当默认(不依赖任何注册项),浏览器流只在**用户显式要求**时试, +//! 而且试之前先预检([`super::preflight`]):没登记就一个监听口都不开。 +//! +//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**) +//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。 + +use crate::auth::error::{AuthError, AuthResult}; + +use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt}; +use super::chain; +use super::flow::{self, DeviceCodePrompt}; +use super::now_ms; +use super::preflight; +use super::session::MicrosoftSession; +use super::MicrosoftAuth; + +/// 「浏览器优先但没走成」的原因(**上屏只看码**,`detail` 只进日志)。 +/// +/// 三个码对应三种不同的下一步: +/// - [`BrowserFallbackReason::RedirectNotRegistered`]:应用注册里没登记回环重定向 URI。 +/// 修法是**具体可执行的**(把 `http://localhost/auth` 加进「重定向 URI」,端口不参与匹配)。 +/// - [`BrowserFallbackReason::PreflightUnavailable`]:没法确认(网络/超时),只能先用设备码。 +/// - [`BrowserFallbackReason::AttemptFailed`]:试过了但失败(超时、换 token 被拒…)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserFallbackReason { + /// 授权端点当场拒绝了这个回环 `redirect_uri`(`invalid_request` / `AADSTS50011`) + RedirectNotRegistered, + /// 预检本身没问出结论(网络/超时/非预期应答) + PreflightUnavailable, + /// 预检通过、但授权码流仍然没走通 + AttemptFailed, +} + +impl BrowserFallbackReason { + /// 稳定的 ascii 码(日志 / 页面「Code: …」/ 测试断言都用它)。 + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::RedirectNotRegistered => "redirect_not_registered", + Self::PreflightUnavailable => "preflight_unavailable", + Self::AttemptFailed => "attempt_failed", + } + } +} + +/// 回退到设备码时的说明:原因码 + 原始诊断(诊断只进日志,不上屏)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserFallback { + /// 原因码 + pub reason: BrowserFallbackReason, + /// 原始诊断(**只进日志**;截断到 240 字符) + pub detail: String, +} + +impl BrowserFallback { + /// 构造(诊断串截断:只进日志,别把整张错误页灌进去)。 + #[must_use] + pub fn new(reason: BrowserFallbackReason, detail: impl Into) -> Self { + let detail = detail.into(); + let detail = match detail.char_indices().nth(240) { + Some((index, _)) => format!("{}…", &detail[..index]), + None => detail, + }; + Self { reason, detail } + } +} + +/// 一次「浏览器优先」登录的完整结果(M178)。 +/// +/// `listeners_opened` 是**这次登录**为本地回调开的监听口个数(0 或 1)。它存在的唯一理由是 +/// 守卫能直接数:「没登记回环」「走设备码」这两条路上必须是 0——用进程级全局计数器做不到 +/// 这一点(并行跑的测试会互相搅动),所以计数跟着结果走。 +#[derive(Debug, Clone)] +pub struct LoginOutcome { + /// 拿到的会话(两条流任意一条走通) + pub session: MicrosoftSession, + /// 回退说明(`None` = 没走浏览器 / 浏览器走通了) + pub browser: Option, + /// 这次登录一共开了几个本地监听口(设备码路恒 0) + pub listeners_opened: usize, +} + +impl LoginOutcome { + /// 浏览器(授权码)流是不是被跳过了。 + #[must_use] + pub fn browser_was_skipped(&self) -> bool { + self.browser.is_some() + } +} + +impl MicrosoftAuth { + /// 浏览器优先、失败自动回退设备码(M178:GUI 的「用浏览器登录」入口)。 + /// + /// 顺序与判据: + /// 1. 先预检回环注册([`authcode::login_checked`])。没登记 / 问不出来 → **不绑端口**、 + /// 不弹浏览器,带一条结构化说明直接进设备码; + /// 2. 预检通过 → 真走授权码流(绑端口 + 弹浏览器); + /// 3. 授权码流失败(超时 / 换 token 被拒 / 端口绑不上)→ 同样回退设备码, + /// 说明码是 [`BrowserFallbackReason::AttemptFailed`](拒绝原因若属于「重定向 URI 没登记」 + /// 则归一成 [`BrowserFallbackReason::RedirectNotRegistered`],好让用户看到怎么修); + /// 4. **用户自己取消不算失败**([`AuthError::Canceled`] 原样上抛):那是明确意图, + /// 不能偷偷换一条流继续问用户要码。 + /// + /// `present_device` 额外收到回退说明(`None` = 根本没走浏览器),宿主据此在设备码面板上 + /// 画一句「为什么改用设备码 + 怎么修」。 + /// + /// # Errors + /// + /// 用户取消、设备码流本身的失败(网络/超时/被拒/无拥有权/无档案),见 [`AuthError`]。 + pub async fn browser_then_device_login( + &self, + present_browser: FB, + mut present_device: FD, + ) -> AuthResult + where + FB: FnOnce(&AuthCodePrompt), + FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>), + { + let mut fallback: Option = None; + // 预检通过之后才会绑端口(`login_checked` 的顺序),所以只有「真去试了」才记 1。 + let mut bound_listener = false; + if self.browser_first { + match authcode::login_checked( + &self.client, + &self.endpoints, + &self.client_id, + self.authcode.clone(), + present_browser, + ) + .await + { + Ok(AuthCodeAttempt::LoggedIn(session)) => { + return Ok(LoginOutcome { + session, + browser: None, + listeners_opened: 1, + }) + } + // 跳过:**没绑端口**(预检在绑定之前),所以计数仍是 0。 + Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note), + Err(AuthError::Canceled) => return Err(AuthError::Canceled), + Err(error) => { + bound_listener = true; + self.record_failure("authcode_login", &error); + let reason = if preflight::error_means_unregistered(&error) { + BrowserFallbackReason::RedirectNotRegistered + } else { + BrowserFallbackReason::AttemptFailed + }; + fallback = Some(BrowserFallback::new(reason, error.to_string())); + } + } + } + let grant = + flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?; + present_device(&grant.prompt, fallback.as_ref()); + let tokens = flow::poll_device_token( + &self.client, + &self.endpoints, + &self.client_id, + &grant, + self.poll, + ) + .await?; + let session = + chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?; + Ok(LoginOutcome { + session, + browser: fallback, + listeners_opened: usize::from(bound_listener), + }) + } +} diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs index ec8c3af..a3e8753 100644 --- a/crates/core/src/auth/microsoft/mod.rs +++ b/crates/core/src/auth/microsoft/mod.rs @@ -1,29 +1,39 @@ //! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义 //! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。 //! -//! 分六块,各管一件事: //! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); -//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); -//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); -//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token); +//! - [`endpoints`] + [`flow`]:端点表与 device-code 发起 / 轮询 / refresh(OAuth 层); +//! - [`authcode`] + [`callback`] + [`preflight`] + [`login`]:授权码流(授权 URL + 本地回调 + +//! 回环注册预检 + 回退设备码的编排); //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! //! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`], //! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。 +//! # 默认走哪条(M178) +//! +//! **设备码是默认**,授权码是可选加速路:授权码流要求应用注册里登记回环重定向 URI +//! (`http://localhost/auth`,端口由微软忽略),而本项目的注册当初只为 device-code 而建。 +//! 没登记时授权端点当场拒掉且**不**把浏览器送回本地回调,用户只看到 +//! `invalid_request ... 'redirect_uri' is not valid` 那张 AAD 原始错误页。所以: +//! +//! | 入口 | 走什么 | +//! |---|---| +//! | [`MicrosoftAuth::device_code_login`] | 设备码,**一个监听口都不开** | +//! | [`MicrosoftAuth::browser_then_device_login`] | 先预检回环注册:没登记就直接设备码;登记了走授权码,失败回退设备码 | +//! | [`MicrosoftAuth::authcode_login`] | 强制授权码(测试/排障用,不做预检) | //! //! # client id 从哪来 //! -//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、 -//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的 -//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 -//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 +//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(租户必须是 `consumers`)。 +//! 授权码流额外要求注册里登记过回环重定向 URI(`http://localhost/auth`,端口不参与匹配; +//! 见 [`login`] 引的微软官方原文)。id 与 HMCL 同款模型:二进制里不硬编码, +//! 改由 `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是 //! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])—— //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 //! -//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在 -//! [`client_id`],本模块只消费结论。 +//! 措辞纪律(M117):id 缺失是**没配置**不是「本期未支持」,判定与启动日志都在 [`client_id`]。 pub mod authcode; pub mod callback; @@ -31,11 +41,15 @@ pub mod chain; pub mod client_id; pub mod endpoints; pub mod flow; +pub mod login; +pub mod preflight; pub mod session; pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri}; pub use callback::CallbackServer; +// 回环重定向 URI 的注册预检(M178):`RedirectRegistration` 是宿主排障时唯一要看的结论。 +pub use preflight::{RedirectRegistration, PROBE_PORT}; // client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认 // `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。 pub use client_id::{ @@ -44,6 +58,7 @@ pub use client_id::{ }; pub use endpoints::MicrosoftEndpoints; pub use flow::{DeviceCodePrompt, PollPolicy}; +pub use login::{BrowserFallback, BrowserFallbackReason, LoginOutcome}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -63,6 +78,7 @@ pub struct MicrosoftAuth { endpoints: MicrosoftEndpoints, poll: PollPolicy, authcode: AuthCodePolicy, + browser_first: bool, journal: Option, } @@ -81,6 +97,7 @@ impl MicrosoftAuth { endpoints: MicrosoftEndpoints::consumers(), poll: PollPolicy::default(), authcode: AuthCodePolicy::default(), + browser_first: false, journal: None, }) } @@ -155,6 +172,22 @@ impl MicrosoftAuth { self } + /// 「浏览器优先」(M178):授权码流只是**可选加速路**,默认不走。 + /// + /// 只有宿主确认用户显式要求时才置位(GUI 的「用浏览器登录」按钮)。置位后仍然先做 + /// 回环注册预检([`preflight`]):没登记就**不绑端口、不弹浏览器**,直接设备码。 + #[must_use] + pub fn with_browser_first(mut self, enabled: bool) -> Self { + self.browser_first = enabled; + self + } + + /// 是否允许尝试浏览器(授权码)流。 + #[must_use] + pub fn browser_first(&self) -> bool { + self.browser_first + } + /// 当前端点表。 #[must_use] pub fn endpoints(&self) -> &MicrosoftEndpoints { @@ -354,13 +387,14 @@ mod tests { .endpoints() .profile .contains("api.minecraftservices.com")); + // 默认**不是**浏览器优先(M178:设备码才是默认路)。 + assert!(!auth.browser_first()); } #[test] fn now_ms_is_sane() { // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 let now = now_ms(); - assert!(now > 1_577_836_800_000); - assert!(now < 4_102_444_800_000); + assert!(now > 1_577_836_800_000 && now < 4_102_444_800_000); } } diff --git a/crates/core/src/auth/microsoft/preflight.rs b/crates/core/src/auth/microsoft/preflight.rs new file mode 100644 index 0000000..dac1e6c --- /dev/null +++ b/crates/core/src/auth/microsoft/preflight.rs @@ -0,0 +1,245 @@ +//! 授权码流的「回环重定向 URI 注册预检」(M178)。 +//! +//! ## 为什么需要预检 +//! +//! 授权码流把浏览器送回 `http://localhost:{port}/auth`。**随机端口**只有在「回环 URI 的 +//! 端口不参与匹配」这件事成立时才可能被接受——微软官方文档写得很清楚 +//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url), +//! 小节 *Localhost exceptions*): +//! +//! > "Due to ephemeral port ranges often required by native applications, the port component +//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI. +//! > As a result, all of these URIs are considered equivalent: `http://localhost/MyApp`, +//! > `http://localhost:1234/MyApp`, `http://localhost:5000/MyApp`, `http://localhost:8080/MyApp`" +//! +//! 换句话说:**注册一次 `http://localhost/auth` 就能接住任意端口**;但**一次都没注册**时, +//! 任何端口的回环 URI 都会被授权端点当场拒掉(`invalid_request: ... 'redirect_uri' is not valid`), +//! 而且**不会**再跳回本地回调——用户只在浏览器里看到一张 AAD 错误页,启动器只能干等超时。 +//! 本项目的 Azure 应用注册当初是为 device-code 建的,重定向 URI 一项都没登记 +//! (`DevDocs/ops/azure-msa-app.md`),所以这条流在官方产物上必然失败。 +//! +//! ## 预检做什么、不做什么 +//! +//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状) +//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。 +//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上 +//! 监听口一次都不开(守卫断言 `callback::bind_attempts()` 不涨)。 +//! +//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。 +//! `Unknown`(网络问题)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。 + +use reqwest::Client; + +use crate::auth::error::AuthError; + +use super::authcode::{authorization_url, RedirectUri}; +use super::endpoints::MicrosoftEndpoints; + +/// 预检用的回环端口。 +/// +/// **不是要绑它**:端口对回环匹配不参与(见模块文档引的那段官方原文),所以随便一个 +/// 「看起来像临时端口」的值就能代表真实绑定后的那个。固定值让日志与测试可复现。 +pub const PROBE_PORT: u16 = 53_691; + +/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。 +const PROBE_STATE: &str = "ull-redirect-probe"; + +/// 授权端点对 `redirect_uri` 的态度。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RedirectRegistration { + /// 注册里有这个回环 URI(端口随意)→ 可以真的走授权码流。 + Registered, + /// 明确没登记(`invalid_request` / `AADSTS50011`)→ 别试,直接设备码。 + NotRegistered { + /// 诊断原文(只进日志) + detail: String, + }, + /// 问不出来(网络/超时/非预期应答)→ 同样不试,但原因是「没确认」而不是「没配」。 + Unknown { + /// 诊断原文(只进日志) + detail: String, + }, +} + +impl RedirectRegistration { + /// 是否值得继续走授权码流。 + #[must_use] + pub fn is_registered(&self) -> bool { + matches!(self, Self::Registered) + } +} + +/// 预检:注册里有没有这个回环重定向 URI。 +/// +/// 判据(顺序即优先级): +/// 1. 应答里出现 `invalid_request` / `AADSTS50011` / `redirect_uri is not valid` 一类字样 → 没登记; +/// 2. 非 2xx/3xx → 按「没登记」处理(AAD 对不合法的 redirect_uri 直接渲染错误页,状态码 400); +/// 3. 其余 2xx/3xx 且没有拒绝字样 → 认为已登记(那就是登录页本身); +/// 4. 连不上 → `Unknown`。 +/// +/// 阅读提示:这条判据**宁可错杀**——错过一次浏览器登录只是慢一步,误放行会让用户撞上 +/// AAD 的原始错误页,而那正是本 mission 要消灭的现象。 +pub(super) async fn check( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + redirect_uri: &RedirectUri, +) -> RedirectRegistration { + let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE); + let response = client.get(&url).send().await; + let response = match response { + Ok(response) => response, + Err(error) => { + return RedirectRegistration::Unknown { + detail: format!("预检请求失败:{error}"), + } + } + }; + let status = response.status().as_u16(); + // 登录页可能带上几十上百 KB 的脚本;拒绝页只有一小段。读整页是为了抓正文里的错误码, + // reqwest 的 `text()` 会把正文一次读尽(授权页不含任何凭据,不需要脱敏)。 + let body = response.text().await.unwrap_or_default(); + if rejects_redirect_uri(&body) { + return RedirectRegistration::NotRegistered { + detail: format!("HTTP {status}:{}", snippet(&body)), + }; + } + if !(200..400).contains(&status) { + return RedirectRegistration::NotRegistered { + detail: format!("HTTP {status}:{}", snippet(&body)), + }; + } + RedirectRegistration::Registered +} + +/// 这个错误是不是「重定向 URI 没登记」那一类(`AuthError` 侧重判,回调/换 token 用)。 +/// +/// 与 [`rejects_redirect_uri`] 同源:一个看页面正文,一个看结构化错误。两者都覆盖 +/// `invalid_request` 与 `AADSTS50011`,因为同一件事在两条流上的形状不一样。 +#[must_use] +pub(super) fn error_means_unregistered(error: &AuthError) -> bool { + match error { + AuthError::Denied { code, description } => { + is_redirect_code(code) || redirects_rejected(description) + } + AuthError::Response(detail) => { + redirects_rejected(detail.server_message.as_deref().unwrap_or_default()) + || redirects_rejected(detail.digest.as_str()) + } + _ => false, + } +} + +fn is_redirect_code(code: &str) -> bool { + matches!( + code, + "invalid_request" + | "invalid_redirect_uri" + | "redirect_uri_mismatch" + | "unauthorized_client" + ) +} + +fn redirects_rejected(text: &str) -> bool { + let lowered = text.to_lowercase(); + lowered.contains("aadsts50011") + || lowered.contains("invalid_request") + || lowered.contains("redirect_uri is not valid") + || lowered.contains("redirect_uri+is+not+valid") + || lowered.contains("reply url specified in the request does not match") + || (lowered.contains("redirect") && lowered.contains("not valid")) +} + +/// 页面正文里有没有「重定向 URI 不合法」的拒绝标记。 +fn rejects_redirect_uri(body: &str) -> bool { + let lowered = body.to_lowercase(); + lowered.contains("aadsts50011") + || lowered.contains("invalid_request") + || lowered.contains("redirect_uri is not valid") + || lowered.contains("redirect_uri+is+not+valid") + || (lowered.contains("redirect") && lowered.contains("not valid")) +} + +/// 正文摘要(单行、截断):只进日志,不上屏。 +fn snippet(body: &str) -> String { + let one_line: String = body + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let collapsed = one_line.split_whitespace().collect::>().join(" "); + match collapsed.char_indices().nth(200) { + Some((index, _)) => format!("{}…", &collapsed[..index]), + None => collapsed, + } +} + +#[cfg(test)] +mod tests { + use super::{error_means_unregistered, rejects_redirect_uri, snippet, RedirectRegistration}; + use crate::auth::body::ResponseDetail; + use crate::auth::error::AuthError; + + /// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。 + #[test] + fn the_reported_aad_page_is_recognized_as_unregistered() { + let page = "error=invalid_request&error_description=The+provided+value+for+the+input+\ +parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\ +redirect+URI+registered+for+this+client+application."; + assert!(rejects_redirect_uri(page)); + // 登录页本身(有脚本、有表单)不能被误判成拒绝。 + assert!(!rejects_redirect_uri( + "Sign in to your account..." + )); + } + + #[test] + fn aadsts50011_is_also_a_registration_problem() { + assert!(rejects_redirect_uri( + "AADSTS50011: The redirect URI 'http://localhost:61492/auth' specified in the request does not match" + )); + } + + /// 两条流上的同一件事:授权页拒绝(`Denied`)与 token 端点拒绝(`Response`)都要认。 + #[test] + fn structured_errors_of_both_shapes_are_recognized() { + assert!(error_means_unregistered(&AuthError::Denied { + code: "invalid_request".to_string(), + description: "bad redirect".to_string(), + })); + assert!(error_means_unregistered(&AuthError::Response(Box::new( + ResponseDetail { + step: "token".to_string(), + what: "换 token".to_string(), + status: 400, + content_type: None, + kind: crate::auth::BodyKind::Json, + server_message: Some("AADSTS50011: reply url mismatch".to_string()), + digest: "digest".to_string(), + } + )))); + // 不相干的失败不是注册问题(别把网络/白名单也归到这条)。 + assert!(!error_means_unregistered(&AuthError::Canceled)); + } + + #[test] + fn registration_state_is_answered_by_one_predicate() { + assert!(RedirectRegistration::Registered.is_registered()); + assert!(!RedirectRegistration::NotRegistered { + detail: String::new() + } + .is_registered()); + assert!(!RedirectRegistration::Unknown { + detail: String::new() + } + .is_registered()); + } + + #[test] + fn snippet_is_single_line_and_capped() { + let long = format!("a\nb\t{}", "x".repeat(400)); + let text = snippet(&long); + assert!(!text.contains('\n'), "{text}"); + assert!(text.ends_with('…'), "{text}"); + assert_eq!(text.chars().count(), 201); + } +} diff --git a/crates/core/tests/auth/main.rs b/crates/core/tests/auth/main.rs index 6fba1e5..de76825 100644 --- a/crates/core/tests/auth/main.rs +++ b/crates/core/tests/auth/main.rs @@ -7,6 +7,7 @@ //! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断); //! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); //! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; +//! - `microsoft_msalogin`:回环重定向 URI 没登记时的回退(M178:默认设备码、不白开监听口); //! - `authlib_api`:Yggdrasil 四动作的报文级对接。 mod support; @@ -16,6 +17,7 @@ mod authlib_api; mod microsoft_authcode; mod microsoft_diagnostics; mod microsoft_flow; +mod microsoft_msalogin; mod microsoft_refresh; mod offline_uuid; mod store_separation; diff --git a/crates/core/tests/auth/microsoft_msalogin.rs b/crates/core/tests/auth/microsoft_msalogin.rs new file mode 100644 index 0000000..b48bfd9 --- /dev/null +++ b/crates/core/tests/auth/microsoft_msalogin.rs @@ -0,0 +1,267 @@ +//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。 +//! +//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回 +//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页, +//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。 +//! +//! 本文件钉四件事(都是能给答案「能不能变红」的): +//! +//! | 守卫 | 用例 | 抓到什么 | +//! |---|---|---| +//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) | +//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) | +//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) | +//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 | +//! +//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。 + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use unlucky_core::auth::microsoft::{ + AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints, +}; +use unlucky_core::auth::AuthError; + +use crate::support::{full_routes, AuthMock, MockReply}; + +const CLIENT_ID: &str = "test-client-id"; +/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。 +const INVALID_REDIRECT_PAGE: &str = + "error=invalid_request&error_description=The+provided+value+for+the+input+\ +parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\ ++redirect+URI+registered+for+this+client+application."; +/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。 +const SIGN_IN_PAGE: &str = "Sign in to your account\ +
"; + +/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。 +type Seen = Arc)>>>; + +fn policy(state: &str) -> AuthCodePolicy { + AuthCodePolicy { + callback_timeout: Duration::from_millis(2_000), + bind_attempts: 1, + port: None, + state: Some(state.to_string()), + } +} + +/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。 +fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec)> { + let mut routes = full_routes(); + routes.push(("/authorize", vec![reply])); + routes +} + +fn auth(base: &str, browser_first: bool) -> MicrosoftAuth { + MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(base)) + .with_poll_policy(crate::support::microsoft::fast_poll()) + .with_authcode_policy(policy("state-abc")) + .with_browser_first(browser_first) +} + +/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。 +#[tokio::test] +async fn unregistered_redirect_falls_back_to_the_device_code() { + let mock = AuthMock::start(routes_with_authorize(MockReply::text( + 400, + INVALID_REDIRECT_PAGE, + ))) + .await; + + let seen: Seen = Arc::new(Mutex::new(None)); + let seen_for_device = Arc::clone(&seen); + let outcome = auth(&mock.base(), true) + .browser_then_device_login( + |_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"), + move |prompt, fallback| { + *seen_for_device.lock().unwrap() = + Some((prompt.user_code.clone(), fallback.map(|note| note.reason))); + }, + ) + .await + .expect("必须回退到设备码并成功"); + + // ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文) + let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示"); + assert_eq!(user_code, "ABCD-1234"); + assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered)); + assert_eq!( + reason.map(BrowserFallbackReason::code), + Some("redirect_not_registered") + ); + assert_eq!(outcome.session.profile_name, "Steve"); + let note = outcome.browser.expect("必须带回退说明"); + assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered); + // 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。 + assert!(!note.detail.is_empty()); + + // ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」) + assert_eq!( + outcome.listeners_opened, 0, + "没登记回环时不许绑本地端口(预检必须在绑定之前)" + ); + mock.stop(); +} + +/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。 +#[tokio::test] +async fn device_code_default_never_opens_a_listening_port() { + let mock = AuthMock::start(full_routes()).await; + let device_seen = Arc::new(Mutex::new(None)); + let device_flag = Arc::clone(&device_seen); + + let outcome = auth(&mock.base(), false) + .browser_then_device_login( + |_| panic!("默认路不许碰浏览器回调"), + move |prompt, fallback| { + *device_flag.lock().unwrap() = Some(prompt.user_code.clone()); + assert!(fallback.is_none(), "没走浏览器就不该有回退说明"); + }, + ) + .await + .expect("设备码登录成功"); + + assert_eq!( + device_seen.lock().unwrap().clone().as_deref(), + Some("ABCD-1234") + ); + assert!(outcome.browser.is_none()); + assert_eq!( + outcome.session.profile_id, + crate::support::microsoft::PROFILE_ID + ); + assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口"); + // 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳) + assert!(mock.bodies_on("/authorize").is_empty()); + mock.stop(); +} + +/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。 +#[tokio::test] +async fn registered_loopback_still_runs_the_auth_code_flow() { + let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await; + + let outcome = auth(&mock.base(), true) + .browser_then_device_login( + |prompt| { + let redirect = prompt.redirect_uri.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let url = format!("{redirect}?code=the-auth-code&state=state-abc"); + let response = reqwest::get(&url).await.expect("浏览器回调"); + assert_eq!(response.status(), 200); + }); + }, + |_prompt, _note| panic!("登记了回环就不该回退设备码"), + ) + .await + .expect("授权码流应当走通"); + + assert_eq!(outcome.session.profile_name, "Steve"); + assert!(outcome.browser.is_none(), "走通了就不该有回退说明"); + assert_eq!( + outcome.listeners_opened, 1, + "登记了回环就应当真的绑端口(否则加速路名存实亡)" + ); + // 换 token 用的是**授权码** grant(不是设备码那条)。 + let token_bodies = mock.bodies_on("/token"); + assert_eq!(token_bodies.len(), 1, "{token_bodies:?}"); + assert!(token_bodies[0].contains("grant_type=authorization_code")); + mock.stop(); +} + +/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。 +#[tokio::test] +async fn user_cancel_on_the_browser_page_does_not_fall_back() { + let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await; + let error = auth(&mock.base(), true) + .browser_then_device_login( + |prompt| { + let redirect = prompt.redirect_uri.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let url = format!("{redirect}?error=access_denied&state=state-abc"); + let _ = reqwest::get(&url).await; + }); + }, + |_prompt, _note| panic!("用户取消后不许自动换流"), + ) + .await + .expect_err("取消应当是最终结果"); + assert_eq!(error, AuthError::Canceled); + assert!( + mock.bodies_on("/devicecode").is_empty(), + "取消之后不该再去要设备码" + ); + mock.stop(); +} + +/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。 +#[tokio::test] +async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() { + // 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。 + let mock = AuthMock::start(full_routes()).await; + let mut endpoints = MicrosoftEndpoints::all_on(&mock.base()); + endpoints.authorize = "http://127.0.0.1:1/authorize".to_string(); + let reason = Arc::new(Mutex::new(None)); + let reason_flag = Arc::clone(&reason); + let outcome = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(endpoints) + .with_poll_policy(crate::support::microsoft::fast_poll()) + .with_authcode_policy(policy("state-abc")) + .with_browser_first(true) + .browser_then_device_login( + |_prompt| panic!("预检失败时不许弹浏览器"), + move |_prompt, fallback| { + *reason_flag.lock().unwrap() = fallback.map(|note| note.reason); + }, + ) + .await + .expect("预检失败不该拦住设备码"); + assert_eq!( + *reason.lock().unwrap(), + Some(BrowserFallbackReason::PreflightUnavailable) + ); + assert_eq!( + outcome.browser.map(|note| note.reason), + Some(BrowserFallbackReason::PreflightUnavailable) + ); + assert_eq!(outcome.session.profile_name, "Steve"); + assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口"); + mock.stop(); +} + +/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。 +#[tokio::test] +async fn aadsts50011_page_is_not_treated_as_registered() { + let mock = AuthMock::start(routes_with_authorize(MockReply::text( + 200, + "AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured", + ))) + .await; + let note = Arc::new(Mutex::new(None)); + let note_flag = Arc::clone(¬e); + let outcome = auth(&mock.base(), true) + .browser_then_device_login( + |_| panic!("AADSTS50011 的页面不许当已登记"), + move |_prompt, fallback| { + *note_flag.lock().unwrap() = fallback.map(|note| note.reason); + }, + ) + .await + .expect("回退设备码后应当成功"); + assert_eq!( + *note.lock().unwrap(), + Some(BrowserFallbackReason::RedirectNotRegistered) + ); + assert_eq!( + outcome.browser.map(|note| note.reason), + Some(BrowserFallbackReason::RedirectNotRegistered) + ); + mock.stop(); +} diff --git a/crates/i18n/src/catalog/account.rs b/crates/i18n/src/catalog/account.rs index c498e62..c902306 100644 --- a/crates/i18n/src/catalog/account.rs +++ b/crates/i18n/src/catalog/account.rs @@ -115,6 +115,23 @@ macro_rules! account_entries { // ── 重新登录的引导 AccountHintAuthlibReauth => ("Enter the password again for {server}.", "请输入 {server} 的密码。"), AccountHintOfflineNoRelogin => ("Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + // ── 浏览器登录回退到设备码的说明(M178) + // 口径:这不是「登录失败」——设备码接着跑。第一句说清「已改用设备码」, + // 需要改配置的那一条**必须给可照抄的下一步**(哪个门户、加什么值、端口不参与匹配), + // 且**不许**出现 AAD 的原始报文(`invalid_request` / `AADSTS…` / JSON)—— + // 用户 2026-11-08 实测撞到的就是那张原始错误页。 + AccountBrowserFallbackNotRegistered => ( + "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", + "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。" + ), + AccountBrowserFallbackUnverified => ( + "Could not confirm that browser sign-in is usable, so it switched to a device code.", + "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。" + ), + AccountBrowserFallbackFailed => ( + "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", + "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。" + ), } } }; } @@ -125,6 +142,48 @@ pub(crate) use account_entries; mod tests { use crate::{Catalog, Key, Lang}; + /// **M178**:浏览器登录回退到设备码时那一句,必须「说清为什么 + 怎么修」,且**不许**把 + /// AAD 的原始报文搬上屏(用户实测撞到的正是那张 `invalid_request ... redirect_uri` 错误页)。 + /// + /// 抓得住:文案退回「登录失败,完整原因在日志里」、删掉可照抄的重定向 URI、或有人把 + /// AAD 原始错误码/JSON 塞进词条。 + /// 抓不住:读起来顺不顺(那靠 review 与截图)。 + #[test] + fn the_browser_fallback_copy_explains_itself_without_raw_aad_text() { + for &lang in Lang::ALL { + let catalog = Catalog::of(lang); + let not_registered = catalog.text(Key::AccountBrowserFallbackNotRegistered); + let unverified = catalog.text(Key::AccountBrowserFallbackUnverified); + let failed = catalog.text(Key::AccountBrowserFallbackFailed); + // 「已改用设备码」这条结论三句里都得有(用户要知道画面为什么变了)。 + for copy in [not_registered, unverified, failed] { + assert!( + copy.contains("device code") || copy.contains("设备码"), + "{}: {copy}", + lang.tag() + ); + // 原文不上屏:这两条是 AAD 的机器码,出现即语言/脱敏纪律失效。 + assert!(!copy.contains("invalid_request"), "{}: {copy}", lang.tag()); + assert!(!copy.contains("AADSTS"), "{}: {copy}", lang.tag()); + assert!(!copy.contains('{'), "{}: {copy}", lang.tag()); + } + // 「没登记回环」这一条必须给**可照抄**的下一步(门户 + 值 + 端口不参与匹配)。 + assert!( + not_registered.contains("http://localhost/auth"), + "{not_registered}" + ); + assert!( + not_registered.to_lowercase().contains("redirect uri") + || not_registered.contains("重定向 URI"), + "{not_registered}" + ); + assert!( + not_registered.to_lowercase().contains("port") || not_registered.contains("端口"), + "{not_registered}" + ); + } + } + /// **M132**:缺 client id 这条是本页唯一「说清原因」的失败文案,它的纪律比别的正文更硬。 /// /// 抓得住:文案退回「登录失败 / 完整原因在日志里」、退化成「未支持」、或者只说缺 id 却 diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 8f2d27f..577af2f 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -180,6 +180,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("AccountNoticeTitleDone", "Account updated", "账户已更新"), ("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"), ("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + ("AccountBrowserFallbackNotRegistered", "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。"), + ("AccountBrowserFallbackUnverified", "Could not confirm that browser sign-in is usable, so it switched to a device code.", "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。"), + ("AccountBrowserFallbackFailed", "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。"), ("SettingsGroupUpdate", "Updates", "更新"), ("SettingsUpdateCurrent", "Current version", "当前版本"), ("SettingsUpdateChannel", "Channel", "更新渠道"), diff --git a/crates/presenter/src/dto/accounts.rs b/crates/presenter/src/dto/accounts.rs index eda6885..3e12bf7 100644 --- a/crates/presenter/src/dto/accounts.rs +++ b/crates/presenter/src/dto/accounts.rs @@ -18,7 +18,7 @@ mod redact; #[path = "accounts/tests.rs"] mod tests; -pub use failure::{AccountNotice, AccountNoticeCode, LoginFailure}; +pub use failure::{AccountNotice, AccountNoticeCode, BrowserLoginNote, LoginFailure}; pub use kind::AccountKind; pub use name::valid_offline_name; pub use redact::{looks_like_jwt, redact, REDACTED}; @@ -36,12 +36,11 @@ pub struct AccountView { pub kind: AccountKind, /// 档案 UUID(公开) pub profile_id: String, - /// 登录名(微软轨才有;可空) + /// 登录名(微软轨才有) pub login_name: String, - /// 创建时间展示串(`2026-10-31 20:12`) + /// 创建时间展示串(`2026-10-31 20:12`)。 /// - /// 口径:core 的 `AccountMeta` 只有 `created_at_ms`,**没有 last-used**,所以列表上贴的是 - /// 「添加于 {created}」,不伪造「最近使用」(缺口登记在 M114 报告)。 + /// core 的 `AccountMeta` 只有 `created_at_ms`(无 last-used),所以列表贴「添加于 {created}」,不伪造「最近使用」。 pub created: String, /// 是否是当前启动使用的账户 pub active: bool, @@ -55,8 +54,6 @@ pub struct AccountView { impl AccountView { /// 首字母块上的字:展示名第一个非空字符(大写化),空名字回落 `?`。 - /// - /// 放在 DTO 而不是页面里,是为了「没有皮肤时画什么」这件事只有一处判据,两个 UI 版一致。 #[must_use] pub fn monogram(&self) -> String { self.name @@ -66,7 +63,7 @@ impl AccountView { .unwrap_or_else(|| "?".to_string()) } - /// 首字母块的取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次都是同一个色块。 + /// 首字母块取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次同色。 #[must_use] pub fn monogram_tone(&self) -> u8 { let mut hash: u32 = 2_166_136_261; @@ -78,10 +75,8 @@ impl AccountView { } } -/// 一张皮肤原图。 -/// -/// **停在「原图」这一层是刻意的**:presenter 不引入图片解码依赖(`presenter-no-ui` + -/// 依赖极简),裁剪/缩放/取首字母块都由 UI 层自己做。离线轨永远没有它——没有皮肤就不给假皮肤。 +/// 一张皮肤原图。**停在「原图」这层是刻意的**:presenter 不引图片解码依赖,裁剪/缩放归 UI 层; +/// 离线轨永远没有它——没有皮肤就不给假皮肤。 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SkinImage { /// PNG 字节(Minecraft 皮肤:64×64 或旧版 64×32) @@ -127,8 +122,7 @@ impl DeviceLoginPhase { /// 设备码登录的可见状态。 /// -/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`—— -/// 它们本来就是给用户看的,不是凭据。 +/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`(给用户看的,不是凭据)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceLoginView { /// 阶段 @@ -137,7 +131,7 @@ pub struct DeviceLoginView { pub user_code: String, /// 验证地址(`https://www.microsoft.com/link`) pub verification_uri: String, - /// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开) + /// 二维码内容(= 带码的链接) pub qr_link: String, /// 有效期(秒;0 = 未知) pub expires_in: u64, @@ -146,6 +140,9 @@ pub struct DeviceLoginView { /// 失败原因码(`None` = 不是失败态 / 未分类) #[serde(default, skip_serializing_if = "Option::is_none")] pub failure: Option, + /// 浏览器登录没走成、已回退设备码的说明码(M178;`None` = 没这回事) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_note: Option, /// 成功后入库的账户展示名 pub account_name: String, } @@ -167,6 +164,7 @@ impl DeviceLoginView { expires_in: 0, message: String::new(), failure: None, + browser_note: None, account_name: String::new(), } } @@ -194,14 +192,14 @@ impl DeviceLoginView { expires_in ), failure: None, + browser_note: None, account_name: String::new(), } } /// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。 /// - /// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/ - /// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。 + /// 面板不展示用户码,改展示**可复制的授权链接**;`warning` 是「浏览器没拉起来」这类非致命提示。 pub fn browser_waiting( authorization_url: &str, expires_in: u64, @@ -222,10 +220,18 @@ impl DeviceLoginView { expires_in, message, failure: None, + browser_note: None, account_name: String::new(), } } + /// 附一条回退说明码(M178)。与 `message`(原文只进日志)分开:页面只拿码去查 i18n。 + #[must_use] + pub fn with_browser_note(mut self, note: BrowserLoginNote) -> Self { + self.browser_note = Some(note); + self + } + /// 失败态(文案在入口就脱敏:错误原文里可能带着服务端回显的 token)。 pub fn failed(message: impl Into) -> Self { Self::failed_as(LoginFailure::Other, message) @@ -256,8 +262,7 @@ impl DeviceLoginView { /// 第三方轨(authlib-injector)登录请求。 /// -/// **凭据红线**:`password` 只是「用户这一次输入的请求值」,从页面流到 host 的 -/// `AuthlibAuth::authenticate` 调用栈里,host 取走即弃——不入账户库、不进日志、 +/// **凭据红线**:`password` 只是「这一次输入的请求值」,host 取走即弃——不入账户库、不进日志、 /// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthlibRequest { @@ -305,14 +310,14 @@ impl AuthlibRequest { } } -/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:都是「申请 → 等待 → 成/败」)。 +/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:「申请 → 等待 → 成/败」)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthlibLoginView { /// 阶段 pub phase: DeviceLoginPhase, /// 认证服务器地址(规范化后的 API 根) pub server: String, - /// 服务发现里的服务器名(校验通过后才有;空 = 没校验/没名字) + /// 服务发现里的服务器名(校验通过后才有;空 = 没校验) pub server_name: String, /// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**) pub message: String, diff --git a/crates/presenter/src/dto/accounts/failure.rs b/crates/presenter/src/dto/accounts/failure.rs index ac07437..2ecd2b1 100644 --- a/crates/presenter/src/dto/accounts/failure.rs +++ b/crates/presenter/src/dto/accounts/failure.rs @@ -75,8 +75,34 @@ impl LoginFailure { } } -/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。 +/// 「浏览器登录没走成,已自动改用设备码」的说明码(M178)。 /// +/// 为什么与 [`LoginFailure`] 分开:这不是登录失败——设备码流接上了,用户照常在等码。 +/// 它解释的是「为什么面板变成了设备码」以及**要不要去改应用注册**,所以单独一个码族, +/// 页面画成一行提示而不是错误横幅。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowserLoginNote { + /// 应用注册里没有回环重定向 URI(授权端点当场拒绝)——**唯一带「怎么修」的一句** + RedirectNotRegistered, + /// 预检没问出结论(网络/超时),只能先用设备码 + PreflightUnavailable, + /// 预检通过、授权码流仍然失败(超时 / 换 token 被拒 / 端口绑不上) + AttemptFailed, +} + +impl BrowserLoginNote { + /// 稳定 ascii code(日志/测试用,**不随语言变**)。 + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::RedirectNotRegistered => "redirect_not_registered", + Self::PreflightUnavailable => "preflight_unavailable", + Self::AttemptFailed => "attempt_failed", + } + } +} + +/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。 /// 为什么不是 toast:app-iced 不渲染 toast(复审 r1 P2-2 实锤),发 toast 等于用户什么都看不到。 /// 横幅是页面上的真实控件,用户点了「Create profile」失败后一定看得见。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index b8710b2..a5d6ea6 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -35,8 +35,8 @@ mod versions; pub use accounts::{ looks_like_jwt, redact, valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, - AccountView, AuthlibLoginView, AuthlibRequest, DeviceLoginPhase, DeviceLoginView, LoginFailure, - SkinImage, REDACTED, + AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, + DeviceLoginView, LoginFailure, SkinImage, REDACTED, }; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use content::{ diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 3edcca3..3398d8f 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -3,9 +3,10 @@ //! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现, //! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。 //! -//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调, -//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到 -//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。 +//! 两条登录流(M178 改了默认):**设备码是默认**([`start_login`]:不依赖任何应用注册项), +//! 授权码([`start_browser_login`],浏览器 + 本地回调)降级成「用户显式要求才试」的加速路, +//! 且带回环注册预检 + 失败自动回退设备码。除了「怎么拿到 MS access_token」之外完全共用: +//! 同一个取消槽位、同一段账户入库、同一个刷新链。 //! //! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 //! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 @@ -17,7 +18,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::auth::account::Credentials; -use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; +use unlucky_core::auth::microsoft::{ + now_ms, BrowserFallbackReason, MicrosoftAuth, MicrosoftSession, +}; use unlucky_core::auth::offline::offline_meta; use unlucky_core::auth::{AccountStore, AuthError, AuthResult}; use unlucky_core::download::blocking::block_on; @@ -25,7 +28,7 @@ use unlucky_core::download::blocking::block_on; use crate::dto::CoreEvent; use crate::dto::{ valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, - DeviceLoginView, LoginFailure, + BrowserLoginNote, DeviceLoginView, LoginFailure, }; use super::env::HostEnv; @@ -183,7 +186,11 @@ impl LoginJob { } } -/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。 +/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 +/// +/// 设备码流不依赖任何应用注册项(Azure 那边只需要一个公共客户端 id),所以它是唯一 +/// 「在官方产物上开箱可用」的路。它**一个本地监听口都不开**,也就不会撞上 +/// `redirect_uri is not valid` 那张 AAD 错误页(守卫断言见 `callback::bind_attempts`)。 pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-login", move || { let auth = match microsoft_auth(&env) { @@ -203,30 +210,69 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { }); } -/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。 +/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 +/// +/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试: +/// `core` 先做回环重定向 URI 的注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码; +/// 试了但失败(超时/被拒/端口绑不上)同样回退。两条路都不满意时用户还能在面板上看到 +/// 一句「为什么改用设备码 + 怎么修」(结构化码 → i18n,原文只进日志)。 /// /// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的 /// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。 pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-authcode", move || { + spawn("msa-browser", move || { let auth = match microsoft_auth(&env) { Ok(auth) => auth, Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; - let tx_prompt = tx.clone(); - let result = block_on(auth.authcode_login(|prompt| { - // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 - let opened = opener::open_url(&prompt.authorization_url); - let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( - &prompt.authorization_url, - prompt.timeout.as_secs(), - opened.err().as_deref(), - )))); - })); - finish(&env, &tx, &job, result); + let tx_browser = tx.clone(); + let tx_device = tx.clone(); + let result = block_on(auth.browser_then_device_login( + |prompt| { + // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 + let opened = opener::open_url(&prompt.authorization_url); + let _ = + tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( + &prompt.authorization_url, + prompt.timeout.as_secs(), + opened.err().as_deref(), + )))); + }, + |prompt, fallback| { + let view = DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + ); + let view = match fallback { + Some(fallback) => { + // 回退原文只进日志;面板只看码。 + eprintln!( + "[auth] 浏览器登录未走成({}),已回退设备码:{}", + fallback.reason.code(), + fallback.detail + ); + view.with_browser_note(browser_note(fallback.reason)) + } + None => view, + }; + let _ = tx_device.send(HostTask::Login(Box::new(view))); + }, + )); + finish(&env, &tx, &job, result.map(|outcome| outcome.session)); }); } +/// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。 +fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote { + match reason { + BrowserFallbackReason::RedirectNotRegistered => BrowserLoginNote::RedirectNotRegistered, + BrowserFallbackReason::PreflightUnavailable => BrowserLoginNote::PreflightUnavailable, + BrowserFallbackReason::AttemptFailed => BrowserLoginNote::AttemptFailed, + } +} + /// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。 fn microsoft_auth(env: &HostEnv) -> AuthResult { MicrosoftAuth::from_env().map(|auth| { diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index cce5d36..e7679ac 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -185,3 +185,34 @@ fn login_job_cancel_flag_is_shared() { job.cancel(); assert!(clone.is_cancelled()); } + +/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同—— +/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。 +/// +/// 抓得住:core 加/改一个回退原因而 presenter 忘了接(`browser_note` 是 `match`, +/// 新增变体会编译失败;这里再把**码的字符串**钉住,防的是两侧各改各的)。 +#[test] +fn browser_fallback_reasons_map_to_the_page_notes_with_the_same_codes() { + for (reason, note) in [ + ( + BrowserFallbackReason::RedirectNotRegistered, + BrowserLoginNote::RedirectNotRegistered, + ), + ( + BrowserFallbackReason::PreflightUnavailable, + BrowserLoginNote::PreflightUnavailable, + ), + ( + BrowserFallbackReason::AttemptFailed, + BrowserLoginNote::AttemptFailed, + ), + ] { + assert_eq!(browser_note(reason), note); + assert_eq!(note.code(), reason.code()); + } + // 「没登记回环」是唯一带「怎么修」的那一条,码也必须稳定(排障脚本按它 grep 日志)。 + assert_eq!( + BrowserLoginNote::RedirectNotRegistered.code(), + "redirect_not_registered" + ); +} From a40b3a5940fc7cf44f780c004f52887a262e0b0c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 12:08:12 +0800 Subject: [PATCH 05/33] =?UTF-8?q?fix(msa):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=8C=89=E9=92=AE=E5=9C=A8=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=9C=9F=E7=9A=84=E7=BD=AE=E4=BD=8D=20browse?= =?UTF-8?q?r=5Ffirst=EF=BC=88=E5=A4=8D=E5=AE=A1=20r1=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是 共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login` 里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路) 实际未交付。 - P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走 `.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的 模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测: 把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对 `/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。 - P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在 `TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去; 删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条 msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的 `callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。 - P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并 回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。 (下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。) - P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是 「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块 文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。 - P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成 视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。 - P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」, 下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。 门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、 `cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到 `client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。 --- crates/app-iced/src/app/update/account.rs | 5 + crates/app-iced/src/pages/account/login.rs | 45 ++- .../app-iced/src/pages/account/tests_msa.rs | 41 +- .../app-iced/src/pages/account/tests_text.rs | 9 +- crates/core/src/auth/microsoft/authcode.rs | 27 +- crates/core/src/auth/microsoft/callback.rs | 18 + crates/core/src/auth/microsoft/client_id.rs | 237 +----------- .../src/auth/microsoft/client_id/tests.rs | 358 ++++++++++++++++++ crates/core/src/auth/microsoft/login.rs | 22 +- crates/core/src/auth/microsoft/preflight.rs | 101 ++++- crates/core/tests/auth/microsoft_msalogin.rs | 7 + crates/i18n/src/catalog/account.rs | 4 +- crates/i18n/src/catalog/golden/tail2.rs | 3 +- crates/presenter/src/host/accounts.rs | 91 ++++- crates/presenter/src/host/accounts/tests.rs | 71 ++++ 15 files changed, 745 insertions(+), 294 deletions(-) create mode 100644 crates/core/src/auth/microsoft/client_id/tests.rs diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index b6f2f18..0851445 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -216,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) { "closed" => { let _ = app.on_account(crate::pages::account::Message::Close); } + // M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动 + // 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。 + "msa-entry" => { + app.account_form.panel = crate::pages::account::Panel::Microsoft; + } // 有账号时「Add an account」展开态(P2-4 的收敛后形态) "add" => { app.account_form.section_open = true; diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index b967c34..a11eaad 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -2,9 +2,8 @@ //! //! ## 为什么不是「一个转圈」 //! -//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, -//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、 -//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 +//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, +//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。 //! //! ## 文案一律**借用模型里的串**(M178) //! @@ -60,12 +59,15 @@ pub fn panel<'a>( /// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方 /// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`, /// 没登记时在预检处直接回退设备码)。 +/// +/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」; +/// 「没有浏览器时用这个」放在按钮下面一行说明里。 fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { let body = column![row![ buttons::filled( ui, Button::new( - catalog.text(Key::AccountDeviceUseCode), + catalog.text(Key::AccountDeviceSignIn), Account::StartDevice.into_page(), ) ), @@ -86,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { ] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center),] - .spacing(ui.space(Space::X3)); + .spacing(ui.space(Space::X3)) + .push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::AccountDeviceFallbackHint), + ui.color(Role::OnSurfaceVariant), + )); frame::card(ui, body) } @@ -115,7 +124,7 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { frame::card(ui, body) } -/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。 +/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。 fn device_code<'a>( ui: &'a Ui, code: &'a str, @@ -257,9 +266,7 @@ fn browser<'a>( frame::card(ui, body) } -/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。 -/// -/// 文案由模型拼好(`panels::device_panel` 用 catalog 渲染 `{time}`),这里只决定颜色与强调。 +/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。 fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> { if expired { return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error)); @@ -273,6 +280,11 @@ fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, P ) } +/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。 +pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str { + catalog.text(super::model::note_key(note)) +} + /// 回退说明那一行(`None` = 没有这回事)。 fn note_text<'a>( ui: &'a Ui, @@ -285,20 +297,17 @@ fn note_text<'a>( ui, TypeRole::BodySmall, false, - catalog.text(super::model::note_key(note)), + browser_note_text(note, catalog), ui.color(Role::OnSurfaceVariant), )) } /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// -/// 四点修订叠在这里: -/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; -/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, -/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); -/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 -/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错; -/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。 +/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; +/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调; +/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」; +/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。 fn failed<'a>( ui: &'a Ui, failure: LoginFailure, @@ -308,7 +317,7 @@ fn failed<'a>( let mut body = catalog.text(failure_body_key(failure)).to_string(); if let Some(note) = note { body.push_str("\n\n"); - body.push_str(catalog.text(super::model::note_key(note))); + body.push_str(browser_note_text(note, catalog)); } let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); let mut actions: Vec> = Vec::new(); diff --git a/crates/app-iced/src/pages/account/tests_msa.rs b/crates/app-iced/src/pages/account/tests_msa.rs index c5f4987..3439e19 100644 --- a/crates/app-iced/src/pages/account/tests_msa.rs +++ b/crates/app-iced/src/pages/account/tests_msa.rs @@ -4,15 +4,23 @@ //! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口) //! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。 +use unlucky_i18n::Lang; use unlucky_presenter::dto::{ AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure, }; use unlucky_presenter::state::AppState; -use super::model::{DevicePanel, Form}; +use super::avatar::Cache; +use super::login::browser_note_text; +use super::model::{DevicePanel, Form, Page}; use super::tests::build; use super::tests_text::{has_cjk, on_screen}; +/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。 +fn build_zh(state: &AppState, form: &Form) -> Page { + Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0) +} + /// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的** /// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。 /// @@ -60,6 +68,37 @@ application." ); assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}"); assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}"); + + // 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口** + // `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。 + let zh = build_zh(&state, &Form::new()); + let zh_text = on_screen(&zh); + assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}"); + let note_text = browser_note_text( + BrowserLoginNote::RedirectNotRegistered, + Lang::ZhCn.catalog(), + ); + assert!( + note_text.contains("http://localhost/auth"), + "中文文案也要给出可照抄的重定向 URI:{note_text}" + ); + assert!(note_text.contains("重定向 URI"), "{note_text}"); + assert!(note_text.contains("设备码"), "{note_text}"); + for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] { + assert!( + !zh_text.contains(forbidden), + "中文界面混进了原始报文:{zh_text}" + ); + assert!( + !note_text.contains(forbidden), + "词条混进了原始报文:{note_text}" + ); + } + // 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。 + assert_eq!( + browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()), + "Could not confirm that browser sign-in is usable, so it switched to a device code." + ); } /// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。 diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index 5e73a7a..6afa153 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -16,7 +16,7 @@ use unlucky_i18n::{Key, Lang}; use unlucky_presenter::dto::AccountNoticeCode; -use super::login::{failure_body_key, failure_title_key}; +use super::login::{browser_note_text, failure_body_key, failure_title_key}; use super::model::{AuthlibPanel, DevicePanel, Hint, Page}; /// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。 @@ -68,9 +68,10 @@ pub(super) fn on_screen(page: &Page) -> String { out.push_str(link); // 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。 out.push_str(countdown); - // M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。 + // M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`), + // 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。 if let Some(note) = note { - out.push_str(catalog.text(super::model::note_key(*note))); + out.push_str(browser_note_text(*note, catalog)); } } DevicePanel::Browser { @@ -84,7 +85,7 @@ pub(super) fn on_screen(page: &Page) -> String { out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); if let Some(note) = note { - out.push_str(catalog.text(super::model::note_key(*note))); + out.push_str(browser_note_text(*note, catalog)); } } DevicePanel::Succeeded { name } => { diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs index 16ce8f5..51ed053 100644 --- a/crates/core/src/auth/microsoft/authcode.rs +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -173,11 +173,28 @@ pub(crate) async fn login( policy: AuthCodePolicy, present: F, ) -> AuthResult +where + F: FnOnce(&AuthCodePrompt), +{ + login_counting(client, endpoints, client_id, policy, None, present).await +} + +/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。 +pub(crate) async fn login_counting( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + counter: Option<&std::sync::atomic::AtomicUsize>, + present: F, +) -> AuthResult where F: FnOnce(&AuthCodePrompt), { let state = policy.state.clone().unwrap_or_else(nonce); - let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?; + let server = + CallbackServer::bind_counting(policy.port, policy.bind_attempts, state.clone(), counter) + .await?; let redirect_uri = RedirectUri::new(server.port()); present(&AuthCodePrompt { authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state), @@ -206,14 +223,15 @@ pub(crate) enum AuthCodeAttempt { /// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。 /// -/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(守卫断言 -/// [`super::callback::bind_attempts`] 不涨),也就不会出现「浏览器里一张 AAD 错误页 + +/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(`counter` 是绑定现场的 +/// 真计数,见 [`CallbackServer::bind_counting`]),也就不会出现「浏览器里一张 AAD 错误页 + /// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。 pub(crate) async fn login_checked( client: &Client, endpoints: &MicrosoftEndpoints, client_id: &str, policy: AuthCodePolicy, + counter: &std::sync::atomic::AtomicUsize, present: F, ) -> AuthResult where @@ -235,7 +253,8 @@ where ))) } } - let session = login(client, endpoints, client_id, policy, present).await?; + let session = + login_counting(client, endpoints, client_id, policy, Some(counter), present).await?; Ok(AuthCodeAttempt::LoggedIn(session)) } diff --git a/crates/core/src/auth/microsoft/callback.rs b/crates/core/src/auth/microsoft/callback.rs index 2e8a27c..400a6a3 100644 --- a/crates/core/src/auth/microsoft/callback.rs +++ b/crates/core/src/auth/microsoft/callback.rs @@ -66,11 +66,29 @@ impl CallbackServer { port: Option, attempts: u8, state: impl Into, + ) -> AuthResult { + Self::bind_counting(port, attempts, state, None).await + } + + /// 同 [`Self::bind`],但把**每一次真正调用 `TcpListener::bind`** 记进 `counter`(M178)。 + /// + /// 为什么不把计数写在外层:`LoginOutcome::listeners_opened` 是「这次登录有没有偷开监听口」 + /// 的唯一证据,它必须是**绑定现场**的数,不能是「进没进授权码分支」的代理指标 + /// (复审 r1 P2-1 的变异 D 实测:代理指标对「真的多绑了一次」不敏感)。 + /// 计数发生在 `TcpListener::bind` 调用之前:要数的是「去开了没」,不是「开成功了没」。 + pub(crate) async fn bind_counting( + port: Option, + attempts: u8, + state: impl Into, + counter: Option<&std::sync::atomic::AtomicUsize>, ) -> AuthResult { let wanted = port.unwrap_or(0); let attempts = attempts.max(1); let mut last = String::new(); for attempt in 0..attempts { + if let Some(counter) = counter { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } match TcpListener::bind(("127.0.0.1", wanted)).await { Ok(listener) => { let port = listener diff --git a/crates/core/src/auth/microsoft/client_id.rs b/crates/core/src/auth/microsoft/client_id.rs index a8ebda7..67a1d32 100644 --- a/crates/core/src/auth/microsoft/client_id.rs +++ b/crates/core/src/auth/microsoft/client_id.rs @@ -116,238 +116,5 @@ pub fn client_id_status() -> ClientIdStatus { } #[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::{ - client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV, - MISSING_CLIENT_ID_MESSAGE, - }; - use crate::auth::error::AuthError; - use crate::auth::microsoft::MicrosoftAuth; - - /// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。 - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - /// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。 - struct EnvGuard { - saved: Option, - } - - impl EnvGuard { - fn clear() -> Self { - let saved = std::env::var(CLIENT_ID_ENV).ok(); - std::env::remove_var(CLIENT_ID_ENV); - Self { saved } - } - - fn set(value: &str) -> Self { - let saved = std::env::var(CLIENT_ID_ENV).ok(); - std::env::set_var(CLIENT_ID_ENV, value); - Self { saved } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - match self.saved.take() { - Some(value) => std::env::set_var(CLIENT_ID_ENV, value), - None => std::env::remove_var(CLIENT_ID_ENV), - } - } - } - - #[test] - fn missing_client_id_message_says_unconfigured_not_unimplemented() { - // 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug), - // 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。 - // 抓不住:文案好不好读——那要靠 review。 - assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id")); - assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置")); - assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持")); - assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现")); - assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV)); - assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build")); - // 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `` 占位**,不出现真 id。 - assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); - } - - #[test] - fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); - let _guard = EnvGuard::clear(); - let result = MicrosoftAuth::from_env(); - match result { - Err(AuthError::Unsupported(message)) => { - // 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流), - // 但**人读文案**必须说「未配置」。 - assert_eq!(message, MISSING_CLIENT_ID_MESSAGE); - assert!(!message.contains("未支持")); - } - // 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里; - // 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。 - // 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红** - // (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。 - Ok(_) => assert!( - CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), - "没配 id 却构造成功" - ), - Err(other) => panic!("应是 Unsupported,实际 {other:?}"), - } - } - - #[test] - fn env_client_id_wins_over_build_and_the_log_says_so() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); - let _guard = EnvGuard::set("0000-test-env-id"); - - let (id, source) = resolve_client_id().expect("环境变量给了 id"); - assert_eq!(id, "0000-test-env-id"); - // 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。 - assert_eq!(source, ClientIdSource::Env); - - let status = client_id_status(); - assert!(status.is_configured()); - assert_eq!(status.source(), Some(ClientIdSource::Env)); - assert_eq!( - status.log_line(), - "[auth] msa client_id=configured source=env" - ); - - // 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止, - // `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。 - let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功"); - assert!(auth.endpoints().profile.contains("minecraftservices.com")); - } - - #[test] - fn missing_client_id_log_line_is_machine_readable() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); - let _guard = EnvGuard::clear(); - if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) { - // 构建期注入过 id:这条没有「缺配置」可言,跳过。 - return; - } - let status = client_id_status(); - assert!(!status.is_configured()); - assert_eq!(status.source(), None); - assert_eq!( - status.log_line(), - "[auth] msa client_id=missing source=none" - ); - } - - #[test] - fn blank_env_value_counts_as_missing() { - // 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。 - let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); - let _guard = EnvGuard::set(" "); - if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) { - assert_eq!( - resolve_client_id().map(|(_, source)| source), - Some(ClientIdSource::Build), - "空白环境变量必须让位给构建期注入" - ); - } else { - assert!(resolve_client_id().is_none()); - } - } - - /// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。 - /// - /// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。 - /// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的 - /// `packaging/check_release_semantics.py` 与 Actions 日志看)。 - #[test] - fn no_client_id_is_hardcoded_in_the_microsoft_sources() { - // 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。 - let manifest = env!("CARGO_MANIFEST_DIR"); - let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs")) - .expect("读 client_id.rs"); - assert!( - source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"), - "client id 的构建期注入点不见了" - ); - // 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量 - // (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。 - // 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的 - // 测试向量,与 client id 无关。 - let mut offenders = Vec::new(); - let mut stack = vec![format!("{manifest}/src/auth/microsoft")]; - while let Some(dir) = stack.pop() { - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path.display().to_string()); - continue; - } - if path.extension().is_some_and(|ext| ext == "rs") { - let text = std::fs::read_to_string(&path).unwrap_or_default(); - if contains_uuid_literal(&text) { - offenders.push(path.display().to_string()); - } - } - } - } - assert!( - offenders.is_empty(), - "这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}" - ); - } - - /// 源码里有没有 `8-4-4-4-12` 的十六进制字面量(Azure 客户端 id 的形状)。 - fn contains_uuid_literal(text: &str) -> bool { - fn hex(value: &[u8]) -> bool { - !value.is_empty() && value.iter().all(u8::is_ascii_hexdigit) - } - let bytes = text.as_bytes(); - let dashes: Vec = bytes - .iter() - .enumerate() - .filter(|(_, byte)| **byte == b'-') - .map(|(index, _)| index) - .collect(); - dashes.windows(4).any(|window| { - let (first, last) = (window[0], window[3]); - let gaps = [ - window[1] - first, - window[2] - window[1], - window[3] - window[2], - ]; - // 4-4-4-12 的段间距(连字符本身没算进 gap):5-5-5-13。 - if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 { - return false; - } - hex(&bytes[first - 8..first]) - && hex(&bytes[first + 1..window[1]]) - && hex(&bytes[window[1] + 1..window[2]]) - && hex(&bytes[window[2] + 1..last]) - && hex(&bytes[last + 1..last + 13]) - }) - } - - /// 判据自检:`contains_uuid_literal` 认得出 UUID,也不会把普通代码当 UUID。 - /// - /// 没有这条,「源码里没有 UUID」可能因为判据本身坏掉而永远为真(假绿)。 - /// 样本**运行时拼**:字面量写在这里会被上面的扫描抓成 offender(自指陷阱)。 - #[test] - fn the_uuid_literal_detector_bites() { - let sample = format!( - "\"{}-{}-{}-{}-{}\"", - "83fd9b2f", "da89", "41f7", "b41b", "13742a73841c" - ); - assert!(contains_uuid_literal(&sample), "{sample}"); - assert!(!contains_uuid_literal("// 0000-test-client")); - assert!(!contains_uuid_literal("a-b-c-d")); - assert!(!contains_uuid_literal( - "http://localhost/auth?client_id=not-a-uuid" - )); - // 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。 - assert!(!contains_uuid_literal( - "abcdefgh-1234-5678-9012-12345678901" - )); - } -} +#[path = "client_id/tests.rs"] +mod tests; diff --git a/crates/core/src/auth/microsoft/client_id/tests.rs b/crates/core/src/auth/microsoft/client_id/tests.rs new file mode 100644 index 0000000..c9ccad6 --- /dev/null +++ b/crates/core/src/auth/microsoft/client_id/tests.rs @@ -0,0 +1,358 @@ +//! `client_id` 的判据(从 `client_id.rs` 拆出来守 400 行硬线)。 +//! +//! 两块:①「缺 id」文案与日志行的措辞纪律(M117);②**M178 守卫**——client id 只能构建期注入, +//! 微软轨源码里不许出现 UUID 形状的字面量(复审 r1 P2-3 加严:`concat!` / 跨行 / `format!` +//! 三种拼法都要抓得住,且不许误杀 `session.rs` 里那个 profile UUID)。 + +use std::sync::Mutex; + +use super::{ + client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV, + MISSING_CLIENT_ID_MESSAGE, +}; +use crate::auth::error::AuthError; +use crate::auth::microsoft::MicrosoftAuth; + +/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。 +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。 +struct EnvGuard { + saved: Option, +} + +impl EnvGuard { + fn clear() -> Self { + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::remove_var(CLIENT_ID_ENV); + Self { saved } + } + + fn set(value: &str) -> Self { + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::set_var(CLIENT_ID_ENV, value); + Self { saved } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match self.saved.take() { + Some(value) => std::env::set_var(CLIENT_ID_ENV, value), + None => std::env::remove_var(CLIENT_ID_ENV), + } + } +} + +#[test] +fn missing_client_id_message_says_unconfigured_not_unimplemented() { + // 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug), + // 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。 + // 抓不住:文案好不好读——那要靠 review。 + assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id")); + assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置")); + assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持")); + assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现")); + assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV)); + assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build")); + // 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `` 占位**,不出现真 id。 + assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); +} + +#[test] +fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::clear(); + let result = MicrosoftAuth::from_env(); + match result { + Err(AuthError::Unsupported(message)) => { + // 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流), + // 但**人读文案**必须说「未配置」。 + assert_eq!(message, MISSING_CLIENT_ID_MESSAGE); + assert!(!message.contains("未支持")); + } + // 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里; + // 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。 + // 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红** + // (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。 + Ok(_) => assert!( + CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), + "没配 id 却构造成功" + ), + Err(other) => panic!("应是 Unsupported,实际 {other:?}"), + } +} + +#[test] +fn env_client_id_wins_over_build_and_the_log_says_so() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::set("0000-test-env-id"); + + let (id, source) = resolve_client_id().expect("环境变量给了 id"); + assert_eq!(id, "0000-test-env-id"); + // 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。 + assert_eq!(source, ClientIdSource::Env); + + let status = client_id_status(); + assert!(status.is_configured()); + assert_eq!(status.source(), Some(ClientIdSource::Env)); + assert_eq!( + status.log_line(), + "[auth] msa client_id=configured source=env" + ); + + // 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止, + // `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。 + let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功"); + assert!(auth.endpoints().profile.contains("minecraftservices.com")); +} + +#[test] +fn missing_client_id_log_line_is_machine_readable() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::clear(); + if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) { + // 构建期注入过 id:这条没有「缺配置」可言,跳过。 + return; + } + let status = client_id_status(); + assert!(!status.is_configured()); + assert_eq!(status.source(), None); + assert_eq!( + status.log_line(), + "[auth] msa client_id=missing source=none" + ); +} + +#[test] +fn blank_env_value_counts_as_missing() { + // 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。 + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let _guard = EnvGuard::set(" "); + if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) { + assert_eq!( + resolve_client_id().map(|(_, source)| source), + Some(ClientIdSource::Build), + "空白环境变量必须让位给构建期注入" + ); + } else { + assert!(resolve_client_id().is_none()); + } +} + +/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。 +/// +/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。 +/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的 +/// `packaging/check_release_semantics.py` 与 Actions 日志看)。 +#[test] +fn no_client_id_is_hardcoded_in_the_microsoft_sources() { + // 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。 + let manifest = env!("CARGO_MANIFEST_DIR"); + let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs")) + .expect("读 client_id.rs"); + assert!( + source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"), + "client id 的构建期注入点不见了" + ); + // 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量 + // (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。 + // 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的 + // 测试向量,与 client id 无关。 + let mut offenders = Vec::new(); + let mut stack = vec![format!("{manifest}/src/auth/microsoft")]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path.display().to_string()); + continue; + } + if path.extension().is_some_and(|ext| ext == "rs") { + let text = std::fs::read_to_string(&path).unwrap_or_default(); + if contains_uuid_literal(&text) { + offenders.push(path.display().to_string()); + } + } + } + } + assert!( + offenders.is_empty(), + "这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}" + ); +} + +/// 源码里有没有「Azure 客户端 id 形状」的字面量(复审 r1 P2-3 加严)。 +/// +/// 两条判据,覆盖复审点名的三种拼法: +/// 1. **带连字符**(`8-4-4-4-12`):把全部字面量首尾相接后扫,于是 +/// `concat!("83fd9b2f-", "da89-…")`、跨行相邻两段、单段完整 UUID 都能抓到; +/// 2. **无连字符的 32 位十六进制、且由 ≥2 个**连续**的「纯十六进制字面量**拼起来 +/// (`format!("{}-{}-{}-{}", "83fd9b2f", "da89", …)` 这类逐段拼法)。 +/// +/// 为什么判据 2 要求 ≥2 段:`session.rs` 的测试数据里有 profile UUID +/// (`"0f8fad5bd9cb469fa16570867728950e"`)——单个字面量的 32 位 hex 是**档案 id**, +/// 不是 client id;只有「拼出来的」才可疑。也不能把字面量无分隔地全部相接去数 hex 段: +/// 那样下一个字面量的开头若恰好是 hex 字符(如 `"Bearer"` 的 `Bea`)会把上一段续长造成误杀。 +fn contains_uuid_literal(text: &str) -> bool { + let literals = string_literals(text); + let joined: String = literals.iter().map(String::as_str).collect(); + if has_dashed_uuid(&joined) { + return true; + } + // 判据 2:连续的「纯 hex 字面量」累计长度 ≥32 且至少两段。 + let mut run_len = 0; + let mut run_parts = 0; + for literal in &literals { + let pure_hex = !literal.is_empty() && literal.bytes().all(|b| b.is_ascii_hexdigit()); + if pure_hex { + run_len += literal.len(); + run_parts += 1; + if run_len >= 32 && run_parts >= 2 { + return true; + } + } else { + run_len = 0; + run_parts = 0; + } + } + false +} + +/// 收集源码里的字符串字面量内容(跳过注释;`\` 转义按「跳一个字符」处理)。 +/// +/// 为什么跳注释:本模块的文档里就写着 `concat!("83fd9b2f-", "da89-…")` 这类**说明性**例子, +/// 不跳的话自检样本会被自己的文档抓成 offender(实测踩过)。注释里出现 id 不构成「代码里 +/// 硬编码 id」,判据只管可执行的字面量。 +/// +/// 近似处(够用即可):字符字面量 `'a'` 不当字符串;`r#"…"#` 按普通字面量近似(前缀 `r` +/// 不在引号内,不影响内容提取)。 +fn string_literals(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut out = Vec::new(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + // 行注释:跳到行尾 + b'/' if bytes.get(index + 1) == Some(&b'/') => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + // 块注释:跳到 `*/` + b'/' if bytes.get(index + 1) == Some(&b'*') => { + index += 2; + while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') + { + index += 1; + } + index = (index + 2).min(bytes.len()); + } + b'"' => { + index += 1; + let start = index; + let mut escaped = false; + while index < bytes.len() { + let byte = bytes[index]; + if escaped { + escaped = false; + index += 1; + continue; + } + match byte { + b'\\' => { + escaped = true; + index += 1; + } + b'"' => break, + _ => index += 1, + } + } + let end = index.min(bytes.len()); + out.push(String::from_utf8_lossy(&bytes[start..end]).into_owned()); + index += 1; + } + _ => index += 1, + } + } + out +} + +/// 拼接后的串里有没有 `8-4-4-4-12` 的十六进制段(间距 5-5-5-13)。 +fn has_dashed_uuid(text: &str) -> bool { + fn hex(value: &[u8]) -> bool { + !value.is_empty() && value.iter().all(u8::is_ascii_hexdigit) + } + let bytes = text.as_bytes(); + let dashes: Vec = bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'-') + .map(|(index, _)| index) + .collect(); + dashes.windows(4).any(|window| { + let (first, last) = (window[0], window[3]); + let gaps = [ + window[1] - first, + window[2] - window[1], + window[3] - window[2], + ]; + if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 { + return false; + } + hex(&bytes[first - 8..first]) + && hex(&bytes[first + 1..window[1]]) + && hex(&bytes[window[1] + 1..window[2]]) + && hex(&bytes[window[2] + 1..last]) + && hex(&bytes[last + 1..last + 13]) + }) +} + +/// 判据自检:三种拼法都要抓得住,且不能把普通代码当 client id。 +/// +/// 没有这条,扫描可能因为判据本身坏掉而永远为真(假绿)。 +/// 样本**运行时拼、且源码里不出现十六进制字面量**(用 `char` 数组拼):否则这条自检的 +/// 样本本身会被扫描抓成 offender——自指陷阱(实测踩过一次)。 +#[test] +fn the_uuid_literal_detector_bites_three_shapes() { + let part = |chars: &[char]| chars.iter().collect::(); + let head = part(&['8', '3', 'f', 'd', '9', 'b', '2', 'f']); + let p2 = part(&['d', 'a', '8', '9']); + let p3 = part(&['4', '1', 'f', '7']); + let p4 = part(&['b', '4', '1', 'b']); + let tail = part(&['1', '3', '7', '4', '2', 'a', '7', '3', '8', '4', '1', 'c']); + let uuid = format!("{head}-{p2}-{p3}-{p4}-{tail}"); + + // ① 单个完整字面量(基线)。 + let plain = format!("let id = \"{uuid}\";"); + assert!(contains_uuid_literal(&plain), "{plain}"); + // ② `concat!` 拼出来(复审点名的形态之一)。 + let concatenated = format!("const ID: &str = concat!(\"{head}-\", \"{p2}-{p3}-{p4}-{tail}\");"); + assert!(contains_uuid_literal(&concatenated), "{concatenated}"); + // ③ 跨行两段字面量。 + let split_lines = + format!("let id = concat!(\n \"{head}-{p2}-\",\n \"{p3}-{p4}-{tail}\"\n);"); + assert!(contains_uuid_literal(&split_lines), "{split_lines}"); + // ④ `format!` 逐段拼(无连字符落点,靠「连续纯 hex 字面量累计 ≥32 且 ≥2 段」抓)。 + let formatted = format!( + "let id = format!(\"{{}}-{{}}-{{}}-{{}}-{{}}\", \"{head}\", \"{p2}\", \"{p3}\", \"{p4}\", \"{tail}\");" + ); + assert!(contains_uuid_literal(&formatted), "{formatted}"); + + // 反例:这些都不许误杀。 + assert!(!contains_uuid_literal("// 0000-test-client")); + assert!(!contains_uuid_literal("a-b-c-d")); + assert!(!contains_uuid_literal( + "let uri = \"http://localhost/auth?client_id=not-a-uuid\";" + )); + // 单个字面量的 32 位 hex = 既有测试数据(`session.rs` 的 profile UUID),不算 client id。 + let profile = format!("let profile_id = \"{head}{p2}{p3}{p4}{tail}\";"); + assert!(!contains_uuid_literal(&profile), "{profile}"); + // 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。 + assert!(!contains_uuid_literal( + "let x = \"abcdefgh-1234-5678-9012-12345678901\";" + )); +} diff --git a/crates/core/src/auth/microsoft/login.rs b/crates/core/src/auth/microsoft/login.rs index d474e2e..ad50f47 100644 --- a/crates/core/src/auth/microsoft/login.rs +++ b/crates/core/src/auth/microsoft/login.rs @@ -22,6 +22,8 @@ //! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**) //! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。 +use std::sync::atomic::{AtomicUsize, Ordering}; + use crate::auth::error::{AuthError, AuthResult}; use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt}; @@ -85,9 +87,10 @@ impl BrowserFallback { /// 一次「浏览器优先」登录的完整结果(M178)。 /// -/// `listeners_opened` 是**这次登录**为本地回调开的监听口个数(0 或 1)。它存在的唯一理由是 -/// 守卫能直接数:「没登记回环」「走设备码」这两条路上必须是 0——用进程级全局计数器做不到 -/// 这一点(并行跑的测试会互相搅动),所以计数跟着结果走。 +/// `listeners_opened` 是**这次登录**真的去开本地监听口的次数(0 或 1,`bind_counting` 在 +/// `TcpListener::bind` 现场 +1)。它存在的唯一理由是守卫能直接数:「没登记回环」「走设备码」 +/// 这两条路上必须是 0。用进程级全局计数器做不到这一点(并行跑的测试会互相搅动),所以计数 +/// 是 per-login 的 `AtomicUsize`,但**增长点在绑定现场**,不在「进没进某个分支」。 #[derive(Debug, Clone)] pub struct LoginOutcome { /// 拿到的会话(两条流任意一条走通) @@ -135,14 +138,16 @@ impl MicrosoftAuth { FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>), { let mut fallback: Option = None; - // 预检通过之后才会绑端口(`login_checked` 的顺序),所以只有「真去试了」才记 1。 - let mut bound_listener = false; + // 绑定现场的**真计数**(`CallbackServer::bind_counting` 每次调 `TcpListener::bind` 前 +1): + // 不是「进没进授权码分支」的代理指标——那正是复审 r1 P2-1 变异 D 打穿的地方。 + let listeners = AtomicUsize::new(0); if self.browser_first { match authcode::login_checked( &self.client, &self.endpoints, &self.client_id, self.authcode.clone(), + &listeners, present_browser, ) .await @@ -151,14 +156,13 @@ impl MicrosoftAuth { return Ok(LoginOutcome { session, browser: None, - listeners_opened: 1, + listeners_opened: listeners.load(Ordering::Relaxed), }) } - // 跳过:**没绑端口**(预检在绑定之前),所以计数仍是 0。 + // 跳过:**没绑端口**(预检在绑定之前),计数仍是 0。 Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note), Err(AuthError::Canceled) => return Err(AuthError::Canceled), Err(error) => { - bound_listener = true; self.record_failure("authcode_login", &error); let reason = if preflight::error_means_unregistered(&error) { BrowserFallbackReason::RedirectNotRegistered @@ -185,7 +189,7 @@ impl MicrosoftAuth { Ok(LoginOutcome { session, browser: fallback, - listeners_opened: usize::from(bound_listener), + listeners_opened: listeners.load(Ordering::Relaxed), }) } } diff --git a/crates/core/src/auth/microsoft/preflight.rs b/crates/core/src/auth/microsoft/preflight.rs index dac1e6c..f2ef0c6 100644 --- a/crates/core/src/auth/microsoft/preflight.rs +++ b/crates/core/src/auth/microsoft/preflight.rs @@ -23,10 +23,13 @@ //! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状) //! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。 //! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上 -//! 监听口一次都不开(守卫断言 `callback::bind_attempts()` 不涨)。 +//! 监听口一次都不开(守卫断言 `LoginOutcome::listeners_opened == 0`,那个数是 +//! [`super::callback::CallbackServer::bind_counting`] 在 `TcpListener::bind` 现场加的)。 //! //! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。 -//! `Unknown`(网络问题)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。 +//! `Unknown`(网络问题/整体超时)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。 +//! 整次预检有 [`PREFLIGHT_TIMEOUT`] 的上限:服务器接了连接却不出响应时,不能让「浏览器登录」 +//! 按钮把界面卡在 Requesting(取消按钮只收面板、停不掉已经跑起来的线程)。 use reqwest::Client; @@ -44,6 +47,14 @@ pub const PROBE_PORT: u16 = 53_691; /// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。 const PROBE_STATE: &str = "ull-redirect-probe"; +/// 整次预检的时间上限(复审 r1 P2-2)。 +/// +/// 为什么必须有:下载引擎那个 HTTP 客户端只设了 `connect_timeout`(10s),**没有**整体超时 +/// ——`read_timeout` 从未应用到 reqwest 客户端。授权端点接了连接却不出响应(或 header 后停住)时, +/// 这一次「用浏览器登录」会把界面卡在 Requesting,而取消按钮只收面板、停不掉已经跑起来的线程。 +/// 超时按 [`RedirectRegistration::Unknown`] 处理:回退设备码,原因码「没确认」。 +pub const PREFLIGHT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + /// 授权端点对 `redirect_uri` 的态度。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum RedirectRegistration { @@ -84,6 +95,42 @@ pub(super) async fn check( endpoints: &MicrosoftEndpoints, client_id: &str, redirect_uri: &RedirectUri, +) -> RedirectRegistration { + check_with_deadline( + client, + endpoints, + client_id, + redirect_uri, + PREFLIGHT_TIMEOUT, + ) + .await +} + +/// [`check`] 的可注入 deadline 版本(单测用短 deadline,不必等 10 秒)。 +pub(super) async fn check_with_deadline( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + redirect_uri: &RedirectUri, + deadline: std::time::Duration, +) -> RedirectRegistration { + match tokio::time::timeout(deadline, probe(client, endpoints, client_id, redirect_uri)).await { + Ok(verdict) => verdict, + Err(_) => RedirectRegistration::Unknown { + detail: format!( + "预检 {} 毫秒内没有结论,按「没确认」处理", + deadline.as_millis() + ), + }, + } +} + +/// 真正的预检请求(不含超时;由 [`check_with_deadline`] 套 deadline)。 +async fn probe( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + redirect_uri: &RedirectUri, ) -> RedirectRegistration { let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE); let response = client.get(&url).send().await; @@ -175,9 +222,13 @@ fn snippet(body: &str) -> String { #[cfg(test)] mod tests { - use super::{error_means_unregistered, rejects_redirect_uri, snippet, RedirectRegistration}; + use super::{ + check_with_deadline, error_means_unregistered, rejects_redirect_uri, snippet, + RedirectRegistration, + }; use crate::auth::body::ResponseDetail; use crate::auth::error::AuthError; + use crate::auth::microsoft::{MicrosoftEndpoints, RedirectUri}; /// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。 #[test] @@ -234,6 +285,50 @@ redirect+URI+registered+for+this+client+application."; .is_registered()); } + /// **复审 r1 P2-2**:服务器接了连接却永不出响应时,预检必须靠**整体 deadline** 收场 + /// (不是 connect_timeout:连接早就建好了),结论是「没确认」⇒ 上层回退设备码。 + /// + /// 抓得住:把 `tokio::time::timeout` 去掉(那条路会一直挂着,本用例在 deadline 后 + /// 断言超时不出来,必然失败)。 + /// 抓不住:真机上网关的其它卡法(这里只有一个「不回字」的假服务器)。 + #[tokio::test] + async fn a_silent_authorize_server_hits_the_overall_deadline() { + // 假授权端点:accept 之后一个字节都不写(connect 成功、读永远等不到)。 + let silent = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("假服务器起不来"); + let port = silent.local_addr().expect("地址").port(); + tokio::spawn(async move { + let mut held = Vec::new(); + loop { + let Ok((stream, _)) = silent.accept().await else { + break; + }; + held.push(stream); + } + }); + + let client = + crate::download::http::build_client(&crate::download::config::EngineConfig::default()) + .expect("HTTP 客户端"); + let mut endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1"); + endpoints.authorize = format!("http://127.0.0.1:{port}/authorize"); + let verdict = check_with_deadline( + &client, + &endpoints, + "test-client-id", + &RedirectUri::new(53_691), + std::time::Duration::from_millis(200), + ) + .await; + match verdict { + RedirectRegistration::Unknown { detail } => { + assert!(detail.contains("200"), "文案要带 deadline:{detail}"); + } + other => panic!("静默服务器必须走 Unknown,实际 {other:?}"), + } + } + #[test] fn snippet_is_single_line_and_capped() { let long = format!("a\nb\t{}", "x".repeat(400)); diff --git a/crates/core/tests/auth/microsoft_msalogin.rs b/crates/core/tests/auth/microsoft_msalogin.rs index b48bfd9..141da54 100644 --- a/crates/core/tests/auth/microsoft_msalogin.rs +++ b/crates/core/tests/auth/microsoft_msalogin.rs @@ -104,6 +104,13 @@ async fn unregistered_redirect_falls_back_to_the_device_code() { outcome.listeners_opened, 0, "没登记回环时不许绑本地端口(预检必须在绑定之前)" ); + // ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。 + // 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。 + assert_eq!( + mock.bodies_on("/authorize").len(), + 1, + "浏览器优先路必须真的发一次回环注册预检" + ); mock.stop(); } diff --git a/crates/i18n/src/catalog/account.rs b/crates/i18n/src/catalog/account.rs index c902306..56bcdd1 100644 --- a/crates/i18n/src/catalog/account.rs +++ b/crates/i18n/src/catalog/account.rs @@ -57,7 +57,9 @@ macro_rules! account_entries { AccountDeviceRetry => ("Try again", "重试"), AccountDeviceCancel => ("Cancel", "取消"), AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"), - AccountDeviceUseCode => ("Use a device code instead", "改用设备码"), + // M178 复审 r1 P2-5:这是**主按钮**(设备码流),不能读起来像备选。 + AccountDeviceSignIn => ("Sign in with your Microsoft account", "使用 Microsoft 账户登录"), + AccountDeviceFallbackHint => ("No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"), // ── 第三方站表单 AccountServerLabel => ("Server address", "服务器地址"), AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"), diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 577af2f..fab5ef2 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -135,7 +135,8 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("AccountDeviceRetry", "Try again", "重试"), ("AccountDeviceCancel", "Cancel", "取消"), ("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"), - ("AccountDeviceUseCode", "Use a device code instead", "改用设备码"), + ("AccountDeviceSignIn", "Sign in with your Microsoft account", "使用 Microsoft 账户登录"), + ("AccountDeviceFallbackHint", "No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"), ("AccountServerLabel", "Server address", "服务器地址"), ("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"), ("AccountServerProbe", "Check server", "校验服务器"), diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 3398d8f..e98095a 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -188,12 +188,11 @@ impl LoginJob { /// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 /// -/// 设备码流不依赖任何应用注册项(Azure 那边只需要一个公共客户端 id),所以它是唯一 -/// 「在官方产物上开箱可用」的路。它**一个本地监听口都不开**,也就不会撞上 -/// `redirect_uri is not valid` 那张 AAD 错误页(守卫断言见 `callback::bind_attempts`)。 +/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**, +/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。 pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-login", move || { - let auth = match microsoft_auth(&env) { + let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) { Ok(auth) => auth, Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; @@ -212,16 +211,13 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { /// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 /// -/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试: -/// `core` 先做回环重定向 URI 的注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码; -/// 试了但失败(超时/被拒/端口绑不上)同样回退。两条路都不满意时用户还能在面板上看到 -/// 一句「为什么改用设备码 + 怎么修」(结构化码 → i18n,原文只进日志)。 -/// -/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的 -/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。 +/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做 +/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。 pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-browser", move || { - let auth = match microsoft_auth(&env) { + // **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位, + // 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。 + let auth = match msa_auth(&env, MsaEntry::Browser, &job) { Ok(auth) => auth, Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; @@ -273,16 +269,75 @@ fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote { } } -/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。 -fn microsoft_auth(env: &HostEnv) -> AuthResult { +/// 微软登录的两个入口(决定 `MicrosoftAuth::browser_first`,M178)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MsaEntry { + /// 默认路:设备码。**一个监听口都不开**,也永远不做回环预检。 + DeviceCode, + /// 用户显式点了「用浏览器登录」:浏览器优先(预检 → 授权码 → 失败自动回退设备码)。 + Browser, +} + +impl MsaEntry { + /// 这个入口要不要把 `browser_first` 置位。 + const fn browser_first(self) -> bool { + matches!(self, Self::Browser) + } +} + +/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。 +/// +/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话, +/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。 +fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult { + #[cfg(test)] + test_seam::record(_job, entry); MicrosoftAuth::from_env().map(|auth| { - auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( - env.layout.logs(), - ))) + auth.with_browser_first(entry.browser_first()) + .with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))) }) } -/// `microsoft_auth` 失败 → 原因码。 +/// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。 +/// +/// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到 +/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用 +/// 的一刻就把模式记下来,把调用点变成可断言的事实。 +/// +/// 为什么按 `LoginJob` 的取消槽指针分桶:同一进程里别的用例也在调这两个入口,一个全局 +/// 「最后一次」记录会串台(实测过:并行跑时读到别人的 Browser ⇒ 假红)。 +#[cfg(test)] +mod test_seam { + use std::sync::{Arc, Mutex}; + + use super::{LoginJob, MsaEntry}; + + static ENTRIES: Mutex> = Mutex::new(Vec::new()); + + fn key(job: &LoginJob) -> usize { + Arc::as_ptr(&job.cancelled) as usize + } + + pub(super) fn record(job: &LoginJob, entry: MsaEntry) { + let key = key(job); + let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner()); + entries.push((key, entry.browser_first())); + } + + /// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。 + pub(super) fn mode_for(job: &LoginJob) -> Option { + let key = key(job); + let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner()); + entries + .iter() + .find(|(recorded, _)| *recorded == key) + .map(|(_, browser_first)| *browser_first) + } +} + +/// `msa_auth` 失败 → 原因码。 /// /// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的 /// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是 diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index e7679ac..ba5558e 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -186,6 +186,77 @@ fn login_job_cancel_flag_is_shared() { assert!(clone.is_cancelled()); } +/// **M178 复审 r1 P1-1 的守卫**:生产路径上,「用浏览器登录」这颗按钮必须真的把 +/// `browser_first` 置位,设备码那颗必须不置位。 +/// +/// 修复前实测过的形态:`start_browser_login` 用共享的 `microsoft_auth(&env)`(默认 +/// `browser_first=false`),于是 `browser_then_device_login` 里整段预检/回退编排不执行—— +/// 按钮静默退化成设备码、连一句说明都不出,`preflight` / `BrowserFallbackReason` / +/// `BrowserLoginNote` / 三条 i18n 文案全成不可达代码。 +/// +/// 抓得住(本用例就是为此写的):把 `msa_auth(&env, MsaEntry::Browser)` 改回 +/// `MsaEntry::DeviceCode`(或删掉 `.with_browser_first(...)` 那一跳以外的模式选择), +/// 记录会变成 `false` ⇒ 红。 +/// 抓不住:真的去连微软(无 client id 的测试构建里两条入口都在网络之前早退)。 +#[test] +fn the_browser_button_actually_turns_on_browser_first() { + let dir = std::env::temp_dir().join(format!("ull-msa-entry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); + + // 设备码入口:不许碰浏览器(也不许碰回环预检)。 + let device_job = LoginJob::new(); + let (tx, _rx) = super::super::job::channel(); + start_login(env.clone(), tx, device_job.clone()); + assert_eq!( + wait_for_entry(&device_job), + Some(false), + "设备码入口把 browser_first 置成了 true:默认路会去开监听口/弹浏览器" + ); + + // 浏览器入口:**必须**置位,否则预检与回退整段不可达。 + let browser_job = LoginJob::new(); + let (tx, _rx) = super::super::job::channel(); + start_browser_login(env, tx, browser_job.clone()); + assert_eq!( + wait_for_entry(&browser_job), + Some(true), + "「用浏览器登录」没有置位 browser_first:按钮会静默退化成设备码(复审 r1 P1-1 的洞)" + ); +} + +/// 等登录线程把「这个 job 的入口模式」记下来(线程是 `spawn` 出去的,最多等这么久)。 +/// +/// 按 job 分桶而不是读「最后一次」:同一进程里别的用例也在调这两个入口(实测会串台假红)。 +fn wait_for_entry(job: &LoginJob) -> Option { + for _ in 0..200 { + if let Some(flag) = super::test_seam::mode_for(job) { + return Some(flag); + } + std::thread::sleep(Duration::from_millis(10)); + } + None +} + +/// 置位不是「记了个数」:`msa_auth` 必须把模式真的落到 `MicrosoftAuth::browser_first` 上。 +/// +/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一, +/// 就会出现「日志说置位了、实际没置位」这类假绿。 +#[test] +fn the_entry_mode_lands_on_the_auth_instance() { + assert!(!MsaEntry::DeviceCode.browser_first()); + assert!(MsaEntry::Browser.browser_first()); + // 无 client id 时 `from_env` 早退,所以这里用显式 id 走同一个 builder 链。 + let auth = unlucky_core::auth::microsoft::MicrosoftAuth::new("0000-entry-test") + .expect("构造") + .with_browser_first(MsaEntry::Browser.browser_first()); + assert!( + auth.browser_first(), + "with_browser_first 没把字段置上(core 里 browser_then_device_login 会整段跳过)" + ); +} + /// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同—— /// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。 /// From 011d525e8de07d4a12bbcea361c6d44851bc52ee Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:24:35 +0800 Subject: [PATCH 06/33] =?UTF-8?q?feat(addon):=20Modrinth=20=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E4=BE=9D=E8=B5=96=20BFS=20=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E4=B8=A4=E6=A1=A3=E5=A4=B1=E8=B4=A5=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model 纳入 `dependencies`(project_id / version_id / file_name + required / optional / incompatible / embedded / unknown 五值),只跟 required 递归; - 新增 `online::dependency`(请求/计划/跳过的形状)与 `online::resolve` (BFS:规范项目 id 去重防环、每项目只解一次版本、同层按项目键字典序、 默认深度 6 且截断写进计划、父子关系进 `required_by`); - 失败从类型上分两档:`Incompatible`(所选版本/加载器没有匹配项)与 `Unreachable`(并保留叶子成因 NotFound/NotConnected/…),`retry_request()` 只含失败项,解析永不返回 Err(失败是数据,不连坐); - 盘上第二道去重:`present_key` 归一化文件名(小写、砍路径、去 `.disabled`), 命中即跳过下载,但依赖图仍然展开; - presenter 装入路径改成「解析计划 → 逐个下载」,文案把两档分开写并列出 「可只重试这 N 个」;`KNOWN_LOADERS` 补 iris/optifine(光影的加载器); - 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。 --- crates/core/src/addon/online/dependency.rs | 287 ++++++++++++++++ crates/core/src/addon/online/mod.rs | 24 +- crates/core/src/addon/online/model/mod.rs | 4 +- crates/core/src/addon/online/model/parse.rs | 30 +- crates/core/src/addon/online/model/query.rs | 15 +- crates/core/src/addon/online/model/types.rs | 82 +++++ crates/core/src/addon/online/resolve.rs | 257 +++++++++++++++ .../core/src/addon/online/tests/dependency.rs | 306 ++++++++++++++++++ .../src/addon/online/tests/fixtures_deps.rs | 277 ++++++++++++++++ crates/core/src/addon/online/tests/mod.rs | 2 + crates/presenter/src/host/addon.rs | 4 +- crates/presenter/src/host/addon/install.rs | 191 +++++------ crates/presenter/src/host/addon/plan.rs | 218 +++++++++++++ crates/presenter/src/host/addon/tests.rs | 187 +++++++++++ 14 files changed, 1787 insertions(+), 97 deletions(-) create mode 100644 crates/core/src/addon/online/dependency.rs create mode 100644 crates/core/src/addon/online/resolve.rs create mode 100644 crates/core/src/addon/online/tests/dependency.rs create mode 100644 crates/core/src/addon/online/tests/fixtures_deps.rs create mode 100644 crates/presenter/src/host/addon/plan.rs diff --git a/crates/core/src/addon/online/dependency.rs b/crates/core/src/addon/online/dependency.rs new file mode 100644 index 0000000..12b2c5e --- /dev/null +++ b/crates/core/src/addon/online/dependency.rs @@ -0,0 +1,287 @@ +//! 依赖图谱的**形状**:一次解析要什么([`ResolveRequest`])、出什么([`DependencyPlan`])。 +//! +//! 算法在 [`super::resolve`];本文件只放类型与不变量,于是「两档失败从类型上可分辨」这件事 +//! 不必读算法也能核对。 +//! +//! # 三份名单,一份都不许丢 +//! +//! | 名单 | 含义 | 上层该做什么 | +//! |------|------|--------------| +//! | [`DependencyPlan::nodes`] | 要下的(BFS 顺序、父子关系齐全) | 逐个下载 | +//! | [`DependencyPlan::skipped`] | 目标目录里已经有同名文件 | 提示「已存在,跳过」 | +//! | [`DependencyPlan::failures`] | 装不了的,**每条自带 [`FailureKind`]** | 两档分开说;进可重试名单 | +//! +//! `truncated` 是第四样东西:撞到 [`DEFAULT_MAX_DEPTH`] 而没展开的项目。它既不是成功也不是 +//! 失败(站点没拒绝,我们主动停),但**必须**露出来——静默截断会让用户看着「装好了」而缺前置。 +//! +//! # 两档为什么不能合成一句 +//! +//! 「所选版本/加载器没有匹配项」与「网络异常 / 项目不存在」要用户做的事完全不同: +//! 前者换版本或换加载器,后者检查网络再试。字符串错误只能靠 `contains("网络")` 猜, +//! 一改文案就失效;[`FailureKind`] 把判据放在类型上,叶子成因(404 / 连不上 / 5xx) +//! 另存在 [`DependencyFailure::cause`] 上,允许上层再细分而不用重新判。 + +use super::model::{VersionFile, VersionQuery}; + +/// 默认最大深度(根 = 0)。六层足够覆盖真实套娃(前置的前置的前置……), +/// 同时给环与异常链一个硬边界——撞上就记 [`DependencyPlan::truncated`],**不静默截断**。 +pub const DEFAULT_MAX_DEPTH: u8 = 6; + +/// 目标目录里已存在的文件(调用方扫描后给;core 不读盘)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PresentFile { + /// 文件名(可以带路径,比对时会砍掉) + pub name: String, +} + +impl PresentFile { + /// 记一个已存在的文件名。 + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } + + /// 归一化后的比对键(见 [`present_key`])。 + #[must_use] + pub fn key(&self) -> String { + present_key(&self.name) + } +} + +/// 文件名 → 比对键:只留文件名、小写、去掉 `.disabled` 后缀。 +/// +/// 为什么带 `.disabled`:本仓用**路径后缀**表示禁用(`mods/x.jar.disabled`,见 +/// `core::addon::export`),禁用中的 jar 也是「装过」,再下一份会和它并存。 +#[must_use] +pub fn present_key(name: &str) -> String { + let base = name + .rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim() + .to_ascii_lowercase(); + base.strip_suffix(".disabled").unwrap_or(&base).to_string() +} + +/// 一次解析请求。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolveRequest { + /// 起点(项目 id 或 slug);同层处理时按项目键字典序,与这里给的顺序无关 + pub roots: Vec, + /// 目标游戏版本(选版本用;`None` = 不限) + pub game_version: Option, + /// 目标加载器(选版本用;`None` = 不限) + pub loader: Option, + /// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`]) + pub max_depth: u8, + /// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的) + pub present: Vec, +} + +impl Default for ResolveRequest { + fn default() -> Self { + Self { + roots: Vec::new(), + game_version: None, + loader: None, + max_depth: DEFAULT_MAX_DEPTH, + present: Vec::new(), + } + } +} + +impl ResolveRequest { + /// 单根请求(内容库「装进实例」的入口形状)。 + #[must_use] + pub fn single(project: impl Into) -> Self { + Self { + roots: vec![project.into()], + ..Self::default() + } + } + + /// 这个项目的版本请求(过滤条件相同,只换项目)。 + #[must_use] + pub fn version_query(&self, project: &str) -> VersionQuery { + VersionQuery { + project: project.to_string(), + game_version: self.game_version.clone(), + loader: self.loader.clone(), + } + } +} + +/// 计划里的一条「要装什么」。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedNode { + /// 规范项目 id(站点回的 `project_id`;空则用请求时的键) + pub project: String, + /// 当初请求它用的键(slug 或 id)——重试要用同一个 + pub requested_as: String, + /// 版本 id + pub version_id: String, + /// 版本名 + pub version_name: String, + /// 版本号 + pub version_number: String, + /// 要下载的那个文件(`primary` 优先,见 `Version::primary_file`) + pub file: VersionFile, + /// 距根的层数(根 = 0) + pub depth: u8, + /// 谁要求装它(规范项目 id,排序去重;根为空 = 用户自己点的) + pub required_by: Vec, +} + +impl PlannedNode { + /// 是用户点的那个项目吗(根 = 层数 0)。 + /// + /// 判据是 `depth` 而不是 `required_by.is_empty()`:环里根也会被别人「要求」 + /// (A ⇄ B 时 A 的 `required_by` 里有 B),但它在计划里仍是起点。 + #[must_use] + pub fn is_root(&self) -> bool { + self.depth == 0 + } +} + +/// 为什么跳过。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + /// 目标目录里已经有同名文件(含 `.disabled`) + AlreadyPresent, +} + +impl SkipReason { + /// 稳定字符串 id(日志 / DTO 用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AlreadyPresent => "already_present", + } + } +} + +/// 一条被跳过的(本来要下载的)项目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedNode { + /// 规范项目 id + pub project: String, + /// 请求它用的键 + pub requested_as: String, + /// 计划里的文件名(在目录里找到了同名的那个) + pub file_name: String, + /// 距根的层数 + pub depth: u8, + /// 谁要求装它 + pub required_by: Vec, + /// 跳过原因 + pub reason: SkipReason, +} + +/// 失败的两档(**类型上就分开,不许拼成一句「下载失败」**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureKind { + /// 站点有这个项目,但所选游戏版本 / 加载器下没有可装的条目 + /// (版本列表为空,或选中的版本没有可下载的主文件)。 + /// + /// 用户该做的动作是**换版本或换加载器**;原样重试得到的还是这一档。 + Incompatible, + /// 取不到:没联网 / 项目不存在 / 站点出错 / 响应坏。 + /// + /// 用户该做的动作是**检查网络后再试**;具体叶子成因保留在 + /// [`DependencyFailure::cause`] 上(`NotFound` 与 `NotConnected` 不能混为一谈)。 + Unreachable, +} + +impl FailureKind { + /// 稳定字符串 id(日志 / 命令行输出用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Incompatible => "incompatible", + Self::Unreachable => "unreachable", + } + } +} + +/// 一条解析失败(进「可重试名单」的就是它们)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyFailure { + /// 请求它用的键(重试也用它) + pub requested_as: String, + /// 当初因为谁而被要求(规范项目 id;根为空) + pub required_by: Vec, + /// 距根的层数 + pub depth: u8, + /// 哪一档 + pub kind: FailureKind, + /// 叶子成因(只有 [`FailureKind::Unreachable`] 有) + pub cause: Option, + /// 人读说明(站点/引擎原话 + 上下文,不是界面文案) + pub message: String, +} + +/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyPlan { + /// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`) + pub request: ResolveRequest, + /// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序) + pub nodes: Vec, + /// 目录里已经有了、不用再下的 + pub skipped: Vec, + /// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`]) + pub failures: Vec, + /// 撞到 `max_depth` 而没展开的项目键(排序去重) + pub truncated: Vec, +} + +impl DependencyPlan { + /// 空计划(`resolve` 从这里开始填)。 + #[must_use] + pub fn new(request: &ResolveRequest) -> Self { + Self { + request: request.clone(), + nodes: Vec::new(), + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + } + } + + /// 一条失败都没有吗(空循环也算干净)。 + #[must_use] + pub fn is_clean(&self) -> bool { + self.failures.is_empty() + } + + /// 某一档的失败(两档分开看时用)。 + pub fn failures_of(&self, kind: FailureKind) -> impl Iterator { + self.failures.iter().filter(move |item| item.kind == kind) + } + + /// 「可重试名单」:失败项原样重启一次解析的请求。 + /// + /// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。 + /// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器, + /// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。 + #[must_use] + pub fn retry_request(&self) -> ResolveRequest { + ResolveRequest { + roots: self + .failures + .iter() + .map(|failure| failure.requested_as.clone()) + .collect(), + ..self.request.clone() + } + } + + /// 这个项目为什么在计划里(规范 id → 要求装它的项目)。`None` = 不在计划里。 + #[must_use] + pub fn required_by(&self, project: &str) -> Option<&[String]> { + self.nodes + .iter() + .find(|node| node.project == project) + .map(|node| node.required_by.as_slice()) + } +} diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index 7886548..0c05038 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -46,23 +46,43 @@ //! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | //! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | //! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 | +//! | [`dependency`] | 依赖图谱的形状(请求 / 计划 / **两档失败**) | +//! | [`resolve`] | BFS 解析(只跟 `required`):去重、按项目缓存、深度上限、确定性顺序 | +//! +//! # 一次「装一个模组」到底发几个请求 +//! +//! M186 之前的实测口径(也是现在仍然成立的基座):`GET /v2/project/{id}/version` 一次 +//! (带 `game_versions` / `loaders` 两个数组参数)+ 文件下载一次 = **2 个请求**, +//! 其中**没有任何**依赖解析——`dependencies` 字段根本没进模型,所以「装了模组但进游戏崩」 +//! 这条路只能靠用户自己发现缺前置。 +//! +//! M186 之后:根项目同样是 2 个请求(版本 + 下载),每个 `required` 前置再各加 1 次版本 +//! 请求 + 1 次下载。与参考实现相比省掉了「每个前置再查一次 `/v2/project/{id}` 换 slug」, +//! 因为依赖里给的就是项目 id,而 `/project/{id}/version` 认 id。 pub mod complete; +pub mod dependency; pub mod download; pub mod model; pub mod modrinth; +pub mod resolve; pub mod transport; pub use complete::identity_of; +pub use dependency::{ + present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, + ResolveRequest, SkipReason, SkippedNode, DEFAULT_MAX_DEPTH, +}; pub use download::{ digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, FetchArgs, RETRY_ATTEMPTS, }; pub use model::{ - ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex, - Version, VersionFile, VersionQuery, VersionType, + ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, + SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType, }; pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API}; +pub use resolve::resolve; pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport}; #[cfg(test)] diff --git a/crates/core/src/addon/online/model/mod.rs b/crates/core/src/addon/online/model/mod.rs index 5faf941..67af82b 100644 --- a/crates/core/src/addon/online/model/mod.rs +++ b/crates/core/src/addon/online/model/mod.rs @@ -32,6 +32,6 @@ mod types; pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions}; pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS}; pub use types::{ - FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery, - VersionType, + Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, + VersionFile, VersionQuery, VersionType, }; diff --git a/crates/core/src/addon/online/model/parse.rs b/crates/core/src/addon/online/model/parse.rs index e7d1ceb..175810e 100644 --- a/crates/core/src/addon/online/model/parse.rs +++ b/crates/core/src/addon/online/model/parse.rs @@ -8,7 +8,10 @@ use std::collections::BTreeMap; use serde::Deserialize; use super::query::KNOWN_LOADERS; -use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType}; +use super::types::{ + Dependency, DependencyType, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, + VersionType, +}; use crate::addon::online::transport::{OnlineError, OnlineErrorKind}; // --------------------------------------------------------------- 解析 @@ -112,6 +115,21 @@ struct RawVersion { downloads: u64, #[serde(default)] files: Vec, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct RawDependency { + #[serde(default)] + project_id: Option, + #[serde(default)] + version_id: Option, + #[serde(default)] + file_name: Option, + // 站点给的是 `required` / `optional` / `incompatible` / `embedded`;认不出 ⇒ `Unknown` + #[serde(default)] + dependency_type: String, } #[derive(Debug, Default, Deserialize)] @@ -238,6 +256,16 @@ fn version_of(raw: RawVersion) -> Version { primary: file.primary, }) .collect(), + dependencies: raw + .dependencies + .into_iter() + .map(|dependency| Dependency { + project_id: non_empty(dependency.project_id), + version_id: non_empty(dependency.version_id), + file_name: non_empty(dependency.file_name), + dependency_type: DependencyType::parse(&dependency.dependency_type), + }) + .collect(), } } diff --git a/crates/core/src/addon/online/model/query.rs b/crates/core/src/addon/online/model/query.rs index 6c660e5..1e2b57f 100644 --- a/crates/core/src/addon/online/model/query.rs +++ b/crates/core/src/addon/online/model/query.rs @@ -83,7 +83,20 @@ impl SortIndex { /// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**, /// 直接整份当加载器会画出假加载器)。 -pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"]; +/// +/// 后两项是**光影**的加载器(Modrinth 把 shader 的 `iris` / `optifine` 也放在 `categories` +/// 里):不登记它们,光影检索结果的 `loaders` 就会恒为空,页面既画不出「支持 Iris」, +/// 也用不上「只看 Iris」这种判断。 +pub const KNOWN_LOADERS: [&str; 8] = [ + "fabric", + "forge", + "neoforge", + "quilt", + "liteloader", + "rift", + "iris", + "optifine", +]; /// 一次检索请求(分页游标也在里面:换页就是换一份 query)。 #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/core/src/addon/online/model/types.rs b/crates/core/src/addon/online/model/types.rs index 532fb64..8bb0af9 100644 --- a/crates/core/src/addon/online/model/types.rs +++ b/crates/core/src/addon/online/model/types.rs @@ -140,6 +140,67 @@ impl VersionType { } } +/// 前置依赖的类型(`/version` 的 `dependencies[].dependency_type`)。 +/// +/// # 为什么要留 `Unknown` 而不是「认不出就当可选」 +/// +/// 站点以后加新取值时,**只有 `Required` 才该触发递归**。认不出的值落进 `Unknown`, +/// 于是「站点改名」的下场是「少装一个前置」(可观察、可补),而不是「把所有建议项都当必备项 +/// 递归拉一遍」(一次装模组变成几十个请求,还装了一堆不兼容的东西)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum DependencyType { + /// 必须装(BFS **只跟这一档**) + Required, + /// 可选(装不装都能跑) + #[default] + Optional, + /// 明确不兼容(**不要**和它一起装) + Incompatible, + /// 已内嵌在被依赖的文件里(不用另外装) + Embedded, + /// 站点给了个我们不认识的取值 + Unknown, +} + +impl DependencyType { + /// 站点原文。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Required => "required", + Self::Optional => "optional", + Self::Incompatible => "incompatible", + Self::Embedded => "embedded", + Self::Unknown => "unknown", + } + } + + /// 从站点原文解析;认不出(含空串)返回 [`Self::Unknown`]。 + #[must_use] + pub fn parse(raw: &str) -> Self { + match raw.trim() { + "required" => Self::Required, + "optional" => Self::Optional, + "incompatible" => Self::Incompatible, + "embedded" => Self::Embedded, + _ => Self::Unknown, + } + } +} + +/// 版本声明的一条前置(`dependencies[]`)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Dependency { + /// 依赖的**项目** id(BFS 的地址;有了它就不用像参考实现那样再查一次 slug) + pub project_id: Option, + /// 依赖的版本 id(站点基本只对 `embedded` 给) + pub version_id: Option, + /// 站点给的文件名(只对 `embedded` / `incompatible` 有意义) + pub file_name: Option, + /// 依赖类型 + pub dependency_type: DependencyType, +} + /// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。 #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Version { @@ -163,6 +224,8 @@ pub struct Version { pub downloads: u64, /// 文件列表 pub files: Vec, + /// 前置声明(站点原文顺序;空 = 站点没说 / 没有) + pub dependencies: Vec, } impl Version { @@ -176,6 +239,25 @@ impl Version { .find(|file| file.primary) .or_else(|| self.files.first()) } + + /// 要跟下去的**前置项目 id**:只取 `required`,去掉空值,**排序去重**。 + /// + /// 排序在这里做(不是让调用方排):BFS 的展开顺序必须是数据本身决定的, + /// 否则站点把 `dependencies` 换个顺序、或者用 `HashMap` 收一遍,测试就会随机红。 + #[must_use] + pub fn required_project_ids(&self) -> Vec<&str> { + let mut ids: Vec<&str> = self + .dependencies + .iter() + .filter(|dependency| dependency.dependency_type == DependencyType::Required) + .filter_map(|dependency| dependency.project_id.as_deref()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } } /// 版本里的一个文件。 diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs new file mode 100644 index 0000000..977bda1 --- /dev/null +++ b/crates/core/src/addon/online/resolve.rs @@ -0,0 +1,257 @@ +//! BFS 依赖解析(**只跟 `required`**)。形状在 [`super::dependency`],这里只有算法。 +//! +//! # 与参考实现(Python 版 Modrinth 下载器)的四处差别 +//! +//! | 参考实现 | 这里 | 为什么 | +//! |---|---|---| +//! | 每个前置再 `GET /v2/project/{id}` 换 slug | **不查**,直接拿 `dependencies[].project_id` 当地址 | 依赖里给的就是项目 id,`/project/{id}/version` 认它;一次安装省掉每个前置一个请求 | +//! | 失败名单是字符串,靠括号里的中文区分「不兼容」与「网络异常」 | [`FailureKind`] 枚举 | 「哪一档」要能从类型上判,不能靠 `contains("网络")` | +//! | 一个 dict 兼做缓存与去重 | 键 = **规范项目 id**,别名单设一张表 | 同一项目可能一次以 slug、一次以 id 出现;只按请求串去重会把它装两遍 | +//! | 队列按 `dependencies` 原文顺序展开 | 每层按项目键**字典序**处理 | 顺序必须是数据决定的,否则测试靠 HashMap 运气 | +//! +//! # 失败是**数据**,不是 `Err` +//! +//! [`resolve`] 永远返回一个 [`DependencyPlan`]:某个前置取不到版本时,它进 `failures`, +//! 解析继续做别的分支。若中途 `return Err`,用户看到的是「装这个模组失败」,而真正该看到的 +//! 是「主文件装上了,缺 fabric-api(不兼容 1.20.1/fabric)」+一份可单独重试的名单。 +//! +//! # 两张去重网 +//! +//! 1. **图内**:同一项目只解一次版本(`nodes` 里每个项目最多一条),多条依赖路径折进 +//! [`PlannedNode::required_by`]; +//! 2. **盘上**:目标目录里已经有同名文件(`.disabled` 也算)就进 `skipped`、**不再下载**。 +//! 这是「已装过的同一项目跳过并提示」里我们真能判的那一半;另一半(同项目**另一个版本** +//! 已装)需要实例侧的已装清单,本模块判不出来(见报告)。 +//! +//! 盘上判定只比文件名(`present` 由调用方扫目录得到,core 不读盘):内容级去重需要先算一份 +//! sha1,为了「跳过」把整个 `mods/` 哈希一遍不划算;同名文件就是同一份 jar 的最常见形态 +//! (我们落盘用站点给的文件名,不重命名)。 + +use std::collections::{BTreeMap, BTreeSet}; + +use super::dependency::{ + present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, + ResolveRequest, SkipReason, SkippedNode, +}; +use super::model::Version; +use super::modrinth::Modrinth; +use super::transport::Transport; + +/// 一层里还没处理的一项。 +struct Wave { + key: String, + depth: u8, + parents: Vec, +} + +/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。 +pub async fn resolve( + client: &Modrinth<'_, T>, + request: &ResolveRequest, +) -> DependencyPlan { + let mut plan = DependencyPlan::new(request); + let present: BTreeSet = request.present.iter().map(PresentFile::key).collect(); + // 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id) + let mut alias: BTreeMap = BTreeMap::new(); + let mut resolved: BTreeSet = BTreeSet::new(); + + let mut layer: Vec = Vec::new(); + for root in &request.roots { + let key = root.trim(); + if !key.is_empty() { + wave_in(&mut layer, key, 0, None); + } + } + + while !layer.is_empty() { + // 同层按项目键字典序:顺序由数据决定,不靠插入顺序或 HashMap 的运气。 + layer.sort_by(|left, right| left.key.cmp(&right.key)); + let mut next: Vec = Vec::new(); + for mut item in layer.drain(..) { + normalize(&mut item.parents); + // ① 已经解过(含「A 依赖 B、B 又依赖 A」的环)⇒ 只把父边折进已有节点 + if let Some(canonical) = alias.get(&item.key).cloned() { + fold_parents(&mut plan, &canonical, &item.parents); + continue; + } + let version = match client.versions(&request.version_query(&item.key)).await { + Ok(versions) => match versions.into_iter().next() { + Some(version) => version, + None => { + plan.failures.push(DependencyFailure { + requested_as: item.key, + required_by: item.parents, + depth: item.depth, + kind: FailureKind::Incompatible, + cause: None, + message: filter_message(request), + }); + continue; + } + }, + Err(error) => { + plan.failures.push(DependencyFailure { + requested_as: item.key, + required_by: item.parents, + depth: item.depth, + kind: FailureKind::Unreachable, + cause: Some(error.kind()), + message: format!( + "取版本失败({}):{}", + error.kind().as_str(), + error.message() + ), + }); + continue; + } + }; + let Some(file) = version + .primary_file() + .filter(|file| !file.url.trim().is_empty()) + .cloned() + else { + let message = format!( + "{} 的版本 {} 里没有可下载的主文件", + item.key, + version_label(&version) + ); + plan.failures.push(DependencyFailure { + requested_as: item.key, + required_by: item.parents, + depth: item.depth, + kind: FailureKind::Incompatible, + cause: None, + message, + }); + continue; + }; + + let canonical = canonical_of(&item.key, &version); + alias.insert(item.key.clone(), canonical.clone()); + alias + .entry(canonical.clone()) + .or_insert_with(|| canonical.clone()); + // ② 同一项目走了两条路(slug 与 id 各一次)⇒ 折边,不重复装 + if !resolved.insert(canonical.clone()) { + fold_parents(&mut plan, &canonical, &item.parents); + continue; + } + + let file_name = file.filename.clone(); + if present.contains(&present_key(&file_name)) { + plan.skipped.push(SkippedNode { + project: canonical.clone(), + requested_as: item.key.clone(), + file_name, + depth: item.depth, + required_by: item.parents.clone(), + reason: SkipReason::AlreadyPresent, + }); + } else { + plan.nodes.push(PlannedNode { + project: canonical.clone(), + requested_as: item.key.clone(), + version_id: version.id.clone(), + version_name: version.name.clone(), + version_number: version.version_number.clone(), + file, + depth: item.depth, + required_by: item.parents.clone(), + }); + } + + // 子项:只跟 required(排序去重由 `required_project_ids` 保证) + for dependency in version.required_project_ids() { + if item.depth >= request.max_depth { + plan.truncated.push(dependency.to_string()); + } else { + wave_in(&mut next, dependency, item.depth + 1, Some(&canonical)); + } + } + } + plan.truncated.sort(); + plan.truncated.dedup(); + layer = next; + } + + plan +} + +/// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。 +fn wave_in(layer: &mut Vec, key: &str, depth: u8, parent: Option<&str>) { + if let Some(existing) = layer.iter_mut().find(|item| item.key == key) { + if let Some(parent) = parent { + existing.parents.push(parent.to_string()); + } + return; + } + layer.push(Wave { + key: key.to_string(), + depth, + parents: parent + .map(|parent| vec![parent.to_string()]) + .unwrap_or_default(), + }); +} + +/// 往已经建好的节点上折一条父边(节点可能在被跳过的那一档,也可能只出现在失败名单里)。 +fn fold_parents(plan: &mut DependencyPlan, project: &str, parents: &[String]) { + if parents.is_empty() { + return; + } + let node = plan + .nodes + .iter_mut() + .find(|node| node.project == project) + .map(|node| &mut node.required_by); + let node = node.or_else(|| { + plan.skipped + .iter_mut() + .find(|node| node.project == project) + .map(|node| &mut node.required_by) + }); + if let Some(required_by) = node { + required_by.extend(parents.iter().cloned()); + normalize(required_by); + return; + } + if let Some(failure) = plan + .failures + .iter_mut() + .find(|failure| failure.requested_as == project) + { + failure.required_by.extend(parents.iter().cloned()); + normalize(&mut failure.required_by); + } +} + +fn normalize(list: &mut Vec) { + list.sort(); + list.dedup(); +} + +/// 规范身份:站点回的 `project_id` 才是身份;没给就退回请求键。 +fn canonical_of(key: &str, version: &Version) -> String { + let project_id = version.project_id.trim(); + if project_id.is_empty() { + key.to_string() + } else { + project_id.to_string() + } +} + +/// 「哪里没有匹配项」这句话(第一档的固定说法,带出用的是什么过滤条件)。 +fn filter_message(request: &ResolveRequest) -> String { + let game_version = request.game_version.as_deref().unwrap_or("不限"); + let loader = request.loader.as_deref().unwrap_or("不限"); + format!("没有匹配所选游戏版本/加载器({game_version} / {loader})的版本") +} + +fn version_label(version: &Version) -> String { + let number = version.version_number.trim(); + if number.is_empty() { + version.id.clone() + } else { + number.to_string() + } +} diff --git a/crates/core/src/addon/online/tests/dependency.rs b/crates/core/src/addon/online/tests/dependency.rs new file mode 100644 index 0000000..1a75948 --- /dev/null +++ b/crates/core/src/addon/online/tests/dependency.rs @@ -0,0 +1,306 @@ +//! 依赖解析的离线用例(录制 JSON 夹具,**不打真实网络**)。 +//! +//! 三条守门对应任务书的三条: +//! 1. [`two_nested_levels_are_pulled_in_a_predictable_order`] —— 嵌套两层能拉全,顺序确定; +//! 2. [`a_dependency_cycle_terminates_and_each_project_appears_once`] —— 环不无限递归; +//! 3. [`an_incompatible_dependency_lands_in_the_retry_list`] —— 不兼容进重试名单,不静默丢。 +//! +//! 每条都配过一次「把实现改坏就该红」的变异(过程写在 M186 报告里),所以断言不是装饰。 + +use crate::addon::online::dependency::{ + present_key, DependencyPlan, FailureKind, PresentFile, ResolveRequest, SkipReason, +}; +use crate::addon::online::model::parse_versions; +use crate::addon::online::{resolve, Modrinth, OnlineErrorKind, MODRINTH_API}; +use crate::download::blocking::block_on; + +use super::fixtures_deps as deps; +use super::support::FixtureTransport; + +const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version"; +const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version"; +const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version"; + +fn request(root: &str) -> ResolveRequest { + ResolveRequest { + roots: vec![root.to_string()], + game_version: Some("1.20.1".to_string()), + loader: Some("fabric".to_string()), + ..ResolveRequest::default() + } +} + +/// 三层链的传输层(sodium → fabric-api → yacl)。 +fn chain_transport() -> FixtureTransport { + FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES) + .with(YACL_VERSIONS, deps::YACL) +} + +fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan { + let client = Modrinth::new(transport); + block_on(resolve(&client, request)) +} + +// ---------------------------------------------------------------- ① 嵌套两层 + +#[test] +fn two_nested_levels_are_pulled_in_a_predictable_order() { + let transport = chain_transport(); + let plan = resolve_with(&transport, &request("sodium")); + + assert!(plan.is_clean(), "不该有失败:{:?}", plan.failures); + // 顺序 = BFS 层序,同层按项目键字典序:根 → fabric-api → yacl + let order: Vec<(&str, u8)> = plan + .nodes + .iter() + .map(|node| (node.project.as_str(), node.depth)) + .collect(); + assert_eq!( + order, + vec![("AANobbMI", 0), ("P7dR8mSH", 1), ("1eAoo2KR", 2)] + ); + + // 「为什么装」:父子关系逐条对得上 + assert!(plan.required_by("AANobbMI").expect("根在计划里").is_empty()); + assert_eq!( + plan.required_by("P7dR8mSH").expect("fabric-api 在计划里"), + ["AANobbMI".to_string()].as_slice() + ); + assert_eq!( + plan.required_by("1eAoo2KR").expect("yacl 在计划里"), + ["P7dR8mSH".to_string()].as_slice() + ); + + // 只跟 required:optional / incompatible / embedded 三个 id 一次请求都没发 + let seen = transport.seen(); + assert_eq!(seen.len(), 3, "只该有 3 次版本请求:{seen:?}"); + for quiet in ["OCS3z7f6", "AAAABBBB", "embedded-lib"] { + assert!( + !seen.iter().any(|url| url.contains(quiet)), + "{quiet} 不该被跟:{seen:?}" + ); + } + + // 主文件取 primary(第一个是 sources.jar) + assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar"); + assert_eq!(plan.nodes[1].file.filename, "fabric-api-0.92.2+1.20.1.jar"); +} + +#[test] +fn only_required_dependencies_are_followed() { + // 模型层:四值都解析得出来,`required_project_ids` 只吐 required 那一条。 + let versions = parse_versions(deps::SODIUM_WITH_DEPENDENCIES).expect("夹具该解析成功"); + let version = &versions[0]; + assert_eq!(version.dependencies.len(), 4); + let kinds: Vec<&str> = version + .dependencies + .iter() + .map(|dependency| dependency.dependency_type.as_str()) + .collect(); + assert_eq!(kinds, ["required", "optional", "incompatible", "embedded"]); + assert_eq!(version.required_project_ids(), ["P7dR8mSH"]); + // embedded 那条只有 file_name、没有 project_id,不该冒出一个空 id + assert!(version.dependencies[3].project_id.is_none()); +} + +#[test] +fn the_depth_limit_is_reported_instead_of_silently_cutting_the_chain() { + let transport = chain_transport(); + let mut request = request("sodium"); + request.max_depth = 1; + let plan = resolve_with(&transport, &request); + + assert_eq!(plan.nodes.len(), 2, "根 + 第一层"); + assert_eq!(plan.truncated, ["1eAoo2KR"], "截断必须写在计划里"); +} + +// ---------------------------------------------------------------- ② 环 + +#[test] +fn a_dependency_cycle_terminates_and_each_project_appears_once() { + let transport = FixtureTransport::new() + .with( + "https://api.modrinth.com/v2/project/cycle-a/version", + deps::CYCLE_A, + ) + .with( + "https://api.modrinth.com/v2/project/cycBid01/version", + deps::CYCLE_B, + ); + let plan = resolve_with(&transport, &request("cycle-a")); + + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!(projects, ["cycAid01", "cycBid01"], "每个项目只出现一次"); + assert_eq!(transport.seen().len(), 2, "环不许把请求数放大"); + // B 是被 A 要来的;A 又被 B 要(环)——这条边折回已有节点,不新建节点 + assert_eq!( + plan.required_by("cycBid01").expect("B 在计划里"), + ["cycAid01".to_string()].as_slice() + ); + assert_eq!( + plan.required_by("cycAid01").expect("A 在计划里"), + ["cycBid01".to_string()].as_slice() + ); + assert!(plan.nodes[0].is_root(), "根判据是 depth,不是 required_by"); + assert!(!plan.nodes[1].is_root()); +} + +// ---------------------------------------------------------------- ③ 两档失败 + +#[test] +fn an_incompatible_dependency_lands_in_the_retry_list() { + // fabric-api 在 1.20.1/fabric 下没有条目(站点回空数组) + let transport = FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with(FABRIC_API_VERSIONS, "[]"); + let plan = resolve_with(&transport, &request("sodium")); + + // 根还在计划里(失败不连坐),缺的那条单独立案 + assert_eq!(plan.nodes.len(), 1); + assert_eq!(plan.failures.len(), 1, "不许静默丢:{:?}", plan.failures); + let failure = &plan.failures[0]; + assert_eq!(failure.kind, FailureKind::Incompatible); + assert_eq!(failure.requested_as, "P7dR8mSH"); + assert_eq!(failure.depth, 1); + assert_eq!(failure.required_by, ["AANobbMI".to_string()]); + assert!(failure.cause.is_none(), "这一档不是网络成因"); + assert!(failure.message.contains("1.20.1"), "文案要带过滤条件"); + assert!(plan.failures_of(FailureKind::Unreachable).next().is_none()); + + // 可重试名单只含失败项,过滤条件沿用 + let retry = plan.retry_request(); + assert_eq!(retry.roots, ["P7dR8mSH"]); + assert_eq!(retry.game_version.as_deref(), Some("1.20.1")); + assert_eq!(retry.loader.as_deref(), Some("fabric")); +} + +#[test] +fn a_missing_project_is_unreachable_and_keeps_its_leaf_cause() { + let transport = FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .missing(FABRIC_API_VERSIONS); + let plan = resolve_with(&transport, &request("sodium")); + + let failure = &plan.failures[0]; + assert_eq!(failure.kind, FailureKind::Unreachable); + assert_eq!(failure.cause, Some(OnlineErrorKind::NotFound)); + // 两档从类型上就分得开:同一个计划里两种成因不会挤进一条 + assert_eq!(plan.failures_of(FailureKind::Incompatible).count(), 0); +} + +#[test] +fn a_version_without_a_usable_file_is_incompatible_not_a_network_error() { + let transport = FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with( + FABRIC_API_VERSIONS, + r#"[{"id": "ver-empty", "project_id": "P7dR8mSH", + "version_number": "0.0", "files": []}]"#, + ); + let plan = resolve_with(&transport, &request("sodium")); + assert_eq!(plan.failures[0].kind, FailureKind::Incompatible); + assert!(plan.failures[0].message.contains("没有可下载的主文件")); +} + +// ---------------------------------------------------------------- 两道去重 + +#[test] +fn one_project_required_by_two_parents_is_planned_once_with_both_parents() { + let transport = FixtureTransport::new() + .with( + "https://api.modrinth.com/v2/project/fanin-root/version", + deps::FANIN_ROOT, + ) + .with( + "https://api.modrinth.com/v2/project/BRANCHAA/version", + deps::FANIN_A, + ) + .with( + "https://api.modrinth.com/v2/project/BRANCHBB/version", + deps::FANIN_B, + ) + .with( + "https://api.modrinth.com/v2/project/SHARED01/version", + deps::SHARED_LIB, + ); + let plan = resolve_with(&transport, &request("fanin-root")); + + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!( + projects, + ["ROOT0001", "BRANCHAA", "BRANCHBB", "SHARED01"], + "同层按项目键字典序,公共前置只出现一次" + ); + assert_eq!( + plan.required_by("SHARED01").expect("公共前置在计划里"), + ["BRANCHAA".to_string(), "BRANCHBB".to_string()].as_slice(), + "两条依赖路径都要留下" + ); + let shared_requests = transport + .seen() + .iter() + .filter(|url| url.contains("SHARED01")) + .count(); + assert_eq!(shared_requests, 1, "一个项目只解一次版本"); +} + +#[test] +fn a_file_that_already_exists_is_skipped_but_its_dependencies_are_still_walked() { + let mut request = request("sodium"); + // 禁用中的 jar 也算「装过」(本仓用 `.disabled` 后缀表示禁用) + request.present = vec![PresentFile::new("fabric-api-0.92.2+1.20.1.jar.disabled")]; + let transport = chain_transport(); + let plan = resolve_with(&transport, &request); + + assert_eq!(plan.skipped.len(), 1); + let skipped = &plan.skipped[0]; + assert_eq!(skipped.project, "P7dR8mSH"); + assert_eq!(skipped.reason, SkipReason::AlreadyPresent); + assert_eq!(skipped.file_name, "fabric-api-0.92.2+1.20.1.jar"); + // 跳过的是**下载**,不是图:fabric-api 的前置照样解析出来 + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!(projects, ["AANobbMI", "1eAoo2KR"]); +} + +#[test] +fn present_keys_normalize_paths_case_and_the_disabled_suffix() { + assert_eq!(present_key("mods/Sodium-0.6.0.jar"), "sodium-0.6.0.jar"); + assert_eq!(present_key("a\\b\\x.jar.disabled"), "x.jar"); + assert_eq!(present_key(" y.jar "), "y.jar"); + assert_eq!(present_key(""), ""); +} + +// ---------------------------------------------------------------- 光影过滤器 + +#[test] +fn shader_loaders_are_recognised_in_search_hits() { + let transport = + FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), deps::SEARCH_SHADER); + let client = Modrinth::new(&transport); + let page = block_on(client.search(&crate::addon::online::SearchQuery { + text: String::new(), + project_type: Some(crate::addon::online::ContentType::Shader), + limit: 1, + ..crate::addon::online::SearchQuery::default() + })) + .expect("解析应当成功"); + assert_eq!( + page.hits[0].loaders, + vec!["iris".to_string(), "optifine".to_string()], + "光影的加载器不该被当成分类标签丢掉" + ); +} diff --git a/crates/core/src/addon/online/tests/fixtures_deps.rs b/crates/core/src/addon/online/tests/fixtures_deps.rs new file mode 100644 index 0000000..4680eac --- /dev/null +++ b/crates/core/src/addon/online/tests/fixtures_deps.rs @@ -0,0 +1,277 @@ +//! 依赖解析用的录制响应(形状按站点原文;数值是示意,不是某天的真值)。 +//! +//! 从 `fixtures.rs` 分出来的原因很实在:那里已经在钉「字段名与 `primary`」这类解析细节, +//! 再往里塞四五个带 `dependencies` 的版本对象就会贴到 400 行硬线。 +//! +//! 三条刻意的布置: +//! - 根项目(`sodium`)的版本里**同时有四类依赖**:只有 `required` 该被跟; +//! - 每个版本都有 `primary: false` 的 `sources.jar`,取第一个就会装错; +//! - 环(`cycle-a` ⇄ `cycle-b`)与扇入(两个分支都要 `shared-lib`)各有一组独立夹具。 + +/// 根项目 `sodium` 的版本:一条 required + 一条 optional + 一条 incompatible + 一条 embedded。 +pub(super) const SODIUM_WITH_DEPENDENCIES: &str = r#"[ + { + "id": "ver-sodium", + "project_id": "AANobbMI", + "name": "Sodium 0.6.0 for 1.20.1", + "version_number": "mc1.20.1-0.6.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2026-02-01T12:00:00Z", + "downloads": 4321, + "files": [ + { + "hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-sources.jar", + "filename": "sodium-sources.jar", + "size": 1000, + "primary": false + }, + { + "hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-fabric-0.6.0.jar", + "filename": "sodium-fabric-0.6.0.jar", + "size": 2048, + "primary": true + } + ], + "dependencies": [ + {"version_id": null, "project_id": "P7dR8mSH", "file_name": null, "dependency_type": "required"}, + {"version_id": null, "project_id": "OCS3z7f6", "file_name": null, "dependency_type": "optional"}, + {"version_id": null, "project_id": "AAAABBBB", "file_name": null, "dependency_type": "incompatible"}, + {"version_id": "ver-embedded", "project_id": null, "file_name": "embedded-lib.jar", "dependency_type": "embedded"} + ] + } +]"#; + +/// `fabric-api`(`P7dR8mSH`):它自己又要求 `yacl`(`1eAoo2KR`)⇒ 第二层嵌套。 +pub(super) const FABRIC_API_WITH_DEPENDENCIES: &str = r#"[ + { + "id": "ver-fabric-api", + "project_id": "P7dR8mSH", + "name": "Fabric API 0.92.2 for 1.20.1", + "version_number": "0.92.2+1.20.1", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2025-12-01T00:00:00Z", + "downloads": 9999, + "files": [ + { + "hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "cc"}, + "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2-sources.jar", + "filename": "fabric-api-0.92.2-sources.jar", + "size": 512, + "primary": false + }, + { + "hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "dd"}, + "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2+1.20.1.jar", + "filename": "fabric-api-0.92.2+1.20.1.jar", + "size": 4096, + "primary": true + } + ], + "dependencies": [ + {"version_id": null, "project_id": "1eAoo2KR", "file_name": null, "dependency_type": "required"} + ] + } +]"#; + +/// `yacl`(`1eAoo2KR`):链的末端,没有依赖。 +pub(super) const YACL: &str = r#"[ + { + "id": "ver-yacl", + "project_id": "1eAoo2KR", + "name": "YetAnotherConfigLib 3.3.2", + "version_number": "3.3.2+1.20.1", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2025-11-01T00:00:00Z", + "downloads": 1234, + "files": [ + { + "hashes": {"sha1": "5555555555555555555555555555555555555555", "sha512": "ee"}, + "url": "https://cdn.modrinth.com/data/1eAoo2KR/versions/ver-yacl/yacl-3.3.2.jar", + "filename": "yacl-3.3.2+1.20.1.jar", + "size": 8192, + "primary": true + } + ], + "dependencies": [] + } +]"#; + +/// 扇入的根:两个 required 分支。 +pub(super) const FANIN_ROOT: &str = r#"[ + { + "id": "ver-root", + "project_id": "ROOT0001", + "name": "Fan-in Root 1.0", + "version_number": "1.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "6666666666666666666666666666666666666666", "sha512": "ff"}, + "url": "https://cdn.modrinth.com/data/ROOT0001/versions/ver-root/fanin-root-1.0.jar", + "filename": "fanin-root-1.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "BRANCHAA", "dependency_type": "required"}, + {"project_id": "BRANCHBB", "dependency_type": "required"} + ] + } +]"#; + +/// 分支 A:也要 `shared-lib`。 +pub(super) const FANIN_A: &str = r#"[ + { + "id": "ver-branch-a", + "project_id": "BRANCHAA", + "name": "Branch A 2.0", + "version_number": "2.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "7777777777777777777777777777777777777777", "sha512": "ab"}, + "url": "https://cdn.modrinth.com/data/BRANCHAA/versions/ver-branch-a/branch-a-2.0.jar", + "filename": "branch-a-2.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "SHARED01", "dependency_type": "required"} + ] + } +]"#; + +/// 分支 B:也要 `shared-lib`(同一项目两条路径 ⇒ 只装一次)。 +pub(super) const FANIN_B: &str = r#"[ + { + "id": "ver-branch-b", + "project_id": "BRANCHBB", + "name": "Branch B 3.0", + "version_number": "3.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "8888888888888888888888888888888888888888", "sha512": "ac"}, + "url": "https://cdn.modrinth.com/data/BRANCHBB/versions/ver-branch-b/branch-b-3.0.jar", + "filename": "branch-b-3.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "SHARED01", "dependency_type": "required"} + ] + } +]"#; + +/// 扇入的公共前置。 +pub(super) const SHARED_LIB: &str = r#"[ + { + "id": "ver-shared", + "project_id": "SHARED01", + "name": "Shared Lib 1.5", + "version_number": "1.5", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "9999999999999999999999999999999999999999", "sha512": "ad"}, + "url": "https://cdn.modrinth.com/data/SHARED01/versions/ver-shared/shared-lib-1.5.jar", + "filename": "shared-lib-1.5.jar", + "size": 2048, + "primary": true + } + ], + "dependencies": [] + } +]"#; + +/// 环的一端:`cycle-a` 要求 `cycle-b`。 +pub(super) const CYCLE_A: &str = r#"[ + { + "id": "ver-cycle-a", + "project_id": "cycAid01", + "name": "Cycle A 1.0", + "version_number": "1.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sha512": "a1"}, + "url": "https://cdn.modrinth.com/data/cycAid01/versions/ver-cycle-a/cycle-a-1.0.jar", + "filename": "cycle-a-1.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "cycBid01", "dependency_type": "required"} + ] + } +]"#; + +/// 环的另一端:`cycle-b` 又要求 `cycle-a`(不设防就是无限递归)。 +pub(super) const CYCLE_B: &str = r#"[ + { + "id": "ver-cycle-b", + "project_id": "cycBid01", + "name": "Cycle B 1.0", + "version_number": "1.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha512": "b1"}, + "url": "https://cdn.modrinth.com/data/cycBid01/versions/ver-cycle-b/cycle-b-1.0.jar", + "filename": "cycle-b-1.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "cycAid01", "dependency_type": "required"} + ] + } +]"#; + +/// 光影检索的一页:`categories` 里是 `iris` / `optifine`(光影的加载器)。 +pub(super) const SEARCH_SHADER: &str = r#"{ + "hits": [ + { + "project_id": "shader01", + "project_type": "shader", + "slug": "complementary-reimagined", + "author": "EminGT", + "title": "Complementary Reimagined", + "description": "A shader pack", + "categories": ["iris", "optifine", "shaders"], + "versions": ["1.20.1"], + "downloads": 1234567, + "follows": 4242, + "icon_url": "", + "date_modified": "2026-01-02T03:04:05Z" + } + ], + "offset": 0, + "limit": 1, + "total_hits": 1 +}"#; diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index f2509a1..24c2c8d 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -8,6 +8,8 @@ mod api; mod classify; +mod dependency; mod download; mod fixtures; +mod fixtures_deps; mod support; diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index be2e7c4..24246ab 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -24,7 +24,8 @@ //! |---|---| //! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] | //! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 | -//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 | +//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 | +//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 | //! //! ## 整合包与 CF 的边界(都写进报告) //! @@ -34,6 +35,7 @@ mod http; mod install; +mod plan; // M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod) mod pack; mod sources; diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index e71388d..6f0a4e9 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -1,19 +1,33 @@ -//! 「一键装入指定实例」(M109)。 +//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。 //! //! ## 网络只走 code 的在线内容源(M162 r1) //! -//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、 -//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。 -//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 +//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在 +//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼 +//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 //! //! ## 它到底做了什么(诚实版) //! //! | 内容类型 | 做什么 | 状态 | //! |---|---|---| -//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 | +//! | Mod / 资源包 / 光影 | 解析「该项目 + 它的 `required` 前置」在目标实例(版本 + 加载器)下的文件 → core 下载引擎下到 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流 | 已接(M186 起含前置) | //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! +//! ## 前置的两档失败与重试名单(M186) +//! +//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 +//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 +//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, +//! `DependencyPlan::retry_request()` 已经把请求备好了。 +//! +//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」) +//! +//! | 内容 | 检索 | 装入 | 差在哪 | +//! |---|---|---|---| +//! | 光影 | 支持(`project_type:shader`);命中项的加载器标签现在认得 `iris` / `optifine`(`KNOWN_LOADERS`) | 支持(落 `shaderpacks/`) | 加载器**过滤**仍筛不了:`LoaderKind::OptiFine` 没有 facet(`loader_facet` 返回 `None`),所以「只看 Iris 光影」只能靠标签目测 | +//! | 数据包 | core 的 `ContentType::Datapack` 有 `datapack` facet | **不支持** | DTO 的 `ContentKind` 没有这一项;数据包还要落到「某个世界的 `datapacks/`」,现在没有选择世界这一步 | +//! //! ## 为什么进度只有阶段级,没有字节条 //! //! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有 @@ -26,7 +40,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery, + download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -39,7 +53,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::sources::failure_of; +use super::{plan, sources::failure_of}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -112,7 +126,10 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu }); } -/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。 +/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。 +/// +/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 +/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 fn run( env: &HostEnv, request: &ContentInstallRequest, @@ -130,33 +147,90 @@ fn run( let target_dir = game_dir.join(dir); std::fs::create_dir_all(&target_dir) .map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?; - - let file = resolve_file(env, request)?; - if cancel.load(Ordering::SeqCst) { - return Err(local("已取消(下载开始前)")); + if request.source != ContentSource::Modrinth { + return Err(ContentFailure::unsupported( + "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", + )); } + + // 解析:根项目 + 只跟 `required` 的前置(顺序与父子关系由 core 给,这里不重排)。 + let resolve_request = plan::request( + &request.project, + &request.game_version, + loader_facet(request.loader), + plan::present_files(&target_dir), + ); + let core_engine = super::super::engine::engine(env); + let dependency_plan = { + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + block_on(resolve(&client, &resolve_request)) + }; + progress.stage = InstallStage::Downloading; - progress.message = file.name.clone(); + progress.files_total = dependency_plan.nodes.len() as u64; + progress.files_done = 0; + progress.message = if dependency_plan.nodes.is_empty() { + "没有要下载的文件".to_string() + } else { + format!( + "{} 个文件(含 {} 个前置)", + dependency_plan.nodes.len(), + dependency_plan + .nodes + .iter() + .filter(|node| !node.is_root()) + .count() + ) + }; let _ = tx.send(HostTask::Install { progress: Box::new(progress.clone()), epoch, }); - let dest = target_dir.join(&file.name); - // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, - // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 - let core_engine = super::super::engine::engine(env); let fetcher = EngineArtifactFetcher::new(&core_engine); - block_on(download_verified( - &fetcher, - &file.url, - file.sha1.as_deref(), - file.sha512.as_deref(), - file.size, - &dest, - )) - .map_err(failure_of)?; - Ok(format!("{}({})", file.name, dest.display())) + for node in &dependency_plan.nodes { + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载中途)")); + } + progress.message = node_label(node); + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, + // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 + let dest = target_dir.join(safe_file_name(&node.file.filename)); + block_on(download_verified( + &fetcher, + &node.file.url, + node.file.sha1(), + node.file.sha512(), + (node.file.size > 0).then_some(node.file.size), + &dest, + )) + .map_err(failure_of)?; + progress.files_done += 1; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + } + + // 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan) { + return Err(failure); + } + Ok(plan::summary(&dependency_plan)) +} + +/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 +fn node_label(node: &PlannedNode) -> String { + if node.is_root() { + node.file.filename.clone() + } else { + format!("{}(前置)", node.file.filename) + } } /// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 @@ -187,69 +261,6 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result, - sha512: Option, - size: Option, -} - -/// 解析「这个项目下最适合目标实例的那个文件」。 -fn resolve_file( - env: &HostEnv, - request: &ContentInstallRequest, -) -> Result { - match request.source { - ContentSource::Modrinth => modrinth_file(env, request), - ContentSource::Curseforge => Err(ContentFailure::unsupported( - "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", - )), - } -} - -/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。 -/// -/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑, -/// `Version::primary_file()` 就是为这件事存在的。 -fn modrinth_file( - env: &HostEnv, - request: &ContentInstallRequest, -) -> Result { - let core_engine = super::super::engine::engine(env); - let transport = EngineTransport::new(&core_engine); - let client = Modrinth::new(&transport); - let versions = block_on(client.versions(&VersionQuery { - project: request.project.clone(), - game_version: Some(request.game_version.clone()), - loader: loader_facet(request.loader).map(str::to_string), - })) - .map_err(failure_of)?; - let version = versions.first().ok_or_else(|| { - ContentFailure::new( - ContentFailureKind::NotFound, - "这个项目没有匹配目标实例(版本 / 加载器)的文件", - ) - })?; - let file = version - .primary_file() - .ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?; - if file.url.trim().is_empty() { - return Err(ContentFailure::new( - ContentFailureKind::Unsupported, - "版本里的主文件没有下载地址", - )); - } - Ok(RemoteFile { - url: file.url.clone(), - name: safe_file_name(&file.filename), - sha1: file.sha1().map(ToString::to_string), - sha512: file.sha512().map(ToString::to_string), - size: (file.size > 0).then_some(file.size), - }) -} - /// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉 /// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。 pub(super) fn safe_file_name(raw: &str) -> String { diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs new file mode 100644 index 0000000..296e7fc --- /dev/null +++ b/crates/presenter/src/host/addon/plan.rs @@ -0,0 +1,218 @@ +//! 依赖计划的**宿主侧那一半**:扫目录、把两档失败映成 DTO 大类、拼给用户看的那句话。 +//! +//! # 为什么判定在 core、说话在 presenter +//! +//! 「哪个前置不兼容、哪个前置取不到」由 `unlucky_core::addon::online::resolve` 判定并可离线 +//! 测试(录制 JSON 夹具)。本文件只做三件宿主才知道的事: +//! +//! | 事 | 为什么只能在这里 | +//! |---|---| +//! | 目标目录里有什么 | core 不读盘(`PresentFile` 由调用方扫) | +//! | 失败大类怎么映 | DTO 在 presenter(`ContentFailureKind`) | +//! | 文案怎么说 | 界面文案不归 core | +//! +//! # 两档失败**分开说** +//! +//! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作, +//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 +//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 + +use std::path::Path; + +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, FailureKind, OnlineError, PresentFile, ResolveRequest, +}; + +use crate::dto::ContentFailureKind; + +use super::sources::failure_of; + +/// 扫目标目录(不递归)里已存在的文件名。 +/// +/// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的 +/// 最常见形态(我们落盘用站点给的文件名,不重命名)。 +pub(super) fn present_files(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|entry| entry.path().is_file()) + .map(|entry| PresentFile::new(entry.file_name().to_string_lossy())) + .collect() +} + +/// 一次装入要用的解析请求(过滤条件与落盘目录一致)。 +pub(super) fn request( + project: &str, + game_version: &str, + loader: Option<&str>, + present: Vec, +) -> ResolveRequest { + ResolveRequest { + roots: vec![project.to_string()], + game_version: Some(game_version.to_string()), + loader: loader.map(str::to_string), + present, + ..ResolveRequest::default() + } +} + +/// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。 +pub(super) fn failure_of_plan(plan: &DependencyPlan) -> Option { + if plan.is_clean() { + return None; + } + Some(crate::dto::ContentFailure::new( + overall_kind(plan), + report(plan), + )) +} + +/// 整体大类:按「用户接下来该做什么」排序,离线 > 查无此项 > 站点错 > 其它 > 不兼容。 +fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind { + let kinds: Vec = plan.failures.iter().map(kind_of).collect(); + for wanted in [ + ContentFailureKind::Offline, + ContentFailureKind::NotFound, + ContentFailureKind::Server, + ContentFailureKind::Other, + ContentFailureKind::Unsupported, + ] { + if kinds.contains(&wanted) { + return wanted; + } + } + ContentFailureKind::Unsupported +} + +/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 +fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { + match failure.cause { + Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, + // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 + None => ContentFailureKind::Unsupported, + } +} + +/// 失败清单(两档各起一段,各自的条数分开数)。 +fn report(plan: &DependencyPlan) -> String { + let mut lines: Vec = Vec::new(); + let incompatible: Vec = plan + .failures_of(FailureKind::Incompatible) + .map(describe) + .collect(); + let unreachable: Vec = plan + .failures_of(FailureKind::Unreachable) + .map(describe) + .collect(); + if !incompatible.is_empty() { + lines.push(format!( + "不兼容所选版本/加载器 {} 个:{}", + incompatible.len(), + incompatible.join(";") + )); + } + if !unreachable.is_empty() { + lines.push(format!( + "取不到(网络 / 项目不存在){} 个:{}", + unreachable.len(), + unreachable.join(";") + )); + } + if !plan.nodes.is_empty() { + lines.push(format!("已装入 {} 个文件", plan.nodes.len())); + } + if !plan.skipped.is_empty() { + lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len())); + } + if !plan.truncated.is_empty() { + lines.push(format!( + "依赖层数超过上限,未展开:{}", + plan.truncated.join("、") + )); + } + // 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的) + let retry: Vec<&str> = plan + .failures + .iter() + .map(|failure| failure.requested_as.as_str()) + .collect(); + if !retry.is_empty() { + lines.push(format!( + "可只重试这 {} 个:{}", + retry.len(), + retry.join("、") + )); + } + lines.join("\n") +} + +fn describe(failure: &DependencyFailure) -> String { + if failure.required_by.is_empty() { + format!("{}({})", failure.requested_as, failure.message) + } else { + format!( + "{}({};被 {} 要求)", + failure.requested_as, + failure.message, + failure.required_by.join("、") + ) + } +} + +/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 +pub(super) fn summary(plan: &DependencyPlan) -> String { + if plan.nodes.is_empty() && plan.skipped.is_empty() { + return "没有要装的文件".to_string(); + } + let mut parts: Vec = Vec::new(); + if !plan.nodes.is_empty() { + let dependencies = plan.nodes.iter().filter(|node| !node.is_root()).count(); + // 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标) + let names: Vec = plan + .nodes + .iter() + .map(|node| { + if node.is_root() { + node.file.filename.clone() + } else { + format!( + "{}(前置,被 {} 要求)", + node.file.filename, + node.required_by.join("、") + ) + } + }) + .collect(); + let head = if dependencies == 0 { + format!("装了 {} 个文件", plan.nodes.len()) + } else { + format!( + "装了 {} 个文件(含 {} 个前置)", + plan.nodes.len(), + dependencies + ) + }; + parts.push(format!("{head}:{}", names.join("、"))); + } + if !plan.skipped.is_empty() { + let names: Vec<&str> = plan + .skipped + .iter() + .map(|node| node.file_name.as_str()) + .collect(); + parts.push(format!( + "跳过 {} 个(目录里已存在,没有重复下载):{}", + plan.skipped.len(), + names.join("、") + )); + } + if !plan.truncated.is_empty() { + parts.push(format!( + "依赖层数超过上限,未展开:{}", + plan.truncated.join("、") + )); + } + parts.join("\n") +} diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 3c29d1e..1fe9bb9 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -115,3 +115,190 @@ fn core_errors_map_to_dto_failure_kinds() { "connect timed out" ); } + +// ------------------------------------------------ M186:前置解析的两档失败与可重试名单 + +use super::plan::{failure_of_plan, summary}; +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason, + SkippedNode, VersionFile, +}; + +fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode { + PlannedNode { + project: project.to_string(), + requested_as: project.to_string(), + version_id: format!("ver-{project}"), + version_name: project.to_string(), + version_number: "1.0".to_string(), + file: VersionFile { + url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"), + filename: file.to_string(), + size: 1024, + ..VersionFile::default() + }, + depth, + required_by: required_by + .iter() + .map(|parent| (*parent).to_string()) + .collect(), + } +} + +fn failure( + project: &str, + kind: FailureKind, + cause: Option, + message: &str, +) -> DependencyFailure { + DependencyFailure { + requested_as: project.to_string(), + required_by: vec!["AANobbMI".to_string()], + depth: 1, + kind, + cause, + message: message.to_string(), + } +} + +fn plan( + failures: Vec, + nodes: Vec, + skipped: Vec, +) -> DependencyPlan { + DependencyPlan { + request: ResolveRequest::single("sodium"), + nodes, + skipped, + failures, + truncated: Vec::new(), + } +} + +/// 全是「不兼容」时:说换版本/加载器,而不是「下载失败」,并且照样给重试名单。 +#[test] +fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { + let plan = plan( + vec![failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", + )], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Unsupported); + assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); + assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); + assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); + assert!( + !failure.detail.contains("取不到"), + "两档不许混:{}", + failure.detail + ); +} + +/// 两档同时出现时:两段都写,大类按「用户接下来该做什么」选(查无此项 > 不兼容)。 +#[test] +fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() { + let plan = plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + Vec::new(), + Vec::new(), + ); + let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::NotFound); + assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); + assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); + assert!(failure.detail.contains("可只重试这 2 个")); +} + +/// 没网那一档:大类是离线(页面据此画「检查网络」而不是「换个关键词」)。 +#[test] +fn an_offline_jump_wins_over_everything_else() { + let plan = plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "连不上 api.modrinth.com", + ), + ], + Vec::new(), + Vec::new(), + ); + let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Offline); + assert!(failure.is_offline()); +} + +/// 全装成的那句话要点出「含 N 个前置」与「跳过 M 个已存在」。 +#[test] +fn the_success_message_counts_dependencies_and_skipped_files() { + let plan = plan( + Vec::new(), + vec![ + node("AANobbMI", "sodium.jar", 0, &[]), + node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]), + ], + vec![SkippedNode { + project: "1eAoo2KR".to_string(), + requested_as: "1eAoo2KR".to_string(), + file_name: "yacl.jar".to_string(), + depth: 2, + required_by: vec!["P7dR8mSH".to_string()], + reason: SkipReason::AlreadyPresent, + }], + ); + assert!(failure_of_plan(&plan).is_none()); + let message = summary(&plan); + assert!( + message.contains("装了 2 个文件(含 1 个前置)"), + "{message}" + ); + // 「为什么装」:前置那句要带出要求它的项目 + assert!( + message.contains("fabric-api.jar(前置,被 AANobbMI 要求)"), + "{message}" + ); + assert!(message.contains("跳过 1 个(目录里已存在,没有重复下载):yacl.jar")); +} + +/// 扫目录只认文件、不认子目录(子目录不是 jar,混进来会让去重误判)。 +#[test] +fn present_files_lists_only_files_in_the_target_directory() { + let dir = std::env::temp_dir().join(format!("ull-m186-present-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("nested")).expect("建临时目录"); + std::fs::write(dir.join("sodium.jar"), b"x").expect("写文件"); + std::fs::write(dir.join("yacl.jar.disabled"), b"x").expect("写文件"); + let mut names: Vec = super::plan::present_files(&dir) + .into_iter() + .map(|file| file.name) + .collect(); + names.sort(); + assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]); + let _ = std::fs::remove_dir_all(&dir); +} From aa33210bb6a5da3ac6d45cad250949206b673b35 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:58:16 +0800 Subject: [PATCH 07/33] =?UTF-8?q?fix(addon):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=A4=B1=E8=B4=A5=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E3=80=81null=20=E8=BD=BD=E8=8D=B7=E3=80=81=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=80=81=E8=BF=9B=E5=BA=A6=E3=80=81=E9=A1=BA=E5=BA=8F=E5=AE=88?= =?UTF-8?q?=E9=97=A8=E3=80=81=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5=E5=BD=92?= =?UTF-8?q?=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按 `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents` 折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复; - P2-2 `RawDependency.dependency_type` 改 `Option`:`#[serde(default)]` 兜不住 `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上; 现在 null/未知一律 `Unknown`(不递归); - P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3); 解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」; - P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红; 另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`); - P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件: 已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request` 自动覆盖它们; - nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档); nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略); nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。 --- crates/core/src/addon/online/dependency.rs | 53 ++++- crates/core/src/addon/online/mod.rs | 2 +- crates/core/src/addon/online/model/parse.rs | 10 +- crates/core/src/addon/online/model/types.rs | 20 ++ crates/core/src/addon/online/resolve.rs | 96 ++++++--- crates/core/src/addon/online/tests/mod.rs | 2 + crates/core/src/addon/online/tests/order.rs | 137 +++++++++++++ .../src/addon/online/tests/regressions.rs | 189 ++++++++++++++++++ crates/core/src/addon/online/tests/support.rs | 20 ++ crates/presenter/src/host/addon/install.rs | 66 ++++-- crates/presenter/src/host/addon/plan.rs | 81 ++++++-- crates/presenter/src/host/addon/tests.rs | 87 +++++++- 12 files changed, 686 insertions(+), 77 deletions(-) create mode 100644 crates/core/src/addon/online/tests/order.rs create mode 100644 crates/core/src/addon/online/tests/regressions.rs diff --git a/crates/core/src/addon/online/dependency.rs b/crates/core/src/addon/online/dependency.rs index 12b2c5e..440742f 100644 --- a/crates/core/src/addon/online/dependency.rs +++ b/crates/core/src/addon/online/dependency.rs @@ -220,6 +220,20 @@ pub struct DependencyFailure { pub message: String, } +/// 声明了 `required`、但**只给了 `version_id`**(没有 `project_id`)的依赖。 +/// +/// BFS 需要项目 id 才能取版本(`/v2/project/{id}/version`),所以这条既装不了、也不该被 +/// 假装没看见:留在计划里,上层才能说「有 N 条前置没法解析」而不是默默少装一个。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnresolvedRequired { + /// 声明它的项目(规范 id) + pub parent: String, + /// 站点给的版本 id(`None` = 连版本 id 都没有,只有文件名) + pub version_id: Option, + /// 站点给的文件名 + pub file_name: Option, +} + /// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct DependencyPlan { @@ -233,6 +247,8 @@ pub struct DependencyPlan { pub failures: Vec, /// 撞到 `max_depth` 而没展开的项目键(排序去重) pub truncated: Vec, + /// 有 `required` 但没有项目 id、解析不了的前置(排序去重) + pub unresolved_required: Vec, } impl DependencyPlan { @@ -245,6 +261,7 @@ impl DependencyPlan { skipped: Vec::new(), failures: Vec::new(), truncated: Vec::new(), + unresolved_required: Vec::new(), } } @@ -259,19 +276,45 @@ impl DependencyPlan { self.failures.iter().filter(move |item| item.kind == kind) } + /// 记一条失败——**按 `requested_as` 去重**,父边合并。 + /// + /// 为什么要有这道去重:同一个项目可能被两条路径要求(A→GONE、B→C→GONE)。若不去重, + /// 「可只重试这 N 个」会写出 `GONE、GONE`,用户按名单重试等于发两次一样的请求。 + /// 解析阶段与下载阶段的失败都走这里,于是名单里一个项目只出现一次。 + pub fn record_failure(&mut self, failure: DependencyFailure) { + if let Some(existing) = self + .failures + .iter_mut() + .find(|item| item.requested_as == failure.requested_as) + { + existing.required_by.extend(failure.required_by); + existing.required_by.sort(); + existing.required_by.dedup(); + // 深度取更浅的那条:越靠近根越该先修 + existing.depth = existing.depth.min(failure.depth); + return; + } + self.failures.push(failure); + } + /// 「可重试名单」:失败项原样重启一次解析的请求。 /// /// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。 /// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器, /// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。 + /// + /// 调用方如果刚跑完一轮下载,重试前应**重新扫一遍目录**再填 `present`:这一轮新落盘的 + /// 文件不在旧的 `present` 里,不重扫就会把它们当「还没有」再下一份。 #[must_use] pub fn retry_request(&self) -> ResolveRequest { + let mut roots: Vec = Vec::new(); + for failure in &self.failures { + if !roots.contains(&failure.requested_as) { + roots.push(failure.requested_as.clone()); + } + } ResolveRequest { - roots: self - .failures - .iter() - .map(|failure| failure.requested_as.clone()) - .collect(), + roots, ..self.request.clone() } } diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index 0c05038..2fa4203 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -71,7 +71,7 @@ pub mod transport; pub use complete::identity_of; pub use dependency::{ present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, - ResolveRequest, SkipReason, SkippedNode, DEFAULT_MAX_DEPTH, + ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, DEFAULT_MAX_DEPTH, }; pub use download::{ digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, diff --git a/crates/core/src/addon/online/model/parse.rs b/crates/core/src/addon/online/model/parse.rs index 175810e..0e4722e 100644 --- a/crates/core/src/addon/online/model/parse.rs +++ b/crates/core/src/addon/online/model/parse.rs @@ -127,9 +127,11 @@ struct RawDependency { version_id: Option, #[serde(default)] file_name: Option, - // 站点给的是 `required` / `optional` / `incompatible` / `embedded`;认不出 ⇒ `Unknown` + // 站点给的是 `required` / `optional` / `incompatible` / `embedded`。 + // 必须是 `Option`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null` + // (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。 #[serde(default)] - dependency_type: String, + dependency_type: Option, } #[derive(Debug, Default, Deserialize)] @@ -263,7 +265,9 @@ fn version_of(raw: RawVersion) -> Version { project_id: non_empty(dependency.project_id), version_id: non_empty(dependency.version_id), file_name: non_empty(dependency.file_name), - dependency_type: DependencyType::parse(&dependency.dependency_type), + dependency_type: DependencyType::parse( + dependency.dependency_type.as_deref().unwrap_or_default(), + ), }) .collect(), } diff --git a/crates/core/src/addon/online/model/types.rs b/crates/core/src/addon/online/model/types.rs index 8bb0af9..3c2b57a 100644 --- a/crates/core/src/addon/online/model/types.rs +++ b/crates/core/src/addon/online/model/types.rs @@ -258,6 +258,26 @@ impl Version { ids.dedup(); ids } + + /// 声明了 `required`、却**没有项目 id** 的前置(站点只给了版本 id / 文件名)。 + /// + /// 这些条目 BFS 跟不下去(取版本要项目 id),但也不能当成「没有这条依赖」—— + /// 上层据此说「有 N 条前置没法解析」,见 `DependencyPlan::unresolved_required`。 + #[must_use] + pub fn unresolved_required(&self) -> Vec<&Dependency> { + self.dependencies + .iter() + .filter(|dependency| dependency.dependency_type == DependencyType::Required) + .filter(|dependency| { + dependency + .project_id + .as_deref() + .map(str::trim) + .map(str::is_empty) + .unwrap_or(true) + }) + .collect() + } } /// 版本里的一个文件。 diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs index 977bda1..30a0e9d 100644 --- a/crates/core/src/addon/online/resolve.rs +++ b/crates/core/src/addon/online/resolve.rs @@ -31,11 +31,11 @@ use std::collections::{BTreeMap, BTreeSet}; use super::dependency::{ present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, - ResolveRequest, SkipReason, SkippedNode, + ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, }; use super::model::Version; use super::modrinth::Modrinth; -use super::transport::Transport; +use super::transport::{OnlineErrorKind, Transport}; /// 一层里还没处理的一项。 struct Wave { @@ -78,30 +78,32 @@ pub async fn resolve( Ok(versions) => match versions.into_iter().next() { Some(version) => version, None => { - plan.failures.push(DependencyFailure { - requested_as: item.key, - required_by: item.parents, - depth: item.depth, - kind: FailureKind::Incompatible, - cause: None, - message: filter_message(request), - }); + let message = filter_message(request); + fail( + &mut plan, + &mut alias, + item, + FailureKind::Incompatible, + None, + message, + ); continue; } }, Err(error) => { - plan.failures.push(DependencyFailure { - requested_as: item.key, - required_by: item.parents, - depth: item.depth, - kind: FailureKind::Unreachable, - cause: Some(error.kind()), - message: format!( - "取版本失败({}):{}", - error.kind().as_str(), - error.message() - ), - }); + let message = format!( + "取版本失败({}):{}", + error.kind().as_str(), + error.message() + ); + fail( + &mut plan, + &mut alias, + item, + FailureKind::Unreachable, + Some(error.kind()), + message, + ); continue; } }; @@ -115,14 +117,14 @@ pub async fn resolve( item.key, version_label(&version) ); - plan.failures.push(DependencyFailure { - requested_as: item.key, - required_by: item.parents, - depth: item.depth, - kind: FailureKind::Incompatible, - cause: None, + fail( + &mut plan, + &mut alias, + item, + FailureKind::Incompatible, + None, message, - }); + ); continue; }; @@ -168,15 +170,51 @@ pub async fn resolve( wave_in(&mut next, dependency, item.depth + 1, Some(&canonical)); } } + // 只有版本 id、没有项目 id 的 required:没有地址可取,记下来不假装没看见 + for dependency in version.unresolved_required() { + plan.unresolved_required.push(UnresolvedRequired { + parent: canonical.clone(), + version_id: dependency.version_id.clone(), + file_name: dependency.file_name.clone(), + }); + } } plan.truncated.sort(); plan.truncated.dedup(); layer = next; } + plan.unresolved_required.sort_by(|left, right| { + (&left.parent, &left.version_id).cmp(&(&right.parent, &right.version_id)) + }); + plan.unresolved_required.dedup(); plan } +/// 记一条失败,并把这个请求键标成「已处理」。 +/// +/// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`, +/// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。 +/// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。 +fn fail( + plan: &mut DependencyPlan, + alias: &mut BTreeMap, + item: Wave, + kind: FailureKind, + cause: Option, + message: String, +) { + plan.record_failure(DependencyFailure { + requested_as: item.key.clone(), + required_by: item.parents, + depth: item.depth, + kind, + cause, + message, + }); + alias.insert(item.key.clone(), item.key); +} + /// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。 fn wave_in(layer: &mut Vec, key: &str, depth: u8, parent: Option<&str>) { if let Some(existing) = layer.iter_mut().find(|item| item.key == key) { diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index 24c2c8d..31f60da 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -12,4 +12,6 @@ mod dependency; mod download; mod fixtures; mod fixtures_deps; +mod order; +mod regressions; mod support; diff --git a/crates/core/src/addon/online/tests/order.rs b/crates/core/src/addon/online/tests/order.rs new file mode 100644 index 0000000..35917a8 --- /dev/null +++ b/crates/core/src/addon/online/tests/order.rs @@ -0,0 +1,137 @@ +//! 顺序确定性(r1 P2-4):同层 ≥2 个前置、夹具数组故意逆序、多根乱序,结果必须一样。 +//! +//! # 这条用例守的是什么 +//! +//! `resolve` 在每层处理前把队列按**项目键字典序**排一遍。把那一行删掉,原来那 11 条用例 +//! (以及 core 的全部用例)**仍然全绿**——因为三跳链每层只有一个前置、扇入用例里两个同层 +//! 节点的先后恰好等于夹具数组顺序。也就是说「顺序由数据决定」这条性质当时没有守门。 +//! +//! # 归因(实测过,别写成「都靠这两条守门」) +//! +//! 删掉 `resolve` 里那行 `layer.sort_by` ⇒ +//! [`prerequisites_from_two_roots_are_ordered_by_project_key`] 与 +//! [`multiple_roots_are_processed_in_key_order_whatever_the_argument_order`] 红, +//! [`same_root_prerequisites_follow_key_order_not_payload_order`] 仍绿——因为那两个前置 +//! 是**同一个根**入队的,顺序已经被 `Version::required_project_ids()` 内部的排序排过了。 +//! 反过来,只删 `required_project_ids()` 的排序时三条都绿:`layer.sort_by` 会把结果重排一遍。 +//! +//! 也就是说:跨层顺序由 `layer.sort_by` 兜底(这两条用例钉它),同一个根内的顺序**两层保护** +//! 冗余存在;`required_project_ids()` 自己的「排序去重」契约由 `tests/regressions.rs` 的 +//! `required_project_ids_are_sorted_and_deduplicated_on_their_own` 单独钉(那条只删排序就会红)。 + +use crate::addon::online::dependency::{DependencyPlan, ResolveRequest}; +use crate::addon::online::{resolve, Modrinth}; +use crate::download::blocking::block_on; + +use super::support::{version_json, FixtureTransport}; + +const API: &str = "https://api.modrinth.com/v2/project"; + +fn request(roots: &[&str]) -> ResolveRequest { + ResolveRequest { + roots: roots.iter().map(|root| (*root).to_string()).collect(), + game_version: Some("1.20.1".to_string()), + loader: Some("fabric".to_string()), + ..ResolveRequest::default() + } +} + +fn plan_of(transport: &FixtureTransport, roots: &[&str]) -> DependencyPlan { + let client = Modrinth::new(transport); + block_on(resolve(&client, &request(roots))) +} + +fn projects(plan: &DependencyPlan) -> Vec<&str> { + plan.nodes + .iter() + .map(|node| node.project.as_str()) + .collect() +} + +#[test] +fn same_root_prerequisites_follow_key_order_not_payload_order() { + // 夹具里 ZEBRA01 在前、ALPHA01 在后(站点原文顺序反过来是常态)。 + // 这一条守的是 `Version::required_project_ids()` 里的排序:靠「入队顺序」展开的话, + // 站点换个数组顺序,安装顺序就跟着变。 + let transport = FixtureTransport::new() + .with( + &format!("{API}/order-root/version"), + &version_json( + "order-root", + "order-root.jar", + &[("ZEBRA01", "required"), ("ALPHA01", "required")], + ), + ) + .with( + &format!("{API}/ALPHA01/version"), + &version_json("ALPHA01", "alpha.jar", &[]), + ) + .with( + &format!("{API}/ZEBRA01/version"), + &version_json("ZEBRA01", "zebra.jar", &[]), + ); + + let plan = plan_of(&transport, &["order-root"]); + assert!(plan.is_clean(), "{:?}", plan.failures); + assert_eq!( + projects(&plan), + ["order-root", "ALPHA01", "ZEBRA01"], + "同层按项目键字典序,与 `dependencies` 数组顺序无关" + ); +} + +/// 两个根各带一个前置 ⇒ 它们在**同一层**相遇,且项目键的顺序与根的先后相反。 +/// +/// 这一条守的是 `resolve` 里每层的 `sort_by`:不排的话,层内顺序就是「谁先被入队」—— +/// 也就是参数顺序与站点响应顺序的混合,删掉那行 `sort_by` 这条会红 +/// (实测:`left: ["zebra-branch","alpha-branch"]` 之类的参数序)。 +#[test] +fn prerequisites_from_two_roots_are_ordered_by_project_key() { + let transport = FixtureTransport::new() + .with( + &format!("{API}/branch-a/version"), + &version_json("branch-a", "branch-a.jar", &[("ZEBRA01", "required")]), + ) + .with( + &format!("{API}/branch-b/version"), + &version_json("branch-b", "branch-b.jar", &[("ALPHA01", "required")]), + ) + .with( + &format!("{API}/ALPHA01/version"), + &version_json("ALPHA01", "alpha.jar", &[]), + ) + .with( + &format!("{API}/ZEBRA01/version"), + &version_json("ZEBRA01", "zebra.jar", &[]), + ); + + let plan = plan_of(&transport, &["branch-a", "branch-b"]); + assert!(plan.is_clean(), "{:?}", plan.failures); + assert_eq!( + projects(&plan), + ["branch-a", "branch-b", "ALPHA01", "ZEBRA01"], + "第二层两个前置按项目键排,不按「谁先入队」" + ); +} + +#[test] +fn multiple_roots_are_processed_in_key_order_whatever_the_argument_order() { + let transport = FixtureTransport::new() + .with( + &format!("{API}/zebra-root/version"), + &version_json("zebra-root", "zebra-root.jar", &[]), + ) + .with( + &format!("{API}/alpha-root/version"), + &version_json("alpha-root", "alpha-root.jar", &[]), + ); + + let forward = plan_of(&transport, &["zebra-root", "alpha-root"]); + let backward = plan_of(&transport, &["alpha-root", "zebra-root"]); + assert_eq!( + projects(&forward), + ["alpha-root", "zebra-root"], + "参数逆序也要按项目键处理" + ); + assert_eq!(projects(&backward), projects(&forward), "两次结果必须一样"); +} diff --git a/crates/core/src/addon/online/tests/regressions.rs b/crates/core/src/addon/online/tests/regressions.rs new file mode 100644 index 0000000..00b5419 --- /dev/null +++ b/crates/core/src/addon/online/tests/regressions.rs @@ -0,0 +1,189 @@ +//! M186 复审(r1)补的用例:失败去重、null 载荷、版本级去重。 +//! +//! 三条都对应一轮真实缺陷或真实缺口,而不是「补覆盖率」: +//! 1. [`the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it`] — P2-1: +//! 失败分支以前直接 `continue`,同一项目被两条路径要求时取两次版本、记两条失败、 +//! 重试名单里写两遍同一个名字; +//! 2. [`a_null_dependency_type_does_not_poison_the_whole_version_list`] — P2-2: +//! `"dependency_type": null` 曾让整份 `/version` 报 Malformed,于是整个项目都装不上; +//! 3. [`another_version_of_the_same_mod_in_the_directory_still_downloads`] — nit③: +//! 归一化一旦改松,同 mod 的另一个版本会被误判成「已装」而跳过。 + +use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest}; +use crate::addon::online::model::parse_versions; +use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile}; +use crate::download::blocking::block_on; + +use super::fixtures_deps as deps; +use super::support::{version_json, FixtureTransport}; + +const API: &str = "https://api.modrinth.com/v2/project"; +const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version"; +const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version"; +const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version"; + +fn request(root: &str) -> ResolveRequest { + ResolveRequest { + roots: vec![root.to_string()], + game_version: Some("1.20.1".to_string()), + loader: Some("fabric".to_string()), + ..ResolveRequest::default() + } +} + +fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan { + let client = Modrinth::new(transport); + block_on(resolve(&client, request)) +} + +fn chain_transport() -> FixtureTransport { + FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES) + .with(YACL_VERSIONS, deps::YACL) +} + +/// 同一个项目被两条路径要求、且它 404:**只请求一次、只记一条失败**,父边合起来。 +/// +/// r1 P2-1 的探针:`ROOT0001 → {BRANCH01, MIDDLE01}`、`BRANCH01 → GONE0001`(404)、 +/// `MIDDLE01 → LATE0001 → GONE0001`。修之前 GONE0001 被请求 2 次、失败 2 条、 +/// 重试名单是 `["GONE0001","GONE0001"]`(用户会看到「可只重试这 2 个:GONE0001、GONE0001」)。 +#[test] +fn the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it() { + let transport = FixtureTransport::new() + .with( + &format!("{API}/root/version"), + &version_json( + "ROOT0001", + "root.jar", + &[("BRANCH01", "required"), ("MIDDLE01", "required")], + ), + ) + .with( + &format!("{API}/BRANCH01/version"), + &version_json("BRANCH01", "branch.jar", &[("GONE0001", "required")]), + ) + .with( + &format!("{API}/MIDDLE01/version"), + &version_json("MIDDLE01", "middle.jar", &[("LATE0001", "required")]), + ) + .with( + &format!("{API}/LATE0001/version"), + &version_json("LATE0001", "late.jar", &[("GONE0001", "required")]), + ) + .missing(&format!("{API}/GONE0001/version")); + + let plan = resolve_with(&transport, &request("root")); + + let gone_requests = transport + .seen() + .iter() + .filter(|url| url.contains("GONE0001")) + .count(); + assert_eq!( + gone_requests, + 1, + "同一个失败项目不许取两次:{:?}", + transport.seen() + ); + assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures); + assert_eq!(plan.failures[0].requested_as, "GONE0001"); + assert_eq!( + plan.failures[0].required_by, + ["BRANCH01".to_string(), "LATE0001".to_string()], + "两条路径的父边都要留下" + ); + assert_eq!(plan.retry_request().roots, ["GONE0001"], "名单不许重复"); +} + +/// `"dependency_type": null` 不许把整份版本响应打成 Malformed(r1 P2-2 的回归)。 +/// +/// 以前 `RawDependency.dependency_type` 是 `String`:`#[serde(default)]` 只兜「字段缺失」, +/// 兜不住 null ⇒ 整份 `/version` 解析失败 ⇒ 根项目记 `Malformed`、`nodes` 为空 ⇒ 一个都装不上。 +#[test] +fn a_null_dependency_type_does_not_poison_the_whole_version_list() { + let body = r#"[{"id": "ver-null", "project_id": "NULLTYPE", "version_number": "1.0", + "files": [{"url": "https://cdn.modrinth.com/data/NULLTYPE/1.0/null-type.jar", + "filename": "null-type.jar", "size": 16, "primary": true}], + "dependencies": [{"project_id": "X1", "dependency_type": null}]}]"#; + + // 解析层:null 与缺失一样落 `Unknown` + let versions = parse_versions(body).expect("null 不该让整份响应失败"); + assert_eq!( + versions[0].dependencies[0].dependency_type, + DependencyType::Unknown + ); + assert_eq!(versions[0].required_project_ids(), Vec::<&str>::new()); + + // 解析流程:根照样装得上,X1 一次请求都不发 + let transport = FixtureTransport::new().with(&format!("{API}/null-type/version"), body); + let plan = resolve_with(&transport, &request("null-type")); + assert!(plan.is_clean(), "{:?}", plan.failures); + assert_eq!(plan.nodes.len(), 1, "根项目要还在计划里"); + assert_eq!(transport.seen().len(), 1, "未知类型不许递归"); +} + +/// 目录里是**同 mod 的另一个版本** ⇒ 文件名不同,仍然要下载(r1 nit③ 的负向用例)。 +/// +/// 归一化如果改松(例如「砍到第一个 `-` 前 + 砍扩展名」),`sodium-fabric-0.5.11.jar` 与 +/// `sodium-fabric-0.6.0.jar` 会被判成同一个文件,该装的被跳过。 +#[test] +fn another_version_of_the_same_mod_in_the_directory_still_downloads() { + let mut request = request("sodium"); + request.present = vec![PresentFile::new("sodium-fabric-0.5.11.jar")]; + let transport = chain_transport(); + let plan = resolve_with(&transport, &request); + + assert!(plan.skipped.is_empty(), "{:?}", plan.skipped); + assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar"); + assert_ne!( + present_key("sodium-fabric-0.5.11.jar"), + present_key("sodium-fabric-0.6.0.jar"), + "不同版本的文件名归一化后不许相等" + ); +} + +/// 未知/大小写不同的取值一律 `Unknown`,**绝不**猜成 required(这条设计不许被 r1 的修法改坏)。 +#[test] +fn unknown_dependency_types_never_trigger_recursion() { + for raw in [ + "required_by_default", + "Required ", + "Recommended", + "", + "REQUIRED", + "req", + ] { + assert_eq!( + DependencyType::parse(raw), + DependencyType::Unknown, + "{raw:?} 不该被当成 required" + ); + } + assert_eq!(DependencyType::parse("required"), DependencyType::Required); +} + +/// `required_project_ids()` 的「排序 + 去重」契约单独钉住。 +/// +/// 为什么要单独钉:resolver 里 `layer.sort_by` 会把它重排一遍、`wave_in` 也会按同键合并, +/// 于是**这条契约在端到端用例里被兜住了**(删掉这里的 `sort_unstable` 三条顺序用例仍绿)。 +/// 但 `dedup()` 只去相邻重复,不排序就留下 `["ZEBRA01","ALPHA01","ZEBRA01"]`—— +/// 公开 API 说好了「排序去重」,就得有自己的一条。 +#[test] +fn required_project_ids_are_sorted_and_deduplicated_on_their_own() { + let body = version_json( + "dup-root", + "dup-root.jar", + &[ + ("ZEBRA01", "required"), + ("ALPHA01", "required"), + ("ZEBRA01", "required"), + ], + ); + let versions = parse_versions(&body).expect("夹具该解析成功"); + assert_eq!( + versions[0].required_project_ids(), + ["ALPHA01", "ZEBRA01"], + "既要去重(非相邻)也要排序" + ); +} diff --git a/crates/core/src/addon/online/tests/support.rs b/crates/core/src/addon/online/tests/support.rs index 6b0a9dd..f3a70fc 100644 --- a/crates/core/src/addon/online/tests/support.rs +++ b/crates/core/src/addon/online/tests/support.rs @@ -167,3 +167,23 @@ pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) { crate::addon::online::download::to_hex(&sha512), ) } + +/// 拼一份 `/v2/project/{id}/version` 响应:一个版本、一个主文件、按给定顺序声明依赖。 +/// +/// `deps` 的每一项是 `(project_id, dependency_type)`,**顺序照抄站点原文**——顺序无关性由 +/// `resolve` 保证(`tests/order.rs` 用它做「夹具逆序也得到同一顺序」的守门)。 +pub(super) fn version_json(project: &str, file: &str, deps: &[(&str, &str)]) -> String { + let dependencies: Vec = deps + .iter() + .map(|(id, kind)| format!(r#"{{"project_id":"{id}","dependency_type":"{kind}"}}"#)) + .collect(); + format!( + r#"[{{"id":"ver-{project}","project_id":"{project}","name":"{project}", + "version_number":"1.0","version_type":"release","loaders":["fabric"], + "game_versions":["1.20.1"], + "files":[{{"hashes":{{}},"url":"https://cdn.modrinth.com/data/{project}/1.0/{file}", + "filename":"{file}","size":16,"primary":true}}], + "dependencies":[{}]}}]"#, + dependencies.join(",") + ) +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 6f0a4e9..d549611 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -21,6 +21,10 @@ //! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, //! `DependencyPlan::retry_request()` 已经把请求备好了。 //! +//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, +//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 +//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。 +//! //! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」) //! //! | 内容 | 检索 | 装入 | 差在哪 | @@ -40,7 +44,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, + download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport, + FailureKind, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -53,7 +58,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::{plan, sources::failure_of}; +use super::plan; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -67,7 +72,8 @@ pub(super) fn install( version: request.title.clone(), instance: request.instance.clone(), loader: request.loader.label().to_string(), - files_total: 1, + // 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。 + files_total: 0, ..InstallProgressView::new() }; @@ -89,8 +95,7 @@ pub(super) fn install( }); match run(env, request, cancel, &mut progress, tx, epoch) { Ok(done) => { - progress.stage = InstallStage::Done; - progress.files_done = 1; + finish(&mut progress); progress.message = done; let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), @@ -161,7 +166,7 @@ fn run( plan::present_files(&target_dir), ); let core_engine = super::super::engine::engine(env); - let dependency_plan = { + let mut dependency_plan = { let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); block_on(resolve(&client, &resolve_request)) @@ -189,11 +194,13 @@ fn run( }); let fetcher = EngineArtifactFetcher::new(&core_engine); - for node in &dependency_plan.nodes { + let mut installed: Vec = Vec::new(); + for index in 0..dependency_plan.nodes.len() { if cancel.load(Ordering::SeqCst) { return Err(local("已取消(下载中途)")); } - progress.message = node_label(node); + let node = dependency_plan.nodes[index].clone(); + progress.message = node_label(&node); let _ = tx.send(HostTask::Install { progress: Box::new(progress.clone()), epoch, @@ -201,15 +208,36 @@ fn run( // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 let dest = target_dir.join(safe_file_name(&node.file.filename)); - block_on(download_verified( + let outcome = block_on(download_verified( &fetcher, &node.file.url, node.file.sha1(), node.file.sha512(), (node.file.size > 0).then_some(node.file.size), &dest, - )) - .map_err(failure_of)?; + )); + match outcome { + Ok(_) => installed.push(node), + Err(error) => { + // 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因): + // 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。 + // 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。 + dependency_plan.record_failure(DependencyFailure { + requested_as: node.requested_as.clone(), + required_by: node.required_by.clone(), + depth: node.depth, + kind: FailureKind::Unreachable, + cause: Some(error.kind()), + message: format!( + "下载 {} 失败({}):{}", + node.file.filename, + error.kind().as_str(), + error.message() + ), + }); + continue; + } + } progress.files_done += 1; let _ = tx.send(HostTask::Install { progress: Box::new(progress.clone()), @@ -217,11 +245,21 @@ fn run( }); } - // 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。 - if let Some(failure) = plan::failure_of_plan(&dependency_plan) { + // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), + // 并列出「可只重试这 N 个」。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { return Err(failure); } - Ok(plan::summary(&dependency_plan)) + Ok(plan::summary(&dependency_plan, &installed)) +} + +/// 完成态:`files_done` 对齐 `files_total`。 +/// +/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 +/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。 +pub(super) fn finish(progress: &mut InstallProgressView) { + progress.stage = InstallStage::Done; + progress.files_done = progress.files_total; } /// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index 296e7fc..f09986c 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -16,11 +16,20 @@ //! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作, //! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 //! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 +//! +//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) +//! +//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 +//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, +//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 +//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 +//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 use std::path::Path; use unlucky_core::addon::online::{ - DependencyFailure, DependencyPlan, FailureKind, OnlineError, PresentFile, ResolveRequest, + DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, + ResolveRequest, }; use crate::dto::ContentFailureKind; @@ -59,13 +68,19 @@ pub(super) fn request( } /// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。 -pub(super) fn failure_of_plan(plan: &DependencyPlan) -> Option { +/// +/// `installed` = **真落盘的**文件数(下载失败的不算):失败文案里要能说清「主文件已装、 +/// 缺的是谁的前置」,所以不能拿 `plan.nodes.len()` 冒充。 +pub(super) fn failure_of_plan( + plan: &DependencyPlan, + installed: usize, +) -> Option { if plan.is_clean() { return None; } Some(crate::dto::ContentFailure::new( overall_kind(plan), - report(plan), + report(plan, installed), )) } @@ -96,7 +111,7 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { } /// 失败清单(两档各起一段,各自的条数分开数)。 -fn report(plan: &DependencyPlan) -> String { +fn report(plan: &DependencyPlan, installed: usize) -> String { let mut lines: Vec = Vec::new(); let incompatible: Vec = plan .failures_of(FailureKind::Incompatible) @@ -120,12 +135,15 @@ fn report(plan: &DependencyPlan) -> String { unreachable.join(";") )); } - if !plan.nodes.is_empty() { - lines.push(format!("已装入 {} 个文件", plan.nodes.len())); + if installed > 0 { + lines.push(format!("已装入 {installed} 个文件(已下好的不回滚)")); } if !plan.skipped.is_empty() { lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len())); } + if let Some(note) = unresolved_note(plan) { + lines.push(note); + } if !plan.truncated.is_empty() { lines.push(format!( "依赖层数超过上限,未展开:{}", @@ -133,11 +151,7 @@ fn report(plan: &DependencyPlan) -> String { )); } // 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的) - let retry: Vec<&str> = plan - .failures - .iter() - .map(|failure| failure.requested_as.as_str()) - .collect(); + let retry = plan.retry_request().roots; if !retry.is_empty() { lines.push(format!( "可只重试这 {} 个:{}", @@ -161,17 +175,43 @@ fn describe(failure: &DependencyFailure) -> String { } } +/// 「有 required 但没有项目 id」那一档的提示(解析不了,但也不是没事发生)。 +fn unresolved_note(plan: &DependencyPlan) -> Option { + if plan.unresolved_required.is_empty() { + return None; + } + let items: Vec = plan + .unresolved_required + .iter() + .map(|item| { + let what = item + .version_id + .clone() + .or_else(|| item.file_name.clone()) + .unwrap_or_else(|| "未给版本".to_string()); + format!("{}(被 {} 要求)", what, item.parent) + }) + .collect(); + Some(format!( + "有 {} 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:{}", + items.len(), + items.join(";") + )) +} + /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 -pub(super) fn summary(plan: &DependencyPlan) -> String { - if plan.nodes.is_empty() && plan.skipped.is_empty() { +/// +/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, +/// 不从 `plan.nodes` 来——否则「装成了 N 个」会把下载失败的也数进去。 +pub(super) fn summary(plan: &DependencyPlan, installed: &[PlannedNode]) -> String { + if installed.is_empty() && plan.skipped.is_empty() && plan.unresolved_required.is_empty() { return "没有要装的文件".to_string(); } let mut parts: Vec = Vec::new(); - if !plan.nodes.is_empty() { - let dependencies = plan.nodes.iter().filter(|node| !node.is_root()).count(); + if !installed.is_empty() { + let dependencies = installed.iter().filter(|node| !node.is_root()).count(); // 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标) - let names: Vec = plan - .nodes + let names: Vec = installed .iter() .map(|node| { if node.is_root() { @@ -186,11 +226,11 @@ pub(super) fn summary(plan: &DependencyPlan) -> String { }) .collect(); let head = if dependencies == 0 { - format!("装了 {} 个文件", plan.nodes.len()) + format!("装了 {} 个文件", installed.len()) } else { format!( "装了 {} 个文件(含 {} 个前置)", - plan.nodes.len(), + installed.len(), dependencies ) }; @@ -208,6 +248,9 @@ pub(super) fn summary(plan: &DependencyPlan) -> String { names.join("、") )); } + if let Some(note) = unresolved_note(plan) { + parts.push(note); + } if !plan.truncated.is_empty() { parts.push(format!( "依赖层数超过上限,未展开:{}", diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 1fe9bb9..89e6436 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -118,10 +118,12 @@ fn core_errors_map_to_dto_failure_kinds() { // ------------------------------------------------ M186:前置解析的两档失败与可重试名单 +use super::install::finish; use super::plan::{failure_of_plan, summary}; +use crate::dto::{InstallProgressView, InstallStage}; use unlucky_core::addon::online::{ DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason, - SkippedNode, VersionFile, + SkippedNode, UnresolvedRequired, VersionFile, }; fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode { @@ -172,6 +174,7 @@ fn plan( skipped, failures, truncated: Vec::new(), + unresolved_required: Vec::new(), } } @@ -188,7 +191,7 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { vec![node("AANobbMI", "sodium.jar", 0, &[])], Vec::new(), ); - let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); assert_eq!(failure.kind, ContentFailureKind::Unsupported); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); @@ -221,7 +224,7 @@ fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() { Vec::new(), Vec::new(), ); - let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); assert_eq!(failure.kind, ContentFailureKind::NotFound); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); @@ -249,7 +252,7 @@ fn an_offline_jump_wins_over_everything_else() { Vec::new(), Vec::new(), ); - let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); assert_eq!(failure.kind, ContentFailureKind::Offline); assert!(failure.is_offline()); } @@ -272,8 +275,9 @@ fn the_success_message_counts_dependencies_and_skipped_files() { reason: SkipReason::AlreadyPresent, }], ); - assert!(failure_of_plan(&plan).is_none()); - let message = summary(&plan); + assert!(failure_of_plan(&plan, 2).is_none()); + let installed = plan.nodes.clone(); + let message = summary(&plan, &installed); assert!( message.contains("装了 2 个文件(含 1 个前置)"), "{message}" @@ -302,3 +306,74 @@ fn present_files_lists_only_files_in_the_target_directory() { assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]); let _ = std::fs::remove_dir_all(&dir); } + +// ------------------------------------------------ M186 r1:完成态进度、下载失败上下文、无地址前置 + +/// 完成态不许把进度倒回 `1/N`(r1 P2-3:这里曾写死 `files_done = 1`)。 +#[test] +fn finishing_aligns_the_terminal_progress_with_the_total() { + let mut progress = InstallProgressView { + files_total: 3, + files_done: 3, + stage: InstallStage::Downloading, + ..InstallProgressView::new() + }; + finish(&mut progress); + assert_eq!(progress.stage, InstallStage::Done); + assert_eq!(progress.files_done, progress.files_total); + assert_eq!(progress.ratio(), 1.0, "装完了条必须满"); + + // 全部跳过(total = 0)时也不许变成 1/0 + let mut skipped_all = InstallProgressView { + files_total: 0, + ..InstallProgressView::new() + }; + finish(&mut skipped_all); + assert_eq!(skipped_all.files_done, 0); + assert_eq!(skipped_all.stage, InstallStage::Done); +} + +/// 下载阶段的失败也要带上下文:已装几个 + 缺的是谁的前置 + 可只重试谁。 +#[test] +fn a_download_failure_keeps_the_already_installed_context() { + let plan = plan( + vec![failure( + "P7dR8mSH", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + )], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + let failure = failure_of_plan(&plan, 1).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Offline); + assert!( + failure.detail.contains("已装入 1 个文件(已下好的不回滚)"), + "{}", + failure.detail + ); + assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); + assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); +} + +/// 只有版本 id、没有项目 id 的必备前置:既装不了,也不许静默吞掉。 +#[test] +fn a_required_dependency_without_a_project_id_is_reported() { + let mut plan = plan( + Vec::new(), + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + plan.unresolved_required = vec![UnresolvedRequired { + parent: "AANobbMI".to_string(), + version_id: Some("ver-embedded".to_string()), + file_name: Some("embedded-lib.jar".to_string()), + }]; + let installed = plan.nodes.clone(); + let message = summary(&plan, &installed); + assert!( + message.contains("有 1 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:ver-embedded(被 AANobbMI 要求)"), + "{message}" + ); +} From ff2f229b802175b65d1bcc30a26608d95e70bdff Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 14:26:50 +0800 Subject: [PATCH 08/33] =?UTF-8?q?fix(addon):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=8B=E8=BD=BD=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=8F=AF=E6=B5=8B=E8=BE=B9=E7=95=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=AE=88=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 阻塞项:下载循环从 `install::run` 抽到新文件 `addon/downloads.rs` (`download_failure()` + `download_stage()`),假下载器就能驱动它;三条用例分别钉 ①失败→Unreachable+叶子成因、②第 2 个失败不中断且已装不回滚、③解析完就取消一份都不下。 实测删掉 `record_failure` 那行会红(failures 0 vs 1),把 `cause` 丢掉也会红; - nit① 补 core 用例:夹具里 `{"version_id":"vid-only","dependency_type":"required"}` ⇒ `plan.unresolved_required == [UnresolvedRequired{parent:"MIXED001",…}]`,同版本的 `project_id` required 仍被跟;删掉收集那段即红(left: [] vs 一条); - nit② 取消检查加回解析之后(`download_stage` 入口),并用例钉「解析后取消 ⇒ 不下任何文件」; - nit③ `fail()` 文档写明去重按**请求键**而非规范 id 的边界(先 slug 后 project_id 仍会两条), 不消掉但不再让人误以为全覆盖; - `install.rs` 只留解析 + 收口(275 行),模块表登记 `downloads`。 --- crates/core/src/addon/online/resolve.rs | 8 + .../src/addon/online/tests/regressions.rs | 45 ++- crates/presenter/src/host/addon.rs | 2 + crates/presenter/src/host/addon/downloads.rs | 315 ++++++++++++++++++ crates/presenter/src/host/addon/install.rs | 69 +--- 5 files changed, 383 insertions(+), 56 deletions(-) create mode 100644 crates/presenter/src/host/addon/downloads.rs diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs index 30a0e9d..16966c7 100644 --- a/crates/core/src/addon/online/resolve.rs +++ b/crates/core/src/addon/online/resolve.rs @@ -196,6 +196,14 @@ pub async fn resolve( /// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`, /// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。 /// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。 +/// +/// # 这道去重按**请求键**,不是规范 id(已知边界,别当成全覆盖) +/// +/// 规范 id(`project_id`)要拿到响应才知道;失败时没有响应,能用的只有请求键。所以同一个 +/// 失败项目如果**先以 slug、后以 project_id** 被两个不同的父项目要求,仍会被取两次、名单里 +/// 出现两个名字。真实载荷里 `dependencies[].project_id` 与用户点的 slug 混用的情况没有证据, +/// 且这一档本来就是「取不到」,重复一条的代价是名单多一行、不是多装一个东西——故本轮不消掉, +/// 只把边界写在这里(复审 r2 nit③)。 fn fail( plan: &mut DependencyPlan, alias: &mut BTreeMap, diff --git a/crates/core/src/addon/online/tests/regressions.rs b/crates/core/src/addon/online/tests/regressions.rs index 00b5419..605c2fc 100644 --- a/crates/core/src/addon/online/tests/regressions.rs +++ b/crates/core/src/addon/online/tests/regressions.rs @@ -11,7 +11,7 @@ use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest}; use crate::addon::online::model::parse_versions; -use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile}; +use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile, UnresolvedRequired}; use crate::download::blocking::block_on; use super::fixtures_deps as deps; @@ -187,3 +187,46 @@ fn required_project_ids_are_sorted_and_deduplicated_on_their_own() { "既要去重(非相邻)也要排序" ); } + +/// 只有 `version_id` 的必备前置:**core 自己**要把它们收进 `unresolved_required`(r2 nit①)。 +/// +/// presenter 那条用例是直接赋值 `plan.unresolved_required`,绕过了 core;这里从夹具走一遍解析, +/// 顺带钉住「别为了收集把正常的 required 也吞掉」——同一版本里带 `project_id` 的那条仍要被跟。 +#[test] +fn a_required_dependency_with_only_a_version_id_is_collected_by_the_resolver() { + let body = r#"[{"id": "ver-mixed", "project_id": "MIXED001", "version_number": "1.0", + "files": [{"url": "https://cdn.modrinth.com/data/MIXED001/1.0/mixed.jar", + "filename": "mixed.jar", "size": 16, "primary": true}], + "dependencies": [ + {"version_id": "vid-only", "dependency_type": "required"}, + {"project_id": "FOLLOW01", "dependency_type": "required"} + ]}]"#; + let transport = FixtureTransport::new() + .with(&format!("{API}/mixed/version"), body) + .with( + &format!("{API}/FOLLOW01/version"), + &version_json("FOLLOW01", "follow.jar", &[]), + ); + + let plan = resolve_with(&transport, &request("mixed")); + + assert_eq!( + plan.unresolved_required, + [UnresolvedRequired { + parent: "MIXED001".to_string(), + version_id: Some("vid-only".to_string()), + file_name: None, + }], + "只给版本 id 的 required 必须被记下来" + ); + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!( + projects, + ["MIXED001", "FOLLOW01"], + "同一版本里带 project_id 的 required 仍要被跟" + ); +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 24246ab..92e6073 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -25,6 +25,7 @@ //! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] | //! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 | //! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 | +//! | [`downloads`] | 下载阶段(可测):逐个下、失败进名单不中断、两处取消点 | //! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 | //! //! ## 整合包与 CF 的边界(都写进报告) @@ -33,6 +34,7 @@ //! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。 //! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。 +mod downloads; mod http; mod install; mod plan; diff --git a/crates/presenter/src/host/addon/downloads.rs b/crates/presenter/src/host/addon/downloads.rs new file mode 100644 index 0000000..3d91061 --- /dev/null +++ b/crates/presenter/src/host/addon/downloads.rs @@ -0,0 +1,315 @@ +//! 下载阶段的**可测那一段**:把计划里的文件逐个下下来,失败进名单而不中断。 +//! +//! # 为什么单独一个文件 +//! +//! 这段逻辑以前整块躺在 `install::run` 里,而 `run` 需要实例目录 + 真引擎 ⇒ 没有用例能驱动它, +//! 于是「下载失败记进两档名单」这条改动**整段删掉也不会红一条**(M186 r2 复审实测: +//! 整段包进 `if false` 后 `cargo test -p unlucky-presenter --lib host::addon` 仍 36 passed)。 +//! 抽成只依赖 [`ArtifactFetcher`] 的函数之后,假下载器就能驱动它。 +//! +//! # 三条不变量(每条都有用例) +//! +//! 1. **失败不中断**:某个文件下载失败 ⇒ 记进 [`DependencyPlan::record_failure`],继续下一个; +//! 2. **已装的不回滚**:成功的文件留在盘上,并出现在返回的「已装」名单里; +//! 3. **两处取消点**:进入本阶段先查一次(`run` 解析完就调它 ⇒ 解析阶段的取消生效, +//! 全部文件都「已存在」而 `nodes` 为空时也不会漏掉取消),循环每次迭代再查一次。 + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +use unlucky_core::addon::online::{ + download_verified, ArtifactFetcher, DependencyFailure, DependencyPlan, FailureKind, + OnlineError, PlannedNode, +}; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{ContentFailure, InstallProgressView}; + +use super::super::job::{HostTask, JobSender}; +use super::install::{local, node_label, safe_file_name}; + +/// 下载失败 → 两档失败里的一条(`Unreachable` + 叶子成因 `cause`)。 +/// +/// 为什么归 `Unreachable`:`download_verified` 的失败就是「取不到这份字节」——没网 / 站点错 / +/// 内容与声明不符 / 本地 IO,叶子成因留在 `cause` 上,上层照 `sources::failure_of` 映射成 +/// DTO 大类;**不是** `Incompatible`(换版本/加载器也救不了一次断网)。 +#[must_use] +pub(super) fn download_failure(node: &PlannedNode, error: &OnlineError) -> DependencyFailure { + DependencyFailure { + requested_as: node.requested_as.clone(), + required_by: node.required_by.clone(), + depth: node.depth, + kind: FailureKind::Unreachable, + cause: Some(error.kind()), + message: format!( + "下载 {} 失败({}):{}", + node.file.filename, + error.kind().as_str(), + error.message() + ), + } +} + +/// 下载阶段:先查一次取消,再逐个下(失败进名单、不中断)。 +/// +/// 返回**真落盘**的节点(失败的不在里面),调用方据此拼「装了 N 个」。 +/// +/// # Errors +/// 用户取消时返回一条 `ContentFailure`(detail 以「已取消」开头,`install()` 据此画 `Cancelled`)。 +pub(super) fn download_stage( + plan: &mut DependencyPlan, + target_dir: &Path, + fetcher: &dyn ArtifactFetcher, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result, ContentFailure> { + // 解析完立刻查一次:解析最多要跑 6 层版本请求,此时取消不该先下一份文件再说 + // (main 的 `resolve_file` 之后就有这条检查,r2 复审指出我们把它丢了)。 + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载开始前)")); + } + let mut installed: Vec = Vec::new(); + for index in 0..plan.nodes.len() { + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载中途)")); + } + let node = plan.nodes[index].clone(); + progress.message = node_label(&node); + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, + // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 + let dest = target_dir.join(safe_file_name(&node.file.filename)); + let outcome = block_on(download_verified( + fetcher, + &node.file.url, + node.file.sha1(), + node.file.sha512(), + (node.file.size > 0).then_some(node.file.size), + &dest, + )); + match outcome { + Ok(_) => installed.push(node), + Err(error) => { + // 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因): + // 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。 + plan.record_failure(download_failure(&node, &error)); + continue; + } + } + progress.files_done += 1; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + } + Ok(installed) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicBool; + use std::sync::Mutex; + + use unlucky_core::addon::online::{ + ArtifactFetcher, DependencyPlan, FailureKind, FetchArgs, OnlineError, OnlineErrorKind, + OnlineFuture, PlannedNode, ResolveRequest, VersionFile, + }; + + use crate::dto::InstallProgressView; + + use super::{download_failure, download_stage}; + + /// 假下载器:`fail_on` 里的文件名一律「没联网」,其余写一行字节(**不校验摘要**)。 + struct FakeFetcher { + fail_on: Vec<&'static str>, + attempts: Mutex>, + } + + impl FakeFetcher { + fn new(fail_on: &[&'static str]) -> Self { + Self { + fail_on: fail_on.to_vec(), + attempts: Mutex::new(Vec::new()), + } + } + + fn attempts(&self) -> Vec { + self.attempts.lock().expect("锁没中毒").clone() + } + } + + impl ArtifactFetcher for FakeFetcher { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + let name = args + .dest + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + self.attempts.lock().expect("锁没中毒").push(name.clone()); + if self.fail_on.contains(&name.as_str()) { + return Err(OnlineError::offline("假装的断网")); + } + std::fs::write(args.dest, b"jar").map_err(|error| { + OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}")) + })?; + Ok(4) + }) + } + } + + fn node(project: &str, file: &str, required_by: &[&str]) -> PlannedNode { + PlannedNode { + project: project.to_string(), + requested_as: project.to_string(), + version_id: format!("ver-{project}"), + version_name: project.to_string(), + version_number: "1.0".to_string(), + file: VersionFile { + url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"), + filename: file.to_string(), + size: 0, + ..VersionFile::default() + }, + depth: u8::from(!required_by.is_empty()), + required_by: required_by + .iter() + .map(|parent| (*parent).to_string()) + .collect(), + } + } + + fn plan(nodes: Vec) -> DependencyPlan { + DependencyPlan { + request: ResolveRequest::single("AANobbMI"), + nodes, + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + unresolved_required: Vec::new(), + } + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ull-m186-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + /// 失败的那一条**进名单**、父边与叶子成因都带着(守的是 [`download_failure`] 的映射)。 + #[test] + fn a_download_error_becomes_an_unreachable_failure_with_its_leaf_cause() { + let node = node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]); + let failure = download_failure(&node, &OnlineError::offline("连不上 cdn.modrinth.com")); + + assert_eq!(failure.kind, FailureKind::Unreachable); + assert_eq!(failure.cause, Some(OnlineErrorKind::NotConnected)); + assert_eq!(failure.requested_as, "P7dR8mSH"); + assert_eq!(failure.required_by, ["AANobbMI".to_string()]); + assert_eq!(failure.depth, 1); + assert!( + failure.message.contains("fabric-api.jar"), + "{}", + failure.message + ); + assert!( + failure.message.contains("online_not_connected"), + "成因要带 code:{}", + failure.message + ); + } + + /// 第 2 个文件失败:**不中断**、已装的不回滚、失败进名单、第 3 个仍会被尝试。 + #[test] + fn a_failed_file_does_not_stop_the_rest_and_keeps_what_already_landed() { + let dir = temp_dir("download-partial"); + let mut plan = plan(vec![ + node("AANobbMI", "sodium.jar", &[]), + node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]), + node("1eAoo2KR", "yacl.jar", &["P7dR8mSH"]), + ]); + let fetcher = FakeFetcher::new(&["fabric-api.jar"]); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut progress = InstallProgressView { + files_total: 3, + ..InstallProgressView::new() + }; + + let installed = download_stage( + &mut plan, + &dir, + &fetcher, + &AtomicBool::new(false), + &mut progress, + &tx, + 7, + ) + .expect("没取消就不该回 Err"); + + let names: Vec<&str> = installed + .iter() + .map(|node| node.file.filename.as_str()) + .collect(); + assert_eq!( + names, + ["sodium.jar", "yacl.jar"], + "成功的不回滚、后面的照下" + ); + // 失败的那个会被 `download_verified` 重试 `RETRY_ATTEMPTS` 次(这里 3 次), + // 关键是**它之后**还会去试第 3 个文件。 + let attempts = fetcher.attempts(); + assert_eq!(attempts.first().map(String::as_str), Some("sodium.jar")); + assert_eq!(attempts.last().map(String::as_str), Some("yacl.jar")); + assert_eq!( + attempts + .iter() + .filter(|name| *name == "fabric-api.jar") + .count(), + 3, + "失败的尝试 + 重试都发生在第 3 个文件之前:{attempts:?}" + ); + assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures); + assert_eq!(plan.failures[0].requested_as, "P7dR8mSH"); + assert!(plan.failures[0].message.contains("fabric-api.jar")); + assert_eq!(progress.files_done, 2, "进度只数真落盘的"); + assert!(dir.join("sodium.jar").exists(), "已下好的文件不许删"); + assert!(!dir.join("fabric-api.jar").exists()); + assert!(dir.join("yacl.jar").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// 解析完就取消 ⇒ 一条文件都不下(r2 nit②:main 有这条检查,我们曾把它丢了)。 + #[test] + fn cancelling_right_after_resolution_touches_nothing() { + let dir = temp_dir("download-cancelled"); + let mut plan = plan(vec![ + node("AANobbMI", "sodium.jar", &[]), + node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]), + ]); + let fetcher = FakeFetcher::new(&[]); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut progress = InstallProgressView::new(); + + let error = download_stage( + &mut plan, + &dir, + &fetcher, + &AtomicBool::new(true), + &mut progress, + &tx, + 7, + ) + .expect_err("取消必须回 Err"); + + assert!(error.detail.contains("已取消"), "{}", error.detail); + assert!(fetcher.attempts().is_empty(), "一份都不许下"); + assert!(plan.failures.is_empty(), "取消不是失败,不进名单"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index d549611..b3d2a4b 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -44,8 +44,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport, - FailureKind, Modrinth, PlannedNode, + resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -58,7 +57,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::plan; +use super::{downloads, plan}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -194,56 +193,16 @@ fn run( }); let fetcher = EngineArtifactFetcher::new(&core_engine); - let mut installed: Vec = Vec::new(); - for index in 0..dependency_plan.nodes.len() { - if cancel.load(Ordering::SeqCst) { - return Err(local("已取消(下载中途)")); - } - let node = dependency_plan.nodes[index].clone(); - progress.message = node_label(&node); - let _ = tx.send(HostTask::Install { - progress: Box::new(progress.clone()), - epoch, - }); - // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, - // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 - let dest = target_dir.join(safe_file_name(&node.file.filename)); - let outcome = block_on(download_verified( - &fetcher, - &node.file.url, - node.file.sha1(), - node.file.sha512(), - (node.file.size > 0).then_some(node.file.size), - &dest, - )); - match outcome { - Ok(_) => installed.push(node), - Err(error) => { - // 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因): - // 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。 - // 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。 - dependency_plan.record_failure(DependencyFailure { - requested_as: node.requested_as.clone(), - required_by: node.required_by.clone(), - depth: node.depth, - kind: FailureKind::Unreachable, - cause: Some(error.kind()), - message: format!( - "下载 {} 失败({}):{}", - node.file.filename, - error.kind().as_str(), - error.message() - ), - }); - continue; - } - } - progress.files_done += 1; - let _ = tx.send(HostTask::Install { - progress: Box::new(progress.clone()), - epoch, - }); - } + // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 + let installed = downloads::download_stage( + &mut dependency_plan, + &target_dir, + &fetcher, + cancel, + progress, + tx, + epoch, + )?; // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 @@ -263,7 +222,7 @@ pub(super) fn finish(progress: &mut InstallProgressView) { } /// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 -fn node_label(node: &PlannedNode) -> String { +pub(super) fn node_label(node: &PlannedNode) -> String { if node.is_root() { node.file.filename.clone() } else { @@ -272,7 +231,7 @@ fn node_label(node: &PlannedNode) -> String { } /// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 -fn local(reason: impl Into) -> ContentFailure { +pub(super) fn local(reason: impl Into) -> ContentFailure { ContentFailure::new(ContentFailureKind::Other, reason) } From b457f0acc819c6fb4e96cc49ecaddf07bd106dfe Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 20:42:28 +0800 Subject: [PATCH 09/33] =?UTF-8?q?feat(m179):=20=E5=A4=96=E8=A7=82=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=8E=A5=E7=9C=9F=E8=AF=AD=E8=A8=80=E5=B9=B6=E6=8B=86?= =?UTF-8?q?=E6=88=90=E4=BA=94=E6=AE=B5=E6=96=87=E4=BB=B6=EF=BC=88=E5=8F=AA?= =?UTF-8?q?=E6=8E=A5=E7=AE=A1=E5=AD=90=EF=BC=8C=E4=B8=8D=E8=A1=A5=E6=96=87?= =?UTF-8?q?=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M172 审计:`pages/appearance/**` 中文界面下真正本地化 0/27,两个根因各断一刀。 ① 拆文件(纯搬移):`section.rs` 395 行 → `section/` 五个文件,行数守卫只剩 5 行余量, 不拆就加不了任何东西。 - `section/mod.rs`(壳:section() + card() + 五个行控件 + 取词口 t) - `section/window.rs`(① 整窗 α + ② 圆角,含 notice/说明段) - `section/panel.rs`(③ 面板 α)、`section/bg_image.rs`(④⑤⑥⑦⑧)、 `section/colour_source.rs`(⑨) 取证:每段与原文件做「去空白/去尾逗号」diff,4/4 相同;只有五处机械改写 (`rows = rows.push(…)`→`rows.push(…)`、`t(Key::X)`→`t(Key::X, lang)`、 `super::notice`→`super::super::notice`、一处 `.into()`、rustfmt 换行)。 ② 语言接到渲染层:`settings::Body` 从 `fn(&Ui, States)` 变成 `fn(&Ui, States, Lang)`, `form` 传 `language.effective`,`settings/appearance.rs` 收 `lang` 转给 `pages::appearance::section(ui, state, lang)`,`section::t(key, lang)` 取词。 选「接进唯一那个槽位」而不是再给 `form` 加一条按下标分发的 `else if`:`settings/mod.rs` 贴着 400 行硬顶(M195 还要在此落地),这条路净增 0 行且不可能与分发表漂开。 验收点:`pages/appearance/**` 里已经搜不到 `Lang::En`。 ③ 能变红的守卫:`the_appearance_group_speaks_chinese_when_the_interface_does` —— 走 `settings::view` 真分发渲染一帧,断言取词记录里含汉字、英文那帧不含汉字且同一 key 中英译文不同。取证口是 `appearance::probe`(`#[cfg(test)]` thread_local):iced 0.14 没有可遍历控件树的公开接口(`Widget::children()` 返回状态 Tree;`operate` 要 Renderer), 所以取证点退到「字进树之前」的唯一取词口。变异证明:把 `t()` 改回 `Lang::En.catalog()` ⇒ 该用例红(失败信息逐条列出四条英文);还原 ⇒ 绿。 只接语言管子,不补文案:本组其余英文仍是字面量(行号见 M179 报告)。真实窗口截图、 macOS/Linux 字体差异未验。 --- crates/app-iced/src/pages/appearance/mod.rs | 6 +- crates/app-iced/src/pages/appearance/probe.rs | 46 ++ .../app-iced/src/pages/appearance/section.rs | 395 ------------------ .../src/pages/appearance/section/bg_image.rs | 109 +++++ .../pages/appearance/section/colour_source.rs | 41 ++ .../src/pages/appearance/section/mod.rs | 258 ++++++++++++ .../src/pages/appearance/section/panel.rs | 45 ++ .../src/pages/appearance/section/window.rs | 86 ++++ .../app-iced/src/pages/settings/appearance.rs | 15 +- crates/app-iced/src/pages/settings/mod.rs | 27 +- .../settings/tests/appearance_section.rs | 121 +++++- .../pages/settings/tests/general_section.rs | 8 +- .../src/pages/settings/tests/java_section.rs | 3 +- .../app-iced/src/pages/settings/tests/mod.rs | 7 +- 14 files changed, 745 insertions(+), 422 deletions(-) create mode 100644 crates/app-iced/src/pages/appearance/probe.rs delete mode 100644 crates/app-iced/src/pages/appearance/section.rs create mode 100644 crates/app-iced/src/pages/appearance/section/bg_image.rs create mode 100644 crates/app-iced/src/pages/appearance/section/colour_source.rs create mode 100644 crates/app-iced/src/pages/appearance/section/mod.rs create mode 100644 crates/app-iced/src/pages/appearance/section/panel.rs create mode 100644 crates/app-iced/src/pages/appearance/section/window.rs diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 1cbda1c..ccb2505 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -20,7 +20,7 @@ //! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | -//! | `section` | 设置页「外观」分组(真控件) | 无 | +//! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 | //! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 | //! //! ## 偏离登记(D30 要求逐条写清) @@ -65,10 +65,12 @@ pub mod background; mod palette; mod picture; +#[cfg(test)] +pub(crate) mod probe; mod section; mod state; -/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。 +/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。 /// /// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。 /// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开 diff --git a/crates/app-iced/src/pages/appearance/probe.rs b/crates/app-iced/src/pages/appearance/probe.rs new file mode 100644 index 0000000..2c340c2 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/probe.rs @@ -0,0 +1,46 @@ +//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。 +//! +//! ## 为什么需要这一层 +//! +//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的 +//! 公开接口: +//! +//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree` +//! (不是子控件),没有 `as_any` 之类的向下转型口; +//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器** +//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。 +//! +//! 所以取证点退到「字进树之前」那一层:`section::t` 是本分组**唯一**的取词口,它把每条译文 +//! 顺手记一份,测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把 +//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。 +//! +//! ## 为什么是 thread-local +//! +//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台, +//! 也不需要加锁或串行化。 +//! +//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。 + +use std::cell::RefCell; + +use unlucky_i18n::catalog::Key; + +// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序。 +thread_local! { + // clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }` + // 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号): + // `thread_local!{ static X: RefCell> = const { RefCell::new(Vec::new()) }; }` + // —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。 + #[allow(clippy::missing_const_for_thread_local)] + static TEXTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// 记一条(由 [`super::section::t`] 调用;不对外)。 +pub(crate) fn record(key: Key, text: &'static str) { + TEXTS.with(|texts| texts.borrow_mut().push((key, text))); +} + +/// 取走并清空本线程记录下的取词(测试用)。 +pub(crate) fn take() -> Vec<(Key, &'static str)> { + TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut())) +} diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs deleted file mode 100644 index 04e3ada..0000000 --- a/crates/app-iced/src/pages/appearance/section.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。 -//! -//! ## 控件与键的对应 -//! -//! | 控件 | 键 | 改动消息 | -//! |---|---|---| -//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] | -//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] | -//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | -//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] | -//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] | -//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | -//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] | -//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] | -//! -//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。 -//! -//! ## 为什么「值」与「生效值」分开显示 -//! -//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 -//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 - -use iced::widget::{button, column, container, row, space}; -use iced::{ - Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, -}; - -use unlucky_design::color::Role; -use unlucky_design::component_map::Component; -use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; -use unlucky_i18n::catalog::Key; -use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner}; - -use crate::app::components::inputs::{slider, text_field, Field, Slider}; -use crate::pages::{frame, Message}; -use crate::theme::Ui; - -use super::alpha::{ - panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction, - WINDOW_ALPHA_FLOOR, -}; -use super::background::{self, BG_DIM_MAX}; -use super::state::State; -use super::Change; - -/// 「外观」分组整块(一张卡)。 -pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { - let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill); - - // ① 整窗透明度 - let window_percent = format!("{:.0}%", state.window_opacity * 100.0); - rows = rows.push(slider_row( - ui, - "Window transparency", - window_percent, - format!( - "Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.", - WINDOW_ALPHA_FLOOR - ), - state.window_opacity < 1.0, - slider( - Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| { - Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction))) - }), - ), - )); - - // ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。 - let corner = state.window_corner(); - rows = rows.push(choice_row( - ui, - t(Key::SettingsAppearanceCorner), - vec![ - choice( - ui, - t(Key::SettingsAppearanceCornerSystem), - corner == WindowCorner::System, - Change::Corner(WindowCorner::System), - ), - choice( - ui, - t(Key::SettingsAppearanceCornerSquare), - corner == WindowCorner::Square, - Change::Corner(WindowCorner::Square), - ), - ], - )); - // **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。 - // 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。 - if corner == WindowCorner::System && state.window_opacity < 1.0 { - rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended))); - let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into(); - rows = rows.push(gap); - } - let tone = ui.color(Role::OnSurfaceVariant); - rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone)); - - // ③ 面板透明度(CPU 档强制 1.0) - let forced = state.panel_is_forced(); - let panel_percent = if forced { - format!("100% (forced from {:.2})", state.panel_alpha) - } else { - format!("{:.0}%", state.effective_panel_alpha() * 100.0) - }; - let panel_hint = if forced { - "Software renderer: panels stay fully opaque (window transparency still works)." - } else { - "Only the panel/card layer. Floor 0.40; independent of window transparency." - }; - let panel_control = if forced { - slider(Slider::new(ui, panel_fraction(1.0))) - } else { - slider( - Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| { - Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction))) - }), - ) - }; - rows = rows.push(slider_row( - ui, - "Panel transparency", - panel_percent, - panel_hint.to_owned(), - !forced, - panel_control, - )); - - // ④ 背景图开关 - rows = rows.push(choice_row( - ui, - "Background image", - vec![ - choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)), - choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)), - ], - )); - - // ⑤ 路径(文本框:受控,输入即生效) - let path_field: Element<'a, Message> = text_field( - ui, - Field::new(&state.bg_path) - .label("Image path") - .placeholder("D:/Pictures/background.png"), - |value| Message::Appearance(Change::BgImage(value)), - ); - rows = rows.push( - column![label(ui, "Image path"), path_field] - .spacing(ui.space(Space::X1)) - .width(Length::Fill), - ); - - // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** - // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 - for note in state.diagnostics() { - rows = rows.push(note_line(ui, note, ui.color(Role::Error))); - } - if let Some(note) = state.backend_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - if let Some(note) = state - .image() - .and_then(|image| background::small_tile_note(image, state.bg_fill)) - { - rows = rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant))); - } - if let Some(note) = state.palette_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - let resolved = format!("Colours: {}", state.palette_level()); - rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); - - // ⑦ 填充 - rows = rows.push(choice_row( - ui, - "Fill", - vec![ - choice( - ui, - "Cover", - state.bg_fill == BgFill::Cover, - Change::BgFill(BgFill::Cover), - ), - choice( - ui, - "Contain", - state.bg_fill == BgFill::Contain, - Change::BgFill(BgFill::Contain), - ), - choice( - ui, - "Tile", - state.bg_fill == BgFill::Tile, - Change::BgFill(BgFill::Tile), - ), - ], - )); - - // ⑧ 暗化 - rows = rows.push(slider_row( - ui, - "Dim", - format!("{:.0}%", state.bg_dim * 100.0), - format!( - "Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}." - ), - state.bg_dim > 0.0, - slider( - Slider::new(ui, state.bg_dim / BG_DIM_MAX) - .on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))), - ), - )); - - // ⑨ 配色来源 - rows = rows.push(choice_row( - ui, - "Colour source", - vec![ - choice( - ui, - "Background", - state.palette_source == PaletteSource::BackgroundImage, - Change::PaletteSource(PaletteSource::BackgroundImage), - ), - choice( - ui, - "Wallpaper", - state.palette_source == PaletteSource::Wallpaper, - Change::PaletteSource(PaletteSource::Wallpaper), - ), - choice( - ui, - "Preset", - state.palette_source == PaletteSource::Preset, - Change::PaletteSource(PaletteSource::Preset), - ), - ], - )); - - card(ui, rows) -} - -/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。 -fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { - let spec = Component::OutlinedCard.spec(); - let background = ui.layer(MaterialLayer::Card); - - container(content) - .width(Length::Fill) - .padding(ui.metrics().card_padding) - .style(move |_theme| container::Style { - background: Some(Background::Color(background)), - border: ui.outlined(spec.shape), - ..container::Style::default() - }) - .into() -} - -// --------------------------------------------------------------- 行与控件 - -fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { - frame::text_role( - ui, - TypeRole::BodyMedium, - false, - content, - ui.color(Role::OnSurface), - ) -} - -/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。 -/// -/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission, -/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**, -/// 接线时只换这一行,不用回来重写控件。 -fn t(key: Key) -> &'static str { - unlucky_i18n::Lang::En.catalog().text(key) -} - -fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> { - // 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。 - frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) -} - -/// 一行「标签 + 值 + 滑块 + 说明」。 -fn slider_row<'a>( - ui: &'a Ui, - title: &'a str, - value: String, - hint: String, - enabled: bool, - control: Element<'a, Message>, -) -> Element<'a, Message> { - let value_color = if enabled { - ui.color(Role::OnSurface) - } else { - ui.color(Role::OnSurfaceVariant) - }; - - let head = row![ - container(label(ui, title)) - .width(Length::Fill) - .align_y(Alignment::Center), - frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color), - ] - .width(Length::Fill) - .align_y(Alignment::Center); - - column![ - head, - control, - frame::text_role_owned( - ui, - TypeRole::BodySmall, - false, - hint, - ui.color(Role::OnSurfaceVariant), - ), - ] - .spacing(ui.space(Space::X1)) - .width(Length::Fill) - .padding(Padding::from([ui.space(Space::X2), 0.0])) - .into() -} - -/// 一行「标签 + 一排互斥按钮」。 -fn choice_row<'a>( - ui: &'a Ui, - title: &'a str, - choices: Vec>, -) -> Element<'a, Message> { - row![ - container(label(ui, title)) - .width(Length::Fill) - .align_y(Alignment::Center), - row(choices) - .spacing(ui.space(Space::X1)) - .align_y(Alignment::Center), - ] - .width(Length::Fill) - .align_y(Alignment::Center) - .padding(Padding::from([ui.space(Space::X2), 0.0])) - .into() -} - -/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。 -fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> { - let height = ui.metrics().control_height; - let content_color = if selected { - ui.color(Role::OnSecondaryContainer) - } else { - ui.color(Role::OnSurfaceVariant) - }; - - button( - container(frame::text_role( - ui, - TypeRole::LabelLarge, - selected, - text, - content_color, - )) - .align_y(Alignment::Center), - ) - .height(height) - .padding(Padding::from([0.0, ui.space(Space::X3)])) - .style(move |_theme: &IcedTheme, status| { - let background = if selected { - Some(Background::Color(ui.color(Role::SecondaryContainer))) - } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Some(Background::Color(ui.state_over( - unlucky_design::color::StateLayer::Hover, - Role::OnSurface, - Role::Surface, - ))) - } else { - None - }; - - button::Style { - background, - text_color: content_color, - border: Border { - radius: ui.radius(ShapeScale::Full, height), - ..Border::default() - }, - shadow: Shadow::default(), - snap: true, - } - }) - .on_press(Message::Appearance(change)) - .into() -} diff --git a/crates/app-iced/src/pages/appearance/section/bg_image.rs b/crates/app-iced/src/pages/appearance/section/bg_image.rs new file mode 100644 index 0000000..0c954e5 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -0,0 +1,109 @@ +//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。 +//! +//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3: +//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。 + +use iced::widget::column; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_presenter::prefs::BgFill; + +use crate::app::components::inputs::{slider, text_field, Field, Slider}; +use crate::pages::Message; +use crate::theme::Ui; + +use super::super::background::{self, BG_DIM_MAX}; +use super::super::state::State; +use super::super::Change; +use super::{choice, choice_row, label, note_line, slider_row}; + +/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。 +pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { + // ④ 背景图开关 + rows.push(choice_row( + ui, + "Background image", + vec![ + choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)), + choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)), + ], + )); + + // ⑤ 路径(文本框:受控,输入即生效) + let path_field: Element<'a, Message> = text_field( + ui, + Field::new(&state.bg_path) + .label("Image path") + .placeholder("D:/Pictures/background.png"), + |value| Message::Appearance(Change::BgImage(value)), + ); + rows.push( + column![label(ui, "Image path"), path_field] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into(), + ); + + // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** + // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 + for note in state.diagnostics() { + rows.push(note_line(ui, note, ui.color(Role::Error))); + } + if let Some(note) = state.backend_note() { + rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + if let Some(note) = state + .image() + .and_then(|image| background::small_tile_note(image, state.bg_fill)) + { + rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant))); + } + if let Some(note) = state.palette_note() { + rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + let resolved = format!("Colours: {}", state.palette_level()); + rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); + + // ⑦ 填充 + rows.push(choice_row( + ui, + "Fill", + vec![ + choice( + ui, + "Cover", + state.bg_fill == BgFill::Cover, + Change::BgFill(BgFill::Cover), + ), + choice( + ui, + "Contain", + state.bg_fill == BgFill::Contain, + Change::BgFill(BgFill::Contain), + ), + choice( + ui, + "Tile", + state.bg_fill == BgFill::Tile, + Change::BgFill(BgFill::Tile), + ), + ], + )); + + // ⑧ 暗化 + rows.push(slider_row( + ui, + "Dim", + format!("{:.0}%", state.bg_dim * 100.0), + format!( + "Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}." + ), + state.bg_dim > 0.0, + slider( + Slider::new(ui, state.bg_dim / BG_DIM_MAX) + .on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))), + ), + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/colour_source.rs b/crates/app-iced/src/pages/appearance/section/colour_source.rs new file mode 100644 index 0000000..e79a2b9 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/colour_source.rs @@ -0,0 +1,41 @@ +//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。 + +use iced::Element; + +use unlucky_presenter::prefs::PaletteSource; + +use crate::pages::Message; +use crate::theme::Ui; + +use super::super::state::State; +use super::super::Change; +use super::{choice, choice_row}; + +/// 把 ⑨ 配色来源推进行 `rows`。 +pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { + // ⑨ 配色来源 + rows.push(choice_row( + ui, + "Colour source", + vec![ + choice( + ui, + "Background", + state.palette_source == PaletteSource::BackgroundImage, + Change::PaletteSource(PaletteSource::BackgroundImage), + ), + choice( + ui, + "Wallpaper", + state.palette_source == PaletteSource::Wallpaper, + Change::PaletteSource(PaletteSource::Wallpaper), + ), + choice( + ui, + "Preset", + state.palette_source == PaletteSource::Preset, + Change::PaletteSource(PaletteSource::Preset), + ), + ], + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs new file mode 100644 index 0000000..e6aa711 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -0,0 +1,258 @@ +//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。 +//! +//! ## 控件与键的对应 +//! +//! | 控件 | 键 | 改动消息 | +//! |---|---|---| +//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] | +//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] | +//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | +//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] | +//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] | +//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | +//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] | +//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] | +//! +//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线) +//! +//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`), +//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它: +//! +//! | 文件 | 是什么 | +//! |---|---| +//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 取词口 [`t`] | +//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) | +//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) | +//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 | +//! | [`colour_source`] | ⑨ 配色来源(三档) | +//! +//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/ +//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动): +//! +//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec`); +//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线); +//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块); +//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`); +//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。 +//! +//! ## 语言(M179 只接管子,不补文案) +//! +//! [`t`] 是**唯一**取词口,现在收 `lang`(真值从 `settings::view` 的 `Language.effective` +//! → `Group.body` → [`section`] 一路递进来),所以中文界面上走词表的那 5 处(窗口圆角一行、 +//! 两个选项、说明段、notice)真的是中文。**其余文案仍是英文基线**(字面量,没有 key)—— +//! 补文案是下一条 mission,M179 只负责让「管子」通到这一层。 +//! +//! ## 为什么「值」与「生效值」分开显示 +//! +//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 +//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 + +use iced::widget::{button, column, container, row}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; + +use crate::pages::{frame, Message}; +use crate::theme::Ui; + +use super::state::State; +use super::Change; + +mod bg_image; +mod colour_source; +mod panel; +mod window; + +/// 「外观」分组整块(一张卡)。 +/// +/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按 +/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。 +pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { + let mut rows: Vec> = Vec::new(); + + window::push(ui, state, lang, &mut rows); + panel::push(ui, state, &mut rows); + bg_image::push(ui, state, &mut rows); + colour_source::push(ui, state, &mut rows); + + card( + ui, + column(rows) + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ) +} + +/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。 +fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { + let spec = Component::OutlinedCard.spec(); + let background = ui.layer(MaterialLayer::Card); + + container(content) + .width(Length::Fill) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(background)), + border: ui.outlined(spec.shape), + ..container::Style::default() + }) + .into() +} + +// --------------------------------------------------------------- 行与控件 +// +// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。 + +pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + content, + ui.color(Role::OnSurface), + ) +} + +/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。 +/// +/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录 +/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块 +/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M179 报告里逐行列了那些字面量,补文案是 +/// 下一条 mission。 +pub(super) fn t(key: Key, lang: Lang) -> &'static str { + let text = lang.catalog().text(key); + // 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的 + // 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。 + #[cfg(test)] + super::probe::record(key, text); + text +} + +pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> { + // 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。 + frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) +} + +/// 一行「标签 + 值 + 滑块 + 说明」。 +pub(super) fn slider_row<'a>( + ui: &'a Ui, + title: &'a str, + value: String, + hint: String, + enabled: bool, + control: Element<'a, Message>, +) -> Element<'a, Message> { + let value_color = if enabled { + ui.color(Role::OnSurface) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + let head = row![ + container(label(ui, title)) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + + column![ + head, + control, + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + hint, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一行「标签 + 一排互斥按钮」。 +pub(super) fn choice_row<'a>( + ui: &'a Ui, + title: &'a str, + choices: Vec>, +) -> Element<'a, Message> { + row![ + container(label(ui, title)) + .width(Length::Fill) + .align_y(Alignment::Center), + row(choices) + .spacing(ui.space(Space::X1)) + .align_y(Alignment::Center), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。 +pub(super) fn choice<'a>( + ui: &'a Ui, + text: &'a str, + selected: bool, + change: Change, +) -> Element<'a, Message> { + let height = ui.metrics().control_height; + let content_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + button( + container(frame::text_role( + ui, + TypeRole::LabelLarge, + selected, + text, + content_color, + )) + .align_y(Alignment::Center), + ) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X3)])) + .style(move |_theme: &IcedTheme, status| { + let background = if selected { + Some(Background::Color(ui.color(Role::SecondaryContainer))) + } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { + Some(Background::Color(ui.state_over( + unlucky_design::color::StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))) + } else { + None + }; + + button::Style { + background, + text_color: content_color, + border: Border { + radius: ui.radius(ShapeScale::Full, height), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(Message::Appearance(change)) + .into() +} diff --git a/crates/app-iced/src/pages/appearance/section/panel.rs b/crates/app-iced/src/pages/appearance/section/panel.rs new file mode 100644 index 0000000..58661ba --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/panel.rs @@ -0,0 +1,45 @@ +//! 外观分组第 ③ 段:**面板透明度**滑块(下限 0.4;CPU 档强制 1.0 并禁用滑块)。 + +use iced::Element; + +use crate::app::components::inputs::{slider, Slider}; +use crate::pages::Message; +use crate::theme::Ui; + +use super::super::alpha::{panel_alpha_from_fraction, panel_fraction}; +use super::super::state::State; +use super::super::Change; +use super::slider_row; + +/// 把 ③ 面板透明度推进 `rows`。 +pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { + // ③ 面板透明度(CPU 档强制 1.0) + let forced = state.panel_is_forced(); + let panel_percent = if forced { + format!("100% (forced from {:.2})", state.panel_alpha) + } else { + format!("{:.0}%", state.effective_panel_alpha() * 100.0) + }; + let panel_hint = if forced { + "Software renderer: panels stay fully opaque (window transparency still works)." + } else { + "Only the panel/card layer. Floor 0.40; independent of window transparency." + }; + let panel_control = if forced { + slider(Slider::new(ui, panel_fraction(1.0))) + } else { + slider( + Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| { + Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction))) + }), + ) + }; + rows.push(slider_row( + ui, + "Panel transparency", + panel_percent, + panel_hint.to_owned(), + !forced, + panel_control, + )); +} diff --git a/crates/app-iced/src/pages/appearance/section/window.rs b/crates/app-iced/src/pages/appearance/section/window.rs new file mode 100644 index 0000000..3cd9399 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/window.rs @@ -0,0 +1,86 @@ +//! 外观分组第 ① ② 段:**整窗**两件——整窗透明度滑块、窗口圆角(含 notice 与说明段)。 +//! +//! 这两段是唯一**一个控件牵动另一个控件**的地方:圆角「跟随系统」时把整窗透明度调低会让圆角失效 +//! (`DwmExtendFrameIntoClientArea` 的世界里没有半透明边框),所以只有这一对放在同一个文件里—— +//! notice 的判据(`corner == System && window_opacity < 1.0`)跨这两段。 + +use iced::widget::space; +use iced::Element; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; +use unlucky_presenter::prefs::WindowCorner; + +use crate::app::components::inputs::{slider, Slider}; +use crate::pages::Message; +use crate::theme::Ui; + +use super::super::alpha::{window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR}; +use super::super::state::State; +use super::super::Change; +use super::{choice, choice_row, note_line, slider_row, t}; + +/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。 +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { + // ① 整窗透明度 + let window_percent = format!("{:.0}%", state.window_opacity * 100.0); + rows.push(slider_row( + ui, + "Window transparency", + window_percent, + format!( + "Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.", + WINDOW_ALPHA_FLOOR + ), + state.window_opacity < 1.0, + slider( + Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| { + Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction))) + }), + ), + )); + + // ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。 + let corner = state.window_corner(); + rows.push(choice_row( + ui, + t(Key::SettingsAppearanceCorner, lang), + vec![ + choice( + ui, + t(Key::SettingsAppearanceCornerSystem, lang), + corner == WindowCorner::System, + Change::Corner(WindowCorner::System), + ), + choice( + ui, + t(Key::SettingsAppearanceCornerSquare, lang), + corner == WindowCorner::Square, + Change::Corner(WindowCorner::Square), + ), + ], + )); + // **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。 + // 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。 + if corner == WindowCorner::System && state.window_opacity < 1.0 { + rows.push(super::super::notice( + ui, + t(Key::SettingsAppearanceCornerSuspended, lang), + )); + let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into(); + rows.push(gap); + } + let tone = ui.color(Role::OnSurfaceVariant); + rows.push(note_line( + ui, + t(Key::SettingsAppearanceCornerHint, lang), + tone, + )); +} diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs index 6d98023..ba2214c 100644 --- a/crates/app-iced/src/pages/settings/appearance.rs +++ b/crates/app-iced/src/pages/settings/appearance.rs @@ -5,20 +5,25 @@ //! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP` //! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。 //! -//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责 +//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责 //! 把它挂进注册表),所以改控件时不用碰设置页目录。 //! //! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」 //! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。 +//! +//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的 +//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`, +//! 因为渲染器拿不到「这一次生效的语言」。 use iced::Element; use unlucky_design::icons::IconKind; +use unlucky_i18n::Lang; use crate::theme::Ui; use super::{Group, Message, States}; -/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件); +/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件); /// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。 pub(super) const GROUP: Group = Group { name: "Appearance", @@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group { }; /// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。 -fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option> { +/// +/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。 +fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { states .appearance - .map(|state| crate::pages::appearance::section(ui, state)) + .map(|state| crate::pages::appearance::section(ui, state, lang)) } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index e96fcdb..8c86965 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -25,7 +25,7 @@ //! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 | //! |---|---|---| //! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) | -//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) | +//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section/`) | //! | Java | [`java`] | java 线 | //! | Downloads | [`download`] | download 线 | //! | Updates | [`update`] | update 线(M159,按下标分发) | @@ -117,7 +117,7 @@ //! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价, //! 不是永久性四路撞车。 //! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是 -//! `fn(&Ui, States) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` +//! `fn(&Ui, States, Lang) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` //! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)` //! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的 //! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派 @@ -193,13 +193,16 @@ impl Default for Language { } } -/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态), -/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态), -/// 由 [`form`] 退回演示行。 +/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态) +/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下 +/// 没有外观状态),由 [`form`] 退回演示行。 /// /// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定, /// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。 -type Body = for<'a> fn(&'a Ui, States<'a>) -> Option>; +/// +/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个 +/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。 +type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option>; /// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 /// @@ -236,9 +239,9 @@ const SELECTED: usize = 0; /// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 /// -/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带 -/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal -/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。 +/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 只带 +/// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下: +/// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。 /// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None` /// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。 const GENERAL_GROUP: usize = 0; @@ -315,8 +318,8 @@ pub fn view<'a>( /// /// **分发点(两条路,M106 起就并存)**: /// -/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回 -/// 演示行),`None` 直接画演示行([`frame::demo_card`]); +/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据、并从形参拿**生效语言** +/// (`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行([`frame::demo_card`]); /// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang` /// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body` /// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。 @@ -357,7 +360,7 @@ fn form<'a>( } else { group .body - .and_then(|body| body(ui, states)) + .and_then(|body| body(ui, states, language.effective)) .unwrap_or_else(|| frame::demo_card(ui, group.rows)) }; diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs index e318042..2fe858e 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -2,12 +2,25 @@ //! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! //! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在 -//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。 +//! `pages/appearance/section/`;本文件只守「注册表这一侧的接线」。 //! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除** //! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。 +//! +//! M179 `I18NSEAM` 加的第 4 条守的是**语言真的进到了渲染层**(此前这一组在中文界面上 0/27 条本地化)。 use super::super::appearance; +use super::super::{demo_groups, view, Language, States}; +use unlucky_design::color::SchemeFlavor; use unlucky_design::icons::IconKind; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::{Lang, LangChoice}; +use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; + +use crate::cli::Backend; +use crate::pages::appearance::{probe, State as AppearanceState}; +use crate::pages::layout::{frame_metrics, PaneKit}; +use crate::pages::Update; +use crate::theme::{Families, Ui}; /// 这一组的**演示行**(没有外观运行期状态时才画)。 /// @@ -50,3 +63,109 @@ fn the_appearance_group_keeps_a_real_control_body() { `body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)" ); } + +// --------------------------------------------------------------- 语言(M179 I18NSEAM) + +/// **本组的语言绊线**:中文界面下,外观分组渲染出来的字里**必须含汉字**。 +/// +/// 走的是真路:`settings::view`(真语言分发)→ `Group.body(lang)` → `pages::appearance::section` +/// → `section::t(key, lang)`;取词口 `t` 把每条译文记进 `pages::appearance::probe`,测试断言这批字。 +/// +/// 为什么不在 `Element` 上遍历:iced 0.14 **没有**可遍历控件树的公开接口(`Widget::children()` +/// 返回的是状态 `Tree`;走 `operate` 要 `Tree` + 布局 + `Renderer`,单测里没有渲染器)。取证点 +/// 因此退到「字进树之前」的唯一取词口,代价是**看不见像素**——字体能不能画出汉字要截图判 +/// (M179 报告「没验的部分」列了这条)。 +/// +/// 抓得住:① `section::t` 退回写死 `Lang::En`(M179 之前正是这一行 ⇒ 中文界面上这一组 0/27 +/// 条本地化);② `settings::view` 不把 `language.effective` 递给 `Group.body`(语言到不了渲染层); +/// ③ 五个 key 的译文在金表里丢了中文。 +/// 抓不住:本组其余文案仍是英文**字面量**(没有 key,M172 口径的 27 条里占 22 条)——这条守的是 +/// 语言的**管子**,不是文案;补文案是下一条 mission(M179 报告逐行列了那些字面量)。 +#[test] +fn the_appearance_group_speaks_chinese_when_the_interface_does() { + let zh = appearance_texts(Lang::ZhCn); + assert!( + !zh.is_empty(), + "外观分组一条词表文案都没取到:渲染路径没走到 `section::t`(语言管子断了?)" + ); + assert!( + zh.iter() + .any(|(key, _)| *key == Key::SettingsAppearanceCorner), + "渲染路径没画到「窗口圆角」那一行:取词记录里没有它的 key,{zh:?}" + ); + + let han: Vec<&str> = zh + .iter() + .map(|(_, text)| *text) + .filter(|text| text.chars().any(is_han)) + .collect(); + assert!( + !han.is_empty(), + "中文界面下外观分组渲染出来的字全是非汉字(`section::t` 没跟随语言):{zh:?}" + ); + + // 反向:英文界面下同一批字里不许出现汉字(否则「含汉字」可能来自写死的中文,而不是语言管子)。 + let en = appearance_texts(Lang::En); + assert!( + en.iter().all(|(_, text)| !text.chars().any(is_han)), + "英文界面下外观分组出现了汉字:{en:?}" + ); + let corner = |texts: &[(Key, &'static str)]| { + texts + .iter() + .find(|(key, _)| *key == Key::SettingsAppearanceCorner) + .map(|(_, text)| *text) + }; + assert_ne!( + corner(&zh), + corner(&en), + "同一个 key 在中英两种语言下取到了同一条译文:语言没生效" + ); +} + +/// 画一帧设置页(选中「外观」分组)并取走这一帧从词表取到的全部译文。 +fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> { + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + let ui = Ui::new(SchemeFlavor::Light, FAMILIES); + let state = AppearanceState::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + Backend::Auto, + SchemeFlavor::Light, + ); + assert_eq!( + state.selected_group, + crate::pages::appearance::APPEARANCE_GROUP, + "工厂态默认选中「外观」分组(否则这一帧根本画不到本组)" + ); + // 前面几条用例不取词,这里先清一次,避免把别的渲染留下的记录算进来。 + probe::take(); + + let metrics = frame_metrics(PaneKit::Supporting, 1180.0); + let update = Update::default(); + let _tree = view( + &ui, + metrics, + States { + appearance: Some(&state), + groups: demo_groups(), + }, + Language { + choice: LangChoice::System, + effective: lang, + }, + &update, + ); + + probe::take() +} + +/// 有没有汉字(判据与 `app/view/tests/launch.rs` 的 `is_cjk` 同一段码位)。 +fn is_han(ch: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&ch) +} diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 5a49fef..702a9e0 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -8,8 +8,8 @@ //! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。 //! //! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP` -//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option`, -//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的 +//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位现在带**生效语言**(`Lang`,M179)但仍是 +//! `States` + 语言,装不下「当前语言档位」(`LangChoice`)。所以 M101 r2 的 //! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。 use super::super::general; @@ -187,8 +187,8 @@ fn the_language_row_offers_three_choices_in_both_languages() { /// **绊线**:这一组仍不走 `Group.body`。 /// /// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发), -/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。 -/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。 +/// 而 `Group.body` 那个槽位只拿到 `States` 与**生效语言**(`Lang`,M179)——装不下「当前语言档位」。 +/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到别人那个组的文件),这条断言逼他先想一遍。 #[test] fn the_general_group_does_not_use_the_appearance_body_slot() { assert!( diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index 18ca878..8e6d8c7 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -40,7 +40,8 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() { /// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。 /// -/// 抓得住:谁把 `body: Some(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。 +/// 抓得住:谁把 `body: Some(..)` 挂上来(M179 之后槽位自带 `Lang`,所以「挂了 body 却没语言」 +/// 这种漂移不再可能——挂上来就必然拿到语言;这条守的是**分发形状**别变)。 /// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。 #[test] fn the_java_group_is_dispatched_by_index_not_by_body() { diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 57c7965..f9a9260 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -163,9 +163,10 @@ fn the_split_kept_the_left_column_order() { /// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。 /// /// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**—— -/// 那个槽位的签名是 `fn(&Ui, States) -> Option`,`States` 只有 `appearance` + `groups` -/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 + -/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。 +/// 那个槽位的签名是 `fn(&Ui, States, Lang) -> Option`(`Lang` 是 M179 加的), +/// `States` 只有 `appearance` + `groups` 两格、槽位只有**生效语言**,都装不下「当前语言档位 + +/// 生效语言」这一对。它走 `settings::view` 的 `language` 形参 + `GENERAL_GROUP` 那条按下标分发, +/// `body` 仍是 `None`,所以本断言不动。 /// 判据没变:**`body: Some(..)` 仍然只有外观组**。 #[test] fn the_settings_module_does_not_name_any_group_state() { From ca92014b816a85249dbbd2a6d038bda1891fe7b2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 21:01:25 +0800 Subject: [PATCH 10/33] =?UTF-8?q?fix(m179):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=AE=A2=E6=AD=A3=E3=80=8CBody=20?= =?UTF-8?q?=E5=B8=A6=20Lang=20=E4=B9=8B=E5=90=8E=E3=80=8D=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E7=9A=84=E5=85=AD=E5=A4=84=E6=96=87=E6=A1=A3=E6=BC=82?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1:`settings/mod.rs:273`(`AI_GROUP`)还写着「`Group.body` 的签名里没有 `Lang`」, 而同一次提交在同一个文件把槽位改成了 `fn(&Ui, States, Lang)` 并在上方 `GENERAL_GROUP` 按新事实重写 —— 一个文件里两段互相矛盾的说明。本条按新事实重写(行数不变,仍是 389 行)。 一并订正 finding 里剩下的外域漂移(tower 已扩 scope): - `settings/ai_about.rs:8`(同一句假话的表格行,finding 没列到)与 `:14-16` - `settings/general.rs:7-11`(槽位现在带 `Lang`,装不下的只剩**档位** `LangChoice`) - `settings/general.rs:287`、`settings/update.rs:5`、`pages/mod.rs:271`、 `settings/language_appearance.rs:20` 的 `pages/appearance/section.rs` 路径 措辞与复审建议的一处偏离(已在提交与报告里说明):复审建议写「槽位给不了『测试连接』的 结论」,与实现不符——该结论在 `States.groups.ai_about` 里,槽位拿得到;AI 组仍按下标分发 的真实原因是**尚未迁移**(槽位要 `Option`,`ai::view::body` 返回 `Element`)。 零代码改动:本提交所有变更行都是以 `//!` / `///` 开头的文档行 (`git diff -U0 | grep '^[+-]' | grep -vE '^[+-]+\s*//[/!]'` 为空)。 按 tower 要求**不动** `crates/i18n/src/catalog/settings/appearance.rs:8`(M180 正在改)。 验证:cargo fmt --all --check → 0;cargo check -p unlucky-app-iced --all-targets → 0; cargo clippy -p unlucky-app-iced --all-targets -D warnings → 0; cargo test -p unlucky-app-iced --lib appearance → 0(37 passed)。 --- crates/app-iced/src/pages/mod.rs | 2 +- crates/app-iced/src/pages/settings/ai_about.rs | 9 +++++---- crates/app-iced/src/pages/settings/general.rs | 11 ++++++----- .../src/pages/settings/language_appearance.rs | 2 +- crates/app-iced/src/pages/settings/mod.rs | 6 +++--- crates/app-iced/src/pages/settings/update.rs | 8 ++++---- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index f0cbd1d..1fb36c1 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -268,7 +268,7 @@ pub fn app<'a>( /// 同 [`app`],但可以带上**外观运行期状态**。 /// -/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`); +/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section/`); /// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支: /// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**—— /// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。 diff --git a/crates/app-iced/src/pages/settings/ai_about.rs b/crates/app-iced/src/pages/settings/ai_about.rs index 4f59f95..e855aff 100644 --- a/crates/app-iced/src/pages/settings/ai_about.rs +++ b/crates/app-iced/src/pages/settings/ai_about.rs @@ -5,15 +5,16 @@ //! | 这里 | 内容 | //! |---|---| //! | [`GROUP`] | 左列名 / 图标 / 演示行(产品路径走 [`super::ai::view::body`]) | -//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组要 i18n 文案与 `Lang` 形参,`Group.body` 的签名里没有它) | +//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组尚未迁到 `Group.body` 槽位,见下节) | //! | [`Message`] | 本线的消息口(落点 `app/update/settings/ai_about.rs`) | //! | [`State`] | 转发 `ai::State`(`Groups.ai_about` 的载荷类型不变,`settings/state.rs` 一行不用改) | //! //! ## 为什么 `body` 是 `None` 而不是 `Some(..)` //! -//! `Group.body` 的签名是 `fn(&Ui, States) -> Option`:拿不到 `Lang`,也就画不出中英两套 -//! 文案(本组的硬要求是「不得硬编码中文」)。M106 的语言行与 M159 的更新组是同一个理由走了 -//! 按下标分发;本组沿用同一条路,代价是 `settings/mod.rs` 里多一行 `else if`。 +//! `Group.body` 的槽位(M179 起)拿的是 [`States`](super::States) 与**生效语言** `Lang`——本组要的 +//! 两件它其实都装得下。本组仍走按下标分发,是因为**还没迁过去**:槽位要求返回 `Option`, +//! 而 `ai::view::body` 返回 `Element`,把本组(以及 Java 组)统一迁到槽位上是一次独立重构。 +//! M106 的语言行与 M159 的更新组当初也是同一个理由走上这条路,代价是 `settings/mod.rs` 多一行 `else if`。 //! //! ## 密钥纪律 //! diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 7b68593..e5fb202 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -4,10 +4,11 @@ //! //! ## 这一组为什么特殊 //! -//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option`,`States` 只有 -//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在 -//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标 -//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。 +//! 它**不走** `Group::body`:那个渲染器拿的是 [`States`](super::States) 与**生效语言** `Lang` +//! (M179 起;此前连 `Lang` 都没有),而语言行要的是「当前语言**档位** `LangChoice` + 生效语言」 +//! 这一对——档位不在 `States` 里。所以它由 `pages::settings::view` 的 `language` 形参一路递进来、 +//! `form` 按下标转交 [`body`];`GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」 +//! 照样成立。 //! //! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记) //! @@ -284,7 +285,7 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me /// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。 /// -/// 与 `pages/appearance/section.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。 +/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。 fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> { let height = ui.metrics().control_height; let content_color = if selected { diff --git a/crates/app-iced/src/pages/settings/language_appearance.rs b/crates/app-iced/src/pages/settings/language_appearance.rs index f167c03..2af25b2 100644 --- a/crates/app-iced/src/pages/settings/language_appearance.rs +++ b/crates/app-iced/src/pages/settings/language_appearance.rs @@ -17,7 +17,7 @@ //! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key | //! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 | //! -//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。 +//! 真控件那半边在 `pages/appearance/section/`(`appearance.rs` 只负责把 `body` 挂上去)。 /// 语言 + 外观线的状态(**只归本线**)。 /// diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 8c86965..92838db 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -269,9 +269,9 @@ const UPDATE_GROUP: usize = update::UPDATE_GROUP; /// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。 /// -/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:本组要画中英两套文案(`Lang`)与 -/// 「测试连接」的结论,`Group.body` 的签名里没有 `Lang`,所以由 `view` 的 `language` 形参一路 -/// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。 +/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:由 `view` 的 `language` 形参递到 [`form`] +/// 按本下标分发,`GROUP.body` 保持 `None`(本组**尚未**迁到 [`Body`] 槽位:槽位现在带 `Lang` 了, +/// 迁它要把 `ai::view::body` 的返回值改成 `Option`,是一次独立重构——M179 不做)。 const AI_GROUP: usize = ai_about::AI_GROUP; /// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。 diff --git a/crates/app-iced/src/pages/settings/update.rs b/crates/app-iced/src/pages/settings/update.rs index ae984c8..98bdf78 100644 --- a/crates/app-iced/src/pages/settings/update.rs +++ b/crates/app-iced/src/pages/settings/update.rs @@ -2,10 +2,10 @@ //! //! ## 为什么这一组不走 `Group.body` //! -//! `Group.body` 的签名是 `fn(&Ui, States) -> Option`,而 `States` 只有「外观状态 + 各组 -//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种 -//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给 -//! [`body`],`GROUP.body` 仍是 `None`。 +//! `Group.body` 的槽位拿的是 [`States`](super::States) 与**生效语言** `Lang`(M179 起),而更新状态 +//! 是 **presenter 快照**(`pages::Update`,不是某一组的字段、也不在 `States` 里),槽位装不下它。 +//! 与 M106 的语言行同一种情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 +//! `update` 形参交给 [`body`],`GROUP.body` 仍是 `None`。 //! //! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 / //! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。 From 2813e2984c07282405aac27896156479769f5b6f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 14:15:52 +0800 Subject: [PATCH 11/33] =?UTF-8?q?ci(m184):=20linux-x64=20=E8=BF=9B?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=90=8D=E5=8D=95=EF=BC=8C=E4=B8=A4=E6=A1=A3?= =?UTF-8?q?=20Linux=20=E8=BD=AC=20mode=3Dci=EF=BC=8Carm64=20=E5=8F=AA?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=B8=8D=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发布流水线默认从 2 个槽位变 4 个:`linux-x64`(ubuntu-22.04)与 `linux-arm64` (ubuntu-22.04-arm)的 mode 由 probe 改 ci,tag 推送默认就编。 构建 ≠ 发布仍由 `PUBLISH_SLOTS` 决定,本轮只加 `linux-x64`: - `linux-x64` 有运行时证据 —— release 产物在 Linux 真机(Ubuntu 22.04 + WSLg 真窗口) 渲出一帧、[theme]/[backend] final 日志齐全、像素判定通过,真窗口 1180x760 从 Windows 侧截屏可见;`release-build.yml` 另加一条**同槽位**的 xvfb 运行时门禁(只在 `inputs.slot == 'linux-x64'` 上跑,不给没验过的 arm64 造红)。 - `linux-arm64` 只有构建证据、运行时零证据,产物照出 artifact 取证但不发布;解锁条件 (真机渲出一帧 + 主题日志)写进 release-notes 与 §9。 守卫跟着扩(`packaging/check_publish_slots.py`): - 自测从 8 条扩到 11 条,新增「声明的 linux-x64 构建红了 ⇒ 必须红(挡住发布)」、 「linux-arm64 有产物但没声明 ⇒ 只警告」、「提前把 linux-arm64 写进名单 ⇒ 缺产物必红」; - probe 档样例从 linux-x64 换成仍是 probe 的 macos-arm64(否则该用例会从「必须红」 变成「本来该绿」); - 新增「mode=ci 却本次没产物 ⇒ ::warning::」:红一档不能只表现为表里少一行。 文档矩阵同步(release-notes §Downloads/§Support matrix、compliance §9/§10、 artifacts §1、build-and-ci §3、README ×2),两份文档都把 Linux 写成 「已构建并发布(x64)/ 已构建未发布(arm64)」,并把新发现写实:Linux 上整窗透明 与圆角没有代码路径(hwnd=none / corner=policy=system),不是「未验」;Linux 上 `ull update apply` 会拒绝 portable-zip(清单里现在有 linux-x64 产物)。 证据账(含实测输出、CI 增量估算、未验清单):packaging/docs/linux-evidence.md。 --- .github/workflows/release-build.yml | 61 +++++- .github/workflows/release-guard.yml | 3 +- .github/workflows/release-publish.yml | 25 ++- .github/workflows/release.yml | 47 ++-- README.md | 78 ++++--- README.zh-CN.md | 61 ++++-- docs/images/linux-x64-wslg-window.png | Bin 0 -> 43084 bytes packaging/README.md | 7 +- packaging/check_publish_slots.py | 73 ++++++- packaging/docs/artifacts-and-version.md | 16 +- packaging/docs/build-and-ci.md | 47 ++-- .../docs/compliance-support-boundaries.md | 31 ++- packaging/docs/linux-evidence.md | 205 ++++++++++++++++++ packaging/release-notes.md | 105 ++++++--- 14 files changed, 608 insertions(+), 151 deletions(-) create mode 100644 docs/images/linux-x64-wslg-window.png create mode 100644 packaging/docs/linux-evidence.md diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index d081786..afbe7ae 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -6,6 +6,9 @@ name: Release / build(可复用) # `matrix.rustflags` → `inputs.rustflags`。 # **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器; # 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。 +# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)—— +# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有 +# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。 # 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`), # 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。 # `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE @@ -259,6 +262,49 @@ jobs: echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1 fi + # (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明, + # 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。 + # **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的 + # xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个 + # 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。 + - name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言) + if: inputs.slot == 'linux-x64' + shell: bash + env: + BUILD_DIR: ${{ inputs.build_dir }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends xvfb xauth \ + libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core + out="$RUNNER_TEMP/linux-smoke" + mkdir -p "$out/data" + log="$out/launcher.log" + # 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。 + set +e + xvfb-run -a --server-args="-screen 0 1280x1024x24" \ + "$BUILD_DIR/unlucky-launcher-iced" --software-renderer \ + --screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log" + code=${PIPESTATUS[0]} + set -e + echo "launcher exit=$code" + # 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE): + # `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light), + # `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。 + grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; } + grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; } + python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json + ls -l "$out" + exit "$code" + + - name: 上传 Linux x64 首帧图(失败也传,人眼复核用) + if: always() && inputs.slot == 'linux-x64' + uses: actions/upload-artifact@v4 + with: + name: linux-x64-first-frame + path: ${{ runner.temp }}/linux-smoke/ + if-no-files-found: warn + - name: 打包(产物 + SHA256SUMS) shell: bash env: @@ -310,7 +356,18 @@ jobs: # 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。 # M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是 # 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。 + # M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里, + # 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。 - name: arm64 失败收尾注解(红不挡发布) - if: failure() && inputs.slot == 'windows-arm64' + if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64') shell: bash - run: echo "::error::[arm64] 本槽位已红,失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release" + env: + SLOT: ${{ inputs.slot }} + run: | + set -euo pipefail + if [ "$SLOT" = "linux-arm64" ]; then + reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)" + else + reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)" + fi + echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release" diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index c87a6cc..22785b4 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -20,7 +20,8 @@ name: Release guard # (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物 # 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)—— # 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志; -# `--self-test` 再注入 8 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明)证明它会红。 +# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 / +# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。 # 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。 # # (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/ diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index fa23053..f882add 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -40,18 +40,21 @@ jobs: contents: write env: # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)── - # **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而齐全性检查是「名单内少一档就 fail」——名字挂着 - # arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证,**红了不挡发布**。 - # (M164)**为什么现在还只有 x64**:arm64 五轮全红其实都红在一个误判的编译前体检步(见 release-build.yml - # 顶部那段),真正的 cargo/link 一次都没跑过;M164 把那个门禁拆了、判定权交回编译器,但「arm64 能不能编出 - # PE」只能由**下一 tag 的 CI** 证明,本机没有 ARM64 真机/工具链证不了。在拿到那次绿之前把 arm64 写进来, - # 等于拿 x64 的发布去赌它 —— 同一个事故不演第二遍(tower M164 裁决)。 - # **可逆,且只是一行**:那句 CI 真出包之后把键加回这一行(`"windows-x64 windows-arm64"`),齐全性检查与 - # SHA256SUMS 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。 + # **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口) + # 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见 + # `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」, + # 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。 + # **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧, + # 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。 + # 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」, + # 不是静默丢弃(这条是任务里点名要验的反方向)。 + # `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。 + # **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS + # 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。 # 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml # 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。 - # 四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。 - PUBLISH_SLOTS: "windows-x64" + # macOS 两档仍未验证,默认连构建都不跑(M139)。 + PUBLISH_SLOTS: "windows-x64 linux-x64" steps: - uses: actions/checkout@v4 @@ -87,7 +90,7 @@ jobs: echo "publish: $base" ;; *) - echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待下一 tag 验证;仍留在 dist- artifact 里取证)" + echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist- artifact 里取证)" rm -f "$f" ;; esac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d84f5a7..89d4d9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,18 @@ name: Release -# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 + -# SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带 `--prerelease`,否则 -# `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。 -# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 仍留 -# 矩阵里取证、红了不挡 x64 发布)。**M164 的更正**:arm64 那五轮红其实全红在一个误判的编译前体检步(cargo/link 一次 -# 都没跑过,见 `release-build.yml` 顶部),该门禁已拆、判定权交回编译器;但「arm64 能不能出 PE」只能由**下一 tag 的 -# CI** 证明,故名单仍只列 x64(tower 裁决:不拿 x64 的发布去赌一个没验证过的槽位)。真出包之后把键加回那一行即可。 -# 四档 Linux/macOS 从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走 +# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` / +# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带 +# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、 +# 不碰标记)。 +# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M184 起是 `windows-x64` + `linux-x64`**。 +# `linux-x64` 本轮翻牌的依据在 `packaging/docs/linux-evidence.md`:x86_64 槽位在 `ubuntu-22.04`(glibc 2.35,与本地 +# WSLg 验证同一地板)真编出 release 产物、真开窗渲出一帧,`release-build.yml` 里还有一条同槽位的 xvfb 运行时门禁 +# ——「声明为 ci 就必须真能跑出帧」,不是「矩阵里写了就算支持」。 +# **`linux-arm64` 本轮只构建、不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟)。 +# 解锁条件写在 `packaging/release-notes.md` 与 `packaging/docs/compliance-support-boundaries.md` §9 ——真机跑一帧之后 +# 才把键加进 `PUBLISH_SLOTS`,不拿没跑过的包去发(M184 tower 裁决)。 +# `windows-arm64` 仍留矩阵里取证、红了不挡 x64 发布(M142/M164:它那五轮红全红在一个误判的编译前体检步,该门禁已拆, +# 判定权交回编译器)。`macos-*` 两档从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走 # `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。 # 名单 ⇄ 产物两个方向都有守卫(M164):`packaging/check_publish_slots.py`(PR 阶段在 release-guard.yml,发布侧在 # release-publish.yml 的 `--dist` 模式)——声明了缺产物 ⇒ 红;有产物没声明 ⇒ `::warning::` 明说未发布。 @@ -31,7 +36,7 @@ on: workflow_dispatch: inputs: all_slots: - description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows" + description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)" type: boolean default: false prerelease: @@ -58,7 +63,7 @@ jobs: # M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs..strategy` # 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。 plan: - name: 选槽位(默认两个 Windows) + name: 选槽位(默认四个:Windows 两档 + Linux 两档) runs-on: ubuntu-latest outputs: matrix: ${{ steps.pick.outputs.matrix }} @@ -85,11 +90,18 @@ jobs: # 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。 dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"), - # ↓ 四档**未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编 - # (将来验证 Linux/macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS + - # 同步 README §1/§9 与 release-notes.md。Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移。 - dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="probe"), - dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="probe"), + # ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。 + # Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。 + # 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里: + # `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见 + # `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机 + # 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。 + # **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。 + dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"), + dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"), + # ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编 + # (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS + + # 同步 README §1/§9 与 release-notes.md。 # macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。 dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"), dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"), @@ -126,8 +138,9 @@ jobs: release: name: GitHub Release needs: build - # `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) - # 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 + # `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里 + # 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步: + # 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。 # tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。 if: always() && !cancelled() && github.ref_type == 'tag' # `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 diff --git a/README.md b/README.md index 99bc5fd..1e630cf 100644 --- a/README.md +++ b/README.md @@ -220,17 +220,24 @@ This list is the reason to believe the rest. None of it is hidden behind marketi section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance management from the GUI is the instance-wizard work still outstanding, and settings change as you edit them rather than on a Save. -- **Linux and macOS artifacts are unverified, and this release does not even build them.** The - reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots - sit in the CI matrix but the default release run skips them, and they have never been downloaded and - run end to end. `gh workflow run release.yml -f all_slots=true` builds them for evidence. Treat them - as untested output, not as "supported because the matrix lists them". -- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why - the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts - them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS - builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared - (`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` - probe with `continue-on-error`, not a gate. +- **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow + builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself + was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its + theme/backend lines — and that same check is now a per-slot CI gate on every release + (`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop + distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified. + Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window + transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are + delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app. + `linux-arm64` is built (native `ubuntu-22.04-arm`) but **not** published — no ARM64 machine has ever run it, so + the artifact stays as build evidence only. +- **macOS is not built by the release workflow, and it is not finalised.** It has two rounds of real-device + evidence (Apple Silicon), which is why the build exists at all — but the menu-bar name, the placement of the + three window buttons (macOS puts them top-left; ours are drawn top-right), and 300s+ steady state still need a + real machine. macOS builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared + (`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` probe with + `continue-on-error`, not a gate; `gh workflow run release.yml -f all_slots=true` builds the two slots for + evidence, but they are never published. - **The content library is cramped at low window heights**, and the window height is not wired into the page model yet — the page lays itself out for a nominal height rather than the one it gets. - **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the @@ -253,25 +260,28 @@ Grab the file for your platform from | Slot | Asset | Shape | |------|-------|-------| | `windows-x64` | `unlucky-launcher__windows_x64.exe` | bare `.exe`, double-click to run | -| `windows-arm64` | `unlucky-launcher__windows_arm64.exe` | bare `.exe` — **not yet published**, see below | -| `linux-x64` | `unlucky-launcher__linux_x64.tar.gz` | one archive, one top-level directory — **not yet published** | -| `linux-arm64` | `unlucky-launcher__linux_arm64.tar.gz` | same — **not yet published** | -| `macos-arm64` | `unlucky-launcher__macos_arm64.tar.gz` | same — **not yet published** | -| `macos-x64` | `unlucky-launcher__macos_x64.tar.gz` | same — **not yet published** | +| `linux-x64` | `unlucky-launcher__linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack | +| `windows-arm64` | `unlucky-launcher__windows_arm64.exe` | bare `.exe` — **built, not published**, see below | +| `linux-arm64` | `unlucky-launcher__linux_arm64.tar.gz` | same — **built, not published** (no runtime evidence yet) | +| `macos-arm64` | `unlucky-launcher__macos_arm64.tar.gz` | same — **not built** by the release workflow | +| `macos-x64` | `unlucky-launcher__macos_x64.tar.gz` | same — **not built** by the release workflow | | every published slot | `ull___[.exe]` | bare CLI binary, its own asset | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | -**This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named -in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before -it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable -asset. A guard (`packaging/check_publish_slots.py`) watches both directions: a slot named in the list -whose artifacts are missing turns the release red, and artifacts that exist for an unlisted slot get an -explicit "not published" warning instead of being deleted quietly. Of those rows `windows-arm64` is the -one CI still builds — natively, on GitHub's `windows-11-arm` runner (Windows 11 Arm64 + Visual Studio -2026), for evidence. Every ARM64 failure so far came from a pre-build toolchain probe that aborted before -`cargo build` ran; that probe is now evidence-only, so an upcoming CI run is the first real answer to -whether the slot can produce a binary. Its `.exe` stays out of the release until then; the row stays -because the architecture is still supported. +**This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots +named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it +writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard +(`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are +missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that +exist for an unlisted slot get an explicit "not published" warning instead of being deleted quietly, and a +`mode: ci` slot that produced nothing at all warns too — a red slot must never be visible only as a missing row. + +Of the unlisted rows, CI builds two. `windows-arm64` is built natively on GitHub's `windows-11-arm` runner +(Windows 11 Arm64 + Visual Studio 2026); every ARM64 failure so far came from a pre-build toolchain probe that +aborted before `cargo build` ran, that probe is now evidence-only, and the next CI run is the first real answer to +whether the slot can produce a binary. `linux-arm64` is built natively on `ubuntu-22.04-arm` (same glibc 2.35 floor +as x64) and is unpublished for a different reason: **nothing has ever run it on ARM64 hardware**. One frame plus the +theme/backend log lines on a real ARM64 machine is the unlock condition; until then the artifact is build evidence. Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three @@ -279,6 +289,18 @@ documents are attached to the release next to it — downloading the Windows bin licence. Download links never carry implementation detail in the name, and the binary inside an archive is always called `unlucky-launcher`. +On Linux x64 that is the whole install: + +```bash +tar -xzf unlucky-launcher__linux_x64.tar.gz +cd unlucky-launcher__linux_x64 +./unlucky-launcher # add --software-renderer if there is no usable GPU adapter +``` + +It asks for glibc 2.35+ (the slot is built on Ubuntu 22.04), `libxkbcommon`, `fontconfig` and `libGL`; on +Debian/Ubuntu `sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` +covers the load-time set, and the bare `ull__linux_x64` beside the archive needs no display at all. + The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it `dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35 @@ -345,7 +367,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde |----------|---------| | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | -| `.github/workflows/release.yml` | tag `v*` → build both Windows slots (`windows-x64` + `windows-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` only; `-f all_slots=true` builds all six slots for evidence) | +| `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) | | `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself | ## Architecture and module map diff --git a/README.zh-CN.md b/README.zh-CN.md index 34c11bb..da62a9b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -175,14 +175,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 - **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 -- **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、 - `macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过; - `gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是 - 「矩阵里有就算支持」。 -- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置 - (macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉 - Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 `workflow_dispatch` - 的探针、带 `continue-on-error`,不是门禁。 +- **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编 + `linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染 + 后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里 + **这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。 + 「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到 + (`hwnd=none`);后者交给桌面合成器(`corner=policy=system`),我们不自己画。 + `linux-arm64` 只是**编出来了,没发布**——没有任何 ARM64 机器跑过它一帧,产物只作构建证据留着。 +- **macOS 不由发布流水线构建,也未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、 + 三个窗口按钮的位置(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名, + 首次启动要自己清掉 Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 + `workflow_dispatch` 的探针、带 `continue-on-error`,不是门禁;`gh workflow run release.yml -f all_slots=true` + 能编这两档取证,但它们从不发布。 - **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它 真正拿到的高度。 - **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子 @@ -202,22 +206,37 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 | 槽位 | 产物 | 形态 | |------|------|------| | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | -| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 | -| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** | -| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** | -| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** | -| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** | +| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 | +| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 | +| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) | +| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** | +| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** | | 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | -**本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位 +**本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位 (`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标 -**暂未发布** 的行是命名规则,不是本版可下载的资产。两个方向都有守卫(`packaging/check_publish_slots.py`): -名单里点名了却没产物 ⇒ Release 变红;产物在、名单没点名 ⇒ 显式打「未发布」警告,不再静默删掉。 -这几行里 `windows-arm64` 是 CI 唯一真跑的那档:在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + -Visual Studio 2026)runner 上编来取证。**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**, -`cargo build` 一次都没跑过;M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。 -在那之前它的 `.exe` 不进 Release;行不删是因为架构仍要支持,真出包了就把键加回 `PUBLISH_SLOTS`(一行)。 +**不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`): +名单里点名了却没产物 ⇒ Release 变红(`linux-x64` 构建红了就挡住整次发布,靠的就是这条);产物在、名单没点名 +⇒ 显式打「未发布」警告,不再静默删掉;`mode: ci` 的槽位本次连产物都没有(那一档红了)⇒ 也打警告。 + +不发布的行里 CI 真编两档。`windows-arm64` 在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + Visual Studio +2026)上编来取证——**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**,`cargo build` 一次都没跑过; +M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。`linux-arm64` 在原生 +`ubuntu-22.04-arm`(与 x64 同 glibc 地板)上编,不发布的理由完全不同:**没有任何 ARM64 机器跑过它**。 +解锁条件 = 真机渲出一帧 + 主题/后端日志行,之后才把键加进 `PUBLISH_SLOTS`。 + +Linux x64 的安装就是解开归档直接跑: + +```bash +tar -xzf unlucky-launcher_<版本>_linux_x64.tar.gz +cd unlucky-launcher_<版本>_linux_x64 +./unlucky-launcher # 没有可用 GPU 适配器就加 --software-renderer +``` + +载入期要 glibc 2.35+、`libxkbcommon`、`fontconfig`、`libGL`;Debian/Ubuntu 上 +`sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` 就够。 +与归档并列的裸 `ull_<版本>_linux_x64` 连显示都不需要。 UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 @@ -278,7 +297,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必 |----------|------| | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | -| `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) | +| `.github/workflows/release.yml` | tag `v*` → 构建默认四档(`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(发布 `windows-x64` 与 `linux-x64`;`linux-x64` 另有一条同槽位的 Xvfb 首帧门禁;`-f all_slots=true` 才整表六槽位取证) | | `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 | ## 架构与模块地图 diff --git a/docs/images/linux-x64-wslg-window.png b/docs/images/linux-x64-wslg-window.png new file mode 100644 index 0000000000000000000000000000000000000000..bd972fab3d7349f63306c34ae8b0a471747f8c2e GIT binary patch literal 43084 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 literal 0 HcmV?d00001 diff --git a/packaging/README.md b/packaging/README.md index 3c3959e..0d9e29d 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -45,6 +45,10 @@ - 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,**不证明界面元素齐全, 更不证明视觉正确**——视觉靠人眼看 artifact。 - 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。 +- (M184)`linux-x64` 已进发布名单,所以这条**同类**判据也常驻在发布流水线里:`release-build.yml` 的 + `Linux x64 运行时冒烟` 步用同一个 `check_screenshot.py` 判 release 产物的首帧,外加 `[theme]` / + `[backend] final=… result=started` 两行日志断言。证据账(含真机 WSLg 实测输出)在 + [`packaging/docs/linux-evidence.md`](docs/linux-evidence.md)。 **本地复跑**:`bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke`——脚本逐步复刻这个 job(同样的 apt 依赖、 `--bin unlucky-launcher-iced`、同样的 `xvfb-run … --software-renderer --screenshot --data-dir`);输出目录别写 `/tmp`, @@ -64,10 +68,11 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | -| `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 | +| `packaging/check_publish_slots.py` | 发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + `mode: ci` 槽位无产物的警告;`--self-test` 注入 11 条回归 | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | +| `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 | diff --git a/packaging/check_publish_slots.py b/packaging/check_publish_slots.py index 827b4e3..62019bc 100644 --- a/packaging/check_publish_slots.py +++ b/packaging/check_publish_slots.py @@ -17,8 +17,12 @@ 3. **名单里每一项的 `mode` 都必须是 `ci`**:`mode: probe` 的槽位默认构建根本不跑,写进发布名单等于 每一轮都缺一档 —— 一个自己写死要红的组合。 4. **产物 ⇄ 名单两个方向**(`--dist`,只在发布侧有产物时跑): - - 名单声明了但没有 GUI 或 CLI 产物 ⇒ `::error::` + exit 1(少一档不发版,这条现在是脚本、本地可跑); - - 有产物但名单没声明 ⇒ `::warning::` 明说「未发布、会被裁掉」,**不许静默**; + - 名单声明了但没有 GUI 或 CLI 产物 ⇒ `::error::` + exit 1(少一档不发版)。声明为发布的多半就是 `mode: ci` + 的槽位,所以这条同时是「**声明为 ci 的槽位构建红了必须挡住发布**」的判据 —— 红在 build 矩阵里的那一档不会 + 让发布跳过(`release` job 是 `always()`),挡住它的就是这里; + - 有产物但名单没声明 ⇒ `::warning::` 明说「未发布、会被裁掉」,**不许静默**(M184 起 `linux-arm64` 正好是 + 这一档:它 `mode: ci`、每轮都编,但运行时未验所以不在名单,产物留着取证); + - `mode: ci` 却本次没有产物(且不在名单)⇒ 也打 `::warning::`:那一档的构建很可能红了,不能只表现为「表里少一行」; - 无论哪种,最后都打一张全槽位表:发布 / 未发布(无产物)/ 未发布(名单没声明)。 用法(零第三方依赖,本地与 CI 同一份代码): @@ -28,7 +32,8 @@ python3 packaging/check_publish_slots.py --self-test # 注入回归,断言每条都变红 `--self-test` 是这条守门的自测证据:把两个 workflow 复制进临时目录、造合成 dist,逐项注入真实回归 -(漏一个资产 / 名单拼错 / 名单写了 probe 档 / 名单声明了但产物缺 / 名单没声明却有产物),断言守门 +(漏一个资产 / 名单拼错 / 名单写了 probe 档 / 名单声明了但产物缺 / 名单没声明却有产物 / +`mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了 / 提前把 `linux-arm64` 写进名单),断言守门 该红的红、该只警告的不红。守门抓不住回归时它自己先红。 """ @@ -150,7 +155,12 @@ def check_dist(dist: Path, declared: list[str], matrix: dict[str, str]) -> tuple if is_declared: missing = [role for prefix, role in ASSET_PREFIXES if not found[prefix]] for role in missing: - errors.append(f"发布白名单缺产物:{slot} 的 {role}(`{prefix_for(role)}_*_{stem(slot)}*`)") + suffix = ( + "(它是 mode=ci 的槽位:构建红了就必须挡住本次发布,别把缺一档的 Release 发出去)" + if matrix.get(slot) == "ci" + else "" + ) + errors.append(f"发布白名单缺产物:{slot} 的 {role}(`{prefix_for(role)}_*_{stem(slot)}*`){suffix}") verdict = "发布" if not missing else "缺产物 ⇒ 不发版" wanted.discard(slot) elif present: @@ -160,6 +170,14 @@ def check_dist(dist: Path, declared: list[str], matrix: dict[str, str]) -> tuple "若这是修复后的首次成功构建,把槽位加进名单即可(齐全性检查与 SHA256SUMS 自动跟着扩)" ) verdict = "未发布(名单没声明)" + elif matrix.get(slot) == "ci": + # 声明为「默认在 CI 里构建」的槽位本次却没产物:那一档的构建红了(或矩阵被改窄了)。它不在名单里 + # ⇒ **不该**让发布变红(arm64 的策略就是「红了不挡发布」),但也不能静默:否则「又红了」只剩翻 job 列表。 + warnings.append( + f"{slot} 是 mode=ci 的槽位(tag 推送默认就编)却本次没有产物:那一档的构建红了或被裁了," + "本次未发布。它不在 PUBLISH_SLOTS 里,故不影响本次发布" + ) + verdict = "未发布(ci 槽位本次无产物)" else: verdict = "未发布(本次没有产物)" rows.append(f"{slot:<20} {'声明' if is_declared else '未声明':<8} {'、'.join(present) or '无':<15} {verdict}") @@ -269,11 +287,12 @@ def self_test() -> int: set_declared(typo, "windows-aarch64") cases.append(("名单拼错成 windows-aarch64", typo, fake_dist(Path(tmp) / "c4d", ("windows-aarch64",)), None, True)) - # 5. 名单写了 probe 档(默认不构建)⇒ 必须红。 + # 5. 名单写了 probe 档(默认不构建)⇒ 必须红。**要挑仍是 probe 的槽位**:`linux-x64` 已翻成 `mode: ci`, + # 再拿它当样例,这条用例就从「必须红」变成「本来该绿」——自测跟着矩阵走,否则它自己先变成假证据。 probe = Path(tmp) / "c5" snapshot(probe) - set_declared(probe, "windows-x64 linux-x64") - cases.append(("名单写了 mode=probe 的槽位", probe, fake_dist(Path(tmp) / "c5d", ("windows-x64", "linux-x64")), None, True)) + set_declared(probe, "windows-x64 macos-arm64") + cases.append(("名单写了 mode=probe 的槽位", probe, fake_dist(Path(tmp) / "c5d", ("windows-x64", "macos-arm64")), None, True)) # 6. 运行期名单与文件里那一行不一致 ⇒ 必须红(防步内覆盖)。 cases.append(("运行期名单与文件不一致", base, fake_dist(Path(tmp) / "c6", tuple(declared)), ["windows-arm64"], True)) @@ -284,6 +303,20 @@ def self_test() -> int: set_declared(flip, " ".join([*declared, "windows-arm64"])) cases.append(("名单加上 windows-arm64 且产物齐全(下一 tag 的翻牌预演)", flip, fake_dist(Path(tmp) / "c7d", (*declared, "windows-arm64")), None, False)) + # 8. (M184 核心)名单里的 `linux-x64` 构建红了(没有它的产物)⇒ **必须挡住发布**:`release` job 是 + # `always()`,构建矩阵里红一档不会把发布跳过;挡住它的只能是这条。 + dist = fake_dist(Path(tmp) / "c8", tuple(s for s in declared if s != "linux-x64") + ("linux-arm64",)) + cases.append(("名单声明的 linux-x64 构建红了(缺产物)", base, dist, None, True)) + + # 9. (M184 真实场景)`linux-arm64` 构建成功但不在名单 ⇒ 只警告、不挡发布(它是 `mode: ci` 的槽位,每轮都编)。 + cases.append(("linux-arm64 构建成功但不在名单(只警告)", base, fake_dist(Path(tmp) / "c9", (*declared, "linux-arm64")), None, False)) + + # 10. 反过来:有人把 `linux-arm64` 提前写进名单、而它没构建出来 ⇒ 必须红(解锁条件还没满足)。 + early = Path(tmp) / "c10" + snapshot(early) + set_declared(early, " ".join([*declared, "linux-arm64"])) + cases.append(("名单提前声明 linux-arm64 但缺产物", early, fake_dist(Path(tmp) / "c10d", tuple(declared)), None, True)) + for label, root, dist, expected, want_red in cases: errors, warnings, _rows = run_checks(root, dist, expected) got_red = bool(errors) @@ -295,13 +328,29 @@ def self_test() -> int: else: print(f"OK: {label} → {'红' if got_red else '绿'}({len(warnings)} 条未发布提示)") - # 第 3 条还要断言**确实打了警告**:不静默才是这条规则的重点。 - _e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / "c8", (*declared, "windows-arm64")), None) - if not any("windows-arm64" in w for w in warnings): - print("::error::自测失败:产物在但名单没声明时没有针对 windows-arm64 的 ::warning::", file=sys.stderr) + # 第 3 / 9 条还要断言**确实打了警告**:不静默才是这两条规则的重点。 + for ghost in ("windows-arm64", "linux-arm64"): + _e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / f"c-warn-{ghost}", (*declared, ghost)), None) + if not any(ghost in w for w in warnings): + print(f"::error::自测失败:产物在但名单没声明时没有针对 {ghost} 的 ::warning::", file=sys.stderr) + failures += 1 + else: + print(f"OK: 产物在但名单没声明 → 有 {ghost} 的 ::warning::") + + # 第 9 条的另一半:`mode: ci` 的槽位**连产物都没有**(构建红了)时也要有注解,不能只在表里少一行。 + ci_slots = [slot for slot, mode in matrix_slots((base / MATRIX_YML).read_text(encoding="utf-8")).items() if mode == "ci"] + missing_ci = [slot for slot in ci_slots if slot not in declared] + if not missing_ci: + print("::error::自测失败:没有「mode=ci 但不在名单」的槽位,这条用例失去意义(矩阵是不是被改窄了?)", file=sys.stderr) failures += 1 else: - print("OK: 产物在但名单没声明 → 有 windows-arm64 的 ::warning::") + _e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / "c-ci-red", tuple(declared)), None) + for slot in missing_ci: + if not any(slot in w and "ci" in w for w in warnings): + print(f"::error::自测失败:{slot} 是 mode=ci 却没产物时没有 ::warning::(红在哪都不知道)", file=sys.stderr) + failures += 1 + else: + print(f"OK: {slot} 是 mode=ci 却无产物 → 有 ::warning::") if failures: return 1 diff --git a/packaging/docs/artifacts-and-version.md b/packaging/docs/artifacts-and-version.md index 09db419..5ed1abb 100644 --- a/packaging/docs/artifacts-and-version.md +++ b/packaging/docs/artifacts-and-version.md @@ -9,9 +9,9 @@ | 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 | |------|---------|-----------|------|---------| | `windows-x64` | ✅ 发布 | `unlucky-launcher__windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 | -| `windows-arm64` | ❌ 不发布(构建取证中) | `unlucky-launcher__windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 在**原生** `windows-11-arm`(Windows 11 Arm64 + VS2026 镜像)上构建,**M164 起编译前体检只取证、不再判定红绿**(此前五轮全红其实全红在那个误判的体检步),**红了不挡 x64 发布**(M142,见 §3) | -| `linux-x64` | ❌ 不构建(未验证) | `unlucky-launcher__linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` | -| `linux-arm64` | ❌ 不构建(未验证) | `unlucky-launcher__linux_arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 | +| `linux-x64` | ✅ 发布 | `unlucky-launcher__linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md`;CI 在原生 `ubuntu-22.04`(glibc 2.35 地板)上构建,**并有同槽位的 xvfb 运行时门禁**(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定);真机验收证据见 `docs/linux-evidence.md` | +| `windows-arm64` | ❌ 不发布(构建取证中) | `unlucky-launcher__windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 在**原生** `windows-11-arm`(Windows 11 Arm64 + VS2026 镜像)上构建,**M164 起编译前体检只取证、不再判定红绿**(此前五轮全红其实全红在那个误判的体检步),**红了不挡发布**(M142,见 §3) | +| `linux-arm64` | ❌ 已构建、不发布(运行时零证据) | `unlucky-launcher__linux_arm64.tar.gz` | 单二进制归档(原生 `ubuntu-22.04-arm` 构建,与 x64 同 glibc 地板) | 同上;M184 起**默认就编**(`mode: ci`),但没有任何 ARM64 真机跑过一帧 ⇒ 不进 `PUBLISH_SLOTS`。解锁条件 = 真机渲出一帧 + 主题日志 | | `macos-arm64` | ❌ 不构建(未验证) | `unlucky-launcher__macos_arm64.tar.gz` | 单二进制归档 | 同上 | | `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher__macos_x64.tar.gz` | 单二进制归档 | 同上 | | 每槽位 | 随所属槽位 | `ull__<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` | @@ -20,10 +20,12 @@ 约定细节: -- **「不构建」= CI 默认根本不跑**(M139):那四档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走 - `gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**只有 `windows-x64`**(M142:arm64 照构建、不发布)。 - (M164)名单 ⇄ 产物两个方向都有守卫 `packaging/check_publish_slots.py`:声明了缺产物 ⇒ 红;有产物但没声明 ⇒ - `::warning::` 明说「未发布、会被裁掉」——arm64 真出包之后把键加回名单是一行改动,守卫与 `SHA256SUMS.txt` 自动跟着扩。 +- **「不构建」= CI 默认根本不跑**(M139):macOS 那两档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走 + `gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**是 `windows-x64` + `linux-x64`**(M184)。 + 「已构建但不发布」是独立的一档(`linux-arm64`、`windows-arm64`):它们 `mode: ci`、每轮都编,产物留在 + `dist-` artifact 里取证,裁剪步会把它们剔出 Release。 + (M164/M184)名单 ⇄ 产物两个方向都有守卫 `packaging/check_publish_slots.py`:声明了缺产物 ⇒ 红;有产物但没声明 ⇒ + `::warning::` 明说「未发布、会被裁掉」;`mode: ci` 却本次没产物 ⇒ 也打 `::warning::`(那一档红了要看得见)。 - **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。 - **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本", 把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。 diff --git a/packaging/docs/build-and-ci.md b/packaging/docs/build-and-ci.md index dee484b..b076911 100644 --- a/packaging/docs/build-and-ci.md +++ b/packaging/docs/build-and-ci.md @@ -32,15 +32,15 @@ python packaging/make_artifacts.py --slot windows-arm64 \ `--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64 宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)。**CI 不走交叉**(M140): -`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 的同类 -冒烟只在 `all_slots=true` 的取证跑里出现(`ubuntu-22.04-arm` 原生 runner 上真跑 `ull --version`)。 +`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 也是原生 +`ubuntu-22.04-arm` 构建 → 冒烟在 runner 上真跑(M184 起它默认就编,不再只在 `all_slots=true` 的取证跑里出现)。 -### CI(默认只跑两个 Windows 槽位;`all_slots=true` 才整表六槽位) +### CI(默认跑四个槽位:Windows 两档 + Linux 两档;`all_slots=true` 才整表六槽位) `.github/workflows/release.yml`: - 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false = - 只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch + 只跑四个槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch 才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.3`。 分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以 「分支 ref 的 dispatch 只构建不发布」与「tag ref 上 dispatch 能选标记」是两条互不冲突的规则。 @@ -48,8 +48,15 @@ python packaging/make_artifacts.py --slot windows-arm64 \ 不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与 **原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC + `-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编); - `ubuntu-22.04`(glibc 2.35 地板)/ `ubuntu-22.04-arm`(原生 ARM64,同地板)/ `macos-latest` / - `macos-15-intel` 标 `mode: probe`——**未验证,默认一个都不编**,只有 `all_slots=true` 才进矩阵。 + (M184)`ubuntu-22.04`(glibc 2.35 地板)与 `ubuntu-22.04-arm`(原生 ARM64,同地板)**也标 `mode: ci`** —— + tag 推送默认就编这两档;只剩 `macos-latest` / `macos-15-intel` 是 `mode: probe`,**未验证、默认一个都不编**, + 只有 `all_slots=true` 才进矩阵。 +- **运行时证据决定发布,构建绿了不够**:`linux-x64` 进 `PUBLISH_SLOTS` 的依据是「release 产物真开窗渲出一帧」 + (真机 WSLg + 截图 + `[theme]` / `[backend] final` 日志,实测输出见 `packaging/docs/linux-evidence.md`), + 所以 `release-build.yml` 给**这个槽位**配了一条 xvfb 运行时冒烟步(`if: inputs.slot == 'linux-x64'`): + 装 xvfb → `xvfb-run … --software-renderer --screenshot` 起 **release** 产物 → 断言 `[theme] source=` 与 + `[backend] final=… result=started` 两行 → `check_screenshot.py` 判像素 → 上传首帧图。 + `linux-arm64` **刻意没有**这条判据:arm64 上这条路一次都没验过,给它加一条没验证过的门禁就是造红。 - 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位没了)。arm64 槽位在编译前 **把工具链显式备齐**(M164):`rustup target add aarch64-pc-windows-msvc`、用微软自己的 `vcvarsall.bat arm64` 取 `LIB`/`INCLUDE`(写进本步环境与 `$GITHUB_ENV`)、用 vswhere 定位 arm64 `link.exe` 后显式导出 @@ -64,16 +71,25 @@ python packaging/make_artifacts.py --slot windows-arm64 \ 自相矛盾(`um/arm64` 明明在)。现在那一步只打印 vswhere 组件查询、各目录 `.lib` **计数**与 `link.exe` 候选, 永不 `exit 1`;**红绿只由编译器 / 链接器 / 静态 CRT 导入表门禁决定**。runner 实况:`windows-11-arm` 自 2026-09 是 Windows 11 Arm64 + **Visual Studio 2026** 镜像,`VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都已装好。 -- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M142 起只有 - `windows-x64`**:齐全性检查是「名单内少一档就 fail」,挂着没验证过的 arm64 就等于 x64 编成功也发不出去。 - (M164)arm64 那五轮红其实全红在误判的体检步(上一条),门禁拆掉后「arm64 能不能出 PE」只能由**下一 tag 的 CI** - 证明,所以名单这轮仍只列 x64 —— 不拿 x64 的发布去赌(tower M164 裁决)。**可逆且只是一行**:那次 CI 真出包后把 - 键加回 `release-publish.yml` 那一行,再同步 §1/§9 与 `release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。 -- **名单 ⇄ 产物两个方向都有守卫(M164)**:`packaging/check_publish_slots.py`,同一份代码两处跑 —— PR 阶段 +- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M184 起是 + `windows-x64 linux-x64`**:齐全性检查是「名单内少一档就 fail」,所以 `linux-x64` 进来之后它的构建红了就挡住整次 + 发布 —— 这正是「声明为 ci 的槽位失败必须阻断发布」那条要求,判据落在 `check_publish_slots.py` 的 `--dist` 模式里 + (`release` job 是 `always()`,构建矩阵里红一档不会让它跳过,挡住它的只能是那道守卫)。 + `linux-arm64` 本轮**只构建不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟), + **解锁条件 = 真机上真跑出一帧 + 主题日志**,之后才把键加进 `release-publish.yml` 那一行,再同步 §1/§9 与 + `release-notes.md`。`windows-arm64` 同理仍未进名单(M164:它的五轮红全红在误判的体检步,等下一 tag 的 CI 证)。 + **可逆且只是一行**:翻牌只改 `PUBLISH_SLOTS` 那一行,齐全性与 SHA256SUMS 自动跟着扩。 +- **耗时(M184 估算,2 槽位 → 4 槽位)**:多出的两档是**并行**跑的(矩阵 `fail-fast: false`),所以墙钟增量 ≈ + 「`linux-x64` 那一档的时长(含新增 xvfb 冒烟步约 1~2 分钟)」与「`linux-arm64` 那一档的时长」取大者,而不是两者相加; + 真正增加的是**并发作业数与总机时**(约 +2× 单槽位机时,公开仓 arm64 Linux runner 不额外计费)。 + 发布侧只多两个资产的裁剪/摘要/上传(秒级)。细节与逐项拆解见 `packaging/docs/linux-evidence.md`。 +- **名单 ⇄ 产物两个方向都有守卫(M164,M184 扩)**:`packaging/check_publish_slots.py`,同一份代码两处跑 —— PR 阶段 (`.github/workflows/release-guard.yml`)断言「名单非空 / 每项都是槽位表里 `mode: ci` 的档」;发布侧 (`release-publish.yml` 的 `--dist` 模式,跑在**裁剪前**的全量集上)断言「声明了就得有 GUI 与 CLI 两个产物, 缺一个就红」,并把「有产物但名单没声明」打成 `::warning::` 明说未发布 —— M142 之后那个方向是静的(裁剪步只 - `echo drop:`),一个真编出来的 arm64 会被就地删掉而没人知道。`--self-test` 注入 8 条回归,本地一条命令可跑。 + `echo drop:`),一个真编出来的 arm64 会被就地删掉而没人知道。M184 补第三条:`mode: ci` 的槽位**本次连产物都没有** + (那一档构建红了)也打 `::warning::`,别只在表里少一行。`--self-test` 注入 11 条回归(含「声明的 `linux-x64` 构建红了 + ⇒ 必须红」「`linux-arm64` 有产物但没声明 ⇒ 只警告」「提前把 `linux-arm64` 写进名单 ⇒ 缺产物必红」),本地一条命令可跑。 - `release` job:下载全部 artifact → **发布名单守卫**(断言 + 未发布提示)→ 按白名单裁剪 → 重算 `SHA256SUMS.txt` → 用 `packaging/release-notes.md` 模板渲染支持矩阵 → 生成更新清单 `manifest.json` → `gh release create` (**push tag 默认建稳定版**:不带任何标记)。 @@ -106,8 +122,9 @@ python packaging/make_artifacts.py --slot windows-arm64 \ 后来只有 M163 动过 `on` 里 `prerelease` 的默认值(`true` → `false`,见上面那张语义表),M164 改过 `release-build.yml` 的 arm64 体检步与 `cargo build` 步、并在 `release-publish.yml` 裁剪前插了「发布名单守卫」 (于是本分支 8 步:checkout / download / **守卫** / 裁剪 / 重算 SHA256SUMS / manifest / 说明 / `gh release`; - 搬家时 6 步、主线 7 步)。改槽位仍只改 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`, - 翻牌时要改的仍是那两处)。 + 搬家时 6 步、主线 7 步)。M184 又在 `release-build.yml` 里给 `linux-x64` 插了「运行时冒烟 + 上传首帧图」两步 + (该文件 11 → 13 步,发布侧仍是 8 步)。改槽位仍只改 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 + `release-publish.yml`,翻牌时要改的仍是那两处)。 - **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值 `${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进 git 文件**:真值放仓库 Secret,由 `release.yml` 的 `build` job 按名显式透传(可复用工作流拿不到未显式传入的 diff --git a/packaging/docs/compliance-support-boundaries.md b/packaging/docs/compliance-support-boundaries.md index a652eb6..cd611df 100644 --- a/packaging/docs/compliance-support-boundaries.md +++ b/packaging/docs/compliance-support-boundaries.md @@ -21,25 +21,32 @@ | 平台 | 本版发布 | 级别 | 运行时要求 | |------|---------|------|-----------| | Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) | +| Linux x64 X11 | ✅ | 一等 | glibc 2.35+(`ubuntu-22.04` 原生构建即地板)、xkbcommon、fontconfig、libGL(或软渲染)、xcb | +| Linux x64 Wayland | ✅(同一归档;**Wayland 路径未验**) | 支持 | 上述 + wayland-client(XWayland 应用仍需 X11 那套) | | Windows 11 ARM64 | ❌ 不发布(构建取证中) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK | +| Linux arm64 | ❌ 已构建、不发布(**运行时零证据**) | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb / wayland-client | | macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine | -| Linux x64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb | -| Linux x64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client | -| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb | -| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client | | Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) | | Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机真机验证两条路径各一次(CPU 软渲染 + 自编 Mesa/turnip GPU 加速,配方见 `packaging/docs/termux-glibc-build.md`、证据见 `README.termux.md`),不在本发布矩阵内 | `packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。 -两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证 -(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release-publish.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。 +**M184 的翻牌(Linux x64 进发布名单,arm64 只构建)**:发布开关仍是 `release-publish.yml` 的 `PUBLISH_SLOTS`, +现在是 `windows-x64 linux-x64`;`release.yml` 槽位表里 `linux-x64` 与 `linux-arm64` 都是 `mode: ci`(tag 推送默认就编)。 +两档的唯一差别是**运行时证据**: -(M164)`windows-arm64` 为什么还没进名单:它 v0.2.1~v0.2.3 的红**全部**红在一个编译前体检步(`find` 猜 SDK 的 +- `linux-x64` 有:release 产物在 Linux 真机(Ubuntu 22.04 + WSLg 真窗口)渲出一帧、`[theme]` 与 + `[backend] final=… result=started` 两行齐全、像素判定通过;`release-build.yml` 里还有一条**同槽位**的 xvfb + 运行时门禁,每轮发布都重跑(证据与实测输出:`packaging/docs/linux-evidence.md`)。 +- `linux-arm64` 没有:没有任何 ARM64 机器跑过它一帧,也没有 arm64 的 xvfb 冒烟。**解锁条件 = 真机(或原生 + ARM64 runner)上真跑出一帧 + 主题日志**,之后才把键加进 `PUBLISH_SLOTS`。构建绿了不算解锁,别拿没跑过的包发人。 + +`windows-arm64` 为什么还没进名单(M164 遗留):它 v0.2.1~v0.2.3 的红**全部**红在一个编译前体检步(`find` 猜 SDK 的 `um/arm64/kernel32.lib`),`cargo build` 一次都没跑过;M164 把那个门禁降级为只取证、判定权交回编译器,但「能不能 -出 PE」只能由**下一 tag 的 CI** 证明。名单 ⇄ 产物的两个方向现在都有守卫(`packaging/check_publish_slots.py`): -声明了缺产物 ⇒ 红,有产物没声明 ⇒ `::warning::` 明说未发布 —— 所以那次 CI 真出包之后,翻一行名单即可。 +出 PE」只能由**下一 tag 的 CI** 证明。名单 ⇄ 产物的两个方向都有守卫(`packaging/check_publish_slots.py`): +声明了缺产物 ⇒ 红(这正是「声明为 ci 的槽位构建红了就挡住发布」那条闸),有产物没声明 ⇒ `::warning::` 明说未发布, +`mode: ci` 却本次没产物 ⇒ 也打 `::warning::`(红了要看得见)—— 所以那次 CI 真出包之后,翻一行名单即可。 ## 10. 已知边界 / 后续 @@ -57,3 +64,9 @@ published 一列,Termux 行标注 experimental)——改一边必须同步 `windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT 门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层 取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。 +8. **Linux 上自更新只"能查不能装"**(M184 发布 Linux x64 之后才有意义):清单现在会带 `linux-x64` 的 + `portable-zip` 产物,而 `ull update apply` 只做便携单文件 `.exe` 的自替换 + (`crates/core/src/update/job.rs::ensure_self_replaceable`)——Linux 上它会带具名原因**拒绝** + ("发布里的 linux-x64 产物是 portable-zip 形态,不能原地替换启动器 … 压缩包请解压后再用"), + 不会半装坏。Linux 的更新方式仍是「下载新归档覆盖旧副本」。真要做 Linux 自更新(换目录里的文件再重启) + 是另一件事,未规划。 diff --git a/packaging/docs/linux-evidence.md b/packaging/docs/linux-evidence.md new file mode 100644 index 0000000..b61f7c9 --- /dev/null +++ b/packaging/docs/linux-evidence.md @@ -0,0 +1,205 @@ +# Linux 槽位的证据账(M184) + +本文是「Linux 到底验过什么、没验什么」的**唯一账本**。M184 把 `linux-x64` 写进了 `PUBLISH_SLOTS`(发布), +把 `linux-arm64` 留在「构建但不发布」,两者都不是凭感觉:下面按「已有哪些证据 → 本轮补的实测 → CI 增量 → +仍然没验的部分 → 解锁条件」逐条记账,任何一条被推翻都要回来改这里与 `compliance-support-boundaries.md` §9。 + +## 1. M184 之前的证据盘点 + +| 证据 | 位置 | 覆盖了什么 | 没覆盖什么 | +|------|------|-----------|-----------| +| xvfb 运行时冒烟(每次 PR/main) | `.github/workflows/ci.yml` 的 `runtime-smoke` | `ubuntu-latest`(x64)上装 xvfb + 软渲染依赖 + 字体,`xvfb-run … --software-renderer --screenshot` 真渲一帧,`packaging/check_screenshot.py` 对**像素本身**断言(唯一色数 / 亮度 σ / 强边缘占比),截图作为 artifact 留证 | 只有 x64;用的是 **debug** 档(`cargo build --bin unlucky-launcher-iced --locked`),不是发出去的那个 release 产物;不跑 GPU 通路;不验 Wayland;不校验 `[theme]` / `[backend] final` 日志行;没有 arm64 | +| 本地复跑脚本(开发机) | `packaging/linux-smoke-docker.sh` | 在 `rust:1-bookworm`(**Debian 12 / glibc 2.36**)里逐步复刻上面那个 job;M131 真跑过一次(2026-11-05):`exit=0`、截图 1180×760、`[ok] 截图非空白` | 基础镜像是 Debian 而不是 runner 的 Ubuntu;仍是 debug 档;同样没有 arm64 | +| 编译期门禁复跑 | `packaging/linux-check-docker.sh` | 在 Linux 上跑 `clippy --workspace --all-targets -- -D warnings` 与 `test --workspace --all-targets`(M144 那两条只能在 Linux 复现的红) | 与运行时无关,只证明「编得过、测得过」 | +| 阈值依据 | `packaging/ci-runtime-smoke.md` | 真实帧与纯色帧的实测数值(唯一色 342~2352、σ 19.45~27.46、强边缘 0.0076~0.0433)都在这里,阈值取在两者之间 | 标定样本来自 x64 软渲染 | +| `ull --version` 冒烟 | `packaging/make_artifacts.py::smoke_version` | 槽位与宿主一致时**真跑** CLI 并断言版本串(`linux-x64` 与 `linux-arm64` 在各自的 CI runner 上都满足这条,所以两个 arm64/x64 槽位都有一份 CLI 运行时证据) | 只覆盖 CLI(零图形栈),GUI 一帧都没有;`linux-arm64` 的 GUI 从未启动过 | + +**glibc 地板怎么定的**:槽位表把 Linux 两档钉在 `ubuntu-22.04` / `ubuntu-22.04-arm`,**不跟 `latest` 走** +(`release.yml` 的槽位表注释:跟 latest 走地板就会随镜像漂移)。Ubuntu 22.04 ⇒ glibc **2.35**,这就是 +`README` / §9 写的「glibc 2.35+」的来源,也是本轮真机验收为什么选 Ubuntu 22.04 的 WSL 发行版:**验证环境的地板 +与发布产物的地板一致**,在更新的发行版上验等于放宽了条件。 + +**一处诚实记录**:M184 的任务描述里提到「曾用过 WSLg 开真窗口跑首帧」。本仓库里查不到那次留下的任何文档或 +脚本痕迹(`grep -rn WSLg` 在本轮改动之前零命中),所以下面这一节是**重新做了一遍并留证**的,不是转述。 + +## 2. 本轮实测(WSLg 真机,linux-x64 release 产物) + +**环境**(2026-11-08,`wsl.exe -v` / 发行版内自报,逐字取自实测输出): + +``` +Linux 6.6.114.1-microsoft-standard-WSL2 x86_64 # WSL 2.7.3.0,WSLg 1.0.73 +PRETTY_NAME="Ubuntu 22.04.5 LTS" # == 发布槽位 ubuntu-22.04 的同一代 +ldd (Ubuntu GLIBC 2.35-0ubuntu3.15) 2.35 # == 文档写的 glibc 2.35 地板 +DISPLAY=:0 WAYLAND_DISPLAY=wayland-0 +rustc 1.99.1 / cargo 1.99.1(rust-toolchain.toml 的 stable) +``` + +**构建**(与 CI 同一条命令,12 核 WSL,crates 走 rsproxy 镜像;镜像只影响下载速度,不影响产物): + +``` +cargo build --release --locked --bin unlucky-launcher-iced --bin ull + Finished `release` profile [optimized] target(s) in 9m 38s # BUILD_WALL=579.19s +-rwxr-xr-x 19571328 unlucky-launcher-iced +-rwxr-xr-x 7534848 ull +``` + +**打包**(CI 的同一份脚本 `packaging/make_artifacts.py`,`--slot linux-x64`): + +``` +arch: unlucky-launcher-iced -> x64 (ok for linux-x64) +smoke: ull --version -> 0.2.3 (ok) # 真跑,不是跳过(宿主 == 产物) +arch: ull -> x64 (ok for linux-x64) +artifact: unlucky-launcher_0.2.3_linux_x64.tar.gz 8.05 MiB sha256=c78e9f6cbab44e0fa76912f5d3608b911d89e2de2adc473339341d8ac3b60c13 +artifact: ull_0.2.3_linux_x64 7.19 MiB sha256=42ff569152f1af89899b0925b4a7495bfcaeb66988889a0348722aa2d6ae2369 +sums: SHA256SUMS.txt +``` + +归档解开就是用户拿到的东西(`unlucky-launcher` 0755;`ELF 64-bit LSB pie executable, x86-64, stripped`): + +``` +-rw-r--r-- LICENSE 11345 / NOTICE 4721 / README.md 30542 +-rwxr-xr-x unlucky-launcher 19571328 +$ ./unlucky-launcher --version +unlucky-launcher 0.2.3(unlucky-cli) +os: Ubuntu 22.04.5 LTS · 6.6.114.1-microsoft-standard-WSL2(x86_64)· 满足最低要求 +``` + +**首帧(`--screenshot` 通道,真窗口在 WSLg 的真显示服务里开出来再渲)**,`exit=0`,关键日志行逐字: + +``` +[probe] adapters=0 hardware=false :: request=cpu -> ICED_BACKEND=tiny-skia +[font] platform=linux text="Noto Sans CJK SC" display="Noto Sans CJK SC" mono="DejaVu Sans Mono" | … source=env cjk=not-needed +[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值) +[theme] source=default-light flavor=light follows_system=false +[window] requested size=1180x760 min=880x560 resizable=true decorations=false transparent=false scale_requested=system size_source=default title="Unlucky Launcher" +[boot] first frame in 485 ms(run() 开始 → 首次 view,含后端探测与字体扫描) +[shot] 1180x760 scale=1 -> …/wslg-first-frame.png +[shot] screen=shell theme=light renderer=tiny-skia route=Detail("survival") +[backend] final=tiny-skia result=started elapsed=1333ms evidence=probe-single-item +``` + +像素判定(`packaging/check_screenshot.py --json`,图 132637 字节): + +``` +{"width": 1180, "height": 760, "pixels": 896800, "unique_colors": 1858, + "top_color": "#f6faff", "top_color_share": 0.5564, "luma_mean": 241.18, + "luma_stddev": 24.06, "edge_ratio": 0.0372, "problems": []} +[ok] 截图非空白 +``` + +**真的开了一个窗口**(交互模式,不加 `--screenshot`):进程 12 秒后仍存活,WSLg 侧出现 +`msrdc` 窗口 **"Unlucky Launcher (cyrene-wsl)"**,`GetWindowRect` 读回 **1180×760**(正是它请求的尺寸), +从 Windows 侧对这块矩形截屏后**人眼可见**:自绘标题栏三键 + 左侧导航轨(Instances / Versions / Logs / +Settings / Assistant)+ "No instance selected" 空态 + 左下 "Not signed in"。这张图已入库: +[`docs/images/linux-x64-wslg-window.png`](../../docs/images/linux-x64-wslg-window.png) +(1180×760,43084 字节,`check_screenshot.py`: 唯一色 871、σ 14.4、强边缘 0.0137 ⇒ 非空白 —— 比进程内那张低, +因为桌面截屏是**已经合成到 Windows 桌面**的结果,抗锯齿被放大过一次;两张都远在阈值之上)。 +交互模式的日志另有两行值得记: + +``` +[probe] adapters=1 [D3D12 (AMD Radeon(TM) Graphics) [Other]] hardware=false :: request=auto -> ICED_BACKEND=tiny-skia +[probe] 探测耗时 1527 ms(wgpu 实例 + 全后端适配器枚举) +[boot] first frame in 2273 ms(…) +[theme] source=system flavor=pending follows_system=true +[theme] 系统主题 = None +[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发) +[corner] boot corner=policy=system source=square os=unknown excluded_reason=unknown-os radius=system pref=none readback=n/a window=none frame=? layered=? +``` + +读法(别过度解读):① WSLg 能枚举到一个 D3D12 适配器但 `hardware=false`,所以 auto 也落到软渲染 +(`tiny-skia`)—— **GPU 通路在 Linux 上仍然没有证据**;② WSLg 没有"系统主题",所以 `follows_system=true` +而 `系统主题 = None`(真桌面上的系统主题跟随仍未验);③ **整窗透明与圆角在 Linux 上本来就是 no-op**:日志自己说 +`hwnd=none`、`corner=os=unknown`(用户令:这两件事交给桌面合成器,我们不自绘)—— 所以它们不是"未验",而是 +**在 Linux 上没有这条代码路径**。 + +**一处 WSLg 的坑**(写在这里免得下次重复踩):WSLg 的 X 侧只有 Weston 的代理窗口, +`import -window root` / `xwd -root` 一律 `X_GetImage … BadMatch`,**拿不到桌面级截屏**; +真窗口像素要么走进程内 `--screenshot`(CI 就是这么做的),要么从 Windows 侧对 `msrdc` 窗口矩形截屏(本节采用后者)。 + + +## 3. 本轮实测(Xvfb,同一个 release 产物) + +同一条命令在无头 X 里再跑一遍(Ubuntu 22.04 的 Xvfb,**release 产物**,判据与 CI 新增那步逐字一致): + +``` +xvfb-run -a --server-args="-screen 0 1280x1024x24" ./unlucky-launcher --software-renderer \ + --screenshot …/xvfb-first-frame.png --data-dir …/data-xvfb +launcher exit=0 +[theme] source=default-light flavor=light follows_system=false +[boot] first frame in 35 ms(run() 开始 → 首次 view,含后端探测与字体扫描) +[shot] 1180x760 scale=1 -> …/xvfb-first-frame.png +[backend] final=tiny-skia result=started elapsed=1147ms evidence=probe-single-item +{"width": 1180, "height": 760, "pixels": 896800, "unique_colors": 1858, "top_color": "#f6faff", + "top_color_share": 0.5564, "luma_mean": 241.18, "luma_stddev": 24.06, "edge_ratio": 0.0372, "problems": []} +[ok] 截图非空白 +``` + +软渲染是确定性的:WSLg 与 Xvfb 两张图的统计量**逐个相同**(1858 / 24.06 / 0.0372),也正是这一点让 +`check_screenshot.py` 的阈值(唯一色 ≥64、亮度 σ ≥3.0、强边缘 ≥0.001)有 20 倍以上余量 —— CI 里这条门禁不会因为 +"某次抗锯齿抖了"而假红。 + +本机 Docker 复跑(`packaging/linux-smoke-docker.sh`,Debian 12 容器 + **debug** 档,缓存卷是热的): + +``` +$ bash packaging/linux-smoke-docker.sh . C:/Temp/ull-m184/smoke-out +rustc 1.99.1 (58cf8f9c3 2026-10-13) / cargo 1.99.1 / 12.15 # 12.15 = Debian 版本号 + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 26s +[font] platform=linux text="DejaVu Sans" display="DejaVu Sans" … source=default cjk=not-needed +[theme] source=default-light flavor=light follows_system=false +[boot] first frame in 69 ms(run() 开始 → 首次 view,含后端探测与字体扫描) +[backend] final=tiny-skia result=started elapsed=1736ms evidence=probe-single-item +exit=0 +/out/runtime-smoke.png: 1180x760 唯一色 1756 亮度σ 23.71 强边缘 0.03366 主色 #f6faff(55.7%) +[ok] 截图非空白 +``` + +两条差异值得写下来(说明"为什么容器绿了还不够"):① 容器里没有中文字体,字体解析落到 `DejaVu Sans`,而 WSL 里是 +`Noto Sans CJK SC` —— **字体选择随环境变**,观感类验收只能在真桌面上做;② 基础镜像是 Debian 12(glibc 2.36), +比发布地板 2.35 高一档,所以它能证明"这条运行时通路通",**不能**代替 `ubuntu-22.04` 上的证据(后者见 §2/§3 前面那段)。 + +单次启动的墙钟(`/usr/bin/time`,含 `xvfb-run` 起 X 的开销):**WALL=1.96s**,日志里 `launch elapsed=1188ms`。 + + +## 4. CI 增量:2 个槽位 → 4 个槽位 + +默认矩阵从 **2 档变 4 档**(`windows-x64` / `windows-arm64` / `linux-x64` / `linux-arm64`),多出来的两档是**并行**作业 +(`fail-fast: false`),所以: + +| 项 | 变化 | 依据 | +|----|------|------| +| 并发作业数 | 2 → 4 | `release.yml` 的 `plan` 输出(本地跑那段 python:默认 4 档、`all_slots=true` 6 档) | +| 每档新增步骤 | `linux-x64` 多 **2 步**(xvfb 运行时冒烟 + 上传首帧图);`linux-arm64` 多 **0 步** | `release-build.yml`:11 步 → 13 步(本地 YAML 解析计数) | +| `linux-x64` 那 2 步的耗时 | apt 装 xvfb ≈ 30~50 s,跑一帧 ≈ 2 s,日志/像素断言 ≈ 1 s,上传 ≈ 3 s ⇒ **≈ 1~2 分钟** | 本地 Xvfb 实测单次启动 WALL=1.96s(`/usr/bin/time`) | +| 单档 release 构建时长(**估算**) | 冷缓存 ≈ 20~30 min;`Swatinem/rust-cache` 命中后 ≈ 8~15 min | 本机 12 核实测 **9m38s**;官方 `ubuntu-22.04` runner 是 4 vCPU,编译墙钟按 2.5~3× 估,再乘缓存命中折扣 | +| 总机时(**估算**) | 每 tag **+约 40 分钟**机时(2 档 × ≈20 min) | 同上 | +| 墙钟(**估算**) | 增量 ≈ `max(0, linux 档时长 − 现有最慢档)`,量级 **0~10 分钟**;发布侧多两个资产,秒级 | 四档并行,墙钟由最慢那档决定 | +| 配额风险 | `ubuntu-22.04-arm` 的可用性**本机无法核实**(无 `gh` CLI、无 Actions API 凭据) | 见 §5 最后一条:它不在发布名单里,起不来也不会挡住那两档发布 | + +诚实边界:单档构建时长是**估算**(本机不是 runner,核数、缓存命中率、网络都不同)。下一次 tag 的真实数字应该回来 +把这张表换成实测——**别让估算一直冒充实测**。 + + +## 5. 仍然没验的部分(如实划出) + +- **真实桌面发行版的观感**:拖动 / 缩放 / 字体度量与中文整形,一个都没在真桌面(GNOME/KDE 的可交互窗口)上 + 人眼验收过。本轮做的是「起窗 + 渲出一帧 + 断言像素与日志」,它证明「能跑出画面」,不证明「观感正确」。 + (**整窗透明度与圆角不在这张清单里**:Linux 上它们没有代码路径 —— 见 §2 的三条读法,别把「不适用」写成「未验」。) +- **GPU 通路**:所有 Linux 证据都是软件渲染(`--software-renderer` / tiny-skia)。wgpu→Vulkan/GL 那条路在 Linux + 上一次都没跑过。 +- **Wayland 原生**:WSLg 同时提供 X11 与 Wayland,本轮只按 `DISPLAY` 走(见 §2 的实测环境),没单独验 + `WAYLAND_DISPLAY` 那条路。 +- **linux-arm64 运行时完全未验**:GUI 进程在 ARM64 上一次都没启动过;它只有「编得过 + `ull --version` 跑得过」。 +- **`ubuntu-22.04-arm` runner 的可用性未在本机核实**:本机没有 `gh` CLI,也没有到 Actions API 的可用凭据, + 所以「这档 runner 在这个仓库上真能起来」只能由下一次 CI 证明。风险是有界的:它**不在** `PUBLISH_SLOTS` 里, + 起不来/红了不会挡住 `windows-x64` + `linux-x64` 的发布(只会让那一条 job 红,外加一条 `::warning::`)。 + +## 6. `linux-arm64` 的解锁条件(写死在这里,别凭感觉放行) + +1. 在**真 ARM64 机器**(或原生 arm64 runner)上把 release 产物跑出一个非空白帧,`packaging/check_screenshot.py` + 通过; +2. 同一份日志里有 `[theme] source=…` 与 `[backend] final=… result=started` 两行(证明主题来源与渲染后端都按预期); +3. 把上面两条的原始输出(时间、命令、图尺寸、日志行)贴进本文 §2 的位置; +4. 然后把 `linux-arm64` 加进 `release-publish.yml` 的 `PUBLISH_SLOTS`(一行),并同步 §9 与 `release-notes.md`。 + +构建绿了**不算**解锁,`ull --version` 跑得过也**不算** —— 那两条只证明 CLI 与编译器,用户拿到的那个 `.tar.gz` 里 +装的是 GUI。 diff --git a/packaging/release-notes.md b/packaging/release-notes.md index 0e73fe3..47e7e1b 100644 --- a/packaging/release-notes.md +++ b/packaging/release-notes.md @@ -6,6 +6,11 @@ No WebView, no bundled runtime, no JRE download needed to start the launcher its ## What's new in {{VERSION}} +- **Linux x64 is a published platform.** The release workflow now builds `linux-x64` on a native `ubuntu-22.04` + runner by default (not only in manual all-slots runs) and publishes it: `unlucky-launcher_{{VERSION}}_linux_x64.tar.gz` + plus the bare `ull_{{VERSION}}_linux_x64`. The artifact itself was started on a Linux machine under a real display + server, rendered a frame and logged the expected theme/backend lines before this decision, and the same check is + now a per-slot CI gate. Linux ARM64 is built too, but stays unpublished until an ARM64 machine runs it. - **{{VERSION}} is the first release published as a stable release.** GitHub only points `releases/latest` at the newest **non-prerelease** release, so `0.2.2` — kept as a historical pre-release, untouched — never appeared there. A pushed tag now creates a stable release by default; @@ -32,6 +37,7 @@ Asset names are `___`, underscore separated — t | Platform | Asset | Notes | | --- | --- | --- | | Windows 10/11 x64 | `unlucky-launcher_{{VERSION}}_windows_x64.exe` | single exe, double-click to run; this is also what you run on ARM64 Windows (x64 emulation) | +| Linux x64 | `unlucky-launcher_{{VERSION}}_linux_x64.tar.gz` | one archive, one top-level directory holding the binary plus `LICENSE` / `NOTICE` / `README.md`; needs glibc 2.35+ and the usual X11/Wayland + fontconfig stack | **Windows ARM64 is not part of this release.** CI still builds it on a native `windows-11-arm` runner (Windows 11 Arm64 with Visual Studio 2026), but the build has not delivered an artifact yet, so @@ -40,21 +46,25 @@ Arm64 with Visual Studio 2026), but the build has not delivered an artifact yet, so far happened in a **pre-build toolchain probe** that guessed whether the SDK's ARM64 import libraries were present and aborted before `cargo build` ever ran — the compiler and linker have not been heard from yet. That probe is now evidence-only (it prints what it sees and never decides the job), so the next CI run is the first -one that can actually answer the question. `windows-x64` is the only published slot; ARM64 rejoins this table as -soon as a run produces its artifact (one line in the workflow's `PUBLISH_SLOTS`, guarded on both sides by -`packaging/check_publish_slots.py`). +one that can actually answer the question. ARM64 rejoins this table as soon as a run produces its artifact (one +line in the workflow's `PUBLISH_SLOTS`, guarded on both sides by `packaging/check_publish_slots.py`). -**This release ships Windows only, and CI builds Windows only.** The Linux (x64 / arm64) and macOS -(Apple Silicon / Intel) slots are not built by the release workflow at all — they have not been verified -on real machines yet, so nothing unverified is handed out and no build minutes are spent on them. They -stay out of the download list and out of `SHA256SUMS.txt` until that verification happens; a manual -workflow run can build all six slots for that purpose, but the four extra slots are never published. The -support matrix below marks those slots accordingly. +**Linux ARM64 is built but not published, and it has no runtime evidence yet.** `linux-arm64` joined the default +build matrix in this release (native `ubuntu-22.04-arm`, same glibc 2.35 floor as x64), so the artifact exists as +build evidence, but nothing has ever run it on ARM64 hardware — not one frame. It stays out of the download list +and out of `SHA256SUMS.txt` until an ARM64 machine renders a frame and the theme/backend log lines come out as +expected; that run is the single unlock condition, and copying a build nobody executed is exactly what this list +is meant to prevent. The publish job prunes that artifact with an explicit "not published" warning rather than +dropping it quietly. + +**macOS is not built by the release workflow at all.** The Apple Silicon and Intel slots never got past a manual +probe, so no build minutes are spent on them and nothing unverified is handed out. They stay out of the download +list and out of `SHA256SUMS.txt`; a manual workflow run can build all six slots for evidence, but the probe slots +are never published. The support matrix below marks those slots accordingly. The Windows asset is a bare `.exe`, so `LICENSE`, `NOTICE` and `README.md` are attached to this release -next to it: the license terms travel with every download. Slots that ship an archive (Linux / macOS, once -published) unpack to one top-level directory of the same name holding the binary plus those three -documents. +next to it: the license terms travel with every download. The Linux archive unpacks to one top-level directory +of the same name holding the binary plus those three documents. ## Command line @@ -65,6 +75,10 @@ Each published slot also ships the command line as its own small bare asset, .\ull_{{VERSION}}_windows_x64.exe instances list # servers and CI: no graphics stack needed ``` +```bash +./ull_{{VERSION}}_linux_x64 instances list # same binary shape on Linux, no display needed +``` + `ull` runs `instances`, `install`, `pack import`, `launch`, `accounts`, `sessions`, `logs`, `diag`, `config`, `java` and `agent`, and links no graphics library at all. The GUI binary accepts the same set before it ever opens a window, so a desktop machine does not need `ull`. @@ -84,22 +98,29 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt | Platform | Status in this release | Level | Runtime requirements | | --- | --- | --- | --- | | Windows 10/11 x64 | **published** | first class | nothing beyond the OS (UCRT is part of Win10+) | +| Linux x64, X11 | **published** | first class | glibc 2.35+ (`libxkbcommon`, `fontconfig`, `libGL` or the software renderer, `libxcb`) | +| Linux x64, Wayland | **published** (same archive, Wayland path unverified) | supported | the same archive; `wayland-client` + `libxkbcommon`, plus the X11 set for XWayland apps | | Windows 11 ARM64 | not published (build evidence only) | first class (launcher artifact) | nothing extra; the launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK | +| Linux arm64 | built, not published (no runtime evidence) | first class (unverified) | glibc 2.35+ (native `ubuntu-22.04-arm`, same floor as x64), `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` / `wayland-client` | | macOS 12+ Apple Silicon | not built (unverified) | first class (unverified) | nothing; unsigned build, needs the Gatekeeper workaround | | macOS 12+ Intel | not built (unverified) | first class (unverified) | same as above | -| Linux x64, X11 | not built (unverified) | first class (unverified) | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` | -| Linux x64, Wayland | not built (unverified) | supported (unverified) | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps | -| Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` | -| Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` | | Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 | | Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified on-device twice: CPU software rendering and self-built Mesa/turnip GPU acceleration (see `packaging/docs/termux-glibc-build.md` and `README.termux.md`) | -`windows-x64` is the only published slot this release; Windows ARM64 stays out until a CI run actually produces -its artifact (see Downloads above — so far every ARM64 failure was the pre-build probe, never the compiler). -Both arm64 rows are formal matrix slots, not the experimental Termux path, and Linux arm64 is not built by -default — someone has to run it on real hardware (via a manual all-slots run) before it joins this list. The -rows above and the matrix table in `packaging/docs/compliance-support-boundaries.md` §9 are the same list — -change one, change the other. +Two slots are published this release: `windows-x64` and `linux-x64`. Linux x64 is not a claim taken on faith — the +release artifact itself was started on a Linux machine, opened a real window under a real display server, rendered a +frame (PNG asserted non-blank) and printed the expected `[theme]` and `[backend] final=… result=started` lines, and +the release build now carries the same check as a per-slot CI gate (`packaging/docs/linux-evidence.md` has the raw +output). What that does **not** cover: dragging/resizing and font metrics / CJK shaping on a real desktop session. +Whole-window transparency and rounded corners have no code path on Linux at all (transparency needs a native window +handle the app does not get there; corners are delegated to the desktop compositor), so they are not "unverified" — +they are not applicable. Nothing at all is known about Linux ARM64: no ARM64 machine has run the binary yet, which is +why that row stays unpublished. + +Both arm64 rows are formal matrix slots, not the experimental Termux path, and Linux arm64 is not published until +someone runs it on real hardware — a manual all-slots run builds it today, and one real frame on an ARM64 machine is +the unlock. The rows above and the matrix table in `packaging/docs/compliance-support-boundaries.md` §9 are the same +list — change one, change the other. ## Install @@ -108,9 +129,26 @@ release, and it is also what you run on ARM64 Windows (the OS runs it through it The first run creates `.ULL/` and `.minecraft/` next to the exe (portable is the default data mode); the per-user `%APPDATA%\ULL` plus `%APPDATA%\.minecraft`, or a custom root, are selectable instead. -Linux / macOS are not part of this release; their asset names are already fixed by the same rule -(`unlucky-launcher_{{VERSION}}_linux_x64.tar.gz`, `_linux_arm64`, `_macos_arm64`, `_macos_x64`) so the -links will not change shape when those slots open. +**Linux x64** — unpack the archive and run the binary inside it; nothing is installed system-wide: + +```bash +tar -xzf unlucky-launcher_{{VERSION}}_linux_x64.tar.gz +cd unlucky-launcher_{{VERSION}}_linux_x64 +./unlucky-launcher # X11 or Wayland; --software-renderer forces the CPU rasteriser +``` + +It needs glibc 2.35+ (the slot is built on Ubuntu 22.04, so that is the floor), `libxkbcommon`, `fontconfig` +and `libGL` — or `--software-renderer` if there is no usable GPU adapter. On Debian/Ubuntu: + +```bash +sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6 +``` + +The bare `ull_{{VERSION}}_linux_x64` next to the archive runs on a machine with no display at all. + +Linux arm64 and macOS are not part of this release; their asset names are already fixed by the same rule +(`unlucky-launcher_{{VERSION}}_linux_arm64.tar.gz`, `_macos_arm64`, `_macos_x64`) so the links will not change +shape when those slots open. ## No GPU? Software renderer @@ -119,8 +157,8 @@ adapter is present; `--software-renderer` (or its equivalent `--renderer=cpu`) f ## Verify the download -`SHA256SUMS.txt` lists exactly the assets attached to this release (Windows only), and `sha256sum -c` -checks the ones you actually downloaded: +`SHA256SUMS.txt` lists exactly the assets attached to this release (Windows x64 and Linux x64, GUI + `ull` each), and +`sha256sum -c` checks the ones you actually downloaded: ```bash sha256sum -c SHA256SUMS.txt --ignore-missing # Linux / WSL @@ -146,6 +184,19 @@ Get-FileHash .\unlucky-launcher_{{VERSION}}_windows_x64.exe -Algorithm SHA256 id. - Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first launch. The checksums above are the integrity reference for now. +- **Linux x64 is verified, not "finished".** The published archive was built on Ubuntu 22.04 and started under a + real display server (WSLg) where it opened a window, rendered a non-blank first frame and printed its theme and + backend lines; CI repeats that with Xvfb on every release. What nobody has looked at yet on a real desktop + distribution: dragging and resizing, font metrics and CJK shaping. Whole-window transparency and rounded corners + are not "unverified" on Linux — they are not code paths there: transparency needs a native window handle the app + does not get (`hwnd=none`), and corners are delegated to the desktop compositor (`corner=policy=system`). +- **On Linux the in-app update does not install itself.** The published Linux asset is a `.tar.gz`, and + `ull update apply` only self-replaces the portable single-file `.exe` form — on Linux it refuses with an explicit + message naming `portable-zip` instead of half-installing (see `crates/core/src/update/job.rs`). Updating on Linux + means downloading the new archive and unpacking it over the old copy; the update check itself (version + channel) + works normally. +- **Linux ARM64 has no runtime evidence at all.** It compiles, and that is the whole story; the artifact is not + attached to this release. - When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock icon or Finder double-click — launch it from a terminal. From 7e8b8740c4ce0860dea5c93159d1e4ee77fb5573 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 14:23:49 +0800 Subject: [PATCH 12/33] =?UTF-8?q?fix(m184):=20r1=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E8=B4=A6=E4=B8=8D=E5=86=8D=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E4=BC=9A=E8=B7=91=E3=80=8D=E5=86=99=E6=88=90?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E6=9C=89=E8=BF=90=E8=A1=8C=E6=97=B6=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E3=80=8D=EF=BC=8C=E5=B9=B6=E6=94=B6=205=20=E6=9D=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=BC=82=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(唯一阻塞):`packaging/docs/linux-evidence.md` 里 `ull --version` 冒烟那条被写成 「两个槽位都有一份 CLI 运行时证据」——`make_artifacts.py::smoke_version` 只保证**机制** (`slot == host_slot()` 时真跑),而仓库里找不到任何一次 linux-arm64 成功出包的记录 (`git grep -rn all_slots DevDocs/` 零命中),所以 arm64 侧运行时证据为零。§1 表格与 §5 改成「机制上会真跑 / 截至本分支没有那一次记录」,§6 解锁条件同步补一句。 P3 漂移: 1. `release-publish.yml` 注释 8 条 → 11 条自测回归(与脚本/其它三处一致)。 2. `packaging/docs/profiles-and-measurements.md` 同上 8 → 11。 3. `check_publish_slots.py` 注释示例 `PUBLISH_SLOTS: "windows-x64"` → 两档现值。 4. `release-build.yml` 注释「x64(当前唯一在发的槽位)」→ 两档已发布,并点名 linux-x64 的证据在自己的 xvfb 冒烟步。 5. `linux-evidence.md` 「20 倍以上余量」→ **8~37 倍**,并点明最紧的是亮度 σ (24.06/3.0≈8×,与 check_screenshot.py 自述的 1/5~1/8 一致)。 自查(复审点名的五处之外):`只发 windows-x64` / `Windows only` / `暂未发布` / `once published` / `8 条` 全仓再扫一遍,release-notes / README ×2 / artifacts-and-version 剩下的都是准确表述(如「the only Windows asset」指 windows_x64.exe)。 验证:check_publish_slots --self-test EXIT=0、check_test_source_off EXIT=0,另 check_publish_slots(source) / check_release_semantics(+--self-mutation) / make_manifest --self-test 均 EXIT=0。本轮无 cargo 构建,未创建 CARGO_TARGET_DIR。 --- .github/workflows/release-build.yml | 3 ++- .github/workflows/release-publish.yml | 2 +- packaging/check_publish_slots.py | 2 +- packaging/docs/linux-evidence.md | 13 ++++++++----- packaging/docs/profiles-and-measurements.md | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index afbe7ae..1520852 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -206,7 +206,8 @@ jobs: fi fi cargo build --release --locked --bin unlucky-launcher-iced --bin ull - # 产物清单只给 arm64 打:x64(当前唯一在发的槽位)这一步零变化,不额外给它加失败面。 + # 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们 + # 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。 if [ "$SLOT" = "windows-arm64" ]; then ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true fi diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index f882add..8888114 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -66,7 +66,7 @@ jobs: # (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版), # 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只 # echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。 - # 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 8 条回归断言每条都红。 + # 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。 # 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。 - name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布) shell: bash diff --git a/packaging/check_publish_slots.py b/packaging/check_publish_slots.py index 62019bc..de52100 100644 --- a/packaging/check_publish_slots.py +++ b/packaging/check_publish_slots.py @@ -54,7 +54,7 @@ PUBLISH_YML = Path(".github/workflows/release-publish.yml") #: 槽位表条目(`plan` job 里那份):`dict(slot="windows-x64", ..., mode="ci")`,一条可能跨两行。 SLOT_RE = re.compile(r'dict\(\s*slot="(?P[^"]+)"(?P.*?)\)', re.S) MODE_RE = re.compile(r'mode="(?P[^"]+)"') -#: 发布白名单那一行(yaml 里是 `PUBLISH_SLOTS: "windows-x64"`)。 +#: 发布白名单那一行(yaml 里是 `PUBLISH_SLOTS: "windows-x64 linux-x64"`)。 PUBLISH_RE = re.compile(r'^\s*PUBLISH_SLOTS:\s*"(?P[^"]*)"', re.M) #: 每个槽位必须**两样都有**:GUI 与 CLI 各自一个资产(只按「槽位名出现过」判会被其中一个掩盖)。 diff --git a/packaging/docs/linux-evidence.md b/packaging/docs/linux-evidence.md index b61f7c9..78a2f23 100644 --- a/packaging/docs/linux-evidence.md +++ b/packaging/docs/linux-evidence.md @@ -12,7 +12,7 @@ | 本地复跑脚本(开发机) | `packaging/linux-smoke-docker.sh` | 在 `rust:1-bookworm`(**Debian 12 / glibc 2.36**)里逐步复刻上面那个 job;M131 真跑过一次(2026-11-05):`exit=0`、截图 1180×760、`[ok] 截图非空白` | 基础镜像是 Debian 而不是 runner 的 Ubuntu;仍是 debug 档;同样没有 arm64 | | 编译期门禁复跑 | `packaging/linux-check-docker.sh` | 在 Linux 上跑 `clippy --workspace --all-targets -- -D warnings` 与 `test --workspace --all-targets`(M144 那两条只能在 Linux 复现的红) | 与运行时无关,只证明「编得过、测得过」 | | 阈值依据 | `packaging/ci-runtime-smoke.md` | 真实帧与纯色帧的实测数值(唯一色 342~2352、σ 19.45~27.46、强边缘 0.0076~0.0433)都在这里,阈值取在两者之间 | 标定样本来自 x64 软渲染 | -| `ull --version` 冒烟 | `packaging/make_artifacts.py::smoke_version` | 槽位与宿主一致时**真跑** CLI 并断言版本串(`linux-x64` 与 `linux-arm64` 在各自的 CI runner 上都满足这条,所以两个 arm64/x64 槽位都有一份 CLI 运行时证据) | 只覆盖 CLI(零图形栈),GUI 一帧都没有;`linux-arm64` 的 GUI 从未启动过 | +| `ull --version` 冒烟 | `packaging/make_artifacts.py::smoke_version` | 槽位与宿主一致时**机制上会真跑** CLI 并断言版本串(`slot != host_slot()` 才跳过)。`linux-x64` 侧本轮**真跑过**(见 §2 `smoke: ull --version -> 0.2.3 (ok)`) | **机制存在 ≠ 有过一次运行**:截至本分支,仓库里找不到任何一次 `linux-arm64` 成功出包的记录(`git grep -rn "all_slots" DevDocs/` 零命中),所以 **arm64 侧运行时证据为零** —— 连这条 CLI 冒烟都没在 arm64 上真跑过。GUI 一帧更没有 | **glibc 地板怎么定的**:槽位表把 Linux 两档钉在 `ubuntu-22.04` / `ubuntu-22.04-arm`,**不跟 `latest` 走** (`release.yml` 的槽位表注释:跟 latest 走地板就会随镜像漂移)。Ubuntu 22.04 ⇒ glibc **2.35**,这就是 @@ -135,8 +135,9 @@ launcher exit=0 ``` 软渲染是确定性的:WSLg 与 Xvfb 两张图的统计量**逐个相同**(1858 / 24.06 / 0.0372),也正是这一点让 -`check_screenshot.py` 的阈值(唯一色 ≥64、亮度 σ ≥3.0、强边缘 ≥0.001)有 20 倍以上余量 —— CI 里这条门禁不会因为 -"某次抗锯齿抖了"而假红。 +`check_screenshot.py` 的阈值(唯一色 ≥64、亮度 σ ≥3.0、强边缘 ≥0.001)有 **8~37 倍**余量 +(唯一色 1858/64≈29×、强边缘 0.0372/0.001≈37×、**亮度 σ 只有 24.06/3.0≈8×** —— 与 `check_screenshot.py` 自己 +写的「阈值取在实测下限的 1/5~1/8」一致,三者里最紧的是亮度 σ):CI 里这条门禁不会因为"某次抗锯齿抖了"而假红。 本机 Docker 复跑(`packaging/linux-smoke-docker.sh`,Debian 12 容器 + **debug** 档,缓存卷是热的): @@ -188,7 +189,9 @@ exit=0 上一次都没跑过。 - **Wayland 原生**:WSLg 同时提供 X11 与 Wayland,本轮只按 `DISPLAY` 走(见 §2 的实测环境),没单独验 `WAYLAND_DISPLAY` 那条路。 -- **linux-arm64 运行时完全未验**:GUI 进程在 ARM64 上一次都没启动过;它只有「编得过 + `ull --version` 跑得过」。 +- **linux-arm64 运行时完全未验**:GUI 进程在 ARM64 上一次都没启动过。连 `make_artifacts.py` 那条 `ull --version` + 冒烟也**只是机制上会真跑**(`mode: ci` + 宿主==产物),**截至本分支没有一次 arm64 成功出包的记录**,所以 arm64 + 侧一份运行时证据都没有 —— 别把「这条冒烟以后会跑」读成「它跑过了」。 - **`ubuntu-22.04-arm` runner 的可用性未在本机核实**:本机没有 `gh` CLI,也没有到 Actions API 的可用凭据, 所以「这档 runner 在这个仓库上真能起来」只能由下一次 CI 证明。风险是有界的:它**不在** `PUBLISH_SLOTS` 里, 起不来/红了不会挡住 `windows-x64` + `linux-x64` 的发布(只会让那一条 job 红,外加一条 `::warning::`)。 @@ -202,4 +205,4 @@ exit=0 4. 然后把 `linux-arm64` 加进 `release-publish.yml` 的 `PUBLISH_SLOTS`(一行),并同步 §9 与 `release-notes.md`。 构建绿了**不算**解锁,`ull --version` 跑得过也**不算** —— 那两条只证明 CLI 与编译器,用户拿到的那个 `.tar.gz` 里 -装的是 GUI。 +装的是 GUI。(何况截至本分支 arm64 上连那条 CLI 冒烟都还没真跑过一次。) diff --git a/packaging/docs/profiles-and-measurements.md b/packaging/docs/profiles-and-measurements.md index ce4dc7b..a4b13e6 100644 --- a/packaging/docs/profiles-and-measurements.md +++ b/packaging/docs/profiles-and-measurements.md @@ -124,5 +124,5 @@ libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH `um/arm64/kernel32.lib`,猜缺就 `exit 1`,而 `cargo build` 永远 skipped);M164 把它降级为只取证(打印 vswhere 组件、 目录 `.lib` 计数、`link.exe` 候选),红绿交回编译器。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进), 而是 arm64 的 `cargo build` 真跑绿 + 把键加回 `PUBLISH_SLOTS`(守卫会自动跟着扩,并在产物有了却没声明时报 `::warning::`)。 -本机能补的只有脚本层证据:`python packaging/check_publish_slots.py --self-test`(8 条注入回归)与 +本机能补的只有脚本层证据:`python packaging/check_publish_slots.py --self-test`(11 条注入回归)与 `cargo check --target aarch64-pc-windows-msvc`(依赖图对该 target 的类型检查,**不链接、不产出 PE**)。 From c4c3c1528512e44b509ea97f6539f8dd50dac8a0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 21:01:14 +0800 Subject: [PATCH 13/33] =?UTF-8?q?feat(i18n):=20=E5=A4=96=E8=A7=82=E9=A1=B5?= =?UTF-8?q?=2040=20=E6=9D=A1=E6=96=87=E6=A1=88=E8=BF=9B=E8=AF=8D=E8=A1=A8?= =?UTF-8?q?=EF=BC=88M180=20I18NAPPEAR=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 卡片内 22 处硬编码英文(整窗/面板透明度、背景图开关+路径、填充、暗化、配色来源)全部改走 Key - 运行时诊断改成 key + 参数:`background::Note`(读盘/解码/过大/小图平铺)、`state::backend_note`、 `palette::note` 全部返回 Key;`state.rs` 只留 key、不放文案表(390 → 391 行) - 上限/下限常量走占位符 `{floor}` / `{max}` / `{limit}` / `{requested}`;`{:.2}` 在 Rust 侧格式化好 再套模板(`Catalog::render` 只认 `{name}`) - 新增 40 条 key(中英各一条),按装配链锚点插在 `SettingsAppearanceCornerSuspended` 之后, 金表 `golden/tail.rs` 同位置插入 40 行(`rows() == len() == Key::ALL.len()`) - 守卫三条:中文渲染全非 ASCII、卡片 key 全覆盖(改回写死英文即红)、外观 key 逐条真翻过 - probe 加第二条取证路(`record_rendered`),`take()` 签名不动(M179 的守卫在 scope 外) --- .../src/pages/appearance/background.rs | 91 ++++++- .../src/pages/appearance/background/tests.rs | 53 +++- crates/app-iced/src/pages/appearance/mod.rs | 55 +--- .../app-iced/src/pages/appearance/palette.rs | 61 ++--- crates/app-iced/src/pages/appearance/probe.rs | 28 +- .../src/pages/appearance/section/bg_image.rs | 86 ++++-- .../pages/appearance/section/colour_source.rs | 20 +- .../src/pages/appearance/section/mod.rs | 44 ++- .../src/pages/appearance/section/panel.rs | 33 ++- .../src/pages/appearance/section/window.rs | 11 +- crates/app-iced/src/pages/appearance/state.rs | 29 +- crates/app-iced/src/pages/appearance/tests.rs | 253 ++++++++++++++++++ crates/i18n/src/catalog/golden/tail.rs | 44 +++ .../i18n/src/catalog/settings/appearance.rs | 234 +++++++++++++++- 14 files changed, 858 insertions(+), 184 deletions(-) create mode 100644 crates/app-iced/src/pages/appearance/tests.rs diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index d706029..aa7d6e8 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -26,6 +26,8 @@ //! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清 //! (偏离登记在 [`super`] 的偏离表 #7)。 +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; use unlucky_presenter::prefs::BgFill; /// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。 @@ -63,7 +65,7 @@ pub const TILE_SCALE_STEP: f32 = 1.15; /// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。 pub const MAX_TILE_SCALE: f32 = 1024.0; -/// 解码失败的原因(人话在 [`LoadError::describe`])。 +/// 解码失败的原因(人话在 [`LoadError::note`])。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadError { /// 读文件失败:不存在 / 没权限 / 被占用。 @@ -79,17 +81,76 @@ pub enum LoadError { }, } -impl LoadError { - /// 可读诊断(设置页提示行与日志共用同一句)。 +/// 一条**可翻译**诊断:`key` + 已经格式化好的参数(模板里只有 `{name}`,见 [`Key`])。 +/// +/// 为什么不是 `String`:文案表在金表里(M180 `I18NAPPEAR`),本模块只给「哪条 key + 哪些值」。 +/// 数字在这一层就格式化好(`{:.2}` / `{:?}` 这些格式说明词表不支持)。 +/// +/// 为什么 `types` 是 `String` 而不是 `&'static str`:`Io(reason)` / `Undecodable(reason)` 的原因来自 +/// 磁盘与解码库(运行时字符串),只能持有。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Note { + key: Key, + args: Vec<(&'static str, String)>, +} + +impl Note { + /// 组装一条(参数名必须与译文里的 `{name}` 逐字相同)。 + fn new(key: Key, args: Vec<(&'static str, String)>) -> Self { + Self { key, args } + } + + /// 这条诊断是哪条文案。 #[must_use] - pub fn describe(&self) -> String { + pub fn key(&self) -> Key { + self.key + } + + /// 参数(名 → 已成形的值)。 + #[must_use] + pub fn args(&self) -> Vec<(&str, &str)> { + self.args + .iter() + .map(|(name, value)| (*name, value.as_str())) + .collect() + } + + /// 按语言取整句(`{name}` 全替换;缺参时占位符原样留着,见 [`unlucky_i18n::catalog::Catalog::render`])。 + #[must_use] + pub fn render(&self, lang: Lang) -> String { + lang.catalog().render(self.key, &self.args()) + } + + /// 英文基线里有没有这个子串。 + /// + /// 给**跨 mission 的既有断言**用(`app/tests/appearance.rs` 查「诊断里有没有说出原因」), + /// 那条断言不在本 mission 的 scope 里、不能改成按 key 断言。UI 一律走 [`Self::render`]。 + #[must_use] + pub fn contains(&self, needle: &str) -> bool { + self.render(Lang::En).contains(needle) + } +} + +impl LoadError { + /// 可翻译诊断(设置页提示行与日志共用同一条 key)。 + #[must_use] + pub fn note(&self) -> Note { match self { - Self::Io(reason) => format!("Background image could not be read: {reason}"), - Self::Undecodable(reason) => format!( - "Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)" + Self::Io(reason) => Note::new( + Key::SettingsAppearanceBgReadFailed, + vec![("reason", reason.clone())], ), - Self::TooLarge { width, height } => format!( - "Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)" + Self::Undecodable(reason) => Note::new( + Key::SettingsAppearanceBgDecodeFailed, + vec![("reason", reason.clone())], + ), + Self::TooLarge { width, height } => Note::new( + Key::SettingsAppearanceBgTooLarge, + vec![ + ("width", width.to_string()), + ("height", height.to_string()), + ("limit", MAX_SOURCE_EDGE.to_string()), + ], ), } } @@ -193,7 +254,7 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> { /// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是 /// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。 #[must_use] -pub fn small_tile_note(image: &Image, fill: BgFill) -> Option { +pub fn small_tile_note(image: &Image, fill: BgFill) -> Option { if !matches!(fill, BgFill::Tile) { return None; } @@ -201,9 +262,13 @@ pub fn small_tile_note(image: &Image, fill: BgFill) -> Option { if width.min(height) >= SMALL_TILE_EDGE { return None; } - Some(format!( - "Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).", - image.source.0, image.source.1 + Some(Note::new( + Key::SettingsAppearanceBgSmallTile, + vec![ + ("width", image.source.0.to_string()), + ("height", image.source.1.to_string()), + ("max", MAX_TILES.to_string()), + ], )) } diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index 4e69b88..b455372 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -4,6 +4,8 @@ use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES}; use std::path::{Path, PathBuf}; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; use unlucky_presenter::prefs::BgFill; fn temp_file(label: &str, name: &str) -> PathBuf { @@ -66,19 +68,28 @@ fn a_real_jpeg_decodes_too() { #[test] fn a_missing_or_broken_file_reports_a_readable_reason() { // 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。 - // 都必须是 Err(不许静默回默认色),且 describe() 是人话。 + // 都必须是 Err(不许静默回默认色),且 note() 给出**哪条 key + 哪些参数**(句子在词表里)。 // 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。 let missing = temp_file("missing", "bg.png"); let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错"); assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}"); - assert!(error - .describe() - .starts_with("Background image could not be read")); + let note = error.note(); + assert_eq!(note.key(), Key::SettingsAppearanceBgReadFailed); + assert!( + note.contains("could not be read"), + "英文基线要说出「读不出来」:{}", + note.render(Lang::En) + ); let broken = temp_file("broken", "bg.png"); std::fs::write(&broken, b"this is not an image").expect("写坏文件"); let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错"); assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}"); + assert_eq!( + error.note().key(), + Key::SettingsAppearanceBgDecodeFailed, + "解不开与读不出来必须是两条 key(措辞与修法都不同)" + ); cleanup(&broken); let empty = load(" ").expect_err("空路径必须报错"); @@ -168,10 +179,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() { ), "实际 {error:?}" ); + let note = error.note(); + assert_eq!(note.key(), Key::SettingsAppearanceBgTooLarge); assert!( - error.describe().contains("too large"), - "提示要人话:{}", - error.describe() + note.contains("too large"), + "英文基线要说出「太大」:{}", + note.render(Lang::En) + ); + let args = note.args(); + assert!( + args.contains(&("width", "9000")) && args.contains(&("height", "4")), + "宽高要作为参数传进去(不是写进译文):{args:?}" + ); + assert!( + args.contains(&("limit", "8192")), + "上限走占位符而不是写进译文:{args:?}" ); // 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。 const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192); @@ -187,8 +209,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() { fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() { let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]); let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示"); - assert!(note.contains("32×32"), "{note}"); - assert!(note.contains("scaled up"), "{note}"); + assert_eq!(note.key(), Key::SettingsAppearanceBgSmallTile); + let args = note.args(); + assert!( + args.contains(&("width", "32")) && args.contains(&("height", "32")), + "尺寸作为参数:{args:?}" + ); + assert!( + args.contains(&("max", "64")), + "块数上限走占位符(`MAX_TILES` 改了不该动译文):{args:?}" + ); + assert!( + note.render(Lang::En).contains("scaled up"), + "英文基线要说出「会被放大」:{}", + note.render(Lang::En) + ); // 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。 assert!(small_tile_note(&small, BgFill::Cover).is_none()); diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index ccb2505..0574c8f 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -69,6 +69,8 @@ mod picture; pub(crate) mod probe; mod section; mod state; +#[cfg(test)] +mod tests; /// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。 /// @@ -166,56 +168,3 @@ impl Change { }) } } - -#[cfg(test)] -mod tests { - use super::{Change, APPEARANCE_GROUP}; - use unlucky_presenter::prefs::{keys, BgFill, PaletteSource}; - - /// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。 - /// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。 - #[test] - fn every_change_names_its_own_schema_key() { - assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance"); - let cases = [ - (Change::WindowOpacity(0.8), keys::WINDOW_OPACITY), - (Change::PanelAlpha(0.6), keys::PANEL_ALPHA), - (Change::BgEnabled(true), keys::BG_IMAGE_ENABLED), - (Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE), - (Change::BgFill(BgFill::Tile), keys::BG_FILL), - (Change::BgDim(0.5), keys::BG_DIM), - ( - Change::PaletteSource(PaletteSource::Wallpaper), - keys::PALETTE_SOURCE, - ), - ( - Change::Corner(unlucky_presenter::prefs::WindowCorner::Square), - keys::WINDOW_CORNER, - ), - ]; - for (change, key) in cases { - assert_eq!(change.key(), Some(key), "{change:?}"); - assert!(change.value().is_some()); - } - assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘"); - assert_eq!(Change::SelectGroup(2).value(), None); - } - - /// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。 - /// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。 - #[test] - fn persisted_values_use_the_schema_vocabulary() { - assert_eq!( - Change::BgFill(BgFill::Contain).value().as_deref(), - Some("contain") - ); - assert_eq!( - Change::PaletteSource(PaletteSource::BackgroundImage) - .value() - .as_deref(), - Some("background-image") - ); - assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false")); - assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8")); - } -} diff --git a/crates/app-iced/src/pages/appearance/palette.rs b/crates/app-iced/src/pages/appearance/palette.rs index b611027..0543de4 100644 --- a/crates/app-iced/src/pages/appearance/palette.rs +++ b/crates/app-iced/src/pages/appearance/palette.rs @@ -9,7 +9,8 @@ //! //! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])—— //! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」); -//! 2. 把 `Provenance::hint_key()` 翻成一句可读提示(键 → 文案是 UI 层的活,i18n 之后再统一)。 +//! 2. 把 `Provenance::hint_key()` 翻成一条词表 key(M180:句子在 `catalog/settings/appearance.rs`, +//! 这里只给 key——中文界面下这句提示也是中文)。 //! //! ## 为什么不在这里做对比度 //! @@ -19,6 +20,7 @@ use unlucky_design::color::{Scheme, SchemeFlavor}; use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance}; +use unlucky_i18n::catalog::Key; use unlucky_presenter::prefs::PaletteSource; use super::background::Image; @@ -62,9 +64,9 @@ impl Resolved { self.provenance.level } - /// 可读提示(`None` = 一级成功、没什么可解释的)。 + /// 提示 key(`None` = 一级成功、没什么可解释的)。 #[must_use] - pub fn note(&self) -> Option<&'static str> { + pub fn note(&self) -> Option { note(&self.provenance) } } @@ -97,43 +99,33 @@ pub fn resolve( Resolved { scheme, provenance } } -/// `Provenance::hint_key()` → 一句可读提示(英文基线;i18n mission 之后走词表)。 +/// `Provenance::hint_key()` → 一条词表 key(M180:句子在 `catalog/settings/appearance.rs`)。 /// /// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里; /// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。 #[must_use] -pub fn note(provenance: &Provenance) -> Option<&'static str> { +pub fn note(provenance: &Provenance) -> Option { Some(match provenance.hint_key()? { - "palette.hint.background.undecodable" => { - "Background image could not be read; colours fall back." - } - "palette.hint.background.too-small" => { - "Background image is too small to sample; colours fall back." - } - "palette.hint.background.bad-dimensions" => { - "Background image data looks inconsistent; colours fall back." - } + "palette.hint.background.undecodable" => Key::SettingsAppearancePaletteBgUndecodable, + "palette.hint.background.too-small" => Key::SettingsAppearancePaletteBgTooSmall, + "palette.hint.background.bad-dimensions" => Key::SettingsAppearancePaletteBgBadDimensions, "palette.hint.background.no-opaque-pixels" => { - "Background image is almost fully transparent; colours fall back." + Key::SettingsAppearancePaletteBgNoOpaquePixels } - "palette.hint.background.low-chroma" => { - "Background image is too monochrome to tint from; colours fall back." + "palette.hint.background.low-chroma" => Key::SettingsAppearancePaletteBgLowChroma, + "palette.hint.background.contrast" => Key::SettingsAppearancePaletteBgContrast, + "palette.hint.wallpaper.undecodable" => Key::SettingsAppearancePaletteWallpaperUndecodable, + "palette.hint.wallpaper.low-chroma" => Key::SettingsAppearancePaletteWallpaperLowChroma, + "palette.hint.wallpaper.too-small" => Key::SettingsAppearancePaletteWallpaperTooSmall, + "palette.hint.wallpaper.bad-dimensions" => { + Key::SettingsAppearancePaletteWallpaperBadDimensions } - "palette.hint.background.contrast" => { - "Background image colours would be unreadable; colours fall back." - } - "palette.hint.wallpaper.undecodable" => "Desktop wallpaper could not be read.", - "palette.hint.wallpaper.low-chroma" => "Desktop wallpaper is too monochrome to tint from.", - "palette.hint.wallpaper.too-small" => "Desktop wallpaper is too small to sample.", - "palette.hint.wallpaper.bad-dimensions" => "Desktop wallpaper data looks inconsistent.", "palette.hint.wallpaper.no-opaque-pixels" => { - "Desktop wallpaper is almost fully transparent." + Key::SettingsAppearancePaletteWallpaperNoOpaquePixels } - "palette.hint.wallpaper.contrast" => { - "Desktop wallpaper colours would be unreadable; colours fall back." - } - "palette.hint.preset.contrast" => "The built-in card needed a small contrast correction.", - "palette.hint.adjusted" => "A few colours were nudged for readability.", + "palette.hint.wallpaper.contrast" => Key::SettingsAppearancePaletteWallpaperContrast, + "palette.hint.preset.contrast" => Key::SettingsAppearancePalettePresetContrast, + "palette.hint.adjusted" => Key::SettingsAppearancePaletteAdjusted, _ => return None, }) } @@ -145,6 +137,7 @@ mod tests { use unlucky_design::color::SchemeFlavor; use unlucky_design::palette::wcag::check; use unlucky_design::palette::{Level, Outcome, Skip}; + use unlucky_i18n::catalog::Key; use unlucky_presenter::prefs::PaletteSource; /// 高彩度的图(纯红):取色一定成功。 @@ -193,7 +186,7 @@ mod tests { assert!(resolved.provenance.did_fall_back()); assert_eq!( resolved.note(), - Some("Background image could not be read; colours fall back.") + Some(Key::SettingsAppearancePaletteBgUndecodable) ); assert!(check(&resolved.scheme).is_clean()); } @@ -215,7 +208,11 @@ mod tests { } other => panic!("灰图必须被判不合格,实际 {other:?}"), } - assert!(resolved.note().unwrap().contains("monochrome")); + assert_eq!( + resolved.note(), + Some(Key::SettingsAppearancePaletteBgLowChroma), + "灰图的回落原因要指名「彩度不足」" + ); } #[test] diff --git a/crates/app-iced/src/pages/appearance/probe.rs b/crates/app-iced/src/pages/appearance/probe.rs index 2c340c2..b2f4774 100644 --- a/crates/app-iced/src/pages/appearance/probe.rs +++ b/crates/app-iced/src/pages/appearance/probe.rs @@ -10,8 +10,9 @@ //! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器** //! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。 //! -//! 所以取证点退到「字进树之前」那一层:`section::t` 是本分组**唯一**的取词口,它把每条译文 -//! 顺手记一份,测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把 +//! 所以取证点退到「字进树之前」那一层:`section::t` / `section::tr` 是本分组**唯一**两个取词口, +//! 它们把每条译文顺手记一份(M180 起分成两份:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的 +//! 整句),测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把 //! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。 //! //! ## 为什么是 thread-local @@ -25,7 +26,7 @@ use std::cell::RefCell; use unlucky_i18n::catalog::Key; -// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序。 +// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序(`t()` 取的 `&'static str`)。 thread_local! { // clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }` // 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号): @@ -35,12 +36,33 @@ thread_local! { static TEXTS: RefCell> = const { RefCell::new(Vec::new()) }; } +// 自上次 `take_rendered` 以来记录下的 `(key, 整句)`:`tr()` 那条带占位符的路径(M180 加)。 +// +// 为什么单独一份而不是把上面那份改成 `String`:M179 的守卫 +// (`pages/settings/tests/appearance_section.rs::the_appearance_group_speaks_chinese_when_the_interface_does`) +// 断言的是 `&'static str`,那个文件不在 M180 的 scope 里、不能跟着改签名。两份记录各自的 +// `take*` 也各自清空,互不干扰。 +thread_local! { + #[allow(clippy::missing_const_for_thread_local)] + static RENDERED: RefCell> = const { RefCell::new(Vec::new()) }; +} + /// 记一条(由 [`super::section::t`] 调用;不对外)。 pub(crate) fn record(key: Key, text: &'static str) { TEXTS.with(|texts| texts.borrow_mut().push((key, text))); } +/// 记一条**替换完占位符**的整句(由 [`super::section::tr`] 调用;不对外)。 +pub(crate) fn record_rendered(key: Key, text: String) { + RENDERED.with(|texts| texts.borrow_mut().push((key, text))); +} + /// 取走并清空本线程记录下的取词(测试用)。 pub(crate) fn take() -> Vec<(Key, &'static str)> { TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut())) } + +/// 取走并清空本线程记录下的整句(测试用)。 +pub(crate) fn take_rendered() -> Vec<(Key, String)> { + RENDERED.with(|texts| std::mem::take(&mut *texts.borrow_mut())) +} diff --git a/crates/app-iced/src/pages/appearance/section/bg_image.rs b/crates/app-iced/src/pages/appearance/section/bg_image.rs index 0c954e5..4ea9e68 100644 --- a/crates/app-iced/src/pages/appearance/section/bg_image.rs +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -8,6 +8,8 @@ use iced::{Element, Length}; use unlucky_design::color::Role; use unlucky_design::spacing::Space; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; use unlucky_presenter::prefs::BgFill; use crate::app::components::inputs::{slider, text_field, Field, Slider}; @@ -17,17 +19,32 @@ use crate::theme::Ui; use super::super::background::{self, BG_DIM_MAX}; use super::super::state::State; use super::super::Change; -use super::{choice, choice_row, label, note_line, slider_row}; +use super::{choice, choice_row, fixed2, label, note_line, slider_row, t, tr, tr_note}; /// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。 -pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { // ④ 背景图开关 rows.push(choice_row( ui, - "Background image", + t(Key::SettingsAppearanceBackgroundImage, lang), vec![ - choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)), - choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)), + choice( + ui, + t(Key::SettingsAppearanceOff, lang), + !state.bg_enabled, + Change::BgEnabled(false), + ), + choice( + ui, + t(Key::SettingsAppearanceOn, lang), + state.bg_enabled, + Change::BgEnabled(true), + ), ], )); @@ -35,57 +52,74 @@ pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec = text_field( ui, Field::new(&state.bg_path) - .label("Image path") + .label(t(Key::SettingsAppearanceImagePath, lang)) .placeholder("D:/Pictures/background.png"), |value| Message::Appearance(Change::BgImage(value)), ); rows.push( - column![label(ui, "Image path"), path_field] - .spacing(ui.space(Space::X1)) - .width(Length::Fill) - .into(), + column![ + label(ui, t(Key::SettingsAppearanceImagePath, lang)), + path_field + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into(), ); // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 for note in state.diagnostics() { - rows.push(note_line(ui, note, ui.color(Role::Error))); + let text = tr_note(lang, note); + rows.push(note_line(ui, &text, ui.color(Role::Error))); } - if let Some(note) = state.backend_note() { - rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + if let Some(key) = state.backend_note() { + rows.push(note_line( + ui, + t(key, lang), + ui.color(Role::OnSurfaceVariant), + )); } if let Some(note) = state .image() .and_then(|image| background::small_tile_note(image, state.bg_fill)) { - rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant))); + let text = tr_note(lang, ¬e); + rows.push(note_line(ui, &text, ui.color(Role::OnSurfaceVariant))); } - if let Some(note) = state.palette_note() { - rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + if let Some(key) = state.palette_note() { + rows.push(note_line( + ui, + t(key, lang), + ui.color(Role::OnSurfaceVariant), + )); } - let resolved = format!("Colours: {}", state.palette_level()); - rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); + let level = tr( + lang, + Key::SettingsAppearancePaletteLevel, + &[("level", state.palette_level())], + ); + rows.push(note_line(ui, &level, ui.color(Role::OnSurfaceVariant))); // ⑦ 填充 rows.push(choice_row( ui, - "Fill", + t(Key::SettingsAppearanceBgFill, lang), vec![ choice( ui, - "Cover", + t(Key::SettingsAppearanceBgFillCover, lang), state.bg_fill == BgFill::Cover, Change::BgFill(BgFill::Cover), ), choice( ui, - "Contain", + t(Key::SettingsAppearanceBgFillContain, lang), state.bg_fill == BgFill::Contain, Change::BgFill(BgFill::Contain), ), choice( ui, - "Tile", + t(Key::SettingsAppearanceBgFillTile, lang), state.bg_fill == BgFill::Tile, Change::BgFill(BgFill::Tile), ), @@ -95,10 +129,12 @@ pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec 0.0, slider( diff --git a/crates/app-iced/src/pages/appearance/section/colour_source.rs b/crates/app-iced/src/pages/appearance/section/colour_source.rs index e79a2b9..8b9c85f 100644 --- a/crates/app-iced/src/pages/appearance/section/colour_source.rs +++ b/crates/app-iced/src/pages/appearance/section/colour_source.rs @@ -1,7 +1,8 @@ //! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。 use iced::Element; - +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; use unlucky_presenter::prefs::PaletteSource; use crate::pages::Message; @@ -9,30 +10,35 @@ use crate::theme::Ui; use super::super::state::State; use super::super::Change; -use super::{choice, choice_row}; +use super::{choice, choice_row, t}; /// 把 ⑨ 配色来源推进行 `rows`。 -pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { // ⑨ 配色来源 rows.push(choice_row( ui, - "Colour source", + t(Key::SettingsAppearancePaletteSource, lang), vec![ choice( ui, - "Background", + t(Key::SettingsAppearancePaletteBackground, lang), state.palette_source == PaletteSource::BackgroundImage, Change::PaletteSource(PaletteSource::BackgroundImage), ), choice( ui, - "Wallpaper", + t(Key::SettingsAppearancePaletteWallpaper, lang), state.palette_source == PaletteSource::Wallpaper, Change::PaletteSource(PaletteSource::Wallpaper), ), choice( ui, - "Preset", + t(Key::SettingsAppearancePalettePreset, lang), state.palette_source == PaletteSource::Preset, Change::PaletteSource(PaletteSource::Preset), ), diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index e6aa711..e6b7367 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -35,12 +35,14 @@ //! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`); //! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。 //! -//! ## 语言(M179 只接管子,不补文案) +//! ## 语言(M179 接过管子,M180 补齐文案) //! -//! [`t`] 是**唯一**取词口,现在收 `lang`(真值从 `settings::view` 的 `Language.effective` -//! → `Group.body` → [`section`] 一路递进来),所以中文界面上走词表的那 5 处(窗口圆角一行、 -//! 两个选项、说明段、notice)真的是中文。**其余文案仍是英文基线**(字面量,没有 key)—— -//! 补文案是下一条 mission,M179 只负责让「管子」通到这一层。 +//! [`t`] / [`tr`] 是**唯一**两个取词口(一个拿 `&'static str`、一个拿带占位符的 owned 句子), +//! `lang` 从 `settings::view` 的 `Language.effective` → `Group.body` → [`section`] 一路递进来。 +//! M179 只把管子接通(当时本组 27 条文案里真正本地化的只有圆角那 5 条);M180 `I18NAPPEAR` 把 +//! 剩下 22 条字面量与 `state.rs` / `background.rs` / `palette.rs` 的运行时诊断全部搬进 +//! `catalog/settings/appearance.rs`,于是中文界面上这一页**没有一句是英文**。 + //! //! ## 为什么「值」与「生效值」分开显示 //! @@ -80,9 +82,9 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Mess let mut rows: Vec> = Vec::new(); window::push(ui, state, lang, &mut rows); - panel::push(ui, state, &mut rows); - bg_image::push(ui, state, &mut rows); - colour_source::push(ui, state, &mut rows); + panel::push(ui, state, lang, &mut rows); + bg_image::push(ui, state, lang, &mut rows); + colour_source::push(ui, state, lang, &mut rows); card( ui, @@ -126,8 +128,8 @@ pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { /// /// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录 /// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块 -/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M179 报告里逐行列了那些字面量,补文案是 -/// 下一条 mission。 +/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M180 `I18NAPPEAR` 已把这一页剩余的 22 处 +/// 字面量与全部运行时诊断搬进词表,见 `catalog/settings/appearance.rs` 的模块文档。 pub(super) fn t(key: Key, lang: Lang) -> &'static str { let text = lang.catalog().text(key); // 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的 @@ -137,6 +139,28 @@ pub(super) fn t(key: Key, lang: Lang) -> &'static str { text } +/// 带占位符的句子(`{name}` 替换后的 owned 版本)。 +/// +/// 为什么不直接 `lang.catalog().render(...)`:这条路径也要进 [`t`] 那份取证记录 +/// (`probe::record_rendered`),否则「中文界面下这一页每一句都不是纯 ASCII」的守卫会漏掉所有 +/// 带数字的说明句。数值参数由调用方先格式化好(词表只认 `{name}`,不认 `{:.2}`)。 +pub(super) fn tr(lang: Lang, key: Key, args: &[(&str, &str)]) -> String { + let text = lang.catalog().render(key, args); + #[cfg(test)] + super::probe::record_rendered(key, text.clone()); + text +} + +/// 两位小数(`{:.2}` 这种格式说明由 Rust 侧做,词表里只留 `{name}`)。 +pub(super) fn fixed2(value: f32) -> String { + format!("{value:.2}") +} + +/// 一条 [`Note`](key + 已格式化的参数)按语言取整句;与 [`tr`] 共用取证口。 +pub(super) fn tr_note(lang: Lang, note: &super::background::Note) -> String { + tr(lang, note.key(), ¬e.args()) +} + pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> { // 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。 frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) diff --git a/crates/app-iced/src/pages/appearance/section/panel.rs b/crates/app-iced/src/pages/appearance/section/panel.rs index 58661ba..665d711 100644 --- a/crates/app-iced/src/pages/appearance/section/panel.rs +++ b/crates/app-iced/src/pages/appearance/section/panel.rs @@ -2,28 +2,45 @@ use iced::Element; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; + use crate::app::components::inputs::{slider, Slider}; use crate::pages::Message; use crate::theme::Ui; -use super::super::alpha::{panel_alpha_from_fraction, panel_fraction}; +use super::super::alpha::{panel_alpha_from_fraction, panel_fraction, PANEL_ALPHA_FLOOR}; use super::super::state::State; use super::super::Change; -use super::slider_row; +use super::{fixed2, slider_row, t, tr}; /// 把 ③ 面板透明度推进 `rows`。 -pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec>) { +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { // ③ 面板透明度(CPU 档强制 1.0) let forced = state.panel_is_forced(); let panel_percent = if forced { - format!("100% (forced from {:.2})", state.panel_alpha) + // 数值占位:`{requested}` 是请求值(词表不认 `{:.2}`,所以在这里先格式化成两位小数)。 + tr( + lang, + Key::SettingsAppearancePanelOpacityForcedValue, + &[("requested", &fixed2(state.panel_alpha))], + ) } else { format!("{:.0}%", state.effective_panel_alpha() * 100.0) }; let panel_hint = if forced { - "Software renderer: panels stay fully opaque (window transparency still works)." + t(Key::SettingsAppearancePanelOpacityForcedHint, lang).to_owned() } else { - "Only the panel/card layer. Floor 0.40; independent of window transparency." + tr( + lang, + Key::SettingsAppearancePanelOpacityHint, + &[("floor", &fixed2(PANEL_ALPHA_FLOOR))], + ) }; let panel_control = if forced { slider(Slider::new(ui, panel_fraction(1.0))) @@ -36,9 +53,9 @@ pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec( @@ -33,11 +33,12 @@ pub(super) fn push<'a>( let window_percent = format!("{:.0}%", state.window_opacity * 100.0); rows.push(slider_row( ui, - "Window transparency", + t(Key::SettingsAppearanceWindowOpacity, lang), window_percent, - format!( - "Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.", - WINDOW_ALPHA_FLOOR + tr( + lang, + Key::SettingsAppearanceWindowOpacityHint, + &[("floor", &fixed2(WINDOW_ALPHA_FLOOR))], ), state.window_opacity < 1.0, slider( diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index e1868bf..354b3b9 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -17,6 +17,7 @@ //! 变成一条**可读诊断**而不是一个 `unwrap`。 use unlucky_design::color::{Scheme, SchemeFlavor}; +use unlucky_i18n::catalog::Key; use unlucky_presenter::prefs::{ AppearanceBase, AppearancePrefs, BgFill, PaletteSource, WindowCorner, }; @@ -24,7 +25,7 @@ use unlucky_presenter::prefs::{ use crate::Backend; use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque}; -use super::background::{self, Image}; +use super::background::{self, Image, Note}; use super::palette::{self, Resolved, Source}; /// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。 @@ -67,8 +68,8 @@ pub struct State { pub selected_group: usize, /// 已解码的背景图(`None` = 没设 / 读失败)。 image: Option, - /// 读盘/解码失败的**可读诊断**(按发生顺序追加)。 - diagnostics: Vec, + /// 读盘/解码失败的**可读诊断**(按发生顺序追加;`Note` = key + 参数,句子在词表里)。 + diagnostics: Vec, /// 上一次的配色解析结果。 resolved: Option, /// 当前深浅(配色解析的输入)。 @@ -196,24 +197,24 @@ impl State { Some(&resolved.scheme) } - /// 读盘/解码失败的诊断(设置页逐条显示)。 + /// 读盘/解码失败的诊断(设置页逐条显示;一条 = 一条 key + 它的参数)。 #[must_use] - pub fn diagnostics(&self) -> &[String] { + pub fn diagnostics(&self) -> &[Note] { &self.diagnostics } - /// CPU 档降级的说明(`None` = 没有降级)。 + /// CPU 档降级的说明 key(`None` = 没有降级)。 + /// + /// M180:返回 key 而不是英文句子——本文件已 390 行、贴着 400 行硬线,**不放文案表**。 #[must_use] - pub fn backend_note(&self) -> Option<&'static str> { - self.panel_is_forced().then_some( - "Software renderer: panel transparency and glass stay fully opaque (window \ - transparency still works).", - ) + pub fn backend_note(&self) -> Option { + self.panel_is_forced() + .then_some(Key::SettingsAppearanceSoftwareRendererNote) } - /// 配色来源的提示(`None` = 一级成功)。 + /// 配色来源的提示 key(`None` = 一级成功)。 #[must_use] - pub fn palette_note(&self) -> Option<&'static str> { + pub fn palette_note(&self) -> Option { self.resolved.as_ref()?.note() } @@ -362,7 +363,7 @@ impl State { } match background::load(trimmed) { Ok(image) => self.image = Some(image), - Err(error) => self.diagnostics.push(error.describe()), + Err(error) => self.diagnostics.push(error.note()), } } diff --git a/crates/app-iced/src/pages/appearance/tests.rs b/crates/app-iced/src/pages/appearance/tests.rs new file mode 100644 index 0000000..b57dceb --- /dev/null +++ b/crates/app-iced/src/pages/appearance/tests.rs @@ -0,0 +1,253 @@ +//! `pages::appearance` 的单测:`Change` 的落盘映射(M93)+ **中文界面文案的取证守卫**(M180)。 +//! +//! 为什么是独立文件:`mod.rs` 已经 220 行上下,而 M180 要在模块里加三条取证用例。搬移口径与 +//! `background/tests.rs` 一致——既有两条用例**一字未动**,只是从 `mod.rs` 的行内 `mod tests` 挪到这里。 +//! +//! ## M180 的两条取证守卫,它们「抓得住什么」 +//! +//! iced 0.14 没有可遍历控件树的公开接口(见 [`super::probe`] 的模块文档),所以取证点只能是 +//! 取词口 `section::t` / `section::tr`。于是这一对守卫分工如下: +//! +//! | 守卫 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`the_appearance_card_speaks_chinese`] | 中文界面下取到的每一条文案都是纯 ASCII(= 有人把某条 key 的英文写回中文表 / 中文表里放英文原句) | 硬编码字面量(它压根不过取词口) | +//! | [`every_appearance_card_key_is_drawn`] | 某一处控件被改回**写死英文**:那条 key 不再被取,集合里就少一个元素 | 新加一条字面量(新 key 还没有,集合不看它) | +//! +//! 两条合起来才等于「中文界面下外观卡片里不再出现英文字面量」——单看任一条都有一个真会发生的 +//! 漏网口,所以这里刻意写两条。 + +use super::{Change, APPEARANCE_GROUP}; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; +use unlucky_presenter::prefs::{keys, BgFill, PaletteSource}; + +/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。 +/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。 +#[test] +fn every_change_names_its_own_schema_key() { + assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance"); + let cases = [ + (Change::WindowOpacity(0.8), keys::WINDOW_OPACITY), + (Change::PanelAlpha(0.6), keys::PANEL_ALPHA), + (Change::BgEnabled(true), keys::BG_IMAGE_ENABLED), + (Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE), + (Change::BgFill(BgFill::Tile), keys::BG_FILL), + (Change::BgDim(0.5), keys::BG_DIM), + ( + Change::PaletteSource(PaletteSource::Wallpaper), + keys::PALETTE_SOURCE, + ), + ( + Change::Corner(unlucky_presenter::prefs::WindowCorner::Square), + keys::WINDOW_CORNER, + ), + ]; + for (change, key) in cases { + assert_eq!(change.key(), Some(key), "{change:?}"); + assert!(change.value().is_some()); + } + assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘"); + assert_eq!(Change::SelectGroup(2).value(), None); +} + +/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。 +/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。 +#[test] +fn persisted_values_use_the_schema_vocabulary() { + assert_eq!( + Change::BgFill(BgFill::Contain).value().as_deref(), + Some("contain") + ); + assert_eq!( + Change::PaletteSource(PaletteSource::BackgroundImage) + .value() + .as_deref(), + Some("background-image") + ); + assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false")); + assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8")); +} + +// --------------------------------------------------------------- 文案(M180 I18NAPPEAR) + +/// 画一帧外观卡片,取走这一帧从词表取到的**全部**字:`(key, 文案)`。 +/// +/// 三档状态各画一帧: +/// - [`Case::Factory`]:auto 档、没设背景图——覆盖常驻的标签 / 选项 / 说明; +/// - [`Case::Cpu`]:CPU 档——覆盖「强制 100%」那一行与软件渲染说明(这两条只在降级态出现); +/// - [`Case::Dim`]:整窗 α 调低——覆盖「圆角暂时不生效」那条 notice。 +/// +/// 两条取词路径都要收:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的整句(后者不进前者的记录)。 +fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> { + use unlucky_design::color::SchemeFlavor; + use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; + + use super::probe; + use crate::cli::Backend; + use crate::theme::{Families, Ui}; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + let (backend, alpha) = match case { + Case::Factory => (Backend::Auto, 1.0), + Case::Cpu => (Backend::Cpu, 1.0), + Case::Dim => (Backend::Gpu, 0.5), + }; + let mut state = super::State::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + backend, + SchemeFlavor::Light, + ); + if alpha < 1.0 { + state.apply(Change::WindowOpacity(alpha)); + } + + let ui = Ui::new(SchemeFlavor::Light, FAMILIES); + // 前面几条用例可能留了记录:本线程的取证每次都从清空开始。 + probe::take(); + probe::take_rendered(); + + let _tree = super::section(&ui, &state, lang); + + let mut texts: Vec<(Key, String)> = probe::take() + .into_iter() + .map(|(key, text)| (key, text.to_owned())) + .collect(); + texts.extend(probe::take_rendered()); + texts +} + +/// 画哪一档状态。 +#[derive(Debug, Clone, Copy)] +enum Case { + /// 工厂态(GPU / auto)。 + Factory, + /// CPU 降级态(面板 α 与玻璃被强制)。 + Cpu, + /// 整窗 α 调低(圆角暂时不生效)。 + Dim, +} + +/// 这一页**常驻**的 key(三档状态合起来必须一个不缺地取到)。 +/// +/// 为什么是手写清单而不是「`Key::ALL` 里所有 `SettingsAppearance*`」:诊断一族(读盘失败 / 配色回落 / +/// 小图平铺)取决于运行时状态,单测里要让它们出现得造磁盘上的坏图;那些由 +/// `background/tests.rs` 与 `palette.rs` 的单测按 key 逐条钉(见各自的用例注释)。 +const CARD_KEYS: &[Key] = &[ + Key::SettingsAppearanceCorner, + Key::SettingsAppearanceCornerSystem, + Key::SettingsAppearanceCornerSquare, + Key::SettingsAppearanceCornerHint, + Key::SettingsAppearanceCornerSuspended, + Key::SettingsAppearanceWindowOpacity, + Key::SettingsAppearanceWindowOpacityHint, + Key::SettingsAppearancePanelOpacity, + Key::SettingsAppearancePanelOpacityHint, + Key::SettingsAppearancePanelOpacityForcedValue, + Key::SettingsAppearancePanelOpacityForcedHint, + Key::SettingsAppearanceSoftwareRendererNote, + Key::SettingsAppearanceBackgroundImage, + Key::SettingsAppearanceOff, + Key::SettingsAppearanceOn, + Key::SettingsAppearanceImagePath, + Key::SettingsAppearancePaletteLevel, + Key::SettingsAppearanceBgFill, + Key::SettingsAppearanceBgFillCover, + Key::SettingsAppearanceBgFillContain, + Key::SettingsAppearanceBgFillTile, + Key::SettingsAppearanceBgDim, + Key::SettingsAppearanceBgDimHint, + Key::SettingsAppearancePaletteSource, + Key::SettingsAppearancePaletteBackground, + Key::SettingsAppearancePaletteWallpaper, + Key::SettingsAppearancePalettePreset, +]; + +/// **M180 守门 ①**:中文界面下,外观卡片里取到的每一条文案都不是纯 ASCII。 +/// +/// 抓得住:某条 key 的中文译文被换成英文原句(中文表里出现纯 ASCII 的一句);中文译文写成 +/// `PNG` / `Preset` 这种专名;`tr()` 那条路径把数字拼进英文模板(比如 `Colours: preset`)。 +/// 抓不住:硬编码字面量——它不过取词口,由 [`every_appearance_card_key_is_drawn`] 与 +/// `catalog/settings/appearance.rs` 的译文守卫合力守。 +/// +/// 变异证明:把 `SettingsAppearanceOff` 的中文改成 `"Off"` ⇒ 本条立刻红。 +#[test] +fn the_appearance_card_speaks_chinese() { + // 白名单:技术专名允许是纯 ASCII(现在一条都没有——`%` / 数字不在文案里,它们是参数)。 + const ASCII_OK: &[&str] = &[]; + + for case in [Case::Factory, Case::Cpu, Case::Dim] { + let pairs = appearance_texts(Lang::ZhCn, case); + assert!( + pairs.len() >= 20, + "{case:?}:取词口只记到 {} 条文案,渲染路径没走到底(语言管子断了?)", + pairs.len() + ); + for (key, text) in &pairs { + if ASCII_OK.contains(&text.as_str()) { + continue; + } + assert!( + !text.is_ascii(), + "{case:?}:中文界面下外观卡片里出现了纯 ASCII 的文案「{text}」({})\ + ——写死英文,或中文表里放了英文原句", + key.name() + ); + } + } +} + +/// **M180 守门 ②(漏项)**:这一页每一处控件都必须从词表取词。 +/// +/// 抓得住:把某处 `t(Key::X, lang)` / `tr(lang, Key::X, …)` 改回写死的英文字面量——那条 key 不再 +/// 出现在取词记录里,集合比较当场红(这正是 M172 口径「22 处硬编码」的复发形态)。 +/// 抓不住:新加一条写死英文(它没有 key,也就没有「少了哪条」)。 +/// +/// 变异证明:把 `bg_image.rs` 里 `t(Key::SettingsAppearanceOff, lang)` 换回 `"Off"` ⇒ 本条红。 +#[test] +fn every_appearance_card_key_is_drawn() { + let mut seen: Vec = Vec::new(); + for case in [Case::Factory, Case::Cpu, Case::Dim] { + seen.extend( + appearance_texts(Lang::ZhCn, case) + .into_iter() + .map(|(key, _)| key), + ); + } + + let missing: Vec<&str> = CARD_KEYS + .iter() + .filter(|key| !seen.contains(key)) + .map(|key| key.name()) + .collect(); + assert!( + missing.is_empty(), + "这些 key 一帧都没取到(控件被改回写死英文,或整段没画):{missing:?}" + ); +} + +/// 英文界面下不许出现汉字(否则「含汉字」可能来自写死的中文,而不是词表)。 +#[test] +fn the_appearance_card_keeps_its_english_baseline() { + for case in [Case::Factory, Case::Cpu, Case::Dim] { + let texts = appearance_texts(Lang::En, case); + let with_han: Vec<&(Key, String)> = texts + .iter() + .filter(|(_, text)| text.chars().any(is_han)) + .collect(); + assert!( + with_han.is_empty(), + "{case:?}:英文界面下外观卡片出现了汉字:{with_han:?}" + ); + } +} + +/// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。 +fn is_han(ch: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&ch) +} diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 8972de0..78f95db 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -29,6 +29,50 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCornerSquare", "Square", "直角"), ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), + // M180:外观分组补齐(`catalog/settings/appearance.rs` 的第二批,M116 圆角那 5 条之后)。 + // 位置就是装配链里那个域的位置——`settings_appearance_entries` 在 `settings_java_entries` 之前, + // 所以这 40 行必须落在 `SettingsAppearanceCornerSuspended` 与 `SettingsJavaScan` 之间 + // (顺序 = `Key::ALL`;为排到文件尾而重排别人的族会让 `split_keeps_every_translation` 整片红)。 + ("SettingsAppearanceWindowOpacity", "Window transparency", "整窗透明度"), + ("SettingsAppearanceWindowOpacityHint", "Applies to the whole window. Floor {floor}; 1.00 = fully opaque.", "作用于整个窗口。下限 {floor};1.00 = 完全不透明。"), + ("SettingsAppearancePanelOpacity", "Panel transparency", "面板透明度"), + ("SettingsAppearancePanelOpacityHint", "Only the panel/card layer. Floor {floor}; independent of window transparency.", "只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。"), + ("SettingsAppearancePanelOpacityForcedValue", "100% (forced from {requested})", "100%(强制,原请求值 {requested})"), + ("SettingsAppearancePanelOpacityForcedHint", "Software renderer: panels stay fully opaque (window transparency still works).", "软件渲染:面板恒为不透明(整窗透明度仍然有效)。"), + ("SettingsAppearanceBackgroundImage", "Background image", "背景图"), + ("SettingsAppearanceOff", "Off", "关"), + ("SettingsAppearanceOn", "On", "开"), + ("SettingsAppearanceImagePath", "Image path", "图片路径"), + ("SettingsAppearancePaletteLevel", "Colours: {level}", "配色:{level}"), + ("SettingsAppearanceBgFill", "Fill", "填充方式"), + ("SettingsAppearanceBgFillCover", "Cover", "铺满"), + ("SettingsAppearanceBgFillContain", "Contain", "完整显示"), + ("SettingsAppearanceBgFillTile", "Tile", "平铺"), + ("SettingsAppearanceBgDim", "Dim", "暗化"), + ("SettingsAppearanceBgDimHint", "Blends the image toward the panel colour so text stays readable. Max {max}.", "把背景图往面板底色上退,让文字读得清。上限 {max}。"), + ("SettingsAppearancePaletteSource", "Colour source", "配色来源"), + ("SettingsAppearancePaletteBackground", "Background", "取自背景图"), + ("SettingsAppearancePaletteWallpaper", "Wallpaper", "取自壁纸"), + ("SettingsAppearancePalettePreset", "Preset", "内置色卡"), + ("SettingsAppearanceSoftwareRendererNote", "Software renderer: panel transparency and glass stay fully opaque (window transparency still works).", "软件渲染:面板透明度与玻璃效果恒为不透明(整窗透明度仍然有效)。"), + ("SettingsAppearanceBgReadFailed", "Background image could not be read: {reason}", "背景图读不出来:{reason}"), + ("SettingsAppearanceBgDecodeFailed", "Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)", "背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)"), + ("SettingsAppearanceBgTooLarge", "Background image is too large to decode: {width}×{height} (limit {limit} px per edge)", "背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)"), + ("SettingsAppearanceBgSmallTile", "Small image ({width}×{height}): tiles are scaled up so the window stays covered (max {max} tiles, no gaps).", "图片很小({width}×{height}):图块会被放大,保证窗口铺满(最多 {max} 块,不留缝)。"), + ("SettingsAppearancePaletteBgUndecodable", "Background image could not be read; colours fall back.", "背景图读不出来,配色回落到下一级。"), + ("SettingsAppearancePaletteBgTooSmall", "Background image is too small to sample; colours fall back.", "背景图太小,取不了色,配色回落到下一级。"), + ("SettingsAppearancePaletteBgBadDimensions", "Background image data looks inconsistent; colours fall back.", "背景图数据前后不一致,配色回落到下一级。"), + ("SettingsAppearancePaletteBgNoOpaquePixels", "Background image is almost fully transparent; colours fall back.", "背景图几乎全透明,配色回落到下一级。"), + ("SettingsAppearancePaletteBgLowChroma", "Background image is too monochrome to tint from; colours fall back.", "背景图太单调(彩度不足),取不出配色,回落到下一级。"), + ("SettingsAppearancePaletteBgContrast", "Background image colours would be unreadable; colours fall back.", "按背景图取的颜色读不清,配色回落到下一级。"), + ("SettingsAppearancePaletteWallpaperUndecodable", "Desktop wallpaper could not be read.", "桌面壁纸读不出来。"), + ("SettingsAppearancePaletteWallpaperTooSmall", "Desktop wallpaper is too small to sample.", "桌面壁纸太小,取不了色。"), + ("SettingsAppearancePaletteWallpaperBadDimensions", "Desktop wallpaper data looks inconsistent.", "桌面壁纸数据前后不一致。"), + ("SettingsAppearancePaletteWallpaperNoOpaquePixels", "Desktop wallpaper is almost fully transparent.", "桌面壁纸几乎全透明。"), + ("SettingsAppearancePaletteWallpaperLowChroma", "Desktop wallpaper is too monochrome to tint from.", "桌面壁纸太单调(彩度不足),取不出配色。"), + ("SettingsAppearancePaletteWallpaperContrast", "Desktop wallpaper colours would be unreadable; colours fall back.", "按桌面壁纸取的颜色读不清,配色回落到下一级。"), + ("SettingsAppearancePalettePresetContrast", "The built-in card needed a small contrast correction.", "内置色卡做了一点对比度修正。"), + ("SettingsAppearancePaletteAdjusted", "A few colours were nudged for readability.", "有几个颜色为了可读性做了微调。"), // M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。 // 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、 // `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在 diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs index 907e9a2..52cdddd 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -1,12 +1,38 @@ -//! 设置页「Appearance」分组文案(M116 首次落地:窗口圆角一档)。 +//! 设置页「Appearance」分组文案(M116 落了圆角一族;M180 `I18NAPPEAR` 补齐全组)。 //! //! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 //! -//! ## 为什么只有圆角这一组的 key +//! ## 两批 key:圆角(M116)在前,其余(M180)在后 //! -//! 本分组的既有控件(整窗/面板透明度、背景图、配色来源……)是 M93 落的,文案是英文基线字面量 -//! (`pages/appearance/section.rs`)。M116 新增的圆角档位**从一开始就走词表**(tower 口径: -//! 设置项文案不许硬编码),所以这里先只有它这一组;老控件接词表归 i18n 接线 mission。 +//! M116 只落了圆角那 5 条;本分组其余控件(整窗/面板透明度、背景图、填充、暗化、配色来源) +//! 是 M93 落的,文案一直是英文基线字面量。M172 审计量出这一页 27 处文案里 22 处硬编码、中文界面 +//! 本地化 0/27。M180 把**控件文案 + 运行时诊断 + 配色回落提示**全部搬进本文件: +//! +//! | 批次 | 条目 | 来源 | +//! |---|---|---| +//! | M116 | `Corner` … `CornerSuspended` | `pages/appearance/section/window.rs` 的圆角一段 | +//! | M180 | `WindowOpacity` … `PaletteAdjusted` | 卡片各段标签/说明 + `state.rs` / `background.rs` / `palette.rs` 的诊断 prose | +//! +//! ## 为什么诊断也在这里(而不是留在 `state.rs`) +//! +//! `pages/appearance/state.rs` 已经 390 行、贴着 400 行硬线,放不下任何一张文案表。所以那一层只 +//! 给 **key + 已格式化好的参数**(`background::Note`),句子一律在本文件里。 +//! +//! ## 数值参数:模板里只有 `{name}`,格式在 Rust 侧做 +//! +//! [`crate::catalog::Catalog::render`] 只做 `{name}` → 值的字面替换,**不认** `{:.2}` 这类格式说明。 +//! 所以「位置/尺寸/上限」这些数字由调用方先 `format!("{:.2}")` 好再当参数传进来: +//! +//! | key | 原字面量 | 现在 | +//! |---|---|---| +//! | `SettingsAppearancePanelOpacityForcedValue` | `format!("100% (forced from {:.2})")` | `{requested}` = `format!("{:.2}")` | +//! | `SettingsAppearanceWindowOpacityHint` | `Floor {:.2}` = `WINDOW_ALPHA_FLOOR` | `{floor}` | +//! | `SettingsAppearancePanelOpacityHint` | `Floor 0.40` = `PANEL_ALPHA_FLOOR` | `{floor}` | +//! | `SettingsAppearanceBgDimHint` | `Max 0.95` = `BG_DIM_MAX` | `{max}` | +//! | `SettingsAppearanceBgTooLarge` | `(limit 8192 px per edge)` = `MAX_SOURCE_EDGE` | `{limit}` | +//! | `SettingsAppearanceBgSmallTile` | `(max 64 tiles …)` = `MAX_TILES` | `{max}` | +//! +//! 常量走占位符而不是写进译文,是为了「改常量不用改两套译文」(M180 任务口径)。 //! //! ## 文案口径(不许暗示所有平台一致) //! @@ -28,8 +54,206 @@ macro_rules! settings_appearance_entries { "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。" ), + // ---- M180 `I18NAPPEAR`:卡片内文案(① 整窗 / ③ 面板 / ④~⑧ 背景图 / ⑨ 配色来源) + SettingsAppearanceWindowOpacity => ("Window transparency", "整窗透明度"), + SettingsAppearanceWindowOpacityHint => ( + "Applies to the whole window. Floor {floor}; 1.00 = fully opaque.", + "作用于整个窗口。下限 {floor};1.00 = 完全不透明。" + ), + SettingsAppearancePanelOpacity => ("Panel transparency", "面板透明度"), + SettingsAppearancePanelOpacityHint => ( + "Only the panel/card layer. Floor {floor}; independent of window transparency.", + "只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。" + ), + SettingsAppearancePanelOpacityForcedValue => ( + "100% (forced from {requested})", + "100%(强制,原请求值 {requested})" + ), + SettingsAppearancePanelOpacityForcedHint => ( + "Software renderer: panels stay fully opaque (window transparency still works).", + "软件渲染:面板恒为不透明(整窗透明度仍然有效)。" + ), + SettingsAppearanceBackgroundImage => ("Background image", "背景图"), + SettingsAppearanceOff => ("Off", "关"), + SettingsAppearanceOn => ("On", "开"), + SettingsAppearanceImagePath => ("Image path", "图片路径"), + SettingsAppearancePaletteLevel => ("Colours: {level}", "配色:{level}"), + SettingsAppearanceBgFill => ("Fill", "填充方式"), + SettingsAppearanceBgFillCover => ("Cover", "铺满"), + SettingsAppearanceBgFillContain => ("Contain", "完整显示"), + SettingsAppearanceBgFillTile => ("Tile", "平铺"), + SettingsAppearanceBgDim => ("Dim", "暗化"), + SettingsAppearanceBgDimHint => ( + "Blends the image toward the panel colour so text stays readable. Max {max}.", + "把背景图往面板底色上退,让文字读得清。上限 {max}。" + ), + SettingsAppearancePaletteSource => ("Colour source", "配色来源"), + SettingsAppearancePaletteBackground => ("Background", "取自背景图"), + SettingsAppearancePaletteWallpaper => ("Wallpaper", "取自壁纸"), + SettingsAppearancePalettePreset => ("Preset", "内置色卡"), + // ---- M180:运行时诊断(`state.rs` 的 backend_note + `background.rs` 的三种读盘失败 + 小图平铺) + SettingsAppearanceSoftwareRendererNote => ( + "Software renderer: panel transparency and glass stay fully opaque (window transparency still works).", + "软件渲染:面板透明度与玻璃效果恒为不透明(整窗透明度仍然有效)。" + ), + SettingsAppearanceBgReadFailed => ( + "Background image could not be read: {reason}", + "背景图读不出来:{reason}" + ), + SettingsAppearanceBgDecodeFailed => ( + "Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)", + "背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)" + ), + SettingsAppearanceBgTooLarge => ( + "Background image is too large to decode: {width}×{height} (limit {limit} px per edge)", + "背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)" + ), + SettingsAppearanceBgSmallTile => ( + "Small image ({width}×{height}): tiles are scaled up so the window stays covered (max {max} tiles, no gaps).", + "图片很小({width}×{height}):图块会被放大,保证窗口铺满(最多 {max} 块,不留缝)。" + ), + // ---- M180:配色回落提示(`palette.rs` 把 design 的 `hint_key()` 翻成下面这些) + SettingsAppearancePaletteBgUndecodable => ( + "Background image could not be read; colours fall back.", + "背景图读不出来,配色回落到下一级。" + ), + SettingsAppearancePaletteBgTooSmall => ( + "Background image is too small to sample; colours fall back.", + "背景图太小,取不了色,配色回落到下一级。" + ), + SettingsAppearancePaletteBgBadDimensions => ( + "Background image data looks inconsistent; colours fall back.", + "背景图数据前后不一致,配色回落到下一级。" + ), + SettingsAppearancePaletteBgNoOpaquePixels => ( + "Background image is almost fully transparent; colours fall back.", + "背景图几乎全透明,配色回落到下一级。" + ), + SettingsAppearancePaletteBgLowChroma => ( + "Background image is too monochrome to tint from; colours fall back.", + "背景图太单调(彩度不足),取不出配色,回落到下一级。" + ), + SettingsAppearancePaletteBgContrast => ( + "Background image colours would be unreadable; colours fall back.", + "按背景图取的颜色读不清,配色回落到下一级。" + ), + SettingsAppearancePaletteWallpaperUndecodable => ( + "Desktop wallpaper could not be read.", + "桌面壁纸读不出来。" + ), + SettingsAppearancePaletteWallpaperTooSmall => ( + "Desktop wallpaper is too small to sample.", + "桌面壁纸太小,取不了色。" + ), + SettingsAppearancePaletteWallpaperBadDimensions => ( + "Desktop wallpaper data looks inconsistent.", + "桌面壁纸数据前后不一致。" + ), + SettingsAppearancePaletteWallpaperNoOpaquePixels => ( + "Desktop wallpaper is almost fully transparent.", + "桌面壁纸几乎全透明。" + ), + SettingsAppearancePaletteWallpaperLowChroma => ( + "Desktop wallpaper is too monochrome to tint from.", + "桌面壁纸太单调(彩度不足),取不出配色。" + ), + SettingsAppearancePaletteWallpaperContrast => ( + "Desktop wallpaper colours would be unreadable; colours fall back.", + "按桌面壁纸取的颜色读不清,配色回落到下一级。" + ), + SettingsAppearancePalettePresetContrast => ( + "The built-in card needed a small contrast correction.", + "内置色卡做了一点对比度修正。" + ), + SettingsAppearancePaletteAdjusted => ( + "A few colours were nudged for readability.", + "有几个颜色为了可读性做了微调。" + ), } } }; } pub(crate) use settings_appearance_entries; + +#[cfg(test)] +mod tests { + use crate::catalog::{placeholders, Catalog, Key}; + use crate::Lang; + + /// 本域的 key(`Key::ALL` 里按名字前缀筛;不手工列表,漏一条就少一条守卫)。 + fn appearance_keys() -> Vec { + Key::ALL + .iter() + .copied() + .filter(|key| key.name().starts_with("SettingsAppearance")) + .collect() + } + + /// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。 + fn is_han(ch: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&ch) + } + + /// **M180 守门 ②**:外观分组的每条 key 都必须真翻过——英文照抄进中文表就地判红。 + /// + /// 抓得住:① 新加 key 时中文栏直接抄英文(`chinese_catalog_is_actually_chinese` 也抓这条, + /// 但那条在 `catalog/tests.rs`、不属于本 mission 的 scope,而且不查「中文字里有没有汉字」); + /// ② 中文译文变成纯 ASCII(比如只写了 `PNG` / `Preset` 这种专名);③ 占位符集合两边不一致 + /// (`{floor}` 只在一边有 ⇒ `render()` 吐出半截句子)。 + /// 抓不住:译文翻得对不对(语义只能人审)。 + /// + /// 变异证明:把任一条的第二个字符串换成第一个(`("Cover", "Cover")`)⇒ 本条立刻红(`assert_ne!` + /// 与 `is_han` 命中同一行)。 + #[test] + fn every_appearance_key_is_translated_into_chinese() { + let keys = appearance_keys(); + assert!( + keys.len() >= 40, + "外观分组至少 40 条 key(M180 落地时是 45 条),实际 {}:\ + key 被搬走或前缀改名了?", + keys.len() + ); + + let en = Catalog::of(Lang::En); + let zh = Catalog::of(Lang::ZhCn); + for key in keys { + let (english, chinese) = (en.text(key), zh.text(key)); + assert_ne!(english, chinese, "{} 的中文是英文照抄", key.name()); + assert!( + chinese.chars().any(is_han), + "{} 的中文里一个汉字都没有:{chinese}", + key.name() + ); + let mut left = placeholders(english); + let mut right = placeholders(chinese); + left.sort_unstable(); + right.sort_unstable(); + assert_eq!(left, right, "{} 的占位符两套译文不一致", key.name()); + } + } + + /// 数值参数必须走占位符,不许把常量写进译文(改常量时不该还要改两套译文)。 + #[test] + fn numeric_limits_are_placeholders_not_text() { + let en = Catalog::of(Lang::En); + for (key, needle, placeholder) in [ + (Key::SettingsAppearanceWindowOpacityHint, "0.08", "floor"), + (Key::SettingsAppearancePanelOpacityHint, "0.40", "floor"), + (Key::SettingsAppearanceBgDimHint, "0.95", "max"), + (Key::SettingsAppearanceBgTooLarge, "8192", "limit"), + (Key::SettingsAppearanceBgSmallTile, "64", "max"), + ] { + let text = en.text(key); + assert!( + !text.contains(needle), + "{} 把常量 {needle} 写死在译文里了(应当走 {{{placeholder}}})", + key.name() + ); + assert!( + placeholders(text).contains(&placeholder), + "{} 缺 {{{placeholder}}} 占位符:{text}", + key.name() + ); + } + } +} From 1d240ff1f2a212f0cacf62c08076676947b15dda Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 21:29:37 +0800 Subject: [PATCH 14/33] =?UTF-8?q?fix(i18n):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=A4=E5=8F=A5=E8=BF=91=E4=B9=89?= =?UTF-8?q?=E8=BD=AF=E4=BB=B6=E6=B8=B2=E6=9F=93=E8=AF=B4=E6=98=8E=E5=90=88?= =?UTF-8?q?=E4=B8=80=EF=BC=8C=E7=94=9F=E6=95=88=E5=B1=82=E7=BA=A7=E7=A0=81?= =?UTF-8?q?=E7=BF=BB=E6=88=90=E8=AF=91=E6=96=87=EF=BC=88M180=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(一屏两句重复): - 留面板那一句(就在被禁用的滑块旁边,正好回答「为什么拖不动」),措辞同时点名面板与玻璃 - 删 `SettingsAppearanceSoftwareRendererNote`(catalog 一条 + 金表一行,两边同时删) - 删 `bg_image.rs` 的 `backend_note` 渲染块与 `State::backend_note()`;state.rs 391 → 382 行 P2-2(配色:preset 的机器码): - `palette::level_key(code)` 把 `preset`/`wallpaper`/其余 映射到已有的三条档位 key,不新增平行词表 - `bg_image.rs` 用 `tr(PaletteLevel, {level})` 传译文,中文界面显示「配色:内置色卡」 - 补 `every_level_code_maps_to_its_own_sentence`:三个码值一一对应 + 三条译文互不相同 顺手(复审 L-1/L-2/L-4,全在本 scope 内): - `appearance_texts` 加「渲染后不许残留 `{`」断言(参数名写错当场红) - `section/mod.rs` 文档:取词口 t/tr、填充措辞、清掉孤立 `//!`、把「没有一句英文」改成 写明两处例外(`{reason}` 原文串、专名) --- .../app-iced/src/pages/appearance/palette.rs | 54 +++++++++++++++++++ .../src/pages/appearance/section/bg_image.rs | 17 +++--- .../src/pages/appearance/section/mod.rs | 12 +++-- crates/app-iced/src/pages/appearance/state.rs | 9 ---- crates/app-iced/src/pages/appearance/tests.rs | 12 ++++- crates/i18n/src/catalog/golden/tail.rs | 3 +- .../i18n/src/catalog/settings/appearance.rs | 13 +++-- 7 files changed, 88 insertions(+), 32 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/palette.rs b/crates/app-iced/src/pages/appearance/palette.rs index 0543de4..c20ecb9 100644 --- a/crates/app-iced/src/pages/appearance/palette.rs +++ b/crates/app-iced/src/pages/appearance/palette.rs @@ -130,6 +130,24 @@ pub fn note(provenance: &Provenance) -> Option { }) } +/// 生效层级**码值** → 已有的那三条档位 key(M180 复审 P2-2:中文界面里不许留机器码)。 +/// +/// 码值的全部来源只有两处,取值集合相同:`Level::code()`(`background-image` / `wallpaper` / +/// `preset`)与 `PaletteSource::as_str()`(同三个词)。所以三条 key 就够,**不新增平行词表**—— +/// 卡片底部「配色来源」用它们表示**用户选的**档,这一行用它们表示**回落之后真正生效**的档。 +/// +/// 未识别的码值落到 `background-image` 那条(宁可显示一个偏保守的档名,也不许把机器码漏进 +/// 中文界面——这条兜底本身由 `tests` 里的 `every_level_code_maps_to_its_own_sentence` 钉住)。 +/// `state.rs` 已贴 400 行线,所以这个映射住在本文件。 +#[must_use] +pub fn level_key(code: &str) -> Key { + match code { + "preset" => Key::SettingsAppearancePalettePreset, + "wallpaper" => Key::SettingsAppearancePaletteWallpaper, + _ => Key::SettingsAppearancePaletteBackground, + } +} + #[cfg(test)] mod tests { use super::{note, resolve, Source}; @@ -273,4 +291,40 @@ mod tests { None ); } + + /// **M180 复审 P2-2 的守门**:生效层级的**每一个码值**都必须映射到它自己那条中文句子。 + /// + /// 抓得住:① 三个码值被映射到同一条 key(界面上分不出到底生效的是哪一级); + /// ② 某条映射被删掉 / 写错(`preset` 落到 background 那条 ⇒ 中文界面说「取自背景图」); + /// ③ 有人再加一级却忘了扩映射。 + /// 抓不住:译文本身的措辞(那是 `catalog/settings/appearance.rs` 的译文守卫的事)。 + /// + /// 变异证明:把 `"preset"` 那行删掉(落进兜底)⇒ 本条红,且 `assert_ne!` 直接点名码值。 + /// + /// 为什么这条必须有:`the_appearance_card_speaks_chinese` 断的是**整句**非 ASCII, + /// 而「配色:preset」里有汉字 ⇒ 整句非 ASCII ⇒ 那个守卫放过它。这个口子只能在这里堵。 + #[test] + fn every_level_code_maps_to_its_own_sentence() { + use super::level_key; + + let codes = ["background-image", "wallpaper", "preset"]; + let keys: Vec = codes.iter().copied().map(level_key).collect(); + assert_eq!( + keys, + vec![ + Key::SettingsAppearancePaletteBackground, + Key::SettingsAppearancePaletteWallpaper, + Key::SettingsAppearancePalettePreset, + ], + "码值与档位 key 必须一一对应" + ); + + // 三条中文句子互不相同(否则「映射对了」但界面上仍分不出层级)。 + let zh = unlucky_i18n::Lang::ZhCn.catalog(); + let mut texts: Vec<&str> = keys.iter().map(|key| zh.text(*key)).collect(); + let total = texts.len(); + texts.sort_unstable(); + texts.dedup(); + assert_eq!(texts.len(), total, "三条档位译文有重复:{texts:?}"); + } } diff --git a/crates/app-iced/src/pages/appearance/section/bg_image.rs b/crates/app-iced/src/pages/appearance/section/bg_image.rs index 4ea9e68..6c71e6b 100644 --- a/crates/app-iced/src/pages/appearance/section/bg_image.rs +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -17,6 +17,7 @@ use crate::pages::Message; use crate::theme::Ui; use super::super::background::{self, BG_DIM_MAX}; +use super::super::palette; use super::super::state::State; use super::super::Change; use super::{choice, choice_row, fixed2, label, note_line, slider_row, t, tr, tr_note}; @@ -66,19 +67,15 @@ pub(super) fn push<'a>( .into(), ); - // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** + // ⑥ 诊断(读盘失败 / 配色回落)——**不许静默**,且必须在**首屏可见处** // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 + // + // CPU 档的「面板/玻璃被强制」那句**不在这里**:它是张卡级状态,挂在被禁用的面板滑块旁边 + // (`panel.rs` 的 `PanelOpacityForcedHint`),一屏只出现一次(M180 复审 P2-1)。 for note in state.diagnostics() { let text = tr_note(lang, note); rows.push(note_line(ui, &text, ui.color(Role::Error))); } - if let Some(key) = state.backend_note() { - rows.push(note_line( - ui, - t(key, lang), - ui.color(Role::OnSurfaceVariant), - )); - } if let Some(note) = state .image() .and_then(|image| background::small_tile_note(image, state.bg_fill)) @@ -93,10 +90,12 @@ pub(super) fn push<'a>( ui.color(Role::OnSurfaceVariant), )); } + // 生效的配色层级:显示的是**回落之后真正生效**的那一级(与底部「配色来源」里用户选的那一档 + // 不一定相同)。码值 → 已有的三条档位 key(不新增平行词表),所以中文界面也读得懂。 let level = tr( lang, Key::SettingsAppearancePaletteLevel, - &[("level", state.palette_level())], + &[("level", t(palette::level_key(state.palette_level()), lang))], ); rows.push(note_line(ui, &level, ui.color(Role::OnSurfaceVariant))); diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index e6b7367..a769e6c 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -9,7 +9,7 @@ //! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | //! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] | //! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] | -//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | +//! | 填充(铺满 / 完整显示 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | //! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] | //! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] | //! @@ -20,7 +20,7 @@ //! //! | 文件 | 是什么 | //! |---|---| -//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 取词口 [`t`] | +//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 两个取词口 [`t`] / [`tr`] | //! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) | //! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) | //! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 | @@ -41,8 +41,12 @@ //! `lang` 从 `settings::view` 的 `Language.effective` → `Group.body` → [`section`] 一路递进来。 //! M179 只把管子接通(当时本组 27 条文案里真正本地化的只有圆角那 5 条);M180 `I18NAPPEAR` 把 //! 剩下 22 条字面量与 `state.rs` / `background.rs` / `palette.rs` 的运行时诊断全部搬进 -//! `catalog/settings/appearance.rs`,于是中文界面上这一页**没有一句是英文**。 - +//! `catalog/settings/appearance.rs`,于是中文界面上这一页的**卡片文案**没有一句是英文。 +//! +//! 两处例外(M180 复审 L-1 记下的口径):① 写进 `{reason}` 的**原因串**里,来自本仓库自写的 +//! 两条(`"no path configured"`、`"unknown image format …"`)与 OS / 解码库给出的文本仍是英文—— +//! 它们是不可翻译的第三方或诊断原文;② `Windows 11` / `macOS` / `Linux` / `PNG` / `JPEG` / `BMP` +//! 这些专名。除此之外这一页不出现英文。 //! //! ## 为什么「值」与「生效值」分开显示 //! diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index 354b3b9..753da40 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -203,15 +203,6 @@ impl State { &self.diagnostics } - /// CPU 档降级的说明 key(`None` = 没有降级)。 - /// - /// M180:返回 key 而不是英文句子——本文件已 390 行、贴着 400 行硬线,**不放文案表**。 - #[must_use] - pub fn backend_note(&self) -> Option { - self.panel_is_forced() - .then_some(Key::SettingsAppearanceSoftwareRendererNote) - } - /// 配色来源的提示 key(`None` = 一级成功)。 #[must_use] pub fn palette_note(&self) -> Option { diff --git a/crates/app-iced/src/pages/appearance/tests.rs b/crates/app-iced/src/pages/appearance/tests.rs index b57dceb..0f8208f 100644 --- a/crates/app-iced/src/pages/appearance/tests.rs +++ b/crates/app-iced/src/pages/appearance/tests.rs @@ -119,6 +119,17 @@ fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> { .map(|(key, text)| (key, text.to_owned())) .collect(); texts.extend(probe::take_rendered()); + + // 复审 L-2:`{name}` 换完还留着花括号 ⇒ 某处参数名与模板不一致(`("floor", …)` 写成 + // `("flor", …)` 时句子会原样显示「下限 {floor}」,而「整句非 ASCII」的守卫看不出来)。 + // 三档状态合起来覆盖本页全部 `tr()` 调用,所以这条断言是全覆盖的。 + for (key, text) in &texts { + assert!( + !text.contains('{'), + "{case:?}:{} 渲染后还留着占位符「{text}」——`tr()` 的参数名与模板对不上", + key.name() + ); + } texts } @@ -150,7 +161,6 @@ const CARD_KEYS: &[Key] = &[ Key::SettingsAppearancePanelOpacityHint, Key::SettingsAppearancePanelOpacityForcedValue, Key::SettingsAppearancePanelOpacityForcedHint, - Key::SettingsAppearanceSoftwareRendererNote, Key::SettingsAppearanceBackgroundImage, Key::SettingsAppearanceOff, Key::SettingsAppearanceOn, diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 78f95db..ab1155a 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -38,7 +38,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearancePanelOpacity", "Panel transparency", "面板透明度"), ("SettingsAppearancePanelOpacityHint", "Only the panel/card layer. Floor {floor}; independent of window transparency.", "只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。"), ("SettingsAppearancePanelOpacityForcedValue", "100% (forced from {requested})", "100%(强制,原请求值 {requested})"), - ("SettingsAppearancePanelOpacityForcedHint", "Software renderer: panels stay fully opaque (window transparency still works).", "软件渲染:面板恒为不透明(整窗透明度仍然有效)。"), + ("SettingsAppearancePanelOpacityForcedHint", "Software renderer: panels and glass stay fully opaque (window transparency still works).", "软件渲染:面板与玻璃效果恒为不透明(整窗透明度仍然有效)。"), ("SettingsAppearanceBackgroundImage", "Background image", "背景图"), ("SettingsAppearanceOff", "Off", "关"), ("SettingsAppearanceOn", "On", "开"), @@ -54,7 +54,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearancePaletteBackground", "Background", "取自背景图"), ("SettingsAppearancePaletteWallpaper", "Wallpaper", "取自壁纸"), ("SettingsAppearancePalettePreset", "Preset", "内置色卡"), - ("SettingsAppearanceSoftwareRendererNote", "Software renderer: panel transparency and glass stay fully opaque (window transparency still works).", "软件渲染:面板透明度与玻璃效果恒为不透明(整窗透明度仍然有效)。"), ("SettingsAppearanceBgReadFailed", "Background image could not be read: {reason}", "背景图读不出来:{reason}"), ("SettingsAppearanceBgDecodeFailed", "Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)", "背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)"), ("SettingsAppearanceBgTooLarge", "Background image is too large to decode: {width}×{height} (limit {limit} px per edge)", "背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)"), diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs index 52cdddd..3d3dce5 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -70,8 +70,8 @@ macro_rules! settings_appearance_entries { "100%(强制,原请求值 {requested})" ), SettingsAppearancePanelOpacityForcedHint => ( - "Software renderer: panels stay fully opaque (window transparency still works).", - "软件渲染:面板恒为不透明(整窗透明度仍然有效)。" + "Software renderer: panels and glass stay fully opaque (window transparency still works).", + "软件渲染:面板与玻璃效果恒为不透明(整窗透明度仍然有效)。" ), SettingsAppearanceBackgroundImage => ("Background image", "背景图"), SettingsAppearanceOff => ("Off", "关"), @@ -91,11 +91,10 @@ macro_rules! settings_appearance_entries { SettingsAppearancePaletteBackground => ("Background", "取自背景图"), SettingsAppearancePaletteWallpaper => ("Wallpaper", "取自壁纸"), SettingsAppearancePalettePreset => ("Preset", "内置色卡"), - // ---- M180:运行时诊断(`state.rs` 的 backend_note + `background.rs` 的三种读盘失败 + 小图平铺) - SettingsAppearanceSoftwareRendererNote => ( - "Software renderer: panel transparency and glass stay fully opaque (window transparency still works).", - "软件渲染:面板透明度与玻璃效果恒为不透明(整窗透明度仍然有效)。" - ), + // ---- M180:运行时诊断(`background.rs` 的三种读盘失败 + 小图平铺)。 + // + // M180 r1 复审 P2-1:软件渲染那句**只有一条**,就挂在面板那一行(`PanelOpacityForcedHint`) + // 上——它挨着被禁用的滑块,正好回答「为什么拖不动」;这里不再有第二句。 SettingsAppearanceBgReadFailed => ( "Background image could not be read: {reason}", "背景图读不出来:{reason}" From 414f6ff88669ee80a3fc26de161dc19b7abfca1d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 22:42:00 +0800 Subject: [PATCH 15/33] =?UTF-8?q?feat(m187):=20=E5=90=AF=E5=8A=A8=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=BC=B9=E7=AA=97=20+=20=E8=AF=8A=E6=96=AD=E5=8C=85?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=8C=E5=B9=B6=E6=8E=A5=E9=80=9A=20Dialog?= =?UTF-8?q?Queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测「MC 启动失败找不到崩溃日志」的直接修法:失败时弹一个框,把退出码、 一句人话原因、证据形态、四条真实路径与三个按钮摆在同一个地方。 - app-iced:最小一层模态(遮罩 + 卡片 + 主/次按钮 + Esc/点遮罩关闭), 消费 presenter 的 DialogQueue(此前全仓零引用,所有弹窗都画不出来); 队列管弹窗,CrashCard 管这次失败的事实,按 DialogId 绑定。 - 弹窗区分两种失败:游戏自己写了 crash-reports(贴 Description + 栈片段)/ 游戏没来得及写(明说「这次没有生成崩溃报告」并贴出捕获的最后 30 行)/ 什么都没抓到(给最可能原因分类)。绝不对第二种说「找不到日志」。 - 导出诊断包:core 侧新 crash_bundle 写 zip(清单 + 启动器日志 + 会话 JSONL + crash-reports/* + hs_err + latest.log + 版本 JSON),落盘即脱敏(家目录 / token / 会话 id),单文件 4 MiB、总量 48 MiB 上限并在清单记截断。 - 启动器自身日志落盘(core::launch::launcher_log → <.ULL>/logs/launcher.log, 登记目录前先缓冲),补上「双击启动时诊断行直接蒸发」的缺口。 - i18n:25 条中英文案进 catalog/logs.rs + 金表。 - 取证:ULL_DIALOG_FIXTURE=crash-report|no-logs|export 在临时目录造真文件出图, wgpu 与 tiny-skia 各一套,见 DevDocs/artifacts/m187/。 未复现 M190 的「owned 文字在 wgpu 下丢字」:同一串中文两条文字路径、两种后端逐字相同 (ab-wgpu.png / ab-tinyskia.png),因此本轮未改任何渲染路径。 --- crates/app-iced/src/app/dialog.rs | 392 +++++++++++++++++ crates/app-iced/src/app/dialog/export.rs | 157 +++++++ crates/app-iced/src/app/dialog/tests.rs | 266 ++++++++++++ crates/app-iced/src/app/dialog/view.rs | 275 ++++++++++++ crates/app-iced/src/app/message.rs | 2 + crates/app-iced/src/app/mod.rs | 1 + crates/app-iced/src/app/state.rs | 10 + crates/app-iced/src/app/state/construct.rs | 2 + crates/app-iced/src/app/state/refresh.rs | 6 + crates/app-iced/src/app/update.rs | 28 +- crates/app-iced/src/app/update/dialog.rs | 101 +++++ .../app-iced/src/app/update/dialog_fixture.rs | 152 +++++++ crates/app-iced/src/app/view.rs | 8 +- crates/core/src/launch/crash_bundle.rs | 302 ++++++++++++++ crates/core/src/launch/crash_bundle/tests.rs | 191 +++++++++ crates/core/src/launch/crash_report.rs | 394 ++++++++++++++++++ crates/core/src/launch/crash_report/tests.rs | 125 ++++++ crates/core/src/launch/launcher_log.rs | 207 +++++++++ crates/core/src/launch/mod.rs | 4 + crates/i18n/src/catalog/golden/tail.rs | 24 ++ crates/i18n/src/catalog/logs.rs | 25 ++ crates/presenter/src/host/launch.rs | 78 +++- docs/backend-cjk-evidence-matrix.md | 78 ++++ 23 files changed, 2818 insertions(+), 10 deletions(-) create mode 100644 crates/app-iced/src/app/dialog.rs create mode 100644 crates/app-iced/src/app/dialog/export.rs create mode 100644 crates/app-iced/src/app/dialog/tests.rs create mode 100644 crates/app-iced/src/app/dialog/view.rs create mode 100644 crates/app-iced/src/app/update/dialog.rs create mode 100644 crates/app-iced/src/app/update/dialog_fixture.rs create mode 100644 crates/core/src/launch/crash_bundle.rs create mode 100644 crates/core/src/launch/crash_bundle/tests.rs create mode 100644 crates/core/src/launch/crash_report.rs create mode 100644 crates/core/src/launch/crash_report/tests.rs create mode 100644 crates/core/src/launch/launcher_log.rs create mode 100644 docs/backend-cjk-evidence-matrix.md diff --git a/crates/app-iced/src/app/dialog.rs b/crates/app-iced/src/app/dialog.rs new file mode 100644 index 0000000..51d8a11 --- /dev/null +++ b/crates/app-iced/src/app/dialog.rs @@ -0,0 +1,392 @@ +//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。 +//! +//! ## 为什么先补这一层 +//! +//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**, +//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事 +//! 在此之前根本无处落脚。本模块是最小可用的一层: +//! +//! ```text +//! presenter AppState::dialogs(唯一队列) +//! │ current() +//! ▼ +//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里) +//! │ +//! ▼ +//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮 +//! ``` +//! +//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。 +//! +//! ## 崩溃卡片额外带什么 +//! +//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**: +//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 / +//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段 +//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**, +//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。 +//! +//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它, +//! 不重算:**界面不读盘、不猜原因**。 + +use unlucky_core::launch::crash_report::{CrashReport, Evidence}; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; +use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource}; + +use crate::app::state::App; + +pub(super) mod export; +#[cfg(test)] +mod tests; +pub(super) mod view; + +/// 崩溃卡片:一次失败的**事实**(不含任何结论)。 +#[derive(Debug, Clone)] +pub struct CrashCard { + /// 现场。 + pub report: CrashReport, + /// 「详细崩溃说明」是否展开。 + pub expanded: bool, + /// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。 + pub dialog: DialogId, + /// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。 + /// + /// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点** + /// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的 + /// 结果不能挂在一条不显示的通道上。 + pub outcome: Option, +} + +/// 弹窗消息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + /// 主按钮。 + Primary, + /// 次按钮。 + Secondary, + /// 点遮罩 / Esc。 + Dismiss, + /// 卡片内的空白处(吞掉点击,避免误关)。 + Swallow, + /// 展开 / 收起详细说明。 + Toggle, + /// 打开日志文件夹。 + OpenFolder, + /// 复制全部路径。 + CopyPaths, + /// 复制第 `index` 条路径(`Modal::paths` 的下标)。 + CopyPath(usize), +} + +/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(super) struct Modal { + /// 标题。 + pub title: String, + /// 正文(每行一段;都是运行时拼出来的 owned 字符串)。 + pub body: Vec, + /// 主按钮文字。 + pub primary: String, + /// 次按钮文字(`None` = 不画)。 + pub secondary: Option, + /// 是否有次按钮(`Progress` 没有)。 + pub blocking_progress: bool, + /// 崩溃卡片(普通弹窗为 `None`)。 + pub card: Option, + /// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。 + pub notice: Option, +} + +/// 崩溃卡片的展示内容。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(super) struct CardView { + /// 展开 / 收起按钮的文字。 + pub toggle: String, + /// 当前的展开态。 + pub expanded: bool, + /// (标签, 路径)列表——**逐条可点**(点了复制该条)。 + pub paths: Vec<(String, String)>, + /// 详细说明的小标题(`None` = 没有可展开的内容)。 + pub detail_title: Option, + /// 详细说明的正文行。 + pub detail_lines: Vec, + /// 三个按钮的文字。 + pub actions: [String; 3], +} + +impl Modal { + /// 正文全文(测试与「复制摘要」共用;换行拼起来)。 + #[must_use] + #[cfg(test)] + pub fn body_text(&self) -> String { + self.body.join("\n") + } +} + +/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。 +#[must_use] +pub(super) fn current( + lang: Lang, + request: &DialogRequestView<'_>, + card: Option<&CrashCard>, +) -> Modal { + let catalog = Catalog::of(lang); + let mut modal = Modal { + title: request.title.to_string(), + body: vec![request.body.to_string()], + primary: request.primary.to_string(), + secondary: request.secondary.map(str::to_string), + blocking_progress: request.blocking_progress, + card: None, + notice: None, + }; + if let Some(card) = card { + let detail = detail_of(&catalog, card); + modal.body = failure_body(&catalog, &card.report); + modal.primary = catalog.text(Key::CrashDialogExport).to_string(); + modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string()); + modal.card = Some(CardView { + toggle: catalog + .text(if card.expanded { + Key::CrashDialogHideDetail + } else { + Key::CrashDialogShowDetail + }) + .to_string(), + expanded: card.expanded, + paths: path_rows(&catalog, &card.report), + detail_title: detail.0, + detail_lines: detail.1, + actions: [ + catalog.text(Key::CrashDialogExport).to_string(), + catalog.text(Key::CrashDialogOpenFolder).to_string(), + catalog.text(Key::CrashDialogCopyPaths).to_string(), + ], + }); + modal.notice = card.outcome.clone(); + } + modal +} + +/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。 +pub(super) struct DialogRequestView<'a> { + /// 标题。 + pub title: &'a str, + /// 正文。 + pub body: &'a str, + /// 主按钮。 + pub primary: &'a str, + /// 次按钮。 + pub secondary: Option<&'a str>, + /// 进度框不可点主按钮。 + pub blocking_progress: bool, +} + +impl<'a> DialogRequestView<'a> { + /// 从 presenter 的请求取视图。 + pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self { + Self { + title: &request.title, + body: &request.body, + primary: request.kind.primary_label(), + secondary: request.kind.secondary_label(), + blocking_progress: request.kind.is_blocking_progress(), + } + } +} + +/// 失败正文:退出码 + **一句人话原因** + 证据形态。 +/// +/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花: +/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。 +fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec { + let mut body = Vec::new(); + if let Some(code) = report.exit_code { + body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())])); + } + if let Some(stage) = &report.stage { + let label = stage_label(catalog, stage); + body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)])); + } else if report.exit_code == Some(0) { + body.push(catalog.text(Key::CrashDialogSilentFailure).to_string()); + } else { + body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string()); + } + body.push( + match report.evidence() { + Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound), + Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog), + Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll), + } + .to_string(), + ); + body +} + +/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。 +fn stage_label(catalog: &Catalog, slug: &str) -> &'static str { + let key = match slug { + "version_manifest" => Key::LaunchStageVersionManifest, + "inheritance" => Key::LaunchStageInheritance, + "instance_config" => Key::LaunchStageInstanceConfig, + "isolation" => Key::LaunchStageIsolation, + "identity" => Key::LaunchStageIdentity, + _ => Key::LaunchStageSpawn, + }; + catalog.text(key) +} + +/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。 +fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> { + let mut rows = Vec::new(); + if let Some(log) = &report.session_log { + rows.push(( + catalog.text(Key::CrashDialogSessionLog).to_string(), + log.display().to_string(), + )); + } + if let Some(dir) = &report.launcher_logs { + rows.push(( + catalog.text(Key::CrashDialogLauncherLogs).to_string(), + dir.display().to_string(), + )); + } + if let Some(path) = report.latest_log_path() { + rows.push(( + catalog.text(Key::CrashDialogGameLog).to_string(), + path.display().to_string(), + )); + } + if let Some(dir) = report.crash_reports_dir() { + rows.push(( + catalog.text(Key::CrashDialogCrashReports).to_string(), + dir.display().to_string(), + )); + } + rows +} + +/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。 +fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option, Vec) { + let report = &card.report; + if let Some(artifact) = report + .artifacts + .iter() + .find(|artifact| artifact.source == "crash_report") + { + let mut lines = Vec::new(); + if let Some(excerpt) = &artifact.excerpt { + lines.extend(excerpt.lines().map(str::to_string)); + } else { + lines.push(artifact.path.display().to_string()); + } + let title = format!( + "{} {}", + catalog.text(Key::LogsDiagnosisTitle), + artifact.path.display() + ); + return (Some(title), lines); + } + if report.stderr_tail.is_empty() { + return (None, Vec::new()); + } + let title = catalog.render( + Key::CrashDialogCapturedTail, + &[("count", &report.stderr_tail.len().to_string())], + ); + (Some(title), report.stderr_tail.clone()) +} + +// --------------------------------------------------------------- 与 App 的接线 + +impl App { + /// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。 + pub(super) fn dialog_modal(&self) -> Option { + let request = self.state.dialogs.current()?; + let view = DialogRequestView::of(request); + let card = self + .dialog_card + .as_ref() + .filter(|card| card.dialog == request.id); + Some(current(self.lang(), &view, card)) + } + + /// 收到一次启动收尾:失败就弹框(成功不打扰)。 + /// + /// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。 + pub(super) fn open_failure_dialog( + &mut self, + finished: &unlucky_presenter::dto::LaunchFinished, + ) { + let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok + || finished.failure.is_some(); + if !failed { + return; + } + let Some(report) = unlucky_core::launch::crash_report::failing_launch() else { + return; + }; + if !finished.session.is_empty() && report.session != finished.session { + // 登记的是另一场(并发启动):不拿它去解释这一场。 + return; + } + let catalog = self.lang().catalog(); + let crashed = report.started(); + let title = catalog.text(if crashed { + Key::CrashDialogTitleCrashed + } else { + Key::CrashDialogTitleFailed + }); + let summary = self.dialog_summary(&report); + let at = self.lang(); + let id = self.state.dialogs.push( + DialogSource::UserAction, + title, + summary, + DialogKind::Confirm { + primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(), + secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(), + }, + ); + self.dialog_card = Some(CrashCard { + report, + // 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。 + expanded: true, + dialog: id, + outcome: None, + }); + self.view_dirty = true; + } + + /// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。 + fn dialog_summary(&self, report: &CrashReport) -> String { + let catalog = self.lang().catalog(); + match report.exit_code { + Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]), + None => catalog.text(Key::CrashDialogTitleFailed).to_string(), + } + } + + /// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。 + pub(super) fn close_dialog(&mut self) { + let closed = self.state.dialogs.current().map(|request| request.id); + self.state.dialogs.close_current(); + if self + .dialog_card + .as_ref() + .is_some_and(|card| closed == Some(card.dialog)) + { + self.dialog_card = None; + } + self.view_dirty = true; + } + + /// 展开 / 收起详细说明。 + pub(super) fn toggle_dialog_detail(&mut self) { + if let Some(card) = &mut self.dialog_card { + card.expanded = !card.expanded; + self.view_dirty = true; + } + } +} diff --git a/crates/app-iced/src/app/dialog/export.rs b/crates/app-iced/src/app/dialog/export.rs new file mode 100644 index 0000000..887cd67 --- /dev/null +++ b/crates/app-iced/src/app/dialog/export.rs @@ -0,0 +1,157 @@ +//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。 +//! +//! 与 [`super`] 分开是因为职责不同:那边管**弹窗与事实**,这边管**把事实带出界面**。 +//! 打包本体在 `unlucky_core::launch::crash_bundle`(脱敏、上限、清单都在那一处), +//! 本文件只负责「放哪、叫什么名字、怎么打开」。 + +use std::path::PathBuf; + +use unlucky_core::launch::crash_bundle::{self, BundleEntry, BundleSpec}; +use unlucky_core::launch::crash_report::CrashReport; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_i18n::Lang; + +/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。 +/// +/// 返回给界面的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」)。 +pub(crate) fn export(report: &CrashReport, lang: Lang) -> String { + let catalog = Catalog::of(lang); + let Some(logs) = &report.launcher_logs else { + return catalog.render( + Key::CrashDialogExportFailed, + &[("error", "no launcher log dir")], + ); + }; + let stamp = + unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()) + .replace([':', '.', 'T'], "-") + .trim_end_matches('Z') + .to_string(); + let name = if report.session.is_empty() { + &report.instance + } else { + &report.session + }; + let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip")); + match crash_bundle::write_bundle(&spec_of(report), &target) { + Ok(bundle) => catalog.render( + Key::CrashDialogExportDone, + &[ + ("bytes", &bundle.bytes.to_string()), + ("path", &bundle.path.display().to_string()), + ], + ), + Err(error) => catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]), + } +} + +/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。 +fn spec_of(report: &CrashReport) -> BundleSpec { + let mut entries: Vec = Vec::new(); + let mut push = |group: &'static str, path: &std::path::Path| { + if path.exists() { + entries.push(BundleEntry::new(group, path)); + } + }; + if let Some(logs) = &report.launcher_logs { + push("launcher", &logs.join("launcher.log")); + push("launcher", &logs.join("launcher.1.log")); + push("launcher", &logs.join("install.log")); + } + if let Some(session) = &report.session_log { + push("session", session); + } + for artifact in &report.artifacts { + // `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。 + if artifact.source != "latest_log" { + push("game", &artifact.path); + } + } + if let Some(latest) = report.latest_log_path() { + push("game", &latest); + } + if let Some(json) = &report.version_json { + push("instance", json); + } + let mut secrets = vec![report.session.clone()]; + secrets.retain(|secret| !secret.is_empty()); + BundleSpec { + instance: report.name.clone(), + version: report.version.clone(), + session: report.session.clone(), + exit_code: report.exit_code, + exit_type: report.exit_type.clone(), + uptime_ms: report.uptime_ms, + command: report.command.clone(), + java: report.java.clone(), + system: report.system.clone(), + notes: report.note_lines(), + missing: report.missing_lines(), + entries, + secrets, + home: std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .ok() + .map(PathBuf::from), + } +} + +/// 打开「日志文件夹」:有现场就开实例运行目录,否则开启动器日志目录。 +/// +/// 平台差异只在这里一处(`explorer` / `open` / `xdg-open`),不往业务代码里散写 `cfg`。 +pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String { + let catalog = Catalog::of(lang); + let target = report + .run_dir + .clone() + .filter(|dir| dir.is_dir()) + .or_else(|| report.launcher_logs.clone().filter(|dir| dir.is_dir())); + let Some(dir) = target else { + return catalog.render( + Key::CrashDialogOpenFailed, + &[("error", "no folder on disk")], + ); + }; + match spawn_opener(&dir) { + Ok(()) => String::new(), + Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]), + } +} + +#[cfg(target_os = "windows")] +fn spawn_opener(dir: &std::path::Path) -> Result<(), String> { + // `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。 + std::process::Command::new("explorer") + .arg(dir) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +#[cfg(target_os = "macos")] +fn spawn_opener(dir: &std::path::Path) -> Result<(), String> { + std::process::Command::new("open") + .arg(dir) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn spawn_opener(dir: &std::path::Path) -> Result<(), String> { + std::process::Command::new("xdg-open") + .arg(dir) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +/// 「复制路径」的一段文本(每条一行,标签 + 路径)。 +pub(crate) fn paths_text(report: &CrashReport) -> String { + report + .path_lines() + .into_iter() + .map(|(label, path)| format!("{label}: {}", path.display())) + .collect::>() + .join("\n") +} diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs new file mode 100644 index 0000000..0e39759 --- /dev/null +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -0,0 +1,266 @@ +//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。 +//! +//! ## 这些用例抓得住什么 / 抓不住什么(说在明处) +//! +//! | 用例 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) | +//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) | +//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) | +//! +//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见 +//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与 +//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。 + +use std::path::PathBuf; + +use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport}; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; +use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource}; +use unlucky_presenter::dto::{LaunchFinished, LogOutcome}; + +use super::{current, path_rows, CrashCard, DialogRequestView}; + +use crate::app::state::App; +use crate::cli::Options; +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn headless() -> App { + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + App::with_presenter( + Options::default(), + FAMILIES, + unlucky_design::color::SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) +} + +fn report(with_crash_report: bool, tail: Vec) -> CrashReport { + let run_dir = PathBuf::from("/instances/survival"); + let artifacts = if with_crash_report { + vec![ArtifactFact { + source: "crash_report".to_string(), + path: run_dir.join("crash-reports/crash-2026-11-08.txt"), + excerpt: Some("Description: Initializing game".to_string()), + }] + } else { + Vec::new() + }; + CrashReport { + instance: "survival".to_string(), + name: "生存".to_string(), + version: "1.20.4-fabric".to_string(), + session: "9f2c".to_string(), + session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")), + version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")), + launcher_logs: Some(PathBuf::from("/logs")), + run_dir: Some(run_dir), + artifacts, + exit_code: Some(1), + exit_type: "application_error".to_string(), + uptime_ms: 1_000, + command: "javaw -Xmx4096m".to_string(), + java: "javaw.exe (21)".to_string(), + system: "os=windows".to_string(), + stage: None, + stage_detail: None, + stderr_tail: tail, + } +} + +/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。 +/// +/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉, +/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。 +#[test] +fn queue_drives_the_modal_through_refresh_view() { + let mut app = headless(); + app.refresh_view(); + assert!( + app.dialog_model.is_none(), + "空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)" + ); + + let title = app.lang().catalog().text(Key::CrashDialogTitleFailed); + app.state.dialogs.push( + DialogSource::UserAction, + title, + "正文一行", + DialogKind::Confirm { + primary: "主要".to_string(), + secondary: "次要".to_string(), + }, + ); + app.view_dirty = true; + app.refresh_view(); + + let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型"); + assert_eq!(modal.title, title); + assert_eq!(modal.primary, "主要"); + assert_eq!(modal.secondary.as_deref(), Some("次要")); +} + +/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。 +/// +/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定 +/// 英文,本用例第一处断言立刻红。 +#[test] +fn runtime_chinese_reaches_the_body() { + let lang = Lang::ZhCn; + let card = CrashCard { + report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]), + expanded: true, + dialog: 1, + outcome: None, + }; + let mut queue = DialogQueue::new(); + queue.push( + DialogSource::UserAction, + "游戏崩溃", + "", + DialogKind::Confirm { + primary: String::new(), + secondary: String::new(), + }, + ); + let request = queue.current().expect("当前条"); + let view = DialogRequestView::of(request); + let modal = current(lang, &view, Some(&card)); + let body = modal.body_text(); + + // 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。 + assert!(body.contains("退出码 1"), "{body}"); + assert!(body.contains("游戏进程起来了"), "{body}"); + // 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。 + assert!(body.contains("没有生成崩溃报告"), "{body}"); + assert!(body.contains("捕获"), "{body}"); + // 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。 + let en = current(Lang::En, &view, Some(&card)); + assert!(!en.body_text().contains('崩'), "{}", en.body_text()); +} + +/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。 +#[test] +fn two_failure_shapes_say_two_different_things() { + let lang = Lang::ZhCn; + let with = report(true, vec!["stack line".to_string()]); + let without = report(false, vec!["Error: could not find java.dll".to_string()]); + + assert_ne!(with.evidence(), without.evidence()); + let with_card = CrashCard { + report: with.clone(), + expanded: true, + dialog: 1, + outcome: None, + }; + let without_card = CrashCard { + report: without.clone(), + expanded: true, + dialog: 1, + outcome: None, + }; + let mut queue = DialogQueue::new(); + queue.push( + DialogSource::UserAction, + "t", + "", + DialogKind::Error { + primary: "p".to_string(), + hint: None, + }, + ); + let request = queue.current().unwrap(); + let view = DialogRequestView::of(request); + + let crash = current(lang, &view, Some(&with_card)); + let no_log = current(lang, &view, Some(&without_card)); + assert_ne!(crash.body_text(), no_log.body_text()); + assert!(crash.body_text().contains("写出了崩溃报告")); + assert!(!no_log.body_text().contains("写出了崩溃报告")); + // 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。 + assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some()); + assert_eq!(path_rows(&lang.catalog(), &without).len(), 4); + assert!(path_rows(&lang.catalog(), &without) + .iter() + .any(|(_, path)| path.ends_with("latest.log"))); +} + +/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。 +#[test] +fn closing_the_dialog_clears_the_card() { + let mut app = headless(); + crash_report::publish(report(false, vec!["line".to_string()])); + let finished = LaunchFinished { + id: "survival".to_string(), + session: "9f2c".to_string(), + outcome: LogOutcome::Crashed, + exit_code: Some(1), + uptime_ms: 1_000, + failure: None, + }; + app.open_failure_dialog(&finished); + assert!(app.state.dialogs.current().is_some(), "失败必须弹框"); + assert!(app.dialog_card.is_some()); + + app.close_dialog(); + assert!(app.state.dialogs.current().is_none()); + assert!(app.dialog_card.is_none()); +} + +/// 成功收尾不打扰用户。 +#[test] +fn a_successful_launch_does_not_pop_anything() { + let mut app = headless(); + crash_report::publish(report(false, Vec::new())); + let finished = LaunchFinished { + id: "survival".to_string(), + session: "9f2c".to_string(), + outcome: LogOutcome::Ok, + exit_code: Some(0), + uptime_ms: 90_000, + failure: None, + }; + app.open_failure_dialog(&finished); + assert!(app.state.dialogs.current().is_none()); +} + +/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。 +#[test] +fn export_writes_a_bundle_even_without_a_crash_report() { + let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("logs")).expect("建目录"); + std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志"); + + let mut spec_report = report(false, vec!["Error: boom".to_string()]); + spec_report.launcher_logs = Some(root.join("logs")); + spec_report.session_log = None; + spec_report.run_dir = None; + spec_report.version_json = None; + + let text = super::export::export(&spec_report, Lang::ZhCn); + assert!(text.contains("已导出"), "{text}"); + assert!(text.contains("字节"), "{text}"); + + let exports = root.join("logs").join("exports"); + let files: Vec<_> = std::fs::read_dir(&exports) + .expect("导出目录") + .filter_map(Result::ok) + .collect(); + assert_eq!(files.len(), 1, "一次导出只该落一个包"); + let size = files[0].metadata().expect("元数据").len(); + assert!(size > 0, "包不该是空文件"); + assert!( + text.contains(&size.to_string()), + "报出来的字节数要与磁盘一致:{text} / {size}" + ); +} diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs new file mode 100644 index 0000000..f7a9efb --- /dev/null +++ b/crates/app-iced/src/app/dialog/view.rs @@ -0,0 +1,275 @@ +//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。 +//! +//! ## 命中语义(为什么点遮罩能关、点卡片不关) +//! +//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()` +//! 立刻停。所以三层的顺序是: +//! +//! ```text +//! stack![ 页面树, 遮罩(mouse_area → Dismiss), 卡片(mouse_area → Swallow) ] +//! ``` +//! +//! 卡片那层用 `mouse_area` 把左键按下**吃掉**([`super::Message::Swallow`]),于是点卡片里的 +//! 空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获,连 Swallow 都不会发。 + +use iced::widget::text::LineHeight; +use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space}; +use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::spacing::Space as Sp; +use unlucky_design::typography::TypeRole; + +use crate::app::components::buttons; +use crate::pages::frame; +use crate::theme::Ui; + +use super::super::message::Message; +use super::{Message as DialogMessage, Modal}; + +/// 卡片最大宽(对话框档:单列 560,窄窗按视口收)。 +const CARD_WIDTH: f32 = 560.0; +/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。 +const SCROLL_HEIGHT: f32 = 300.0; +/// 卡片最大高(兜底:不许把边框撑出屏幕)。 +const CARD_HEIGHT: f32 = 620.0; +/// 遮罩不透明度(Material scrim 档;0.32 时下面的页面还看得见轮廓)。 +const SCRIM_ALPHA: f32 = 0.32; + +/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。 +pub(crate) fn layer<'a>( + ui: &'a Ui, + base: Element<'a, Message>, + modal: &'a Modal, +) -> Element<'a, Message> { + let scrim = mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(scrim_color(ui))), + ..container::Style::default() + }), + ) + .on_press(Message::Dialog(DialogMessage::Dismiss)); + + let overlay = mouse_area( + container(card(ui, modal)) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(ui.space(Sp::X6)), + ) + .on_press(Message::Dialog(DialogMessage::Swallow)); + + iced::widget::stack![base, scrim, overlay].into() +} + +fn scrim_color(ui: &Ui) -> Color { + Color { + a: SCRIM_ALPHA, + ..ui.color(Role::OnSurface) + } +} + +fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { + let shape = Component::OutlinedCard.spec().shape; + + // 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在 + // (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。 + let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill); + header = header.push(role( + ui, + TypeRole::HeadlineSmall, + true, + modal.title.as_str(), + )); + for line in &modal.body { + header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface))); + } + + let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill); + if let Some(card) = &modal.card { + // **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。 + if card.expanded { + if let Some(title) = &card.detail_title { + middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str())); + } + for line in &card.detail_lines { + middle = middle.push(mono(ui, line.clone())); + } + middle = middle.push(frame::divider(ui).map(Message::Page)); + } + for (index, (label, path)) in card.paths.iter().enumerate() { + middle = middle.push( + button( + column![ + role(ui, TypeRole::LabelMedium, false, label.as_str()), + mono(ui, path.clone()), + ] + .spacing(0.0) + .width(Length::Fill), + ) + .width(Length::Fill) + .padding(0) + .style(|_theme, _status| button::Style { + background: None, + text_color: Color::TRANSPARENT, + border: Border::default(), + shadow: Shadow::default(), + snap: true, + }) + .on_press(Message::Dialog(DialogMessage::CopyPath(index))), + ); + } + } + + let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill); + if let Some(notice) = &modal.notice { + footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary))); + } + footer = footer.push(actions(ui, modal)); + + // 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面 + // (实测第一版就是这样:导出/关闭两个按钮整排不可见)。 + let body = if modal.card.is_some() { + column![ + header, + scrollable(middle).height(Length::Fixed(SCROLL_HEIGHT)), + frame::divider(ui).map(Message::Page), + footer, + ] + } else { + column![header, frame::divider(ui).map(Message::Page), footer,] + } + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + + container(body) + .width(Length::Fixed(CARD_WIDTH)) + .max_width(CARD_WIDTH + 48.0) + .max_height(CARD_HEIGHT + 120.0) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: ui.outlined(shape), + shadow: Shadow { + color: Color { + a: 0.18, + ..Color::BLACK + }, + offset: iced::Vector::new(0.0, 8.0), + blur_radius: 24.0, + }, + ..container::Style::default() + }) + .into() +} + +/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。 +/// +/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样, +/// 「导出诊断包」被挤成两行单字)。 +fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { + let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill); + + if let Some(card) = &modal.card { + column_actions = column_actions.push( + row![ + buttons::outlined( + ui, + buttons::Button::new( + card.actions[1].as_str(), + Message::Dialog(DialogMessage::OpenFolder), + ), + ), + buttons::outlined( + ui, + buttons::Button::new( + card.actions[2].as_str(), + Message::Dialog(DialogMessage::CopyPaths), + ), + ), + buttons::text( + ui, + buttons::Button::new( + card.toggle.as_str(), + Message::Dialog(DialogMessage::Toggle), + ), + ), + ] + .spacing(ui.space(Sp::X2)) + .align_y(Alignment::Center), + ); + } + + let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2)); + if let Some(secondary) = &modal.secondary { + line = line.push(buttons::text( + ui, + buttons::Button::new( + secondary.as_str(), + Message::Dialog(DialogMessage::Secondary), + ), + )); + } + if !modal.blocking_progress { + line = line.push(buttons::filled( + ui, + buttons::Button::new( + modal.primary.as_str(), + Message::Dialog(DialogMessage::Primary), + ), + )); + } + column_actions.push(line.align_y(Alignment::Center)).into() +} + +/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。 +fn role<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: &'a str, +) -> Element<'a, Message> { + frame::text_role( + ui, + role, + emphasized, + content, + ui.color(Role::OnSurfaceVariant), + ) + .map(Message::Page) +} + +/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。 +fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> { + let style = ui.style(TypeRole::BodySmall); + text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(Font { + family: iced::font::Family::Name(ui.families().mono), + ..Font::DEFAULT + }) + .color(ui.color(Role::OnSurface)) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into() +} + +/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。 +fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> { + let style = ui.style(TypeRole::BodyMedium); + text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into() +} diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index 2fcaabb..2d9c4e6 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -67,6 +67,8 @@ pub enum Message { Captured(window::Screenshot), /// gallery(组件库 harness)里的演示交互。 Demo(components::gallery::DemoMessage), + /// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。 + Dialog(super::dialog::Message), } /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index a58ccbb..4a0be42 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -30,6 +30,7 @@ mod appearance; mod backend; mod boot; +mod dialog; mod message; mod state; mod subscription; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index a835487..acff1c8 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -193,6 +193,16 @@ pub struct App { /// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在 /// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。 pub(super) launch_form: pages::LaunchForm, + /// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。 + /// + /// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」 + /// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。 + pub(super) dialog_card: Option, + /// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。 + /// + /// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就 + /// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。 + pub(super) dialog_model: Option, /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 pub(super) avatars: pages::account::avatar::Cache, /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 231d64b..b6f4a2e 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -135,6 +135,8 @@ impl App { launch_form: pages::LaunchForm::default(), install_cancelled_seen: 0, launch_ends_seen: 0, + dialog_card: None, + dialog_model: None, content: pages::content::PageState::default(), account_form: pages::account::Form::new(), account: pages::account::Page::build( diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index cfd1e58..859c84a 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -51,6 +51,9 @@ impl App { ToastKind::Error }; self.state.toasts.push(kind, text); + // M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。 + let ended = finished.clone(); + self.open_failure_dialog(&ended); } } @@ -78,6 +81,9 @@ impl App { // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, // 皮肤解码走缓存,所以重复重建不重解图)。 self.rebuild_account_page(width); + // M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量 + // (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。 + self.dialog_model = self.dialog_modal(); self.view_dirty = false; self.refreshes += 1; } diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 110d8a6..e1e13dd 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -48,12 +48,25 @@ mod boot_env; mod clock; mod content; mod content_pack; +mod dialog; +mod dialog_fixture; mod download; mod instances; mod page_messages; mod settings; mod update_action; +/// 这一条键盘事件是不是 `Esc` 按下。 +fn is_escape(event: &iced::keyboard::Event) -> bool { + matches!( + event, + iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + } + ) +} + impl App { /// 一条消息进来,改状态并返回后续任务。 pub(super) fn update(&mut self, message: Message) -> Task { @@ -119,7 +132,16 @@ impl App { Task::none() } Message::Page(message) => self.on_page(message), - Message::Agent(message) => self.on_agent(message), + Message::Dialog(message) => dialog::on_dialog(self, message), + Message::Agent(message) => { + // `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。 + if let crate::shell::agent::Message::Key(event) = &message { + if is_escape(event) && dialog::escape(self) { + return Task::none(); + } + } + self.on_agent(message) + } // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 Message::Select(nav) => { self.state.navigate(route_of(nav)); @@ -202,6 +224,10 @@ impl App { self.ensure_content_loaded(); } account::force_account_state(self); + // M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。 + if let Some(mode) = dialog_fixture::apply(self) { + eprintln!("[boot] ULL_DIALOG_FIXTURE → {mode}"); + } self.auto_select(); self.view_dirty = true; diff --git a/crates/app-iced/src/app/update/dialog.rs b/crates/app-iced/src/app/update/dialog.rs new file mode 100644 index 0000000..a3d08eb --- /dev/null +++ b/crates/app-iced/src/app/update/dialog.rs @@ -0,0 +1,101 @@ +//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。 +//! +//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。 +//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级, +//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。 + +use iced::Task; +use unlucky_presenter::toast::ToastKind; + +use crate::app::dialog::{export, Message as DialogMessage}; +use crate::app::state::App; + +pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task { + match message { + DialogMessage::Primary => { + // 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。 + if let Some(card) = app.dialog_card.clone() { + let text = export::export(&card.report, app.lang()); + let failed = card.report.launcher_logs.is_none(); + app.state.toasts.push( + if failed { + ToastKind::Error + } else { + ToastKind::Success + }, + text.clone(), + ); + // 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast + // (app-iced 的 Toast 至今没有渲染点)。 + if let Some(state) = &mut app.dialog_card { + state.outcome = Some(text); + } + app.view_dirty = true; + app.refresh_view(); + return Task::none(); + } + app.close_dialog(); + Task::none() + } + DialogMessage::Secondary | DialogMessage::Dismiss => { + app.close_dialog(); + Task::none() + } + DialogMessage::Swallow => Task::none(), + DialogMessage::Toggle => { + app.toggle_dialog_detail(); + Task::none() + } + DialogMessage::OpenFolder => { + let Some(card) = app.dialog_card.clone() else { + return Task::none(); + }; + let problem = export::open_folder(&card.report, app.lang()); + if !problem.is_empty() { + if let Some(state) = &mut app.dialog_card { + state.outcome = Some(problem.clone()); + } + app.state.toasts.push(ToastKind::Error, problem); + app.view_dirty = true; + app.refresh_view(); + } + Task::none() + } + DialogMessage::CopyPaths => { + let Some(card) = app.dialog_card.clone() else { + return Task::none(); + }; + app.state.toasts.push( + ToastKind::Info, + app.lang() + .catalog() + .text(unlucky_i18n::catalog::Key::CrashDialogCopied), + ); + iced::clipboard::write(export::paths_text(&card.report)) + } + DialogMessage::CopyPath(index) => { + let Some(card) = app.dialog_card.clone() else { + return Task::none(); + }; + let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else { + return Task::none(); + }; + app.state.toasts.push( + ToastKind::Info, + app.lang() + .catalog() + .text(unlucky_i18n::catalog::Key::CrashDialogCopied), + ); + iced::clipboard::write(path.display().to_string()) + } + } +} + +/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。 +pub(super) fn escape(app: &mut App) -> bool { + if app.state.dialogs.current().is_none() { + return false; + } + app.close_dialog(); + true +} diff --git a/crates/app-iced/src/app/update/dialog_fixture.rs b/crates/app-iced/src/app/update/dialog_fixture.rs new file mode 100644 index 0000000..bd1d9e5 --- /dev/null +++ b/crates/app-iced/src/app/update/dialog_fixture.rs @@ -0,0 +1,152 @@ +//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。 +//! +//! ## 为什么要有它 +//! +//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个 +//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档: +//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log), +//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。 +//! +//! 取值: +//! +//! | 值 | 现场 | +//! |---|---| +//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) | +//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) | +//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) | +//! +//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` → +//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport}; +use unlucky_core::launch::crash_scan::{self, CrashSource}; +use unlucky_presenter::dto::{LaunchFinished, LogOutcome}; + +use crate::app::state::App; + +/// 现场根(`%TEMP%/ull-m187-fixture-`)。 +fn root() -> PathBuf { + std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id())) +} + +fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, text); +} + +/// 出图用的夹具(`None` = 没有开关,什么都不做)。 +pub(super) fn apply(app: &mut App) -> Option { + let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?; + let mode = mode.trim().to_lowercase(); + if !app.has_screenshot() { + return None; + } + let root = root(); + let _ = std::fs::remove_dir_all(&root); + let logs = root.join("logs"); + write( + &logs.join("launcher.log"), + "2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\ + 2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\ + 2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n", + ); + write(&logs.join("install.log"), "installed 1.20.4-fabric\n"); + + let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e"; + let session_log = root + .join("sessions") + .join("game") + .join(format!("{session}.jsonl")); + write( + &session_log, + "{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\ + {\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\ + {\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\ + {\"kind\":\"exit\",\"payload\":{\"code\":1}}\n", + ); + let tail: Vec = vec![ + "[main/INFO]: Loading Minecraft 1.20.4".to_string(), + "java.lang.RuntimeException: mixin injection failed".to_string(), + "Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(), + ]; + + let run_dir = root.join("instances").join("survival"); + let latest = run_dir.join("logs").join("latest.log"); + let version_json = root + .join("game") + .join("versions") + .join("1.20.4-fabric") + .join("1.20.4-fabric.json"); + write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n"); + + let mut artifacts: Vec = Vec::new(); + if mode != "no-logs" { + write( + &run_dir + .join("crash-reports") + .join("crash-2026-11-08_15.30.19-client.txt"), + "---- Minecraft Crash Report ----\n\ + Time: 2026-11-08 15:30:19\n\ + Description: Initializing game\n\n\ + java.lang.RuntimeException: mixin injection failed\n\ + \tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\ + \tat net.minecraft.client.main.Main.main(Main.java:51)\n", + ); + write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n"); + let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120); + artifacts = crash_scan::scan(&run_dir, since, 8) + .into_iter() + .map(|artifact| ArtifactFact { + source: artifact.source.as_str().to_string(), + path: artifact.path, + excerpt: artifact.excerpt, + }) + .collect(); + } + + let report = CrashReport { + instance: "survival".to_string(), + name: "生存服的 1.20.4".to_string(), + version: "1.20.4-fabric".to_string(), + session: session.to_string(), + session_log: Some(session_log), + version_json: Some(version_json), + launcher_logs: Some(logs), + run_dir: Some(run_dir), + artifacts, + exit_code: Some(1), + exit_type: "application_error".to_string(), + uptime_ms: 7_400, + command: "javaw -Xmx4096m -Djava.library.path=… ".to_string(), + java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(), + system: "os=windows version=10.0.26100 arch=x86_64".to_string(), + stage: None, + stage_detail: None, + stderr_tail: tail, + }; + crash_report::publish(report); + let finished = LaunchFinished { + id: "survival".to_string(), + session: session.to_string(), + outcome: LogOutcome::Crashed, + exit_code: Some(1), + uptime_ms: 7_400, + failure: None, + }; + app.open_failure_dialog(&finished); + if mode == "export" { + // 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。 + let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary); + } + Some(mode) +} + +/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。 +#[allow(dead_code)] +pub(super) fn crash_report_source() -> &'static str { + CrashSource::CrashReport.as_str() +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index e6d9c26..997497f 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -75,7 +75,8 @@ impl App { }; let account = self.rail_account(); - match pages::Screen::of(self.state.route()) { + // M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。 + let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) { Some(_) => shell::view_with_account( &self.ui, shell::NavRail { @@ -124,6 +125,11 @@ impl App { self.options.force_state, self.lang(), ), + }; + // 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。 + match self.dialog_model.as_ref() { + Some(modal) => super::dialog::view::layer(&self.ui, tree, modal), + None => tree, } } } diff --git a/crates/core/src/launch/crash_bundle.rs b/crates/core/src/launch/crash_bundle.rs new file mode 100644 index 0000000..be453e8 --- /dev/null +++ b/crates/core/src/launch/crash_bundle.rs @@ -0,0 +1,302 @@ +//! 诊断包(M187):把一次启动失败的现场打成一个 zip 交给用户。 +//! +//! ## 为什么不只是「打开日志文件夹」 +//! +//! 用户实测(2026-11-08):「MC 启动失败『游戏崩溃 退出码 1』,找半天没找到崩溃日志」。 +//! 现场散在四处——启动器日志(`.ULL/logs/`)、会话事件流 JSONL(`data/agent/sessions/game/`)、 +//! 游戏自己写的 `crash-reports/` 与 `logs/latest.log`、还有环境(系统 / Java / 命令行)。 +//! 让用户自己去四个目录里拼,等于没修。打成一个包 + 一行清单,才是「能发给别人看」的东西。 +//! +//! ## 三条纪律 +//! +//! 1. **落盘即脱敏**(`launch::redact` 的唯一一份规则):主目录 → ``,token → 占位符, +//! 长不透明串(base64/hex)与带点 token(JWT 形状)同样替换;调用方另外把**会话 id** +//! 之类也要抹掉的串塞进 [`BundleSpec::secrets`]。 +//! 2. **缺什么要写出来**:`crash-reports/` 不存在不是错误——那是「游戏没来得及写」的**证据**, +//! 清单里必须有一行说清楚,不许静默漏掉。见 [`BundleSpec::missing`]。 +//! 3. **有上限**:单个文件最多收 [`MAX_ENTRY_BYTES`],包内总字节最多 [`MAX_TOTAL_BYTES`]。 +//! 超了就在清单里记一行「已截断」。数百 MB 的 `latest.log` 不该把导出卡成十秒。 +//! +//! 不做的事:不判断崩溃原因(那是诊断引擎 `diagnose` 的活),不改任何图片之外的二进制, +//! 不联网。打包只读盘。 + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use super::redact::Redactor; + +/// 单个文件最多收多少字节(超出只收头部,清单里记「已截断」)。 +pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024; +/// 整个包最多收多少字节(超出后剩余文件只记清单、不收内容)。 +pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024; + +/// 一个要进包的文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BundleEntry { + /// 包内分组(`launcher` / `session` / `game` / `instance`),决定包内目录。 + pub group: &'static str, + /// 源文件绝对路径。 + pub path: PathBuf, +} + +impl BundleEntry { + /// 建一条。 + pub fn new(group: &'static str, path: impl Into) -> Self { + Self { + group, + path: path.into(), + } + } +} + +/// 一次导出的输入(全部是事实,不含任何结论)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BundleSpec { + /// 实例名。 + pub instance: String, + /// 版本 id。 + pub version: String, + /// 会话 id(**同时会被脱敏**)。 + pub session: String, + /// 退出码(`None` = 进程没起来)。 + pub exit_code: Option, + /// 退出判定 slug(`application_error` / `jvm_error` / `normal` …)。 + pub exit_type: String, + /// 存活毫秒数。 + pub uptime_ms: u64, + /// 脱敏后的命令行(**调用方**已经过 [`Redactor::scrub_all`])。 + pub command: String, + /// Java 一行说明(路径 + 主版本)。 + pub java: String, + /// 系统一行说明(`OsInfo::summary_line`)。 + pub system: String, + /// 额外说明(例如「本次没有生成 crash-report」)。 + pub notes: Vec, + /// 明确不存在的现场(清单里写成「没有:…」)。 + pub missing: Vec, + /// 要收的文件。 + pub entries: Vec, + /// 除了主目录之外还要抹掉的串(会话 id 等)。 + pub secrets: Vec, + /// 用户主目录(`None` = 不抹)。 + pub home: Option, +} + +impl BundleSpec { + /// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。 + pub fn manifest(&self) -> String { + let mut text = String::new(); + text.push_str("Unlucky Launcher — launch failure bundle\n"); + text.push_str("=======================================\n"); + text.push_str(&format!("instance: {}\n", self.instance)); + text.push_str(&format!("version: {}\n", self.version)); + text.push_str(&format!("session: {}\n", self.session)); + text.push_str(&format!( + "exit_code: {}\n", + self.exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "(none)".to_string()) + )); + text.push_str(&format!("exit_type: {}\n", self.exit_type)); + text.push_str(&format!("uptime: {:.1}s\n", self.uptime_ms as f64 / 1000.0)); + text.push_str(&format!("os: {}\n", self.system)); + text.push_str(&format!("java: {}\n", self.java)); + text.push_str(&format!("command: {}\n", self.command)); + + if !self.notes.is_empty() { + text.push_str("\nnotes\n-----\n"); + for note in &self.notes { + text.push_str(&format!("- {note}\n")); + } + } + if !self.missing.is_empty() { + text.push_str("\nmissing (不存在)\n----------------\n"); + for item in &self.missing { + text.push_str(&format!("- {item}\n")); + } + } + + text.push_str("\nfiles\n-----\n"); + for entry in &self.entries { + text.push_str(&format!( + "- {}/{} <- {}\n", + entry.group, + file_name(&entry.path), + entry.path.display() + )); + } + text + } + + fn redactor(&self) -> Redactor { + let mut redactor = Redactor::new(); + if let Some(home) = &self.home { + redactor = redactor.with_home(home); + } + for secret in &self.secrets { + redactor = redactor.with_secret(secret.clone()); + } + redactor + } +} + +/// 一次导出的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bundle { + /// 落盘路径。 + pub path: PathBuf, + /// 字节数(**落盘后**的真实大小)。 + pub bytes: u64, + /// 收进包的文件数(不含清单)。 + pub files: usize, + /// 被截断 / 因总量上限没收内容的文件数。 + pub truncated: usize, +} + +impl Bundle { + /// 一行说明(Toast 与日志共用)。 + pub fn describe(&self) -> String { + format!( + "{} ({} bytes, {} files, {} truncated)", + self.path.display(), + self.bytes, + self.files, + self.truncated + ) + } +} + +/// 把 `spec` 打成一个 zip 写到 `target`(父目录不存在会建)。 +/// +/// # Errors +/// 目录建不出来、zip 写失败、清单写失败时返回人话(导出失败要能一眼看出哪一步断)。 +pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result { + let redactor = spec.redactor(); + + if let Some(parent) = target.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?; + } + } + let file = File::create(target) + .map_err(|error| format!("建诊断包 {} 失败:{error}", target.display()))?; + let mut zip = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + + let mut manifest = spec.manifest(); + // 截断与缺失在**收集时**才知道,所以清单最后再补一段。 + let mut collected = Vec::new(); + let mut total: u64 = 0; + let mut files = 0usize; + let mut truncated = 0usize; + let mut used_names: Vec = Vec::new(); + + for entry in &spec.entries { + let name = unique_name(&mut used_names, entry.group, &file_name(&entry.path)); + let Ok(bytes) = std::fs::read(&entry.path) else { + collected.push(format!("- {name}: 读不出来(已跳过)")); + truncated += 1; + continue; + }; + let original_len = bytes.len(); + let mut body = bytes; + if total + body.len() as u64 > MAX_TOTAL_BYTES || body.len() as u64 > MAX_ENTRY_BYTES { + let keep = usize::try_from(MAX_ENTRY_BYTES.min(body.len() as u64)).unwrap_or(0); + let keep = usize::try_from(MAX_TOTAL_BYTES.saturating_sub(total).min(keep as u64)) + .unwrap_or(0); + if keep == 0 { + collected.push(format!("- {name}: 超出总量上限,未收内容")); + truncated += 1; + continue; + } + body.truncate(keep); + collected.push(format!( + "- {name}: 已截断(只收前 {keep} 字节,原 {original_len} 字节)" + )); + truncated += 1; + } + // 文本才脱敏;二进制原样(截断过的文本尾部可能是半个 UTF-8 字符,退化成原样字节)。 + if let Ok(text) = String::from_utf8(body.clone()) { + body = redactor.scrub(&text).into_owned().into_bytes(); + } + total += body.len() as u64; + files += 1; + write_entry(&mut zip, &name, &body, options)?; + } + + if !collected.is_empty() { + manifest.push_str("\ncollected\n---------\n"); + for line in collected { + manifest.push_str(&line); + manifest.push('\n'); + } + } + write_entry( + &mut zip, + "manifest.txt", + redactor.scrub(&manifest).as_bytes(), + options, + )?; + zip.finish() + .map_err(|error| format!("收尾诊断包失败:{error}"))?; + + let bytes = std::fs::metadata(target) + .map(|meta| meta.len()) + .unwrap_or(0); + Ok(Bundle { + path: target.to_path_buf(), + bytes, + files, + truncated, + }) +} + +fn write_entry( + zip: &mut zip::ZipWriter, + name: &str, + body: &[u8], + options: zip::write::SimpleFileOptions, +) -> Result<(), String> { + zip.start_file(name, options) + .map_err(|error| format!("写入 {name} 失败:{error}"))?; + zip.write_all(body) + .map_err(|error| format!("写入 {name} 失败:{error}")) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()) +} + +/// 包内重名时加序号(`latest.log` 与 `crash-reports/latest.log` 不会互相覆盖)。 +fn unique_name(used: &mut Vec, group: &str, name: &str) -> String { + let base = format!("{group}/{name}"); + if !used.contains(&base) { + used.push(base.clone()); + return base; + } + for index in 2.. { + let candidate = format!("{group}/{index}-{name}"); + if !used.contains(&candidate) { + used.push(candidate.clone()); + return candidate; + } + } + unreachable!("序号一定找得到空位") +} + +/// 默认落点:启动器日志分区下的 `exports/`。 +/// +/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope), +/// 也不该为一次导出引一棵新依赖树。导出后界面把**完整路径**与字节数都给出来,另有 +/// 「打开文件夹」按钮直达——用户不用猜。 +pub fn default_target_dir(logs_dir: &Path) -> PathBuf { + logs_dir.join("exports") +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/launch/crash_bundle/tests.rs b/crates/core/src/launch/crash_bundle/tests.rs new file mode 100644 index 0000000..ea47b1c --- /dev/null +++ b/crates/core/src/launch/crash_bundle/tests.rs @@ -0,0 +1,191 @@ +//! 诊断包的断言:清单齐全、脱敏生效、缺件写明白、超大文件截断。 +//! +//! 为什么这些值得单开文件:守卫的三条(内容有清单 / 脱敏生效 / 没 crash-report 也能导出) +//! 都要**真读回 zip** 才作数——只断言 `write_bundle` 返回 `Ok` 抓不住任何一种坏法。 + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use super::{default_target_dir, write_bundle, BundleEntry, BundleSpec, MAX_ENTRY_BYTES}; + +fn temp_dir(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ull-m187-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("建夹具目录"); + root +} + +fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建父目录"); + } + std::fs::write(path, text).expect("写夹具"); +} + +/// 打开 zip 读某个条目的全文(不存在返回 `None`)。 +fn entry_text(path: &Path, name: &str) -> Option { + let file = std::fs::File::open(path).expect("打开 zip"); + let mut archive = zip::ZipArchive::new(file).expect("读 zip"); + let mut entry = archive.by_name(name).ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).expect("读条目"); + Some(text) +} + +fn names(path: &Path) -> Vec { + let file = std::fs::File::open(path).expect("打开 zip"); + let mut archive = zip::ZipArchive::new(file).expect("读 zip"); + (0..archive.len()) + .filter_map(|index| { + let item = archive.by_index(index).ok()?; + item.name().ok().map(|name| name.to_string()) + }) + .collect() +} + +fn spec(root: &Path, secrets: Vec) -> BundleSpec { + BundleSpec { + instance: "1.20.4 · Fabric".into(), + version: "1.20.4-fabric".into(), + session: "0f8fad5b-d9cb-469f-a165-70867728950e".into(), + exit_code: Some(1), + exit_type: "application_error".into(), + uptime_ms: 12_345, + command: "javac -Xmx4096m -Djava.library.path=".into(), + java: "C:/Java/bin/javaw.exe (major 21)".into(), + system: "os=windows version=10.0 arch=x86_64".into(), + notes: vec!["本次没有生成 crash-report".into()], + missing: vec!["crash-reports/ 不存在".into()], + entries: vec![ + BundleEntry::new("launcher", root.join("launcher.log")), + BundleEntry::new("session", root.join("game").join("s1.jsonl")), + ], + secrets, + home: Some(root.join("home")), + } +} + +#[test] +fn bundle_has_a_manifest_and_the_collected_files() { + let root = temp_dir("manifest"); + write(&root.join("launcher.log"), "[launch] spawn argv=...\n"); + write(&root.join("game").join("s1.jsonl"), "{\"kind\":\"exit\"}\n"); + + let target = default_target_dir(&root).join("m187.zip"); + let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出"); + + assert!(bundle.bytes > 0, "落盘字节数不该是 0"); + assert_eq!(bundle.files, 2, "两个现场文件都该进包"); + assert_eq!(bundle.truncated, 0); + + let listed = names(&target); + assert!(listed.contains(&"manifest.txt".to_string()), "{listed:?}"); + assert!( + listed.contains(&"launcher/launcher.log".to_string()), + "{listed:?}" + ); + assert!( + listed.contains(&"session/s1.jsonl".to_string()), + "{listed:?}" + ); + + let manifest = entry_text(&target, "manifest.txt").expect("清单"); + // 清单要能把「发给别人看」需要的三件事说清:哪一场、什么退出、文件在哪。 + assert!(manifest.contains("instance: 1.20.4 · Fabric"), "{manifest}"); + assert!(manifest.contains("exit_code: 1"), "{manifest}"); + assert!( + manifest.contains("exit_type: application_error"), + "{manifest}" + ); + assert!(manifest.contains("launcher/launcher.log <-"), "{manifest}"); +} + +/// 守卫②:**脱敏真的生效**(主目录 / token / 会话 id 三条各自替换)。 +#[test] +fn secrets_home_and_session_ids_are_scrubbed_in_every_entry() { + let root = temp_dir("scrub"); + let home = root.join("home"); + let token = "eyJhbGciOiJIUzI1NiJ9.eyJ4IjoxMjN9.c2lnbmF0dXJlLXZhbHVl"; + let session = "0f8fad5b-d9cb-469f-a165-70867728950e"; + let dirty = format!( + "argv=--accessToken {token}\npath={}\nsession={session}\n", + home.join("AppData").display() + ); + write(&root.join("launcher.log"), &dirty); + write(&root.join("game").join("s1.jsonl"), &dirty); + + let target = default_target_dir(&root).join("m187.zip"); + let mut spec = spec(&root, vec![session.to_string()]); + spec.command = format!("java -accessToken {token}"); + let bundle = write_bundle(&spec, &target).expect("导出"); + assert_eq!(bundle.files, 2); + + for name in ["manifest.txt", "launcher/launcher.log", "session/s1.jsonl"] { + let text = entry_text(&target, name).unwrap_or_else(|| panic!("缺条目 {name}")); + assert!(!text.contains(token), "{name} 漏了 token:{text}"); + assert!(!text.contains(session), "{name} 漏了会话 id:{text}"); + } + let log = entry_text(&target, "launcher/launcher.log").expect("日志"); + assert!(log.contains(""), "主目录没被替换:{log}"); + assert!( + !log.contains(&home.display().to_string()), + "家目录原文还在:{log}" + ); +} + +/// 守卫③:**没有 crash-report 也能导出**,而且「没有」这件事写进清单(不许静默漏掉)。 +#[test] +fn missing_crash_report_is_stated_in_the_manifest() { + let root = temp_dir("missing"); + write( + &root.join("game").join("s1.jsonl"), + "{\"kind\":\"stderr\"}\n", + ); + + let target = default_target_dir(&root).join("m187.zip"); + let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出"); + assert_eq!(bundle.files, 1, "只有会话 JSONL 时也要出包"); + + let manifest = entry_text(&target, "manifest.txt").expect("清单"); + assert!(manifest.contains("missing"), "{manifest}"); + assert!(manifest.contains("crash-reports/ 不存在"), "{manifest}"); + assert!(manifest.contains("本次没有生成 crash-report"), "{manifest}"); + // 清单里也要能看见那个「不存在的文件名」,否则用户以为我们忘收了。 + assert!(manifest.contains("session/s1.jsonl"), "{manifest}"); +} + +/// 有上限:单个超限文件只收头部,并在清单里记一行「已截断」。 +#[test] +fn oversized_entries_are_truncated_and_recorded() { + let root = temp_dir("truncate"); + let big = root.join("game").join("latest.log"); + let body = "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32); + write(&big, &body); + + let target = default_target_dir(&root).join("m187.zip"); + let mut spec = spec(&root, Vec::new()); + spec.entries = vec![BundleEntry::new("game", &big)]; + let bundle = write_bundle(&spec, &target).expect("导出"); + + assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数"); + let manifest = entry_text(&target, "manifest.txt").expect("清单"); + assert!(manifest.contains("已截断"), "{manifest}"); + assert!( + bundle.bytes < MAX_ENTRY_BYTES + 8 * 1024, + "包不该把整个超限文件塞进去({} 字节)", + bundle.bytes + ); +} + +#[test] +fn missing_source_files_are_skipped_not_fatal() { + let root = temp_dir("gone"); + let mut spec = spec(&root, Vec::new()); + spec.entries = vec![BundleEntry::new("game", root.join("nope/absent.log"))]; + + let target = default_target_dir(&root).join("m187.zip"); + let bundle = write_bundle(&spec, &target).expect("缺文件也要出包"); + assert_eq!(bundle.files, 0); + let manifest = entry_text(&target, "manifest.txt").expect("清单"); + assert!(manifest.contains("读不出来"), "{manifest}"); +} diff --git a/crates/core/src/launch/crash_report.rs b/crates/core/src/launch/crash_report.rs new file mode 100644 index 0000000..2b978ac --- /dev/null +++ b/crates/core/src/launch/crash_report.rs @@ -0,0 +1,394 @@ +//! 一次失败启动的**现场**(M187):路径 + 捕获输出 + 退出事实,供失败弹窗与诊断包共用。 +//! +//! ## 为什么是「事实」而不是「结论」 +//! +//! 弹窗要说三件事——**是哪一场失败**(装配阶段 / 起进程后崩溃)、**退出码**、**证据在哪**。 +//! 这三件都是启动器已经知道的事实,不含任何推断。崩溃原因的智能诊断是另一条线(诊断引擎 +//! `diagnose` / AI),本模块只把事实摆齐,让用户能一键把证据带走。 +//! +//! ## 两种失败形态必须分开(用户投诉的原点) +//! +//! | 形态 | 判据 | 弹窗该说什么 | +//! |---|---|---| +//! | 游戏自己写了崩溃报告 | [`CrashReport::has_game_crash_report`] 为真 | 「游戏写出了崩溃报告」,贴 Description/栈片段 | +//! | 游戏**没来得及写** | 没有 crash-report,但有捕获输出 | 「这次没有生成崩溃报告,下面是捕获的最后若干行」 | +//! | 什么输出都没抓到 | 两者皆无 | 「游戏还没来得及写日志」+ 最可能原因分类(缺 Java / 内存 / 参数 / 库) | +//! +//! 第二、三种**不许**显示成「找不到日志」——日志在启动器这里,只是游戏没写它自己那份。 +//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。 + +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +use super::crash_scan::{self, CrashArtifact, CrashSource}; +use super::launch_session::SessionHandle; +use super::redact::Redactor; + +/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。 +pub const TAIL_LINES: usize = 30; +/// 现场文件的扫描条数上限。 +pub const MAX_ARTIFACTS: usize = 8; +/// 弹窗里贴崩溃报告摘要的字符上限。 +pub const MAX_EXCERPT_CHARS: usize = 1200; + +/// 一个现场文件(来源 + 路径 + 人读摘要)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtifactFact { + /// 来源 slug(`crash_report` / `hs_err` / `latest_log`)。 + pub source: String, + /// 文件路径。 + pub path: PathBuf, + /// 摘要(崩溃报告的 Description + 栈片段;`latest.log` 与 `hs_err` 不给)。 + pub excerpt: Option, +} + +/// 一次失败启动的现场。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CrashReport { + /// 实例 id。 + pub instance: String, + /// 实例名。 + pub name: String, + /// 绑定的版本 id。 + pub version: String, + /// 会话 id(空 = 进程根本没起来)。 + pub session: String, + /// 会话事件流 JSONL(**游戏侧证据的真正落点**)。 + pub session_log: Option, + /// 该实例绑定的版本 JSON(诊断包要带上它,别人才能复现装配)。 + pub version_json: Option, + /// 启动器自己的日志目录(`.ULL/logs`)。 + pub launcher_logs: Option, + /// 实例运行目录(`crash-reports/` 与 `logs/` 的父目录)。 + pub run_dir: Option, + /// 崩溃现场文件(按修改时间倒序)。 + pub artifacts: Vec, + /// 退出码(`None` = 进程没起来 / 平台没给)。 + pub exit_code: Option, + /// 退出判定 slug(`application_error` / `jvm_error` / `normal` …;进程没起来时是 `spawn`)。 + pub exit_type: String, + /// 存活毫秒数。 + pub uptime_ms: u64, + /// 脱敏后的命令行一行。 + pub command: String, + /// Java 一行说明(二进制 + 主版本)。 + pub java: String, + /// 系统一行说明。 + pub system: String, + /// 装配阶段失败的 slug(`version_manifest` / `spawn` …);`None` = 进程起来过。 + pub stage: Option, + /// 装配阶段的原始错误文本(不翻译,可能含路径)。 + pub stage_detail: Option, + /// 启动器捕获的最后若干行(已脱敏、已截断到 [`TAIL_LINES`])。 + pub stderr_tail: Vec, +} + +/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Evidence { + /// 游戏自己写了 `crash-reports/crash-*.txt`。 + GameCrashReport, + /// 游戏没写崩溃报告,但启动器抓到了输出。 + CapturedOutput, + /// 连输出都没抓到(JVM 都没起来 / 立刻失败)。 + Nothing, +} + +impl Evidence { + /// 稳定 slug(测试与日志用;不是文案)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::GameCrashReport => "game_crash_report", + Self::CapturedOutput => "captured_output", + Self::Nothing => "nothing", + } + } +} + +impl CrashReport { + /// 游戏侧证据的形态(界面按它选文案,**不许**再自己判断)。 + #[must_use] + pub fn evidence(&self) -> Evidence { + if self.has_game_crash_report() { + Evidence::GameCrashReport + } else if self.stderr_tail.is_empty() { + Evidence::Nothing + } else { + Evidence::CapturedOutput + } + } + + /// 是否找到游戏自己写的崩溃报告。 + #[must_use] + pub fn has_game_crash_report(&self) -> bool { + self.artifacts + .iter() + .any(|a| a.source == CrashSource::CrashReport.as_str()) + } + + /// 进程是否起来过(决定标题写「启动失败」还是「游戏崩溃」)。 + #[must_use] + pub fn started(&self) -> bool { + self.stage.is_none() + } + + /// 该实例运行目录下的 `crash-reports/`。 + #[must_use] + pub fn crash_reports_dir(&self) -> Option { + self.run_dir.as_ref().map(|dir| dir.join("crash-reports")) + } + + /// 该实例的 `logs/latest.log`。 + #[must_use] + pub fn latest_log_path(&self) -> Option { + self.run_dir + .as_ref() + .map(|dir| dir.join("logs").join("latest.log")) + } + + /// 供「复制路径」用的(标签, 路径)列表(提示词在前、目录在后)。 + #[must_use] + pub fn path_lines(&self) -> Vec<(String, PathBuf)> { + let mut lines = Vec::new(); + if let Some(log) = &self.session_log { + lines.push(("session".to_string(), log.clone())); + } + if let Some(dir) = &self.launcher_logs { + lines.push(("launcher_logs".to_string(), dir.clone())); + } + if let Some(path) = self.latest_log_path() { + lines.push(("latest_log".to_string(), path)); + } + if let Some(dir) = self.crash_reports_dir() { + lines.push(("crash_reports".to_string(), dir)); + } + if let Some(dir) = &self.run_dir { + lines.push(("run_dir".to_string(), dir.clone())); + } + lines + } + + /// 收集一次**已经起来过**的启动的现场。 + /// + /// `session` 由调用方在 `finish()` 之后给出;`lines` 是该会话的事件流行(**调用方读**, + /// 因为读 JSONL 属于 host 的活)。只有最后 [`TAIL_LINES`] 行进弹窗。 + #[must_use] + pub fn collect(session: &SessionHandle, ctx: &ReportContext, lines: &[String]) -> Self { + let run_dir = ctx.run_dir.clone(); + let artifacts = crash_scan::scan(&run_dir, ctx.started_at, MAX_ARTIFACTS) + .into_iter() + .map(|artifact| fact(&artifact, &ctx.redactor())) + .collect(); + + let raw: Vec = lines.iter().rev().take(TAIL_LINES).rev().cloned().collect(); + + Self { + instance: ctx.instance.clone(), + name: ctx.name.clone(), + version: ctx.version.clone(), + session: session.session_id.clone(), + session_log: Some(session.log_path.clone()), + version_json: ctx.version_json.clone(), + launcher_logs: Some(ctx.launcher_logs.clone()), + run_dir: Some(run_dir), + artifacts, + exit_code: Some(session.exit.code), + exit_type: session.exit.exit_type.as_str().to_string(), + uptime_ms: session.uptime_ms, + command: ctx + .redactor() + .scrub(session.redacted_display().as_str()) + .into_owned(), + java: ctx.java.clone(), + system: ctx.system.clone(), + stage: None, + stage_detail: None, + stderr_tail: scrub_tail(&raw, &ctx.redactor()), + } + } + + /// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。 + /// + /// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以 + /// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。 + #[must_use] + pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self { + let redactor = ctx.redactor(); + Self { + instance: ctx.instance.clone(), + name: ctx.name.clone(), + version: ctx.version.clone(), + session: String::new(), + session_log: None, + version_json: ctx.version_json.clone(), + launcher_logs: Some(ctx.launcher_logs.clone()), + run_dir: Some(ctx.run_dir.clone()), + artifacts: Vec::new(), + exit_code: None, + exit_type: exit_type.to_string(), + uptime_ms: 0, + command: String::new(), + java: ctx.java.clone(), + system: ctx.system.clone(), + stage: Some(stage.to_string()), + stage_detail: Some(redactor.scrub(detail).into_owned()), + stderr_tail: Vec::new(), + } + } + + /// 诊断包清单里要写明的「没有的东西」(**不许静默省略**)。 + #[must_use] + pub fn missing_lines(&self) -> Vec { + let mut missing = Vec::new(); + if !self.has_game_crash_report() { + missing.push("crash-reports/crash-*.txt: 本次没有(游戏没来得及写)".to_string()); + } + if let Some(path) = self.latest_log_path() { + if !path.is_file() { + missing.push(format!("logs/latest.log: 不存在({})", path.display())); + } + } + if self.session_log.is_none() { + missing.push("会话 JSONL: 不存在(进程没起来)".to_string()); + } + missing + } + + /// 备注(清单里「这次发生了什么」那一段)。 + #[must_use] + pub fn note_lines(&self) -> Vec { + let mut notes = match self.evidence() { + Evidence::GameCrashReport => vec!["游戏写出了崩溃报告(crash-reports/)".to_string()], + Evidence::CapturedOutput => { + vec!["本次没有生成崩溃报告;证据是启动器捕获的输出".to_string()] + } + Evidence::Nothing => vec![ + "本次没有崩溃报告,也没捕获到输出(最可能:缺 Java / 内存不足 / 参数错误 / 库缺失)" + .to_string(), + ], + }; + if let Some(stage) = &self.stage { + notes.push(format!("进程没有起来,失败在:{stage}")); + } + notes + } +} + +/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。 +/// +/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍 +/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportContext { + /// 实例 id。 + pub instance: String, + /// 实例名。 + pub name: String, + /// 版本 id。 + pub version: String, + /// 实例运行目录。 + pub run_dir: PathBuf, + /// 启动器日志目录(`.ULL/logs`)。 + pub launcher_logs: PathBuf, + /// 本次会话的开始时刻(现场文件的时间闸)。 + pub started_at: std::time::SystemTime, + /// 该实例绑定的版本 JSON。 + pub version_json: Option, + /// Java 一行说明。 + pub java: String, + /// 系统一行说明。 + pub system: String, + /// 要额外抹掉的串(会话 id、access token 等)。 + pub secrets: Vec, +} + +impl ReportContext { + /// 建一份(`java` 可以后补:装配阶段还没选 Java)。 + #[must_use] + pub fn new( + instance: impl Into, + name: impl Into, + version: impl Into, + run_dir: impl Into, + launcher_logs: impl Into, + started_at: std::time::SystemTime, + system: impl Into, + ) -> Self { + Self { + instance: instance.into(), + name: name.into(), + version: version.into(), + run_dir: run_dir.into(), + launcher_logs: launcher_logs.into(), + started_at, + version_json: None, + java: String::new(), + system: system.into(), + secrets: Vec::new(), + } + } + + fn redactor(&self) -> Redactor { + let mut redactor = Redactor::new(); + for key in ["USERPROFILE", "HOME"] { + if let Ok(home) = std::env::var(key) { + if !home.trim().is_empty() { + redactor = redactor.with_home_str(home); + } + } + } + for secret in &self.secrets { + redactor = redactor.with_secret(secret.clone()); + } + redactor + } +} + +fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec { + lines + .iter() + .map(|line| redactor.scrub(line).into_owned()) + .collect() +} + +fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact { + let excerpt = artifact.excerpt.as_ref().map(|text| { + let scrubbed = redactor.scrub(text); + let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect(); + trimmed + }); + ArtifactFact { + source: artifact.source.as_str().to_string(), + path: artifact.path.clone(), + excerpt, + } +} + +fn slot() -> &'static Mutex> { + static SLOT: OnceLock>> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(None)) +} + +/// 登记一次失败启动的现场(启动线程收尾时调用)。 +pub fn publish(report: CrashReport) { + if let Ok(mut guard) = slot().lock() { + *guard = Some(report); + } +} + +/// 读最近一次失败启动的现场。 +#[must_use] +pub fn failing_launch() -> Option { + slot().lock().ok().and_then(|guard| guard.clone()) +} + +/// 清空槽位(测试用)。 +pub fn clear() { + if let Ok(mut guard) = slot().lock() { + *guard = None; + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/launch/crash_report/tests.rs b/crates/core/src/launch/crash_report/tests.rs new file mode 100644 index 0000000..1f61fa6 --- /dev/null +++ b/crates/core/src/launch/crash_report/tests.rs @@ -0,0 +1,125 @@ +//! 现场事实的断言:三态证据分类、脱敏、缺件写明、槽位读写。 + +use std::path::PathBuf; + +use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext}; + +fn report(artifacts: Vec, tail: Vec) -> CrashReport { + CrashReport { + instance: "survival".into(), + name: "生存".into(), + version: "1.20.4".into(), + session: "0f8fad5b".into(), + session_log: Some(PathBuf::from("/sessions/game/0f8fad5b.jsonl")), + version_json: None, + launcher_logs: Some(PathBuf::from("/.ULL/logs")), + run_dir: Some(PathBuf::from("/instances/survival")), + artifacts, + exit_code: Some(1), + exit_type: "application_error".into(), + uptime_ms: 12_000, + command: "javaw -Xmx4096m".into(), + java: "javaw.exe (21)".into(), + system: "os=windows".into(), + stage: None, + stage_detail: None, + stderr_tail: tail, + } +} + +#[test] +fn evidence_tells_the_three_shapes_apart() { + let with_report = report( + vec![ArtifactFact { + source: "crash_report".into(), + path: PathBuf::from("/instances/survival/crash-reports/crash-1.txt"), + excerpt: Some("Description: Ticking entity".into()), + }], + vec!["Exception in thread main".into()], + ); + assert_eq!(with_report.evidence(), Evidence::GameCrashReport); + assert_eq!(with_report.evidence().slug(), "game_crash_report"); + + let stderr_only = report(Vec::new(), vec!["Error: could not find java.dll".into()]); + assert_eq!( + stderr_only.evidence(), + Evidence::CapturedOutput, + "没有 crash-report 但有捕获输出 ⇒ 不许说「找不到日志」" + ); + + let nothing = report(Vec::new(), Vec::new()); + assert_eq!(nothing.evidence(), Evidence::Nothing); + + // 三种 slug 必须互不相同(界面按它选 key,撞了就会显示错的一句)。 + let slugs = [ + with_report.evidence().slug(), + stderr_only.evidence().slug(), + nothing.evidence().slug(), + ]; + let mut unique = slugs.to_vec(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), 3, "{slugs:?}"); +} + +#[test] +fn missing_crash_report_is_listed_not_silently_dropped() { + let report = report(Vec::new(), vec!["line".into()]); + let missing = report.missing_lines().join("\n"); + assert!(missing.contains("crash-reports"), "{missing}"); + assert!(missing.contains("游戏没来得及写"), "{missing}"); + assert!( + !missing.contains("会话 JSONL"), + "会话在,不该说没会话:{missing}" + ); +} + +#[test] +fn notes_name_the_fallback_reason_classification() { + let report = report(Vec::new(), Vec::new()); + let notes = report.note_lines().join("\n"); + assert!(notes.contains("缺 Java"), "{notes}"); + assert!(notes.contains("内存不足"), "{notes}"); +} + +#[test] +fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + if home.is_empty() { + return; + } + let ctx = ReportContext::new( + "survival", + "生存", + "1.20.4", + std::path::Path::new("/instances/survival"), + std::path::Path::new("/.ULL/logs"), + std::time::SystemTime::now(), + "os=windows", + ); + let report = CrashReport::before_start( + &ctx, + "spawn", + &format!("{home}/java/bin/javaw.exe 不存在"), + "spawn", + ); + assert!(!report.started(), "进程没起来"); + assert_eq!(report.evidence(), Evidence::Nothing); + let detail = report.stage_detail.expect("阶段原文"); + assert!(detail.contains(""), "{detail}"); + assert!(!detail.contains(&home), "主目录原文漏了:{detail}"); +} + +#[test] +fn slot_keeps_the_last_failure_only() { + clear(); + assert!(failing_launch().is_none()); + publish(report(Vec::new(), vec!["first".into()])); + publish(report(Vec::new(), vec!["second".into()])); + let last = failing_launch().expect("最近一次"); + assert_eq!(last.stderr_tail, vec!["second".to_string()]); + clear(); + assert!(failing_launch().is_none()); +} diff --git a/crates/core/src/launch/launcher_log.rs b/crates/core/src/launch/launcher_log.rs new file mode 100644 index 0000000..38fe218 --- /dev/null +++ b/crates/core/src/launch/launcher_log.rs @@ -0,0 +1,207 @@ +//! 启动器**自己的**运行日志(M187):`<.ULL>/logs/launcher.log`。 +//! +//! ## 为什么要有这个文件 +//! +//! 用户实测(2026-11-08):「游戏崩溃后只有一个安装日志,没有崩溃日志」。核查结论是: +//! `logs/` 分区里此前只有 `install.log`(安装流水)与 auth journal——**启动器自己没有运行期 +//! 日志**。双击启动的 GUI 进程没有 stderr 去向,所有 `eprintln!` 诊断当场蒸发,于是 +//! 「启动器视角发生了什么」在事故之后无法复原。 +//! +//! 本模块只做一件事:把一行行 `[scope] message` 追加到一个文件里,**落盘即脱敏**。 +//! +//! ## 三条约束(都是有意的) +//! +//! 1. **best-effort**:写不进去就静默放过。日志失败不该让启动器崩、也不该弹提示。 +//! 2. **不阻塞**:不建后台线程、不做异步队列。调用点都是「一次启动 / 一次导出」这种低频事件, +//! 每次一行 `write_all` 的代价可以忽略;引入通道反而多一处要收尾的生命周期。 +//! 3. **有上限**:超过 [`MAX_BYTES`] 就滚成 `launcher.1.log`(只保留一份历史)。日志文件 +//! 无限长会让「导出诊断包」变成搬砖。 +//! +//! ## 目录从哪来 +//! +//! 目录**不是**本模块自己解析的:`.ULL` 的落点由 `platform::data_dir` 的三模式决定(导出的 +//! exe 目录 / 标准目录 / 自定义根),解析它需要读配置——那是宿主(presenter / app)已经做过 +//! 的事。所以这里是「**先到者登记**」:知道真相的调用方用 [`set_dir`] 登记, +//! 登记之前 [`record`] 的行进内存缓冲(最多 [`MAX_BUFFERED`] 行),登记时一次刷出去。 +//! 这样启动早期(字体 / GPU / 路由)那几行也不会丢。 + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use super::redact::Redactor; + +/// 日志文件名。 +pub const FILE_NAME: &str = "launcher.log"; +/// 单个文件上限(超过就滚成 `launcher.1.log`,只留一份历史)。 +pub const MAX_BYTES: u64 = 1024 * 1024; +/// 登记目录之前最多缓冲多少行。 +pub const MAX_BUFFERED: usize = 200; + +#[derive(Debug, Default)] +struct State { + dir: Option, + buffer: Vec, +} + +fn state() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(State::default())) +} + +/// 登记日志目录(`<.ULL>/logs`)。重复登记时以**最后一次**为准(数据根可能被 `redirect.json` 改)。 +pub fn set_dir(logs_dir: impl Into) { + let dir = logs_dir.into(); + let mut guard = match state().lock() { + Ok(guard) => guard, + Err(_) => return, + }; + if guard.dir.as_ref() == Some(&dir) { + return; + } + guard.dir = Some(dir); + let buffered = std::mem::take(&mut guard.buffer); + let Some(directory) = guard.dir.clone() else { + return; + }; + drop(guard); + for line in buffered { + append(&directory, &line); + } +} + +/// 当前日志文件路径(`None` = 还没登记目录)。 +pub fn path() -> Option { + let guard = state().lock().ok()?; + guard.dir.as_ref().map(|dir| dir.join(FILE_NAME)) +} + +/// 记一行(`[scope] message`)。scope 是稳定短的英文标签(`boot` / `launch` / `dialog`)。 +/// +/// 时间戳走 `launch::time::now()`(与事件流同一份时钟口径),所以日志行与 JSONL 能对上。 +pub fn record(scope: &str, message: &str) { + let line = format!( + "{} [{scope}] {message}", + super::time::format_rfc3339_millis(super::time::now()) + ); + let dir = { + let mut guard = match state().lock() { + Ok(guard) => guard, + Err(_) => return, + }; + match guard.dir.clone() { + Some(dir) => dir, + None => { + if guard.buffer.len() < MAX_BUFFERED { + guard.buffer.push(line); + } + return; + } + } + }; + append(&dir, &line); +} + +/// 脱敏器:主目录换成 ``(同一份规则见 [`super::redact`])。 +fn redactor() -> Redactor { + let mut redactor = Redactor::new(); + for key in ["USERPROFILE", "HOME"] { + if let Ok(home) = std::env::var(key) { + if !home.trim().is_empty() { + redactor = redactor.with_home_str(home); + } + } + } + redactor +} + +fn append(dir: &Path, line: &str) { + let path = dir.join(FILE_NAME); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // 滚动:只在超过上限时做一次 rename,平时的路径只有「打开 → 追加 → 关」。 + if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > MAX_BYTES) { + let _ = std::fs::rename(&path, dir.join("launcher.1.log")); + } + let scrubbed = redactor().scrub(line).into_owned(); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = file.write_all(scrubbed.as_bytes()); + let _ = file.write_all(b"\n"); + } +} + +#[cfg(test)] +fn state_reset() { + if let Ok(mut guard) = state().lock() { + guard.dir = None; + guard.buffer.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::MAX_BUFFERED; + use std::path::PathBuf; + + /// 测试之间共享全局状态,必须串行(`cargo test` 默认多线程)。 + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn temp_dir(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ull-m187-log-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("建夹具目录"); + root + } + + #[test] + fn buffered_lines_are_flushed_once_the_dir_is_known() { + let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner()); + super::state_reset(); + let dir = temp_dir("flush"); + + super::record("boot", "before the dir was known"); + assert!(super::path().is_none(), "没登记目录时不该报出路径"); + super::set_dir(&dir); + super::record("launch", "after"); + + let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志"); + assert!(text.contains("before the dir was known"), "{text}"); + assert!(text.contains("[launch] after"), "{text}"); + } + + #[test] + fn home_paths_are_scrubbed_on_write() { + let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner()); + super::state_reset(); + let dir = temp_dir("scrub"); + super::set_dir(&dir); + let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")); + let Ok(home) = home else { + return; + }; + super::record("launch", &format!("argv --nativesDir {home}/natives")); + let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志"); + assert!(text.contains(""), "{text}"); + assert!(!text.contains(home.as_str()), "家目录原文还在:{text}"); + } + + #[test] + fn buffer_is_bounded_before_the_dir_is_known() { + let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner()); + super::state_reset(); + for index in 0..(MAX_BUFFERED + 50) { + super::record("boot", &format!("line {index}")); + } + let dir = temp_dir("bounded"); + super::set_dir(&dir); + let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志"); + let lines = text.lines().count(); + assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行"); + assert!(lines > 0); + } +} diff --git a/crates/core/src/launch/mod.rs b/crates/core/src/launch/mod.rs index b26cc18..b296ae3 100644 --- a/crates/core/src/launch/mod.rs +++ b/crates/core/src/launch/mod.rs @@ -28,6 +28,8 @@ pub mod adapter; pub mod args_table; pub mod argument; pub mod command_builder; +pub mod crash_bundle; +pub mod crash_report; pub mod crash_scan; pub mod defaults; pub mod diagnosis_hook; @@ -38,6 +40,7 @@ pub mod game_version; pub mod jvm_args; pub mod launch_session; pub mod launcher; +pub mod launcher_log; pub mod log_level; pub mod natives; pub mod overrides; @@ -62,6 +65,7 @@ pub use adapter::{ build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput, }; pub use args_table::{assemble, AssembledCommand, AssemblyWarning}; +pub use crash_report::{ArtifactFact, CrashReport, Evidence, ReportContext}; pub use crash_scan::{CrashArtifact, CrashSource}; pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis}; pub use error::{LaunchError, MissingComponent, Result}; diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index ab1155a..0c382ba 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -244,6 +244,30 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("LogsEmptyTitle", "No output yet", "还没有输出"), ("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"), ("LogsEmptyAction", "Choose an instance", "选择实例"), + ("CrashDialogTitleFailed", "Launch failed", "启动失败"), + ("CrashDialogTitleCrashed", "The game crashed", "游戏崩溃"), + ("CrashDialogExitCode", "Exit code {code}", "退出码 {code}"), + ("CrashDialogStartedThenDied", "The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"), + ("CrashDialogSilentFailure", "The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"), + ("CrashDialogStageReason", "It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"), + ("CrashDialogCrashReportFound", "The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"), + ("CrashDialogNoGameLog", "No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"), + ("CrashDialogNoLogAtAll", "No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"), + ("CrashDialogShowDetail", "Show crash details", "展开崩溃说明"), + ("CrashDialogHideDetail", "Hide crash details", "收起崩溃说明"), + ("CrashDialogLauncherLogs", "Launcher logs", "启动器日志"), + ("CrashDialogSessionLog", "This session", "本次会话"), + ("CrashDialogGameLog", "Game latest.log", "游戏 latest.log"), + ("CrashDialogCrashReports", "Crash reports", "崩溃报告目录"), + ("CrashDialogCapturedTail", "CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"), + ("CrashDialogExport", "Export report bundle", "导出诊断包"), + ("CrashDialogOpenFolder", "Open log folder", "打开日志文件夹"), + ("CrashDialogCopyPaths", "Copy file paths", "复制文件路径"), + ("CrashDialogClose", "Close", "关闭"), + ("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"), + ("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), + ("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"), + ("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"), ("AgentTitle", "Assistant", "助手"), ("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"), ("AgentCollapse", "Collapse", "收起"), diff --git a/crates/i18n/src/catalog/logs.rs b/crates/i18n/src/catalog/logs.rs index 14716b3..8c1e6df 100644 --- a/crates/i18n/src/catalog/logs.rs +++ b/crates/i18n/src/catalog/logs.rs @@ -33,6 +33,31 @@ macro_rules! logs_entries { "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。" ), LogsEmptyAction => ("Choose an instance", "选择实例"), + // --- 启动失败弹窗(M187)--- + CrashDialogTitleFailed => ("Launch failed", "启动失败"), + CrashDialogTitleCrashed => ("The game crashed", "游戏崩溃"), + CrashDialogExitCode => ("Exit code {code}", "退出码 {code}"), + CrashDialogStartedThenDied => ("The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"), + CrashDialogSilentFailure => ("The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"), + CrashDialogStageReason => ("It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"), + CrashDialogCrashReportFound => ("The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"), + CrashDialogNoGameLog => ("No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"), + CrashDialogNoLogAtAll => ("No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"), + CrashDialogShowDetail => ("Show crash details", "展开崩溃说明"), + CrashDialogHideDetail => ("Hide crash details", "收起崩溃说明"), + CrashDialogLauncherLogs => ("Launcher logs", "启动器日志"), + CrashDialogSessionLog => ("This session", "本次会话"), + CrashDialogGameLog => ("Game latest.log", "游戏 latest.log"), + CrashDialogCrashReports => ("Crash reports", "崩溃报告目录"), + CrashDialogCapturedTail => ("CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"), + CrashDialogExport => ("Export report bundle", "导出诊断包"), + CrashDialogOpenFolder => ("Open log folder", "打开日志文件夹"), + CrashDialogCopyPaths => ("Copy file paths", "复制文件路径"), + CrashDialogClose => ("Close", "关闭"), + CrashDialogCopied => ("File paths copied to the clipboard", "文件路径已复制到剪贴板"), + CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), + CrashDialogExportFailed => ("Export failed: {error}", "导出失败:{error}"), + CrashDialogOpenFailed => ("Could not open the folder: {error}", "打不开文件夹:{error}"), } } }; } diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 5c4e271..3362fd9 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -17,6 +17,8 @@ use unlucky_core::game::{self, VersionJson}; use unlucky_core::launch::adapter::{ self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, }; +use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext}; +use unlucky_core::launch::launcher_log; use unlucky_core::launch::request::{LaunchRequest, MemoryOptions}; use unlucky_core::launch::time::{format_rfc3339_millis, now}; use unlucky_core::launch::tree_kill; @@ -216,6 +218,19 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { let id = target.id.clone(); let tx_progress = tx.clone(); super::job::spawn("launch", move || { + // M187:启动器自己的运行日志从这里开始有落点(早于这一拍的行由 core 缓冲接住)。 + let logs_dir = env.layout.logs(); + launcher_log::set_dir(&logs_dir); + let started_at = std::time::SystemTime::now(); + let mut report = ReportContext::new( + &target.id, + &target.name, + &target.version_id, + &env.data.game_home, + &logs_dir, + started_at, + unlucky_core::platform::os_info::current().summary_line(), + ); let _ = tx_progress.send(HostTask::Runtime { id: id.clone(), runtime: InstanceRuntime::Busy { @@ -226,6 +241,9 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { let (request, warnings) = match build_request(&env, &target) { Ok(ok) => ok, Err(failure) => { + let line = format!("instance={} 装配失败:{}", target.id, failure.detail); + launcher_log::record("launch", &line); + publish_before_start(&report, failure.stage.slug(), &failure.detail); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, session: String::new(), @@ -237,6 +255,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { return; } }; + report.run_dir = request.dirs.game_dir.clone(); + report.version_json = Some(env.game.version_json(&target.version_id)); + report.java = format!( + "{} (major {})", + request.java.binary.display(), + request.java.major + ); for warning in &warnings { let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new( &format!("launch-warn:{}", target.id), @@ -245,21 +270,33 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { warning, )))); } + // 命令行(脱敏版)进启动器日志:事故之后「到底用什么参数起的」是第一个要看的。 + let line = format!( + "instance={} game_dir={} java={} memory={}MB", + target.id, + report.run_dir.display(), + report.java, + request.memory.max_mb.unwrap_or(0) + ); + launcher_log::record("launch", &line); let config = LauncherConfig::new(env.sessions_root()) .with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); let session = match Launcher::new(config).spawn(&request) { Ok(session) => session, Err(error) => { + let failure = LaunchFailureView::new(LaunchFailureStage::Spawn, error.to_string()); + launcher_log::record( + "launch", + &format!("instance={} spawn 失败:{error}", target.id), + ); + publish_before_start(&report, "spawn", &failure.detail); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, session: String::new(), outcome: LogOutcome::Crashed, exit_code: None, uptime_ms: 0, - failure: Some(LaunchFailureView::new( - LaunchFailureStage::Spawn, - error.to_string(), - )), + failure: Some(failure), }))); return; } @@ -278,6 +315,22 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { match session.finish() { Ok(handle) => { let outcome = super::identity::outcome_of(&handle); + let line = format!( + "instance={} session={session_id} exit={} type={} uptime={}ms outcome={:?} artifacts={}", + target.id, + handle.exit.code, + handle.exit.exit_type.as_str(), + handle.uptime_ms, + outcome, + handle.artifacts.len() + ); + launcher_log::record("launch", &line); + report.secrets.push(session_id.clone()); + let lines: Vec = super::logs::read_lines(&handle.log_path) + .into_iter() + .map(|line| line.text) + .collect(); + crash_report::publish(CrashReport::collect(&handle, &report, &lines)); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, session: session_id, @@ -289,22 +342,31 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { }))); } Err(error) => { + let failure = LaunchFailureView::new(LaunchFailureStage::Spawn, error.to_string()); + let line = format!( + "instance={} session={session_id} 收尾失败:{error}", + target.id + ); + launcher_log::record("launch", &line); + publish_before_start(&report, "spawn", &failure.detail); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, session: session_id, outcome: LogOutcome::SilentFail, exit_code: None, uptime_ms: 0, - failure: Some(LaunchFailureView::new( - LaunchFailureStage::Spawn, - error.to_string(), - )), + failure: Some(failure), }))); } } }); } +/// 登记一次「进程没起来」的现场(四个失败出口共用这一处,避免各写一遍 exit_type)。 +fn publish_before_start(report: &ReportContext, stage: &str, detail: &str) { + crash_report::publish(CrashReport::before_start(report, stage, detail, stage)); +} + /// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。 pub fn stop(pid: u32) -> Result<(), String> { tree_kill::kill_tree(pid, std::time::Duration::from_secs(3)) diff --git a/docs/backend-cjk-evidence-matrix.md b/docs/backend-cjk-evidence-matrix.md new file mode 100644 index 0000000..ccbbd00 --- /dev/null +++ b/docs/backend-cjk-evidence-matrix.md @@ -0,0 +1,78 @@ +# 后端 × 中文:什么断言只在软渲染下成立,什么必须在 GPU 下看(M187) + +本文件登记一条**通道级缺口**,不是一条 bug:自动化证据全部走软件渲染,所以「只有 GPU 后端 +才出现」的缺陷在 CI 里永远是盲区。下次有人要验「文字/像素在 GPU 上是否正常」时,先读这一页。 + +## 1. 现状:所有自动出图都是 `--software-renderer` + +| 通道 | 位置 | 后端 | +|---|---|---| +| CI 冒烟截屏 | `.github/workflows/ci.yml:101` | `--software-renderer`(强制 CPU) | +| CI 交互冒烟 | `.github/workflows/ci.yml:246`、`:283` | `--software-renderer` | +| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh:90` | `--software-renderer` | +| D30 视觉验收(人工,按协议出图) | `DevDocs/PlanDoc/`(视觉验收协议) | 同上(CI 同款命令) | + +`--renderer=auto`(默认)会挑 wgpu;`--software-renderer` 强制 tiny-skia。CI 走后者是**有意的**: +容器/无 GPU 机器上没有 Vulkan/DX12 设备,硬要 wgpu 会红在一句「adapter 创建失败」上,与皮肤无关。 + +**代价**:tiny-skia 与 wgpu 是两套完全不同的文字光栅化路径(前者 `cosmic-text` + CPU 位图上传, +后者经 `cryoglyph` 上传到 GPU 字形图集)。所以「GPU 侧字形图集出错」这类缺陷,**任何自动证据都抓不到**。 + +## 2. 这轮实测:M190 的「owned 文字在 wgpu 下丢字」在本 branch 上**没有复现** + +同一份构建、同一串中文、同一字号,一次 wgpu 一次 tiny-skia: + +| 图 | 后端 | 内容 | +|---|---|---| +| `DevDocs/artifacts/m187/ab-wgpu.png` | wgpu(Vulkan,AMD RX 6600 XT) | 四行:`text_role_owned` / `text_role` × 大字号 / 正文字号,逐字相同 | +| `DevDocs/artifacts/m187/ab-tinyskia.png` | tiny-skia(`--software-renderer`) | 同上,与 wgpu 那张逐字一致 | +| `DevDocs/artifacts/m187/probe-acc-wgpu.png` | wgpu | 账户页设备码面板(M190 钉的那一行「剩 14:59」在 wgpu 下**正常画出**) | + +结论(只对这份构建、这台机器成立,不推广):`frame::text_role_owned` 与 `frame::text_role` +产出的控件在小规模中文下**行为一致**,两种后端都能画;把缺陷归因到「owned 路径」在本 branch +上没有证据支撑。要复现 M190 的现象,需要它当时的构建/环境。 + +## 3. 分工:哪些结论只信软渲染,哪些必须在 GPU 上看 + +| 断言类型 | 软渲染(tiny-skia)够吗 | 必须在 GPU 下复核 | 理由 | +|---|---|---|---| +| 版面几何(位置、宽高、折行、间距) | ✅ 够 | 建议一次 | 几何由布局树决定,两后端共用 | +| 文字**在不在**(整行缺失 / 空块) | ⚠️ 只能证明 CPU 路径 | **必须** | GPU 字形图集是独立实现(`cryoglyph`) | +| 字形形状 / 连字 / 每字形字体落点(「个别字换了字体」) | ⚠️ 同上 | **必须** | 两种后备字体选择可能不同 | +| 颜色 / 对比度 / 主题令牌 | ✅ 够(纯色由令牌给) | 建议一次 | 面板混合色只有 GPU 路径可验 | +| 材质层 / 高程阴影 | ⚠️ | **必须** | 阴影在 wgpu 与 tiny-skia 是两套画法 | +| 整窗 α / 无边框圆角(Win32) | ❌ 不看 | **必须**(且只在 Windows) | 与渲染后端无关,靠 DWM 下发 | +| 背景图 / 壁纸取色 / 图片缩放 | ⚠️ | **必须** | GPU 侧走纹理采样 | +| SVG 图标(含描边宽度) | ✅ 够 | 建议一次 | 光栅化后端不同 | + +读法:**「有没有画出来」与「字形对不对」这两类,只认 GPU 图**;几何与令牌只认一份即可。 + +## 4. 怎么在 GPU 下出图(本机命令) + +```bash +# GPU(wgpu;会自动挑适配器,日志里 [gpu] Selected: AdapterInfo{…} 是「真用上 GPU」的实证) +unlucky-launcher-iced --screenshot out-gpu.png --screenshot-theme=light + +# CPU(tiny-skia) +unlucky-launcher-iced --screenshot out-cpu.png --screenshot-theme=light --software-renderer + +# 两图逐像素比对(本程序自带,不需要 Python/PIL) +unlucky-launcher-iced --diff out-gpu.png out-cpu.png +``` + +`--diff` 的退出码:`0` 一致 / `1` 有差异 / `2` 参数错。**几何应当一致**(D30 的硬项), +颜色与字形抗锯齿允许不同——所以「有差异」不等于坏,要**看着图**判。 + +启动失败弹窗的两态各两张(wgpu / tiny-skia)就是这么出的: + +```bash +ULL_DIALOG_FIXTURE=crash-report unlucky-launcher-iced --screenshot dialog-crash-wgpu.png +ULL_DIALOG_FIXTURE=no-logs unlucky-launcher-iced --screenshot dialog-no-logs-wgpu.png +``` + +## 5. 机器条件(写下来,免得下次怀疑「没 GPU」) + +本机 `--renderer=auto` 的实测选择:`AMD Radeon RX 6600 XT` / Vulkan / `device_type: DiscreteGpu`。 +无独显的机器上 wgpu 会落到 `Microsoft Basic Render Driver`(CPU 实现,`device_type: Cpu`)—— +那种图**不能**算 GPU 证据(它仍是 CPU 光栅化,只是走了 DX12 接口)。 +判据:看 `[gpu] Selected:` 那行的 `device_type`,`Cpu` 一律不算。 From 3cd371b537a0deef50d9cc456ce3e2ff07fd1a66 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 23:21:26 +0800 Subject: [PATCH 16/33] =?UTF-8?q?fix(m187):=20r1=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=B7=B1=E8=89=B2=E9=81=AE=E7=BD=A9=E6=8F=90?= =?UTF-8?q?=E4=BA=AE=E3=80=81=E7=82=B9=E9=81=AE=E7=BD=A9=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E3=80=81=E5=8C=85=E5=86=85=E6=9D=A1=E7=9B=AE=E5=90=8D=E3=80=81?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E4=BF=9D=E5=B0=BE=E3=80=81=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1(阻断):遮罩从 Role::OnSurface@32% 换成组件层的不透明预合成 (feedback::dialog::scrim,黑@0.32 叠画布)。深色档此前是「近白@32%」把整窗提亮, 实测 (9,21,28)→(129,138,145);现在 (6,14,19)(= 组件文档预测值),且两后端逐字节相同。 顺带按纪律改用 Component::Dialog + DialogGeometry(半径 28 / 宽 560 / 内距 24), 两后端弹窗图差异从 66% 降到 2.65%(浅)/ 2.76%(深),余量全是文字抗锯齿。 P2-1:卡片那层的命中区改回卡片本身(opaque 只包卡片)。此前 mouse_area 包的是整窗居中 容器 ⇒ 点卡片外先被 Swallow 捕获,遮罩永远收不到,「点遮罩关闭」只在注释里成立。 守卫:断言卡片层的 size_hint 任一维都不是 Fill。 P2-2:条目名与清单**同源**(BundleSpec::entry_names),会话 JSONL 钉成 session/session.jsonl。 此前条目名用原始文件名(会话 id 原样进包)而 manifest 过了 redactor ⇒ 清单指向的条目不存在。 (上一轮报告把这条写反了,本轮以 unzip -l 实输出为准。) P2-3:截断改「头 + 尾」(MAX_ENTRY_BYTES 一半一半,中间插 ), 并在弹窗补一句「有 N 个文件太大,已截断」。此前只留头部 ⇒ 长会话 JSONL 的 exit 事件丢失。 P2-4:app 启动那一拍(主题/语言/路由/窗口/实例数/msa 行)真的写进 launcher_log; 目录仍由 presenter 在首次启动时登记,所以这些行先进 core 缓冲、登记时一次刷出。 P3-1/P3-2:关闭与 Esc 立即重画(否则空闲 1Hz 下弹窗再留 1 秒);导出成败按 write_bundle 真实结果判(不再看 launcher_logs 有没有值)。 用例:新增 5 条(深色遮罩方向、卡片层命中区、条目名与清单一致、截断保尾、启动日志落点), 每条都给了变异证明(只改实现侧,均已还原)。 --- crates/app-iced/src/app/dialog.rs | 2 - crates/app-iced/src/app/dialog/export.rs | 82 ++++++++--- crates/app-iced/src/app/dialog/tests.rs | 108 +++++++++++++- crates/app-iced/src/app/dialog/view.rs | 99 ++++++++----- crates/app-iced/src/app/update.rs | 6 +- crates/app-iced/src/app/update/boot_env.rs | 35 +++++ crates/app-iced/src/app/update/dialog.rs | 43 +++--- crates/core/src/launch/crash_bundle.rs | 141 +++++++++++++++---- crates/core/src/launch/crash_bundle/tests.rs | 107 ++++++++++++++ crates/i18n/src/catalog/golden/tail.rs | 1 + crates/i18n/src/catalog/logs.rs | 1 + 11 files changed, 513 insertions(+), 112 deletions(-) diff --git a/crates/app-iced/src/app/dialog.rs b/crates/app-iced/src/app/dialog.rs index 51d8a11..9eb707d 100644 --- a/crates/app-iced/src/app/dialog.rs +++ b/crates/app-iced/src/app/dialog.rs @@ -67,8 +67,6 @@ pub enum Message { Secondary, /// 点遮罩 / Esc。 Dismiss, - /// 卡片内的空白处(吞掉点击,避免误关)。 - Swallow, /// 展开 / 收起详细说明。 Toggle, /// 打开日志文件夹。 diff --git a/crates/app-iced/src/app/dialog/export.rs b/crates/app-iced/src/app/dialog/export.rs index 887cd67..0ce34a1 100644 --- a/crates/app-iced/src/app/dialog/export.rs +++ b/crates/app-iced/src/app/dialog/export.rs @@ -11,16 +11,32 @@ use unlucky_core::launch::crash_report::CrashReport; use unlucky_i18n::catalog::{Catalog, Key}; use unlucky_i18n::Lang; +/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Exported { + /// 给用户看的那一句(已本地化)。 + pub text: String, + /// 是否成功。 + pub ok: bool, + /// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。 + pub truncated: usize, +} + /// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。 /// -/// 返回给界面的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」)。 -pub(crate) fn export(report: &CrashReport, lang: Lang) -> String { +/// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」); +/// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。 +pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported { let catalog = Catalog::of(lang); let Some(logs) = &report.launcher_logs else { - return catalog.render( - Key::CrashDialogExportFailed, - &[("error", "no launcher log dir")], - ); + return Exported { + text: catalog.render( + Key::CrashDialogExportFailed, + &[("error", "no launcher log dir")], + ), + ok: false, + truncated: 0, + }; }; let stamp = unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()) @@ -34,44 +50,66 @@ pub(crate) fn export(report: &CrashReport, lang: Lang) -> String { }; let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip")); match crash_bundle::write_bundle(&spec_of(report), &target) { - Ok(bundle) => catalog.render( - Key::CrashDialogExportDone, - &[ - ("bytes", &bundle.bytes.to_string()), - ("path", &bundle.path.display().to_string()), - ], - ), - Err(error) => catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]), + Ok(bundle) => { + let mut text = catalog.render( + Key::CrashDialogExportDone, + &[ + ("bytes", &bundle.bytes.to_string()), + ("path", &bundle.path.display().to_string()), + ], + ); + if bundle.truncated > 0 { + text.push('\n'); + text.push_str(&catalog.render( + Key::CrashDialogExportTruncated, + &[("count", &bundle.truncated.to_string())], + )); + } + Exported { + text, + ok: true, + truncated: bundle.truncated, + } + } + Err(error) => Exported { + text: catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]), + ok: false, + truncated: 0, + }, } } /// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。 fn spec_of(report: &CrashReport) -> BundleSpec { let mut entries: Vec = Vec::new(); - let mut push = |group: &'static str, path: &std::path::Path| { + let push = |entries: &mut Vec, group: &'static str, path: &std::path::Path| { if path.exists() { entries.push(BundleEntry::new(group, path)); } }; if let Some(logs) = &report.launcher_logs { - push("launcher", &logs.join("launcher.log")); - push("launcher", &logs.join("launcher.1.log")); - push("launcher", &logs.join("install.log")); + push(&mut entries, "launcher", &logs.join("launcher.log")); + push(&mut entries, "launcher", &logs.join("launcher.1.log")); + push(&mut entries, "launcher", &logs.join("install.log")); } if let Some(session) = &report.session_log { - push("session", session); + // 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id, + // 也不会出现「清单写 而包里是原始 id」(r1 复审 P2-2)。 + if session.exists() { + entries.push(BundleEntry::named("session", session, "session.jsonl")); + } } for artifact in &report.artifacts { // `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。 if artifact.source != "latest_log" { - push("game", &artifact.path); + push(&mut entries, "game", &artifact.path); } } if let Some(latest) = report.latest_log_path() { - push("game", &latest); + push(&mut entries, "game", &latest); } if let Some(json) = &report.version_json { - push("instance", json); + push(&mut entries, "instance", json); } let mut secrets = vec![report.session.clone()]; secrets.retain(|secret| !secret.is_empty()); diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 0e39759..983c1af 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -247,7 +247,10 @@ fn export_writes_a_bundle_even_without_a_crash_report() { spec_report.run_dir = None; spec_report.version_json = None; - let text = super::export::export(&spec_report, Lang::ZhCn); + let done = super::export::export(&spec_report, Lang::ZhCn); + let text = done.text.clone(); + assert!(done.ok, "{text}"); + assert_eq!(done.truncated, 0); assert!(text.contains("已导出"), "{text}"); assert!(text.contains("字节"), "{text}"); @@ -264,3 +267,106 @@ fn export_writes_a_bundle_even_without_a_crash_report() { "报出来的字节数要与磁盘一致:{text} / {size}" ); } + +/// P2-1:**卡片那一层只能是卡片大**。 +/// +/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗, +/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。 +/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明 +/// `Fill`/`Fill`,就等于「命中区 = 整窗」。 +/// +/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update` +/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。 +#[test] +fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() { + use iced::Length; + + let mut app = headless(); + app.state.dialogs.push( + DialogSource::UserAction, + "t", + "b", + DialogKind::Confirm { + primary: "p".to_string(), + secondary: "s".to_string(), + }, + ); + let modal = { + let request = app.state.dialogs.current().expect("当前条"); + let view = DialogRequestView::of(request); + super::current(app.lang(), &view, None) + }; + let plane = super::view::card_plane(app.ui(), &modal); + let hint = iced::advanced::Widget::size_hint(plane.as_widget()); + // 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器), + // 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。 + assert_ne!( + hint.width, + Length::Fill, + "卡片层宽度不能 Fill(会撑成整窗)" + ); + assert_ne!( + hint.height, + Length::Fill, + "卡片层高度不能 Fill(会撑成整窗)" + ); +} + +/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。 +/// +/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗 +/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。 +/// +/// 抓不住:具体色值对不对(那是令牌层的事)。 +#[test] +fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() { + use unlucky_design::color::{Role, SchemeFlavor}; + use unlucky_design::elevation::MaterialLayer; + use unlucky_design::typography::Platform; + + let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; + + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES); + // 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。 + let backdrop = super::view::backdrop_color(&ui); + assert_eq!( + backdrop.a, 1.0, + "{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)" + ); + + let canvas = crate::theme::to_iced( + unlucky_design::color::Scheme::baseline(flavor) + .get(MaterialLayer::Canvas.surface_role()), + ); + assert!( + luma(backdrop) < luma(canvas), + "{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮", + luma(backdrop), + luma(canvas) + ); + assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role()); + } +} + +/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。 +/// +/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。 +/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`, +/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。 +#[test] +fn boot_facts_are_recorded_into_the_launcher_log() { + let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建目录"); + unlucky_core::launch::launcher_log::set_dir(&dir); + + let app = headless(); + crate::app::update::boot_env::record_boot_log(&app); + + let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME); + let text = std::fs::read_to_string(&path).expect("launcher.log"); + assert!(text.contains("[boot] app boot"), "{text}"); + assert!(text.contains("theme="), "{text}"); + assert!(text.contains("window="), "{text}"); +} diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs index f7a9efb..34d6ab8 100644 --- a/crates/app-iced/src/app/dialog/view.rs +++ b/crates/app-iced/src/app/dialog/view.rs @@ -6,11 +6,13 @@ //! 立刻停。所以三层的顺序是: //! //! ```text -//! stack![ 页面树, 遮罩(mouse_area → Dismiss), 卡片(mouse_area → Swallow) ] +//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ] //! ``` //! -//! 卡片那层用 `mouse_area` 把左键按下**吃掉**([`super::Message::Swallow`]),于是点卡片里的 -//! 空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获,连 Swallow 都不会发。 +//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用 +//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。 +//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗, +//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。 use iced::widget::text::LineHeight; use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space}; @@ -23,60 +25,84 @@ use unlucky_design::spacing::Space as Sp; use unlucky_design::typography::TypeRole; use crate::app::components::buttons; +use crate::app::components::feedback::dialog::scrim; +use crate::app::components::feedback::panel::DialogGeometry; use crate::pages::frame; use crate::theme::Ui; use super::super::message::Message; use super::{Message as DialogMessage, Modal}; -/// 卡片最大宽(对话框档:单列 560,窄窗按视口收)。 -const CARD_WIDTH: f32 = 560.0; /// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。 const SCROLL_HEIGHT: f32 = 300.0; +/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。 +const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0; /// 卡片最大高(兜底:不许把边框撑出屏幕)。 -const CARD_HEIGHT: f32 = 620.0; -/// 遮罩不透明度(Material scrim 档;0.32 时下面的页面还看得见轮廓)。 -const SCRIM_ALPHA: f32 = 0.32; +const CARD_HEIGHT: f32 = 660.0; /// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。 +/// +/// ## 三层的命中语义(r1 复审 P2-1 的修法) +/// +/// ```text +/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ] +/// ``` +/// +/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里 +/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。 +/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它 +/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」 +/// 只存在于注释里。 pub(crate) fn layer<'a>( ui: &'a Ui, base: Element<'a, Message>, modal: &'a Modal, ) -> Element<'a, Message> { - let scrim = mouse_area( + let backdrop = mouse_area( container(Space::new()) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| container::Style { - background: Some(Background::Color(scrim_color(ui))), + background: Some(Background::Color(backdrop_color(ui))), ..container::Style::default() }), ) .on_press(Message::Dialog(DialogMessage::Dismiss)); - let overlay = mouse_area( - container(card(ui, modal)) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .padding(ui.space(Sp::X6)), - ) - .on_press(Message::Dialog(DialogMessage::Swallow)); + // 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。 + let centered = container(card_plane(ui, modal)) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(ui.space(Sp::X6)); - iced::widget::stack![base, scrim, overlay].into() + iced::widget::stack![base, backdrop, centered].into() } -fn scrim_color(ui: &Ui) -> Color { - Color { - a: SCRIM_ALPHA, - ..ui.color(Role::OnSurface) - } +/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。 +/// +/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把 +/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档 +/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。 +/// +/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。 +pub(super) fn backdrop_color(ui: &Ui) -> Color { + scrim(ui) } +/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。 +/// +/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。 +pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { + iced::widget::opaque(card(ui, modal)) +} + +/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。 fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { - let shape = Component::OutlinedCard.spec().shape; + // 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。 + let geometry = DialogGeometry::of(ui); + let shape = Component::Dialog.spec().shape; // 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在 // (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。 @@ -138,7 +164,11 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { let body = if modal.card.is_some() { column![ header, - scrollable(middle).height(Length::Fixed(SCROLL_HEIGHT)), + scrollable(middle).height(Length::Fixed(if modal.notice.is_some() { + SCROLL_HEIGHT_WITH_NOTICE + } else { + SCROLL_HEIGHT + })), frame::divider(ui).map(Message::Page), footer, ] @@ -149,21 +179,12 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { .width(Length::Fill); container(body) - .width(Length::Fixed(CARD_WIDTH)) - .max_width(CARD_WIDTH + 48.0) - .max_height(CARD_HEIGHT + 120.0) - .padding(ui.metrics().card_padding) + .width(Length::Fixed(geometry.max_width)) + .max_height(CARD_HEIGHT) + .padding(geometry.padding) .style(move |_theme| container::Style { background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), border: ui.outlined(shape), - shadow: Shadow { - color: Color { - a: 0.18, - ..Color::BLACK - }, - offset: iced::Vector::new(0.0, 8.0), - blur_radius: 24.0, - }, ..container::Style::default() }) .into() diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index e1e13dd..efea010 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -44,7 +44,7 @@ use super::CAPTURE_AT_FRAME; mod account; mod agent; mod appearance; -mod boot_env; +pub(super) mod boot_env; mod clock; mod content; mod content_pack; @@ -90,6 +90,8 @@ impl App { None => { eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); self.log_appearance("boot", None); + // M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。 + boot_env::record_boot_log(self); } } // M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`, @@ -198,6 +200,8 @@ impl App { self.appearance.apply_env(); self.rebuild_ui(); self.log_appearance("boot", None); + // M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。 + boot_env::record_boot_log(self); // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) if self.options.wants_system_theme() { diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index 65690a9..31e0419 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -24,3 +24,38 @@ pub(super) fn env_start_route() -> Option { _ => None, } } + +/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 +/// +/// ## 为什么这一拍只能「先记、后落」 +/// +/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`), +/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record` +/// (core 侧缓冲,最多 200 行);第一次启动时 presenter 调 `launcher_log::set_dir`,缓冲一次 +/// 刷进 `<.ULL>/logs/launcher.log`。 +/// +/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从 +/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。 +/// +/// 边界(写在明处):进程若一次都没启动游戏、又没触发导出,`set_dir` 就不会被调用, +/// 这些行只留在内存里 → 那个进程不产生 `launcher.log`。要彻底解决得让 presenter 在构造 +/// `HostEnv` 时就登记目录(`host/mod.rs` 不在本 mission scope,已登记为 follow-up)。 +pub(crate) fn record_boot_log(app: &super::super::state::App) { + use unlucky_core::launch::launcher_log; + + launcher_log::record("boot", "app boot"); + launcher_log::record( + "boot", + &format!( + "theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}", + app.ui().name(), + app.lang(), + app.state.route(), + app.width, + app.height, + app.has_screenshot(), + app.state.instances.len(), + ), + ); + launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line()); +} diff --git a/crates/app-iced/src/app/update/dialog.rs b/crates/app-iced/src/app/update/dialog.rs index a3d08eb..8c9f12a 100644 --- a/crates/app-iced/src/app/update/dialog.rs +++ b/crates/app-iced/src/app/update/dialog.rs @@ -14,34 +14,36 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task { // 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。 - if let Some(card) = app.dialog_card.clone() { - let text = export::export(&card.report, app.lang()); - let failed = card.report.launcher_logs.is_none(); - app.state.toasts.push( - if failed { - ToastKind::Error - } else { - ToastKind::Success - }, - text.clone(), - ); - // 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast - // (app-iced 的 Toast 至今没有渲染点)。 - if let Some(state) = &mut app.dialog_card { - state.outcome = Some(text); - } - app.view_dirty = true; - app.refresh_view(); + let Some(card) = app.dialog_card.clone() else { + app.close_dialog(); return Task::none(); + }; + let done = export::export(&card.report, app.lang()); + // 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast + // (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判, + // 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。 + app.state.toasts.push( + if done.ok { + ToastKind::Success + } else { + ToastKind::Error + }, + done.text.clone(), + ); + if let Some(state) = &mut app.dialog_card { + state.outcome = Some(done.text); } - app.close_dialog(); + app.view_dirty = true; + app.refresh_view(); Task::none() } DialogMessage::Secondary | DialogMessage::Dismiss => { app.close_dialog(); + // 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒 + // (r1 复审 P3-1)。 + app.refresh_view(); Task::none() } - DialogMessage::Swallow => Task::none(), DialogMessage::Toggle => { app.toggle_dialog_detail(); Task::none() @@ -97,5 +99,6 @@ pub(super) fn escape(app: &mut App) -> bool { return false; } app.close_dialog(); + app.refresh_view(); true } diff --git a/crates/core/src/launch/crash_bundle.rs b/crates/core/src/launch/crash_bundle.rs index be453e8..99cbdcf 100644 --- a/crates/core/src/launch/crash_bundle.rs +++ b/crates/core/src/launch/crash_bundle.rs @@ -26,8 +26,13 @@ use std::path::{Path, PathBuf}; use super::redact::Redactor; -/// 单个文件最多收多少字节(超出只收头部,清单里记「已截断」)。 +/// 单个文件最多收多少字节(超出按**头 + 尾**截断,清单里记「已截断」)。 +/// +/// 为什么不是「只收头部」:崩溃证据在**尾部**——会话 JSONL 的最后几行是 `exit` 事件与 +/// stderr,`latest.log` 的栈也在尾。只留头部的包等于「证据是残的,而用户不知道」。 pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024; +/// 截断时头部保留的字节(另一半点给尾部)。 +pub const HEAD_BYTES: u64 = MAX_ENTRY_BYTES / 2; /// 整个包最多收多少字节(超出后剩余文件只记清单、不收内容)。 pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024; @@ -38,14 +43,30 @@ pub struct BundleEntry { pub group: &'static str, /// 源文件绝对路径。 pub path: PathBuf, + /// 包内固定文件名(`None` = 用源文件名)。 + /// + /// 为什么要能钉死:会话 JSONL 的源文件名就是**会话 id**(`.jsonl`), + /// 拿它当条目名要么泄漏 id、要么让「清单里的名字」与「包里的名字」对不上。钉成 + /// `session.jsonl` 两个问题一起消失(见 [`BundleSpec::entry_names`])。 + pub name: Option, } impl BundleEntry { - /// 建一条。 + /// 建一条(包内名 = 源文件名)。 pub fn new(group: &'static str, path: impl Into) -> Self { Self { group, path: path.into(), + name: None, + } + } + + /// 建一条并**钉死包内文件名**(会话 JSONL、`latest.log` 这类有唯一身份的用它)。 + pub fn named(group: &'static str, path: impl Into, name: &str) -> Self { + Self { + group, + path: path.into(), + name: Some(name.to_string()), } } } @@ -85,7 +106,22 @@ pub struct BundleSpec { impl BundleSpec { /// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。 + /// + /// 这是「自算条目名」的包装(测试与单独看清单用);真正落盘时用的是 + /// [`Self::header`] + 收集循环里长出来的行,保证与包内条目一字不差。 + #[must_use] pub fn manifest(&self) -> String { + let rows: Vec = self + .entry_names() + .iter() + .zip(&self.entries) + .map(|(name, entry)| format!("- {name} <- {}", entry.path.display())) + .collect(); + self.header(&rows) + } + + /// 清单的**事实段 + files 段**(files 段由调用方给,行内已是最终条目名)。 + fn header(&self, rows: &[String]) -> String { let mut text = String::new(); text.push_str("Unlucky Launcher — launch failure bundle\n"); text.push_str("=======================================\n"); @@ -118,17 +154,33 @@ impl BundleSpec { } text.push_str("\nfiles\n-----\n"); - for entry in &self.entries { - text.push_str(&format!( - "- {}/{} <- {}\n", - entry.group, - file_name(&entry.path), - entry.path.display() - )); + // 清单里的名字**必须**与包里的条目一一对应(r1 复审 P2-2 就是这里对不上): + // 两边都走同一份解析(`rows` 由调用方给出,行内已是最终条目名): + // 清单与 zip 不许各算一遍 —— 各算一遍就是 r1 复审 P2-2。 + for row in rows { + text.push_str(row); + text.push('\n'); } text } + /// 包内条目名(与 `entries` 一一对应;**名字也过脱敏**)。 + /// + /// 名字与内容分开治:内容走 `redactor` 的整套规则,名字走同一份 redactor 的 token 规则。 + /// **两边都以返回值为准**——清单与 zip 都读这一份,不许各算一遍(各算一遍就是 P2-2)。 + #[must_use] + pub fn entry_names(&self) -> Vec { + let redactor = self.redactor(); + self.entries + .iter() + .map(|entry| { + let raw = entry.name.clone().unwrap_or_else(|| file_name(&entry.path)); + let safe = redactor.scrub(&raw).into_owned(); + format!("{}/{}", entry.group, safe) + }) + .collect() + } + fn redactor(&self) -> Redactor { let mut redactor = Redactor::new(); if let Some(home) = &self.home { @@ -186,35 +238,39 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let mut manifest = spec.manifest(); - // 截断与缺失在**收集时**才知道,所以清单最后再补一段。 - let mut collected = Vec::new(); + // 名字与清单**先在同一个循环里长出来**:`files` 段只列真进了包的条目, + // 谁被跳过、谁被截断都在 `collected` 段写明(r1 复审 P2-2 的修法)。 + let names = spec.entry_names(); + let mut listed: Vec = Vec::new(); + let mut collected: Vec = Vec::new(); let mut total: u64 = 0; let mut files = 0usize; let mut truncated = 0usize; - let mut used_names: Vec = Vec::new(); + let mut used: Vec = Vec::new(); - for entry in &spec.entries { - let name = unique_name(&mut used_names, entry.group, &file_name(&entry.path)); + for (index, entry) in spec.entries.iter().enumerate() { + let name = unique_name(&mut used, &names[index]); let Ok(bytes) = std::fs::read(&entry.path) else { collected.push(format!("- {name}: 读不出来(已跳过)")); - truncated += 1; continue; }; let original_len = bytes.len(); let mut body = bytes; - if total + body.len() as u64 > MAX_TOTAL_BYTES || body.len() as u64 > MAX_ENTRY_BYTES { - let keep = usize::try_from(MAX_ENTRY_BYTES.min(body.len() as u64)).unwrap_or(0); - let keep = usize::try_from(MAX_TOTAL_BYTES.saturating_sub(total).min(keep as u64)) - .unwrap_or(0); - if keep == 0 { + let budget = MAX_TOTAL_BYTES.saturating_sub(total).min(MAX_ENTRY_BYTES); + if body.len() as u64 > budget { + let clipped = clip(&body, budget); + if clipped.is_empty() { collected.push(format!("- {name}: 超出总量上限,未收内容")); truncated += 1; continue; } - body.truncate(keep); + body = clipped; collected.push(format!( - "- {name}: 已截断(只收前 {keep} 字节,原 {original_len} 字节)" + "- {name}: 已截断(收头 {} B + 尾 {} B,原 {original_len} B)", + HEAD_BYTES.min(original_len as u64), + MAX_ENTRY_BYTES + .saturating_sub(HEAD_BYTES) + .min(original_len as u64) )); truncated += 1; } @@ -224,9 +280,11 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result } total += body.len() as u64; files += 1; + listed.push(format!("- {name} <- {}", entry.path.display())); write_entry(&mut zip, &name, &body, options)?; } + let mut manifest = spec.header(&listed); if !collected.is_empty() { manifest.push_str("\ncollected\n---------\n"); for line in collected { @@ -272,15 +330,16 @@ fn file_name(path: &Path) -> String { .unwrap_or_else(|| "file".to_string()) } -/// 包内重名时加序号(`latest.log` 与 `crash-reports/latest.log` 不会互相覆盖)。 -fn unique_name(used: &mut Vec, group: &str, name: &str) -> String { - let base = format!("{group}/{name}"); +/// 包内重名时在**目录名后**加序号(`game/latest.log` 与 `game/2-latest.log` 不会互相覆盖)。 +fn unique_name(used: &mut Vec, name: &str) -> String { + let base = name.to_string(); if !used.contains(&base) { used.push(base.clone()); return base; } + let (group, file) = base.split_once('/').unwrap_or(("", base.as_str())); for index in 2.. { - let candidate = format!("{group}/{index}-{name}"); + let candidate = format!("{group}/{index}-{file}"); if !used.contains(&candidate) { used.push(candidate.clone()); return candidate; @@ -289,6 +348,34 @@ fn unique_name(used: &mut Vec, group: &str, name: &str) -> String { unreachable!("序号一定找得到空位") } +/// 头 + 尾截断:保留前 [`HEAD_BYTES`] 与后 `budget - HEAD_BYTES` 字节,中间插一行说明。 +/// +/// 为什么是头 + 尾:崩溃证据在**尾部**(会话 JSONL 的 `exit` 事件与最后几条 stderr、 +/// `latest.log` 的栈),只留头部会把最有用的那几行丢掉(r1 复审 P2-3)。 +fn clip(body: &[u8], budget: u64) -> Vec { + let budget = usize::try_from(budget).unwrap_or(usize::MAX); + if body.len() <= budget || budget == 0 { + return body.to_vec(); + } + let head = usize::try_from(HEAD_BYTES) + .unwrap_or(usize::MAX) + .min(budget / 2); + let tail = budget.saturating_sub(head); + let dropped = body.len().saturating_sub(head + tail); + let mut out = Vec::with_capacity(head + tail + 64); + out.extend_from_slice(&body[..head]); + out.extend_from_slice( + format!( + " + +" + ) + .as_bytes(), + ); + out.extend_from_slice(&body[body.len() - tail..]); + out +} + /// 默认落点:启动器日志分区下的 `exports/`。 /// /// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope), diff --git a/crates/core/src/launch/crash_bundle/tests.rs b/crates/core/src/launch/crash_bundle/tests.rs index ea47b1c..d7b6a4d 100644 --- a/crates/core/src/launch/crash_bundle/tests.rs +++ b/crates/core/src/launch/crash_bundle/tests.rs @@ -189,3 +189,110 @@ fn missing_source_files_are_skipped_not_fatal() { let manifest = entry_text(&target, "manifest.txt").expect("清单"); assert!(manifest.contains("读不出来"), "{manifest}"); } + +/// P2-2:**包内条目名与清单里的名字必须一字不差**,且名字也要过脱敏。 +/// +/// 抓得住:r1 复审抓到的原 bug —— 条目名用原始文件名(会话 id 原样泄漏),而 manifest 过了 +/// redactor(写 ``)⇒ 清单指向的条目在包里不存在。 +#[test] +fn entry_names_are_scrubbed_and_match_the_manifest_exactly() { + let root = temp_dir("names"); + let session = "0f8fad5b-d9cb-469f-a165-70867728950e"; + write( + &root.join("game").join(format!("{session}.jsonl")), + "{} +", + ); + write( + &root.join("launcher.log"), + "boot +", + ); + + // 第三条**不钉名**:它的源文件名里带会话 id,靠 `entry_names` 的脱敏兜住。 + write( + &root.join("game").join(format!("hs-{session}.log")), + "x +", + ); + let mut spec = spec(&root, vec![session.to_string()]); + spec.entries = vec![ + BundleEntry::new("launcher", root.join("launcher.log")), + // 源文件名就是会话 id:钉成 session.jsonl(调用方 app-iced 的做法) + BundleEntry::named( + "session", + root.join("game").join(format!("{session}.jsonl")), + "session.jsonl", + ), + BundleEntry::new("game", root.join("game").join(format!("hs-{session}.log"))), + ]; + let target = default_target_dir(&root).join("names.zip"); + write_bundle(&spec, &target).expect("导出"); + + let listed = names(&target); + assert!( + listed.iter().any(|name| name == "session/session.jsonl"), + "钉死的条目名没生效:{listed:?}" + ); + assert!( + !listed.iter().any(|name| name.contains(session)), + "条目名里还有原始会话 id:{listed:?}" + ); + + // 清单的 files 段必须与真实条目一一对应(顺序也一致)。 + let manifest = entry_text(&target, "manifest.txt").expect("清单"); + let manifest_names: Vec = manifest + .lines() + .filter(|line| line.starts_with("- ") && line.contains(" <- ")) + .map(|line| { + line.trim_start_matches("- ") + .split(" <- ") + .next() + .unwrap_or_default() + .to_string() + }) + .collect(); + let zip_names: Vec = listed + .iter() + .filter(|name| name.as_str() != "manifest.txt") + .cloned() + .collect(); + assert_eq!(manifest_names, zip_names, "清单与包内条目对不上"); +} + +/// P2-3:截断必须**保留尾部**——崩溃证据(`exit` 事件 / 栈尾)在尾部。 +/// +/// 抓得住:只留头部的截断(r1 复审 P2-3 的原实现)会让包里没有 `exit` 行。 +#[test] +fn truncation_keeps_the_tail_where_the_crash_evidence_lives() { + let root = temp_dir("tail"); + let session = root.join("game").join("session.jsonl"); + let head_marker = "HEAD-MARKER-first-line"; + let tail_marker = "{\"kind\":\"exit\",\"payload\":{\"code\":1}}"; + let body = format!( + "{head_marker} +{}{tail_marker} +", + "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64) + ); + write(&session, &body); + + let mut spec = spec(&root, Vec::new()); + spec.entries = vec![BundleEntry::named("session", &session, "session.jsonl")]; + let target = default_target_dir(&root).join("tail.zip"); + let bundle = write_bundle(&spec, &target).expect("导出"); + + assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数"); + let kept = entry_text(&target, "session/session.jsonl").expect("条目"); + assert!(kept.contains(head_marker), "头部标记丢了"); + assert!( + kept.contains(tail_marker), + "尾部标记丢了 —— 包里将没有 exit 事件(证据是残的)" + ); + assert!( + kept.contains(" ("File paths copied to the clipboard", "文件路径已复制到剪贴板"), CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), CrashDialogExportFailed => ("Export failed: {error}", "导出失败:{error}"), + CrashDialogExportTruncated => ("{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"), CrashDialogOpenFailed => ("Could not open the folder: {error}", "打不开文件夹:{error}"), } } }; From 2b6dcbed40a1a6c4b19053f16eda47002a075bd5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 23:57:49 +0800 Subject: [PATCH 17/33] =?UTF-8?q?fix(m187):=20r2=20=E5=A4=8D=E5=AE=A1=20p2?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=88=AA=E6=96=AD=E8=BF=87=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B9=9F=E5=BF=85=E9=A1=BB=E8=84=B1=E6=95=8F=EF=BC=88?= =?UTF-8?q?=E6=88=AA=E6=96=AD=20=E6=94=B9=E5=88=B0=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=B5=81=E9=87=8C=E5=81=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审挖到的隐私旁路:上一版在**字节**上切两刀,切在多字节字符中间时 String::from_utf8 失败 ⇒ 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文 按字节进包,而清单什么也不说。命中的正是最容易超过 4 MiB 的 latest.log 与会话 JSONL; 中文 Windows 用户名(C:\Users\张三)让家目录串本身就是 CJK,切点落在附近必然命中。 修法(比复审给的三条更严一点:先脱敏再截断,避免切点留下半截 secret): 解码(lossy,任何字节都能进脱敏器)→ 脱敏整份 → 按**字符/行边界**截断 → 插截断标记。 新增 crash_bundle/clip.rs(切点逻辑 + 4 条用例),write_bundle 里只剩一条通路, 「二进制原样」这条分支彻底删除。 守卫:truncated_entries_are_still_redacted —— 造一个 '中' 正好跨在 HEAD_BYTES 上的 4 MiB+ 文件,内含注册过的会话 id 与家目录;断言 ①包里找不到原文 ② 在 ③尾部 exit 事件仍在。变异(改回字节级截断 + 仅解码成功才脱敏)⇒ 红在「仍带着家目录原文」。 顺带: - P3-1 启动日志写两遍(WindowHandle(None) 分支重复调 record_boot_log)→ 删掉重复调用。 - P3-2 清单里的截断字节数改成**实际保留量**(原来报的是常量 HEAD_BYTES)。 - P3-4 truncated>0 时界面那句话补用例(拆出 dialog/tests/export.rs,两个文件都 < 400 行)。 - P3-6 BundleSpec::manifest() 自己也过脱敏,去掉「谁调谁泄漏」的口子。 - P3-5 卡片宽度取官方上限 560 的语义在代码里写清(不改行为,登记为台账)。 --- crates/app-iced/src/app/dialog/tests.rs | 38 +---- .../app-iced/src/app/dialog/tests/export.rs | 85 +++++++++++ crates/app-iced/src/app/dialog/view.rs | 3 + crates/app-iced/src/app/update.rs | 2 - crates/core/src/launch/crash_bundle.rs | 63 +++----- crates/core/src/launch/crash_bundle/clip.rs | 138 ++++++++++++++++++ crates/core/src/launch/crash_bundle/tests.rs | 95 +++++++++++- 7 files changed, 340 insertions(+), 84 deletions(-) create mode 100644 crates/app-iced/src/app/dialog/tests/export.rs create mode 100644 crates/core/src/launch/crash_bundle/clip.rs diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 983c1af..7002088 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -233,41 +233,6 @@ fn a_successful_launch_does_not_pop_anything() { assert!(app.state.dialogs.current().is_none()); } -/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。 -#[test] -fn export_writes_a_bundle_even_without_a_crash_report() { - let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(root.join("logs")).expect("建目录"); - std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志"); - - let mut spec_report = report(false, vec!["Error: boom".to_string()]); - spec_report.launcher_logs = Some(root.join("logs")); - spec_report.session_log = None; - spec_report.run_dir = None; - spec_report.version_json = None; - - let done = super::export::export(&spec_report, Lang::ZhCn); - let text = done.text.clone(); - assert!(done.ok, "{text}"); - assert_eq!(done.truncated, 0); - assert!(text.contains("已导出"), "{text}"); - assert!(text.contains("字节"), "{text}"); - - let exports = root.join("logs").join("exports"); - let files: Vec<_> = std::fs::read_dir(&exports) - .expect("导出目录") - .filter_map(Result::ok) - .collect(); - assert_eq!(files.len(), 1, "一次导出只该落一个包"); - let size = files[0].metadata().expect("元数据").len(); - assert!(size > 0, "包不该是空文件"); - assert!( - text.contains(&size.to_string()), - "报出来的字节数要与磁盘一致:{text} / {size}" - ); -} - /// P2-1:**卡片那一层只能是卡片大**。 /// /// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗, @@ -370,3 +335,6 @@ fn boot_facts_are_recorded_into_the_launcher_log() { assert!(text.contains("theme="), "{text}"); assert!(text.contains("window="), "{text}"); } + +#[path = "tests/export.rs"] +mod export; diff --git a/crates/app-iced/src/app/dialog/tests/export.rs b/crates/app-iced/src/app/dialog/tests/export.rs new file mode 100644 index 0000000..8fd2d21 --- /dev/null +++ b/crates/app-iced/src/app/dialog/tests/export.rs @@ -0,0 +1,85 @@ +//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。 +//! +//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。 + +use unlucky_i18n::Lang; + +use super::report; +use crate::app::dialog::export; + +/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。 +#[test] +fn export_writes_a_bundle_even_without_a_crash_report() { + let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("logs")).expect("建目录"); + std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志"); + + let mut spec_report = report(false, vec!["Error: boom".to_string()]); + spec_report.launcher_logs = Some(root.join("logs")); + spec_report.session_log = None; + spec_report.run_dir = None; + spec_report.version_json = None; + + let done = export::export(&spec_report, Lang::ZhCn); + let text = done.text.clone(); + assert!(done.ok, "{text}"); + assert_eq!(done.truncated, 0); + assert!(text.contains("已导出"), "{text}"); + assert!(text.contains("字节"), "{text}"); + + let exports = root.join("logs").join("exports"); + let files: Vec<_> = std::fs::read_dir(&exports) + .expect("导出目录") + .filter_map(Result::ok) + .collect(); + assert_eq!(files.len(), 1, "一次导出只该落一个包"); + let size = files[0].metadata().expect("元数据").len(); + assert!(size > 0, "包不该是空文件"); + assert!( + text.contains(&size.to_string()), + "报出来的字节数要与磁盘一致:{text} / {size}" + ); +} + +/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。 +/// +/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。 +/// 抓不住:那句话排版好不好看(视觉)。 +#[test] +fn a_truncated_export_says_so_in_the_notice() { + use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES; + + let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("logs")).expect("建目录"); + std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录"); + std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志"); + + // 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被 + // 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。 + let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n"; + let mut big = String::new(); + while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 { + big.push_str(line); + } + std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志"); + + let mut report = report(false, Vec::new()); + report.launcher_logs = Some(root.join("logs")); + report.run_dir = Some(root.join("game")); + report.session_log = None; + report.version_json = None; + + let done = export::export(&report, Lang::ZhCn); + assert!(done.ok, "{}", done.text); + assert_eq!( + done.truncated, 1, + "这份 latest.log 必须真的被截断(>4 MiB)" + ); + assert!( + done.text.contains("已截断"), + "界面上没有说「有文件被截断」:{}", + done.text + ); +} diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs index 34d6ab8..d96c2e7 100644 --- a/crates/app-iced/src/app/dialog/view.rs +++ b/crates/app-iced/src/app/dialog/view.rs @@ -178,6 +178,9 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> { .spacing(ui.space(Sp::X3)) .width(Length::Fill); + // 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480: + // 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。 + // 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。 container(body) .width(Length::Fixed(geometry.max_width)) .max_height(CARD_HEIGHT) diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index efea010..8762ca9 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -90,8 +90,6 @@ impl App { None => { eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); self.log_appearance("boot", None); - // M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。 - boot_env::record_boot_log(self); } } // M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`, diff --git a/crates/core/src/launch/crash_bundle.rs b/crates/core/src/launch/crash_bundle.rs index 99cbdcf..6eb9a68 100644 --- a/crates/core/src/launch/crash_bundle.rs +++ b/crates/core/src/launch/crash_bundle.rs @@ -117,7 +117,8 @@ impl BundleSpec { .zip(&self.entries) .map(|(name, entry)| format!("- {name} <- {}", entry.path.display())) .collect(); - self.header(&rows) + // 对外出口一律脱敏(r2 复审 P3-6):不给「谁调谁泄漏」的口子。 + self.redactor().scrub(&self.header(&rows)).into_owned() } /// 清单的**事实段 + files 段**(files 段由调用方给,行内已是最终条目名)。 @@ -255,29 +256,27 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result continue; }; let original_len = bytes.len(); - let mut body = bytes; + // **唯一的通路**:解码 → 脱敏 → 按字符边界截断(见 `clip.rs` 的模块文档)。 + // 没有「二进制原样」分支,所以也不存在「截坏一个字就整份跳过脱敏」的旁路 + // (r2 复审 P2-1)。`lossy` 保证任何字节序列都能进脱敏器。 + let text = String::from_utf8_lossy(&bytes).into_owned(); + let scrubbed = redactor.scrub(&text).into_owned(); let budget = MAX_TOTAL_BYTES.saturating_sub(total).min(MAX_ENTRY_BYTES); - if body.len() as u64 > budget { - let clipped = clip(&body, budget); - if clipped.is_empty() { + let mut kept = scrubbed; + if kept.len() as u64 > budget { + let Some(clipped) = clip_text(&kept, budget) else { collected.push(format!("- {name}: 超出总量上限,未收内容")); truncated += 1; continue; - } - body = clipped; + }; + kept = clipped; collected.push(format!( - "- {name}: 已截断(收头 {} B + 尾 {} B,原 {original_len} B)", - HEAD_BYTES.min(original_len as u64), - MAX_ENTRY_BYTES - .saturating_sub(HEAD_BYTES) - .min(original_len as u64) + "- {name}: 已截断(收 {} B,原 {original_len} B)", + kept.len() )); truncated += 1; } - // 文本才脱敏;二进制原样(截断过的文本尾部可能是半个 UTF-8 字符,退化成原样字节)。 - if let Ok(text) = String::from_utf8(body.clone()) { - body = redactor.scrub(&text).into_owned().into_bytes(); - } + let body = kept.into_bytes(); total += body.len() as u64; files += 1; listed.push(format!("- {name} <- {}", entry.path.display())); @@ -348,34 +347,6 @@ fn unique_name(used: &mut Vec, name: &str) -> String { unreachable!("序号一定找得到空位") } -/// 头 + 尾截断:保留前 [`HEAD_BYTES`] 与后 `budget - HEAD_BYTES` 字节,中间插一行说明。 -/// -/// 为什么是头 + 尾:崩溃证据在**尾部**(会话 JSONL 的 `exit` 事件与最后几条 stderr、 -/// `latest.log` 的栈),只留头部会把最有用的那几行丢掉(r1 复审 P2-3)。 -fn clip(body: &[u8], budget: u64) -> Vec { - let budget = usize::try_from(budget).unwrap_or(usize::MAX); - if body.len() <= budget || budget == 0 { - return body.to_vec(); - } - let head = usize::try_from(HEAD_BYTES) - .unwrap_or(usize::MAX) - .min(budget / 2); - let tail = budget.saturating_sub(head); - let dropped = body.len().saturating_sub(head + tail); - let mut out = Vec::with_capacity(head + tail + 64); - out.extend_from_slice(&body[..head]); - out.extend_from_slice( - format!( - " - -" - ) - .as_bytes(), - ); - out.extend_from_slice(&body[body.len() - tail..]); - out -} - /// 默认落点:启动器日志分区下的 `exports/`。 /// /// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope), @@ -385,5 +356,9 @@ pub fn default_target_dir(logs_dir: &Path) -> PathBuf { logs_dir.join("exports") } +mod clip; + +use clip::clip_text; + #[cfg(test)] mod tests; diff --git a/crates/core/src/launch/crash_bundle/clip.rs b/crates/core/src/launch/crash_bundle/clip.rs new file mode 100644 index 0000000..e69731b --- /dev/null +++ b/crates/core/src/launch/crash_bundle/clip.rs @@ -0,0 +1,138 @@ +//! 超限文件的**文本流**截断(头 + 尾),切点落在字符/行边界上。 +//! +//! ## 为什么截断必须发生在「文本流」里,而不是字节上 +//! +//! 上一版在**字节**上切两刀(头 2 MiB / 尾 2 MiB),切完才试 `String::from_utf8` +//! 决定要不要脱敏。中文与 emoji 是 3~4 字节,只要切点落在字中间,`from_utf8` 就失败, +//! 于是**整份文件退回「原样字节」分支、一条脱敏规则都没跑**(r2 复审 P2-1 抓到的隐私旁路: +//! 长会话的 `latest.log` 与会话 JSONL 正好是最容易超大、也最该脱敏的两个文件; +//! 中文 Windows 用户名让家目录串本身就是 CJK,切在它附近必然命中)。 +//! +//! 现在这条通路只有一种形态:**解码 → 脱敏 → 按字符边界截断**。没有「二进制原样」这条分支, +//! 也就没有旁路。 +//! +//! ## 为什么先脱敏再截断(而不是先截断再脱敏) +//! +//! 顺序反过来会在切点留下**半截 secret**:被切开的前缀不再匹配任何规则,于是以原文形态留在 +//! 包里。先脱敏就没有这个问题——整串已经变成占位符,截断只能切占位符。 +//! +//! 代价:要先把整份文本过一遍脱敏器。导出是低频人工动作、上限 4 MiB,代价可以忽略。 + +/// 在**字符边界**上截断,保留头部与尾部,中间插一行说明。 +/// +/// 返回 `None` 表示不需要截(预算足够或预算为 0)。 +/// 返回值里的 `String` 已按预算裁好;调用方负责把 `truncated` 记进清单。 +pub(super) fn clip_text(text: &str, budget: u64) -> Option { + let budget = usize::try_from(budget).unwrap_or(usize::MAX); + if budget == 0 || text.len() <= budget { + return None; + } + + let head_budget = usize::try_from(super::HEAD_BYTES) + .unwrap_or(usize::MAX) + .min(budget / 2); + let tail_budget = budget - head_budget; + + let head_end = snap_back(text, head_budget); + let tail_start = snap_forward(text, text.len() - tail_budget); + if tail_start <= head_end { + return None; + } + + let dropped = tail_start - head_end; + Some(format!( + "{}\n\n{}", + &text[..head_end], + &text[tail_start..] + )) +} + +/// 从 `index` 向左找最近的**行首**,找不到就在字符边界上回退。 +/// +/// 行边界只为可读性:切在一条日志行中间,读的人会以为那份日志本身是坏的。 +fn snap_back(text: &str, index: usize) -> usize { + let index = floor_char(text, index); + let window = floor_char(text, index.saturating_sub(200)); + match text[window..index].rfind('\n') { + Some(newline) => window + newline + 1, + None => index, + } +} + +/// 从 `index` 向右找最近的**行首**,找不到就在字符边界上前进。 +fn snap_forward(text: &str, index: usize) -> usize { + let index = ceil_char(text, index); + let window = ceil_char(text, (index + 200).min(text.len())); + match text[index..window].find('\n') { + Some(newline) => index + newline + 1, + None => index, + } +} + +/// 向左回退到最近的字符边界(`str::floor_char_boundary` 仍是 nightly)。 +fn floor_char(text: &str, index: usize) -> usize { + let mut at = index.min(text.len()); + while at > 0 && !text.is_char_boundary(at) { + at -= 1; + } + at +} + +/// 向右前进到最近的字符边界。 +fn ceil_char(text: &str, index: usize) -> usize { + let mut at = index.min(text.len()); + while at < text.len() && !text.is_char_boundary(at) { + at += 1; + } + at +} + +#[cfg(test)] +mod tests { + use super::{clip_text, snap_back, snap_forward}; + use crate::launch::crash_bundle::MAX_ENTRY_BYTES; + + /// 切点必须落在字符边界上(否则下游 `str` 切片会直接 panic)。 + #[test] + fn cuts_land_on_char_boundaries() { + // 每个 CJK 字 3 字节:把边界刻意放在字的中间 + let text = "中".repeat(2000); + let clipped = clip_text(&text, 3 * 500 + 1).expect("应当截断"); + assert!(clipped.contains(" {})", + clipped.len(), + MAX_ENTRY_BYTES + ); + } + + #[test] + fn small_files_are_not_touched() { + assert!(clip_text("short", 1024).is_none()); + assert!(clip_text("short", 0).is_none()); + } + + #[test] + fn snapping_prefers_line_bounds() { + let text = "aaaa\nbbbb\ncccc"; + assert_eq!(snap_back(text, 7), 5, "应回退到「bbbb」行首"); + assert_eq!(snap_forward(text, 5), 10, "应前进到「cccc」行首"); + } +} diff --git a/crates/core/src/launch/crash_bundle/tests.rs b/crates/core/src/launch/crash_bundle/tests.rs index d7b6a4d..e4f0062 100644 --- a/crates/core/src/launch/crash_bundle/tests.rs +++ b/crates/core/src/launch/crash_bundle/tests.rs @@ -6,7 +6,9 @@ use std::io::Read; use std::path::{Path, PathBuf}; -use super::{default_target_dir, write_bundle, BundleEntry, BundleSpec, MAX_ENTRY_BYTES}; +use super::{ + default_target_dir, write_bundle, BundleEntry, BundleSpec, HEAD_BYTES, MAX_ENTRY_BYTES, +}; fn temp_dir(tag: &str) -> PathBuf { let root = std::env::temp_dir().join(format!("ull-m187-{}-{tag}", std::process::id())); @@ -43,6 +45,21 @@ fn names(path: &Path) -> Vec { .collect() } +/// 接近真实日志的填充物。 +/// +/// 为什么不用 `"x".repeat(n)`:连续 4 MiB 的同字符会被脱敏器的「长不透明串」规则 +/// (长度 ≥64 的 base64/hex 形状)整段替换成占位符,长度就不再超限 —— 那样测的是脱敏, +/// 不是截断。 +fn filler(bytes: usize) -> String { + let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk with some words +"; + let mut text = String::with_capacity(bytes + line.len()); + while text.len() < bytes { + text.push_str(line); + } + text +} + fn spec(root: &Path, secrets: Vec) -> BundleSpec { BundleSpec { instance: "1.20.4 · Fabric".into(), @@ -159,7 +176,7 @@ fn missing_crash_report_is_stated_in_the_manifest() { fn oversized_entries_are_truncated_and_recorded() { let root = temp_dir("truncate"); let big = root.join("game").join("latest.log"); - let body = "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32); + let body = filler(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32); write(&big, &body); let target = default_target_dir(&root).join("m187.zip"); @@ -273,7 +290,7 @@ fn truncation_keeps_the_tail_where_the_crash_evidence_lives() { "{head_marker} {}{tail_marker} ", - "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64) + filler(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64) ); write(&session, &body); @@ -296,3 +313,75 @@ fn truncation_keeps_the_tail_where_the_crash_evidence_lives() { let manifest = entry_text(&target, "manifest.txt").expect("清单"); assert!(manifest.contains("已截断"), "{manifest}"); } + +/// P2-1(r2 复审新增,按 p1 对待):**截断过的文件也必须脱敏**。 +/// +/// 抓得住的原 bug:上一版在字节上切两刀,切在多字节字符中间时 `from_utf8` 失败 ⇒ +/// 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文按字节进包(隐私旁路)。 +/// +/// 构造要点:让一个 3 字节的 `中` **正好跨在 `HEAD_BYTES` 边界上**,文件里放注册过的 +/// 会话 id 与家目录路径,尾部再放 exit 事件(证明截断仍然保尾)。 +#[test] +fn truncated_entries_are_still_redacted() { + let root = temp_dir("trunc-redact"); + let home = root.join("home"); + let session = "0f8fad5b-d9cb-469f-a165-70867728950e"; + let head_budget = usize::try_from(HEAD_BYTES).expect("常数"); + + // 前段:真实的日志行 + 家目录 + 会话 id;填充到「离边界只差 2 字节」, + // 再放一个 3 字节的 CJK 字 ⇒ 它必然跨在 HEAD_BYTES 上。 + let mut text = String::new(); + text.push_str(&format!( + "[boot] home={} +", + home.display() + )); + text.push_str(&format!( + "[launch] session={session} +" + )); + let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk +"; + while text.len() + line.len() < head_budget - 2 { + text.push_str(line); + } + while text.len() < head_budget - 2 { + text.push('a'); + } + text.push('中'); + while text.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64 { + text.push_str(line); + } + text.push_str( + "{\"kind\":\"exit\",\"payload\":{\"code\":1}} +", + ); + + let log = root.join("game").join("latest.log"); + write(&log, &text); + assert!( + !text.is_char_boundary(head_budget), + "构造失败:切点应落在字中间" + ); + + let mut spec = spec(&root, vec![session.to_string()]); + spec.entries = vec![BundleEntry::named("game", &log, "latest.log")]; + let target = default_target_dir(&root).join("trunc-redact.zip"); + let bundle = write_bundle(&spec, &target).expect("导出"); + assert_eq!( + bundle.truncated, 1, + "这份文件必须真的被截断(否则测不到旁路)" + ); + + let kept = entry_text(&target, "game/latest.log").expect("条目"); + // ① 包里找不到原文(家目录 / 会话 id) + assert!( + !kept.contains(&home.display().to_string()), + "截断后的文件仍带着家目录原文 —— 脱敏被跳过了" + ); + assert!(!kept.contains(session), "截断后的文件仍带着会话 id 原文"); + // ② 占位符在(说明脱敏真的跑过) + assert!(kept.contains(""), "没有 占位符"); + // ③ 尾部证据仍在(截断仍保尾) + assert!(kept.contains("\"kind\":\"exit\""), "尾部 exit 事件丢了"); +} From 5fd5cd47dc4afb38373ef900fc8261a93f165bee Mon Sep 17 00:00:00 2001 From: worker-logplace Date: Mon, 9 Nov 2026 00:43:45 +0800 Subject: [PATCH 18/33] =?UTF-8?q?feat(m188):=20launcher.log=20=E6=97=A9?= =?UTF-8?q?=E8=90=BD=E7=9B=98=20+=20=E4=BC=9A=E8=AF=9D=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=90=AC=E5=88=B0=20logs/game=EF=BC=88=E6=97=A7=E5=AE=B6?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E5=85=BC=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 启动器运行日志(.ULL/logs/launcher.log) - presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme]) 当场刷盘,不再是「只有开过一局游戏才有日志」。 - app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope), 含 boot/boot::fallback/state/update 各线。 - boot 那一拍补一行后端事实(request/effective/fallback)。 - 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。 会话日志搬家:data/agent/sessions → logs - LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。 - host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。 - 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。 入口与文案 - 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。 - 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。 - README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有), docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。 守门(每条都有变异证明,见报告) - 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。 - launcher.log 滚动 ⇒ 干掉 rename 变红。 - 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。 - 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。 --- README.md | 27 ++- README.zh-CN.md | 31 +++- crates/app-iced/src/app/appearance.rs | 7 +- crates/app-iced/src/app/boot.rs | 21 +-- crates/app-iced/src/app/boot/fallback.rs | 7 +- crates/app-iced/src/app/diag.rs | 164 +++++++++++++++++ crates/app-iced/src/app/dialog/export.rs | 12 +- crates/app-iced/src/app/dialog/tests.rs | 4 + crates/app-iced/src/app/logs_folder.rs | 120 +++++++++++++ crates/app-iced/src/app/mod.rs | 2 + crates/app-iced/src/app/state/boot_probe.rs | 3 +- crates/app-iced/src/app/state/construct.rs | 3 + crates/app-iced/src/app/state/theme.rs | 5 +- crates/app-iced/src/app/update.rs | 19 +- crates/app-iced/src/app/update/agent.rs | 11 +- crates/app-iced/src/app/update/appearance.rs | 3 +- crates/app-iced/src/app/update/boot_env.rs | 21 ++- crates/app-iced/src/app/update/clock.rs | 5 +- crates/app-iced/src/app/update/content.rs | 5 +- .../app-iced/src/app/update/page_messages.rs | 21 ++- .../app-iced/src/app/update/settings/java.rs | 3 +- .../app-iced/src/app/update/update_action.rs | 11 +- crates/app-iced/src/pages/settings/general.rs | 5 + .../app-iced/src/pages/settings/logs_row.rs | 124 +++++++++++++ crates/app-iced/src/pages/settings/mod.rs | 1 + .../pages/settings/tests/general_section.rs | 45 +++++ .../cli/src/commands/agent/ports/diagnose.rs | 9 +- crates/cli/src/commands/agent/ports/events.rs | 62 +++++-- crates/cli/src/commands/agent/ports/mod.rs | 8 +- .../cli/src/commands/agent/ports/session.rs | 9 +- crates/cli/src/commands/launch.rs | 3 + crates/cli/src/commands/sessions.rs | 62 ++++++- crates/cli/src/context.rs | 20 ++- crates/core/src/platform/layout.rs | 22 ++- crates/i18n/src/catalog/golden/tail.rs | 9 +- crates/i18n/src/catalog/golden/tail2.rs | 5 + crates/i18n/src/catalog/settings/general.rs | 11 ++ crates/presenter/src/host/env.rs | 147 ++++++++++++++- crates/presenter/src/host/game.rs | 6 +- crates/presenter/src/host/handle.rs | 4 +- crates/presenter/src/host/launch.rs | 2 + crates/presenter/src/host/logs.rs | 65 ++++--- crates/presenter/src/host/logs/location.rs | 107 +++++++++++ crates/presenter/src/host/logs_tests.rs | 168 ++++++++++++++++++ crates/presenter/src/host/mod.rs | 3 + docs/images/logs-folder-zh.png | Bin 0 -> 118942 bytes docs/logs-location.md | 88 +++++++++ 47 files changed, 1371 insertions(+), 119 deletions(-) create mode 100644 crates/app-iced/src/app/diag.rs create mode 100644 crates/app-iced/src/app/logs_folder.rs create mode 100644 crates/app-iced/src/pages/settings/logs_row.rs create mode 100644 crates/presenter/src/host/logs/location.rs create mode 100644 docs/images/logs-folder-zh.png create mode 100644 docs/logs-location.md diff --git a/README.md b/README.md index 1e630cf..386373a 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,31 @@ lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `se `ull update apply`. Debug builds can rehearse the whole chain against a local directory source (`ULL_UPDATE_DIR`); release builds do not contain that path at all. +### Where the logs are + +Everything the launcher writes on its own side lives under `/logs/`. The settings page has +a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has +*Open folder* as well. The data root is `/.ULL` in portable mode, `/ULL` +in standard mode, and `/.ULL` in custom mode — the same value the settings page displays +as the launcher data directory. + +| Path | What it is | +|---|---| +| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. | +| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. | +| `logs/game/.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. | +| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. | +| `logs/install.log` | Install pipeline journal, one line per stage. | +| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. | +| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. | + +The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`, +`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long +enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft +initialises, the game never gets that far, and the honest evidence is the session JSONL plus +`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is +hiding. + ### Not done yet This list is the reason to believe the rest. None of it is hidden behind marketing copy. @@ -216,7 +241,7 @@ This list is the reason to believe the rest. None of it is hidden behind marketi real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release. The first observation on the live stable channel happens after `0.2.3` ships. - **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*, - *Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset + *Open folder*, *Delete*. In the settings frame: *Save*, *Reset section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance management from the GUI is the instance-wizard work still outstanding, and settings change as you edit them rather than on a Save. diff --git a/README.zh-CN.md b/README.zh-CN.md index da62a9b..e931918 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -163,6 +163,34 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布 构建里**不存在**这条路径。 +### 日志在哪 + +启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个 +路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。 + +![设置页「通用」分组:日志文件夹一行,显示 D:/ULL-Demo/.ULL/logs,旁边是「打开目录」与「复制路径」两个按钮,下面是解释文案](docs/images/logs-folder-zh.png) + +*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同), +打开失败时这一行照样显示,右按钮把路径送进剪贴板。* + +数据根按三种模式取值:便携是 `/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是 +`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。 + +| 路径 | 是什么 | +|---|---| +| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 | +| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 | +| `logs/game/.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 | +| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 | +| `logs/install.log` | 安装流水:每个阶段一行。 | +| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 | +| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 | + +游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、 +`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏 +弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与 +`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。 + ### 还没做 这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。 @@ -172,9 +200,10 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 - **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的, 因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次 观察要等 `0.2.3` 发出去之后。 -- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 +- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 + (日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。) - **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编 `linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染 后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里 diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs index 68fe4c7..385d4ae 100644 --- a/crates/app-iced/src/app/appearance.rs +++ b/crates/app-iced/src/app/appearance.rs @@ -18,6 +18,7 @@ //! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。 //! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。 +use crate::app::diag::diag; use unlucky_presenter::dto::AppCommand; use crate::pages::appearance::Change; @@ -54,7 +55,7 @@ impl App { } else { line.push_str(" layered=skipped outcome=no-window"); } - eprintln!("{line}"); + diag!("{line}"); } /// 设置页改了一项外观。 @@ -123,8 +124,8 @@ impl App { self.appearance_prefs = prefs; match self.bridge.path() { - Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()), - None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"), + Some(path) => diag!("[settings] 外观已写回 {}", path.display()), + None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"), } } } diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 38161ed..82f8362 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -12,6 +12,7 @@ //! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 +use crate::app::diag::diag; use std::time::{Duration, Instant}; use iced::Task; @@ -51,10 +52,10 @@ pub(crate) fn run(options: Options) -> iced::Result { let font_started = Instant::now(); let resolved = fonts::resolve(); let font_ms = font_started.elapsed(); - eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0); - eprintln!("{}", resolved.describe()); + diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0); + diag!("{}", resolved.describe()); if resolved.has_synthetic() { - eprintln!( + diag!( "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理" ); } @@ -64,7 +65,7 @@ pub(crate) fn run(options: Options) -> iced::Result { // 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。 // **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。 if options.screenshot.is_some() && options.shot_flavor().is_none() { - eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); + diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); } let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); diagnostics::report_theme(&options, flavor); @@ -86,14 +87,14 @@ pub(crate) fn run(options: Options) -> iced::Result { } Some(OnFailure::Refuse) => { // 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。 - eprintln!( + diag!( "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", options .screenshot .as_ref() .map_or_else(String::new, |path| path.display().to_string()) ); - eprintln!( + diag!( "[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。" ); Err(iced::Error::GraphicsCreationFailed(error)) @@ -192,13 +193,13 @@ fn boot_probe(options: &Options) -> Boot { if let Some(kind) = renderer::forced_fallback() { let decision = renderer::fallback_round(kind); std::env::set_var("ICED_BACKEND", kind.env_value()); - eprintln!( + diag!( "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", renderer::FALLBACK_ENV, kind.env_value(), kind.env_value() ); - eprintln!("[probe] {}", decision.describe()); + diag!("[probe] {}", decision.describe()); // 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None` // ⇒ 日志写 `probe=skipped`,不写 0ms)。 let (probe, probe_ms) = if acceptance::enabled() { @@ -232,11 +233,11 @@ fn boot_probe(options: &Options) -> Boot { if cfg!(feature = "gpu") { match probe_ms { - Some(elapsed) => eprintln!( + Some(elapsed) => diag!( "[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)", elapsed.as_secs_f64() * 1000.0 ), - None => eprintln!( + None => diag!( "[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)", options.backend.name() ), diff --git a/crates/app-iced/src/app/boot/fallback.rs b/crates/app-iced/src/app/boot/fallback.rs index ef20a28..d373f0a 100644 --- a/crates/app-iced/src/app/boot/fallback.rs +++ b/crates/app-iced/src/app/boot/fallback.rs @@ -9,6 +9,7 @@ //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程 //! 只能建一次事件循环。 +use crate::app::diag::diag; use crate::renderer::Decision; use crate::renderer::{self, acceptance, Kind}; @@ -58,10 +59,10 @@ pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] /// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。 pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { let exe = std::env::current_exe().unwrap_or_else(|err| { - eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); + diag!("[gpu] 降级重开失败:拿不到自身路径({err})"); std::process::exit(exit_code(None)); }); - eprintln!( + diag!( "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ 下一次的日志从 `[probe] 本次是降级重开` 开始。", fallback.name(), @@ -78,7 +79,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { let code = match status { Ok(status) => exit_code(status.code()), Err(err) => { - eprintln!("[gpu] 降级重开失败:{err}"); + diag!("[gpu] 降级重开失败:{err}"); exit_code(None) } }; diff --git a/crates/app-iced/src/app/diag.rs b/crates/app-iced/src/app/diag.rs new file mode 100644 index 0000000..ade7352 --- /dev/null +++ b/crates/app-iced/src/app/diag.rs @@ -0,0 +1,164 @@ +//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。 +//! +//! ## 为什么要有这一层 +//! +//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` / +//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角 +//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。 +//! +//! 现在这些行同时进 `launch::launcher_log`: +//! +//! ```text +//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms +//! → launcher.log: 2026-… [font] 求值耗时 12 ms +//! ``` +//! +//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。 +//! +//! ## 哪些行进日志、哪些只进 stderr +//! +//! 判据只有一条:**事故之后还要不要看它**。 +//! +//! | 进日志 | 只进 stderr | +//! |---|---| +//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | 验收与出图通道的逐帧度量(`[window]` / `[perf]` / `[caps]`——那些行由 `renderer::acceptance` 打,不在本 mission scope) | +//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 组件几何探针(`[feedback-probe]` / `[gallery]` 的断言输出,`components/**`) | +//! | 一次性且量很小的诊断(`[appearance]` / `[corner]` 的切换、`[theme]`、`[shot]` 的通道提示) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) | +//! +//! 反过来**不许**把高频行接进来:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份), +//! 逐帧写会把「事故现场」冲掉。 + +/// 行首没有 `[tag]` 时的默认 scope。 +pub const APP_SCOPE: &str = "app"; + +/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。 +pub(crate) fn emit(line: &str) { + eprintln!("{line}"); + let (scope, message) = split_scope(line); + unlucky_core::launch::launcher_log::record(scope, message); +} + +/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。 +/// +/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。 +pub(crate) fn split_scope(line: &str) -> (&str, &str) { + let Some(rest) = line.strip_prefix('[') else { + return (APP_SCOPE, line); + }; + let Some((scope, tail)) = rest.split_once(']') else { + return (APP_SCOPE, line); + }; + if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) { + return (APP_SCOPE, line); + } + (scope, tail.trim_start()) +} + +/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。 +macro_rules! diag { + ($($arg:tt)*) => {{ + crate::app::diag::emit(&format!($($arg)*)); + }}; +} + +pub(crate) use diag; + +/// 串行化「会改 `launcher_log` 全局目录」的用例。 +/// +/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里 +/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。 +/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。 +#[cfg(test)] +pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::split_scope; + + #[test] + fn the_leading_bracket_becomes_the_scope() { + assert_eq!( + split_scope("[font] 求值耗时 12 ms"), + ("font", "求值耗时 12 ms") + ); + assert_eq!(split_scope("[gpu]"), ("gpu", "")); + // 正文里的方括号不是标签 + assert_eq!( + split_scope("[page] 占位动作 [x] pressed"), + ("page", "占位动作 [x] pressed") + ); + } + + #[test] + fn lines_without_a_tag_fall_back_to_the_default_scope() { + assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行")); + assert_eq!(split_scope("[a b] 空白不是标签"), ("app", "[a b] 空白不是标签")); + } + + /// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。 + /// + /// ## 为什么要「重试」 + /// + /// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例 + /// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会 + /// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要 + /// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被 + /// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。 + /// + /// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」, + /// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。 + #[test] + fn the_launcher_log_rolls_over_and_stays_bounded() { + use unlucky_core::launch::launcher_log; + + let _guard = super::LOG_STATE_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + + let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建夹具目录"); + let path = dir.join(launcher_log::FILE_NAME); + let rolled = dir.join("launcher.1.log"); + let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16; + + for _ in 0..50 { + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&rolled); + launcher_log::set_dir(&dir); + if launcher_log::path() != Some(path.clone()) { + continue; // 这一轮被别人的 set_dir 抢先了 + } + launcher_log::record("boot", "第一行"); + std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志"); + if launcher_log::path() != Some(path.clone()) { + continue; + } + launcher_log::record("boot", "滚过之后这一行必须在新文件里"); + if launcher_log::path() != Some(path.clone()) { + continue; // 追加落到了别人的目录,这一轮不算 + } + + // 到这儿为止,追加**确实**落在本目录:超限就必须滚。 + assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log"); + let size = std::fs::metadata(&path).expect("新文件").len(); + assert!( + size < launcher_log::MAX_BYTES, + "滚完之后新文件必须远小于上限:{size}" + ); + let text = std::fs::read_to_string(&path).expect("读新文件"); + assert!(text.contains("滚过之后这一行"), "{text}"); + + // 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。 + std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志"); + launcher_log::record("boot", "第二次滚动"); + assert!(rolled.is_file()); + assert!(!dir.join("launcher.2.log").exists(), "历史只留一份"); + assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES); + + std::fs::remove_dir_all(&dir).ok(); + return; + } + panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来"); + } +} diff --git a/crates/app-iced/src/app/dialog/export.rs b/crates/app-iced/src/app/dialog/export.rs index 0ce34a1..13b83a8 100644 --- a/crates/app-iced/src/app/dialog/export.rs +++ b/crates/app-iced/src/app/dialog/export.rs @@ -150,12 +150,22 @@ pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String { &[("error", "no folder on disk")], ); }; - match spawn_opener(&dir) { + match open_dir(&dir) { Ok(()) => String::new(), Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]), } } +/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。 +/// +/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。 +pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> { + if !dir.is_dir() { + return Err(format!("{}(目录不存在)", dir.display())); + } + spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display())) +} + #[cfg(target_os = "windows")] fn spawn_opener(dir: &std::path::Path) -> Result<(), String> { // `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。 diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 7002088..41c379a 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -321,6 +321,10 @@ fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() { /// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。 #[test] fn boot_facts_are_recorded_into_the_launcher_log() { + // 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。 + let _guard = crate::app::diag::LOG_STATE_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("建目录"); diff --git a/crates/app-iced/src/app/logs_folder.rs b/crates/app-iced/src/app/logs_folder.rs new file mode 100644 index 0000000..eba9a6e --- /dev/null +++ b/crates/app-iced/src/app/logs_folder.rs @@ -0,0 +1,120 @@ +//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。 +//! +//! ## 为什么不在页面里现拼路径 +//! +//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义) +//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`), +//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。 +//! +//! 本模块只做两件事: +//! +//! 1. 从设置桥给定的 `config/launcher.json` 反推 ``,再用 `LauncherLayout::logs()` 取分区 +//! (分区名只从 core 的 `layout` 来,不手拼字符串); +//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。 +//! +//! ## 谁来登记 +//! +//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方 +//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回 +//! `platform::data::layout()`——同一份解析链的缓存版本。 + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_presenter::settings_bridge::SettingsBridge; + +static LOGS_DIR: OnceLock> = OnceLock::new(); + +/// `/config/launcher.json` → `/logs`(**只有**这一条形状,别处不许手拼)。 +pub(crate) fn logs_dir_of(config: &Path) -> Option { + let home = config.parent()?.parent()?; + Some(LauncherLayout::new(home).logs()) +} + +/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。 +pub(crate) fn register(bridge: &SettingsBridge) { + let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of)); +} + +/// 本进程的日志目录。 +/// +/// - 已登记且解析出来 ⇒ 那条; +/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录; +/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。 +pub(crate) fn logs_dir() -> Option { + match LOGS_DIR.get() { + Some(known) => known.clone(), + None => crate::platform::data::layout().map(|layout| layout.logs()), + } +} + +/// 确保日志目录存在(不在就建),返回给用户看的路径。 +/// +/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。 +pub(crate) fn prepare(dir: &Path) -> Result { + std::fs::create_dir_all(dir) + .map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?; + if !dir.is_dir() { + return Err(format!("{}(这个路径不是一个目录)", dir.display())); + } + Ok(dir.to_path_buf()) +} + +/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。 +/// +/// # Errors +/// +/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。 +pub(crate) fn open() -> Result { + let dir = logs_dir().ok_or_else(|| { + "日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)".to_string() + })?; + let dir = prepare(&dir)?; + crate::app::dialog::export::open_dir(&dir)?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 落点形状:`/config/launcher.json` → `/logs`。 + /// + /// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。 + #[test] + fn the_log_folder_is_the_logs_partition_next_to_config() { + let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json"); + assert_eq!( + logs_dir_of(&config), + Some(PathBuf::from("/tmp/ull-home/.ULL/logs")) + ); + // 少了 config 那一层就没有 `` 可言:宁可不猜。 + assert_eq!(logs_dir_of(Path::new("launcher.json")), None); + } + + /// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。 + /// + /// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。 + #[test] + fn prepare_creates_the_folder_and_reports_the_same_path() { + let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join(".ULL/logs"); + assert!(!dir.is_dir(), "夹具目录不该已经存在"); + let prepared = prepare(&dir).expect("应当建得出来"); + assert_eq!(prepared, dir); + assert!(dir.is_dir(), "打开之前必须真的存在"); + std::fs::remove_dir_all(&root).ok(); + } + + /// 失败文本带路径(「不许只在成功时可用」)。 + #[test] + fn a_failure_names_the_path_it_could_not_use() { + let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id())); + std::fs::write(&file, b"x").expect("写夹具"); + let error = prepare(&file).expect_err("一个文件不是目录"); + assert!(error.contains(&file.display().to_string()), "{error}"); + std::fs::remove_file(&file).ok(); + } +} diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index 4a0be42..dec5277 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -30,7 +30,9 @@ mod appearance; mod backend; mod boot; +mod diag; mod dialog; +pub(crate) mod logs_folder; mod message; mod state; mod subscription; diff --git a/crates/app-iced/src/app/state/boot_probe.rs b/crates/app-iced/src/app/state/boot_probe.rs index a5852ed..94d6e0d 100644 --- a/crates/app-iced/src/app/state/boot_probe.rs +++ b/crates/app-iced/src/app/state/boot_probe.rs @@ -4,6 +4,7 @@ //! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出, //! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。 +use crate::app::diag::diag; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; use std::time::Instant; @@ -32,7 +33,7 @@ pub(crate) fn log_first_frame() { return; } if let Some(started) = STARTED.get() { - eprintln!( + diag!( "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", started.elapsed().as_secs_f64() * 1000.0 ); diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index b6f4a2e..1b4ba1a 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -41,6 +41,9 @@ impl App { Some(root) => SettingsBridge::with_path(config_path(root)), None => SettingsBridge::open(), }; + // M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效), + // 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。 + crate::app::logs_folder::register(&bridge); Self::with_presenter(options, families, flavor, backend, bridge) } diff --git a/crates/app-iced/src/app/state/theme.rs b/crates/app-iced/src/app/state/theme.rs index 6ecb723..7094a52 100644 --- a/crates/app-iced/src/app/state/theme.rs +++ b/crates/app-iced/src/app/state/theme.rs @@ -5,6 +5,7 @@ //! //! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。 +use crate::app::diag::diag; use unlucky_design::color::SchemeFlavor; use unlucky_presenter::prefs::ThemeMode; @@ -30,7 +31,7 @@ impl App { self.appearance.set_flavor(flavor); let previous = self.ui.name(); self.rebuild_ui(); - eprintln!( + diag!( "[theme] {} -> {} (Theme::name 变化触发缓存重算)", previous, self.ui.name() @@ -48,7 +49,7 @@ impl App { let panel_alpha = self.appearance.effective_panel_alpha(); self.ui = match self.appearance.derived_scheme() { Some(scheme) => { - eprintln!( + diag!( "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", self.appearance.palette_level() ); diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 8762ca9..cca731e 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -25,6 +25,7 @@ //! //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 +use crate::app::diag::diag; use std::time::Instant; use iced::{window, Task}; @@ -88,7 +89,7 @@ impl App { match hwnd { Some(_) => self.apply_window_alpha("boot"), None => { - eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); + diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); self.log_appearance("boot", None); } } @@ -102,7 +103,7 @@ impl App { iced::theme::Mode::Dark => SchemeFlavor::Dark, iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light, }; - eprintln!("[theme] 系统主题 = {mode:?}"); + diag!("[theme] 系统主题 = {mode:?}"); // 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。 // 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现 // `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。 @@ -169,7 +170,7 @@ impl App { Message::Captured(screenshot) => self.on_captured(&screenshot), Message::Demo(message) => { // gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收) - eprintln!("[gallery] {} pressed", message.id()); + diag!("[gallery] {} pressed", message.id()); Task::none() } } @@ -187,7 +188,7 @@ impl App { // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, // 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨 // 「没配置」与「配了但登录失败」。 - eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line()); + diag!("{}", unlucky_cli::commands::msa_client_id_log_line()); // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 @@ -218,7 +219,7 @@ impl App { // 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope, // 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。 if let Some(route) = boot_env::env_start_route() { - eprintln!("[boot] ULL_START_ROUTE → {route:?}"); + diag!("[boot] ULL_START_ROUTE → {route:?}"); self.state.navigate(route); // 直达某一页时不要再被「自动选中第一条实例」拉回详情页。 self.auto_selected = true; @@ -228,7 +229,7 @@ impl App { account::force_account_state(self); // M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。 if let Some(mode) = dialog_fixture::apply(self) { - eprintln!("[boot] ULL_DIALOG_FIXTURE → {mode}"); + diag!("[boot] ULL_DIALOG_FIXTURE → {mode}"); } self.auto_select(); @@ -287,9 +288,9 @@ impl App { }; match crate::shot::save(screenshot, &path) { Ok(summary) => { - eprintln!("{}", summary.describe()); + diag!("{}", summary.describe()); // M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。 - eprintln!( + diag!( "[shot] screen={} theme={} renderer={} route={:?}", self.options.screen.name(), crate::shot::flavor_slug(&self.ui), @@ -297,7 +298,7 @@ impl App { self.state.route() ); } - Err(error) => eprintln!("[shot] 失败:{error}"), + Err(error) => diag!("[shot] 失败:{error}"), } iced::exit() diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs index 8f32caa..79b5d16 100644 --- a/crates/app-iced/src/app/update/agent.rs +++ b/crates/app-iced/src/app/update/agent.rs @@ -29,6 +29,7 @@ //! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻 //! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。 +use crate::app::diag::diag; use iced::keyboard::{key, Key}; use iced::Task; @@ -141,10 +142,10 @@ impl App { self.drain_agent(); } SubmitOutcome::Failed(failure) => { - eprintln!("[agent] 提交失败({})", failure.code()); + diag!("[agent] 提交失败({})", failure.code()); } SubmitOutcome::Rejected => { - eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住"); + diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住"); } } } @@ -204,7 +205,7 @@ impl App { let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); if pending && !on_confirm { self.agent.focus = Focus::ConfirmAllow; - eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)"); + diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)"); } else if !pending && on_confirm { self.agent.focus = Focus::Composer; self.agent.keyboard_mode = false; @@ -279,7 +280,7 @@ impl App { } if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent { - eprintln!("[agent] rail=detached → Route::Agent"); + diag!("[agent] rail=detached → Route::Agent"); self.state.navigate(Route::Agent); // 直达独立页时不要再被「自动选中第一条实例」拉回详情页。 self.auto_selected = true; @@ -371,7 +372,7 @@ impl App { let _ = self.on_agent(message); } else { // 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。 - eprintln!( + diag!( "[agent] Enter on focus={} handled by the composer", self.agent.focus.name() ); diff --git a/crates/app-iced/src/app/update/appearance.rs b/crates/app-iced/src/app/update/appearance.rs index e95f6f1..9031a18 100644 --- a/crates/app-iced/src/app/update/appearance.rs +++ b/crates/app-iced/src/app/update/appearance.rs @@ -28,6 +28,7 @@ //! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 //! (M116 取消了 8/12/16 固定档)。 +use crate::app::diag::diag; use crate::platform; use crate::platform::win32::corner::{self, CornerApplied}; use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome}; @@ -73,7 +74,7 @@ impl App { "frame=? layered=?".to_owned(), ), }; - eprintln!( + diag!( "{}", corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by) ); diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index 31e0419..2e77b4e 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -31,15 +31,15 @@ pub(super) fn env_start_route() -> Option { /// /// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`), /// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record` -/// (core 侧缓冲,最多 200 行);第一次启动时 presenter 调 `launcher_log::set_dir`,缓冲一次 -/// 刷进 `<.ULL>/logs/launcher.log`。 +/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调 +/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。 /// /// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从 /// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。 /// -/// 边界(写在明处):进程若一次都没启动游戏、又没触发导出,`set_dir` 就不会被调用, -/// 这些行只留在内存里 → 那个进程不产生 `launcher.log`。要彻底解决得让 presenter 在构造 -/// `HostEnv` 时就登记目录(`host/mod.rs` 不在本 mission scope,已登记为 follow-up)。 +/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走 +/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」 +/// 的进程也会留下 `launcher.log`。 pub(crate) fn record_boot_log(app: &super::super::state::App) { use unlucky_core::launch::launcher_log; @@ -58,4 +58,15 @@ pub(crate) fn record_boot_log(app: &super::super::state::App) { ), ); launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line()); + // 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到 + // stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。 + launcher_log::record( + "boot", + &format!( + "backend request={} effective={} renderer_fallback={}", + app.options.backend.name(), + super::super::backend::effective_backend(app.options.backend).name(), + std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()), + ), + ); } diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 31f9aed..bf79ccd 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -17,6 +17,7 @@ //! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干, //! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。 +use crate::app::diag::diag; use std::time::{Duration, Instant}; use unlucky_presenter::dto::AppCommand; @@ -74,7 +75,7 @@ impl App { let reloaded = !self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some(); if reloaded { - eprintln!("[settings] 配置文件变了,重读主题 + 外观"); + diag!("[settings] 配置文件变了,重读主题 + 外观"); self.apply_config_theme(); // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 self.reload_appearance(); @@ -92,7 +93,7 @@ impl App { // 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)—— // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { - eprintln!( + diag!( "[tick] n={} frames={} refreshes={} clock={}", self.ticks, self.frames, diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 42668b8..4d30049 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -16,6 +16,7 @@ //! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时 //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 +use crate::app::diag::diag; use unlucky_presenter::dto::{ AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView, @@ -199,10 +200,10 @@ impl App { if !model.can_install() { // 有原因就打印出来(取证用);没有选中项时什么都不做 for reason in model.blocker_reasons(catalog) { - eprintln!("[content] 装入被挡下:{reason}"); + diag!("[content] 装入被挡下:{reason}"); } if model.blockers.contains(&Blocker::NeedsPipeline) { - eprintln!("[content] 整合包装入未实现(M111)"); + diag!("[content] 整合包装入未实现(M111)"); } return None; } diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index afe52b9..992de11 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -21,6 +21,7 @@ use unlucky_i18n::{Lang, LangChoice}; +use crate::app::diag::diag; use crate::pages; use crate::ui::lang::LangState; @@ -94,9 +95,23 @@ impl App { pages::Message::Instances(message) => return self.on_instance_message(message), // M114:账户页(三轨登录 / 列表动作 / 表单输入) pages::Message::Account(message) => return self.on_account(message), + // M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推 + // (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。 + pages::Message::Action(pages::Action::OpenLogs) => { + return match crate::app::logs_folder::open() { + Ok(dir) => { + diag!("[logs] 已打开日志文件夹 {}", dir.display()); + Task::none() + } + Err(error) => { + diag!("[logs] 打开日志文件夹失败:{error}"); + iced::clipboard::write(error) + } + }; + } // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) pages::Message::Action(action) => { - eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); + diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); } } @@ -127,9 +142,7 @@ impl App { self.lang = LangState::of(choice); self.lang.log(); if !self.bridge.persist_ui_language(choice.setting_value()) { - eprintln!( - "[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值" - ); + diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"); } self.lang.log_cjk(); self.view_dirty = true; diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index 7747a6f..3ebc6c7 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -21,6 +21,7 @@ //! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。 //! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。 +use crate::app::diag::diag; use super::super::*; use crate::pages::settings::java::{Context, Layer, Notice, Requirement}; @@ -284,7 +285,7 @@ fn write_notice(error: pages::settings::java::store::WriteError) -> Notice { WriteError::Refused(refusal) => Notice::Refused(refusal), WriteError::Unwritable(reason) => Notice::Unwritable(reason), WriteError::Schema(detail) => { - eprintln!("[java] 设置写盘失败:{detail}"); + diag!("[java] 设置写盘失败:{detail}"); Notice::WriteFailed } } diff --git a/crates/app-iced/src/app/update/update_action.rs b/crates/app-iced/src/app/update/update_action.rs index a277a4c..7c0d47a 100644 --- a/crates/app-iced/src/app/update/update_action.rs +++ b/crates/app-iced/src/app/update/update_action.rs @@ -8,6 +8,7 @@ //! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己—— //! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。 +use crate::app::diag::diag; use iced::Task; use unlucky_presenter::dto::AppCommand; @@ -54,7 +55,7 @@ impl App { /// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。 fn refuse_busy(&self, what: &str) -> Task { - eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」"); + diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」"); Task::none() } @@ -64,26 +65,26 @@ impl App { /// 文件、并把取证进程拖成死循环。 fn restart_now(&mut self) -> Task { if self.options.screenshot.is_some() { - eprintln!("[update] 截图通道不重启(忽略「立即重启」)"); + diag!("[update] 截图通道不重启(忽略「立即重启」)"); return Task::none(); } match std::env::current_exe() { Ok(exe) => { let args: Vec = std::env::args_os().skip(1).collect(); match std::process::Command::new(&exe).args(&args).spawn() { - Ok(child) => eprintln!( + Ok(child) => diag!( "[update] 已拉起新进程 pid={}:{}", child.id(), exe.display() ), Err(error) => { - eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)"); + diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)"); return Task::none(); } } } Err(error) => { - eprintln!("[update] 取 current_exe 失败:{error}"); + diag!("[update] 取 current_exe 失败:{error}"); return Task::none(); } } diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index e5fb202..4757b56 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -78,6 +78,11 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { stacked = stacked.push(divider(ui)); stacked = stacked.push(font_row(ui, language)); + // M188:日志文件夹(路径 + 打开 + 复制路径)。放在「主题/启动页」之前——用户投诉 + // 「找不到崩溃日志」要的答案,不该埋在只读行后面。 + stacked = stacked.push(divider(ui)); + stacked = stacked.push(super::logs_row::folder_row(ui, &catalog)); + // 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 for (label, value) in [ (Key::SettingsTheme, Key::SettingsThemeFollowSystem), diff --git a/crates/app-iced/src/pages/settings/logs_row.rs b/crates/app-iced/src/pages/settings/logs_row.rs new file mode 100644 index 0000000..83b66ed --- /dev/null +++ b/crates/app-iced/src/pages/settings/logs_row.rs @@ -0,0 +1,124 @@ +//! 设置页「General」组的**日志文件夹**一行(M188)。 +//! +//! 用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 +//! 这一行是对那句投诉的直接回答,三件事缺一不可: +//! +//! 1. **路径永远显示**(不是只在打开成功时才显示):`<数据根>/logs`,三模式解析出来的真值; +//! 2. **两个入口**:「打开文件夹」走平台打开器(`app::dialog::export::open_dir`,与崩溃弹窗同一个, +//! 不另写一套 `cfg`),「复制路径」把路径送进剪贴板——打开失败时用户至少能照着路径自己去找; +//! 3. **说清里面有什么**:`launcher.log`(滚动上限写进文案)与 `logs/game/` 的会话 jsonl, +//! 以及旧目录「仍能读、没搬动」——否则用户换了版本会以为旧日志丢了。 +//! +//! 为什么单独一个文件:`pages/settings/general.rs` 贴着 400 行硬线(语言行 + 字体行 + 三行只读), +//! 这一行自带按钮与样式,塞回去必破线。 + +use iced::widget::{button, column, container, row as row_widget}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; + +use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::{Catalog, Key}; + +use crate::pages::{frame, Action, Message}; +use crate::theme::Ui; + +/// 这一行(竖排:标签 / 路径 / 两个按钮 / 一句说明)。 +pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> { + let path = crate::app::logs_folder::logs_dir() + .map(|dir| dir.display().to_string()) + .unwrap_or_else(|| catalog.text(Key::SettingsLogsUnknown).to_string()); + + let hint = catalog.text(Key::SettingsLogsFolderHint); + let actions = row_widget![ + action_button( + ui, + catalog.text(Key::LogsOpenFolder), + Message::Action(Action::OpenLogs), + ), + action_button( + ui, + catalog.text(Key::SettingsLogsCopy), + Message::CopySummary(path.clone()), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + column![ + frame_text( + ui, + TypeRole::BodyMedium, + catalog.text(Key::SettingsLogsFolder), + ui.color(Role::OnSurface), + ), + container(frame_text_owned( + ui, + TypeRole::BodySmall, + path, + ui.color(Role::OnSurfaceVariant), + )) + .width(Length::Fill), + actions, + frame_text( + ui, + TypeRole::BodySmall, + hint, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +fn frame_text<'a>( + ui: &'a Ui, + role: TypeRole, + text: &'a str, + color: iced::Color, +) -> Element<'a, Message> { + frame::text_role(ui, role, false, text, color) +} + +fn frame_text_owned<'a>( + ui: &'a Ui, + role: TypeRole, + text: String, + color: iced::Color, +) -> Element<'a, Message> { + frame::text_role_owned(ui, role, false, text, color) +} + +/// 描边小按钮(与设置页其他「次级动作」同款:透明底、hover 出状态层、全圆角)。 +fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a, Message> { + let height = ui.metrics().control_height; + let content = ui.color(Role::OnSurface); + let outline = ui.color(Role::OutlineVariant); + button( + container(frame_text(ui, TypeRole::LabelLarge, label, content)).align_y(Alignment::Center), + ) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X3)])) + .style(move |_theme: &IcedTheme, status| { + let background = matches!(status, button::Status::Hovered | button::Status::Pressed) + .then(|| { + Background::Color(ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface)) + }); + button::Style { + background, + text_color: content, + border: Border { + color: outline, + width: 1.0, + radius: ui.radius(ShapeScale::Full, height), + }, + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(message) + .into() +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 92838db..4e17bf3 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -143,6 +143,7 @@ mod frame; mod general; pub(crate) mod java; pub(crate) mod language_appearance; +mod logs_row; mod message; mod state; pub(crate) mod update; diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 702a9e0..ad7894e 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -196,3 +196,48 @@ fn the_general_group_does_not_use_the_appearance_body_slot() { "General 的真控件走 pages::Language 入口(见 settings/mod.rs 的 GENERAL_GROUP),不是 body" ); } + +/// **M188:日志文件夹那一行**的文案必须与实现一致(用户投诉「找不到崩溃日志」的直接判据)。 +/// +/// 抓得住:①文案里的路径写错(写成 `data/logs`、`cache/game`);②滚动上限与 +/// `core::launch::launcher_log::MAX_BYTES` 脱钩(文案说 1 MiB、实现改成 4 MiB); +/// ③旧目录被静默改成「已迁移」(那会让用户以为旧日志没了); +/// ④某条 key 只进了英文表(中文界面上是 `⟪missing⟫`)。 +/// 抓不住:界面画出来长什么样(截图验收管那一层)。 +#[test] +fn the_logs_row_names_the_real_paths_and_the_real_limit() { + use unlucky_core::launch::launcher_log; + use unlucky_core::platform::layout::{LauncherLayout, GAME_LOG_DIR, LOGS_DIR}; + + // 实现侧的真值:分区名与上限都只从 core 来。 + assert_eq!(LOGS_DIR, "logs"); + assert_eq!(GAME_LOG_DIR, "game"); + assert_eq!(launcher_log::MAX_BYTES, 1024 * 1024, "文案说 1 MiB"); + let layout = LauncherLayout::new(std::path::Path::new("/tmp/ull-home")); + assert_eq!( + layout.game_logs(), + std::path::Path::new("/tmp/ull-home/logs/game") + ); + + for lang in Lang::ALL { + let catalog = lang.catalog(); + let hint = catalog.text(Key::SettingsLogsFolderHint); + for needle in [ + "launcher.log", + "1 MiB", + &format!("{LOGS_DIR}/{GAME_LOG_DIR}/"), + "data/agent/sessions", + ] { + assert!(hint.contains(needle), "{} 缺 `{needle}`:{hint}", lang.tag()); + } + assert!(!hint.contains('⟪'), "{} 目录里缺词:{hint}", lang.tag()); + assert!( + !catalog.text(Key::SettingsLogsFolder).contains('⟪'), + "{} 缺「日志文件夹」:{}", + lang.tag(), + catalog.text(Key::SettingsLogsFolder) + ); + assert!(!catalog.text(Key::SettingsLogsCopy).contains('⟪')); + assert!(!catalog.text(Key::SettingsLogsUnknown).contains('⟪')); + } +} diff --git a/crates/cli/src/commands/agent/ports/diagnose.rs b/crates/cli/src/commands/agent/ports/diagnose.rs index 0143f0e..0f9b4e5 100644 --- a/crates/cli/src/commands/agent/ports/diagnose.rs +++ b/crates/cli/src/commands/agent/ports/diagnose.rs @@ -27,7 +27,7 @@ pub struct CoreDiagnose { } impl CoreDiagnose { - /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + /// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。 #[must_use] pub fn new(root: impl Into) -> Self { Self { @@ -35,6 +35,13 @@ impl CoreDiagnose { tail: DEFAULT_TAIL, } } + + /// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧会话也能诊断。 + #[must_use] + pub fn with_legacy(mut self, legacy: impl Into) -> Self { + self.source = self.source.with_legacy(legacy); + self + } } fn map_kind(kind: CoreFixKind) -> FixActionKind { diff --git a/crates/cli/src/commands/agent/ports/events.rs b/crates/cli/src/commands/agent/ports/events.rs index d313200..86f249d 100644 --- a/crates/cli/src/commands/agent/ports/events.rs +++ b/crates/cli/src/commands/agent/ports/events.rs @@ -13,35 +13,69 @@ use unlucky_core::launch::session_index::{self, SessionIndexEntry}; use unlucky_agent::port::PortResult; -/// 一个数据根下的会话索引 + 事件流。 +/// 一个数据根下的会话索引 + 事件流(可以再挂一个**只读兼容**的旧根,M188)。 #[derive(Debug, Clone)] pub struct SessionSource { root: PathBuf, + legacy: Option, } impl SessionSource { - /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + /// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。 #[must_use] pub fn new(root: impl Into) -> Self { - Self { root: root.into() } + Self { + root: root.into(), + legacy: None, + } } - /// 索引文件路径。 + /// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧的会话仍然列得出来、打得开。 + #[must_use] + pub fn with_legacy(mut self, legacy: impl Into) -> Self { + self.legacy = Some(legacy.into()); + self + } + + /// 读会话时要看的根,**新根在前**。 + fn roots(&self) -> Vec<&Path> { + let mut roots = vec![self.root.as_path()]; + if let Some(legacy) = &self.legacy { + if legacy.is_dir() { + roots.push(legacy.as_path()); + } + } + roots + } + + /// 索引文件路径(新根)。 #[must_use] pub fn index(&self) -> PathBuf { session_index::index_path(&self.root) } - /// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。 + /// 全部会话(按 `started_at` 倒序,同 id 取最后一行;两个根合并,新根优先)。 /// /// # Errors /// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。 pub fn entries(&self) -> PortResult> { - let index = self.index(); - let mut entries = session_index::read_all(&index) - .map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?; - entries.sort_by(|a, b| b.started_at.cmp(&a.started_at)); - Ok(entries) + let mut merged: Vec = Vec::new(); + for root in self.roots() { + let index = session_index::index_path(root); + let entries = session_index::read_all(&index) + .map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?; + for entry in entries { + if merged + .iter() + .any(|item| item.session_id == entry.session_id) + { + continue; + } + merged.push(entry); + } + } + merged.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + Ok(merged) } /// 按完整 id 或唯一前缀找会话。 @@ -85,9 +119,15 @@ impl SessionSource { .collect()) } - /// 事件流文件路径。 + /// 事件流文件路径(新根没有时回落到旧根)。 #[must_use] pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf { + for root in self.roots() { + let path = root.join("game").join(&entry.log_path); + if path.is_file() { + return path; + } + } self.root.join("game").join(&entry.log_path) } diff --git a/crates/cli/src/commands/agent/ports/mod.rs b/crates/cli/src/commands/agent/ports/mod.rs index 195b067..719a845 100644 --- a/crates/cli/src/commands/agent/ports/mod.rs +++ b/crates/cli/src/commands/agent/ports/mod.rs @@ -37,8 +37,12 @@ pub fn build(ctx: &Ctx) -> Ports { ctx.launcher_config(), ctx.portable_launcher_config(), )), - sessions: Arc::new(CoreSessions::new(ctx.sessions_root())), - diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())), + sessions: Arc::new( + CoreSessions::new(ctx.sessions_root()).with_legacy(ctx.legacy_sessions_root()), + ), + diagnose: Arc::new( + CoreDiagnose::new(ctx.sessions_root()).with_legacy(ctx.legacy_sessions_root()), + ), game: Arc::new(CoreGame::new( ctx.layout(), ctx.game(), diff --git a/crates/cli/src/commands/agent/ports/session.rs b/crates/cli/src/commands/agent/ports/session.rs index 773fc85..ae03cd9 100644 --- a/crates/cli/src/commands/agent/ports/session.rs +++ b/crates/cli/src/commands/agent/ports/session.rs @@ -15,13 +15,20 @@ pub struct CoreSessions { } impl CoreSessions { - /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + /// `root` = `<数据根>/.ULL/logs`(M188 起;`LauncherConfig` 在其下建 `game/`)。 #[must_use] pub fn new(root: impl Into) -> Self { Self { source: SessionSource::new(root), } } + + /// 再挂一个只读兼容的旧根(`<数据根>/.ULL/data/agent/sessions`,M188)。 + #[must_use] + pub fn with_legacy(mut self, legacy: impl Into) -> Self { + self.source = self.source.with_legacy(legacy); + self + } } fn summary_of( diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 8340651..ba7e80a 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -106,6 +106,9 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result { }; let plan = snapshot::prepare(ctx, &input)?; + // M188:命令行这一路也把启动器自己的运行日志落到 `/logs/launcher.log` + // (GUI 由 presenter 的 `HostEnv` 登记;两条路的落点必须是同一个)。 + unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir()); let config = LauncherConfig::new(ctx.sessions_root()) .with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); let launcher = Launcher::new(config); diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs index e8865c0..de26875 100644 --- a/crates/cli/src/commands/sessions.rs +++ b/crates/cli/src/commands/sessions.rs @@ -4,6 +4,9 @@ //! - `/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序); //! - `/game/.jsonl`:事件流(`kind` + `payload`)。 //! +//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容: +//! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。 +//! //! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。 use std::path::Path; @@ -13,6 +16,7 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; use unlucky_core::diagnose::loader::RuleLoader; use unlucky_core::launch::session_index::{self, SessionIndexEntry}; +use unlucky_core::platform::layout::GAME_LOG_DIR; use crate::cli::{DiagArgs, LogsArgs}; use crate::context::Ctx; @@ -26,8 +30,7 @@ use crate::output::Report; /// 索引文件读不了时返回用户错误(不存在 = 空列表)。 pub fn list(ctx: &Ctx, limit: usize) -> Result { let index = ctx.session_index(); - let entries = session_index::read_all(&index) - .map_err(|error| CliError::io("读会话索引", &index, &error))?; + let entries = merged_entries(ctx)?; let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect(); let rows: Vec = shown @@ -56,6 +59,15 @@ pub fn list(ctx: &Ctx, limit: usize) -> Result { shown.len(), index.display() )]; + // M188:旧家的会话仍然列得出来,就得说清它们从哪来——否则用户会以为「新目录里怎么没有」。 + let legacy = ctx.legacy_session_index(); + if legacy.is_file() { + human.push(format!( + " 旧会话目录仍然只读兼容:{}(不搬不删)", + legacy.display() + )); + } + human.push(format!(" 启动器日志:{}", ctx.logs_dir().display())); if shown.is_empty() { human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string()); } @@ -205,8 +217,7 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result { fn find_entry(ctx: &Ctx, key: &str) -> Result { let index = ctx.session_index(); - let entries = session_index::read_all(&index) - .map_err(|error| CliError::io("读会话索引", &index, &error))?; + let entries = merged_entries(ctx)?; if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) { return Ok(entry.clone()); } @@ -228,7 +239,48 @@ fn find_entry(ctx: &Ctx, key: &str) -> Result { } fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf { - ctx.sessions_root().join("game").join(&entry.log_path) + // M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。 + // 先看新根,再回落旧家;两处都没有就把新根路径交出去(错误信息据此说「该去哪找」)。 + let primary = ctx + .sessions_root() + .join(GAME_LOG_DIR) + .join(&entry.log_path); + if primary.is_file() { + return primary; + } + let legacy = ctx + .legacy_sessions_root() + .join(GAME_LOG_DIR) + .join(&entry.log_path); + if legacy.is_file() { + return legacy; + } + primary +} + +/// 两个索引合并后的条目(新根 `logs/` 在前,旧家只读兼容在后;同 id 以新根为准)。 +fn merged_entries(ctx: &Ctx) -> Result, CliError> { + let mut indexes = vec![ctx.session_index()]; + let legacy = ctx.legacy_session_index(); + if legacy.is_file() { + indexes.push(legacy); + } + let mut merged: Vec = Vec::new(); + for index in indexes { + for entry in session_index::read_all(&index) + .map_err(|error| CliError::io("读会话索引", &index, &error))? + { + if merged + .iter() + .any(|item| item.session_id == entry.session_id) + { + continue; + } + merged.push(entry); + } + } + merged.sort_by(|left, right| right.started_at.cmp(&left.started_at)); + Ok(merged) } fn read_events(path: &Path) -> Result, CliError> { diff --git a/crates/cli/src/context.rs b/crates/cli/src/context.rs index 1ae4b90..3c46d49 100644 --- a/crates/cli/src/context.rs +++ b/crates/cli/src/context.rs @@ -168,12 +168,24 @@ impl Ctx { self.layout().profiles_config() } - /// 会话根(`/agent/sessions`,launch 的事件流与索引都在这下面)。 + /// 会话根(M188 起 = `/logs`;launch 的事件流与索引都在这下面)。 #[must_use] pub fn sessions_root(&self) -> PathBuf { + self.layout().sessions_root() + } + + /// 会话日志的**旧家**(`/data/agent/sessions`):只读兼容,M188 起不再往里写。 + #[must_use] + pub fn legacy_sessions_root(&self) -> PathBuf { self.layout().agent_sessions() } + /// 启动器日志目录(`/logs`:`launcher.log` + `game/` 会话)。 + #[must_use] + pub fn logs_dir(&self) -> PathBuf { + self.layout().logs() + } + /// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。 #[must_use] pub fn auth_data(&self) -> PathBuf { @@ -186,6 +198,12 @@ impl Ctx { unlucky_core::launch::session_index::index_path(&self.sessions_root()) } + /// 旧家的会话索引(`data/agent/sessions/game/index.jsonl`):只读兼容。 + #[must_use] + pub fn legacy_session_index(&self) -> PathBuf { + unlucky_core::launch::session_index::index_path(&self.legacy_sessions_root()) + } + /// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。 /// /// # Errors diff --git a/crates/core/src/platform/layout.rs b/crates/core/src/platform/layout.rs index 146a011..bb05618 100644 --- a/crates/core/src/platform/layout.rs +++ b/crates/core/src/platform/layout.rs @@ -19,6 +19,8 @@ pub const DATA_DIR: &str = "data"; pub const CACHE_DIR: &str = "cache"; /// 分区:启动器日志。 pub const LOGS_DIR: &str = "logs"; +/// `logs/` 下的游戏会话目录名(事件流与 `index.jsonl`;M188)。 +pub const GAME_LOG_DIR: &str = "game"; /// 全局设置文件名。 pub const LAUNCHER_CONFIG_FILE: &str = "launcher.json"; @@ -181,7 +183,25 @@ impl LauncherLayout { self.data().join("auth") } - /// `data/agent/sessions/`——AI 会话 jsonl 与审计日志。 + /// 会话根(M188 起 = `logs/`)。 + /// + /// `launch::LauncherConfig` 在会话根下自己建 `game/`,所以「会话根」与「事件流目录」差一层: + /// 根是这里,真落点是 [`Self::game_logs`]。 + #[must_use] + pub fn sessions_root(&self) -> PathBuf { + self.logs() + } + + /// `logs/game/`——游戏会话事件流(`.jsonl`)与 `index.jsonl` 的真落点(M188)。 + #[must_use] + pub fn game_logs(&self) -> PathBuf { + self.logs().join(GAME_LOG_DIR) + } + + /// `data/agent/sessions/`——会话日志的**旧家**(M188 起只读兼容)。 + /// + /// 目录名里的 `agent` 是历史遗留:它从来只装游戏会话,与 AI 无关。M188 把新会话挪到 + /// `logs/game/`,这里**不搬不删**(用户的数据不该被偷偷移动),只是继续被读出来。 #[must_use] pub fn agent_sessions(&self) -> PathBuf { self.data().join("agent").join("sessions") diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index f80c3a0..6560f69 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -24,6 +24,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"), ("SettingsLanguage", "Language", "界面语言"), ("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"), + ("SettingsLogsFolder", "Log folder", "日志文件夹"), + ("SettingsLogsFolderHint", "What explains a crash lives here: the launcher's own runtime log (launcher.log, rolled over at 1 MiB with one old copy kept) and one jsonl per game session under logs/game/. Sessions from older versions (data/agent/sessions) are still read, not moved.", "解释一次崩溃要看的东西都在这:启动器自己的运行日志(launcher.log,满 1 MiB 滚动一次、只留一份旧档)与每次游戏会话的 jsonl(logs/game/)。旧版本留下的会话(data/agent/sessions)仍然读得出来,不会被搬动。"), + ("SettingsLogsCopy", "Copy path", "复制路径"), + ("SettingsLogsUnknown", "Data directory not resolved — restart the launcher from its own folder", "数据根没解析出来——从启动器所在目录重新启动试试"), ("SettingsAppearanceCorner", "Window corners", "窗口圆角"), ("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"), ("SettingsAppearanceCornerSquare", "Square", "直角"), @@ -389,9 +393,4 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"), ("PackPathLabel", "Pack path", "整合包路径"), ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"), - ("PackImportAction", "Import", "导入"), - ("PackCancel", "Cancel import", "取消导入"), - ("PackDismiss", "Dismiss", "关闭"), - ("PackTargetLabel", "Install into", "装到实例"), - ("PackTargetNew", "New instance", "新建实例"), ]; diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index fab5ef2..a7570c7 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -4,6 +4,11 @@ //! 拼装见 `golden.rs` 的 [`super::rows`]。 pub(super) const ROWS: &[(&str, &str, &str)] = &[ + ("PackImportAction", "Import", "导入"), + ("PackCancel", "Cancel import", "取消导入"), + ("PackDismiss", "Dismiss", "关闭"), + ("PackTargetLabel", "Install into", "装到实例"), + ("PackTargetNew", "New instance", "新建实例"), ("PackInstanceName", "Instance name", "实例名"), ("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), ("PackStageProbe", "Reading the manifest", "读取清单"), diff --git a/crates/i18n/src/catalog/settings/general.rs b/crates/i18n/src/catalog/settings/general.rs index ba523cf..bfab316 100644 --- a/crates/i18n/src/catalog/settings/general.rs +++ b/crates/i18n/src/catalog/settings/general.rs @@ -12,6 +12,17 @@ macro_rules! settings_general_entries { "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。" ), + // ── 日志文件夹(M188:用户投诉「找不到崩溃日志」的直接落点) + SettingsLogsFolder => ("Log folder", "日志文件夹"), + SettingsLogsFolderHint => ( + "What explains a crash lives here: the launcher's own runtime log (launcher.log, rolled over at 1 MiB with one old copy kept) and one jsonl per game session under logs/game/. Sessions from older versions (data/agent/sessions) are still read, not moved.", + "解释一次崩溃要看的东西都在这:启动器自己的运行日志(launcher.log,满 1 MiB 滚动一次、只留一份旧档)与每次游戏会话的 jsonl(logs/game/)。旧版本留下的会话(data/agent/sessions)仍然读得出来,不会被搬动。" + ), + SettingsLogsCopy => ("Copy path", "复制路径"), + SettingsLogsUnknown => ( + "Data directory not resolved — restart the launcher from its own folder", + "数据根没解析出来——从启动器所在目录重新启动试试" + ), } } }; } diff --git a/crates/presenter/src/host/env.rs b/crates/presenter/src/host/env.rs index a1b6fd7..28b22f6 100644 --- a/crates/presenter/src/host/env.rs +++ b/crates/presenter/src/host/env.rs @@ -65,9 +65,17 @@ impl HostEnv { } /// 由解析结果建环境。 + /// + /// **顺带做一件有副作用的事**:把本进程的日志目录登记给 `launch::launcher_log`(M188)。 + /// 这是**启动早期那几行诊断**能落到 `<.ULL>/logs/launcher.log` 的唯一时机——app 层在 + /// presenter 打开之前就在打 `[probe]` / `[font]` / `[theme]`,那些行先被 core 缓冲 + /// (上限 200 行),到这里一次刷出。此前只有「启动一次游戏」或「导出诊断包」才登记, + /// 于是「双击 → 启动 → 崩」的进程根本不产生 launcher.log——正是用户投诉的那一半。 pub fn from_data(data: ResolvedData) -> Self { + let layout = data.layout(); + unlucky_core::launch::launcher_log::set_dir(layout.logs()); Self { - layout: data.layout(), + layout, game: GameLayout::new(data.game_home.clone()), platform: Platform::current(), data, @@ -84,11 +92,21 @@ impl HostEnv { SettingStore::load(self.launcher_config(), launcher_schema()) } - /// 启动会话根(`.ULL/data/agent/sessions`)。 + /// 启动会话根(M188 起 = `logs/`;`LauncherConfig` 在其下建 `game/`)。 pub fn sessions_root(&self) -> PathBuf { + self.layout.sessions_root() + } + + /// 会话日志的**旧家**(`data/agent/sessions/`):只读兼容,M188 起不再往里写。 + pub fn legacy_sessions_root(&self) -> PathBuf { self.layout.agent_sessions() } + /// 启动器日志目录(`logs/`:`launcher.log` + `game/` 会话)。 + pub fn logs_dir(&self) -> PathBuf { + self.layout.logs() + } + /// 数据目录模式。 pub fn mode(&self) -> DataMode { self.data.mode @@ -137,14 +155,133 @@ pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use unlucky_core::platform::paths::MapEnv; #[test] fn at_root_splits_launcher_and_game_homes() { - let env = HostEnv::at_root("D:/tmp/ull-demo"); - assert_eq!(env.layout.home(), Path::new("D:/tmp/ull-demo/.ULL")); - assert_eq!(env.game.root(), Path::new("D:/tmp/ull-demo/.minecraft")); + let root = std::env::temp_dir().join(format!("ull-host-env-root-{}", std::process::id())); + let env = HostEnv::at_root(&root); + assert_eq!(env.layout.home(), root.join(".ULL")); + assert_eq!(env.game.root(), root.join(".minecraft")); assert_eq!(env.mode(), DataMode::Portable); assert!(env.data_line().contains("portable")); + std::fs::remove_dir_all(&root).ok(); + } + + /// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。 + /// + /// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`): + /// + /// | 模式 | 期望的 `` | 期望的日志根 | + /// |---|---|---| + /// | portable | `/.ULL` | `/.ULL/logs` | + /// | standard | `%APPDATA%/ULL` | `%APPDATA%/ULL/logs` | + /// | custom | `/.ULL` | `/.ULL/logs` | + /// + /// 这里只碰解析结果、不建 `HostEnv`:`from_data` 会真的去登记日志目录(那是产品行为, + /// 单测里拿假绝对路径调它会在盘根建目录)。 + /// 变异证明:把 `LauncherLayout::logs` 改成返回 `cache/`,三条全红。 + #[test] + fn logs_root_follows_the_three_data_modes() { + let env = MapEnv::new().with("APPDATA", "/roaming"); + let exe_dir = PathBuf::from("/games/ULL"); + // custom 模式的根必须是**绝对路径**(Windows 上 `/custom-root` 不算绝对,要带盘符)。 + let custom_root = std::env::temp_dir().join("ull-m188-custom"); + let resolver = Resolver::with_env(Platform::current(), Some(exe_dir.clone()), &env); + + let cases = [ + ( + DataDirRequest { + mode: DataMode::Portable, + ..DataDirRequest::default() + }, + exe_dir.join(".ULL/logs"), + ), + ( + DataDirRequest { + mode: DataMode::Standard, + ..DataDirRequest::default() + }, + PathBuf::new(), + ), + ( + DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(custom_root.clone()), + ..DataDirRequest::default() + }, + custom_root.join(".ULL/logs"), + ), + ]; + + for (request, expected) in cases { + let Ok(data) = resolver.resolve(&request) else { + // 标准模式在某些平台拿不到(没有 HOME/APPDATA):这一档只能跳过,不许假绿。 + assert_eq!(request.mode, DataMode::Standard, "{request:?}"); + continue; + }; + let home = data.launcher_home.clone(); + let layout = data.layout(); + assert_eq!(layout.logs(), home.join("logs"), "{request:?}"); + assert_eq!(layout.sessions_root(), home.join("logs"), "{request:?}"); + assert_eq!(layout.game_logs(), home.join("logs/game"), "{request:?}"); + assert_eq!( + layout.agent_sessions(), + home.join("data/agent/sessions"), + "{request:?}" + ); + if !expected.as_os_str().is_empty() { + assert_eq!(layout.logs(), expected, "{request:?}"); + } + // 自定义模式的根在临时目录里,顺手把 `HostEnv` 那三个访问器也钉一遍—— + // 「layout 对了但 `HostEnv::logs_dir` 写死」这种假绿只有这一段抓得住。 + if request.mode == DataMode::Custom { + let env = HostEnv::from_data(data); + assert_eq!(env.logs_dir(), home.join("logs"), "{request:?}"); + assert_eq!(env.sessions_root(), home.join("logs"), "{request:?}"); + assert_eq!( + env.legacy_sessions_root(), + home.join("data/agent/sessions"), + "{request:?}" + ); + } + } + } + + /// 三种模式的日志根**各不相同**(防止「都指向同一个写死的目录」这种假绿)。 + #[test] + fn the_three_modes_do_not_share_one_logs_root() { + let env = MapEnv::new().with("APPDATA", "/roaming"); + let resolver = + Resolver::with_env(Platform::current(), Some(PathBuf::from("/games/ULL")), &env); + let portable = resolver + .resolve(&DataDirRequest { + mode: DataMode::Portable, + ..DataDirRequest::default() + }) + .expect("portable 一定解析得出"); + let custom = resolver + .resolve(&DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(std::env::temp_dir().join("ull-m188-custom")), + ..DataDirRequest::default() + }) + .expect("custom 有绝对路径"); + assert_ne!(portable.layout().logs(), custom.layout().logs()); + } + + /// `HostEnv` 自己的三格(日志根 / 会话根 / 旧会话根)指着同一份解析结果。 + #[test] + fn env_paths_come_from_one_layout() { + let root = std::env::temp_dir().join(format!("ull-host-env-paths-{}", std::process::id())); + let env = HostEnv::at_root(&root); + assert_eq!(env.logs_dir(), root.join(".ULL/logs")); + assert_eq!(env.sessions_root(), root.join(".ULL/logs")); + assert_eq!( + env.legacy_sessions_root(), + root.join(".ULL/data/agent/sessions") + ); + std::fs::remove_dir_all(&root).ok(); } #[test] diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index c7df977..1a90fa3 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -12,7 +12,6 @@ use unlucky_core::game::install::instance::{ }; use unlucky_core::game::isolation::IsolationMode; use unlucky_core::game::{ProfileList, VersionJson}; -use unlucky_core::launch::session_index; use crate::dto::{ InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView, @@ -34,10 +33,9 @@ pub fn profiles(env: &HostEnv) -> ProfileList { read_profiles(&env.layout).unwrap_or_default() } -/// 全会话索引里「每个实例最近一次启动」的时间串。 +/// 全会话索引里「每个实例最近一次启动」的时间串(`logs/` + 旧家合并)。 pub fn last_played(env: &HostEnv) -> BTreeMap { - let index = session_index::index_path(&env.sessions_root()); - let entries = session_index::read_all(&index).unwrap_or_default(); + let entries = super::logs::entries(env); let mut map: BTreeMap = BTreeMap::new(); for entry in entries { if entry.instance_id.is_empty() || entry.started_at.is_empty() { diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 102f2b4..1b81354 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -142,8 +142,8 @@ impl CoreHost { if self.tail.session().is_none() { self.tail.attach( self.env - .sessions_root() - .join("game") + .layout + .game_logs() .join(format!("{session_id}.jsonl")), &session_id, ); diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 3362fd9..9408799 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -219,6 +219,8 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { let tx_progress = tx.clone(); super::job::spawn("launch", move || { // M187:启动器自己的运行日志从这里开始有落点(早于这一拍的行由 core 缓冲接住)。 + // M188 起 presenter 在构造 `HostEnv` 时已经登记过目录(缓冲那时就刷出去了),这里是 + // 幂等的一次兜底:数据根被 `redirect.json` 改过时以最后一次为准。 let logs_dir = env.layout.logs(); launcher_log::set_dir(&logs_dir); let started_at = std::time::SystemTime::now(); diff --git a/crates/presenter/src/host/logs.rs b/crates/presenter/src/host/logs.rs index f9fd901..3b575a5 100644 --- a/crates/presenter/src/host/logs.rs +++ b/crates/presenter/src/host/logs.rs @@ -3,6 +3,15 @@ //! 与 CLI `ull logs` / `ull diagnose` 同源(`launch::session_index` + `diagnose` 规则引擎): //! 「同一份 jsonl 的两种读法」只有这一处实现(M39 ports/events.rs 的设计)。 //! 实时滚动 = host 每拍尾随启动会话的 JSONL(只读新增字节),不额外开第二条事件通道。 +//! +//! ## 会话日志在哪(M188) +//! +//! | 位置 | 角色 | +//! |---|---| +//! | `<.ULL>/logs/game/.jsonl` + `index.jsonl` | **新会话写这里** | +//! | `<.ULL>/data/agent/sessions/game/…` | 旧家:**只读兼容**,不搬不删 | +//! +//! 两个根的合并、落点记录都在 [`location`] 里;本文件只做「读出来 → DTO」的翻译。 use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -11,7 +20,8 @@ use serde_json::Value; use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; use unlucky_core::diagnose::loader::RuleLoader; -use unlucky_core::launch::session_index::{self, SessionIndexEntry}; +use unlucky_core::launch::session_index::SessionIndexEntry; +use unlucky_core::platform::layout::GAME_LOG_DIR; use crate::dto::{ Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId, @@ -19,25 +29,17 @@ use crate::dto::{ use super::env::HostEnv; +#[path = "logs/location.rs"] +mod location; + +pub use location::{entries, legacy_root, note_location, root, sessions}; + /// 送进规则引擎的默认尾巴行数(与 CLI 同值,避免两条路看到不同证据)。 pub const DEFAULT_TAIL: usize = 2000; /// 日志页一次最多渲染的行数(超出的截断,避免大文件把 UI 拖死)。 pub const MAX_RENDERED_LINES: usize = 2_000; -/// 会话索引目录(`.ULL/data/agent/sessions`)。 -pub fn root(env: &HostEnv) -> PathBuf { - env.sessions_root() -} - -/// 会话列表(新→旧)。 -pub fn sessions(env: &HostEnv) -> Vec { - let index = session_index::index_path(&root(env)); - let mut entries = session_index::read_all(&index).unwrap_or_default(); - entries.sort_by(|a, b| b.started_at.cmp(&a.started_at)); - entries.iter().map(session_of).collect() -} - /// 索引条目 → 左栏会话行。 pub fn session_of(entry: &SessionIndexEntry) -> LogSession { LogSession { @@ -78,15 +80,26 @@ pub fn display_time(started_at: &str) -> String { format!("{date} {time}") } -/// 事件流文件路径(`/game/`)。 +/// 事件流文件路径(`/game/`;新根没有时回落到旧家)。 +/// +/// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。 pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf { - root(env).join("game").join(&entry.log_path) + let primary = root(env).join(GAME_LOG_DIR).join(&entry.log_path); + if primary.is_file() { + return primary; + } + if let Some(legacy) = legacy_root(env) { + let fallback = legacy.join(GAME_LOG_DIR).join(&entry.log_path); + if fallback.is_file() { + return fallback; + } + } + primary } -/// 会话 id 或唯一前缀 → 索引条目。 +/// 会话 id 或唯一前缀 → 索引条目(两个根的合并视图)。 fn find(env: &HostEnv, key: &str) -> Option { - let index = session_index::index_path(&root(env)); - let entries = session_index::read_all(&index).ok()?; + let entries = entries(env); if let Some(exact) = entries.iter().find(|entry| entry.session_id == key) { return Some(exact.clone()); } @@ -328,13 +341,15 @@ impl Tail { } } -/// 会话是否已收尾(索引里出现 `ended_at` 或文件停止增长超过一拍)。 +/// 会话是否已收尾(任一索引里出现 `ended_at` 或文件停止增长超过一拍)。 pub fn session_finished(env: &HostEnv, session: &str) -> bool { - let index = session_index::index_path(&root(env)); - session_index::read_all(&index) - .unwrap_or_default() - .iter() - .any(|entry| entry.session_id == session && !entry.ended_at.is_empty()) + location::roots(env).into_iter().any(|dir| { + let index = unlucky_core::launch::session_index::index_path(&dir); + unlucky_core::launch::session_index::read_all(&index) + .unwrap_or_default() + .iter() + .any(|entry| entry.session_id == session && !entry.ended_at.is_empty()) + }) } #[cfg(test)] diff --git a/crates/presenter/src/host/logs/location.rs b/crates/presenter/src/host/logs/location.rs new file mode 100644 index 0000000..240c79a --- /dev/null +++ b/crates/presenter/src/host/logs/location.rs @@ -0,0 +1,107 @@ +//! 会话日志的**落点**:新家 `logs/`、旧家 `data/agent/sessions/`,以及两者合并的读法(M188)。 +//! +//! ## 为什么要搬 +//! +//! 旧落点是 `<.ULL>/data/agent/sessions/game/`:目录名里的 `agent` 是历史遗留(它从来只装**游戏** +//! 会话,与 AI 无关),而 `data/` 分区是「可重建但优先保留」的状态数据。用户投诉原话是「我们的 +//! 启动日志放在哪?…他找半天没找到崩溃日志」——把日志放在 `data/agent/…` 底下,找不着是必然的。 +//! +//! 新落点 `<.ULL>/logs/game/`:与启动器自己的 `launcher.log` 同一个分区、同一个顶层目录, +//! 「打开日志文件夹」一次就能看见两者。 +//! +//! ## 旧数据怎么办:**不搬** +//! +//! 搬动用户已有数据是不可逆动作(中途失败留半份会话、崩溃弹窗的会话关联会断),收益只是目录好看。 +//! 所以旧目录**原样留着、继续被读**:本模块的 [`entries`] 是两个根的合并视图,同 id 以新根为准。 +//! 「我们没动你的数据」这件事写进 [`note_location`] 的记录里,事后可查。 + +use std::path::PathBuf; + +use unlucky_core::launch::session_index::{self, SessionIndexEntry}; +use unlucky_core::platform::layout::GAME_LOG_DIR; + +use crate::dto::LogSession; + +use super::{session_of, HostEnv}; + +/// 会话日志落点记录的文件名(`/session-log-location.json`,见 [`note_location`])。 +pub const SESSION_LOCATION_FILE: &str = "session-log-location.json"; + +/// 会话根(**新会话写在这里**:`logs/`;`launch::LauncherConfig` 在其下建 `game/`)。 +pub fn root(env: &HostEnv) -> PathBuf { + env.sessions_root() +} + +/// 会话日志的**旧家**(`data/agent/sessions/`):M188 起只读兼容。 +/// +/// 目录不存在时给 `None`——「兼容」不等于「凭空造一个旧目录出来」。 +pub fn legacy_root(env: &HostEnv) -> Option { + let dir = env.legacy_sessions_root(); + dir.is_dir().then_some(dir) +} + +/// 读会话要看的根,**新根在前**(同 id 撞车时新根赢)。 +pub(super) fn roots(env: &HostEnv) -> Vec { + let mut roots = vec![root(env)]; + roots.extend(legacy_root(env)); + roots +} + +/// 全部索引条目(两个根合并;同 id 以**先出现的**为准,即 `logs/` 那条)。 +/// +/// 为什么两个根都读:用户已有的会话在 `data/agent/sessions/` 下,M188 只改「新会话写到哪」, +/// 不改「旧会话还能不能看」——否则崩溃弹窗里的会话关联会突然断掉。 +pub fn entries(env: &HostEnv) -> Vec { + let mut merged: Vec = Vec::new(); + for dir in roots(env) { + let index = session_index::index_path(&dir); + for entry in session_index::read_all(&index).unwrap_or_default() { + if merged + .iter() + .any(|item| item.session_id == entry.session_id) + { + continue; + } + merged.push(entry); + } + } + merged.sort_by(|left, right| right.started_at.cmp(&left.started_at)); + merged +} + +/// 会话列表(新→旧;`logs/` 与旧家合并)。 +pub fn sessions(env: &HostEnv) -> Vec { + entries(env).iter().map(session_of).collect() +} + +/// 会话日志「换了个家」的记录:迁了什么、什么时候、旧路径还在不在。 +/// +/// ```json +/// {"v":1,"at":"…","sessions_root":"/logs","legacy_root":"/data/agent/sessions", +/// "legacy_present":true,"legacy_files":3,"moved":0,"policy":"read-only-compat"} +/// ``` +/// +/// best-effort:写不进去就算了(日志目录不可写时不该让启动器起不来)。 +pub fn note_location(env: &HostEnv) -> Option { + let legacy = env.legacy_sessions_root(); + let legacy_present = legacy.is_dir(); + let legacy_files = std::fs::read_dir(legacy.join(GAME_LOG_DIR)) + .map(|items| items.filter_map(Result::ok).count()) + .unwrap_or(0); + let body = serde_json::json!({ + "v": 1, + "at": unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()), + "sessions_root": root(env).display().to_string(), + "legacy_root": legacy.display().to_string(), + "legacy_present": legacy_present, + "legacy_files": legacy_files, + "moved": 0, + "policy": "read-only-compat", + }); + let path = env.logs_dir().join(SESSION_LOCATION_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok()?; + } + std::fs::write(&path, serde_json::to_vec_pretty(&body).ok()?).ok()?; + Some(path) +} diff --git a/crates/presenter/src/host/logs_tests.rs b/crates/presenter/src/host/logs_tests.rs index ccbb6a3..e83c5a7 100644 --- a/crates/presenter/src/host/logs_tests.rs +++ b/crates/presenter/src/host/logs_tests.rs @@ -1,6 +1,7 @@ //! host/logs.rs 的单测(拆文件守 400 行上限)。 use super::*; +use unlucky_core::launch::session_index; #[test] fn time_display_and_outcomes() { @@ -84,3 +85,170 @@ fn confidence_percent_is_rounded_and_clamped() { assert_eq!(confidence_percent(-1.0), 0); assert_eq!(confidence_percent(2.0), 100); } + +// ---------------------------------------------------------------- M188:会话日志搬家 + +/// 一个临时数据根(`/.ULL` + `/.minecraft`)。 +fn temp_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ull-m188-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + root +} + +fn sample_entry(id: &str, file: &str, started_at: &str) -> SessionIndexEntry { + SessionIndexEntry { + v: 1, + session_id: id.to_string(), + instance_id: "inst".to_string(), + instance_name: "实例".to_string(), + game_version: "1.20.1".to_string(), + started_at: started_at.to_string(), + ended_at: String::new(), + duration_ms: 0, + exit_code: Some(0), + exit_type: Some("normal".to_string()), + silent_failure: false, + window_detected: true, + event_count: 1, + log_path: file.to_string(), + crash_found: false, + diagnosis_top: None, + } +} + +/// 守门①前半:**旧家的会话仍然列得出来、打得开**(只读兼容)。 +/// +/// 变异证明: +/// - 把 [`entries`] 改成只读 `root(env)`(新根)⇒ 列表里没有 `old-session`,本条红; +/// - 把 `event_path` 的新旧回落删掉(只认新根)⇒ `open` 拿到 0 行,本条红。 +#[test] +fn legacy_sessions_are_still_listed_and_openable() { + let root = temp_root("legacy"); + let env = HostEnv::at_root(&root); + let legacy_game = env.legacy_sessions_root().join("game"); + std::fs::create_dir_all(&legacy_game).expect("建旧家目录"); + let entry = sample_entry("old-session", "old-session.jsonl", "2026-11-01T10:00:00.000Z"); + session_index::append( + &session_index::index_path(&env.legacy_sessions_root()), + &entry, + ) + .expect("写旧索引"); + std::fs::write( + legacy_game.join("old-session.jsonl"), + "{\"kind\":\"stdout\",\"payload\":{\"line\":\"旧会话这一行\"}}\n", + ) + .expect("写旧事件流"); + + let listed = sessions(&env); + assert!( + listed.iter().any(|session| session.id == "old-session"), + "旧家的会话没有出现在列表里:{listed:?}" + ); + assert_eq!(event_path(&env, &entry), legacy_game.join("old-session.jsonl")); + let (lines, _) = open(&env, "old-session"); + assert!( + lines.iter().any(|line| line.text.contains("旧会话这一行")), + "旧家的事件流没读出来:{lines:?}" + ); + // 「兼容」不等于「复制一份到新根」:旧文件原地不动、新根里没有它。 + assert!(legacy_game.join("old-session.jsonl").is_file()); + assert!(!root.join(".ULL/logs/game/old-session.jsonl").is_file()); + std::fs::remove_dir_all(&root).ok(); +} + +/// 守门①后半:**新会话写在 `logs/game/`**,索引与事件流都在那儿。 +/// +/// 变异证明:把 `HostEnv::sessions_root` 改回 `layout.agent_sessions()` ⇒ 第一条断言红; +/// 把 [`entries`] 改成只读旧家 ⇒ 列表里没有 `fresh`,本条红。 +#[test] +fn new_sessions_are_written_under_logs_game() { + let root = temp_root("newhome"); + let env = HostEnv::at_root(&root); + let index = session_index::index_path(&env.sessions_root()); + assert_eq!(index, root.join(".ULL/logs/game/index.jsonl")); + + let entry = sample_entry("fresh", "fresh.jsonl", "2026-11-08T09:00:00.000Z"); + session_index::append(&index, &entry).expect("写新索引"); + let file = env.sessions_root().join("game/fresh.jsonl"); + std::fs::write( + &file, + "{\"kind\":\"stdout\",\"payload\":{\"line\":\"新会话\"}}\n", + ) + .expect("写新事件流"); + + let listed = sessions(&env); + assert_eq!( + listed.first().map(|session| session.id.as_str()), + Some("fresh"), + "{listed:?}" + ); + assert_eq!(event_path(&env, &entry), file); + // 旧目录不许被凭空造出来(我们没搬东西,也没义务建一个旧家)。 + assert!(!env.legacy_sessions_root().exists()); + std::fs::remove_dir_all(&root).ok(); +} + +/// 同一个 id 同时出现在两个根时**新根赢**(用户在新位置又跑了一局同名会话)。 +#[test] +fn the_new_root_wins_when_both_indexes_know_the_same_id() { + let root = temp_root("both"); + let env = HostEnv::at_root(&root); + let legacy_game = env.legacy_sessions_root().join("game"); + std::fs::create_dir_all(&legacy_game).expect("建旧家目录"); + let old = sample_entry("same", "same.jsonl", "2026-10-01T00:00:00.000Z"); + session_index::append(&session_index::index_path(&env.legacy_sessions_root()), &old) + .expect("写旧索引"); + std::fs::write( + legacy_game.join("same.jsonl"), + "{\"kind\":\"stdout\",\"payload\":{\"line\":\"旧\"}}\n", + ) + .expect("写旧事件流"); + + let fresh = sample_entry("same", "same.jsonl", "2026-11-08T00:00:00.000Z"); + session_index::append(&session_index::index_path(&env.sessions_root()), &fresh) + .expect("写新索引"); + let new_file = env.sessions_root().join("game/same.jsonl"); + std::fs::write( + &new_file, + "{\"kind\":\"stdout\",\"payload\":{\"line\":\"新\"}}\n", + ) + .expect("写新事件流"); + + let entries = super::entries(&env); + let hits: Vec<&SessionIndexEntry> = entries + .iter() + .filter(|entry| entry.session_id == "same") + .collect(); + assert_eq!(hits.len(), 1, "同一个 id 只能出现一次:{hits:?}"); + assert_eq!(hits[0].started_at, fresh.started_at, "新根那条应当赢"); + assert_eq!(event_path(&env, &fresh), new_file); + std::fs::remove_dir_all(&root).ok(); +} + +/// 落点记录:迁了什么(没有)、什么时候、旧路径还在不在。 +#[test] +fn the_location_receipt_names_both_roots_and_the_policy() { + let root = temp_root("receipt"); + let env = HostEnv::at_root(&root); + let legacy_game = env.legacy_sessions_root().join("game"); + std::fs::create_dir_all(&legacy_game).expect("建旧家目录"); + std::fs::write(legacy_game.join("a.jsonl"), "{}\n").expect("写旧文件"); + + let path = note_location(&env).expect("记录应当写得出来"); + assert_eq!(path, env.logs_dir().join(super::location::SESSION_LOCATION_FILE)); + let value: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).expect("读记录")).expect("记录是 JSON"); + assert_eq!(value["policy"], "read-only-compat"); + assert_eq!(value["moved"], 0, "我们一个文件都没搬"); + assert_eq!(value["legacy_present"], true); + assert_eq!(value["legacy_files"], 1); + assert_eq!( + value["sessions_root"], + env.sessions_root().display().to_string() + ); + assert_eq!( + value["legacy_root"], + env.legacy_sessions_root().display().to_string() + ); + std::fs::remove_dir_all(&root).ok(); +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index c830ebd..94f4aef 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -130,6 +130,9 @@ impl CoreHost { fn build(env: HostEnv, refresh: bool) -> Self { let (tx, rx) = job::channel(); game::ensure_root(&env); + // M188:把「会话日志换了家」这件事记一笔(旧目录还在不在、有几个文件、搬了什么)。 + // 策略是**不搬**,所以这份记录是「我们没动你的数据」的唯一凭据。 + logs::note_location(&env); // M159:更新状态要在构造时就有一份(提示条/设置页都读它),静默检查在 `refresh_all` // 里按开关派发——`headless` 走 `refresh=false`,于是单测不会发任何网络请求。 let update = update_state::Runtime::for_env(&env); diff --git a/docs/images/logs-folder-zh.png b/docs/images/logs-folder-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..ce11eda94de5aa2e0909e03ecf80fd4d84798d10 GIT binary patch literal 118942 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 P_x6/logs/` 一个目录里**,游戏自己的东西在实例运行目录里。 +设置页那行「日志文件夹」显示的就是前者的绝对路径,旁边有 *打开文件夹* 与 *复制路径*;日志页页头 +也有 *打开文件夹*。两个入口走同一个落点(`crates/app-iced/src/app/logs_folder.rs`),失败时输出的 +文本带完整路径。 + +## 1. 三个数据模式下的绝对路径 + +`<数据根>` 由 `core::platform::data_dir` 的三模式解析(便携 / 标准 / 自定义): + +| 模式 | 启动器数据目录(= `<数据根>`) | 日志根 | +|---|---|---| +| portable | `/.ULL` | `/.ULL/logs` | +| standard | Windows `%APPDATA%\ULL` · macOS `~/Library/Application Support/ULL` · Linux `$XDG_DATA_HOME/ULL`(缺省 `~/.local/share/ULL`) | `<上者>/logs` | +| custom | `<自定义根>/.ULL` | `<自定义根>/.ULL/logs` | + +GUI 的 `--data-dir ` 是 custom 模式的一种入口:那时日志根是 `/.ULL/logs`。设置页显示的 +路径与 `launcher.log` 的实际落点取的是**同一份解析结果**(`SettingsBridge` → `/config/launcher.json` +→ ``),所以两者不会各说各话。 + +## 2. 目录里有什么 + +| 路径 | 写者 | 是什么 | +|---|---|---| +| `logs/launcher.log` | `core::launch::launcher_log` | 启动器自己的运行日志:启动拍(主题 / 语言 / 路由 / 窗口)、后端探测与降级、字体链、每一次启动的命令行事实 / spawn 结果 / 退出码、打开日志目录等用户动作的结果。 | +| `logs/launcher.1.log` | 同上(滚动) | 上一份日志。**只留一份**历史。 | +| `logs/game/.jsonl` | `core::launch::session` | 每次启动一份 JSONL 事件流(命令行已脱敏、每行 stdout/stderr、退出、诊断)。 | +| `logs/game/index.jsonl` | `core::launch::session_index` | 一会话一行摘要的索引,日志页左栏读它。 | +| `logs/install.log` | `presenter::host::install_journal` | 安装流水,每阶段一行。 | +| `logs/session-log-location.json` | `presenter::host::logs::note_location` | 会话日志现在在哪 / 以前在哪 / 搬没搬(`moved: 0`),每次启动重写。 | +| `data/agent/sessions/game/…` | **旧版本** | 只读兼容,见 §4。 | + +### 上限与滚动 + +- 单文件上限 `launcher_log::MAX_BYTES = 1 MiB`;每次追加前检查,超过就把 `launcher.log` 改名成 + `launcher.1.log`(旧的那份被替换),新文件从 0 开始。 +- 保留份数:**1**。所以 `logs/` 里启动器日志的稳态占用 ≤ 约 2 MiB(+ 同目录的会话 jsonl)。 +- 为什么不做按日期滚动:事故复现往往在「刚刚那一次」,份数比历史长度更重要;而无限份数会让「导出 + 诊断包」变成搬砖。 + +## 3. 哪些行进 launcher.log、哪些只进 stderr + +判据只有一条:**事故之后还要不要看它**。实现是 `crates/app-iced/src/app/diag.rs` 的 `diag!` 宏 +(stderr + 文件双写,行首 `[tag]` 就是日志里的 scope)。 + +| 进 launcher.log(`crates/app-iced/src/app/**` 里打的行) | 只进 stderr(本 mission scope 之外的行) | +|---|---| +| 启动编排:`[probe]`(降级重开 / 探测耗时)、`[font]`、`[theme]`、`[appearance]`、`[gpu]` 的降级与拒绝降级、`[boot]`(含后端 request/effective)、`[shot]` 的通道提示 | `renderer::acceptance` 的验收度量:`[window]` / `[perf]` / `[caps]` / `[theme] source=…` | +| 用户动作的结果:`[logs]`(打开日志文件夹成功/失败)、`[settings]` 写盘失败、`[i18n]` 语言没落盘、`[update]` 的忽略/重启、`[agent]` / `[content]` / `[page]` 的落点提示 | `renderer` 自己的 `[gpu] …初始化失败`、`[gpu] iced_wgpu: Selected`、`[probe] request=…`(`probe.rs`)、`[corner]`(`platform/win32/corner.rs`) | +| 一次性、量很小的诊断(每次启动几行到几十行) | 组件几何探针与测试:`[feedback-probe]` / `[gallery]` / `[probe] divider…` | + +不允许把高频行接进来:1 MiB 的上限就是为了保住「事故现场」,逐帧写会把它冲掉。 + +**已知边界(M188 没做,已登记)**:`renderer/**`、`probe.rs`、`platform/win32/corner.rs` 里的行 +仍然只走 stderr——它们各自属于别的 mission 的 scope。要全线接通,得把这些文件的 `eprintln!` +也换成 `crate::app::diag::diag!`(或把 `emit` 提成 lib 级函数)。 + +## 4. 会话日志搬家:策略是**不搬** + +M188 把新会话从 `data/agent/sessions/game/` 挪到 `logs/game/`(目录名里的 `agent` 是历史遗留, +它从来只装游戏会话)。旧目录的处理方式: + +- **原样保留、继续读**:`presenter::host::logs` 与 `cli` 的会话读取都是两个根合并,同一个 + `session_id` 同时存在时以 `logs/` 那条为准; +- **不搬、不删**:搬运用户已有数据是不可逆动作,中途失败会留下半份会话,崩溃弹窗里的会话关联也会断; +- **留记录**:`logs/session-log-location.json` 写明两个根、旧根在不在、旧根里几个文件、`moved: 0`、 + `policy: "read-only-compat"`。 + +## 5. 游戏自己的日志(不归启动器写) + +在实例的运行目录里(隔离实例是 `<游戏目录>/versions/<隔离名>/…`,共享实例直接是 `.minecraft`): + +- `logs/latest.log`、`logs/<日期>-<序号>.log.gz` —— 游戏自己的日志; +- `crash-reports/crash-*.txt` —— **只在 JVM 活到崩溃处理器那一步时才有**; +- `hs_err_pid*.log` —— JVM 自己崩(native crash)时才写。 + +模组 / 缺库 / Java 选错把游戏弄死在 Minecraft 初始化之前时,上面三个都不会出现——那不是启动器 +丢了日志,而是游戏没机会写。那种时候的证据是 `logs/game/.jsonl` 与 `logs/launcher.log`。 + +## 6. 没验的部分 + +- **真机「双击启动后日志确实在」**:本轮只在开发机上用 `cargo test` 与本地进程验证,Windows 桌面 + 双击启动需要用户实测。 +- **macOS / Linux 的日志根**:路径由同一条解析链给出(`standard_launcher_home` 有单测覆盖三个平台), + 但「Mac 上点打开文件夹真的会开 Finder」「Linux 上没有 `xdg-open` 时会怎样」没有真机验证。 +- **只读数据目录**:日志写不进去时按设计静默放过(best-effort),此时设置页显示的路径仍然正确, + 但文件不存在。 From 8c5bebf3e395c3a60963b8f65cd1d75c4ca2ec1e Mon Sep 17 00:00:00 2001 From: worker-logplace Date: Mon, 9 Nov 2026 01:05:40 +0800 Subject: [PATCH 19/33] =?UTF-8?q?fix(m188):=20r1=20=E5=A4=8D=E5=AE=A1=20P2?= =?UTF-8?q?-1=E2=80=94=E2=80=94=E5=90=8E=E7=AB=AF=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E5=85=B7=E5=90=8D=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E8=BF=9B=20launcher.log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审结论 p2-1/1 item + fix-then-merge:主线成立,但「启动器自己因 GPU / 渲染后端起不来」的 事故原因仍只进 stderr —— 双击启动没有 stderr,那条路径上等于没有日志。 - renderer/mod.rs::report_failure 的两行(`[gpu] 初始化失败(具名)` / `[gpu] 失败详情(具名)`)改走 diag!; - renderer/log_bridge.rs 转出的 wgpu 日志(含 `…Selected: AdapterInfo…`,GPU 真生效的唯一证据 行)改走 diag!; - renderer/preflight.rs 的 `[gpu-preflight]`(ULL_PREFLIGHT=1)改走 diag!; - probe.rs::apply_with_preflight 的三行 `[probe]` 改走 diag!。 - app/diag.rs 模块文档 + docs/logs-location.md §3 的判据改准: 「逐帧度量不接;显式取证开关与一次性事故行可接」,并修掉 `[corner]` 两处自相矛盾 (唯一打印点在 app/update/appearance.rs,走 diag! ⇒ 进日志;corner.rs 只 format_line)。 - 新守门 renderer::tests::a_backend_startup_failure_lands_in_the_launcher_log: 断言那两行真的落进 launcher.log;变异(改回纯 eprintln)→ 红。 - README 两语的 launcher.log 行补上「渲染后端起不来的具名原因」。 --- README.md | 2 +- README.zh-CN.md | 2 +- crates/app-iced/src/app/diag.rs | 14 ++++--- crates/app-iced/src/app/mod.rs | 2 +- crates/app-iced/src/probe.rs | 9 +++-- crates/app-iced/src/renderer/log_bridge.rs | 4 +- crates/app-iced/src/renderer/mod.rs | 47 +++++++++++++++++++++- crates/app-iced/src/renderer/preflight.rs | 8 +++- docs/logs-location.md | 26 +++++++----- 9 files changed, 87 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 386373a..1c23e9e 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ as the launcher data directory. | Path | What it is | |---|---| -| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. | +| `logs/launcher.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/.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. | diff --git a/README.zh-CN.md b/README.zh-CN.md index e931918..9742baa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -178,7 +178,7 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 | 路径 | 是什么 | |---|---| -| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 | +| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择、**渲染后端起不来时的具名原因**,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 | | `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 | | `logs/game/.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 | | `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 | diff --git a/crates/app-iced/src/app/diag.rs b/crates/app-iced/src/app/diag.rs index ade7352..ffb2693 100644 --- a/crates/app-iced/src/app/diag.rs +++ b/crates/app-iced/src/app/diag.rs @@ -17,16 +17,18 @@ //! //! ## 哪些行进日志、哪些只进 stderr //! -//! 判据只有一条:**事故之后还要不要看它**。 +//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。** //! //! | 进日志 | 只进 stderr | //! |---|---| -//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | 验收与出图通道的逐帧度量(`[window]` / `[perf]` / `[caps]`——那些行由 `renderer::acceptance` 打,不在本 mission scope) | -//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 组件几何探针(`[feedback-probe]` / `[gallery]` 的断言输出,`components/**`) | -//! | 一次性且量很小的诊断(`[appearance]` / `[corner]` 的切换、`[theme]`、`[shot]` 的通道提示) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) | +//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) | +//! | **一次性事故行**:`[gpu] 初始化失败(具名)` / `[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`,只留一份), -//! 逐帧写会把「事故现场」冲掉。 +//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份), +//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体—— +//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。 /// 行首没有 `[tag]` 时的默认 scope。 pub const APP_SCOPE: &str = "app"; diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index dec5277..4e2a686 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -30,7 +30,7 @@ mod appearance; mod backend; mod boot; -mod diag; +pub(crate) mod diag; mod dialog; pub(crate) mod logs_folder; mod message; diff --git a/crates/app-iced/src/probe.rs b/crates/app-iced/src/probe.rs index 3b53de0..2755801 100644 --- a/crates/app-iced/src/probe.rs +++ b/crates/app-iced/src/probe.rs @@ -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.describe(), request.name(), decision.primary.env_value() ); - eprintln!("[probe] {}", decision.describe()); + crate::app::diag::diag!("[probe] {}", decision.describe()); if decision.primary == renderer::Kind::Wgpu { - eprintln!( + crate::app::diag::diag!( "[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;\ 出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU" ); diff --git a/crates/app-iced/src/renderer/log_bridge.rs b/crates/app-iced/src/renderer/log_bridge.rs index d45f9a6..5b23e1d 100644 --- a/crates/app-iced/src/renderer/log_bridge.rs +++ b/crates/app-iced/src/renderer/log_bridge.rs @@ -88,7 +88,9 @@ impl log::Log for Bridge { } // iced_wgpu 用 `{:#?}` 打多行结构体;压成一行才好 grep / 贴验收报告。 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) {} diff --git a/crates/app-iced/src/renderer/mod.rs b/crates/app-iced/src/renderer/mod.rs index 90ea616..46d9121 100644 --- a/crates/app-iced/src/renderer/mod.rs +++ b/crates/app-iced/src/renderer/mod.rs @@ -199,9 +199,12 @@ pub fn forced_fallback() -> Option { /// `iced::Error::GraphicsCreationFailed` 的 `Display` **不包含**内部错误 /// (`#[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)) { - eprintln!("[gpu] {} 初始化失败(具名):{error}", backend.name()); - eprintln!("[gpu] 失败详情(具名):{error:?}"); + crate::app::diag::diag!("[gpu] {} 初始化失败(具名):{error}", backend.name()); + crate::app::diag::diag!("[gpu] 失败详情(具名):{error:?}"); } #[cfg(test)] @@ -246,4 +249,44 @@ mod tests { }); 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(); + } } diff --git a/crates/app-iced/src/renderer/preflight.rs b/crates/app-iced/src/renderer/preflight.rs index ff6b101..a7c2276 100644 --- a/crates/app-iced/src/renderer/preflight.rs +++ b/crates/app-iced/src/renderer/preflight.rs @@ -76,10 +76,14 @@ pub fn run_if_requested() -> Option> { let started = std::time::Instant::now(); let verdict = run(); let took = started.elapsed().as_secs_f64() * 1000.0; + // M188:显式开关(`ULL_PREFLIGHT=1`)下的一次性行,按 `app::diag` 的判据可以进 + // `launcher.log`——用户开这个开关就是为了拿到「为什么起不来」的证据。 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) => { - eprintln!("[gpu-preflight] {} took={took:.0}ms", failure.describe()); + crate::app::diag::diag!("[gpu-preflight] {} took={took:.0}ms", failure.describe()); } } Some(verdict) diff --git a/docs/logs-location.md b/docs/logs-location.md index 7eb2e30..7f790c0 100644 --- a/docs/logs-location.md +++ b/docs/logs-location.md @@ -41,20 +41,26 @@ GUI 的 `--data-dir ` 是 custom 模式的一种入口:那时日志根是 ## 3. 哪些行进 launcher.log、哪些只进 stderr -判据只有一条:**事故之后还要不要看它**。实现是 `crates/app-iced/src/app/diag.rs` 的 `diag!` 宏 -(stderr + 文件双写,行首 `[tag]` 就是日志里的 scope)。 +判据(复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接**。实现是 +`crates/app-iced/src/app/diag.rs` 的 `diag!` 宏(stderr + 文件双写,行首 `[tag]` 就是日志里的 scope)。 -| 进 launcher.log(`crates/app-iced/src/app/**` 里打的行) | 只进 stderr(本 mission scope 之外的行) | +| 进 launcher.log | 只进 stderr | |---|---| -| 启动编排:`[probe]`(降级重开 / 探测耗时)、`[font]`、`[theme]`、`[appearance]`、`[gpu]` 的降级与拒绝降级、`[boot]`(含后端 request/effective)、`[shot]` 的通道提示 | `renderer::acceptance` 的验收度量:`[window]` / `[perf]` / `[caps]` / `[theme] source=…` | -| 用户动作的结果:`[logs]`(打开日志文件夹成功/失败)、`[settings]` 写盘失败、`[i18n]` 语言没落盘、`[update]` 的忽略/重启、`[agent]` / `[content]` / `[page]` 的落点提示 | `renderer` 自己的 `[gpu] …初始化失败`、`[gpu] iced_wgpu: Selected`、`[probe] request=…`(`probe.rs`)、`[corner]`(`platform/win32/corner.rs`) | -| 一次性、量很小的诊断(每次启动几行到几十行) | 组件几何探针与测试:`[feedback-probe]` / `[gallery]` / `[probe] divider…` | +| 启动编排:`[probe]`(`request=… -> ICED_BACKEND=…`、降级重开、探测耗时)、`[font]`、`[theme]`、`[appearance]`、`[gpu]` 的降级与拒绝降级、`[boot]`(含后端 request/effective)、`[shot]` 的通道提示 | 验收/出图矩阵的**逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=…`(`renderer::acceptance/**`,一次启动上百行,会冲掉事故现场) | +| **一次性事故行**:`[gpu] 初始化失败(具名)` 与 `[gpu] 失败详情(具名)`(`renderer::report_failure`,复审 P2-1)、`[gpu] : …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]` 的切换 | — | -不允许把高频行接进来:1 MiB 的上限就是为了保住「事故现场」,逐帧写会把它冲掉。 +`[corner]` 到底在哪一侧,看**打印点**:唯一打印点是 `app/update/appearance.rs`(走 `diag!`), +`platform/win32/corner.rs::format_line` 只拼字符串、不打印 ⇒ 它**进** launcher.log。 -**已知边界(M188 没做,已登记)**:`renderer/**`、`probe.rs`、`platform/win32/corner.rs` 里的行 -仍然只走 stderr——它们各自属于别的 mission 的 scope。要全线接通,得把这些文件的 `eprintln!` -也换成 `crate::app::diag::diag!`(或把 `emit` 提成 lib 级函数)。 +## 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. 会话日志搬家:策略是**不搬** From 44a0c87a11063ec9b2b3bd4ad281ad53cc82a35e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 01:43:31 +0800 Subject: [PATCH 20/33] =?UTF-8?q?style:=20cargo=20fmt=20--all=EF=BC=88?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=9C=9F=E6=95=B4=E5=90=88=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M187/M188 并入后全仓 fmt --check 为红(CI 的 fmt job 会红)。 本次仅 cargo fmt --all 的换行/缩进变化,无语义改动; cargo fmt --check 与 cargo check 均已复验为绿。 --- crates/app-iced/src/app/diag.rs | 5 +++- crates/app-iced/src/app/logs_folder.rs | 3 ++- .../app-iced/src/app/update/settings/java.rs | 2 +- .../app-iced/src/pages/settings/logs_row.rs | 4 ++-- .../pages/settings/tests/general_section.rs | 6 ++++- crates/cli/src/commands/sessions.rs | 5 +--- crates/presenter/src/host/logs_tests.rs | 23 +++++++++++++++---- 7 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/app-iced/src/app/diag.rs b/crates/app-iced/src/app/diag.rs index ffb2693..658d231 100644 --- a/crates/app-iced/src/app/diag.rs +++ b/crates/app-iced/src/app/diag.rs @@ -94,7 +94,10 @@ mod tests { #[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] 空白不是标签")); + assert_eq!( + split_scope("[a b] 空白不是标签"), + ("app", "[a b] 空白不是标签") + ); } /// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。 diff --git a/crates/app-iced/src/app/logs_folder.rs b/crates/app-iced/src/app/logs_folder.rs index eba9a6e..25ca4ab 100644 --- a/crates/app-iced/src/app/logs_folder.rs +++ b/crates/app-iced/src/app/logs_folder.rs @@ -68,7 +68,8 @@ pub(crate) fn prepare(dir: &Path) -> Result { /// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。 pub(crate) fn open() -> Result { let dir = logs_dir().ok_or_else(|| { - "日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)".to_string() + "日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)" + .to_string() })?; let dir = prepare(&dir)?; crate::app::dialog::export::open_dir(&dir)?; diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index 3ebc6c7..69d8671 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -21,8 +21,8 @@ //! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。 //! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。 -use crate::app::diag::diag; use super::super::*; +use crate::app::diag::diag; use crate::pages::settings::java::{Context, Layer, Notice, Requirement}; diff --git a/crates/app-iced/src/pages/settings/logs_row.rs b/crates/app-iced/src/pages/settings/logs_row.rs index 83b66ed..a2eef3e 100644 --- a/crates/app-iced/src/pages/settings/logs_row.rs +++ b/crates/app-iced/src/pages/settings/logs_row.rs @@ -103,8 +103,8 @@ fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a .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(|| { + let background = + matches!(status, button::Status::Hovered | button::Status::Pressed).then(|| { Background::Color(ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface)) }); button::Style { diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index ad7894e..289eb15 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -228,7 +228,11 @@ fn the_logs_row_names_the_real_paths_and_the_real_limit() { &format!("{LOGS_DIR}/{GAME_LOG_DIR}/"), "data/agent/sessions", ] { - assert!(hint.contains(needle), "{} 缺 `{needle}`:{hint}", lang.tag()); + assert!( + hint.contains(needle), + "{} 缺 `{needle}`:{hint}", + lang.tag() + ); } assert!(!hint.contains('⟪'), "{} 目录里缺词:{hint}", lang.tag()); assert!( diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs index de26875..d44fdf2 100644 --- a/crates/cli/src/commands/sessions.rs +++ b/crates/cli/src/commands/sessions.rs @@ -241,10 +241,7 @@ fn find_entry(ctx: &Ctx, key: &str) -> Result { fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf { // M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。 // 先看新根,再回落旧家;两处都没有就把新根路径交出去(错误信息据此说「该去哪找」)。 - let primary = ctx - .sessions_root() - .join(GAME_LOG_DIR) - .join(&entry.log_path); + let primary = ctx.sessions_root().join(GAME_LOG_DIR).join(&entry.log_path); if primary.is_file() { return primary; } diff --git a/crates/presenter/src/host/logs_tests.rs b/crates/presenter/src/host/logs_tests.rs index e83c5a7..37fa192 100644 --- a/crates/presenter/src/host/logs_tests.rs +++ b/crates/presenter/src/host/logs_tests.rs @@ -127,7 +127,11 @@ fn legacy_sessions_are_still_listed_and_openable() { 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"); + 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, @@ -144,7 +148,10 @@ fn legacy_sessions_are_still_listed_and_openable() { listed.iter().any(|session| session.id == "old-session"), "旧家的会话没有出现在列表里:{listed:?}" ); - assert_eq!(event_path(&env, &entry), legacy_game.join("old-session.jsonl")); + 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("旧会话这一行")), @@ -196,8 +203,11 @@ fn the_new_root_wins_when_both_indexes_know_the_same_id() { 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("写旧索引"); + 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", @@ -235,7 +245,10 @@ fn the_location_receipt_names_both_roots_and_the_policy() { 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)); + 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"); From b7d9b99e1285d5cad13254b654df073e8be793df Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 01:39:37 +0800 Subject: [PATCH 21/33] =?UTF-8?q?feat(m190):=20=E4=BA=A7=E7=89=A9=E7=BD=B2?= =?UTF-8?q?=E5=90=8D=EF=BC=88PE=20VERSIONINFO=EF=BC=89+=20=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20Ed25519=20=E5=A4=9A=E7=AD=BE=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=89=8D=E9=AA=8C=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。 ① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName 「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/ LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被 `crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。 判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。 ② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始 字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组, 任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs` 里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。 管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest` 是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类 (signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试, CLI 的 hint 单独说人话。 ③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml, 用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是 `packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。 ④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上 一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个 脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。 Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是 toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。 --- .github/workflows/release-build.yml | 19 + .github/workflows/release-guard.yml | 7 + .github/workflows/release-publish.yml | 30 +- .github/workflows/release.yml | 3 + Cargo.lock | 48 ++- crates/app-iced/Cargo.toml | 6 + crates/app-iced/build.rs | 13 + crates/cli/Cargo.toml | 6 + crates/cli/build.rs | 10 + crates/cli/src/commands/update.rs | 10 +- crates/cli/tests/support/update_e2e.rs | 69 +++- crates/core/src/update/ed25519/field.rs | 247 +++++++++++ crates/core/src/update/ed25519/group.rs | 168 ++++++++ crates/core/src/update/ed25519/mod.rs | 265 ++++++++++++ crates/core/src/update/error.rs | 36 ++ crates/core/src/update/github.rs | 35 +- crates/core/src/update/job.rs | 6 +- crates/core/src/update/mod.rs | 20 +- crates/core/src/update/signature.rs | 256 ++++++++++++ crates/core/src/update/source.rs | 123 +++++- crates/core/src/update/tests/checker_cases.rs | 6 +- crates/core/src/update/tests/ed25519_cases.rs | 213 ++++++++++ crates/core/src/update/tests/github_cases.rs | 73 ++-- crates/core/src/update/tests/job_cases.rs | 14 +- crates/core/src/update/tests/mod.rs | 2 + .../core/src/update/tests/signature_cases.rs | 303 ++++++++++++++ crates/core/src/update/tests/source_cases.rs | 8 +- crates/core/src/update/tests/support.rs | 72 +++- crates/core/src/update/trust.rs | 98 +++++ packaging/README.md | 14 + packaging/check_pe_version.py | 188 +++++++++ packaging/docs/signing.md | 137 +++++++ packaging/pe_version.rs | 101 +++++ packaging/sign_manifest.py | 383 ++++++++++++++++++ 34 files changed, 2885 insertions(+), 104 deletions(-) create mode 100644 crates/app-iced/build.rs create mode 100644 crates/cli/build.rs create mode 100644 crates/core/src/update/ed25519/field.rs create mode 100644 crates/core/src/update/ed25519/group.rs create mode 100644 crates/core/src/update/ed25519/mod.rs create mode 100644 crates/core/src/update/signature.rs create mode 100644 crates/core/src/update/tests/ed25519_cases.rs create mode 100644 crates/core/src/update/tests/signature_cases.rs create mode 100644 crates/core/src/update/trust.rs create mode 100644 packaging/check_pe_version.py create mode 100644 packaging/docs/signing.md create mode 100644 packaging/pe_version.rs create mode 100644 packaging/sign_manifest.py diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 1520852..9993e80 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -212,6 +212,25 @@ jobs: ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true fi + # (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于 + # 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式 + # 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。 + # 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见 + # `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。 + - name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里) + if: runner.os == 'Windows' + shell: bash + env: + BUILD_DIR: ${{ inputs.build_dir }} + run: | + set -euo pipefail + PY=$(command -v python3 || command -v python) + if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi + # 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。 + version=$(sed -n 's/^version = "\(.*\)"//p' Cargo.toml | head -n1) + echo "expect-version=$version" + "$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version" + - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) if: runner.os == 'Windows' shell: bash diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index 22785b4..9d2e8ea 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -68,3 +68,10 @@ jobs: run: python3 packaging/check_test_source_off.py --self-test - name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开) run: python3 packaging/check_test_source_off.py + # M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出 + # 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本 + # 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。 + - name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析) + run: python3 packaging/sign_manifest.py --self-test + - name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红) + run: python3 packaging/check_pe_version.py --self-test diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 8888114..f859e6a 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -131,6 +131,32 @@ jobs: --asset-base-url "$RELEASE_BASE/$TAG" \ --released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + # (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量, + # 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release: + # ① `--self-test`:RFC 8032 向量(签名脚本自己没坏) + # ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住 + # (签出一份客户端不认的清单,比不发版更糟) + # ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig` + # 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。 + - name: 签更新清单(Ed25519;签不上就阻断发布) + shell: bash + env: + ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }} + run: | + set -euo pipefail + set +x + PY=$(command -v python3 || command -v python) + if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi + "$PY" packaging/sign_manifest.py --self-test + if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then + echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。" + echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs" + exit 1 + fi + "$PY" packaging/sign_manifest.py --check-pinned-keyring + "$PY" packaging/sign_manifest.py --sign dist/manifest.json + ls -l dist/manifest.json dist/manifest.json.sig + - name: 生成 Release 说明(支持矩阵模板) shell: bash env: @@ -172,7 +198,9 @@ jobs: echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})" # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。 - assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md) + # `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource` + # 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。 + assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig dist/SHA256SUMS.txt LICENSE NOTICE README.md) if gh release view "$TAG" >/dev/null 2>&1; then # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 gh release upload "$TAG" "${assets[@]}" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89d4d9b..03ceff7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,3 +150,6 @@ jobs: secrets: # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串, + # 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。 + ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }} diff --git a/Cargo.lock b/Cargo.lock index 9c87a6a..f0332a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4125,6 +4125,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4739,10 +4748,25 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +name = "toml" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ "serde_core", ] @@ -4768,6 +4792,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + [[package]] name = "tower" version = "0.5.3" @@ -5007,6 +5037,7 @@ dependencies = [ "unlucky-design", "unlucky-i18n", "unlucky-presenter", + "winresource", ] [[package]] @@ -5018,6 +5049,7 @@ dependencies = [ "tokio", "unlucky-agent", "unlucky-core", + "winresource", ] [[package]] @@ -5968,6 +6000,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 248cecb..2e98e76 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -100,3 +100,9 @@ update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/updat [lints] workspace = true + +# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。 +# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回), +# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。 +[build-dependencies] +winresource = "0.1" diff --git a/crates/app-iced/build.rs b/crates/app-iced/build.rs new file mode 100644 index 0000000..466b360 --- /dev/null +++ b/crates/app-iced/build.rs @@ -0,0 +1,13 @@ +//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。 +//! +//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的 +//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。 +//! +//! ⚠ 本文件**不**碰 `windows_subsystem`:GUI 子系统由 `src/main.rs` 的 +//! `#![cfg_attr(windows, windows_subsystem = "windows")]` 决定,版本资源与子系统是两件事。 + +include!("../../packaging/pe_version.rs"); + +fn main() { + emit_pe_version_resource("unlucky-launcher-iced"); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 84ce552..55af3b0 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -38,3 +38,9 @@ workspace = true # M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。 # `ull update check/apply` 的端到端验收要靠它指向本地目录源。 update-test-source = ["unlucky-core/update-test-source"] + +# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。 +# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回), +# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。 +[build-dependencies] +winresource = "0.1" diff --git a/crates/cli/build.rs b/crates/cli/build.rs new file mode 100644 index 0000000..19c3b82 --- /dev/null +++ b/crates/cli/build.rs @@ -0,0 +1,10 @@ +//! `ull` 的 PE 版本资源(M190)。 +//! +//! 只做一件事:调共享口径 [`emit_pe_version_resource`](定义在 `packaging/pe_version.rs`, +//! 两个 bin 共用同一份署名表)。非 windows 目标里那个函数会立刻返回,Linux/macOS 构建零影响。 + +include!("../../packaging/pe_version.rs"); + +fn main() { + emit_pe_version_resource("ull"); +} diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index a3d1df6..fecf33d 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -77,7 +77,15 @@ fn job(ctx: &Ctx, dest: Option) -> Job { /// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。 /// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。 fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError { - let hint = if error.is_retryable() { + // 签名类错误单独给一句:它们**不可重试**,而且绝不能靠「换个源/跳过校验」绕过——绕过一次 + // 就等于把信任根拆了(见 `core::update::signature` 的文件头)。 + let hint = if error.kind.is_signature() { + concat!( + "清单签名没通过,重试没有意义:signature_missing = 发布侧漏了签名;", + "signature_unknown_key / keyring_empty = 本构建的信任表里没有这把钥(升级启动器或等发布侧换钥);", + "signature_invalid = 签名之后清单被改过。别绕过校验,等发布侧修" + ) + } else if error.is_retryable() { "网络/占用类失败,过一会儿重试一次通常就好" } else { "这不是瞬时失败:清单或版本号本身有问题,等发布侧修" diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs index d3fa42f..3fbf68f 100644 --- a/crates/cli/tests/support/update_e2e.rs +++ b/crates/cli/tests/support/update_e2e.rs @@ -81,6 +81,65 @@ fn asset_name(version: &str) -> String { ) } +/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥, +/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。 +const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001"; +/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。 +const TEST_KEY_ID: &str = "test-only-key"; + +/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。 +/// +/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒ +/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带 +/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 +fn write_signature(manifest_path: &Path) { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py"); + assert!(script.is_file(), "找不到签名脚本:{}", script.display()); + let seed_hex: String = TEST_KEY_SEED + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#); + let mut command = None; + for candidate in ["python3", "python"] { + if std::process::Command::new(candidate) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + { + command = Some(candidate); + break; + } + } + let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}")); + let output = std::process::Command::new(python) + .arg(&script) + .arg("--sign") + .arg(manifest_path) + .env("ULL_MANIFEST_SIGNING_KEY", &secret) + .output() + .expect("跑签名脚本"); + assert!( + output.status.success(), + "签名脚本失败:stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + manifest_path + .with_file_name(format!( + "{}.sig", + manifest_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + )) + .is_file(), + "签名脚本没产出 .sig" + ); +} + /// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。 /// /// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条 @@ -258,11 +317,11 @@ impl Fixture { /// 发一版,摘要/长度由调用方给(篡改用例用)。 pub fn publish(&self, sha256: &str, size: u64) { std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物"); - std::fs::write( - self.dist.path().join("stable.json"), - manifest(&self.version, &self.asset, size, sha256), - ) - .expect("写清单"); + let path = self.dist.path().join("stable.json"); + std::fs::write(&path, manifest(&self.version, &self.asset, size, sha256)).expect("写清单"); + // M190:清单与签名成对落盘。篡改用例给的是「错的 sha/size」,签的是**改过之后**那份清单 + // —— 那正是「清单本身可信、包体不对」的现场(摘要判据要独立于签名判据被证到)。 + write_signature(&path); } /// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。 diff --git a/crates/core/src/update/ed25519/field.rs b/crates/core/src/update/ed25519/field.rs new file mode 100644 index 0000000..bc205b6 --- /dev/null +++ b/crates/core/src/update/ed25519/field.rs @@ -0,0 +1,247 @@ +//! 素域 `GF(2^255 - 19)` 的算术:5 条 51 位肢(radix-2^51)+ 128 位中间积。 +//! +//! 为什么自己写而不是引 `curve25519-dalek`:M190 的 scope 不含 `crates/core/Cargo.toml` +//! (见 `ed25519/mod.rs` 文件头的取舍说明)。这一层只服务**验签**,不接触任何私钥, +//! 也不需要常数时间性质:输入全是公开数据(公钥、签名、清单字节)。 +//! +//! 表示是「宽松的」:`[u64; 5]` 只是数值的内部形态,同一元素有多种写法(肢可以不规范)。 +//! 需要规范字节时一律走 [`Fe::to_bytes`](它先进位、再按 `h >= p` 条件减 `p`)。 + +/// 单肢掩码(51 位)。 +const MASK: u64 = (1 << 51) - 1; + +/// 域元素。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Fe(pub(crate) [u64; 5]); + +impl Fe { + /// 0。 + pub(crate) const ZERO: Self = Self([0; 5]); + + /// 1。 + pub(crate) const ONE: Self = Self([1, 0, 0, 0, 0]); + + /// 从整数构造(只用于 121665 / 121666 这类小常数)。 + pub(crate) fn from_u64(value: u64) -> Self { + Self([value & MASK, value >> 51, 0, 0, 0]) + } + + /// 读 32 字节小端(不检查是否 `< p`:调用方要自己判定规范性)。 + pub(crate) fn from_bytes(bytes: &[u8; 32]) -> Self { + let load = |offset: usize| -> u64 { + let mut buf = [0_u8; 8]; + buf.copy_from_slice(&bytes[offset..offset + 8]); + u64::from_le_bytes(buf) + }; + // 51 位一段:位 0..51 从字节 0,位 51..102 从字节 6 起右移 3,依此类推。 + Self([ + load(0) & MASK, + (load(6) >> 3) & MASK, + (load(12) >> 6) & MASK, + (load(19) >> 1) & MASK, + (load(24) >> 12) & MASK, + ]) + } + + /// 规范 32 字节小端编码(先完全进位,再按 `>= p` 条件减一次 `p`)。 + pub(crate) fn to_bytes(self) -> [u8; 32] { + let mut h = carry(self.0); + // `q = 1` 当且仅当 `h >= p`:`h + 19` 溢出 2^255 就说明 `h >= 2^255 - 19`。 + let mut q = (h[0] + 19) >> 51; + q = (h[1] + q) >> 51; + q = (h[2] + q) >> 51; + q = (h[3] + q) >> 51; + q = (h[4] + q) >> 51; + h[0] += 19 * q; + h[1] += h[0] >> 51; + h[0] &= MASK; + h[2] += h[1] >> 51; + h[1] &= MASK; + h[3] += h[2] >> 51; + h[2] &= MASK; + h[4] += h[3] >> 51; + h[3] &= MASK; + h[4] &= MASK; + let mut out = [0_u8; 32]; + let mut acc: u128 = 0; + let mut bits = 0_u32; + let mut at = 0_usize; + for limb in h { + acc |= u128::from(limb) << bits; + bits += 51; + while bits >= 8 { + out[at] = (acc & 0xFF) as u8; + acc >>= 8; + bits -= 8; + at += 1; + } + } + while at < 32 { + out[at] = (acc & 0xFF) as u8; + acc >>= 8; + at += 1; + } + out + } + + /// 加法。 + pub(crate) fn add(&self, rhs: &Self) -> Self { + let f = self.0; + let g = rhs.0; + Self(carry([ + f[0] + g[0], + f[1] + g[1], + f[2] + g[2], + f[3] + g[3], + f[4] + g[4], + ])) + } + + /// 减法(加 `2p` 再减,避免借位)。 + pub(crate) fn sub(&self, rhs: &Self) -> Self { + let f = self.0; + let g = rhs.0; + // 2p 的肢:`2*(2^51-19)` 与 `2*(2^51-1)`。 + let two_p0 = 0x000F_FFFF_FFFF_FFDA; + let two_p = 0x000F_FFFF_FFFF_FFFE; + Self(carry([ + f[0] + two_p0 - g[0], + f[1] + two_p - g[1], + f[2] + two_p - g[2], + f[3] + two_p - g[3], + f[4] + two_p - g[4], + ])) + } + + /// 取负。 + pub(crate) fn neg(&self) -> Self { + Self::ZERO.sub(self) + } + + /// 乘法(教科书式 5x5,中间积用 128 位)。 + pub(crate) fn mul(&self, rhs: &Self) -> Self { + let f = self.0; + let g = rhs.0; + let g1 = u128::from(g[1]) * 19; + let g2 = u128::from(g[2]) * 19; + let g3 = u128::from(g[3]) * 19; + let g4 = u128::from(g[4]) * 19; + let f0 = u128::from(f[0]); + let f1 = u128::from(f[1]); + let f2 = u128::from(f[2]); + let f3 = u128::from(f[3]); + let f4 = u128::from(f[4]); + let g0 = u128::from(g[0]); + let mut h0 = f0 * g0 + f1 * g4 + f2 * g3 + f3 * g2 + f4 * g1; + let mut h1 = f0 * u128::from(g[1]) + f1 * g0 + f2 * g4 + f3 * g3 + f4 * g2; + let mut h2 = f0 * u128::from(g[2]) + f1 * u128::from(g[1]) + f2 * g0 + f3 * g4 + f4 * g3; + let mut h3 = f0 * u128::from(g[3]) + + f1 * u128::from(g[2]) + + f2 * u128::from(g[1]) + + f3 * g0 + + f4 * g4; + let mut h4 = f0 * u128::from(g[4]) + + f1 * u128::from(g[3]) + + f2 * u128::from(g[2]) + + f3 * u128::from(g[1]) + + f4 * g0; + let mask128 = u128::from(MASK); + let mut c = h0 >> 51; + h0 &= mask128; + h1 += c; + c = h1 >> 51; + h1 &= mask128; + h2 += c; + c = h2 >> 51; + h2 &= mask128; + h3 += c; + c = h3 >> 51; + h3 &= mask128; + h4 += c; + c = h4 >> 51; + h4 &= mask128; + h0 += c * 19; + c = h0 >> 51; + h0 &= mask128; + h1 += c; + Self([h0 as u64, h1 as u64, h2 as u64, h3 as u64, h4 as u64]) + } + + /// 平方(乘法的特化,只为可读性)。 + pub(crate) fn square(&self) -> Self { + self.mul(self) + } + + /// `self^exponent`,指数以 32 字节小端给出(从最高位往下平方-乘)。 + pub(crate) fn pow(&self, exponent: &[u8; 32]) -> Self { + let mut acc = Self::ONE; + for byte_index in (0..32).rev() { + for bit in (0..8).rev() { + acc = acc.square(); + if (exponent[byte_index] >> bit) & 1 == 1 { + acc = acc.mul(self); + } + } + } + acc + } + + /// 乘法逆元(`p` 是素数,故 `x^(p-2)`)。 + pub(crate) fn invert(&self) -> Self { + // p - 2 = 2^255 - 21 + let mut e = [0xFF_u8; 32]; + e[0] = 0xEB; + e[31] = 0x7F; + self.pow(&e) + } + + /// `x^((p-5)/8)`,开方用(`(p-5)/8 = 2^252 - 3`)。 + pub(crate) fn pow_p58(&self) -> Self { + let mut e = [0xFF_u8; 32]; + e[0] = 0xFD; + e[31] = 0x0F; + self.pow(&e) + } + + /// `sqrt(-1) = 2^((p-1)/4)`(`p ≡ 5 mod 8` 时开方失败要用它翻一次符号)。 + pub(crate) fn sqrt_m1() -> Self { + // (p-1)/4 = 2^253 - 5 + let mut e = [0xFF_u8; 32]; + e[0] = 0xFB; + e[31] = 0x1F; + Self::from_u64(2).pow(&e) + } + + /// 最低位(规范编码的第 0 位);点解压时与签名的符号位比对。 + pub(crate) fn is_odd(&self) -> bool { + self.to_bytes()[0] & 1 == 1 + } + + /// 是否等于 0(按规范编码比)。 + pub(crate) fn is_zero(&self) -> bool { + self.to_bytes() == [0_u8; 32] + } +} + +/// 把肢挽到 51 位以内(高位的溢出折回第 0 肢,因为 `2^255 ≡ 19`)。 +fn carry(mut h: [u64; 5]) -> [u64; 5] { + let mut c = h[0] >> 51; + h[0] &= MASK; + h[1] += c; + c = h[1] >> 51; + h[1] &= MASK; + h[2] += c; + c = h[2] >> 51; + h[2] &= MASK; + h[3] += c; + c = h[3] >> 51; + h[3] &= MASK; + h[4] += c; + c = h[4] >> 51; + h[4] &= MASK; + h[0] += c * 19; + c = h[0] >> 51; + h[0] &= MASK; + h[1] += c; + h +} diff --git a/crates/core/src/update/ed25519/group.rs b/crates/core/src/update/ed25519/group.rs new file mode 100644 index 0000000..6dbb275 --- /dev/null +++ b/crates/core/src/update/ed25519/group.rs @@ -0,0 +1,168 @@ +//! 扭曲爱德华兹曲线上的群运算(`a = -1`,扩展坐标 `(X:Y:Z:T)`,`xy = T/Z`)。 +//! +//! 只用两条公式(Hisil-Wong-Carter-Dawson 2008):加法 `add-2008-hwcd-3`、倍点 `dbl-2008-hwcd`, +//! 以及一个最朴素的「从最高位往下的平方-乘」标量乘。验签一次只要两次标量乘,朴素实现足够, +//! 换来的是每一步都能对着公式读——这里宁可慢一点也不要一个看不懂的窗口表。 + +use std::sync::OnceLock; + +use super::field::Fe; + +/// 曲线常数 `d = -121665/121666`。 +fn d() -> &'static Fe { + static D: OnceLock = OnceLock::new(); + D.get_or_init(|| { + Fe::from_u64(121665) + .mul(&Fe::from_u64(121666).invert()) + .neg() + }) +} + +/// `2d`(加法公式里那一步)。 +fn d2() -> &'static Fe { + static D2: OnceLock = OnceLock::new(); + D2.get_or_init(|| d().add(d())) +} + +/// 基点 `B` 的压缩编码(RFC 8032 §5.1 的标准常量,解压即得基点)。 +const BASE_ENCODING: [u8; 32] = [ + 0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, +]; + +/// 曲线上的点(扩展坐标)。 +#[derive(Clone, Copy, Debug)] +pub(crate) struct Point { + x: Fe, + y: Fe, + z: Fe, + t: Fe, +} + +impl Point { + /// 单位元 `(0, 1, 1, 0)`。 + pub(crate) fn identity() -> Self { + Self { + x: Fe::ZERO, + y: Fe::ONE, + z: Fe::ONE, + t: Fe::ZERO, + } + } + + /// 基点 `B`。 + pub(crate) fn base() -> Self { + // 常量本身是合法压缩点;万一实现坏了这一步会返回 None,用身份元兜底并让上层验签必然失败。 + Self::decompress(&BASE_ENCODING).unwrap_or_else(Self::identity) + } + + /// 点加。 + pub(crate) fn add(&self, rhs: &Self) -> Self { + let a = self.y.sub(&self.x).mul(&rhs.y.sub(&rhs.x)); + let b = self.y.add(&self.x).mul(&rhs.y.add(&rhs.x)); + let c = self.t.mul(&d2().mul(&rhs.t)); + let dd = self.z.mul(&rhs.z); + let dd = dd.add(&dd); + let e = b.sub(&a); + let f = dd.sub(&c); + let g = dd.add(&c); + let h = b.add(&a); + Self { + x: e.mul(&f), + y: g.mul(&h), + t: e.mul(&h), + z: f.mul(&g), + } + } + + /// 倍点。 + pub(crate) fn double(&self) -> Self { + let a = self.x.square(); + let b = self.y.square(); + let z2 = self.z.square(); + let c = z2.add(&z2); + let dd = a.neg(); + let e = self.x.add(&self.y).square().sub(&a).sub(&b); + let g = dd.add(&b); + let f = g.sub(&c); + let h = dd.sub(&b); + Self { + x: e.mul(&f), + y: g.mul(&h), + t: e.mul(&h), + z: f.mul(&g), + } + } + + /// 压缩成 32 字节(`y` 的规范编码 + `x` 的最低位放进第 255 位)。 + pub(crate) fn compress(&self) -> [u8; 32] { + let z_inv = self.z.invert(); + let x = self.x.mul(&z_inv); + let y = self.y.mul(&z_inv); + let mut out = y.to_bytes(); + if x.is_odd() { + out[31] |= 0x80; + } + out + } + + /// 从 32 字节压缩编码解压;**严格**:非规范的 `y`、解不出 `x`、`x = 0` 却带符号位都返回 `None`。 + pub(crate) fn decompress(bytes: &[u8; 32]) -> Option { + let mut raw = *bytes; + let sign = raw[31] >> 7; + raw[31] &= 0x7F; + let y = Fe::from_bytes(&raw); + // 规范编码才收:`y >= p` 的写法(同一元素的第二种编法)一律拒绝,防「同一签名两种字节」。 + if y.to_bytes() != raw { + return None; + } + // x^2 = (y^2 - 1) / (d y^2 + 1) + let y2 = y.square(); + let u = y2.sub(&Fe::ONE); + let v = d().mul(&y2).add(&Fe::ONE); + let v3 = v.square().mul(&v); + let v7 = v3.square().mul(&v); + let mut x = u.mul(&v3).mul(&u.mul(&v7).pow_p58()); + if v.mul(&x.square()).to_bytes() != u.to_bytes() { + // p ≡ 5 mod 8:这一支解不出时,乘 sqrt(-1) 再试一次(另一半平方根)。 + x = x.mul(&Fe::sqrt_m1()); + if v.mul(&x.square()).to_bytes() != u.to_bytes() { + return None; + } + } + if x.is_zero() { + // x = 0 时符号位只能是 0(否则就是「-0」,非规范)。 + if sign == 1 { + return None; + } + } else if x.is_odd() != (sign == 1) { + x = x.neg(); + } + Some(Self { + x, + y, + z: Fe::ONE, + t: x.mul(&y), + }) + } + + /// `[scalar] self`,标量以 32 字节小端给出(从最高位往下:倍点,置位才加)。 + pub(crate) fn mul(&self, scalar: &[u8; 32]) -> Self { + let mut acc = Self::identity(); + for byte_index in (0..32).rev() { + for bit in (0..8).rev() { + acc = acc.double(); + if (scalar[byte_index] >> bit) & 1 == 1 { + acc = acc.add(self); + } + } + } + acc + } + + /// 是否小阶点(`[8]P` 是单位元就是;`verify_strict` 的第一道严格性判据)。 + pub(crate) fn is_small_order(&self) -> bool { + let p8 = self.double().double().double(); + p8.x.is_zero() && p8.y.to_bytes() == p8.z.to_bytes() + } +} diff --git a/crates/core/src/update/ed25519/mod.rs b/crates/core/src/update/ed25519/mod.rs new file mode 100644 index 0000000..4c44e7b --- /dev/null +++ b/crates/core/src/update/ed25519/mod.rs @@ -0,0 +1,265 @@ +//! Ed25519 **严格验签**(RFC 8032 §5.1.7),只验不签、只用公开数据。 +//! +//! # 为什么这里没有引 `curve25519-dalek` / `ed25519-dalek` +//! +//! M190 的 scope 不含 `crates/core/Cargo.toml`,而清单验签必须在 `core::update` 里(GUI 与 CLI +//! 共用 `Job`,验签只能有一个收口点)。取舍:把验签实现**就地**写成三个文件(`field` 素域算术 / +//! `group` 曲线运算 / 本文件判据),零新依赖,判据对着 RFC 8032 的官方测试向量逐条验 +//! (`tests/ed25519_cases.rs`)。 +//! +//! 这条取舍的代价必须说清: +//! +//! * 实现只有「验」这一半——没有私钥、没有 nonce、没有常数时间要求,攻击面比完整签名库小得多; +//! * 但它是**自写密码学**。日后 scope 允许时应当整体换成 `ed25519-dalek` 的 +//! `verify_strict`,对外只留 [`verify`] 一个函数,替换是改这一个函数体; +//! * 严格性按 `verify_strict` 的口径来:拒绝非规范编码(`y >= p`、`-0`)、拒绝小阶公钥与小阶 `R`、 +//! 拒绝非规范标量 `S`(`S >= L`,即可锻性)。少任何一条都能造出「改一个字节仍通过」的假签。 +//! +//! # 判据(逐条,都能单独变红) +//! +//! ```text +//! ① 解 A、解 R 都必须成功且是规范编码 (非规范编码 → 拒) +//! ② A、R 都不能是小阶点 (小阶 → 拒) +//! ③ S < L(L = 2^252 + 27742317777372353535851937790883648493) +//! ④ k = SHA-512(R‖A‖M) mod L +//! ⑤ [S]B == R + [k]A(压缩后逐字节比) +//! ``` + +mod field; +mod group; + +use sha2::{Digest, Sha512}; + +use self::group::Point; + +/// 公钥字节数。 +pub(crate) const PUBLIC_KEY_LEN: usize = 32; +/// 签名字节数。 +pub(crate) const SIGNATURE_LEN: usize = 64; + +/// `L = 2^252 + 27742317777372353535851937790883648493`(小端 4 条 64 位肢)。 +const L: [u64; 4] = [ + 0x5812_631a_5cf5_d3ed, + 0x14de_f9de_a2f7_9cd6, + 0x0000_0000_0000_0000, + 0x1000_0000_0000_0000, +]; + +/// 验一条 Ed25519 签名。 +/// +/// `public_key` 与 `signature` 的语义就是 RFC 8032 的那两段字节;`message` 是任意长原文 +/// (这里恒为清单文件的原始字节,见 [`super::signature`])。 +#[must_use] +pub(crate) fn verify( + public_key: &[u8; PUBLIC_KEY_LEN], + message: &[u8], + signature: &[u8; SIGNATURE_LEN], +) -> bool { + let mut r_bytes = [0_u8; PUBLIC_KEY_LEN]; + r_bytes.copy_from_slice(&signature[..PUBLIC_KEY_LEN]); + let mut s_bytes = [0_u8; PUBLIC_KEY_LEN]; + s_bytes.copy_from_slice(&signature[PUBLIC_KEY_LEN..]); + // ③ S 必须是规范标量:`S >= L` 时同一个「消息 + 公钥」存在多个可验证的签名,签名的不可锻性就没了。 + if !scalar_is_canonical(&s_bytes) { + return false; + } + let Some(public_point) = Point::decompress(public_key) else { + return false; + }; + let Some(r_point) = Point::decompress(&r_bytes) else { + return false; + }; + // ② 小阶点会让「谁都能构造一个自洽的四元组」,严格验签一律拒。 + if public_point.is_small_order() || r_point.is_small_order() { + return false; + } + // ④ k = SHA-512(R ‖ A ‖ M) mod L + let mut hasher = Sha512::new(); + hasher.update(r_bytes); + hasher.update(public_key); + hasher.update(message); + let digest = hasher.finalize(); + let mut wide = [0_u8; 64]; + wide.copy_from_slice(&digest); + let k = reduce_512(&wide); + // ⑤ [S]B 与 R + [k]A 必须是同一个点(压缩后逐字节比即「同一个点」,因为压缩是单射)。 + let left = Point::base().mul(&s_bytes); + let right = r_point.add(&public_point.mul(&k)); + left.compress() == right.compress() +} + +/// 从 32 字节种子推出公钥(**只给测试用**:正式构建的验签不需要它,也没有任何私钥代码)。 +#[cfg(test)] +pub(crate) fn public_key_from_seed_for_tests(seed: &[u8; 32]) -> [u8; PUBLIC_KEY_LEN] { + let (scalar, _) = expand_seed(seed); + Point::base().mul(&scalar).compress() +} + +/// 用 32 字节种子签一条消息(**只给测试用**,见 [`public_key_from_seed_for_tests`])。 +#[cfg(test)] +pub(crate) fn sign_for_tests(seed: &[u8; 32], message: &[u8]) -> [u8; SIGNATURE_LEN] { + let (scalar, prefix) = expand_seed(seed); + let public_key = Point::base().mul(&scalar).compress(); + let mut hasher = Sha512::new(); + hasher.update(prefix); + hasher.update(message); + let mut wide = [0_u8; 64]; + wide.copy_from_slice(&hasher.finalize()); + let r = reduce_512(&wide); + let r_encoded = Point::base().mul(&r).compress(); + let mut hasher = Sha512::new(); + hasher.update(r_encoded); + hasher.update(public_key); + hasher.update(message); + let mut wide = [0_u8; 64]; + wide.copy_from_slice(&hasher.finalize()); + let k = reduce_512(&wide); + let s = scalar_mul_add_mod_l(&k, &scalar, &r); + let mut out = [0_u8; SIGNATURE_LEN]; + out[..PUBLIC_KEY_LEN].copy_from_slice(&r_encoded); + out[PUBLIC_KEY_LEN..].copy_from_slice(&s); + out +} + +/// RFC 8032 §5.1.5 的种子展开:`SHA-512(seed)` 的前半是按位裁剪的标量、后半是 nonce 前缀。 +#[cfg(test)] +fn expand_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) { + let digest = Sha512::digest(seed); + let mut scalar = [0_u8; 32]; + scalar.copy_from_slice(&digest[..32]); + scalar[0] &= 248; + scalar[31] &= 127; + scalar[31] |= 64; + let mut prefix = [0_u8; 32]; + prefix.copy_from_slice(&digest[32..]); + (scalar, prefix) +} + +/// `S < L` 判定(小端 32 字节)。 +fn scalar_is_canonical(bytes: &[u8; 32]) -> bool { + let mut limbs = [0_u64; 4]; + let (chunks, _) = bytes.as_chunks::<8>(); + for (limb, chunk) in limbs.iter_mut().zip(chunks) { + *limb = u64::from_le_bytes(*chunk); + } + // 从最高肢往下比:高于 L 的最高肢就不可能小于 L。 + for index in (0..4).rev() { + if limbs[index] < L[index] { + return true; + } + if limbs[index] > L[index] { + return false; + } + } + false +} + +/// 把一个 512 位大端-无关的字节串按小端解释,对 `L` 取模。 +/// +/// `bytes[63]` 是最高字节。做法是最朴素的竖式:每次左移 8 位再补一个字节,随后反复减 `L` +/// 直到小于 `L`。一次验签跑两遍、每遍最坏 64*256 次减——比任何花招都更容易对着看懂。 +fn reduce_512(bytes: &[u8; 64]) -> [u8; 32] { + let mut acc = [0_u64; 5]; + for byte in bytes.iter().rev() { + let mut carry = u128::from(*byte); + for limb in &mut acc { + let wide = (u128::from(*limb) << 8) | carry; + *limb = wide as u64; + carry = wide >> 64; + } + while !below_l(&acc) { + subtract_l(&mut acc); + } + } + let mut out = [0_u8; 32]; + for index in 0..4 { + out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes()); + } + out +} + +/// `acc < L`(`acc` 是 5 肢,最高肢非零即「远大于 L」)。 +fn below_l(acc: &[u64; 5]) -> bool { + if acc[4] != 0 { + return false; + } + for index in (0..4).rev() { + if acc[index] < L[index] { + return true; + } + if acc[index] > L[index] { + return false; + } + } + false +} + +/// `acc -= L`(调用方保证 `acc >= L`)。 +fn subtract_l(acc: &mut [u64; 5]) { + let mut borrow = 0_u64; + for index in 0..4 { + let (value, first) = acc[index].overflowing_sub(L[index]); + let (value, second) = value.overflowing_sub(borrow); + acc[index] = value; + borrow = u64::from(first || second); + } + let (value, _) = acc[4].overflowing_sub(borrow); + acc[4] = value; +} + +/// `(k*a + r) mod L`(测试签名用;`k`、`a`、`r` 都已是规范标量)。 +#[cfg(test)] +fn scalar_mul_add_mod_l(k: &[u8; 32], a: &[u8; 32], r: &[u8; 32]) -> [u8; 32] { + // 256x256 -> 512 位乘积:8 条 64 位肢,逐肢进位后再按字节展开成 64 字节(小端)。 + let mut product = [0_u64; 8]; + for (left_index, left) in limbs_of(k).iter().enumerate() { + let mut carry = 0_u128; + for (right_index, right) in limbs_of(a).iter().enumerate() { + let cell = u128::from(*left) * u128::from(*right) + + u128::from(product[left_index + right_index]) + + carry; + product[left_index + right_index] = cell as u64; + carry = cell >> 64; + } + product[left_index + 4] = u64::try_from(carry).unwrap_or(0); + } + let mut wide = [0_u8; 64]; + for (index, limb) in product.iter().enumerate() { + wide[index * 8..index * 8 + 8].copy_from_slice(&limb.to_le_bytes()); + } + let reduced = reduce_512(&wide); + // 加上 r 再取模一次(`acc` 5 肢 + 反复减 L,与 reduce_512 同一手法)。 + let mut acc = [0_u64; 5]; + for index in 0..4 { + let mut buf = [0_u8; 8]; + buf.copy_from_slice(&reduced[index * 8..index * 8 + 8]); + acc[index] = u64::from_le_bytes(buf); + } + let mut carry = 0_u64; + for (slot, rhs) in acc.iter_mut().zip(limbs_of(r)) { + let (sum, overflow) = slot.overflowing_add(rhs); + let (sum, overflow2) = sum.overflowing_add(carry); + *slot = sum; + carry = u64::from(overflow || overflow2); + } + acc[4] = carry; + while !below_l(&acc) { + subtract_l(&mut acc); + } + let mut out = [0_u8; 32]; + for index in 0..4 { + out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes()); + } + out +} + +/// 32 字节小端 -> 4 条 64 位肢。 +#[cfg(test)] +fn limbs_of(bytes: &[u8; 32]) -> [u64; 4] { + let mut limbs = [0_u64; 4]; + let (chunks, _) = bytes.as_chunks::<8>(); + for (limb, chunk) in limbs.iter_mut().zip(chunks) { + *limb = u64::from_le_bytes(*chunk); + } + limbs +} diff --git a/crates/core/src/update/error.rs b/crates/core/src/update/error.rs index 65553f8..5826669 100644 --- a/crates/core/src/update/error.rs +++ b/crates/core/src/update/error.rs @@ -33,6 +33,21 @@ pub enum UpdateErrorKind { DigestMismatch, /// 长度不符:典型成因是下载被截断,也可能清单写错。 SizeMismatch, + /// 清单没有配套签名文件(`<清单名>.sig` 不在源里)。 + /// + /// 与 [`Self::SignatureInvalid`] 分开:这条是**发布侧漏了签名**,不是清单被改; + /// 处置也不同(等发布侧补,或回滚发布),所以必须是两个分类。 + SignatureMissing, + /// 签名文件本身坏了:不是 JSON、字段名拼错、`format` 不认识、`signatures` 是空的。 + SignatureMalformed, + /// 本构建没有钉任何清单签名公钥(正式信任表为空)。 + /// + /// 这是「构建形态」的问题,不是清单的问题:空表一律拒装,绝不「没钥就放行」。 + KeyringEmpty, + /// 清单的签名者不在钉住的信任表里(`key_id` 一个都没命中)。 + SignatureUnknownKey, + /// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。 + SignatureInvalid, } impl UpdateErrorKind { @@ -49,6 +64,11 @@ impl UpdateErrorKind { Self::Io => "io", Self::DigestMismatch => "digest_mismatch", Self::SizeMismatch => "size_mismatch", + Self::SignatureMissing => "signature_missing", + Self::SignatureMalformed => "signature_malformed", + Self::KeyringEmpty => "keyring_empty", + Self::SignatureUnknownKey => "signature_unknown_key", + Self::SignatureInvalid => "signature_invalid", } } @@ -56,6 +76,9 @@ impl UpdateErrorKind { /// /// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层 /// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。 + /// + /// 签名五兄弟**一律不可重试**:签名不对不是抖动,重试一千次还是不对。把它们标成可重试 + /// 会让壳层反复弹「网络问题,稍后重试」,掩盖真正的原因(发布侧漏签 / 钥没钉 / 清单被改)。 #[must_use] pub const fn is_retryable(self) -> bool { matches!( @@ -63,6 +86,19 @@ impl UpdateErrorKind { Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io ) } + + /// 是否属于「清单签名」这一组(壳层据此换文案:这不是网络问题,别让用户重试)。 + #[must_use] + pub const fn is_signature(self) -> bool { + matches!( + self, + Self::SignatureMissing + | Self::SignatureMalformed + | Self::KeyringEmpty + | Self::SignatureUnknownKey + | Self::SignatureInvalid + ) + } } /// 自更新错误。 diff --git a/crates/core/src/update/github.rs b/crates/core/src/update/github.rs index 72ae052..ac0d059 100644 --- a/crates/core/src/update/github.rs +++ b/crates/core/src/update/github.rs @@ -60,8 +60,8 @@ use crate::download::{ use super::channel::Channel; use super::error::{UpdateError, UpdateErrorKind}; -use super::manifest::{Artifact, ReleaseManifest}; -use super::source::UpdateSource; +use super::manifest::Artifact; +use super::source::{ManifestDocuments, UpdateSource}; /// 默认仓库 owner。 pub const DEFAULT_OWNER: &str = "AskaEth"; @@ -71,6 +71,8 @@ pub const DEFAULT_REPO: &str = "Unlucky-Launcher"; pub const DEFAULT_BASE: &str = "https://github.com"; /// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。 pub const MANIFEST_ASSET: &str = "manifest.json"; +/// 清单签名的资产名(M190:与清单并列挂在同一个 release 上)。 +pub const MANIFEST_SIGNATURE_ASSET: &str = "manifest.json.sig"; /// GitHub Releases 更新源。 #[derive(Debug, Clone)] @@ -265,27 +267,24 @@ impl UpdateSource for GitHubReleaseSource { &self.id } - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError> { let Some(url) = self.manifest_url(channel) else { return Ok(None); }; - let Some(text) = self.fetch_text(&url)? else { + let Some(manifest) = self.fetch_text(&url)? else { return Ok(None); }; - let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| { - error.message = format!("{}(来源 {url})", error.message); - error - })?; - if manifest.channel != channel { - return Err(UpdateError::new( - UpdateErrorKind::ChannelMismatch, - format!( - "请求的是 {channel} 渠道,清单 {url} 自报 {}", - manifest.channel - ), - )); - } - Ok(Some(manifest)) + // 签名文件的 404 与清单的 404 处理不同:清单没有 = 该渠道没发过版(不是错误); + // 签名没有 = 发布侧漏了签名(上层判 SignatureMissing 并拒装)。 + let signature_url = format!("{}/{MANIFEST_SIGNATURE_ASSET}", self.download_base()); + let signatures = self.fetch_text(&signature_url)?; + Ok(Some(ManifestDocuments { + manifest, + signatures, + })) } fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { diff --git a/crates/core/src/update/job.rs b/crates/core/src/update/job.rs index a1d1f91..2a0f750 100644 --- a/crates/core/src/update/job.rs +++ b/crates/core/src/update/job.rs @@ -227,11 +227,11 @@ impl UpdateSource for SourceRef<'_> { self.0.source_id() } - fn fetch_manifest( + fn fetch_manifest_documents( &self, channel: Channel, - ) -> Result, UpdateError> { - self.0.fetch_manifest(channel) + ) -> Result, UpdateError> { + self.0.fetch_manifest_documents(channel) } fn fetch_artifact( diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index ce4db7c..bbec6d3 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -30,6 +30,8 @@ //! manifest 清单模型 + JSON 校验 target 目标平台与包形态 //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 +//! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring) +//! ed25519 Ed25519 严格验签(自写,无新依赖) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) //! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! ``` @@ -49,17 +51,32 @@ //! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。 //! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。 //! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。 +//! +//! # 信任根(M190) +//! +//! ```text +//! 源只取字节 ──> signature::verify_manifest(清单**原始字节** + `manifest.json.sig`) +//! │ 判据:钉死的公钥(trust::keyring,编译期常量)验 Ed25519 +//! ├─ 过了 ──> manifest::from_json ──> checker/apply +//! └─ 不过 ──> 五类签名错误(不可重试),**什么都不会被安装** +//! ``` +//! +//! 一句话:**先验签、再解析**。清单的 sha256 只保证「包体和清单对上」,清单本身可不可信由签名回答—— +//! 两条判据互不替代(`tests/signature_cases.rs` 分别证明)。 pub mod channel; pub mod checker; +mod ed25519; pub mod error; pub mod github; pub mod install; pub mod job; pub mod manifest; mod replace_io; +mod signature; pub mod source; pub mod target; +mod trust; pub mod verify; pub mod version; @@ -77,7 +94,8 @@ pub use self::job::{ default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV, }; pub use self::manifest::{Artifact, Release, ReleaseManifest}; -pub use self::source::{DirSource, UpdateSource}; +pub use self::signature::VerifiedSignature; +pub use self::source::{verified_manifest, DirSource, ManifestDocuments, UpdateSource}; pub use self::target::{Arch, PackageFormat, Target}; pub use self::verify::{stage, verify_file, StagedPackage}; pub use self::version::{Stage, Version, VersionError}; diff --git a/crates/core/src/update/signature.rs b/crates/core/src/update/signature.rs new file mode 100644 index 0000000..eb7cafe --- /dev/null +++ b/crates/core/src/update/signature.rs @@ -0,0 +1,256 @@ +//! 清单签名信封:`manifest.json.sig` 的解析与验签。 +//! +//! # 为什么是**分离**的签名文件,而不是清单里的一个 `signatures` 字段 +//! +//! 签名只能覆盖「签的时候那份字节」。把签名塞进清单本身,验签就得先把那个字段从 JSON 里摘出来 +//! 再规范化(空白、键序、数字写法全都得钉死),任何一处规范化差异都会让「同一份内容」有两种字节, +//! 于是「改字节不改语义」的老把戏就回来了。分离文件把这件事变简单:**签名覆盖清单文件的原始字节**, +//! 一个 bit 都不能变。顺序也就固定了:先验签,再解析。 +//! +//! ```json +//! {"format": 1, +//! "signatures": [ +//! {"alg": "ed25519", "key_id": "ull-release-2026a", "sig": "<128 位十六进制>", +//! "manifest_sha256": "<64 位十六进制>"} +//! ]} +//! ``` +//! +//! `manifest_sha256` 是冗余但**有意**的:它让「清单变了而签名没跟着换」这种最常见的发布事故 +//! 给出**具体**原因(签名声明的是哪一份、手上是哪一份),而不是一句「验签失败」。它不参与判据 +//! ——判据永远是 Ed25519 的那条方程。 +//! +//! # 多签名 +//! +//! 数组里可以有任意多条,**任意一条**由钉死的钥验过就放行。这给了「新旧钥并存」的窗口期: +//! 轮换期一份清单同时带旧钥与新钥签名,装着旧二进制的用户与装着新二进制的用户都过得去。 +//! +//! # 判据顺序(每条都能单独变红) +//! +//! 1. 信封存在且能解析(`format` 必须是 1); +//! 2. 本构建钉了至少一把公钥([`super::trust::keyring`] 非空)——空表一律拒,绝不「没钥就放行」; +//! 3. 至少一条签名的 `key_id` 命中钉住的表,且它的 `alg` 是 `ed25519`; +//! 4. 那条签名对**清单原始字节**验过; +//! 5. 若签名带 `manifest_sha256`,与清单字节的 SHA-256 相符(只为给出可读原因,不替代第 4 条)。 + +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use super::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN}; +use super::error::{UpdateError, UpdateErrorKind}; +use super::trust; + +/// 信封格式版本(改字段形状要抬它,老客户端会以「格式不支持」明确拒绝,而不是静默不认签名)。 +pub(crate) const ENVELOPE_FORMAT: u32 = 1; + +/// 验收通过的签名摘要(进日志/CLI 输出:谁签的、用哪把钥验过的)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedSignature { + /// 验过的 `key_id`。 + pub key_id: String, + /// 算法标识(当前只有 `ed25519`)。 + pub alg: String, +} + +/// 清单的签名信封。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct Envelope { + format: u32, + signatures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EnvelopeSignature { + alg: String, + key_id: String, + sig: String, + manifest_sha256: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelope { + format: u32, + #[serde(default)] + signatures: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelopeSignature { + alg: String, + key_id: String, + sig: String, + manifest_sha256: Option, +} + +/// 验一份清单的签名。 +/// +/// # Errors +/// +/// * [`UpdateErrorKind::SignatureMissing`]:源里没有配套的签名文件; +/// * [`UpdateErrorKind::SignatureMalformed`]:信封不是合法 JSON / 字段名拼错 / `format` 不认识; +/// * [`UpdateErrorKind::KeyringEmpty`]:本构建一把公钥都没钉(正式表还没录钥); +/// * [`UpdateErrorKind::SignatureUnknownKey`]:所有签名的 `key_id` 都不在钉住的表里; +/// * [`UpdateErrorKind::SignatureInvalid`]:命中了钥但对清单字节验不过(清单被改过、签名不是给这份的)。 +pub(crate) fn verify_manifest( + manifest_bytes: &[u8], + envelope_text: Option<&str>, +) -> Result { + let Some(text) = envelope_text else { + return Err(UpdateError::new( + UpdateErrorKind::SignatureMissing, + "清单没有配套签名(清单旁边缺 `<清单名>.sig`):本启动器只安装「清单签名对得上」的更新,\ + 没有签名一律拒装——这不是你的问题,是发布侧漏了签名", + )); + }; + let envelope = parse_envelope(text)?; + let keys = trust::keyring(); + if keys.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::KeyringEmpty, + "本构建没有钉任何清单签名公钥(正式表为空),因此任何清单都无法验签:\ + 请勿绕过,这是「还没录钥」的构建,不是清单有问题", + )); + } + let actual_sha256 = hex_sha256(manifest_bytes); + let mut unknown = Vec::new(); + let mut attempted = Vec::new(); + for signature in &envelope.signatures { + if signature.alg != "ed25519" { + unknown.push(format!("{}({})", signature.key_id, signature.alg)); + continue; + } + let Some(pinned) = keys.iter().find(|key| key.key_id == signature.key_id) else { + unknown.push(format!("{}(未钉住)", signature.key_id)); + continue; + }; + let Some(public_key) = trust::decode_hex(pinned.public_key_hex) else { + return Err(UpdateError::invalid_manifest(format!( + "钉住的公钥 `{}` 不是合法十六进制(这是构建缺陷,不是清单的问题)", + pinned.key_id + ))); + }; + if public_key.len() != PUBLIC_KEY_LEN { + return Err(UpdateError::invalid_manifest(format!( + "钉住的公钥 `{}` 长 {} 字节,Ed25519 公钥必须是 {PUBLIC_KEY_LEN} 字节(构建缺陷)", + pinned.key_id, + public_key.len() + ))); + } + let mut key_bytes = [0_u8; PUBLIC_KEY_LEN]; + key_bytes.copy_from_slice(&public_key); + let Some(signature_bytes) = trust::decode_hex(&signature.sig) else { + attempted.push(format!("{}(`sig` 不是十六进制)", signature.key_id)); + continue; + }; + if signature_bytes.len() != SIGNATURE_LEN { + attempted.push(format!( + "{}(`sig` 长 {} 字节,应为 {SIGNATURE_LEN})", + signature.key_id, + signature_bytes.len() + )); + continue; + } + let mut sig_bytes = [0_u8; SIGNATURE_LEN]; + sig_bytes.copy_from_slice(&signature_bytes); + if !verify(&key_bytes, manifest_bytes, &sig_bytes) { + attempted.push(signature.key_id.clone()); + continue; + } + // 第 5 条:只为「把话说清楚」,不替代上面那条方程。 + if let Some(declared) = &signature.manifest_sha256 { + if declared.to_ascii_lowercase() != actual_sha256 { + return Err(UpdateError::new( + UpdateErrorKind::SignatureInvalid, + format!( + "清单签名声明的是另一份清单(签名里的 sha256 = {},手上这份 = {}):\ + 签名对上了、内容不是它签的那份", + short(declared), + short(&actual_sha256) + ), + )); + } + } + return Ok(VerifiedSignature { + key_id: signature.key_id.clone(), + alg: signature.alg.clone(), + }); + } + if attempted.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::SignatureUnknownKey, + format!( + "清单由本构建不认识的钥签名({}):本构建钉住的 key_id = [{}]。\ + 要么清单是别人签的,要么发布侧用了还没进信任表的新钥", + unknown.join("、"), + keys.iter() + .map(|key| key.key_id) + .collect::>() + .join(", ") + ), + )); + } + Err(UpdateError::new( + UpdateErrorKind::SignatureInvalid, + format!( + "清单签名验不过(key_id = {},清单 sha256 = {}):这份清单不是那把钥签的,\ + 或签名之后清单被改过。绝不安装", + attempted.join("、"), + short(&actual_sha256) + ), + )) +} + +/// 解析信封(`deny_unknown_fields`:字段名拼错宁可在 CI/发布侧当场炸,也别在用户机器上变成「验签失败」)。 +fn parse_envelope(text: &str) -> Result { + let raw: RawEnvelope = serde_json::from_str(text).map_err(|error| { + UpdateError::new( + UpdateErrorKind::SignatureMalformed, + format!("清单签名文件不是合法 JSON({error})"), + ) + })?; + if raw.format != ENVELOPE_FORMAT { + return Err(UpdateError::new( + UpdateErrorKind::SignatureMalformed, + format!( + "清单签名格式 {} 不认识(本构建只认 {ENVELOPE_FORMAT}):请升级启动器", + raw.format + ), + )); + } + if raw.signatures.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::SignatureMalformed, + "清单签名文件里 `signatures` 是空的:签了个空", + )); + } + Ok(Envelope { + format: raw.format, + signatures: raw + .signatures + .into_iter() + .map(|item| EnvelopeSignature { + alg: item.alg, + key_id: item.key_id, + sig: item.sig, + manifest_sha256: item.manifest_sha256, + }) + .collect(), + }) +} + +/// 清单字节的 SHA-256(小写十六进制)。 +pub(crate) fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// 日志/文案里只露前 8 位。 +fn short(hex: &str) -> String { + let trimmed: String = hex.trim().chars().take(8).collect(); + format!("{trimmed}…") +} diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs index 7184a9a..55a9dd1 100644 --- a/crates/core/src/update/source.rs +++ b/crates/core/src/update/source.rs @@ -11,6 +11,7 @@ //! //! ```text //! /stable.json 渠道清单(文件名 = 渠道名 + .json) +//! /stable.json.sig 清单签名信封(M190;缺了它清单一律拒装,见 `signature`) //! /beta.json //! /<产物文件> 清单里 url 写相对路径时,相对 root //! ``` @@ -26,6 +27,19 @@ use super::channel::Channel; use super::error::{UpdateError, UpdateErrorKind}; use super::manifest::{Artifact, ReleaseManifest}; +/// 源交出来的「清单两份原文」:清单本身 + 配套签名信封(M190)。 +/// +/// 刻意是**原文**而不是解析后的结构:签名只能覆盖字节,先解析再验签就等于给「解析器认了、 +/// 签名没认」的东西开了门。判据顺序因此固定为「先验签、再解析」,两者都在 +/// [`verified_manifest`] 里,源实现自己不做任何验签。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestDocuments { + /// 清单原文(`manifest.json` / `.json` 的字节,按 UTF-8 解释)。 + pub manifest: String, + /// 签名信封原文(`<清单名>.sig`);`None` = 源里没有这份文件(会被判 [`UpdateErrorKind::SignatureMissing`])。 + pub signatures: Option, +} + /// 更新源:清单与包体的来源。 /// /// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**: @@ -34,12 +48,16 @@ pub trait UpdateSource: Send + Sync { /// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。 fn source_id(&self) -> &str; - /// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。 + /// 取某渠道清单的两份原文;`Ok(None)` = 该渠道还没发布过(不是错误)。 /// /// # Errors /// - /// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。 - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError>; + /// 读不到文件、响应为空、网络失败都返回 [`UpdateError`]。**这里不验签、不解析** + /// (见 [`ManifestDocuments`])——管线的下一站是 [`verified_manifest`]。 + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError>; /// 把产物取到 `dest`,返回写入的字节数。 /// @@ -50,6 +68,60 @@ pub trait UpdateSource: Send + Sync { /// /// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。 fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result; + + /// 取清单 → **验签** → 解析 → 渠道校验。`check`/`apply` 都只走这一个入口。 + /// + /// 提供默认实现是为了让「怎么取字节」与「怎么判字节」彻底分开:源只实现 + /// [`Self::fetch_manifest_documents`],判据只有 [`verified_manifest`] 一份。 + /// + /// # Errors + /// + /// 取不到/取到空、签名不通过(五类,见 [`UpdateErrorKind`])、清单 JSON 坏、渠道不符。 + fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + verified_manifest(self, channel) + } +} + +/// 取清单 → 验签 → 解析 → 渠道校验(**唯一**入口,GUI 与 CLI 共用)。 +/// +/// # Errors +/// +/// 源里没有该渠道清单 ⇒ `Ok(None)`;签名/解析/渠道任一不过 ⇒ [`UpdateError`]。 +/// 签名相关的分类见 [`UpdateErrorKind::is_signature`],它们**不可重试**。 +pub fn verified_manifest( + source: &S, + channel: Channel, +) -> Result, UpdateError> { + let Some(documents) = source.fetch_manifest_documents(channel)? else { + return Ok(None); + }; + // ① 先验签:拿到的是不是「我们发的那份字节」,在这里一次说清。 + let source_label = source.source_id().to_string(); + super::signature::verify_manifest( + documents.manifest.as_bytes(), + documents.signatures.as_deref(), + ) + .map_err(|mut error| { + error.message = format!("{}(来源 {source_label})", error.message); + error + })?; + // ② 再解析:到了这一步,字节已经是发布侧签过的那份,解析失败只可能是签名者也写错了。 + let manifest = ReleaseManifest::from_json(&documents.manifest).map_err(|mut error| { + error.message = format!("{}(来源 {source_label})", error.message); + error + })?; + // ③ 渠道:清单自报的渠道与请求的对不上是源的问题,不能悄悄当成「没有更新」。 + if manifest.channel != channel { + return Err(UpdateError::new( + UpdateErrorKind::ChannelMismatch, + format!( + "请求的是 {channel} 渠道,清单(源 {})自报 {}", + source.source_id(), + manifest.channel + ), + )); + } + Ok(Some(manifest)) } /// `Box` 也是 `UpdateSource`。 @@ -62,8 +134,11 @@ impl UpdateSource for Box { (**self).source_id() } - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { - (**self).fetch_manifest(channel) + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError> { + (**self).fetch_manifest_documents(channel) } fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { @@ -111,6 +186,12 @@ impl DirSource { self.root.join(format!("{}.json", channel.as_str())) } + /// 某渠道清单的签名文件路径(`stable.json` → `stable.json.sig`)。 + #[must_use] + pub fn signature_path(&self, channel: Channel) -> PathBuf { + self.root.join(format!("{}.json.sig", channel.as_str())) + } + /// 把清单里的 `url` 解析成源侧本地路径。 fn resolve(&self, url: &str) -> Result { if let Some(rest) = url.strip_prefix("file://") { @@ -149,27 +230,27 @@ impl UpdateSource for DirSource { &self.id } - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + fn fetch_manifest_documents( + &self, + channel: Channel, + ) -> Result, UpdateError> { let path = self.manifest_path(channel); - let text = match fs::read_to_string(&path) { + let manifest = match fs::read_to_string(&path) { Ok(text) => text, Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(source) => return Err(UpdateError::io_at(&path, &source)), }; - let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?; - if manifest.channel != channel { - return Err(UpdateError::new( - UpdateErrorKind::ChannelMismatch, - format!( - "请求的是 {} 渠道,清单 {} 自报 {}", - channel, - path.display(), - manifest.channel - ), - ) - .with_path(&path)); - } - Ok(Some(manifest)) + // 签名文件缺失**不是**「没有清单」:清单在、签名不在 ⇒ 上层判 SignatureMissing 并拒装。 + let signature_path = self.signature_path(channel); + let signatures = match fs::read_to_string(&signature_path) { + Ok(text) => Some(text), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => None, + Err(source) => return Err(UpdateError::io_at(&signature_path, &source)), + }; + Ok(Some(ManifestDocuments { + manifest, + signatures, + })) } fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { diff --git a/crates/core/src/update/tests/checker_cases.rs b/crates/core/src/update/tests/checker_cases.rs index 456e70c..a2e676e 100644 --- a/crates/core/src/update/tests/checker_cases.rs +++ b/crates/core/src/update/tests/checker_cases.rs @@ -17,7 +17,7 @@ fn target() -> Target { /// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。 fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) { let dir = TempDir::new("checker"); - dir.write(&format!("{}.json", channel.as_str()), json.as_bytes()); + dir.write_signed(&format!("{}.json", channel.as_str()), json); let source = DirSource::new(dir.path()); (dir, source) } @@ -215,7 +215,7 @@ fn falls_back_to_the_newest_release_that_does_have_the_target() { #[test] fn channel_mismatch_in_the_source_is_an_error() { let tmp = TempDir::new("checker-mismatch"); - tmp.write("stable.json", manifest("beta", &[]).as_bytes()); + tmp.write_signed("stable.json", &manifest("beta", &[])); let source = crate::update::DirSource::new(tmp.path()); let current = crate::update::Version::parse("0.2.1").expect("版本"); let error = UpdateChecker::new(source, target()) @@ -228,7 +228,7 @@ fn channel_mismatch_in_the_source_is_an_error() { fn prepare_stages_the_selected_artifact() { let tmp = TempDir::new("checker-prepare"); tmp.write("a.zip", PAYLOAD); - tmp.write("stable.json", stable_release("0.3.0").as_bytes()); + tmp.write_signed("stable.json", &stable_release("0.3.0")); let source = crate::update::DirSource::new(tmp.path()); let checker = UpdateChecker::new(source, target()); let current = crate::update::Version::parse("0.2.0").expect("版本"); diff --git a/crates/core/src/update/tests/ed25519_cases.rs b/crates/core/src/update/tests/ed25519_cases.rs new file mode 100644 index 0000000..a7fdbe3 --- /dev/null +++ b/crates/core/src/update/tests/ed25519_cases.rs @@ -0,0 +1,213 @@ +//! Ed25519 严格验签的判据(RFC 8032 §7.1 官方测试向量 + 六条「该拒就拒」)。 +//! +//! 这一组用例是自写密码学的**唯一**公信力来源:向量来自 RFC 附录(不是自己签自己验), +//! 拒绝类用例覆盖「改一个字节仍然通过」的全部已知构造(改 R / 改 S / S += L / 小阶公钥 / +//! 非规范 R 编码 / 空签名)。 + +use crate::update::ed25519::{ + public_key_from_seed_for_tests, sign_for_tests, verify, PUBLIC_KEY_LEN, SIGNATURE_LEN, +}; + +/// 十六进制转字节(长度不对/字符非法当场 panic:这些向量是写死的常量)。 +fn unhex(text: &str) -> Vec { + let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + cleaned.len().is_multiple_of(2), + "十六进制串长度必须是偶数:{text}" + ); + cleaned + .as_bytes() + .chunks(2) + .map(|pair| { + let hi = (pair[0] as char).to_digit(16).expect("十六进制字符"); + let lo = (pair[1] as char).to_digit(16).expect("十六进制字符"); + u8::try_from(hi * 16 + lo).expect("字节") + }) + .collect() +} + +fn array32(bytes: &[u8]) -> [u8; 32] { + let mut out = [0_u8; 32]; + out.copy_from_slice(bytes); + out +} + +fn array64(bytes: &[u8]) -> [u8; 64] { + let mut out = [0_u8; 64]; + out.copy_from_slice(bytes); + out +} + +/// L(群阶)的小端字节,用于构造「S + L」这种可锻变体。 +const L_BYTES: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, +]; + +/// RFC 8032 的四个向量:(public, message, signature)。 +fn rfc_vectors() -> Vec<(String, String, String)> { + vec![ + ( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".to_string(), + String::new(), + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b".to_string(), + ), + ( + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c".to_string(), + "72".to_string(), + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00".to_string(), + ), + ( + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025".to_string(), + "af82".to_string(), + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a".to_string(), + ), + ( + "ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf".to_string(), + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f".to_string(), + "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704".to_string(), + ), + ] +} + +#[test] +fn rfc8032_vectors_verify() { + for (index, (public, message, signature)) in rfc_vectors().into_iter().enumerate() { + let public = array32(&unhex(&public)); + let message = unhex(&message); + let signature = array64(&unhex(&signature)); + assert!( + verify(&public, &message, &signature), + "RFC 8032 第 {} 条向量必须通过", + index + 1 + ); + } +} + +#[test] +fn message_bit_flip_is_rejected() { + let (public, message, signature) = &rfc_vectors()[1]; + let public = array32(&unhex(public)); + let mut message = unhex(message); + message[0] ^= 0x01; + let signature = array64(&unhex(signature)); + assert!( + !verify(&public, &message, &signature), + "改一个消息字节必须被拒" + ); +} + +#[test] +fn signature_bit_flip_is_rejected() { + let (public, message, signature) = &rfc_vectors()[2]; + let public = array32(&unhex(public)); + let message = unhex(message); + let mut signature = array64(&unhex(signature)); + // 只改 R 的最后一位(不动消息、不动 S):证明「只改签名字节」也会红。 + signature[31] ^= 0x01; + assert!(!verify(&public, &message, &signature), "改 R 必须被拒"); + let mut signature = array64(&unhex(&rfc_vectors()[2].2)); + signature[63] ^= 0x04; + assert!(!verify(&public, &message, &signature), "改 S 必须被拒"); +} + +#[test] +fn malleable_s_plus_l_is_rejected() { + // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余),严格实现必须拒。 + let (public, message, signature) = &rfc_vectors()[0]; + let public = array32(&unhex(public)); + let message = unhex(message); + let mut signature = array64(&unhex(signature)); + let mut carry = 0_u16; + for index in 0..32 { + let sum = u16::from(signature[32 + index]) + u16::from(L_BYTES[index]) + carry; + signature[32 + index] = (sum & 0xFF) as u8; + carry = sum >> 8; + } + assert_eq!( + carry, 0, + "S + L 不该溢出 256 位(否则这条用例本身没造出来)" + ); + assert!( + !verify(&public, &message, &signature), + "S >= L 必须被拒(可锻变体)" + ); +} + +#[test] +fn small_order_public_key_is_rejected() { + // 阶为 1 的点(y = 1, x = 0)与阶为 2 的点(y = 0):用它们做公钥,谁都能凑出「自洽」的签名。 + let message = b"manifest bytes"; + let identity_key = { + let mut key = [0_u8; PUBLIC_KEY_LEN]; + key[0] = 1; + key + }; + let order_two_key = [0_u8; PUBLIC_KEY_LEN]; + let mut signature = [0_u8; SIGNATURE_LEN]; + signature[0] = 1; // R = 单位元 + assert!( + !verify(&identity_key, message, &signature), + "小阶公钥必须被拒" + ); + assert!( + !verify(&order_two_key, message, &signature), + "零公钥必须被拒" + ); +} + +#[test] +fn non_canonical_r_encoding_is_rejected() { + // R 的 y = p(= 2^255-19)与 y = 1 同值但编码不同:非规范写法必须拒。 + let (public, message, _) = &rfc_vectors()[0]; + let public = array32(&unhex(public)); + let message = unhex(message); + let p_bytes: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ]; + let mut signature = [0_u8; SIGNATURE_LEN]; + signature[..32].copy_from_slice(&p_bytes); + assert!( + !verify(&public, &message, &signature), + "非规范 R 编码必须被拒" + ); +} + +#[test] +fn empty_signature_is_rejected() { + let (public, message, _) = &rfc_vectors()[0]; + assert!(!verify( + &array32(&unhex(public)), + &unhex(message), + &[0_u8; 64] + )); +} + +#[test] +fn test_signer_round_trips_and_uses_rfc_seed() { + // 测试钥不是「另写一套算法」:RFC 8032 的 secret 反推出 public 就对上了,签出来的也能验过。 + let seed = array32(&unhex( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + )); + let expected = array32(&unhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + )); + assert_eq!( + public_key_from_seed_for_tests(&seed), + expected, + "种子展开必须与 RFC 一致" + ); + let signature = sign_for_tests(&seed, b""); + assert_eq!( + signature, + array64(&unhex(&rfc_vectors()[0].2)), + "空消息的签名必须逐字节等于 RFC 向量(证明签名侧与向量同一套算术语义)" + ); + assert!(verify( + &expected, + b"unlucky", + &sign_for_tests(&seed, b"unlucky") + )); +} diff --git a/crates/core/src/update/tests/github_cases.rs b/crates/core/src/update/tests/github_cases.rs index b15cbbb..56e9651 100644 --- a/crates/core/src/update/tests/github_cases.rs +++ b/crates/core/src/update/tests/github_cases.rs @@ -14,7 +14,9 @@ use crate::update::{ UpdateErrorKind, UpdateSource, Version, }; -use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir}; +use super::support::{ + artifact, engine_for, manifest, release, signature_envelope, FakeHttp, FakeResponse, TempDir, +}; const PAYLOAD: &[u8] = b"github release payload"; @@ -23,6 +25,22 @@ fn manifest_path() -> String { format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}") } +/// 假服务上签名所在路径(M190:与清单并列)。 +fn signature_path() -> String { + format!("{}.sig", manifest_path()) +} + +/// 「清单 + 签名」两条路由:签名用测试钥现签,与 `DirSource` 夹具同一把钥。 +fn signed_routes(manifest_json: &str) -> Vec<(String, FakeResponse)> { + vec![ + (manifest_path(), FakeResponse::json(manifest_json)), + ( + signature_path(), + FakeResponse::json(&signature_envelope(manifest_json)), + ), + ] +} + /// 按 D49 命名规则的产物文件名。 fn asset_name() -> &'static str { "unlucky-launcher_0.6.0_windows_x64.exe" @@ -104,10 +122,7 @@ fn manifest_url_is_the_fixed_latest_download_path() { #[test] fn channels_other_than_stable_publish_nothing() { let tmp = TempDir::new("gh-channels"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json(&stable_manifest_json()), - )]); + let server = FakeHttp::start(signed_routes(&stable_manifest_json())); let source = local_source(&tmp, &server); for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] { @@ -173,10 +188,7 @@ fn artifact_url_appends_the_release_download_path() { #[test] fn fetches_and_parses_the_manifest_from_the_base() { let tmp = TempDir::new("gh-manifest-ok"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json(&stable_manifest_json()), - )]); + let server = FakeHttp::start(signed_routes(&stable_manifest_json())); let source = local_source(&tmp, &server); let manifest = source @@ -186,14 +198,18 @@ fn fetches_and_parses_the_manifest_from_the_base() { assert_eq!(manifest.channel, Channel::Stable); assert_eq!(manifest.releases.len(), 1); assert_eq!(manifest.releases[0].version.to_string(), "0.6.0"); - assert_eq!(server.hits(), vec![manifest_path()]); + assert_eq!( + server.hits(), + vec![manifest_path(), signature_path()], + "先取清单、再取它旁边的签名,两个请求都发过" + ); } #[test] fn missing_manifest_in_the_release_is_not_an_error() { // 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。 let tmp = TempDir::new("gh-manifest-404"); - let server = FakeHttp::start(vec![]); + let server = FakeHttp::start(Vec::new()); let source = local_source(&tmp, &server); let outcome = source @@ -210,10 +226,8 @@ fn missing_manifest_in_the_release_is_not_an_error() { #[test] fn broken_json_is_reported_as_invalid_manifest() { let tmp = TempDir::new("gh-manifest-broken"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json("{ not json"), - )]); + // 签名是对的(签的就是这串坏字节)——只有「签名对、内容坏」才走得到解析这一步。 + let server = FakeHttp::start(signed_routes("{ not json")); let source = local_source(&tmp, &server); let error = source @@ -221,8 +235,8 @@ fn broken_json_is_reported_as_invalid_manifest() { .expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert!( - error.message.contains(&server.base()), - "错误里要带出来源地址,日志才定位得到(实际:{})", + error.message.contains("github:"), + "错误里要带出来源标识,日志才定位得到(实际:{})", error.message ); assert!(!error.is_retryable(), "清单坏重试多少次都一样"); @@ -233,7 +247,7 @@ fn illegal_manifest_fields_are_reported() { let tmp = TempDir::new("gh-manifest-illegal"); // size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。 let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#; - let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]); + let server = FakeHttp::start(signed_routes(json)); let source = local_source(&tmp, &server); let error = source @@ -245,10 +259,7 @@ fn illegal_manifest_fields_are_reported() { #[test] fn manifest_self_reporting_another_channel_is_a_mismatch() { let tmp = TempDir::new("gh-manifest-mismatch"); - let server = FakeHttp::start(vec![( - manifest_path().as_str(), - FakeResponse::json(&manifest("beta", &[])), - )]); + let server = FakeHttp::start(signed_routes(&manifest("beta", &[]))); let source = local_source(&tmp, &server); let error = source @@ -260,7 +271,7 @@ fn manifest_self_reporting_another_channel_is_a_mismatch() { #[test] fn server_errors_are_network_errors() { let tmp = TempDir::new("gh-manifest-500"); - let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]); + let server = FakeHttp::start(vec![(manifest_path(), FakeResponse::status(500))]); let source = local_source(&tmp, &server); let error = source @@ -273,7 +284,7 @@ fn server_errors_are_network_errors() { #[test] fn downloads_the_artifact_through_the_engine() { let tmp = TempDir::new("gh-artifact-download"); - let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]); + let server = FakeHttp::start(vec![(asset_path(), FakeResponse::bin(PAYLOAD))]); let source = local_source(&tmp, &server); let dest = tmp.join("staging/package.exe"); @@ -292,7 +303,7 @@ fn downloads_the_artifact_through_the_engine() { #[test] fn missing_artifact_is_reported_as_source_missing() { let tmp = TempDir::new("gh-artifact-404"); - let server = FakeHttp::start(vec![]); + let server = FakeHttp::start(Vec::new()); let source = local_source(&tmp, &server); let error: UpdateError = source @@ -354,18 +365,14 @@ fn agrees_with_dir_source_on_the_same_manifest() { let json = stable_manifest_json(); // 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。 - tmp.write( - &format!("{}.json", Channel::Stable.as_str()), - json.as_bytes(), - ); + tmp.write_signed(&format!("{}.json", Channel::Stable.as_str()), &json); tmp.write(asset_name(), PAYLOAD); let dir = DirSource::new(tmp.path()); // GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。 - let server = FakeHttp::start(vec![ - (manifest_path().as_str(), FakeResponse::json(&json)), - (asset_path().as_str(), FakeResponse::bin(PAYLOAD)), - ]); + let mut routes = signed_routes(&json); + routes.push((asset_path(), FakeResponse::bin(PAYLOAD))); + let server = FakeHttp::start(routes); let github = local_source(&tmp, &server); let current = Version::parse("0.5.0").expect("版本号合法"); diff --git a/crates/core/src/update/tests/job_cases.rs b/crates/core/src/update/tests/job_cases.rs index 2788dae..68dc5ac 100644 --- a/crates/core/src/update/tests/job_cases.rs +++ b/crates/core/src/update/tests/job_cases.rs @@ -22,17 +22,16 @@ fn target() -> Target { /// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。 fn fixture(format: &str, version: &str) -> (TempDir, Job) { let dir = TempDir::new("job"); - dir.write( + dir.write_signed( "stable.json", - manifest( + &manifest( "stable", &[release( version, "", &[artifact("win-x64", format, "asset.bin", PAYLOAD)], )], - ) - .as_bytes(), + ), ); dir.write("asset.bin", PAYLOAD); let dest = dir.join("bin/unlucky-launcher.exe"); @@ -119,17 +118,16 @@ fn a_portable_exe_runs_the_whole_chain() { #[test] fn nothing_to_apply_does_not_touch_the_target() { let dir = TempDir::new("job-none"); - dir.write( + dir.write_signed( "stable.json", - manifest( + &manifest( "stable", &[release( "0.2.2", "", &[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)], )], - ) - .as_bytes(), + ), ); let dest = dir.join("bin/unlucky-launcher.exe"); std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录"); diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index 2e0bf78..bdb4ad6 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -20,12 +20,14 @@ mod channel_cases; mod checker_cases; +mod ed25519_cases; mod github_cases; mod install_cases; mod install_io_cases; mod install_xdev_cases; mod job_cases; mod manifest_cases; +mod signature_cases; mod source_cases; mod support; mod target_cases; diff --git a/crates/core/src/update/tests/signature_cases.rs b/crates/core/src/update/tests/signature_cases.rs new file mode 100644 index 0000000..6b60c98 --- /dev/null +++ b/crates/core/src/update/tests/signature_cases.rs @@ -0,0 +1,303 @@ +//! 清单签名:**先验签再解析**(M190)。 +//! +//! 这一组用例是 mission 守卫里「更新侧」的那几条,每条都能单独变红: +//! +//! | 用例 | 红掉意味着什么 | +//! |---|---| +//! | `tampered_field_is_rejected` | 改清单里任意一个字段仍能装 → 签名判据被绕过 | +//! | `tampered_signature_only_is_rejected` | 只改签名文件、内容不动仍能装 → 「只改签名不换内容」没被拒 | +//! | `missing_signature_is_rejected` | 没有 `.sig` 也照装 → 发布侧漏签名即可绕过整套信任根 | +//! | `unknown_key_is_rejected` | 别人用自己的钥签的清单被认 → 信任锚不是钉死的那把 | +//! | `key_id_spoofing_does_not_help` | 按 `key_id` 名字信任而不是按公钥字节验 → 伪个名字就能签 | +//! | `unsigned_install_never_touches_the_target` | 验签失败却已经动过目标 exe → 判据跑在安装之后(顺序错) | +//! | `no_runtime_keyring_override` | 环境变量/旁边的 keyring 文件能换信任锚 → 钉在二进制里的公钥白钉 | +//! +//! 还有一条「两条判据互不替代」:`sha256_mismatch_is_reported_separately` 证明签名过了、包体摘要 +//! 不符时仍是 `DigestMismatch`——签名管「清单是不是我们发的」,sha256 管「包体和清单对不对得上」。 + +use std::fs; + +use crate::update::ed25519::public_key_from_seed_for_tests; +use crate::update::{ + trust, Channel, DirSource, Job, OsFamily, Target, UpdateChecker, UpdateErrorKind, UpdateSource, + Version, +}; + +use super::support::{ + artifact, hex_of, manifest, release, signature_entry, signature_entry_from_bytes, + signature_envelope, signature_envelope_of, signature_envelope_with, TempDir, TEST_KEY_ID, + TEST_SIGNING_SEED, +}; + +const PAYLOAD: &[u8] = b"signed release payload"; + +/// 另一把「攻击者的钥」:种子换一个词,公钥就完全不同。 +const ATTACKER_SEED: &[u8; 32] = b"ull-m190-attacker-seed-000000001"; + +fn target() -> Target { + Target::new(OsFamily::Windows, crate::update::Arch::X64) +} + +fn stable_json(version: &str) -> String { + manifest( + "stable", + &[release( + version, + "", + &[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)], + )], + ) +} + +fn current() -> Version { + Version::parse("0.2.0").expect("版本号合法") +} + +#[test] +fn pinned_test_key_matches_the_test_seed() { + // 信任表里那把测试钥必须就是测试种子推出来的那把。漂了的话「钉住的公钥」与「签名的钥」不是同一个, + // 下面所有正路用例会一起变红——先在这里单独钉死。 + let derived = hex_of(&public_key_from_seed_for_tests(TEST_SIGNING_SEED)); + let pinned = trust::keyring() + .iter() + .find(|key| key.key_id == TEST_KEY_ID) + .expect("测试构建的信任表里必须有测试钥"); + assert_eq!( + pinned.public_key_hex, derived, + "钉住的测试公钥与测试种子不匹配" + ); +} + +#[test] +fn signed_manifest_is_accepted_and_picks_the_candidate() { + let dir = TempDir::new("sig-ok"); + dir.write_signed("stable.json", &stable_json("0.3.0")); + let outcome = UpdateChecker::new(DirSource::new(dir.path()), target()) + .check(¤t(), Channel::Stable) + .expect("签名对得上就不该报错"); + assert_eq!( + outcome.candidate().map(|c| c.release.version.to_string()), + Some("0.3.0".to_string()) + ); +} + +#[test] +fn tampered_field_is_rejected() { + // 守卫①:清单里改一个字段(版本号多一个数字),签名不跟着换 ⇒ 必须拒。 + let original = stable_json("0.3.0"); + let tampered = stable_json("0.3.1"); + assert_ne!(original, tampered, "两次生成的清单必须真的不同"); + + let dir = TempDir::new("sig-tamper-field"); + dir.write("stable.json", tampered.as_bytes()); + // 签名还是**原始**那份的签名。 + dir.write("stable.json.sig", signature_envelope(&original).as_bytes()); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("改过字段的清单必须验不过"); + assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); + assert!( + error.message.contains("sha256"), + "要说清「签名声明的是哪一份、手上是哪一份」(实际:{})", + error.message + ); +} + +#[test] +fn tampered_signature_only_is_rejected() { + // 守卫①的另一半:清单字节一个不动,只把签名的最后一个字节改掉。 + let json = stable_json("0.3.0"); + let mut signature = crate::update::ed25519::sign_for_tests(TEST_SIGNING_SEED, json.as_bytes()); + let good = signature_envelope(&json); + signature[63] ^= 0x04; + let broken = + signature_envelope_of(&[signature_entry_from_bytes(TEST_KEY_ID, &signature, &json)]); + assert_ne!(good, broken, "两份信封必须真的不同"); + + let dir = TempDir::new("sig-tamper-sig"); + dir.write("stable.json", json.as_bytes()); + dir.write("stable.json.sig", broken.as_bytes()); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("只改签名也必须被拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); +} + +#[test] +fn missing_signature_is_rejected() { + let dir = TempDir::new("sig-missing"); + dir.write("stable.json", stable_json("0.3.0").as_bytes()); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("没有签名文件必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMissing); + assert!(!error.is_retryable(), "漏签名不是抖动,重试没有意义"); + assert!(error.kind.is_signature(), "壳层据此换文案"); +} + +#[test] +fn empty_signature_array_is_malformed() { + let dir = TempDir::new("sig-empty-array"); + dir.write("stable.json", stable_json("0.3.0").as_bytes()); + dir.write("stable.json.sig", br#"{"format":1,"signatures":[]}"#); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("空签名数组必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); +} + +#[test] +fn unknown_field_in_envelope_is_malformed() { + // `deny_unknown_fields`:发布侧拼错字段名要在 CI/发布侧当场炸,而不是变成用户机器上的「验签失败」。 + let json = stable_json("0.3.0"); + let dir = TempDir::new("sig-unknown-field"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + br#"{"format":1,"signatures":[],"comment":"hi"}"#, + ); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("未知字段必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); +} + +#[test] +fn unknown_key_is_rejected() { + // 攻击者用自己那把钥签了一份**完全合法**的清单:字节没问题,只是钥不在钉住的表里。 + let json = stable_json("9.9.9"); + let dir = TempDir::new("sig-unknown-key"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), + ); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("不认识的钥必须拒"); + assert_eq!(error.kind, UpdateErrorKind::SignatureUnknownKey); + assert!( + error.message.contains("attacker-key"), + "要说出是谁签的(实际:{})", + error.message + ); +} + +#[test] +fn key_id_spoofing_does_not_help() { + // 攻击者把自己那把钥的 key_id 伪造成钉住的那个:查表命中,但**公钥字节**对不上 ⇒ 仍拒。 + // 这条就是「公钥钉在二进制里,不是按名字信任」的直接证据。 + let json = stable_json("9.9.9"); + let dir = TempDir::new("sig-key-id-spoof"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + signature_envelope_with(ATTACKER_SEED, TEST_KEY_ID, &json).as_bytes(), + ); + let error = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect_err("key_id 撞名不能替代公钥"); + assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); +} + +#[test] +fn multi_signature_accepts_any_pinned_key() { + // 轮换窗口:一份清单同时带「钉住的旧钥」与「本构建不认识的新钥」的签名,只要有一条能过就放行。 + let json = stable_json("0.3.0"); + let dir = TempDir::new("sig-multi"); + dir.write("stable.json", json.as_bytes()); + let merged = signature_envelope_of(&[ + signature_entry(ATTACKER_SEED, "someone-else", &json), + signature_entry(TEST_SIGNING_SEED, TEST_KEY_ID, &json), + ]); + dir.write("stable.json.sig", merged.as_bytes()); + let manifest = DirSource::new(dir.path()) + .fetch_manifest(Channel::Stable) + .expect("有一条能过就放行"); + assert!(manifest.is_some()); +} + +#[test] +fn unsigned_install_never_touches_the_target() { + // 顺序证据:验签在**取包之前**,因此目标 exe 与暂存目录一个字节都没动过。 + let dir = TempDir::new("sig-apply-order"); + dir.write("stable.json", stable_json("0.3.0").as_bytes()); + dir.write("asset.bin", PAYLOAD); + let dest = dir.join("bin/unlucky-launcher.exe"); + fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录"); + fs::write(&dest, b"old-bytes").expect("写旧 exe"); + + let job = Job::new( + Box::new(DirSource::new(dir.path())), + target(), + dir.join("staging"), + dest.clone(), + ); + let error = job + .apply(¤t(), Channel::Stable) + .expect_err("没有签名不许 apply"); + assert_eq!(error.kind, UpdateErrorKind::SignatureMissing); + assert_eq!( + fs::read(&dest).expect("目标仍在"), + b"old-bytes", + "目标 exe 不许被动过" + ); + let staging_empty = match fs::read_dir(dir.join("staging")) { + Ok(mut entries) => entries.next().is_none(), + Err(_) => true, + }; + assert!(staging_empty, "暂存目录不该留下任何东西(验签在取包之前)"); +} + +#[test] +fn sha256_mismatch_is_reported_separately() { + // 两条判据互不替代:签名对(内容就是发布侧签的那份),但包体摘要对不上 ⇒ DigestMismatch。 + let dir = TempDir::new("sig-vs-digest"); + dir.write_signed("stable.json", &stable_json("0.3.0")); + // 等长但不同内容:先过长度判据,才走得到摘要判据(顺序在 verify.rs 里是有意的)。 + dir.write("asset.bin", &[b'X'; PAYLOAD.len()]); + let job = Job::new( + Box::new(DirSource::new(dir.path())), + target(), + dir.join("staging"), + dir.join("bin/unlucky-launcher.exe"), + ); + let error = job + .apply(¤t(), Channel::Stable) + .expect_err("摘要不符必须拒"); + assert_eq!(error.kind, UpdateErrorKind::DigestMismatch); + assert!(!error.kind.is_signature(), "这是摘要判据,不是签名判据"); +} + +#[test] +fn no_runtime_keyring_override() { + // 守卫②:信任锚是编译期常量。运行期旁边放一份「信任表」、或设环境变量,都不该有任何作用 + // (现在压根没有这种口子;将来谁加了,这条会红)。 + let json = stable_json("9.9.9"); + let dir = TempDir::new("sig-no-override"); + dir.write("stable.json", json.as_bytes()); + dir.write( + "stable.json.sig", + signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), + ); + let attacker_public = hex_of(&public_key_from_seed_for_tests(ATTACKER_SEED)); + dir.write( + "keyring.json", + format!(r#"{{"keys":[{{"key_id":"attacker-key","public_key_hex":"{attacker_public}"}}]}}"#) + .as_bytes(), + ); + dir.write("attacker.pub", attacker_public.as_bytes()); + let keyring_path = dir.join("keyring.json"); + std::env::set_var("ULL_UPDATE_KEYRING", &keyring_path); + std::env::set_var("ULL_UPDATE_KEYRING_FILE", &keyring_path); + let outcome = DirSource::new(dir.path()).fetch_manifest(Channel::Stable); + std::env::remove_var("ULL_UPDATE_KEYRING"); + std::env::remove_var("ULL_UPDATE_KEYRING_FILE"); + let error = outcome.expect_err("旁边的信任表文件不许生效"); + assert_eq!( + error.kind, + UpdateErrorKind::SignatureUnknownKey, + "改不了信任锚:攻击者的钥仍在表外(实际:{})", + error.message + ); +} diff --git a/crates/core/src/update/tests/source_cases.rs b/crates/core/src/update/tests/source_cases.rs index 67f8477..f31b416 100644 --- a/crates/core/src/update/tests/source_cases.rs +++ b/crates/core/src/update/tests/source_cases.rs @@ -10,7 +10,7 @@ use super::support::{artifact, manifest, release, TempDir}; const PAYLOAD: &[u8] = b"source payload"; fn publish(tmp: &TempDir, channel: Channel, json: &str) { - tmp.write(&format!("{}.json", channel.as_str()), json.as_bytes()); + tmp.write_signed(&format!("{}.json", channel.as_str()), json); } #[test] @@ -66,7 +66,11 @@ fn broken_manifest_json_is_reported_with_path() { .fetch_manifest(Channel::Stable) .expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); - assert!(error.path.is_some(), "错误应带清单路径"); + assert!( + error.message.contains("来源 file:"), + "错误里要带出来源(本地源的 id 就是根目录),日志才定位得到(实际:{})", + error.message + ); } #[test] diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index a85e8f2..2047197 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -37,6 +37,19 @@ impl TempDir { self.path.join(name) } + /// 写一份清单**连它的签名**(`` + `.sig`),返回清单路径。 + /// + /// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了 + /// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。 + pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> PathBuf { + let path = self.write(name, manifest_text.as_bytes()); + self.write( + &format!("{name}.sig"), + signature_envelope(manifest_text).as_bytes(), + ); + path + } + /// 写一个文件(父目录自动建),返回路径。 pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { let path = self.join(name); @@ -213,6 +226,59 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String { ) } +/// 测试签名钥的 32 字节种子。 +/// +/// **它是公开的,故意不保密**:它只对 `cfg(test)` 与 `update-test-source` 构建有意义 +/// (`trust::TEST_ONLY` 才认这把钥),正式构建的信任表里没有它,所以拿它签不出任何正式版本 +/// 能装的清单。正式私钥只能来自仓库 Secret(见 `packaging/docs/signing.md`)。 +pub(crate) const TEST_SIGNING_SEED: &[u8; 32] = b"ull-m190-test-key-seed-000000001"; + +/// 测试钥的 `key_id`(与 `trust::TEST_ONLY` 里那条一致)。 +pub(crate) const TEST_KEY_ID: &str = "test-only-key"; + +/// 用测试钥签一份清单,返回 `manifest.json.sig` 的信封原文。 +pub(crate) fn signature_envelope(manifest_text: &str) -> String { + signature_envelope_with(TEST_SIGNING_SEED, TEST_KEY_ID, manifest_text) +} + +/// 用指定种子/`key_id` 签一份清单(负向用例要造「不认识的钥」「改过内容的签名」)。 +pub(crate) fn signature_envelope_with( + seed: &[u8; 32], + key_id: &str, + manifest_text: &str, +) -> String { + signature_envelope_of(&[signature_entry(seed, key_id, manifest_text)]) +} + +/// 多签名信封(轮换窗口的夹具:一份清单同时带几把钥的签名)。 +pub(crate) fn signature_envelope_of(entries: &[String]) -> String { + format!(r#"{{"format":1,"signatures":[{}]}}"#, entries.join(",")) +} + +/// 一条签名条目(可以直接被 [`signature_envelope_of`] 拼起来,也可以单独改坏再拼)。 +pub(crate) fn signature_entry(seed: &[u8; 32], key_id: &str, manifest_text: &str) -> String { + let signature = crate::update::ed25519::sign_for_tests(seed, manifest_text.as_bytes()); + signature_entry_from_bytes(key_id, &signature, manifest_text) +} + +/// 用**现成的签名字节**拼一条条目(「只改签名字节」的用例靠它)。 +pub(crate) fn signature_entry_from_bytes( + key_id: &str, + signature: &[u8; 64], + manifest_text: &str, +) -> String { + let sha = crate::update::signature::hex_sha256(manifest_text.as_bytes()); + format!( + r#"{{"alg":"ed25519","key_id":"{key_id}","sig":"{}","manifest_sha256":"{sha}"}}"#, + hex_of(signature) + ) +} + +/// 字节转小写十六进制。 +pub(crate) fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。 /// /// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。 @@ -280,13 +346,9 @@ pub(crate) struct FakeHttp { impl FakeHttp { /// 起服务(`routes` 是「路径 → 应答」表)。 - pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self { + pub(crate) fn start(routes: Vec<(String, FakeResponse)>) -> Self { let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口"); let addr = listener.local_addr().expect("取本地地址"); - let routes: Vec<(String, FakeResponse)> = routes - .into_iter() - .map(|(path, response)| (path.to_string(), response)) - .collect(); let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); diff --git a/crates/core/src/update/trust.rs b/crates/core/src/update/trust.rs new file mode 100644 index 0000000..4ab3af3 --- /dev/null +++ b/crates/core/src/update/trust.rs @@ -0,0 +1,98 @@ +//! 钉进二进制的清单签名信任锚(keyring):**多把**公钥,`key_id` 只是查表用的标签。 +//! +//! # 为什么是一张编译期常量表 +//! +//! 自更新的信任根必须不可被「用户机器上的一枚文件」替换:清单与签名都能被能改 Release 的人一起 +//! 换掉,唯一挡得住的是「客户端**编进二进制**的那几把公钥」。所以这张表是 `const`: +//! +//! * 改仓库里的本文件只会改变**下一次构建**的行为,不会改变任何**已发布二进制**的验签结果; +//! * 没有环境变量、没有设置键、没有「旁边的 keyring.json」这类运行期覆盖口子 +//! (`tests/signature_cases.rs::no_runtime_keyring_override` 守这条)。 +//! +//! # 为什么是「一组」而不是「一把」 +//! +//! 换签名方(例如日后接 SignPath 的托管签名,或旧钥泄露后轮换)时,用户机器上装着的是**旧**二进制, +//! 它只认旧钥。做成数组才有两条路同时成立:新签出的清单可以同时带新旧两把钥的签名(旧二进制认旧的、 +//! 新二进制认新的),以及新二进制可以一把一把地收紧。步骤见 `packaging/docs/signing.md`。 +//! +//! # 唯一的加钥匙纪律:**旧钥签新钥** +//! +//! 往本表里加一把公钥,必须同时有「旧钥对新钥的背书」——即那次发布的清单同时带旧钥签名(见上), +//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表: +//! 那等于让「能改仓库的人」变成「能改信任根的人」。 +//! +//! # 正式构建的表当前是**空的**(fail-closed) +//! +//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有: +//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新, +//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」 +//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。 +//! +//! 录一把真钥的两步(细节见 `packaging/docs/signing.md`): +//! +//! ```text +//! python packaging/sign_manifest.py --generate-key --out-key release.key # ① 生成,私钥写进 Secret +//! # ② 把打印出来的 `PinnedKey { … }` 一行贴到下面 PINNED-KEYS 标记之间 +//! ``` +//! +//! 测试/验收构建另有一把**只在** `cfg(test)` 与 `update-test-source` feature 里存在的测试钥: +//! 它的私钥种子就写在测试夹具里、公开可见,因此它可以签出「本仓库自己认得」的清单,但对任何正式 +//! 构建毫无意义(正式表里没有它)。这条也由 `packaging/check_test_source_off.py` 的同一道闸守着 +//! ——正式发布流水线与打包脚本都不许打开 `update-test-source`。 + +use std::sync::OnceLock; + +/// 一把钉死的公钥。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PinnedKey { + /// 标签(清单签名里的 `key_id` 靠它查表;**改名就是换钥**)。 + pub(crate) key_id: &'static str, + /// Ed25519 公钥的 64 位十六进制小写形态(32 字节)。 + pub(crate) public_key_hex: &'static str, +} + +/// 正式构建的信任锚(发布二进制里就是这一份)。 +/// +/// ── 加钥匙只改下面 `PINNED-KEYS` 标记之间的内容,别改标记本身 ── +/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。 +pub(crate) const PRODUCTION: &[PinnedKey] = &[ + // >>> PINNED-KEYS >>> + // <<< PINNED-KEYS <<< +]; + +/// 测试钥:**只**在 `cfg(test)` 与 `update-test-source` 构建里存在(正式表里没有它)。 +/// +/// 公钥由 `tests/support.rs` 里那串公开可见的 32 字节种子推出来(种子 = `ull-m190-test-key-seed-000000001`), +/// 两者一旦漂开,`tests/signature_cases.rs::pinned_test_key_matches_the_test_seed` 会当场红。 +pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey { + key_id: "test-only-key", + public_key_hex: "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b", +}]; + +/// 本构建实际的信任锚(正式表 + 仅测试构建才有的那把测试钥)。 +pub(crate) fn keyring() -> &'static [PinnedKey] { + static ALL: OnceLock> = OnceLock::new(); + ALL.get_or_init(|| { + let mut keys = PRODUCTION.to_vec(); + if cfg!(any(test, feature = "update-test-source")) { + keys.extend_from_slice(TEST_ONLY); + } + keys + }) +} + +/// 十六进制解码(只收小写/大写十六进制,长度必须是偶数)。 +pub(crate) fn decode_hex(text: &str) -> Option> { + if !text.len().is_multiple_of(2) { + return None; + } + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 2); + let (pairs, _) = bytes.as_chunks::<2>(); + for pair in pairs { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + out.push(u8::try_from(hi * 16 + lo).ok()?); + } + Some(out) +} diff --git a/packaging/README.md b/packaging/README.md index 0d9e29d..8eac8bd 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -146,3 +146,17 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) `ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + `portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。 **不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。 + +## 14. 产物署名与清单签名(M190) + +这一节只放「去哪看」的三句话,手册本体在 **`packaging/docs/signing.md`**: + +- **PE 版本资源**(Windows「属性 → 详细信息」里的发布者/产品/版权/四段式版本):口径唯一来源 + `packaging/pe_version.rs`,被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次; + 判据是**读产物字节**的 `packaging/check_pe_version.py`(CI:`release-build.yml` 的 Windows 槽位)。 +- **清单签名**:`manifest.json.sig` 与 `manifest.json` 并列上传,是分离的 Ed25519 签名文件(签的是清单 + 的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的 + **编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装, + **不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。 +- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen** + ——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。 diff --git a/packaging/check_pe_version.py b/packaging/check_pe_version.py new file mode 100644 index 0000000..09c43d7 --- /dev/null +++ b/packaging/check_pe_version.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Assert the PE **version resource is really inside the artifact** (M190). + +Writing `CompanyName` into `build.rs` proves nothing — this reads the produced `.exe` bytes and +looks for the `VS_VERSIONINFO` structure (`FIXEDFILEINFO.dwSignature = 0xFEEF04BD`, key strings in +UTF-16LE) plus the exact values Windows "Properties → Details" must show: + + CompanyName Unlucky Launcher Project + ProductName Unlucky Launcher + FileDescription Unlucky Launcher + LegalCopyright © 2026 Unlucky Launcher Project · Apache-2.0 + FileVersion + +It is deliberately a *byte* check, not a "did the build script run" check: a resource that got lost +in linking still fails here. `--expect-version` also compares `dwFileVersionMS/LS` with the crate +version, so "version drifted from Cargo.toml" is caught. + + python3 packaging/check_pe_version.py ... --expect-version 0.2.3 + python3 packaging/check_pe_version.py --self-test +""" + +from __future__ import annotations + +import argparse +import struct +import sys +from pathlib import Path + +PE_MAGIC = bytes([0x50, 0x45, 0, 0]) # "PE" + two NUL +FIXED_SIGNATURE = b"\xbd\x04\xef\xfe" # FIXEDFILEINFO.dwSignature = 0xFEEF04BD +PE_VERSION_SOURCE = Path(__file__).resolve().parent / "pe_version.rs" + +# 承诺的署名(与 packaging/pe_version.rs 同一份口径;`--self-test` 解析那个文件对拍)。 +EXPECTED_STRINGS = { + "CompanyName": "Unlucky Launcher Project", + "ProductName": "Unlucky Launcher", + "FileDescription": "Unlucky Launcher", + "LegalCopyright": "© 2026 Unlucky Launcher Project · Apache-2.0", +} + + +def utf16(text: str) -> bytes: + return text.encode("utf-16-le") + + +def four_part(version: str) -> tuple[int, int, int, int]: + """`v0.2.3` / `0.2.3-beta.1` → `(0, 2, 3, 0)`(与 `pe_version.rs::four_part` 同规则)。""" + numeric = version.split("-")[0].split("+")[0].strip().lstrip("v") + parts = [0, 0, 0, 0] + for index, piece in enumerate(numeric.split(".")[:4]): + try: + parts[index] = min(int(piece), 0xFFFF) + except ValueError: + parts[index] = 0 + return (parts[0], parts[1], parts[2], parts[3]) + + +def pe_subsystem(data: bytes) -> int | None: + """可选头里的 `Subsystem`(2 = GUI,3 = 控制台);PE 头不对时返回 None。""" + if len(data) < 0x40 or data[:2] != b"MZ": + return None + offset = struct.unpack_from(" len(data) or data[offset : offset + 4] != PE_MAGIC: + return None + return struct.unpack_from(" list[str]: + """返回没过的判据(空 = 通过)。""" + failures: list[str] = [] + data = path.read_bytes() + if len(data) < 0x100: + return [f"{path}: 文件太小({len(data)} 字节),不像 PE"] + + subsystem = pe_subsystem(data) + if subsystem is None: + return [f"{path}: 不是可解析的 PE(MZ / PE 头不对)"] + + has_resource = FIXED_SIGNATURE in data + if not has_resource: + failures.append(f"{path}: 找不到 FIXEDFILEINFO 签名 0xFEEF04BD —— 这个 exe 里**没有版本资源**") + + for key in ("VS_VERSION_INFO", "StringFileInfo", "VarFileInfo", "Translation"): + if utf16(key) not in data: + failures.append(f"{path}: 版本资源里缺结构键 `{key}`(UTF-16LE 未命中)") + + for field, value in EXPECTED_STRINGS.items(): + if utf16(field) not in data: + failures.append(f"{path}: 缺字段名 `{field}`") + if utf16(value) not in data: + failures.append(f"{path}: 字段 `{field}` 的值不是 `{value}`") + + if utf16(f"{path.stem}.exe") not in data: + failures.append(f"{path}: `OriginalFilename` 不是 `{path.stem}.exe`") + + if expect_version: + parts = four_part(expect_version) + dotted = ".".join(str(part) for part in parts) + if utf16(dotted) not in data: + failures.append(f"{path}: 版本字符串 `{dotted}` 不在资源里(与 Cargo.toml 不同源?)") + index = data.find(FIXED_SIGNATURE) + if index >= 0 and index + 16 <= len(data): + ms, ls = struct.unpack_from("> 16, ms & 0xFFFF, ls >> 16, ls & 0xFFFF) + if actual != parts: + failures.append(f"{path}: 数值版本 {actual} 与期望 {parts} 不一致(dwFileVersion)") + else: + failures.append(f"{path}: FIXEDFILEINFO 之后读不到 dwFileVersion") + + print( + f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)" + f" / 版本资源={'有' if has_resource else '无'}" + ) + return failures + + +def self_test() -> int: + """口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。""" + import os + import tempfile + + failures = 0 + source = PE_VERSION_SOURCE.read_text(encoding="utf-8") + for field, value in ( + ("COMPANY_NAME", EXPECTED_STRINGS["CompanyName"]), + ("PRODUCT_NAME", EXPECTED_STRINGS["ProductName"]), + ("COPYRIGHT", EXPECTED_STRINGS["LegalCopyright"]), + ): + if f'pub const {field}: &str = "{value}";' not in source: + print(f"FAIL: pe_version.rs 里找不到 {field} = {value!r}", file=sys.stderr) + failures += 1 + + for case, expect in (("0.2.3", (0, 2, 3, 0)), ("v1.2.3-beta.4", (1, 2, 3, 0)), ("1.2.3.4", (1, 2, 3, 4))): + if four_part(case) != expect: + print(f"FAIL: four_part({case!r}) = {four_part(case)},期望 {expect}", file=sys.stderr) + failures += 1 + + # 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错 + # (否则这个脚本对任何输入都会「绿」,门禁等于没有)。 + fake = bytearray(0x200) + fake[0:2] = b"MZ" + struct.pack_into(" int: + parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)") + parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe") + parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)") + parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红)") + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + if not args.artifacts: + parser.print_help() + return 2 + + failures: list[str] = [] + for artifact in args.artifacts: + if not artifact.is_file(): + failures.append(f"{artifact}: 文件不存在") + continue + failures.extend(check(artifact, args.expect_version)) + + for line in failures: + print(f"error: {line}", file=sys.stderr) + if failures: + print(f"FAIL:{len(failures)} 条判据没过(版本资源没进产物就是没进,构建日志不算证据)", file=sys.stderr) + return 1 + print(f"ok: {len(args.artifacts)} 个产物的版本资源齐全") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md new file mode 100644 index 0000000..c2d5a7b --- /dev/null +++ b/packaging/docs/signing.md @@ -0,0 +1,137 @@ +# 产物签名与更新验签(M190) + +本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。 +用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。 + +## 0. 先说清这件事**不**解决什么 + +| 有 / 没有 | 状态 | +|---|---| +| 更新器验「清单是不是我们发的」 | **有**(Ed25519 清单签名 + 二进制里钉住的公钥) | +| Windows 上「未知发布者」提示消失 | **没有**。项目自有签名与 Authenticode 是两件事 | +| SmartScreen 信誉 | **没有**。只有买了 OV/EV 证书并真签名才可能逐步积累 | + +> 一句话:**项目自有签名保护的是自更新这条链,不是 Windows 的信任判断。** 用户双击 exe 时看到的 +> 「未知发布者」来自 SmartScreen,与本文件的方案无关;买证书是另一条线(见 M190 报告里的 +> 「后续要用户拍板的两件事」)。 + +## 1. 威胁模型(为什么要签名) + +清单里的 `sha256` 只保证「包体与清单对得上」。**能改 Release 的人可以同时改包和摘要**,所以摘要 +本身不是信任根。签名的对象因此是**清单文件的原始字节**:改了任意一个字段(哪怕只改版本号里一个数字), +签名立刻对不上。 + +``` +发布侧 用户机器 +------ -------- +manifest.json(字节)──签名──> manifest.json.sig + │ │ + └── 挂到同一个 Release ─────────────┤ + ├─ 取清单 + .sig + ├─ 用**编译进二进制**的公钥验签(trust.rs) + ├─ 过了 → 再解析、再比 sha256、再装 + └─ 不过 → 五类签名错误,**什么都不会被安装** +``` + +## 2. 信任根在哪:`crates/core/src/update/trust.rs` + +* 公钥是**编译期常量数组**(`PRODUCTION`),不是用户机器上的文件 —— 没有环境变量、没有设置键、 + 没有「清单旁边的 keyring.json」这类运行期口子(`tests/signature_cases.rs::no_runtime_keyring_override` + 守着这条)。 +* 是**数组**而不是一把:换签名方时新旧钥可以并存(见 §5)。 +* `key_id` 只是查表标签,**判据永远是公钥字节**:把 `key_id` 伪造成钉住的那个也过不了 + (`key_id_spoofing_does_not_help`)。 + +### 空表 = fail-closed + +正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。 +空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。 +**这是有意的**:宁可不出 Release,也不出未签名的 Release。 + +## 3. 一次性录入(只做一次) + +```bash +# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey +python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a +``` + +* 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret: + Settings → Secrets and variables → Actions → New repository secret,名字 + **`ULL_MANIFEST_SIGNING_KEY`**。 +* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `crates/core/src/update/trust.rs` 的 + `>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。 +* 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。 + +### 私钥纪律(违反任何一条都算事故) + +1. **只**存在于仓库 Secret `ULL_MANIFEST_SIGNING_KEY`。本地需要签测试产物时,走 `--key <文件>` + 并把那个文件放在**仓库外**(gitignore 不保险,别放仓库里)。 +2. **不得**出现在日志、产物、提交、issue、聊天记录里。CI 里任何 `echo`/`cat`/`set -x` 都不许碰到它 + (工作流里的签名步骤显式 `set +x` 且只回显**公钥**)。 +3. 一旦怀疑泄露:立即按 §5 轮换,并在下一个 Release 的说明里点名旧 `key_id` 作废。 +4. 私钥文件/Secret 的备份不归仓库管;**丢了 = 旧钥作废**,用户机器上的旧二进制只能靠 + 「先手动装一次新版本」恢复(`trust.rs` 里的旧条目那时才可以删)。 + +## 4. 发布时发生什么(`.github/workflows/release-publish.yml`) + +在 `manifest.json` 生成之后、`gh release create/upload` 之前,多了一步: + +```bash +python3 packaging/sign_manifest.py --self-test # RFC 8032 向量自检 +python3 packaging/sign_manifest.py --check-pinned-keyring # 密钥 ↔ trust.rs 必须对得上 +python3 packaging/sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig +python3 packaging/sign_manifest.py --verify dist/manifest.json dist/manifest.json.sig --public-key +``` + +四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法: + +| 现象 | 原因 | 怎么补 | +|---|---|---| +| `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 | +| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 | +| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 | +| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue | + +`dist/manifest.json.sig` 与 `dist/manifest.json` 一起作为 Release 资产上传(`assets=(...)` 那一行)。 + +## 5. 轮换(换钥 / 旧钥泄露 / 接托管签名) + +**纪律:旧钥签新钥。** 不允许在没有旧钥签名的情况下替换信任表 —— 那等于让「能改仓库的人」变成 +「能改信任根的人」。 + +1. 生成新钥(§3),得到 `key_id = ull-release-2027a`。 +2. `trust.rs` 里**保留**旧条目、**追加**新条目(数组顺序无关;`key_id` 不能重名)。 +3. 在一次发布里,让清单**同时带两把钥的签名**(两条 `signatures`,客户端「任意一条通过即放行」): + 用旧钥签一次、新钥签一次,把两个信封的 `signatures` 数组拼起来放进 `manifest.json.sig`。 + 这一步让「装着旧二进制的用户」和「装着新二进制的用户」都过得去。 +4. 之后至少**一个完整发布周期**保持双签名,再观察下载/更新失败率。 +5. 确认新钥已被广泛接受后,用新钥单独签发下一次清单,并从 `trust.rs` 删掉旧条目 + (删条目的那个版本必须**只**由新钥签)。 +6. changelog 里点名:新增/作废的 `key_id` 与公钥指纹。 + +托管签名(例如 SignPath Foundation)作为**后续可替换路径**:它的产物签名由 CI 侧调用其服务完成, +`trust.rs` 的数组形状不用变(多一条 `PinnedKey` 即可);清单签名这一层保持本项目自有钥不动。 +**不允许**为了接托管签名把判据改成「只认证书」。 + +## 6. 三条守门(都能单独变红) + +| 守门 | 脚本 / 用例 | 变红意味着 | +|---|---|---| +| 版本资源真的在产物里 | `packaging/check_pe_version.py --expect-version <版本>` | 读回产物字节找不到 `VS_VERSIONINFO` / 字段值不对(构建日志绿也没用) | +| 篡改清单 ⇒ 验签失败且不安装 | `cargo test -p unlucky-core --lib update::tests::signature_cases` | 改字段/只改签名/漏签名/陌生钥 任一仍能装 | +| 签名失败阻断发布 | `release-publish.yml` 的签名步 + `--check-pinned-keyring` | 没钥/钥没钉/钥不匹配时仍发出 Release | + +另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读 +`target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。 + +## 7. 本地自测(不需要私钥) + +```bash +python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析 +python3 packaging/check_pe_version.py --self-test # 口径表与 pe_version.rs 对拍 +cargo test -p unlucky-core --lib update::tests::signature_cases +cargo test -p unlucky-core --lib update::tests::ed25519_cases +``` + +带 `update-test-source` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于 +「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。 diff --git a/packaging/pe_version.rs b/packaging/pe_version.rs new file mode 100644 index 0000000..724c554 --- /dev/null +++ b/packaging/pe_version.rs @@ -0,0 +1,101 @@ +// PE 版本资源(VERSIONINFO)的唯一口径:发布者/产品/版权/版本号。 +// +// 这个文件被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次——两个产物 +// (`ull` 与 `unlucky-launcher-iced`)的署名必须逐字相同,复制两份表迟早会漂。 +// +// # 字段口径(M190,塔已拍板) +// +// | 字段 | 值 | 为什么 | +// |---|---|---| +// | `CompanyName` | `Unlucky Launcher Project` | 发布者。**不得出现个人真实姓名**:这个名字随每个下载的 exe 传播,将来买 Authenticode 证书时 SmartScreen 显示的是证书主体,二者要对得上 | +// | `ProductName` / `FileDescription` | `Unlucky Launcher` | Windows「属性 → 详细信息」里最显眼的两行 | +// | `LegalCopyright` | `© 2026 Unlucky Launcher Project · Apache-2.0` | 版权 + 许可(仓库是 Apache-2.0) | +// | `FileVersion` / `ProductVersion` | `CARGO_PKG_VERSION` 的四段式 | 与 `Cargo.toml` **同源**:不另写一份版本号 | +// | `InternalName` / `OriginalFilename` | bin 名 / `.exe` | 供「这个 exe 到底叫什么」的取证 | +// | `Comments` | 仓库地址 | 项目署名 `AskaEth` 的可读落点(GitHub 命名空间,不是个人姓名) | +// +// # 只在 windows 目标上真跑 +// +// 版本资源是 PE 概念:`build.rs` 先看 `CARGO_CFG_TARGET_OS`,不是 `windows` 就整段返回, +// Linux/macOS 构建一个字节都不多编译(它们的发布产物本来也没有 PE 头)。 +// +// # 为什么不用 `windows_subsystem` 之外的资源 +// +// 只要 VERSIONINFO 一块:图标、清单(DPI/权限)另有归属,本 mission 不碰(加了会改变运行时行为)。 +// +// # 守门 +// +// `packaging/check_pe_version.py` 是「写进源码不算,读回产物才算」的那道判据:它直接读 exe 字节, +// 断言 `VS_VERSION_INFO` 结构在、上述字符串(UTF-16LE)在。CI 的构建槽位跑它。 + +/// 发布者(CompanyName)。**改它等于改署名**:同时要改 Authenticode 证书主体与 README 的署名口径。 +pub const COMPANY_NAME: &str = "Unlucky Launcher Project"; + +/// 产品名(`ProductName` 与 `FileDescription`)。 +pub const PRODUCT_NAME: &str = "Unlucky Launcher"; + +/// 版权行(`LegalCopyright`)。 +pub const COPYRIGHT: &str = "© 2026 Unlucky Launcher Project · Apache-2.0"; + +/// 项目署名(GitHub 命名空间;`Comments` 里随仓库地址一起出现)。 +pub const PROJECT_HANDLE: &str = "AskaEth"; + +/// 仓库地址(`Comments`)。 +pub const REPOSITORY: &str = "https://github.com/AskaEth/Unlucky-Launcher"; + +/// 把版本号补成四段式的数值(`0.2.3` → `0.2.3.0`;预发布段丢掉,因为 PE 的数值字段只认数字)。 +/// +/// 返回 `(四段元组, 打包成 FILEVERSION 用的 u64)`;任一数字大于 65535 时按 65535 截断 +/// (PE 的每个分量只有 16 位,真实版本号远小于它)。 +#[must_use] +pub fn four_part(version: &str) -> ([u64; 4], u64) { + // `0.2.3-beta.1` 的数值部分只有前三段;`+build` 元数据同理不计入版本资源。 + let numeric = version.split(['-', '+']).next().unwrap_or(version).trim(); + let mut parts = [0_u64; 4]; + for (index, piece) in numeric.split('.').take(4).enumerate() { + parts[index] = piece.parse::().unwrap_or(0).min(0xFFFF); + } + let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16) | parts[3]; + (parts, packed) +} + +/// 给一个 bin 名(`ull` / `unlucky-launcher-iced`)写出版本资源。 +/// +/// 由两个 crate 的 `build.rs` 调用;非 windows 目标直接返回。 +pub fn emit_pe_version_resource(binary_name: &str) { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); + // 共享口径文件改了也要重编(`include!` 不被 cargo 追踪)。 + println!("cargo:rerun-if-changed=../../packaging/pe_version.rs"); + + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + // Linux/macOS:没有 PE 头,整段不跑(发布产物的形状不变)。 + return; + } + + let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string()); + let (parts, packed) = four_part(&version); + let dotted = format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], parts[3]); + + let mut resource = winresource::WindowsResource::new(); + resource.set("CompanyName", COMPANY_NAME); + resource.set("ProductName", PRODUCT_NAME); + resource.set("FileDescription", PRODUCT_NAME); + resource.set("LegalCopyright", COPYRIGHT); + resource.set("InternalName", binary_name); + resource.set("OriginalFilename", &format!("{binary_name}.exe")); + resource.set("Comments", REPOSITORY); + // 字符串形态(「属性」页显示的)与数值形态(资源管理器/安装器排序用的)都要给。 + resource.set("FileVersion", &dotted); + resource.set("ProductVersion", &dotted); + resource.set_version_info(winresource::VersionInfo::FILEVERSION, packed); + resource.set_version_info(winresource::VersionInfo::PRODUCTVERSION, packed); + + if let Err(error) = resource.compile() { + // 资源编不出来就**中断构建**:宁可不出产物,也不出一个「属性里什么都没有」的 exe + // (那正是 M190 要修的病)。 + panic!("写 PE 版本资源失败({binary_name} {dotted}):{error}"); + } + println!("cargo:warning=PE VERSIONINFO: {binary_name} {dotted} / {COMPANY_NAME}"); + let _ = PROJECT_HANDLE; +} diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py new file mode 100644 index 0000000..5686dc8 --- /dev/null +++ b/packaging/sign_manifest.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Sign / verify the update manifest, and check the pinned keyring (M190). + +Why Ed25519 + a **detached** `.sig`: the manifest's `sha256` only says "the package matches the +manifest"; it says nothing about who wrote the manifest. A signature over the manifest's *exact +bytes* answers that, and a separate file avoids JSON canonicalisation games (any byte change +breaks the signature). + +Key discipline: the **private key** lives only in the repository Secret +`ULL_MANIFEST_SIGNING_KEY` (never in git, logs or artifacts); the **public key** is pinned +*inside the binary* — `crates/core/src/update/trust.rs`, between the `>>> PINNED-KEYS >>>` / +`<<< PINNED-KEYS <<<` markers. `--check-pinned-keyring` fails the release when the secret's key +is not pinned (a release signed with a key clients do not trust is worse than no release). +Manual: `packaging/docs/signing.md`. + +Usage: + python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a + ULL_MANIFEST_SIGNING_KEY='{"key_id":"…","seed_hex":"…"}' python3 packaging/sign_manifest.py --sign dist/manifest.json + python3 packaging/sign_manifest.py --check-pinned-keyring | --self-test +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys +from pathlib import Path + +FORMAT = 1 +ALG = "ed25519" +KEY_ENV = "ULL_MANIFEST_SIGNING_KEY" +SIG_SUFFIX = ".sig" +TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trust.rs" +PIN_BEGIN = ">>> PINNED-KEYS >>>" +PIN_END = "<<< PINNED-KEYS <<<" +KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"') +# Ed25519 (RFC 8032), reference-shaped implementation (same formulas as +# `crates/core/src/update/ed25519/*`). Validated against the RFC 8032 §7.1 vectors in +# `--self-test`; the Rust verifier is validated against the *same* vectors, so the two +# implementations cross-check each other. No third-party dependency: CI needs no pip install. +Q = 2**255 - 19 +L = 2**252 + 27742317777372353535851937790883648493 + +def _inv(x: int) -> int: + return pow(x, Q - 2, Q) + +D = -121665 * _inv(121666) % Q +_D2 = 2 * D % Q +_SQRT_M1 = pow(2, (Q - 1) // 4, Q) + +def _recover_x(y: int, sign: int) -> int | None: + u = (y * y - 1) % Q + v = (D * y * y + 1) % Q + v3 = pow(v, 3, Q) + v7 = pow(v, 7, Q) + x = u * v3 % Q * pow(u * v7 % Q, (Q - 5) // 8, Q) % Q + if (v * x * x - u) % Q != 0: + x = x * _SQRT_M1 % Q + if (v * x * x - u) % Q != 0: + return None + if x % 2 != sign: + x = (Q - x) % Q + return x + +def _decompress(data: bytes) -> tuple[int, int, int, int] | None: + if len(data) != 32: + return None + y = int.from_bytes(data, "little") + sign = (y >> 255) & 1 + y &= (1 << 255) - 1 + if y >= Q: + return None + x = _recover_x(y, sign) + if x is None: + return None + return (x, y, 1, x * y % Q) + +def _add(p: tuple[int, int, int, int], r: tuple[int, int, int, int]): + x1, y1, z1, t1 = p + x2, y2, z2, t2 = r + a = (y1 - x1) * (y2 - x2) % Q + b = (y1 + x1) * (y2 + x2) % Q + c = t1 * _D2 * t2 % Q + d = 2 * z1 * z2 % Q + e, f, g, h = (b - a) % Q, (d - c) % Q, (d + c) % Q, (b + a) % Q + return (e * f % Q, g * h % Q, f * g % Q, e * h % Q) + +def _double(p: tuple[int, int, int, int]): + x1, y1, z1, _ = p + a = x1 * x1 % Q + b = y1 * y1 % Q + c = 2 * z1 * z1 % Q + d = -a % Q + e = ((x1 + y1) * (x1 + y1) - a - b) % Q + g = (d + b) % Q + f = (g - c) % Q + h = (d - b) % Q + return (e * f % Q, g * h % Q, f * g % Q, e * h % Q) + +def _scalarmult(p, e: int): + acc = (0, 1, 1, 0) + for bit in bin(e)[2:] if e else "": + acc = _double(acc) + if bit == "1": + acc = _add(acc, p) + return acc + +def _encode(p) -> bytes: + x, y, z, _ = p + zi = _inv(z) + x, y = x * zi % Q, y * zi % Q + return (y | ((x & 1) << 255)).to_bytes(32, "little") + +def _base(): + encoded = (4 * _inv(5) % Q).to_bytes(32, "little") + return _decompress(encoded) + +def _hint(data: bytes) -> int: + return int.from_bytes(hashlib.sha512(data).digest(), "little") % L + +def public_key(seed: bytes) -> bytes: + h = hashlib.sha512(seed).digest() + a = int.from_bytes(_clamp(bytearray(h[:32])), "little") + return _encode(_scalarmult(_base(), a)) + +def _clamp(scalar: bytearray) -> bytes: + scalar[0] &= 248 + scalar[31] &= 127 + scalar[31] |= 64 + return bytes(scalar) + +def sign(seed: bytes, message: bytes) -> bytes: + if len(seed) != 32: + raise SystemExit("error: Ed25519 种子必须 32 字节") + h = hashlib.sha512(seed).digest() + a = int.from_bytes(_clamp(bytearray(h[:32])), "little") + prefix = h[32:] + r = _hint(prefix + message) + r_encoded = _encode(_scalarmult(_base(), r)) + pk = _encode(_scalarmult(_base(), a)) + k = _hint(r_encoded + pk + message) + s = (r + k * a) % L + return r_encoded + s.to_bytes(32, "little") + +def verify(pk: bytes, message: bytes, signature: bytes) -> bool: + if len(pk) != 32 or len(signature) != 64: + return False + r_bytes, s_bytes = signature[:32], signature[32:] + if int.from_bytes(s_bytes, "little") >= L: + return False + a = _decompress(pk) + r_point = _decompress(r_bytes) + if a is None or r_point is None: + return False + k = _hint(r_bytes + pk + message) + left = _scalarmult(_base(), int.from_bytes(s_bytes, "little")) + return _encode(left) == _encode(_add(r_point, _scalarmult(a, k))) + + + +def load_key(text: str) -> tuple[str, bytes]: + """Parse the secret JSON (`{"key_id":…,"seed_hex":…}`) or a bare 64-char hex seed.""" + text = text.strip() + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + key_id = str(payload.get("key_id", "")).strip() + seed_hex = str(payload.get("seed_hex", "")).strip() + else: + key_id, seed_hex = "", text + if not key_id: + raise SystemExit("error: 密钥缺少 `key_id`(清单签名要靠它查信任表)") + try: + seed = bytes.fromhex(seed_hex) + except ValueError as error: + raise SystemExit(f"error: `seed_hex` 不是十六进制({error})") from error + if len(seed) != 32: + raise SystemExit(f"error: `seed_hex` 必须 32 字节(实际 {len(seed)})") + return key_id, seed + +def key_from_env_or_arg(path: str | None) -> tuple[str, bytes]: + if path: + secret = Path(path).read_text(encoding="utf-8") + else: + secret = os.environ.get(KEY_ENV, "") + if not secret.strip(): + raise SystemExit( + f"error: 没有签名密钥:设环境变量 {KEY_ENV}(内容 = --generate-key 打出来的那一行 JSON)," + "或显式给 --key <文件>。**绝不**把私钥写进仓库/日志/产物。" + ) + return load_key(secret) + +def pinned_keys(trust_text: str) -> list[tuple[str, str]]: + """Extract `(key_id, public_key_hex)` pairs between the PINNED-KEYS markers.""" + begin = trust_text.find(PIN_BEGIN) + end = trust_text.find(PIN_END) + if begin < 0 or end < 0 or end < begin: + raise SystemExit(f"error: {TRUST_RS} 里找不到 {PIN_BEGIN} / {PIN_END} 标记") + return KID_RE.findall(trust_text[begin:end]) + + + +def do_generate_key(key_id: str) -> int: + seed = os.urandom(32) + pk = public_key(seed) + pk_hex = pk.hex() + if not verify(pk, b"self-check", sign(seed, b"self-check")): + print("error: 自己签自己验不过——不要用这把钥", file=sys.stderr) + return 1 + secret = json.dumps({"key_id": key_id, "seed_hex": seed.hex()}, ensure_ascii=False) + print("# ① 私钥(**只**贴进仓库 Secret " + KEY_ENV + ",别写进任何文件/日志/提交):") + print(secret) + print("# ② 公钥(贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间):") + print(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},') + print(f"# 公钥十六进制:{pk_hex}") + return 0 + +def do_sign(manifest: Path, key_path: str | None, out: Path | None, key_id_override: str | None) -> int: + if not manifest.is_file(): + print(f"error: 没有清单文件 {manifest}", file=sys.stderr) + return 1 + raw = manifest.read_bytes() + # 签的必须是**能解析**的清单:签一串垃圾只会把事故推迟到用户机器上。 + try: + parsed = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + print(f"error: {manifest} 不是合法 JSON({error})", file=sys.stderr) + return 1 + if not isinstance(parsed, dict) or "channel" not in parsed or "releases" not in parsed: + print(f"error: {manifest} 不像发布清单(缺 channel/releases)", file=sys.stderr) + return 1 + key_id, seed = key_from_env_or_arg(key_path) + if key_id_override: + key_id = key_id_override + signature = sign(seed, raw) + if not verify(public_key(seed), raw, signature): + print("error: 签出来的签名自己验不过(实现有问题),拒绝落盘", file=sys.stderr) + return 1 + envelope = { + "format": FORMAT, + "signatures": [ + { + "alg": ALG, + "key_id": key_id, + "sig": signature.hex(), + "manifest_sha256": hashlib.sha256(raw).hexdigest(), + } + ], + } + out = out or manifest.with_name(manifest.name + SIG_SUFFIX) + out.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") + print(f"signed: {manifest} → {out}") + print(f" key_id = {key_id}") + print(f" public_key = {public_key(seed).hex()}") + print(f" manifest_sha256 = {envelope['signatures'][0]['manifest_sha256']}") + return 0 + +def do_verify(manifest: Path, sig: Path, public_key_hex: str) -> int: + raw = manifest.read_bytes() + envelope = json.loads(sig.read_text(encoding="utf-8")) + pk = bytes.fromhex(public_key_hex) + for item in envelope.get("signatures", []): + if item.get("alg") != ALG: + continue + if verify(pk, raw, bytes.fromhex(item.get("sig", ""))): + print(f"ok: {sig} 里的签名(key_id={item.get('key_id')})通过") + return 0 + print("error: 没有任何一条签名能验过", file=sys.stderr) + return 1 + +def do_check_pinned(key_path: str | None) -> int: + key_id, seed = key_from_env_or_arg(key_path) + pk_hex = public_key(seed).hex() + keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8")) + if not keys: + # 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。 + print("error: 信任表是空的(fail-closed):客户端一把公钥都没有,任何清单都验不过,所以拒绝发布。", file=sys.stderr) + print(f"error: 本次签名钥 = key_id {key_id} / 公钥 {pk_hex}", file=sys.stderr) + print("error: 补法:把这行贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr) + print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', file=sys.stderr) + print("error: 手册:packaging/docs/signing.md §3", file=sys.stderr) + return 1 + for pinned_id, pinned_hex in keys: + if pinned_hex.lower() == pk_hex: + print(f"ok: 签名钥 {key_id} 已被钉住(trust.rs 里叫 {pinned_id})") + if pinned_id != key_id: + print( + f"warning: 同一个公钥在 trust.rs 里叫 {pinned_id}、密钥文件里叫 {key_id};" + "清单会带后者,客户端按**公钥字节**验签,所以两种写法都能过(但建议改名对齐)", + file=sys.stderr, + ) + return 0 + pinned_ids = [k[0] for k in keys] + print( + f"error: 签名钥(key_id={key_id} / 公钥={pk_hex})不在信任表里 —— 签出来的清单没有任何客户端会认,所以拒绝发布。", + file=sys.stderr, + ) + print(f"error: trust.rs 现在钉的是:{pinned_ids}", file=sys.stderr) + for pinned_id, pinned_hex in keys: + print(f"error: {pinned_id} = {pinned_hex}", file=sys.stderr) + print("error: 二选一:", file=sys.stderr) + print("error: ① 用已钉住的那把钥签(换回对应的 Secret);或", file=sys.stderr) + print( + "error: ② 换钥:把上面的公钥按「旧钥签新钥」贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 之间," + "并让本次清单同时带旧钥签名", + file=sys.stderr, + ) + print("error: 手册:packaging/docs/signing.md §3/§5", file=sys.stderr) + return 1 + +def do_self_test() -> int: + vectors = [ + ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"), + ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"), + ("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"), + ] + failures = 0 + for index, (seed_hex, pk_hex, message_hex, sig_hex) in enumerate(vectors, 1): + seed, pk, message = bytes.fromhex(seed_hex), bytes.fromhex(pk_hex), bytes.fromhex(message_hex) + if public_key(seed).hex() != pk_hex: + print(f"FAIL vector {index}: public key mismatch", file=sys.stderr) + failures += 1 + if sign(seed, message).hex() != sig_hex: + print(f"FAIL vector {index}: signature mismatch", file=sys.stderr) + failures += 1 + if not verify(pk, message, bytes.fromhex(sig_hex)): + print(f"FAIL vector {index}: verify rejected", file=sys.stderr) + failures += 1 + tampered = bytearray(message) + tampered.append(0) + if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)): + print(f"FAIL vector {index}: tampered message still verifies", file=sys.stderr) + failures += 1 + # 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。 + sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}' + if pinned_keys(sample) != [("k1", "ab" * 32)]: + print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr) + failures += 1 + if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []: + print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr) + failures += 1 + print("self-test:", "ok" if failures == 0 else f"{failures} 处失败") + return 0 if failures == 0 else 1 + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="更新清单的 Ed25519 签名/验签与信任表检查(M190)") + parser.add_argument("--generate-key", action="store_true", help="生成一把新钥(打印 Secret 与 PinnedKey 片段)") + parser.add_argument("--key-id", default="ull-release", help="生成/签名时用的 key_id") + parser.add_argument("--sign", metavar="MANIFEST", type=Path, help="签这份清单(产出 <清单>.sig)") + parser.add_argument("--out", metavar="SIG", type=Path, help="签名输出路径(默认 <清单>.sig)") + parser.add_argument("--key", metavar="FILE", help=f"密钥文件(不给则读环境变量 {KEY_ENV})") + parser.add_argument("--verify", nargs=2, metavar=("MANIFEST", "SIG"), help="手工验一份签名") + parser.add_argument("--public-key", metavar="HEX", help="--verify 用的公钥") + parser.add_argument("--check-pinned-keyring", action="store_true", help="检查密钥是否已被钉进 trust.rs") + parser.add_argument("--self-test", action="store_true", help="跑 RFC 8032 向量与解析器自检") + args = parser.parse_args(argv) + + if args.self_test: + return do_self_test() + if args.generate_key: + return do_generate_key(args.key_id) + if args.sign: + return do_sign(args.sign, args.key, args.out, args.key_id if args.key_id != "ull-release" else None) + if args.verify: + if not args.public_key: + print("error: --verify 需要 --public-key", file=sys.stderr) + return 1 + return do_verify(Path(args.verify[0]), Path(args.verify[1]), args.public_key) + if args.check_pinned_keyring: + return do_check_pinned(args.key) + parser.print_help() + return 2 + +if __name__ == "__main__": + raise SystemExit(main()) From 918d8266454a2041a65cb72bc8cc61ee70f549be Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 02:14:32 +0800 Subject: [PATCH 22/33] =?UTF-8?q?fix(m190):=20r1=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E2=80=94=E2=80=940x01=20=E4=BF=AE=E6=88=90=20\1=20+=20YAML=20?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E3=80=81=E8=A2=AB=E8=B0=83=E6=96=B9=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=20secret=E3=80=81=E6=8B=86=E4=B8=A4=E4=B8=AA=E8=B6=85?= =?UTF-8?q?=E9=99=90=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E3=80=81=E9=AA=8C?= =?UTF-8?q?=E7=AD=BE=E6=8D=A2=20ed25519-dalek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四条 P1 + 四条 P2 一并处置(不变的是判据,变的是「谁在承重」)。 P1-1 `.github/workflows/release-build.yml` 的 sed 替换串是裸 0x01(本意 `\1`):GitHub 解析该文件会 失败 ⇒ 整个 run 起不来;即便容忍,版本号也会被求值成单字节,PE 判据报「与 Cargo.toml 不同源?」把 排障引向错方向。修成字面 `\1`,并新增 `packaging/check_workflows_yaml.py`(真 YAML 解析器 parse 全部 workflow + 裸 C0 控制字符字节判据;带 --self-test 注入 0x01/坏结构断言必红),接进 `release-guard.yml` 两步。文本扫描型守卫看不见这类字节,这道门禁专门挡它。 P1-2 `release-publish.yml` 没在 `on.workflow_call.secrets` 里声明 `ULL_MANIFEST_SIGNING_KEY`,调用方 却传了:可复用工作流只拿得到自己声明过的 secret ⇒ 用户配好钥也会在签名步红,而错误文案反过来指控 Secret 没配。补声明(required: false,fail-closed 由步内 ::error:: + exit 1 承担,同 ULL_MSA_CLIENT_ID 的模式)。 P1-3 两个测试支撑文件越过 400 行守卫:签名夹具拆到 `tests/support_sign.rs`(support.rs 458→392), CLI e2e 的签名部分拆到 `support/update_e2e_sign.rs`(update_e2e.rs 406→348)。纯搬家。 P2-2 「唯一收口点」改成结构事实:从 `UpdateSource` 删掉 `fetch_manifest` 默认实现,调用方只能拿 `verified_manifest(&source, channel)`(源只交原文,想绕过验签就得自己重写解析与渠道判据)。 P2-3 文档写的第 ④ 条命令补进流水线:`sign_manifest.py --verify-pinned` 用 trust.rs 里钉住的公钥 回验刚落盘的 .sig(不碰私钥),发布侧因此验证的是「客户端的判据」而不是「签名脚本的自我声明」。 P2-4 随自写实现一起删除(`Fe` 的错误 PartialEq 不再存在)。 P2-5 app-iced/build.rs 注释里那个 windows_subsystem 属性本分支不存在(M196 才有),改为说明 「本文件只写版本资源,子系统由 rustc 属性决定,check_pe_version.py 会把 subsystem 打出来」。 P2-6 坏清单不再带 error.path 是有意降级(来源 id 在 message 里),保持。 裁定项:自写 Ed25519 换成 `ed25519-dalek::verify_strict`(信任锚不许自写密码学;此刻信任表还是空表, 零兼容负担)。删除 `update/ed25519/{field,group,mod}.rs`(含只服务它们的单测),新 `update/ed25519.rs` 只剩一个薄封装;拒绝类用例(可锻 S+L / 小阶公钥 / 非规范 R / 空签名 / 未知 key)**一条没删**, 改成钉 dalek 的 strict 语义——实测 dalek 对全部五类的结论与旧实现一致(非规范 R 的拒绝理由不同: dalek 走「小阶点」而非「编码非规范」,用例注释里写明)。签名侧留在 `packaging/ed25519_ref.py` (纯 Python、零 pip 依赖),`--self-test` 用 RFC 8032 同一组向量钉两边。 --- .github/workflows/release-build.yml | 2 +- .github/workflows/release-guard.yml | 9 + .github/workflows/release-publish.yml | 11 + Cargo.lock | 118 ++++++++ Cargo.toml | 7 + crates/app-iced/build.rs | 5 +- crates/cli/tests/support/mod.rs | 1 + crates/cli/tests/support/update_e2e.rs | 60 +--- crates/cli/tests/support/update_e2e_sign.rs | 69 +++++ crates/core/Cargo.toml | 2 + crates/core/src/update/checker.rs | 2 +- crates/core/src/update/ed25519.rs | 50 ++++ crates/core/src/update/ed25519/field.rs | 247 ---------------- crates/core/src/update/ed25519/group.rs | 168 ----------- crates/core/src/update/ed25519/mod.rs | 265 ------------------ crates/core/src/update/mod.rs | 2 +- crates/core/src/update/source.rs | 16 +- crates/core/src/update/tests/ed25519_cases.rs | 181 +++++++----- crates/core/src/update/tests/github_cases.rs | 34 +-- crates/core/src/update/tests/mod.rs | 1 + .../core/src/update/tests/signature_cases.rs | 47 ++-- crates/core/src/update/tests/source_cases.rs | 15 +- crates/core/src/update/tests/support.rs | 66 ----- crates/core/src/update/tests/support_sign.rs | 94 +++++++ packaging/check_workflows_yaml.py | 143 ++++++++++ packaging/docs/signing.md | 25 +- packaging/ed25519_ref.py | 142 ++++++++++ packaging/sign_manifest.py | 163 +++-------- 28 files changed, 860 insertions(+), 1085 deletions(-) create mode 100644 crates/cli/tests/support/update_e2e_sign.rs create mode 100644 crates/core/src/update/ed25519.rs delete mode 100644 crates/core/src/update/ed25519/field.rs delete mode 100644 crates/core/src/update/ed25519/group.rs delete mode 100644 crates/core/src/update/ed25519/mod.rs create mode 100644 crates/core/src/update/tests/support_sign.rs create mode 100644 packaging/check_workflows_yaml.py create mode 100644 packaging/ed25519_ref.py diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 9993e80..aba0c3f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -227,7 +227,7 @@ jobs: PY=$(command -v python3 || command -v python) if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi # 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。 - version=$(sed -n 's/^version = "\(.*\)"//p' Cargo.toml | head -n1) + version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) echo "expect-version=$version" "$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version" diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index 9d2e8ea..af53ab6 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -75,3 +75,12 @@ jobs: run: python3 packaging/sign_manifest.py --self-test - name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红) run: python3 packaging/check_pe_version.py --self-test + # M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01 + # (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫 + # 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow, + # 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。 + # PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。 + - name: workflow YAML 自检(注入 0x01 / 坏结构必须红) + run: python3 packaging/check_workflows_yaml.py --self-test + - name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符) + run: python3 packaging/check_workflows_yaml.py diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index f859e6a..7585c2f 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -27,6 +27,14 @@ on: PUBLISH_TOKEN: description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)" required: true + # (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方 + # `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串—— + # 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。 + # `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed + # (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。 + ULL_MANIFEST_SIGNING_KEY: + description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release" + required: false # 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。 permissions: @@ -155,6 +163,9 @@ jobs: fi "$PY" packaging/sign_manifest.py --check-pinned-keyring "$PY" packaging/sign_manifest.py --sign dist/manifest.json + # ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本 + # 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。 + "$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json ls -l dist/manifest.json dist/manifest.json.sig - name: 生成 Release 说明(支持矩阵模板) diff --git a/Cargo.lock b/Cargo.lock index f0332a1..a16ef25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -660,6 +666,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.11.0" @@ -846,6 +858,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.24.1" @@ -892,6 +931,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -977,6 +1026,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1094,6 +1167,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -3087,6 +3166,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3332,6 +3421,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -4197,6 +4295,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4397,6 +4504,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sse-stream" version = "0.2.6" @@ -5056,6 +5173,7 @@ dependencies = [ name = "unlucky-core" version = "0.2.3" dependencies = [ + "ed25519-dalek", "flate2", "futures-util", "md-5", diff --git a/Cargo.toml b/Cargo.toml index c85314a..a7f49be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,13 @@ md-5 = "0.10" # 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } +# M190(core::update::ed25519):清单签名**验签**用官方实现,不再自写曲线算术。 +# M190 r1 复审的裁定:信任锚不许用自写密码学(非规范编码/小阶点/批量验证差异的后果是 +# 「验签通过但本不该通过」)。BSD-3-Clause,在 deny.toml 白名单内。 +# 只开 `std`(不用 `rand_core`/`batch`/`fast` 这类可选面):本仓的签名侧在 +# `packaging/sign_manifest.py`,二进制里只有「验」这一半。 +ed25519-dalek = { version = "2", default-features = false, features = ["std"] } + # M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`, # 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 flate2 = "1" diff --git a/crates/app-iced/build.rs b/crates/app-iced/build.rs index 466b360..30cd908 100644 --- a/crates/app-iced/build.rs +++ b/crates/app-iced/build.rs @@ -3,8 +3,9 @@ //! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的 //! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。 //! -//! ⚠ 本文件**不**碰 `windows_subsystem`:GUI 子系统由 `src/main.rs` 的 -//! `#![cfg_attr(windows, windows_subsystem = "windows")]` 决定,版本资源与子系统是两件事。 +//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的 +//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。 +//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。 include!("../../packaging/pe_version.rs"); diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index f2596da..aa0befa 100644 --- a/crates/cli/tests/support/mod.rs +++ b/crates/cli/tests/support/mod.rs @@ -4,6 +4,7 @@ #![allow(dead_code)] pub mod update_e2e; +pub mod update_e2e_sign; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs index 3fbf68f..f46af21 100644 --- a/crates/cli/tests/support/update_e2e.rs +++ b/crates/cli/tests/support/update_e2e.rs @@ -27,6 +27,7 @@ use std::path::{Path, PathBuf}; +use super::update_e2e_sign::write_signature; use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV}; /// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。 @@ -81,65 +82,6 @@ fn asset_name(version: &str) -> String { ) } -/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥, -/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。 -const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001"; -/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。 -const TEST_KEY_ID: &str = "test-only-key"; - -/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。 -/// -/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒ -/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带 -/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 -fn write_signature(manifest_path: &Path) { - let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py"); - assert!(script.is_file(), "找不到签名脚本:{}", script.display()); - let seed_hex: String = TEST_KEY_SEED - .as_bytes() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#); - let mut command = None; - for candidate in ["python3", "python"] { - if std::process::Command::new(candidate) - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()) - { - command = Some(candidate); - break; - } - } - let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}")); - let output = std::process::Command::new(python) - .arg(&script) - .arg("--sign") - .arg(manifest_path) - .env("ULL_MANIFEST_SIGNING_KEY", &secret) - .output() - .expect("跑签名脚本"); - assert!( - output.status.success(), - "签名脚本失败:stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - manifest_path - .with_file_name(format!( - "{}.sig", - manifest_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default() - )) - .is_file(), - "签名脚本没产出 .sig" - ); -} - /// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。 /// /// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条 diff --git a/crates/cli/tests/support/update_e2e_sign.rs b/crates/cli/tests/support/update_e2e_sign.rs new file mode 100644 index 0000000..5b899fa --- /dev/null +++ b/crates/cli/tests/support/update_e2e_sign.rs @@ -0,0 +1,69 @@ +//! 更新 e2e 夹具的**签名**部分(M190):用 `packaging/sign_manifest.py` 给清单签 `.sig`。 +//! +//! 为什么单独一个文件:`support/update_e2e.rs` 已经贴着全仓 400 行红线(M190 r1 复审 P1-3), +//! 而签名这一摊只被 `Fixture::publish` 用到。搬出来是纯搬家,语义未改。 +//! +//! 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒ +//! 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带 +//! python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 + +use std::path::Path; + +/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥, +/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。 +const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001"; +/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。 +const TEST_KEY_ID: &str = "test-only-key"; + +/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。 +/// +/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒ +/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带 +/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。 +pub fn write_signature(manifest_path: &Path) { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py"); + assert!(script.is_file(), "找不到签名脚本:{}", script.display()); + let seed_hex: String = TEST_KEY_SEED + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#); + let mut command = None; + for candidate in ["python3", "python"] { + if std::process::Command::new(candidate) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + { + command = Some(candidate); + break; + } + } + let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}")); + let output = std::process::Command::new(python) + .arg(&script) + .arg("--sign") + .arg(manifest_path) + .env("ULL_MANIFEST_SIGNING_KEY", &secret) + .output() + .expect("跑签名脚本"); + assert!( + output.status.success(), + "签名脚本失败:stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + manifest_path + .with_file_name(format!( + "{}.sig", + manifest_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + )) + .is_file(), + "签名脚本没产出 .sig" + ); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 98838d4..d11d56a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -26,6 +26,8 @@ futures-util.workspace = true sha1.workspace = true sha2.workspace = true zip.workspace = true +# M190(core::update::ed25519):清单签名验签(官方实现,见 workspace 依赖处的取舍说明)。 +ed25519-dalek.workspace = true # M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。 md-5.workspace = true # M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。 diff --git a/crates/core/src/update/checker.rs b/crates/core/src/update/checker.rs index 56f0d01..a3186de 100644 --- a/crates/core/src/update/checker.rs +++ b/crates/core/src/update/checker.rs @@ -132,7 +132,7 @@ impl UpdateChecker { /// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。 /// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。 pub fn check(&self, current: &Version, channel: Channel) -> Result { - let Some(manifest) = self.source.fetch_manifest(channel)? else { + let Some(manifest) = super::source::verified_manifest(&self.source, channel)? else { return Ok(CheckOutcome::UpToDate { current: current.clone(), latest: None, diff --git a/crates/core/src/update/ed25519.rs b/crates/core/src/update/ed25519.rs new file mode 100644 index 0000000..7758146 --- /dev/null +++ b/crates/core/src/update/ed25519.rs @@ -0,0 +1,50 @@ +//! Ed25519 **严格验签**(`ed25519-dalek::verify_strict`),只验不签、只用公开数据。 +//! +//! # 为什么是官方实现,而不是本仓自写 +//! +//! M190 第一版为了「scope 里不含 `crates/core/Cargo.toml`」把曲线算术写在了仓里 +//! (5x51 素域 + 扩展坐标 + 平方-乘)。r1 复审的裁定是**不允许**:这是**信任锚**, +//! 非规范编码、小阶点、批量验证差异这类微妙错误的后果是「验签通过但本不该通过」, +//! 自写的向量覆盖再全也证明不了「严格性判据选对了」。当时的时机也最好:正式信任表 +//! (`trust::PRODUCTION`)还是空表,换实现**零兼容负担**。 +//! +//! 于是这里只剩一层薄封装,判据全部交给 dalek: +//! +//! * `VerifyingKey::from_bytes` —— 公钥必须是**规范编码的合法点**(非规范 `y` / 不在曲线上都拒); +//! * `VerifyingKey::verify_strict` —— 比 RFC 8032 的最低要求更严:拒绝**小阶**公钥与小阶 `R` +//! (阻止「谁都能构造自洽四元组」的伪造族),并拒绝**非规范标量 `S`**(`S >= L` 的可锻变体); +//! * `Signature::from_bytes` —— 只搬运 64 字节;真正的判据在 `verify_strict` 里,**不能**换成 +//! `verify`(后者不做小阶/可锻检查)。 +//! +//! 这批行为由 `tests/ed25519_cases.rs` 逐条钉住(RFC 8032 四条向量 + 六条「该拒就拒」): +//! 那些用例原来用来测自写实现,现在改成**钉 dalek 的 strict 语义**——将来谁把 +//! `verify_strict` 换成 `verify`(或换回自写实现),它们会当场红。 +//! +//! # 签名侧不在二进制里 +//! +//! 本仓没有任何私钥代码:清单由 `packaging/sign_manifest.py`(纯 Python 参考实现,零第三方依赖) +//! 在发布侧签。测试里的签名夹具走 dalek 的 `SigningKey`,只在 `cfg(test)` 里编译。 + +use ed25519_dalek::{Signature, VerifyingKey}; + +/// 公钥字节数。 +pub(crate) const PUBLIC_KEY_LEN: usize = 32; +/// 签名字节数。 +pub(crate) const SIGNATURE_LEN: usize = 64; + +/// 验一条 Ed25519 签名(**严格**语义,见模块文档)。 +/// +/// `message` 是任意长原文——这里恒为清单文件的**原始字节**(见 [`super::signature`])。 +#[must_use] +pub(crate) fn verify( + public_key: &[u8; PUBLIC_KEY_LEN], + message: &[u8], + signature: &[u8; SIGNATURE_LEN], +) -> bool { + let Ok(key) = VerifyingKey::from_bytes(public_key) else { + // 公钥都不是规范编码的合法点:直接拒(`from_bytes` 已经做了曲线方程与规范编码检查)。 + return false; + }; + key.verify_strict(message, &Signature::from_bytes(signature)) + .is_ok() +} diff --git a/crates/core/src/update/ed25519/field.rs b/crates/core/src/update/ed25519/field.rs deleted file mode 100644 index bc205b6..0000000 --- a/crates/core/src/update/ed25519/field.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! 素域 `GF(2^255 - 19)` 的算术:5 条 51 位肢(radix-2^51)+ 128 位中间积。 -//! -//! 为什么自己写而不是引 `curve25519-dalek`:M190 的 scope 不含 `crates/core/Cargo.toml` -//! (见 `ed25519/mod.rs` 文件头的取舍说明)。这一层只服务**验签**,不接触任何私钥, -//! 也不需要常数时间性质:输入全是公开数据(公钥、签名、清单字节)。 -//! -//! 表示是「宽松的」:`[u64; 5]` 只是数值的内部形态,同一元素有多种写法(肢可以不规范)。 -//! 需要规范字节时一律走 [`Fe::to_bytes`](它先进位、再按 `h >= p` 条件减 `p`)。 - -/// 单肢掩码(51 位)。 -const MASK: u64 = (1 << 51) - 1; - -/// 域元素。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct Fe(pub(crate) [u64; 5]); - -impl Fe { - /// 0。 - pub(crate) const ZERO: Self = Self([0; 5]); - - /// 1。 - pub(crate) const ONE: Self = Self([1, 0, 0, 0, 0]); - - /// 从整数构造(只用于 121665 / 121666 这类小常数)。 - pub(crate) fn from_u64(value: u64) -> Self { - Self([value & MASK, value >> 51, 0, 0, 0]) - } - - /// 读 32 字节小端(不检查是否 `< p`:调用方要自己判定规范性)。 - pub(crate) fn from_bytes(bytes: &[u8; 32]) -> Self { - let load = |offset: usize| -> u64 { - let mut buf = [0_u8; 8]; - buf.copy_from_slice(&bytes[offset..offset + 8]); - u64::from_le_bytes(buf) - }; - // 51 位一段:位 0..51 从字节 0,位 51..102 从字节 6 起右移 3,依此类推。 - Self([ - load(0) & MASK, - (load(6) >> 3) & MASK, - (load(12) >> 6) & MASK, - (load(19) >> 1) & MASK, - (load(24) >> 12) & MASK, - ]) - } - - /// 规范 32 字节小端编码(先完全进位,再按 `>= p` 条件减一次 `p`)。 - pub(crate) fn to_bytes(self) -> [u8; 32] { - let mut h = carry(self.0); - // `q = 1` 当且仅当 `h >= p`:`h + 19` 溢出 2^255 就说明 `h >= 2^255 - 19`。 - let mut q = (h[0] + 19) >> 51; - q = (h[1] + q) >> 51; - q = (h[2] + q) >> 51; - q = (h[3] + q) >> 51; - q = (h[4] + q) >> 51; - h[0] += 19 * q; - h[1] += h[0] >> 51; - h[0] &= MASK; - h[2] += h[1] >> 51; - h[1] &= MASK; - h[3] += h[2] >> 51; - h[2] &= MASK; - h[4] += h[3] >> 51; - h[3] &= MASK; - h[4] &= MASK; - let mut out = [0_u8; 32]; - let mut acc: u128 = 0; - let mut bits = 0_u32; - let mut at = 0_usize; - for limb in h { - acc |= u128::from(limb) << bits; - bits += 51; - while bits >= 8 { - out[at] = (acc & 0xFF) as u8; - acc >>= 8; - bits -= 8; - at += 1; - } - } - while at < 32 { - out[at] = (acc & 0xFF) as u8; - acc >>= 8; - at += 1; - } - out - } - - /// 加法。 - pub(crate) fn add(&self, rhs: &Self) -> Self { - let f = self.0; - let g = rhs.0; - Self(carry([ - f[0] + g[0], - f[1] + g[1], - f[2] + g[2], - f[3] + g[3], - f[4] + g[4], - ])) - } - - /// 减法(加 `2p` 再减,避免借位)。 - pub(crate) fn sub(&self, rhs: &Self) -> Self { - let f = self.0; - let g = rhs.0; - // 2p 的肢:`2*(2^51-19)` 与 `2*(2^51-1)`。 - let two_p0 = 0x000F_FFFF_FFFF_FFDA; - let two_p = 0x000F_FFFF_FFFF_FFFE; - Self(carry([ - f[0] + two_p0 - g[0], - f[1] + two_p - g[1], - f[2] + two_p - g[2], - f[3] + two_p - g[3], - f[4] + two_p - g[4], - ])) - } - - /// 取负。 - pub(crate) fn neg(&self) -> Self { - Self::ZERO.sub(self) - } - - /// 乘法(教科书式 5x5,中间积用 128 位)。 - pub(crate) fn mul(&self, rhs: &Self) -> Self { - let f = self.0; - let g = rhs.0; - let g1 = u128::from(g[1]) * 19; - let g2 = u128::from(g[2]) * 19; - let g3 = u128::from(g[3]) * 19; - let g4 = u128::from(g[4]) * 19; - let f0 = u128::from(f[0]); - let f1 = u128::from(f[1]); - let f2 = u128::from(f[2]); - let f3 = u128::from(f[3]); - let f4 = u128::from(f[4]); - let g0 = u128::from(g[0]); - let mut h0 = f0 * g0 + f1 * g4 + f2 * g3 + f3 * g2 + f4 * g1; - let mut h1 = f0 * u128::from(g[1]) + f1 * g0 + f2 * g4 + f3 * g3 + f4 * g2; - let mut h2 = f0 * u128::from(g[2]) + f1 * u128::from(g[1]) + f2 * g0 + f3 * g4 + f4 * g3; - let mut h3 = f0 * u128::from(g[3]) - + f1 * u128::from(g[2]) - + f2 * u128::from(g[1]) - + f3 * g0 - + f4 * g4; - let mut h4 = f0 * u128::from(g[4]) - + f1 * u128::from(g[3]) - + f2 * u128::from(g[2]) - + f3 * u128::from(g[1]) - + f4 * g0; - let mask128 = u128::from(MASK); - let mut c = h0 >> 51; - h0 &= mask128; - h1 += c; - c = h1 >> 51; - h1 &= mask128; - h2 += c; - c = h2 >> 51; - h2 &= mask128; - h3 += c; - c = h3 >> 51; - h3 &= mask128; - h4 += c; - c = h4 >> 51; - h4 &= mask128; - h0 += c * 19; - c = h0 >> 51; - h0 &= mask128; - h1 += c; - Self([h0 as u64, h1 as u64, h2 as u64, h3 as u64, h4 as u64]) - } - - /// 平方(乘法的特化,只为可读性)。 - pub(crate) fn square(&self) -> Self { - self.mul(self) - } - - /// `self^exponent`,指数以 32 字节小端给出(从最高位往下平方-乘)。 - pub(crate) fn pow(&self, exponent: &[u8; 32]) -> Self { - let mut acc = Self::ONE; - for byte_index in (0..32).rev() { - for bit in (0..8).rev() { - acc = acc.square(); - if (exponent[byte_index] >> bit) & 1 == 1 { - acc = acc.mul(self); - } - } - } - acc - } - - /// 乘法逆元(`p` 是素数,故 `x^(p-2)`)。 - pub(crate) fn invert(&self) -> Self { - // p - 2 = 2^255 - 21 - let mut e = [0xFF_u8; 32]; - e[0] = 0xEB; - e[31] = 0x7F; - self.pow(&e) - } - - /// `x^((p-5)/8)`,开方用(`(p-5)/8 = 2^252 - 3`)。 - pub(crate) fn pow_p58(&self) -> Self { - let mut e = [0xFF_u8; 32]; - e[0] = 0xFD; - e[31] = 0x0F; - self.pow(&e) - } - - /// `sqrt(-1) = 2^((p-1)/4)`(`p ≡ 5 mod 8` 时开方失败要用它翻一次符号)。 - pub(crate) fn sqrt_m1() -> Self { - // (p-1)/4 = 2^253 - 5 - let mut e = [0xFF_u8; 32]; - e[0] = 0xFB; - e[31] = 0x1F; - Self::from_u64(2).pow(&e) - } - - /// 最低位(规范编码的第 0 位);点解压时与签名的符号位比对。 - pub(crate) fn is_odd(&self) -> bool { - self.to_bytes()[0] & 1 == 1 - } - - /// 是否等于 0(按规范编码比)。 - pub(crate) fn is_zero(&self) -> bool { - self.to_bytes() == [0_u8; 32] - } -} - -/// 把肢挽到 51 位以内(高位的溢出折回第 0 肢,因为 `2^255 ≡ 19`)。 -fn carry(mut h: [u64; 5]) -> [u64; 5] { - let mut c = h[0] >> 51; - h[0] &= MASK; - h[1] += c; - c = h[1] >> 51; - h[1] &= MASK; - h[2] += c; - c = h[2] >> 51; - h[2] &= MASK; - h[3] += c; - c = h[3] >> 51; - h[3] &= MASK; - h[4] += c; - c = h[4] >> 51; - h[4] &= MASK; - h[0] += c * 19; - c = h[0] >> 51; - h[0] &= MASK; - h[1] += c; - h -} diff --git a/crates/core/src/update/ed25519/group.rs b/crates/core/src/update/ed25519/group.rs deleted file mode 100644 index 6dbb275..0000000 --- a/crates/core/src/update/ed25519/group.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! 扭曲爱德华兹曲线上的群运算(`a = -1`,扩展坐标 `(X:Y:Z:T)`,`xy = T/Z`)。 -//! -//! 只用两条公式(Hisil-Wong-Carter-Dawson 2008):加法 `add-2008-hwcd-3`、倍点 `dbl-2008-hwcd`, -//! 以及一个最朴素的「从最高位往下的平方-乘」标量乘。验签一次只要两次标量乘,朴素实现足够, -//! 换来的是每一步都能对着公式读——这里宁可慢一点也不要一个看不懂的窗口表。 - -use std::sync::OnceLock; - -use super::field::Fe; - -/// 曲线常数 `d = -121665/121666`。 -fn d() -> &'static Fe { - static D: OnceLock = OnceLock::new(); - D.get_or_init(|| { - Fe::from_u64(121665) - .mul(&Fe::from_u64(121666).invert()) - .neg() - }) -} - -/// `2d`(加法公式里那一步)。 -fn d2() -> &'static Fe { - static D2: OnceLock = OnceLock::new(); - D2.get_or_init(|| d().add(d())) -} - -/// 基点 `B` 的压缩编码(RFC 8032 §5.1 的标准常量,解压即得基点)。 -const BASE_ENCODING: [u8; 32] = [ - 0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, - 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, -]; - -/// 曲线上的点(扩展坐标)。 -#[derive(Clone, Copy, Debug)] -pub(crate) struct Point { - x: Fe, - y: Fe, - z: Fe, - t: Fe, -} - -impl Point { - /// 单位元 `(0, 1, 1, 0)`。 - pub(crate) fn identity() -> Self { - Self { - x: Fe::ZERO, - y: Fe::ONE, - z: Fe::ONE, - t: Fe::ZERO, - } - } - - /// 基点 `B`。 - pub(crate) fn base() -> Self { - // 常量本身是合法压缩点;万一实现坏了这一步会返回 None,用身份元兜底并让上层验签必然失败。 - Self::decompress(&BASE_ENCODING).unwrap_or_else(Self::identity) - } - - /// 点加。 - pub(crate) fn add(&self, rhs: &Self) -> Self { - let a = self.y.sub(&self.x).mul(&rhs.y.sub(&rhs.x)); - let b = self.y.add(&self.x).mul(&rhs.y.add(&rhs.x)); - let c = self.t.mul(&d2().mul(&rhs.t)); - let dd = self.z.mul(&rhs.z); - let dd = dd.add(&dd); - let e = b.sub(&a); - let f = dd.sub(&c); - let g = dd.add(&c); - let h = b.add(&a); - Self { - x: e.mul(&f), - y: g.mul(&h), - t: e.mul(&h), - z: f.mul(&g), - } - } - - /// 倍点。 - pub(crate) fn double(&self) -> Self { - let a = self.x.square(); - let b = self.y.square(); - let z2 = self.z.square(); - let c = z2.add(&z2); - let dd = a.neg(); - let e = self.x.add(&self.y).square().sub(&a).sub(&b); - let g = dd.add(&b); - let f = g.sub(&c); - let h = dd.sub(&b); - Self { - x: e.mul(&f), - y: g.mul(&h), - t: e.mul(&h), - z: f.mul(&g), - } - } - - /// 压缩成 32 字节(`y` 的规范编码 + `x` 的最低位放进第 255 位)。 - pub(crate) fn compress(&self) -> [u8; 32] { - let z_inv = self.z.invert(); - let x = self.x.mul(&z_inv); - let y = self.y.mul(&z_inv); - let mut out = y.to_bytes(); - if x.is_odd() { - out[31] |= 0x80; - } - out - } - - /// 从 32 字节压缩编码解压;**严格**:非规范的 `y`、解不出 `x`、`x = 0` 却带符号位都返回 `None`。 - pub(crate) fn decompress(bytes: &[u8; 32]) -> Option { - let mut raw = *bytes; - let sign = raw[31] >> 7; - raw[31] &= 0x7F; - let y = Fe::from_bytes(&raw); - // 规范编码才收:`y >= p` 的写法(同一元素的第二种编法)一律拒绝,防「同一签名两种字节」。 - if y.to_bytes() != raw { - return None; - } - // x^2 = (y^2 - 1) / (d y^2 + 1) - let y2 = y.square(); - let u = y2.sub(&Fe::ONE); - let v = d().mul(&y2).add(&Fe::ONE); - let v3 = v.square().mul(&v); - let v7 = v3.square().mul(&v); - let mut x = u.mul(&v3).mul(&u.mul(&v7).pow_p58()); - if v.mul(&x.square()).to_bytes() != u.to_bytes() { - // p ≡ 5 mod 8:这一支解不出时,乘 sqrt(-1) 再试一次(另一半平方根)。 - x = x.mul(&Fe::sqrt_m1()); - if v.mul(&x.square()).to_bytes() != u.to_bytes() { - return None; - } - } - if x.is_zero() { - // x = 0 时符号位只能是 0(否则就是「-0」,非规范)。 - if sign == 1 { - return None; - } - } else if x.is_odd() != (sign == 1) { - x = x.neg(); - } - Some(Self { - x, - y, - z: Fe::ONE, - t: x.mul(&y), - }) - } - - /// `[scalar] self`,标量以 32 字节小端给出(从最高位往下:倍点,置位才加)。 - pub(crate) fn mul(&self, scalar: &[u8; 32]) -> Self { - let mut acc = Self::identity(); - for byte_index in (0..32).rev() { - for bit in (0..8).rev() { - acc = acc.double(); - if (scalar[byte_index] >> bit) & 1 == 1 { - acc = acc.add(self); - } - } - } - acc - } - - /// 是否小阶点(`[8]P` 是单位元就是;`verify_strict` 的第一道严格性判据)。 - pub(crate) fn is_small_order(&self) -> bool { - let p8 = self.double().double().double(); - p8.x.is_zero() && p8.y.to_bytes() == p8.z.to_bytes() - } -} diff --git a/crates/core/src/update/ed25519/mod.rs b/crates/core/src/update/ed25519/mod.rs deleted file mode 100644 index 4c44e7b..0000000 --- a/crates/core/src/update/ed25519/mod.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Ed25519 **严格验签**(RFC 8032 §5.1.7),只验不签、只用公开数据。 -//! -//! # 为什么这里没有引 `curve25519-dalek` / `ed25519-dalek` -//! -//! M190 的 scope 不含 `crates/core/Cargo.toml`,而清单验签必须在 `core::update` 里(GUI 与 CLI -//! 共用 `Job`,验签只能有一个收口点)。取舍:把验签实现**就地**写成三个文件(`field` 素域算术 / -//! `group` 曲线运算 / 本文件判据),零新依赖,判据对着 RFC 8032 的官方测试向量逐条验 -//! (`tests/ed25519_cases.rs`)。 -//! -//! 这条取舍的代价必须说清: -//! -//! * 实现只有「验」这一半——没有私钥、没有 nonce、没有常数时间要求,攻击面比完整签名库小得多; -//! * 但它是**自写密码学**。日后 scope 允许时应当整体换成 `ed25519-dalek` 的 -//! `verify_strict`,对外只留 [`verify`] 一个函数,替换是改这一个函数体; -//! * 严格性按 `verify_strict` 的口径来:拒绝非规范编码(`y >= p`、`-0`)、拒绝小阶公钥与小阶 `R`、 -//! 拒绝非规范标量 `S`(`S >= L`,即可锻性)。少任何一条都能造出「改一个字节仍通过」的假签。 -//! -//! # 判据(逐条,都能单独变红) -//! -//! ```text -//! ① 解 A、解 R 都必须成功且是规范编码 (非规范编码 → 拒) -//! ② A、R 都不能是小阶点 (小阶 → 拒) -//! ③ S < L(L = 2^252 + 27742317777372353535851937790883648493) -//! ④ k = SHA-512(R‖A‖M) mod L -//! ⑤ [S]B == R + [k]A(压缩后逐字节比) -//! ``` - -mod field; -mod group; - -use sha2::{Digest, Sha512}; - -use self::group::Point; - -/// 公钥字节数。 -pub(crate) const PUBLIC_KEY_LEN: usize = 32; -/// 签名字节数。 -pub(crate) const SIGNATURE_LEN: usize = 64; - -/// `L = 2^252 + 27742317777372353535851937790883648493`(小端 4 条 64 位肢)。 -const L: [u64; 4] = [ - 0x5812_631a_5cf5_d3ed, - 0x14de_f9de_a2f7_9cd6, - 0x0000_0000_0000_0000, - 0x1000_0000_0000_0000, -]; - -/// 验一条 Ed25519 签名。 -/// -/// `public_key` 与 `signature` 的语义就是 RFC 8032 的那两段字节;`message` 是任意长原文 -/// (这里恒为清单文件的原始字节,见 [`super::signature`])。 -#[must_use] -pub(crate) fn verify( - public_key: &[u8; PUBLIC_KEY_LEN], - message: &[u8], - signature: &[u8; SIGNATURE_LEN], -) -> bool { - let mut r_bytes = [0_u8; PUBLIC_KEY_LEN]; - r_bytes.copy_from_slice(&signature[..PUBLIC_KEY_LEN]); - let mut s_bytes = [0_u8; PUBLIC_KEY_LEN]; - s_bytes.copy_from_slice(&signature[PUBLIC_KEY_LEN..]); - // ③ S 必须是规范标量:`S >= L` 时同一个「消息 + 公钥」存在多个可验证的签名,签名的不可锻性就没了。 - if !scalar_is_canonical(&s_bytes) { - return false; - } - let Some(public_point) = Point::decompress(public_key) else { - return false; - }; - let Some(r_point) = Point::decompress(&r_bytes) else { - return false; - }; - // ② 小阶点会让「谁都能构造一个自洽的四元组」,严格验签一律拒。 - if public_point.is_small_order() || r_point.is_small_order() { - return false; - } - // ④ k = SHA-512(R ‖ A ‖ M) mod L - let mut hasher = Sha512::new(); - hasher.update(r_bytes); - hasher.update(public_key); - hasher.update(message); - let digest = hasher.finalize(); - let mut wide = [0_u8; 64]; - wide.copy_from_slice(&digest); - let k = reduce_512(&wide); - // ⑤ [S]B 与 R + [k]A 必须是同一个点(压缩后逐字节比即「同一个点」,因为压缩是单射)。 - let left = Point::base().mul(&s_bytes); - let right = r_point.add(&public_point.mul(&k)); - left.compress() == right.compress() -} - -/// 从 32 字节种子推出公钥(**只给测试用**:正式构建的验签不需要它,也没有任何私钥代码)。 -#[cfg(test)] -pub(crate) fn public_key_from_seed_for_tests(seed: &[u8; 32]) -> [u8; PUBLIC_KEY_LEN] { - let (scalar, _) = expand_seed(seed); - Point::base().mul(&scalar).compress() -} - -/// 用 32 字节种子签一条消息(**只给测试用**,见 [`public_key_from_seed_for_tests`])。 -#[cfg(test)] -pub(crate) fn sign_for_tests(seed: &[u8; 32], message: &[u8]) -> [u8; SIGNATURE_LEN] { - let (scalar, prefix) = expand_seed(seed); - let public_key = Point::base().mul(&scalar).compress(); - let mut hasher = Sha512::new(); - hasher.update(prefix); - hasher.update(message); - let mut wide = [0_u8; 64]; - wide.copy_from_slice(&hasher.finalize()); - let r = reduce_512(&wide); - let r_encoded = Point::base().mul(&r).compress(); - let mut hasher = Sha512::new(); - hasher.update(r_encoded); - hasher.update(public_key); - hasher.update(message); - let mut wide = [0_u8; 64]; - wide.copy_from_slice(&hasher.finalize()); - let k = reduce_512(&wide); - let s = scalar_mul_add_mod_l(&k, &scalar, &r); - let mut out = [0_u8; SIGNATURE_LEN]; - out[..PUBLIC_KEY_LEN].copy_from_slice(&r_encoded); - out[PUBLIC_KEY_LEN..].copy_from_slice(&s); - out -} - -/// RFC 8032 §5.1.5 的种子展开:`SHA-512(seed)` 的前半是按位裁剪的标量、后半是 nonce 前缀。 -#[cfg(test)] -fn expand_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) { - let digest = Sha512::digest(seed); - let mut scalar = [0_u8; 32]; - scalar.copy_from_slice(&digest[..32]); - scalar[0] &= 248; - scalar[31] &= 127; - scalar[31] |= 64; - let mut prefix = [0_u8; 32]; - prefix.copy_from_slice(&digest[32..]); - (scalar, prefix) -} - -/// `S < L` 判定(小端 32 字节)。 -fn scalar_is_canonical(bytes: &[u8; 32]) -> bool { - let mut limbs = [0_u64; 4]; - let (chunks, _) = bytes.as_chunks::<8>(); - for (limb, chunk) in limbs.iter_mut().zip(chunks) { - *limb = u64::from_le_bytes(*chunk); - } - // 从最高肢往下比:高于 L 的最高肢就不可能小于 L。 - for index in (0..4).rev() { - if limbs[index] < L[index] { - return true; - } - if limbs[index] > L[index] { - return false; - } - } - false -} - -/// 把一个 512 位大端-无关的字节串按小端解释,对 `L` 取模。 -/// -/// `bytes[63]` 是最高字节。做法是最朴素的竖式:每次左移 8 位再补一个字节,随后反复减 `L` -/// 直到小于 `L`。一次验签跑两遍、每遍最坏 64*256 次减——比任何花招都更容易对着看懂。 -fn reduce_512(bytes: &[u8; 64]) -> [u8; 32] { - let mut acc = [0_u64; 5]; - for byte in bytes.iter().rev() { - let mut carry = u128::from(*byte); - for limb in &mut acc { - let wide = (u128::from(*limb) << 8) | carry; - *limb = wide as u64; - carry = wide >> 64; - } - while !below_l(&acc) { - subtract_l(&mut acc); - } - } - let mut out = [0_u8; 32]; - for index in 0..4 { - out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes()); - } - out -} - -/// `acc < L`(`acc` 是 5 肢,最高肢非零即「远大于 L」)。 -fn below_l(acc: &[u64; 5]) -> bool { - if acc[4] != 0 { - return false; - } - for index in (0..4).rev() { - if acc[index] < L[index] { - return true; - } - if acc[index] > L[index] { - return false; - } - } - false -} - -/// `acc -= L`(调用方保证 `acc >= L`)。 -fn subtract_l(acc: &mut [u64; 5]) { - let mut borrow = 0_u64; - for index in 0..4 { - let (value, first) = acc[index].overflowing_sub(L[index]); - let (value, second) = value.overflowing_sub(borrow); - acc[index] = value; - borrow = u64::from(first || second); - } - let (value, _) = acc[4].overflowing_sub(borrow); - acc[4] = value; -} - -/// `(k*a + r) mod L`(测试签名用;`k`、`a`、`r` 都已是规范标量)。 -#[cfg(test)] -fn scalar_mul_add_mod_l(k: &[u8; 32], a: &[u8; 32], r: &[u8; 32]) -> [u8; 32] { - // 256x256 -> 512 位乘积:8 条 64 位肢,逐肢进位后再按字节展开成 64 字节(小端)。 - let mut product = [0_u64; 8]; - for (left_index, left) in limbs_of(k).iter().enumerate() { - let mut carry = 0_u128; - for (right_index, right) in limbs_of(a).iter().enumerate() { - let cell = u128::from(*left) * u128::from(*right) - + u128::from(product[left_index + right_index]) - + carry; - product[left_index + right_index] = cell as u64; - carry = cell >> 64; - } - product[left_index + 4] = u64::try_from(carry).unwrap_or(0); - } - let mut wide = [0_u8; 64]; - for (index, limb) in product.iter().enumerate() { - wide[index * 8..index * 8 + 8].copy_from_slice(&limb.to_le_bytes()); - } - let reduced = reduce_512(&wide); - // 加上 r 再取模一次(`acc` 5 肢 + 反复减 L,与 reduce_512 同一手法)。 - let mut acc = [0_u64; 5]; - for index in 0..4 { - let mut buf = [0_u8; 8]; - buf.copy_from_slice(&reduced[index * 8..index * 8 + 8]); - acc[index] = u64::from_le_bytes(buf); - } - let mut carry = 0_u64; - for (slot, rhs) in acc.iter_mut().zip(limbs_of(r)) { - let (sum, overflow) = slot.overflowing_add(rhs); - let (sum, overflow2) = sum.overflowing_add(carry); - *slot = sum; - carry = u64::from(overflow || overflow2); - } - acc[4] = carry; - while !below_l(&acc) { - subtract_l(&mut acc); - } - let mut out = [0_u8; 32]; - for index in 0..4 { - out[index * 8..index * 8 + 8].copy_from_slice(&acc[index].to_le_bytes()); - } - out -} - -/// 32 字节小端 -> 4 条 64 位肢。 -#[cfg(test)] -fn limbs_of(bytes: &[u8; 32]) -> [u64; 4] { - let mut limbs = [0_u64; 4]; - let (chunks, _) = bytes.as_chunks::<8>(); - for (limb, chunk) in limbs.iter_mut().zip(chunks) { - *limb = u64::from_le_bytes(*chunk); - } - limbs -} diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index bbec6d3..18117c2 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -31,7 +31,7 @@ //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 //! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring) -//! ed25519 Ed25519 严格验签(自写,无新依赖) +//! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) //! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! ``` diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs index 55a9dd1..eea1610 100644 --- a/crates/core/src/update/source.rs +++ b/crates/core/src/update/source.rs @@ -68,22 +68,14 @@ pub trait UpdateSource: Send + Sync { /// /// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。 fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result; - - /// 取清单 → **验签** → 解析 → 渠道校验。`check`/`apply` 都只走这一个入口。 - /// - /// 提供默认实现是为了让「怎么取字节」与「怎么判字节」彻底分开:源只实现 - /// [`Self::fetch_manifest_documents`],判据只有 [`verified_manifest`] 一份。 - /// - /// # Errors - /// - /// 取不到/取到空、签名不通过(五类,见 [`UpdateErrorKind`])、清单 JSON 坏、渠道不符。 - fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { - verified_manifest(self, channel) - } } /// 取清单 → 验签 → 解析 → 渠道校验(**唯一**入口,GUI 与 CLI 共用)。 /// +/// 它是个**自由函数**而不是 trait 的默认方法:trait 方法可被任何实现方覆写,那样「信任根的收口点」 +/// 就只剩纪律(M190 r1 复审 P2-2)。删掉那条默认实现之后,调用方手里只有源交出来的**原文** +/// ([`ManifestDocuments`]),想绕过验签就必须自己重写一遍解析 + 渠道判据 —— 不再是「一行覆写」。 +/// /// # Errors /// /// 源里没有该渠道清单 ⇒ `Ok(None)`;签名/解析/渠道任一不过 ⇒ [`UpdateError`]。 diff --git a/crates/core/src/update/tests/ed25519_cases.rs b/crates/core/src/update/tests/ed25519_cases.rs index a7fdbe3..4b5008c 100644 --- a/crates/core/src/update/tests/ed25519_cases.rs +++ b/crates/core/src/update/tests/ed25519_cases.rs @@ -1,12 +1,23 @@ -//! Ed25519 严格验签的判据(RFC 8032 §7.1 官方测试向量 + 六条「该拒就拒」)。 +//! Ed25519 验签判据:**钉住 `ed25519-dalek::verify_strict` 的行为**(RFC 8032 §7.1 向量 + 六条拒绝类)。 //! -//! 这一组用例是自写密码学的**唯一**公信力来源:向量来自 RFC 附录(不是自己签自己验), -//! 拒绝类用例覆盖「改一个字节仍然通过」的全部已知构造(改 R / 改 S / S += L / 小阶公钥 / -//! 非规范 R 编码 / 空签名)。 +//! M190 r1 复审把自写曲线算术整体换成了官方实现(理由见 `crate::update::ed25519` 的模块文档)。 +//! 这批用例的定位因此从「测自己的实现」变成「**钉 dalek 的严格语义**」: +//! +//! | 用例 | 它挡住什么 | +//! |---|---| +//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量是 RFC 附录的,不是自签自验) | +//! | `malleable_s_plus_l_is_rejected` | 把 `verify_strict` 换成 `verify`(后者不检查 `S` 规范性) | +//! | `small_order_public_key_is_rejected` | 同上:小阶公钥/小阶 `R` 在 `verify` 下能「自洽通过」 | +//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | +//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | +//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧是同一套语义 | +//! +//! 每条都实测过 dalek 2.2 的**实际**行为;与「RFC 最低要求」不同之处在用例注释里写明(见 +//! `non_canonical_r_encoding_is_rejected`:dalek 拒它的理由是**小阶**,不是「非规范编码」单独一条)。 -use crate::update::ed25519::{ - public_key_from_seed_for_tests, sign_for_tests, verify, PUBLIC_KEY_LEN, SIGNATURE_LEN, -}; +use crate::update::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN}; + +use super::support_sign::{hex_of, public_key_hex, sign_bytes}; /// 十六进制转字节(长度不对/字符非法当场 panic:这些向量是写死的常量)。 fn unhex(text: &str) -> Vec { @@ -15,15 +26,15 @@ fn unhex(text: &str) -> Vec { cleaned.len().is_multiple_of(2), "十六进制串长度必须是偶数:{text}" ); - cleaned - .as_bytes() - .chunks(2) - .map(|pair| { - let hi = (pair[0] as char).to_digit(16).expect("十六进制字符"); - let lo = (pair[1] as char).to_digit(16).expect("十六进制字符"); - u8::try_from(hi * 16 + lo).expect("字节") - }) - .collect() + let bytes = cleaned.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 2); + let (pairs, _) = bytes.as_chunks::<2>(); + for pair in pairs { + let hi = (pair[0] as char).to_digit(16).expect("十六进制字符"); + let lo = (pair[1] as char).to_digit(16).expect("十六进制字符"); + out.push(u8::try_from(hi * 16 + lo).expect("字节")); + } + out } fn array32(bytes: &[u8]) -> [u8; 32] { @@ -44,28 +55,28 @@ const L_BYTES: [u8; 32] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, ]; -/// RFC 8032 的四个向量:(public, message, signature)。 -fn rfc_vectors() -> Vec<(String, String, String)> { +/// RFC 8032 §7.1 的四条向量:(public, message, signature)。 +fn rfc_vectors() -> Vec<(&'static str, &'static str, &'static str)> { vec![ ( - "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".to_string(), - String::new(), - "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b".to_string(), + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", ), ( - "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c".to_string(), - "72".to_string(), - "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00".to_string(), + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", ), ( - "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025".to_string(), - "af82".to_string(), - "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a".to_string(), + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a", ), ( - "ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf".to_string(), - "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f".to_string(), - "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704".to_string(), + "ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf", + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704", ), ] } @@ -73,11 +84,12 @@ fn rfc_vectors() -> Vec<(String, String, String)> { #[test] fn rfc8032_vectors_verify() { for (index, (public, message, signature)) in rfc_vectors().into_iter().enumerate() { - let public = array32(&unhex(&public)); - let message = unhex(&message); - let signature = array64(&unhex(&signature)); assert!( - verify(&public, &message, &signature), + verify( + &array32(&unhex(public)), + &unhex(message), + &array64(&unhex(signature)) + ), "RFC 8032 第 {} 条向量必须通过", index + 1 ); @@ -86,37 +98,41 @@ fn rfc8032_vectors_verify() { #[test] fn message_bit_flip_is_rejected() { - let (public, message, signature) = &rfc_vectors()[1]; - let public = array32(&unhex(public)); + let (public, message, signature) = rfc_vectors()[1]; let mut message = unhex(message); message[0] ^= 0x01; - let signature = array64(&unhex(signature)); assert!( - !verify(&public, &message, &signature), + !verify( + &array32(&unhex(public)), + &message, + &array64(&unhex(signature)) + ), "改一个消息字节必须被拒" ); } #[test] fn signature_bit_flip_is_rejected() { - let (public, message, signature) = &rfc_vectors()[2]; - let public = array32(&unhex(public)); - let message = unhex(message); - let mut signature = array64(&unhex(signature)); - // 只改 R 的最后一位(不动消息、不动 S):证明「只改签名字节」也会红。 - signature[31] ^= 0x01; - assert!(!verify(&public, &message, &signature), "改 R 必须被拒"); - let mut signature = array64(&unhex(&rfc_vectors()[2].2)); - signature[63] ^= 0x04; - assert!(!verify(&public, &message, &signature), "改 S 必须被拒"); + let (public, message, signature) = rfc_vectors()[2]; + let mut r_flipped = array64(&unhex(signature)); + r_flipped[31] ^= 0x01; + assert!( + !verify(&array32(&unhex(public)), &unhex(message), &r_flipped), + "改 R 必须被拒" + ); + let mut s_flipped = array64(&unhex(signature)); + s_flipped[63] ^= 0x04; + assert!( + !verify(&array32(&unhex(public)), &unhex(message), &s_flipped), + "改 S 必须被拒" + ); } #[test] fn malleable_s_plus_l_is_rejected() { - // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余),严格实现必须拒。 - let (public, message, signature) = &rfc_vectors()[0]; - let public = array32(&unhex(public)); - let message = unhex(message); + // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它, + // `verify_strict` 必须拒 —— 这条用例就是「别把 verify_strict 换成 verify」的判据。 + let (public, message, signature) = rfc_vectors()[0]; let mut signature = array64(&unhex(signature)); let mut carry = 0_u16; for index in 0..32 { @@ -129,21 +145,20 @@ fn malleable_s_plus_l_is_rejected() { "S + L 不该溢出 256 位(否则这条用例本身没造出来)" ); assert!( - !verify(&public, &message, &signature), + !verify(&array32(&unhex(public)), &unhex(message), &signature), "S >= L 必须被拒(可锻变体)" ); } #[test] fn small_order_public_key_is_rejected() { - // 阶为 1 的点(y = 1, x = 0)与阶为 2 的点(y = 0):用它们做公钥,谁都能凑出「自洽」的签名。 + // 阶为 1 的点(y = 1, x = 0)与零字节公钥:用它们做公钥,`verify`(非 strict)会接受一批伪造。 let message = b"manifest bytes"; let identity_key = { let mut key = [0_u8; PUBLIC_KEY_LEN]; key[0] = 1; key }; - let order_two_key = [0_u8; PUBLIC_KEY_LEN]; let mut signature = [0_u8; SIGNATURE_LEN]; signature[0] = 1; // R = 单位元 assert!( @@ -151,17 +166,19 @@ fn small_order_public_key_is_rejected() { "小阶公钥必须被拒" ); assert!( - !verify(&order_two_key, message, &signature), + !verify(&[0_u8; PUBLIC_KEY_LEN], message, &signature), "零公钥必须被拒" ); } #[test] fn non_canonical_r_encoding_is_rejected() { - // R 的 y = p(= 2^255-19)与 y = 1 同值但编码不同:非规范写法必须拒。 - let (public, message, _) = &rfc_vectors()[0]; - let public = array32(&unhex(public)); - let message = unhex(message); + // R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同。 + // **dalek 的实际行为(M190 r1 实测)**:`CompressedEdwardsY::decompress` 不单独校验「规范编码」, + // 这个点解压出来是阶为 4 的点 ⇒ 被 `verify_strict` 的**小阶**判据拒。结论(拒)与自写实现的 + // 「非规范编码」理由不同,但对这份输入是同一条出路;下面把「公钥非规范编码」也一并钉住, + // 因为那条走的是 `VerifyingKey::from_bytes` 的编码校验。 + let (public, message, _) = rfc_vectors()[0]; let p_bytes: [u8; 32] = [ 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, @@ -170,14 +187,23 @@ fn non_canonical_r_encoding_is_rejected() { let mut signature = [0_u8; SIGNATURE_LEN]; signature[..32].copy_from_slice(&p_bytes); assert!( - !verify(&public, &message, &signature), + !verify(&array32(&unhex(public)), &unhex(message), &signature), "非规范 R 编码必须被拒" ); + // 公钥换成同一串非规范编码:`VerifyingKey::from_bytes` 直接拒(连验签都不进)。 + assert!( + !verify( + &p_bytes, + &unhex(message), + &array64(&unhex(rfc_vectors()[0].2)) + ), + "非规范公钥编码必须被拒" + ); } #[test] fn empty_signature_is_rejected() { - let (public, message, _) = &rfc_vectors()[0]; + let (public, message, _) = rfc_vectors()[0]; assert!(!verify( &array32(&unhex(public)), &unhex(message), @@ -186,28 +212,33 @@ fn empty_signature_is_rejected() { } #[test] -fn test_signer_round_trips_and_uses_rfc_seed() { - // 测试钥不是「另写一套算法」:RFC 8032 的 secret 反推出 public 就对上了,签出来的也能验过。 +fn signing_key_round_trips() { + // 测试夹具(dalek `SigningKey`)与验签侧同一套语义:RFC 的 secret 反推出的公钥要对得上, + // 签出来的能验过,改一个字节就验不过。 let seed = array32(&unhex( "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", )); - let expected = array32(&unhex( - "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", - )); assert_eq!( - public_key_from_seed_for_tests(&seed), - expected, + public_key_hex(&seed), + rfc_vectors()[0].0, "种子展开必须与 RFC 一致" ); - let signature = sign_for_tests(&seed, b""); assert_eq!( - signature, - array64(&unhex(&rfc_vectors()[0].2)), - "空消息的签名必须逐字节等于 RFC 向量(证明签名侧与向量同一套算术语义)" + hex_of(&sign_bytes(&seed, b"")), + rfc_vectors()[0].2, + "空消息的签名必须逐字节等于 RFC 向量(测试夹具与向量同一套语义)" ); + let signature = sign_bytes(&seed, b"unlucky"); assert!(verify( - &expected, + &array32(&unhex(rfc_vectors()[0].0)), b"unlucky", - &sign_for_tests(&seed, b"unlucky") + &signature + )); + let mut tampered = signature; + tampered[0] ^= 0x80; + assert!(!verify( + &array32(&unhex(rfc_vectors()[0].0)), + b"unlucky", + &tampered )); } diff --git a/crates/core/src/update/tests/github_cases.rs b/crates/core/src/update/tests/github_cases.rs index 56e9651..97ba278 100644 --- a/crates/core/src/update/tests/github_cases.rs +++ b/crates/core/src/update/tests/github_cases.rs @@ -10,13 +10,12 @@ use crate::download::mirrors::SOURCE_GH_PROXY; use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL}; use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET}; use crate::update::{ - Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError, - UpdateErrorKind, UpdateSource, Version, + verified_manifest, Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, + UpdateChecker, UpdateError, UpdateErrorKind, UpdateSource, Version, }; -use super::support::{ - artifact, engine_for, manifest, release, signature_envelope, FakeHttp, FakeResponse, TempDir, -}; +use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir}; +use super::support_sign::signature_envelope; const PAYLOAD: &[u8] = b"github release payload"; @@ -130,7 +129,7 @@ fn channels_other_than_stable_publish_nothing() { source.manifest_url(channel).is_none(), "{channel} 不该有清单地址" ); - let manifest = source.fetch_manifest(channel).expect("没开通道不是错误"); + let manifest = verified_manifest(&source, channel).expect("没开通道不是错误"); assert!( manifest.is_none(), "{channel} 应返回 Ok(None)(不发布即无更新)" @@ -191,8 +190,7 @@ fn fetches_and_parses_the_manifest_from_the_base() { let server = FakeHttp::start(signed_routes(&stable_manifest_json())); let source = local_source(&tmp, &server); - let manifest = source - .fetch_manifest(Channel::Stable) + let manifest = verified_manifest(&source, Channel::Stable) .expect("应取到清单") .expect("清单存在"); assert_eq!(manifest.channel, Channel::Stable); @@ -212,9 +210,7 @@ fn missing_manifest_in_the_release_is_not_an_error() { let server = FakeHttp::start(Vec::new()); let source = local_source(&tmp, &server); - let outcome = source - .fetch_manifest(Channel::Stable) - .expect("404 不是错误"); + let outcome = verified_manifest(&source, Channel::Stable).expect("404 不是错误"); assert!(outcome.is_none(), "取不到清单 = 还没发过版"); assert_eq!( server.hits(), @@ -230,9 +226,7 @@ fn broken_json_is_reported_as_invalid_manifest() { let server = FakeHttp::start(signed_routes("{ not json")); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("坏 JSON 必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert!( error.message.contains("github:"), @@ -250,9 +244,7 @@ fn illegal_manifest_fields_are_reported() { let server = FakeHttp::start(signed_routes(json)); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("size 为 0 必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("size 为 0 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); } @@ -262,9 +254,7 @@ fn manifest_self_reporting_another_channel_is_a_mismatch() { let server = FakeHttp::start(signed_routes(&manifest("beta", &[]))); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("渠道不符必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("渠道不符必须报错"); assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); } @@ -274,9 +264,7 @@ fn server_errors_are_network_errors() { let server = FakeHttp::start(vec![(manifest_path(), FakeResponse::status(500))]); let source = local_source(&tmp, &server); - let error = source - .fetch_manifest(Channel::Stable) - .expect_err("5xx 必须报错"); + let error = verified_manifest(&source, Channel::Stable).expect_err("5xx 必须报错"); assert_eq!(error.kind, UpdateErrorKind::Network); assert!(error.is_retryable(), "网络抖动值得隔一会儿再试"); } diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index bdb4ad6..5ec5af5 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -30,6 +30,7 @@ mod manifest_cases; mod signature_cases; mod source_cases; mod support; +mod support_sign; mod target_cases; mod verify_cases; mod version_cases; diff --git a/crates/core/src/update/tests/signature_cases.rs b/crates/core/src/update/tests/signature_cases.rs index 6b60c98..9716926 100644 --- a/crates/core/src/update/tests/signature_cases.rs +++ b/crates/core/src/update/tests/signature_cases.rs @@ -17,16 +17,15 @@ use std::fs; -use crate::update::ed25519::public_key_from_seed_for_tests; use crate::update::{ - trust, Channel, DirSource, Job, OsFamily, Target, UpdateChecker, UpdateErrorKind, UpdateSource, - Version, + trust, verified_manifest, Channel, DirSource, Job, OsFamily, Target, UpdateChecker, + UpdateErrorKind, Version, }; -use super::support::{ - artifact, hex_of, manifest, release, signature_entry, signature_entry_from_bytes, - signature_envelope, signature_envelope_of, signature_envelope_with, TempDir, TEST_KEY_ID, - TEST_SIGNING_SEED, +use super::support::{artifact, manifest, release, TempDir}; +use super::support_sign::{ + public_key_hex, sign_bytes, signature_entry, signature_entry_from_bytes, signature_envelope, + signature_envelope_of, signature_envelope_with, TEST_KEY_ID, TEST_SIGNING_SEED, }; const PAYLOAD: &[u8] = b"signed release payload"; @@ -57,7 +56,7 @@ fn current() -> Version { fn pinned_test_key_matches_the_test_seed() { // 信任表里那把测试钥必须就是测试种子推出来的那把。漂了的话「钉住的公钥」与「签名的钥」不是同一个, // 下面所有正路用例会一起变红——先在这里单独钉死。 - let derived = hex_of(&public_key_from_seed_for_tests(TEST_SIGNING_SEED)); + let derived = public_key_hex(TEST_SIGNING_SEED); let pinned = trust::keyring() .iter() .find(|key| key.key_id == TEST_KEY_ID) @@ -92,8 +91,7 @@ fn tampered_field_is_rejected() { dir.write("stable.json", tampered.as_bytes()); // 签名还是**原始**那份的签名。 dir.write("stable.json.sig", signature_envelope(&original).as_bytes()); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("改过字段的清单必须验不过"); assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); assert!( @@ -107,7 +105,7 @@ fn tampered_field_is_rejected() { fn tampered_signature_only_is_rejected() { // 守卫①的另一半:清单字节一个不动,只把签名的最后一个字节改掉。 let json = stable_json("0.3.0"); - let mut signature = crate::update::ed25519::sign_for_tests(TEST_SIGNING_SEED, json.as_bytes()); + let mut signature = sign_bytes(TEST_SIGNING_SEED, json.as_bytes()); let good = signature_envelope(&json); signature[63] ^= 0x04; let broken = @@ -117,8 +115,7 @@ fn tampered_signature_only_is_rejected() { let dir = TempDir::new("sig-tamper-sig"); dir.write("stable.json", json.as_bytes()); dir.write("stable.json.sig", broken.as_bytes()); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("只改签名也必须被拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); } @@ -127,8 +124,7 @@ fn tampered_signature_only_is_rejected() { fn missing_signature_is_rejected() { let dir = TempDir::new("sig-missing"); dir.write("stable.json", stable_json("0.3.0").as_bytes()); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("没有签名文件必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureMissing); assert!(!error.is_retryable(), "漏签名不是抖动,重试没有意义"); @@ -140,8 +136,7 @@ fn empty_signature_array_is_malformed() { let dir = TempDir::new("sig-empty-array"); dir.write("stable.json", stable_json("0.3.0").as_bytes()); dir.write("stable.json.sig", br#"{"format":1,"signatures":[]}"#); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("空签名数组必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); } @@ -156,8 +151,7 @@ fn unknown_field_in_envelope_is_malformed() { "stable.json.sig", br#"{"format":1,"signatures":[],"comment":"hi"}"#, ); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("未知字段必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed); } @@ -172,8 +166,7 @@ fn unknown_key_is_rejected() { "stable.json.sig", signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), ); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("不认识的钥必须拒"); assert_eq!(error.kind, UpdateErrorKind::SignatureUnknownKey); assert!( @@ -194,8 +187,7 @@ fn key_id_spoofing_does_not_help() { "stable.json.sig", signature_envelope_with(ATTACKER_SEED, TEST_KEY_ID, &json).as_bytes(), ); - let error = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable) .expect_err("key_id 撞名不能替代公钥"); assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid); } @@ -211,9 +203,8 @@ fn multi_signature_accepts_any_pinned_key() { signature_entry(TEST_SIGNING_SEED, TEST_KEY_ID, &json), ]); dir.write("stable.json.sig", merged.as_bytes()); - let manifest = DirSource::new(dir.path()) - .fetch_manifest(Channel::Stable) - .expect("有一条能过就放行"); + let manifest = + verified_manifest(&DirSource::new(dir.path()), Channel::Stable).expect("有一条能过就放行"); assert!(manifest.is_some()); } @@ -280,7 +271,7 @@ fn no_runtime_keyring_override() { "stable.json.sig", signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(), ); - let attacker_public = hex_of(&public_key_from_seed_for_tests(ATTACKER_SEED)); + let attacker_public = public_key_hex(ATTACKER_SEED); dir.write( "keyring.json", format!(r#"{{"keys":[{{"key_id":"attacker-key","public_key_hex":"{attacker_public}"}}]}}"#) @@ -290,7 +281,7 @@ fn no_runtime_keyring_override() { let keyring_path = dir.join("keyring.json"); std::env::set_var("ULL_UPDATE_KEYRING", &keyring_path); std::env::set_var("ULL_UPDATE_KEYRING_FILE", &keyring_path); - let outcome = DirSource::new(dir.path()).fetch_manifest(Channel::Stable); + let outcome = verified_manifest(&DirSource::new(dir.path()), Channel::Stable); std::env::remove_var("ULL_UPDATE_KEYRING"); std::env::remove_var("ULL_UPDATE_KEYRING_FILE"); let error = outcome.expect_err("旁边的信任表文件不许生效"); diff --git a/crates/core/src/update/tests/source_cases.rs b/crates/core/src/update/tests/source_cases.rs index f31b416..bacdd97 100644 --- a/crates/core/src/update/tests/source_cases.rs +++ b/crates/core/src/update/tests/source_cases.rs @@ -3,7 +3,7 @@ use std::fs; use crate::update::manifest::ReleaseManifest; -use crate::update::{Channel, DirSource, UpdateErrorKind, UpdateSource}; +use crate::update::{verified_manifest, Channel, DirSource, UpdateErrorKind, UpdateSource}; use super::support::{artifact, manifest, release, TempDir}; @@ -17,9 +17,7 @@ fn publish(tmp: &TempDir, channel: Channel, json: &str) { fn missing_channel_manifest_is_not_an_error() { let tmp = TempDir::new("source-missing"); let source = DirSource::new(tmp.path()); - let manifest = source - .fetch_manifest(Channel::Stable) - .expect("缺清单不该报错"); + let manifest = verified_manifest(&source, Channel::Stable).expect("缺清单不该报错"); assert!(manifest.is_none(), "还没发过版 = Ok(None)"); } @@ -36,8 +34,7 @@ fn reads_and_validates_the_channel_manifest() { ); publish(&tmp, Channel::Stable, &json); let source = DirSource::new(tmp.path()); - let parsed = source - .fetch_manifest(Channel::Stable) + let parsed = verified_manifest(&source, Channel::Stable) .expect("应取到清单") .expect("清单存在"); assert_eq!(parsed.channel, Channel::Stable); @@ -52,8 +49,7 @@ fn channel_mismatch_is_an_error() { let tmp = TempDir::new("source-mismatch"); // 清单自称 beta,却躺在 stable.json 里 publish(&tmp, Channel::Stable, &manifest("beta", &[])); - let error = DirSource::new(tmp.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable) .expect_err("渠道不符必须报错"); assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); } @@ -62,8 +58,7 @@ fn channel_mismatch_is_an_error() { fn broken_manifest_json_is_reported_with_path() { let tmp = TempDir::new("source-broken"); publish(&tmp, Channel::Stable, "{ not json"); - let error = DirSource::new(tmp.path()) - .fetch_manifest(Channel::Stable) + let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable) .expect_err("坏 JSON 必须报错"); assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); assert!( diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index 2047197..c725912 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -37,19 +37,6 @@ impl TempDir { self.path.join(name) } - /// 写一份清单**连它的签名**(`` + `.sig`),返回清单路径。 - /// - /// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了 - /// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。 - pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> PathBuf { - let path = self.write(name, manifest_text.as_bytes()); - self.write( - &format!("{name}.sig"), - signature_envelope(manifest_text).as_bytes(), - ); - path - } - /// 写一个文件(父目录自动建),返回路径。 pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { let path = self.join(name); @@ -226,59 +213,6 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String { ) } -/// 测试签名钥的 32 字节种子。 -/// -/// **它是公开的,故意不保密**:它只对 `cfg(test)` 与 `update-test-source` 构建有意义 -/// (`trust::TEST_ONLY` 才认这把钥),正式构建的信任表里没有它,所以拿它签不出任何正式版本 -/// 能装的清单。正式私钥只能来自仓库 Secret(见 `packaging/docs/signing.md`)。 -pub(crate) const TEST_SIGNING_SEED: &[u8; 32] = b"ull-m190-test-key-seed-000000001"; - -/// 测试钥的 `key_id`(与 `trust::TEST_ONLY` 里那条一致)。 -pub(crate) const TEST_KEY_ID: &str = "test-only-key"; - -/// 用测试钥签一份清单,返回 `manifest.json.sig` 的信封原文。 -pub(crate) fn signature_envelope(manifest_text: &str) -> String { - signature_envelope_with(TEST_SIGNING_SEED, TEST_KEY_ID, manifest_text) -} - -/// 用指定种子/`key_id` 签一份清单(负向用例要造「不认识的钥」「改过内容的签名」)。 -pub(crate) fn signature_envelope_with( - seed: &[u8; 32], - key_id: &str, - manifest_text: &str, -) -> String { - signature_envelope_of(&[signature_entry(seed, key_id, manifest_text)]) -} - -/// 多签名信封(轮换窗口的夹具:一份清单同时带几把钥的签名)。 -pub(crate) fn signature_envelope_of(entries: &[String]) -> String { - format!(r#"{{"format":1,"signatures":[{}]}}"#, entries.join(",")) -} - -/// 一条签名条目(可以直接被 [`signature_envelope_of`] 拼起来,也可以单独改坏再拼)。 -pub(crate) fn signature_entry(seed: &[u8; 32], key_id: &str, manifest_text: &str) -> String { - let signature = crate::update::ed25519::sign_for_tests(seed, manifest_text.as_bytes()); - signature_entry_from_bytes(key_id, &signature, manifest_text) -} - -/// 用**现成的签名字节**拼一条条目(「只改签名字节」的用例靠它)。 -pub(crate) fn signature_entry_from_bytes( - key_id: &str, - signature: &[u8; 64], - manifest_text: &str, -) -> String { - let sha = crate::update::signature::hex_sha256(manifest_text.as_bytes()); - format!( - r#"{{"alg":"ed25519","key_id":"{key_id}","sig":"{}","manifest_sha256":"{sha}"}}"#, - hex_of(signature) - ) -} - -/// 字节转小写十六进制。 -pub(crate) fn hex_of(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - /// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。 /// /// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。 diff --git a/crates/core/src/update/tests/support_sign.rs b/crates/core/src/update/tests/support_sign.rs new file mode 100644 index 0000000..d79c844 --- /dev/null +++ b/crates/core/src/update/tests/support_sign.rs @@ -0,0 +1,94 @@ +//! 清单签名的**测试夹具**:用测试钥签出 `manifest.json.sig` 的信封(M190)。 +//! +//! 为什么单独一个文件:`support.rs` 是 update 测试的通用夹具(临时目录、假 HTTP、清单片段), +//! 已经贴着 400 行红线;签名这一摊只被 `signature_cases` / `github_cases` 用到,搬出来两清。 +//! +//! 签名走 `ed25519-dalek` 的 `SigningKey`(与仓里验签用的 `verify_strict` 同一棵树),而**不是** +//! 自写实现——M190 r1 复审把自写曲线算术整体删掉了,这里也不留第二条算法。测试钥的种子是公开的 +//! (见下),它只对 `cfg(test)` 与 `update-test-source` 构建有意义。 + +use super::support::TempDir; + +/// 测试签名钥的 32 字节种子。 +/// +/// **它是公开的,故意不保密**:它只对 `cfg(test)` 与 `update-test-source` 构建有意义 +/// (`trust::TEST_ONLY` 才认这把钥),正式构建的信任表里没有它,所以拿它签不出任何正式版本 +/// 能装的清单。正式私钥只能来自仓库 Secret(见 `packaging/docs/signing.md`)。 +pub(crate) const TEST_SIGNING_SEED: &[u8; 32] = b"ull-m190-test-key-seed-000000001"; + +/// 测试钥的 `key_id`(与 `trust::TEST_ONLY` 里那条一致)。 +pub(crate) const TEST_KEY_ID: &str = "test-only-key"; + +/// 字节转小写十六进制。 +pub(crate) fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// 用测试钥签一份清单,返回 `manifest.json.sig` 的信封原文。 +pub(crate) fn signature_envelope(manifest_text: &str) -> String { + signature_envelope_with(TEST_SIGNING_SEED, TEST_KEY_ID, manifest_text) +} + +/// 用指定种子/`key_id` 签一份清单(负向用例要造「不认识的钥」「改过内容的签名」)。 +pub(crate) fn signature_envelope_with( + seed: &[u8; 32], + key_id: &str, + manifest_text: &str, +) -> String { + signature_envelope_of(&[signature_entry(seed, key_id, manifest_text)]) +} + +/// 多签名信封(轮换窗口的夹具:一份清单同时带几把钥的签名)。 +pub(crate) fn signature_envelope_of(entries: &[String]) -> String { + format!(r#"{{"format":1,"signatures":[{}]}}"#, entries.join(",")) +} + +/// 一条签名条目(可以被 [`signature_envelope_of`] 拼起来,也可以单独改坏再拼)。 +pub(crate) fn signature_entry(seed: &[u8; 32], key_id: &str, manifest_text: &str) -> String { + signature_entry_from_bytes( + key_id, + &sign_bytes(seed, manifest_text.as_bytes()), + manifest_text, + ) +} + +/// 用**现成的签名字节**拼一条条目(「只改签名字节」的用例靠它)。 +pub(crate) fn signature_entry_from_bytes( + key_id: &str, + signature: &[u8; 64], + manifest_text: &str, +) -> String { + let sha = crate::update::signature::hex_sha256(manifest_text.as_bytes()); + format!( + r#"{{"alg":"ed25519","key_id":"{key_id}","sig":"{}","manifest_sha256":"{sha}"}}"#, + hex_of(signature) + ) +} + +/// 用 32 字节种子签一段字节(dalek 的 `SigningKey`;测试专用,正式构建里没有签名代码)。 +pub(crate) fn sign_bytes(seed: &[u8; 32], message: &[u8]) -> [u8; 64] { + use ed25519_dalek::{Signer, SigningKey}; + let key = SigningKey::from_bytes(seed); + key.sign(message).to_bytes() +} + +/// 种子对应的公钥(用来让「钉住的公钥」与「签名的钥」对得上——漂了就当场红)。 +pub(crate) fn public_key_hex(seed: &[u8; 32]) -> String { + use ed25519_dalek::SigningKey; + hex_of(SigningKey::from_bytes(seed).verifying_key().as_bytes()) +} + +impl TempDir { + /// 写一份清单**连它的签名**(`` + `.sig`),返回清单路径。 + /// + /// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了 + /// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。 + pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> std::path::PathBuf { + let path = self.write(name, manifest_text.as_bytes()); + self.write( + &format!("{name}.sig"), + signature_envelope(manifest_text).as_bytes(), + ); + path + } +} diff --git a/packaging/check_workflows_yaml.py b/packaging/check_workflows_yaml.py new file mode 100644 index 0000000..c0c565c --- /dev/null +++ b/packaging/check_workflows_yaml.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Parse every workflow file with a **real YAML parser** (M190 r1 复审 P1-1). + +Why this exists: a raw 0x01 byte slipped into `.github/workflows/release-build.yml` (a `sed` replacement +string that should have been `\\1`). GitHub refuses to parse such a file, so the whole run would not even +start — and no other guard noticed, because they all read workflow files as *text* (`check_release_semantics.py` +does `yaml_scalars()`, the file-size job only counts lines). A text scanner cannot see a control byte that +a YAML 1.2 reader rejects outright. + +So: hand every `.github/workflows/*.yml` to PyYAML. Two checks, both cheap: + ① it parses (real error message with position, not a mystery red); + ② no C0 control character other than TAB/LF/CR is present (YAML 1.2 forbids them; parsers differ in + whether they reject, so this is the belt-and-braces half). + +PyYAML is preinstalled on GitHub-hosted runners; if it is not, this script installs it. If that fails too +the gate **fails** rather than degrading to a text scan ("工具缺失也 fail" — same rule as the other guards). + + python3 packaging/check_workflows_yaml.py + python3 packaging/check_workflows_yaml.py --self-test +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +WORKFLOW_DIR = Path(__file__).resolve().parent.parent / ".github/workflows" +ALLOWED_CONTROL = {0x09, 0x0A, 0x0D} + + +def load_yaml(): + """Import PyYAML, installing it once if the runner lacks it.""" + try: + import yaml # noqa: PLC0415 + + return yaml + except ImportError: + print("PyYAML 缺失,尝试安装一次(pip install --quiet pyyaml)…", file=sys.stderr) + done = subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"], + capture_output=True, + text=True, + check=False, + ) + if done.returncode != 0: + print( + "error: 装不上 PyYAML,YAML 门禁无法运行(门禁不降级)。输出:\n" + done.stdout + done.stderr, + file=sys.stderr, + ) + raise SystemExit(2) from None + import yaml # noqa: PLC0415 + + return yaml + + +def control_characters(data: bytes) -> list[tuple[int, int]]: + """`[(位置, 字节)]`:YAML 1.2 不许出现的 C0 控制字符(TAB/LF/CR 除外)。""" + return [(index, byte) for index, byte in enumerate(data) if byte < 0x20 and byte not in ALLOWED_CONTROL] + + +def check_file(path: Path, yaml) -> list[str]: + """返回该文件没过的判据(空 = 通过)。""" + failures: list[str] = [] + data = path.read_bytes() + for index, byte in control_characters(data): + line = data[:index].count(b"\n") + 1 + failures.append( + f"::error file={path},line={line}::第 {line} 行含裸控制字符 {byte:#04x}(YAML 1.2 禁止;" + "常见成因是 sed/printf 把 `\\1` 写成了 0x01)" + ) + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + failures.append(f"::error file={path}::不是合法 UTF-8({error})") + return failures + try: + yaml.safe_load(text) + except Exception as error: # noqa: BLE001 - 解析器的异常类型随版本变,这里只关心「没通过」 + first = str(error).splitlines()[0] if str(error) else repr(error) + failures.append(f"::error file={path}::YAML 解析失败:{first}") + return failures + + +def self_test() -> int: + """注入一枚 0x01 到临时副本,断言门禁**会红**(否则这条门禁对任何输入都绿)。""" + import tempfile + + yaml = load_yaml() + failures = 0 + sample = "name: x\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: |\n echo hi\n" + with tempfile.TemporaryDirectory() as tmp: + good = Path(tmp) / "good.yml" + good.write_text(sample, encoding="utf-8") + if check_file(good, yaml): + print("FAIL: 合法 YAML 被判红(误杀)", file=sys.stderr) + failures += 1 + bad = Path(tmp) / "bad.yml" + bad.write_bytes(sample.replace('"/\\1/p"', "").encode("utf-8") + b'x\n v=$(s/"' + bytes([1]) + b'/p)\n') + if not check_file(bad, yaml): + print("FAIL: 含裸 0x01 的 YAML 竟然通过(判据失效)", file=sys.stderr) + failures += 1 + # 只让 PyYAML 判的那一半也要能红:把控制字节换成合法字符,但结构坏掉。 + broken = Path(tmp) / "broken.yml" + broken.write_text("a: [1, 2\nb: 3\n", encoding="utf-8") + if not check_file(broken, yaml): + print("FAIL: 结构坏的 YAML 竟然通过", file=sys.stderr) + failures += 1 + print("self-test:", "ok" if failures == 0 else f"{failures} 处失败") + return 0 if failures == 0 else 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="用真 YAML 解析器 parse 全部 workflow(M190 r1 P1-1)") + parser.add_argument("--self-test", action="store_true", help="自检:注入 0x01 / 坏结构必须红") + parser.add_argument("--dir", type=Path, default=WORKFLOW_DIR, help="workflow 目录(默认 .github/workflows)") + args = parser.parse_args(argv) + + if args.self_test: + return self_test() + + yaml = load_yaml() + files = sorted(args.dir.glob("*.yml")) + sorted(args.dir.glob("*.yaml")) + if not files: + print(f"error: {args.dir} 里一个 workflow 都没有(路径错了?门禁不降级)", file=sys.stderr) + return 2 + + failures: list[str] = [] + for path in files: + failures.extend(check_file(path, yaml)) + + for line in failures: + print(line, file=sys.stderr) + if failures: + print(f"FAIL:{len(failures)} 条判据没过({len(files)} 个文件)", file=sys.stderr) + return 1 + print(f"ok: {len(files)} 个 workflow 全部能被真 YAML 解析器 parse,且无裸控制字符") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md index c2d5a7b..3386f2e 100644 --- a/packaging/docs/signing.md +++ b/packaging/docs/signing.md @@ -77,12 +77,15 @@ python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a 在 `manifest.json` 生成之后、`gh release create/upload` 之前,多了一步: ```bash -python3 packaging/sign_manifest.py --self-test # RFC 8032 向量自检 -python3 packaging/sign_manifest.py --check-pinned-keyring # 密钥 ↔ trust.rs 必须对得上 -python3 packaging/sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig -python3 packaging/sign_manifest.py --verify dist/manifest.json dist/manifest.json.sig --public-key +python3 packaging/sign_manifest.py --self-test # ① RFC 8032 向量自检 +python3 packaging/sign_manifest.py --check-pinned-keyring # ② 密钥 ↔ trust.rs 必须对得上 +python3 packaging/sign_manifest.py --sign dist/manifest.json # ③ 产出 dist/manifest.json.sig +python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用钉住的公钥回验产出 ``` +第 ④ 步复现「客户端会怎么判」:它**不碰私钥**,只拿 `trust.rs` 里那把公钥去验刚落盘的 `.sig`。 +因此「签名脚本自己说签好了」不构成证据,证据是「按客户端的判据能验过」。 + 四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法: | 现象 | 原因 | 怎么补 | @@ -124,7 +127,19 @@ python3 packaging/sign_manifest.py --verify dist/manifest.json dist/manifest.jso 另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读 `target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。 -## 7. 本地自测(不需要私钥) +## 7. 实现分工(M190 r1 复审后) + +| 哪一半 | 在哪 | 用什么 | +|---|---|---| +| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(拒非规范编码 / 小阶点 / 非规范 `S`) | +| **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) | + +两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成 +仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」, +因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是 +**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`(不做小阶/可锻检查),那批用例当场红。 + +## 8. 本地自测(不需要私钥) ```bash python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析 diff --git a/packaging/ed25519_ref.py b/packaging/ed25519_ref.py new file mode 100644 index 0000000..dea1ca5 --- /dev/null +++ b/packaging/ed25519_ref.py @@ -0,0 +1,142 @@ +"""Ed25519 的**参考实现**(RFC 8032),只给发布侧签名脚本用(M190)。 + +为什么这里自写一套:发布侧签名必须**零第三方依赖**(CI 不装 pip 包),而仓里 `crates/core` 的验签 +用的是 `ed25519-dalek`。两套实现互相独立,`sign_manifest.py --self-test` 用 RFC 8032 §7.1 的同一组 +向量把两边各自钉住 —— 任何一边坏了,向量那一半会红;两边都「一致地错」的概率极低。 + +⚠ 这里的私钥路径(`sign`)**只在发布侧**(`packaging/sign_manifest.py --sign`)跑,不进任何产物; +二进制里只有验签(`ed25519-dalek::verify_strict`)。扩展坐标公式与 dalek/ref10 同源,向量自检兜底。 + + import ed25519_ref as ref + seed = bytes.fromhex("…") # 32 字节种子 + pk = ref.public_key(seed) + sig = ref.sign(seed, b"message") # 64 字节 + assert ref.verify(pk, b"message", sig) +""" + +from __future__ import annotations + +import hashlib + +ALG = "ed25519" + +Q = 2**255 - 19 +L = 2**252 + 27742317777372353535851937790883648493 + +def _inv(x: int) -> int: + return pow(x, Q - 2, Q) + +D = -121665 * _inv(121666) % Q +_D2 = 2 * D % Q +_SQRT_M1 = pow(2, (Q - 1) // 4, Q) + +def _recover_x(y: int, sign: int) -> int | None: + u = (y * y - 1) % Q + v = (D * y * y + 1) % Q + v3 = pow(v, 3, Q) + v7 = pow(v, 7, Q) + x = u * v3 % Q * pow(u * v7 % Q, (Q - 5) // 8, Q) % Q + if (v * x * x - u) % Q != 0: + x = x * _SQRT_M1 % Q + if (v * x * x - u) % Q != 0: + return None + if x % 2 != sign: + x = (Q - x) % Q + return x + +def _decompress(data: bytes) -> tuple[int, int, int, int] | None: + if len(data) != 32: + return None + y = int.from_bytes(data, "little") + sign = (y >> 255) & 1 + y &= (1 << 255) - 1 + if y >= Q: + return None + x = _recover_x(y, sign) + if x is None: + return None + return (x, y, 1, x * y % Q) + +def _add(p: tuple[int, int, int, int], r: tuple[int, int, int, int]): + x1, y1, z1, t1 = p + x2, y2, z2, t2 = r + a = (y1 - x1) * (y2 - x2) % Q + b = (y1 + x1) * (y2 + x2) % Q + c = t1 * _D2 * t2 % Q + d = 2 * z1 * z2 % Q + e, f, g, h = (b - a) % Q, (d - c) % Q, (d + c) % Q, (b + a) % Q + return (e * f % Q, g * h % Q, f * g % Q, e * h % Q) + +def _double(p: tuple[int, int, int, int]): + x1, y1, z1, _ = p + a = x1 * x1 % Q + b = y1 * y1 % Q + c = 2 * z1 * z1 % Q + d = -a % Q + e = ((x1 + y1) * (x1 + y1) - a - b) % Q + g = (d + b) % Q + f = (g - c) % Q + h = (d - b) % Q + return (e * f % Q, g * h % Q, f * g % Q, e * h % Q) + +def _scalarmult(p, e: int): + acc = (0, 1, 1, 0) + for bit in bin(e)[2:] if e else "": + acc = _double(acc) + if bit == "1": + acc = _add(acc, p) + return acc + +def _encode(p) -> bytes: + x, y, z, _ = p + zi = _inv(z) + x, y = x * zi % Q, y * zi % Q + return (y | ((x & 1) << 255)).to_bytes(32, "little") + +def _base(): + encoded = (4 * _inv(5) % Q).to_bytes(32, "little") + return _decompress(encoded) + +def _hint(data: bytes) -> int: + return int.from_bytes(hashlib.sha512(data).digest(), "little") % L + +def public_key(seed: bytes) -> bytes: + h = hashlib.sha512(seed).digest() + a = int.from_bytes(_clamp(bytearray(h[:32])), "little") + return _encode(_scalarmult(_base(), a)) + +def _clamp(scalar: bytearray) -> bytes: + scalar[0] &= 248 + scalar[31] &= 127 + scalar[31] |= 64 + return bytes(scalar) + +def sign(seed: bytes, message: bytes) -> bytes: + if len(seed) != 32: + raise SystemExit("error: Ed25519 种子必须 32 字节") + h = hashlib.sha512(seed).digest() + a = int.from_bytes(_clamp(bytearray(h[:32])), "little") + prefix = h[32:] + r = _hint(prefix + message) + r_encoded = _encode(_scalarmult(_base(), r)) + pk = _encode(_scalarmult(_base(), a)) + k = _hint(r_encoded + pk + message) + s = (r + k * a) % L + return r_encoded + s.to_bytes(32, "little") + +def verify(pk: bytes, message: bytes, signature: bytes) -> bool: + if len(pk) != 32 or len(signature) != 64: + return False + r_bytes, s_bytes = signature[:32], signature[32:] + if int.from_bytes(s_bytes, "little") >= L: + return False + a = _decompress(pk) + r_point = _decompress(r_bytes) + if a is None or r_point is None: + return False + k = _hint(r_bytes + pk + message) + left = _scalarmult(_base(), int.from_bytes(s_bytes, "little")) + return _encode(left) == _encode(_add(r_point, _scalarmult(a, k))) + + + diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py index 5686dc8..6e65508 100644 --- a/packaging/sign_manifest.py +++ b/packaging/sign_manifest.py @@ -13,10 +13,9 @@ Key discipline: the **private key** lives only in the repository Secret is not pinned (a release signed with a key clients do not trust is worse than no release). Manual: `packaging/docs/signing.md`. -Usage: - python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a - ULL_MANIFEST_SIGNING_KEY='{"key_id":"…","seed_hex":"…"}' python3 packaging/sign_manifest.py --sign dist/manifest.json - python3 packaging/sign_manifest.py --check-pinned-keyring | --self-test +Usage: `--generate-key [--key-id K]` / `--sign dist/manifest.json`(钥从环境变量 +`ULL_MANIFEST_SIGNING_KEY` 取)/ `--check-pinned-keyring`(钥必须已钉住)/ `--verify-pinned +dist/manifest.json`(用 trust.rs 里钉的公钥回验产出,复现客户端判据)/ `--self-test`。 """ from __future__ import annotations @@ -41,125 +40,12 @@ KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{ # `crates/core/src/update/ed25519/*`). Validated against the RFC 8032 §7.1 vectors in # `--self-test`; the Rust verifier is validated against the *same* vectors, so the two # implementations cross-check each other. No third-party dependency: CI needs no pip install. -Q = 2**255 - 19 -L = 2**252 + 27742317777372353535851937790883648493 - -def _inv(x: int) -> int: - return pow(x, Q - 2, Q) - -D = -121665 * _inv(121666) % Q -_D2 = 2 * D % Q -_SQRT_M1 = pow(2, (Q - 1) // 4, Q) - -def _recover_x(y: int, sign: int) -> int | None: - u = (y * y - 1) % Q - v = (D * y * y + 1) % Q - v3 = pow(v, 3, Q) - v7 = pow(v, 7, Q) - x = u * v3 % Q * pow(u * v7 % Q, (Q - 5) // 8, Q) % Q - if (v * x * x - u) % Q != 0: - x = x * _SQRT_M1 % Q - if (v * x * x - u) % Q != 0: - return None - if x % 2 != sign: - x = (Q - x) % Q - return x - -def _decompress(data: bytes) -> tuple[int, int, int, int] | None: - if len(data) != 32: - return None - y = int.from_bytes(data, "little") - sign = (y >> 255) & 1 - y &= (1 << 255) - 1 - if y >= Q: - return None - x = _recover_x(y, sign) - if x is None: - return None - return (x, y, 1, x * y % Q) - -def _add(p: tuple[int, int, int, int], r: tuple[int, int, int, int]): - x1, y1, z1, t1 = p - x2, y2, z2, t2 = r - a = (y1 - x1) * (y2 - x2) % Q - b = (y1 + x1) * (y2 + x2) % Q - c = t1 * _D2 * t2 % Q - d = 2 * z1 * z2 % Q - e, f, g, h = (b - a) % Q, (d - c) % Q, (d + c) % Q, (b + a) % Q - return (e * f % Q, g * h % Q, f * g % Q, e * h % Q) - -def _double(p: tuple[int, int, int, int]): - x1, y1, z1, _ = p - a = x1 * x1 % Q - b = y1 * y1 % Q - c = 2 * z1 * z1 % Q - d = -a % Q - e = ((x1 + y1) * (x1 + y1) - a - b) % Q - g = (d + b) % Q - f = (g - c) % Q - h = (d - b) % Q - return (e * f % Q, g * h % Q, f * g % Q, e * h % Q) - -def _scalarmult(p, e: int): - acc = (0, 1, 1, 0) - for bit in bin(e)[2:] if e else "": - acc = _double(acc) - if bit == "1": - acc = _add(acc, p) - return acc - -def _encode(p) -> bytes: - x, y, z, _ = p - zi = _inv(z) - x, y = x * zi % Q, y * zi % Q - return (y | ((x & 1) << 255)).to_bytes(32, "little") - -def _base(): - encoded = (4 * _inv(5) % Q).to_bytes(32, "little") - return _decompress(encoded) - -def _hint(data: bytes) -> int: - return int.from_bytes(hashlib.sha512(data).digest(), "little") % L - -def public_key(seed: bytes) -> bytes: - h = hashlib.sha512(seed).digest() - a = int.from_bytes(_clamp(bytearray(h[:32])), "little") - return _encode(_scalarmult(_base(), a)) - -def _clamp(scalar: bytearray) -> bytes: - scalar[0] &= 248 - scalar[31] &= 127 - scalar[31] |= 64 - return bytes(scalar) - -def sign(seed: bytes, message: bytes) -> bytes: - if len(seed) != 32: - raise SystemExit("error: Ed25519 种子必须 32 字节") - h = hashlib.sha512(seed).digest() - a = int.from_bytes(_clamp(bytearray(h[:32])), "little") - prefix = h[32:] - r = _hint(prefix + message) - r_encoded = _encode(_scalarmult(_base(), r)) - pk = _encode(_scalarmult(_base(), a)) - k = _hint(r_encoded + pk + message) - s = (r + k * a) % L - return r_encoded + s.to_bytes(32, "little") - -def verify(pk: bytes, message: bytes, signature: bytes) -> bool: - if len(pk) != 32 or len(signature) != 64: - return False - r_bytes, s_bytes = signature[:32], signature[32:] - if int.from_bytes(s_bytes, "little") >= L: - return False - a = _decompress(pk) - r_point = _decompress(r_bytes) - if a is None or r_point is None: - return False - k = _hint(r_bytes + pk + message) - left = _scalarmult(_base(), int.from_bytes(s_bytes, "little")) - return _encode(left) == _encode(_add(r_point, _scalarmult(a, k))) - +# Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。 +# 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek —— +# 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from ed25519_ref import ALG, sign, verify, public_key # noqa: E402 def load_key(text: str) -> tuple[str, bytes]: """Parse the secret JSON (`{"key_id":…,"seed_hex":…}`) or a bare 64-char hex seed.""" @@ -313,6 +199,36 @@ def do_check_pinned(key_path: str | None) -> int: print("error: 手册:packaging/docs/signing.md §3/§5", file=sys.stderr) return 1 +def do_verify_pinned(manifest: Path) -> int: + """用 trust.rs 里钉住的公钥回验 `.sig` —— 复现「客户端会怎么判」,不需要私钥。""" + keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8")) + if not keys: + print("error: 信任表是空的,无从回验(先钉公钥,见 --check-pinned-keyring)", file=sys.stderr) + return 1 + sig_path = manifest.with_name(manifest.name + SIG_SUFFIX) + if not sig_path.is_file(): + print(f"error: 没有签名文件 {sig_path}", file=sys.stderr) + return 1 + raw = manifest.read_bytes() + envelope = json.loads(sig_path.read_text(encoding="utf-8")) + for item in envelope.get("signatures", []): + if item.get("alg") != ALG: + continue + try: + signature = bytes.fromhex(item.get("sig", "")) + except ValueError: + continue + for pinned_id, pinned_hex in keys: + if verify(bytes.fromhex(pinned_hex), raw, signature): + print(f"ok: {sig_path.name} 用钉住的公钥 {pinned_id} 能验过(客户端会接受)") + return 0 + print( + "error: 拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版", + file=sys.stderr, + ) + return 1 + + def do_self_test() -> int: vectors = [ ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", @@ -360,6 +276,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--verify", nargs=2, metavar=("MANIFEST", "SIG"), help="手工验一份签名") parser.add_argument("--public-key", metavar="HEX", help="--verify 用的公钥") parser.add_argument("--check-pinned-keyring", action="store_true", help="检查密钥是否已被钉进 trust.rs") + parser.add_argument("--verify-pinned", metavar="MANIFEST", type=Path, help="用 trust.rs 里钉的公钥回验产出") parser.add_argument("--self-test", action="store_true", help="跑 RFC 8032 向量与解析器自检") args = parser.parse_args(argv) @@ -376,6 +293,8 @@ def main(argv: list[str] | None = None) -> int: return do_verify(Path(args.verify[0]), Path(args.verify[1]), args.public_key) if args.check_pinned_keyring: return do_check_pinned(args.key) + if args.verify_pinned: + return do_verify_pinned(args.verify_pinned) parser.print_help() return 2 From a219a21db88b730550048dc8e753e3f0503c7acd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 02:33:08 +0800 Subject: [PATCH 23/33] =?UTF-8?q?fix(m190):=20r2=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E2=80=94=E2=80=94dalek=20strict=20=E5=8F=A3=E5=BE=84=E7=85=A7?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=86=99=E6=AD=A3=E3=80=81=E8=A1=A5=E5=B0=8F?= =?UTF-8?q?=E9=98=B6=20R=20=E7=94=A8=E4=BE=8B=E3=80=81=E9=94=81=E9=87=8C?= =?UTF-8?q?=20toml=5Fdatetime=20=E9=80=80=E5=9B=9E=201.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条 P2 都是「文档/注释与事实不符 + 一条上报事实错了」,代码行为没变。 P2-1 dalek 的 strict vs 非 strict 口径改成正测试出来的样子: * `VerifyingKey::from_bytes` **不**校验编码规范性(`y = p` 的非规范写法返回 Ok,`is_weak() == true`), 先前写的「要求规范编码」是错的; * 非 strict 的 `verify` **也**查 `S` 规范性 —— 先前把它写成「不做可锻检查」是半错; * 于是这批拒绝类用例里,strict 与非 strict 唯一的分歧是**小阶点**:承重的是 `small_order_public_key_is_rejected`,其余几条(malleable / non_canonical_r / empty)换掉 strict 后 **仍然绿**,文件头的表与用例注释都改成这个口径,别让人误以为它们也是守卫。 * 新补 `small_order_r_is_rejected_only_by_strict`:R = 单位元 + S = k·a mod L 的伪造,**两边都断言** —— dalek 的非 strict `verify` 接受它、我们的 `verify` 拒绝。这是第二条「只由 strict 拒」的判据。 * `non_canonical_r_encoding_is_rejected` 里那条「非规范公钥编码」原来对两种实现都绿(等于没测), 现在断言 dalek 的**真实**行为(`from_bytes(...).is_ok()` + `is_weak()`),dalek 升级改口径会红。 P2-2 `packaging/sign_manifest.py` 里指向已删除 `ed25519/{field,group}.rs` 的旧注释删掉(那段还写着 「same formulas」,而两套实现现在是**互相独立**才构成交叉验证)。 P2-3 `Cargo.lock`:`toml_datetime` 从 1.1.3 **退回 1.1.2**(`cargo update -p toml_datetime --precise 1.1.2` 实测可降、`cargo metadata --locked` 自洽)。先前报告里「被 `toml` 拒绝所以无法避免」的理由 **不成立**,已在报告里更正。现在相对 `44a0c87` 的锁变更是**纯新增**:16 个包、零既有版本变动。 变异证明(本轮按要求重跑):把 `verify_strict` 换成 `verify`(并补 `Verifier` trait 使其可编译) ⇒ `test result: FAILED. 110 passed; 2 failed`,红的两条正是 `small_order_public_key_is_rejected` 与 `small_order_r_is_rejected_only_by_strict`;其余拒绝类用例全绿 (与修正后的口径一致)。恢复后 `112 passed; 0 failed`。 --- Cargo.lock | 4 +- crates/core/src/update/ed25519.rs | 28 ++++--- crates/core/src/update/tests/ed25519_cases.rs | 82 ++++++++++++++----- packaging/docs/signing.md | 6 +- packaging/sign_manifest.py | 4 - 5 files changed, 83 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a16ef25..694148f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4881,9 +4881,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] diff --git a/crates/core/src/update/ed25519.rs b/crates/core/src/update/ed25519.rs index 7758146..9863ce0 100644 --- a/crates/core/src/update/ed25519.rs +++ b/crates/core/src/update/ed25519.rs @@ -4,21 +4,23 @@ //! //! M190 第一版为了「scope 里不含 `crates/core/Cargo.toml`」把曲线算术写在了仓里 //! (5x51 素域 + 扩展坐标 + 平方-乘)。r1 复审的裁定是**不允许**:这是**信任锚**, -//! 非规范编码、小阶点、批量验证差异这类微妙错误的后果是「验签通过但本不该通过」, -//! 自写的向量覆盖再全也证明不了「严格性判据选对了」。当时的时机也最好:正式信任表 -//! (`trust::PRODUCTION`)还是空表,换实现**零兼容负担**。 +//! 非规范编码、小阶点这类微妙错误的后果是「验签通过但本不该通过」,自写的向量覆盖再全也证明不了 +//! 「严格性判据选对了」。当时的时机也最好:正式信任表(`trust::PRODUCTION`)还是空表,换实现 +//! **零兼容负担**。于是这里只剩一层薄封装。 //! -//! 于是这里只剩一层薄封装,判据全部交给 dalek: +//! # 判据分工(**实测口径**,`ed25519-dalek 2.2.0`;别照抄「常识」) //! -//! * `VerifyingKey::from_bytes` —— 公钥必须是**规范编码的合法点**(非规范 `y` / 不在曲线上都拒); -//! * `VerifyingKey::verify_strict` —— 比 RFC 8032 的最低要求更严:拒绝**小阶**公钥与小阶 `R` -//! (阻止「谁都能构造自洽四元组」的伪造族),并拒绝**非规范标量 `S`**(`S >= L` 的可锻变体); -//! * `Signature::from_bytes` —— 只搬运 64 字节;真正的判据在 `verify_strict` 里,**不能**换成 -//! `verify`(后者不做小阶/可锻检查)。 +//! | 步骤 | 它实际做什么 | 它**不**做什么 | +//! |---|---|---| +//! | `VerifyingKey::from_bytes` | 解压成曲线上的合法点;解不出来才 `Err` | **不**校验编码规范性:`y >= p` 这种第二种写法照样 `Ok`(此时 `is_weak() == true`)。M190 r2 复审实测并纠正了先前「要求规范编码」的说法 | +//! | `VerifyingKey::verify`(**非 strict**,本仓不用) | 验方程 `[S]B = R + [k]A`,并检查 `S` 是**规范标量**(`S >= L` 会拒) | **不**检查小阶点:小阶公钥 / 小阶 `R` 下「自洽的伪造四元组」能验过 | +//! | `VerifyingKey::verify_strict`(**本仓用的这个**) | 上面全部 + 拒**小阶公钥**与**小阶 `R`** | —— | //! -//! 这批行为由 `tests/ed25519_cases.rs` 逐条钉住(RFC 8032 四条向量 + 六条「该拒就拒」): -//! 那些用例原来用来测自写实现,现在改成**钉 dalek 的 strict 语义**——将来谁把 -//! `verify_strict` 换成 `verify`(或换回自写实现),它们会当场红。 +//! 换成大白话:在这批拒绝类用例里,strict 与**非** strict 唯一的分歧是**小阶点**(不是可锻性—— +//! `verify` 自己就查 `S` 规范性)。所以守卫里真正承重的是 +//! `tests/ed25519_cases.rs::small_order_public_key_is_rejected` 与 +//! `small_order_r_is_rejected_only_by_strict` 这两条;`malleable_s_plus_l_is_rejected` 等其余几条在 +//! 非 strict 下**照样绿**,它们钉的是「判据没被删成恒真」,不要误当成 strict 的守卫。 //! //! # 签名侧不在二进制里 //! @@ -42,7 +44,7 @@ pub(crate) fn verify( signature: &[u8; SIGNATURE_LEN], ) -> bool { let Ok(key) = VerifyingKey::from_bytes(public_key) else { - // 公钥都不是规范编码的合法点:直接拒(`from_bytes` 已经做了曲线方程与规范编码检查)。 + // 不是曲线上的合法点:直接拒。注意 `from_bytes` **不**管编码规范性(见模块文档的表)。 return false; }; key.verify_strict(message, &Signature::from_bytes(signature)) diff --git a/crates/core/src/update/tests/ed25519_cases.rs b/crates/core/src/update/tests/ed25519_cases.rs index 4b5008c..f76d956 100644 --- a/crates/core/src/update/tests/ed25519_cases.rs +++ b/crates/core/src/update/tests/ed25519_cases.rs @@ -3,17 +3,21 @@ //! M190 r1 复审把自写曲线算术整体换成了官方实现(理由见 `crate::update::ed25519` 的模块文档)。 //! 这批用例的定位因此从「测自己的实现」变成「**钉 dalek 的严格语义**」: //! -//! | 用例 | 它挡住什么 | -//! |---|---| -//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量是 RFC 附录的,不是自签自验) | -//! | `malleable_s_plus_l_is_rejected` | 把 `verify_strict` 换成 `verify`(后者不检查 `S` 规范性) | -//! | `small_order_public_key_is_rejected` | 同上:小阶公钥/小阶 `R` 在 `verify` 下能「自洽通过」 | -//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | -//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | -//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧是同一套语义 | +//! | 用例 | 它挡住什么 | 把 `verify_strict` 换成 `verify` 后会怎样(r2 实测) | +//! |---|---|---| +//! | `small_order_public_key_is_rejected` | 小阶公钥下「自洽的伪造」(谁都能凑) | **红** —— 这是**唯一**承重的守卫 | +//! | `small_order_r_is_rejected_only_by_strict` | 小阶 `R`(R = 单位元)的同一族伪造 | **红**(其余几条不会) | +//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量来自 RFC 附录) | 仍绿(`verify` 也验方程) | +//! | `malleable_s_plus_l_is_rejected` | `S >= L` 的可锻变体 | **仍绿**:`verify` 自己就查 `S` 规范性,别把它当 strict 的守卫 | +//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | 仍绿(dalek 靠自己的下游判据拒,见该用例注释) | +//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | 仍绿 | +//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧同一套语义 | 仍绿 | //! -//! 每条都实测过 dalek 2.2 的**实际**行为;与「RFC 最低要求」不同之处在用例注释里写明(见 -//! `non_canonical_r_encoding_is_rejected`:dalek 拒它的理由是**小阶**,不是「非规范编码」单独一条)。 +//! 口径来源:M190 r2 复审在 `ed25519-dalek 2.2.0` 上逐条实测(并把先前的四处错误说法打回)。 +//! **结论**:strict 与**非** strict 在这批输入上唯一的分歧是**小阶点**——所以想确认「strict 还开着」, +//! 看前两条;其余几条只在「判据被删成恒真」时才会红。 + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use crate::update::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN}; @@ -130,8 +134,9 @@ fn signature_bit_flip_is_rejected() { #[test] fn malleable_s_plus_l_is_rejected() { - // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它, - // `verify_strict` 必须拒 —— 这条用例就是「别把 verify_strict 换成 verify」的判据。 + // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它。 + // ⚠ 这条**不是** strict 的守卫:dalek 的非 strict `verify` 自己就检查 `S` 规范性,换掉 strict 它 + // 仍绿(r2 实测)。它挡的是「判据被删成恒真」。strict 的守卫见 `small_order_*` 两条。 let (public, message, signature) = rfc_vectors()[0]; let mut signature = array64(&unhex(signature)); let mut carry = 0_u16; @@ -173,11 +178,13 @@ fn small_order_public_key_is_rejected() { #[test] fn non_canonical_r_encoding_is_rejected() { - // R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同。 - // **dalek 的实际行为(M190 r1 实测)**:`CompressedEdwardsY::decompress` 不单独校验「规范编码」, - // 这个点解压出来是阶为 4 的点 ⇒ 被 `verify_strict` 的**小阶**判据拒。结论(拒)与自写实现的 - // 「非规范编码」理由不同,但对这份输入是同一条出路;下面把「公钥非规范编码」也一并钉住, - // 因为那条走的是 `VerifyingKey::from_bytes` 的编码校验。 + // R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同(非规范)。 + // dalek 的实际行为(r2 实测,与先前的说法不同): + // * `CompressedEdwardsY::decompress` **不**校验编码规范性 —— 这串字节解出来是**阶为 4**的点; + // * 于是它被 `verify_strict` 的**小阶**判据拒,而不是被「编码非规范」拒; + // * 公钥侧同理:`VerifyingKey::from_bytes` 对非规范编码返回 **Ok**(`is_weak() == true`), + // 所以要拒它只能靠下游判据 —— 下面把 dalek 的这个真实行为**断言下来**,将来 dalek 升级 + // 改了口径会红(否则这条断言对任何实现都绿,等于没测)。 let (public, message, _) = rfc_vectors()[0]; let p_bytes: [u8; 32] = [ 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, @@ -188,16 +195,51 @@ fn non_canonical_r_encoding_is_rejected() { signature[..32].copy_from_slice(&p_bytes); assert!( !verify(&array32(&unhex(public)), &unhex(message), &signature), - "非规范 R 编码必须被拒" + "非规范 R 编码必须被拒(dalek 的理由是小阶点)" ); - // 公钥换成同一串非规范编码:`VerifyingKey::from_bytes` 直接拒(连验签都不进)。 + + // dalek 的真实行为:非规范编码的公钥**能**通过 `from_bytes`,并被标成 weak。 + let weak = VerifyingKey::from_bytes(&p_bytes).expect("dalek 2.2 不校验编码规范性,应返回 Ok"); + assert!( + weak.is_weak(), + "这个非规范编码解出的是小阶点,dalek 应标记 is_weak" + ); + // 我们的 `verify` 对同一串公钥仍然拒(下游方程/小阶判据兜住)。 assert!( !verify( &p_bytes, &unhex(message), &array64(&unhex(rfc_vectors()[0].2)) ), - "非规范公钥编码必须被拒" + "非规范/小阶公钥不得验过" + ); +} + +#[test] +fn small_order_r_is_rejected_only_by_strict() { + // **第二条只由 strict 承重的判据**(r2 复审建议补的):小阶 `R`。 + // 构造:R = 单位元(阶 1),S = k·a mod L,其中 k = SHA-512(R‖A‖M) mod L、a 是测试钥的标量。 + // 这样方程 `[S]B = R + [k]A` 自洽 ⇒ **非 strict 的 `verify` 会通过**;`verify_strict` 因 + // 「R 是小阶点」拒。下面两边都断言,所以将来谁把 strict 换成非 strict,这条会红。 + let public = array32(&unhex( + "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b", + )); + let message = b"manifest bytes"; + let forged = array64(&unhex(concat!( + "0100000000000000000000000000000000000000000000000000000000000000", + "698a94a4c82aa13c1a4da6248799849f2254ef333b5f5d69dcbd71b6320e5e0e" + ))); + + // 非 strict 侧:dalek 的 `verify` 会接受这条伪造(这就是 strict 存在的理由)。 + let key = VerifyingKey::from_bytes(&public).expect("合法公钥"); + assert!( + key.verify(message, &Signature::from_bytes(&forged)).is_ok(), + "非 strict 的 verify 应当接受这条伪造(构造前提;若 dalek 改了行为,这条先红)" + ); + // strict 侧:我们导出的 `verify` 必须拒。 + assert!( + !verify(&public, message, &forged), + "小阶 R 的伪造必须被 verify_strict 拒" ); } diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md index 3386f2e..d7962fe 100644 --- a/packaging/docs/signing.md +++ b/packaging/docs/signing.md @@ -131,13 +131,15 @@ python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用 | 哪一半 | 在哪 | 用什么 | |---|---|---| -| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(拒非规范编码 / 小阶点 / 非规范 `S`) | +| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(在非 strict 的基础上额外拒**小阶公钥**与**小阶 `R`**;`S` 规范性是非 strict 的 `verify` 也查的) | | **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) | 两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成 仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」, 因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是 -**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`(不做小阶/可锻检查),那批用例当场红。 +**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`,`small_order_public_key_is_rejected` 与 +`small_order_r_is_rejected_only_by_strict` 会红(这是 r2 实测的结论——其余几条拒绝类用例在非 strict +下仍绿,别把它们当 strict 的守卫)。 ## 8. 本地自测(不需要私钥) diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py index 6e65508..0984304 100644 --- a/packaging/sign_manifest.py +++ b/packaging/sign_manifest.py @@ -36,10 +36,6 @@ TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trus PIN_BEGIN = ">>> PINNED-KEYS >>>" PIN_END = "<<< PINNED-KEYS <<<" KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"') -# Ed25519 (RFC 8032), reference-shaped implementation (same formulas as -# `crates/core/src/update/ed25519/*`). Validated against the RFC 8032 §7.1 vectors in -# `--self-test`; the Rust verifier is validated against the *same* vectors, so the two -# implementations cross-check each other. No third-party dependency: CI needs no pip install. # Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。 # 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek —— # 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。 From a1876a08c082f4488df4bdd17564375ca62998aa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 03:56:19 +0800 Subject: [PATCH 24/33] =?UTF-8?q?feat(m192):=20=E5=8A=A8=E6=95=88=E5=B8=A7?= =?UTF-8?q?=E6=B3=B5=E2=80=94=E2=80=94design=20motion=20=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E6=8E=A5=E8=BF=9B=20iced=20=E7=9A=84=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E5=B8=A7=E8=AE=A2=E9=98=85=20+=20=E9=A6=96=E6=89=B9=203=20?= =?UTF-8?q?=E6=9D=A1=E5=85=A5=E5=9C=BA=E5=8A=A8=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 令牌早就在 `crates/design/src/motion/` 里齐着(16 档时长 / 可采样缓动 / 弹簧二维表 / MotionScale 降级),但适配层从没消费过,所以一切状态变化都是硬切。本次补的正是那条中间层: 新增 `components/motion/`(时间轴,不是控件家族) - `pump.rs`:`Slot`(boot/screen/detail 三个互斥触发点)× `Spec`(时长档 × 缓动档, **唯一一张表**,页面拿不到「给我 250ms」这种口子,D31)→ 进度采样;时间轴按 16ms 结算 (≈60 次/秒),到期轨道**当场删除**。 - `mod.rs`:进程内单时间轴(thread-local;iced 在事件循环线程上串行调 update/view, 与 `App::update(&mut self)` 同一条纪律,测试也因此天然隔离)+ `enter`/`shifted` 两个页面入口。 - **默认 NONE**:只有产品 `on_boot` 按档位打开;`pages_demo` 这类截图 harness 从不打开。 接线(都接在既有那条链上,不另开循环) - `App::needs_frames` 多一条「有轨道」分支 → 与截图 / Busy / toast / watch_until 共享**同一个布尔**, `window::frames()` 在源码里仍只有一处(守卫:双订阅就红)。 - `on_frame` 每帧推进时间轴;`on_heartbeat` 不变(有帧时不推进)。 - 定档策略集中在 `update/boot_env.rs::motion_scale`:截图档 NONE(D30 出图可复现)、 CPU 渲染档 HALF、GPU 档 STANDARD,`ULL_MOTION` 可强制。 首批 3 条动效(`pages::render` 一个漏斗,静息恒为 (0,0) ⇒ 既有截图基线一张都不用重出) - 首屏进入 16px / 换页 12px(纵向) / 列表↔详情 8px(横向); 时长 400/300/350ms 全部取自 `DurationToken`,曲线 `Easing::Standard` (`EmphasizedDecelerate` 把 75% 位移压在 51ms 内走完,12px 的整页平移会像跳一格)。 守卫与取证 - 11 条帧泵算术守卫 + 4 条接线守卫(含「NONE 档帧采样次数必须为 0」与「帧订阅源码只出现一处」); - 5 条变异证明全部复现(删退订 / 删 NONE 短路 / 删 needs_frames 分支 / 删 16ms 闸门 / 加第二条帧订阅 ⇒ 对应用例红); - 多帧序列图 12 张在 `DevDocs/artifacts/m192/`(t=0 / 半程 / 末 + 静息基线, `--diff --diff-scan` 实测换页 12px/6px、详情 8px); - 空闲实证:`ULL_TICK_TRACE=1` 跑真窗口,帧订阅窗口一过 `clock=idle-1hz` 且 `frames=` 冻结。 --- crates/app-iced/src/app/state.rs | 3 + crates/app-iced/src/app/subscription.rs | 10 + crates/app-iced/src/app/subscription/tests.rs | 118 +++++++ crates/app-iced/src/app/update.rs | 69 +++- crates/app-iced/src/app/update/boot_env.rs | 85 ++++- crates/app-iced/src/app/update/clock.rs | 7 +- crates/app-iced/src/components/mod.rs | 3 + crates/app-iced/src/components/motion/mod.rs | 152 +++++++++ crates/app-iced/src/components/motion/pump.rs | 307 ++++++++++++++++++ .../app-iced/src/components/motion/tests.rs | 279 ++++++++++++++++ crates/app-iced/src/pages/mod.rs | 27 +- 11 files changed, 1052 insertions(+), 8 deletions(-) create mode 100644 crates/app-iced/src/app/subscription/tests.rs create mode 100644 crates/app-iced/src/components/motion/mod.rs create mode 100644 crates/app-iced/src/components/motion/pump.rs create mode 100644 crates/app-iced/src/components/motion/tests.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index acff1c8..7128c53 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -56,6 +56,9 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI /// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 /// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: /// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 +/// +/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅, +/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。 pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1); /// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 1511ebb..321b67a 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -11,10 +11,14 @@ //! | 条件 | 为什么需要 33 ms 节拍 | //! |---|---| //! | 截图通道 | 第 3 帧要落盘 | +//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) | //! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | //! | 有未过期的 toast | toast 按毫秒倒计时 | //! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | //! +//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里), +//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。 +//! //! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 //! //! ## 空闲态的时钟从哪来(M90 r3 / P2-2) @@ -43,6 +47,7 @@ use std::time::Instant; use iced::{window, Subscription}; +use super::components::motion; use super::message::Message; use super::state::App; @@ -85,6 +90,8 @@ impl App { /// 现在要不要**时钟**(见模块文档的表)。 pub(super) fn needs_frames(&self) -> bool { self.has_screenshot() + // M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。 + || motion::needs_frames() || !self.state.toasts.is_empty() || !self.state.running().is_empty() // M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。 @@ -94,3 +101,6 @@ impl App { .is_some_and(|deadline| Instant::now() < deadline) } } + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/app/subscription/tests.rs b/crates/app-iced/src/app/subscription/tests.rs new file mode 100644 index 0000000..fb6291a --- /dev/null +++ b/crates/app-iced/src/app/subscription/tests.rs @@ -0,0 +1,118 @@ +//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 +//! +//! 这一组是**接线级**的(走真的 `App::needs_frames`),与 `components::motion::tests` 的算术守卫 +//! 互补:那边证明帧泵会清空轨道,这边证明清空之后订阅表真的跟着变干净。 +//! +//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 + +use super::*; +use crate::app::components::motion::{Slot, FRAME_MS}; +use crate::cli::Options; +use crate::theme::Families; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::motion::MotionScale; +use unlucky_presenter::mock::MockBackend; +use unlucky_presenter::settings_bridge::SettingsBridge; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 +fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) +} + +/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 +fn run_until_idle() { + for _ in 0..1250 { + if !motion::needs_frames() { + return; + } + motion::advance_by(FRAME_MS); + } +} + +/// **动画期间订阅帧、结束立刻退订**(空闲回到 1 Hz 心跳)。 +/// +/// 变异证明:把 `App::needs_frames` 里的 `motion::needs_frames()` 分支删掉 ⇒ 第二条断言红; +/// 把 `Pump::advance` 的「到期即删除」删掉 ⇒ 第三条断言红(跑完还在订阅)。 +#[test] +fn frames_are_subscribed_while_animating_and_dropped_afterwards() { + motion::reset(); + let mut app = headless(); + app.watch_until = None; + assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); + + motion::configure(MotionScale::STANDARD, None); + // 首帧:页面主体入场(Boot 槽) + motion::enter(1, Some("survival")); + assert!(motion::needs_frames(), "起跑后帧泵必须有轨道"); + assert!(app.needs_frames(), "有动画在跑时必须订阅帧"); + + run_until_idle(); + assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); + assert!( + !app.needs_frames(), + "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" + ); + motion::reset(); +} + +/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 +/// +/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧订阅次数非 0,本用例红。 +#[test] +fn no_scale_requests_no_frames_at_all() { + motion::reset(); + let app = headless(); + motion::configure(MotionScale::NONE, None); + + // 三种状态变化:首屏 / 换页 / 换详情 + assert_eq!(motion::enter(1, None), (0.0, 0.0)); + assert_eq!(motion::enter(2, Some("survival")), (0.0, 0.0)); + assert_eq!(motion::enter(2, Some("creative")), (0.0, 0.0)); + run_until_idle(); + + assert!(!motion::needs_frames()); + assert!(!app.needs_frames(), "关掉动效后一帧都不该订阅"); + assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); + motion::reset(); +} + +/// 帧订阅在源码里**只出现一处**:五条理由共享同一个布尔,所以不存在「动画与 Busy 各订一条」。 +/// +/// 这是结构守卫(扫自己的源码):谁另起一条 `window::frames()`,本用例立刻红。 +#[test] +fn there_is_exactly_one_frame_subscription() { + let source = include_str!("../subscription.rs"); + let product = source.split("#[cfg(test)]").next().unwrap_or(source); + let code = product + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join( + " +", + ); + assert_eq!( + code.matches("window::frames()").count(), + 1, + "`window::frames()` 只许出现一处代码(五条理由共享同一个 if)" + ); +} + +/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 +#[test] +fn slots_are_the_three_documented_ones() { + assert_eq!(Slot::ALL.len(), 3); + assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index cca731e..d4297b6 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -20,11 +20,14 @@ //! //! | 场景 | 时钟 | 周期 | 谁触发 | //! |---|---|---|---| -//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | +//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | //! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] | //! //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 +//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空, +//! 下一拍就退回心跳(见 `components::motion`)。 +use crate::app::components::motion; use crate::app::diag::diag; use std::time::Instant; @@ -183,6 +186,10 @@ impl App { fn on_boot(&mut self) -> Task { let mut tasks = vec![window::latest().map(Message::WindowReady)]; + // M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。 + // 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。 + self.configure_motion(); + // M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。 // 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络; // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, @@ -242,6 +249,25 @@ impl App { Task::batch(tasks) } + /// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。 + fn configure_motion(&mut self) { + let pin = boot_env::env_motion_pin(); + let gpu = matches!( + super::backend::effective_kind(), + Some(crate::renderer::Kind::Wgpu) + ); + let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some()); + motion::configure(scale, pin); + // 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。 + diag!( + "[motion] scale={:.2} pin={:?} gpu={} screenshot={}", + motion::scale().factor(), + pin, + gpu, + self.options.screenshot.is_some() + ); + } + /// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。 /// /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 @@ -256,13 +282,52 @@ impl App { heartbeat() } - /// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。 + /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`boot_env::MotionFixture`])。 + fn motion_fixture(&mut self) { + use boot_env::MotionFixture; + let Some(fixture) = boot_env::env_motion_fixture() else { + return; + }; + let route = match (fixture, self.frames) { + // 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。 + (MotionFixture::Detail, 1) => Route::Instances, + (MotionFixture::Detail, 2) => { + let Some(first) = self + .state + .tiles() + .first() + .map(|cell| cell.instance.id.clone()) + else { + return; + }; + Route::Detail(first) + } + (MotionFixture::Route(route), 1) => route, + _ => return, + }; + diag!("[motion] ULL_MOTION_FIXTURE → {route:?}"); + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + } + + /// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第 + /// [`CAPTURE_AT_FRAME`] 帧出图。 /// /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, /// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。 fn on_frame(&mut self) -> Task { self.frames += 1; + // M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的 + // 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。 + motion::advance(); + // 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。 + // 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。 + if self.frames <= 2 { + self.motion_fixture(); + } + let now = Instant::now(); let elapsed = now.saturating_duration_since(self.last_tick); if elapsed >= TICK { diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index 2e77b4e..a4b1789 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -1,8 +1,11 @@ -//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。 +//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。 //! //! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线): -//! **纯函数、无状态**,只是取值表长到了七条。 +//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组 +//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处, +//! 帧泵自己不读环境变量。 +use unlucky_design::motion::MotionScale; use unlucky_presenter::router::Route; /// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。 @@ -25,6 +28,84 @@ pub(super) fn env_start_route() -> Option { } } +/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。 +/// +/// ## 为什么档位在这里算 +/// +/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端 +/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。 +/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略 +/// 只有一处,测试也不必摆环境变量。 +/// +/// | 条件 | 档位 | 为什么 | +/// |---|---|---| +/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) | +/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) | +/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 | +/// | 其余(`wgpu`) | `STANDARD` | —— | +/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) | +/// +/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up), +/// 所以本函数只认上面这些来源。 +pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale { + match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) { + Some("off") | Some("none") => return MotionScale::NONE, + Some("half") => return MotionScale::HALF, + Some("full") => return MotionScale::STANDARD, + _ => {} + } + if pinned { + // 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。 + return MotionScale::STANDARD; + } + if screenshot { + return MotionScale::NONE; + } + if gpu { + MotionScale::STANDARD + } else { + MotionScale::HALF + } +} + +/// `ULL_MOTION_AT=`:把动画时钟钉在某一毫秒(多帧序列图取证)。 +/// +/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在 +/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。 +pub(super) fn env_motion_pin() -> Option { + std::env::var("ULL_MOTION_AT") + .ok()? + .trim() + .parse::() + .ok() + .filter(|ms| ms.is_finite() && *ms >= 0.0) +} + +/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。 +/// +/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::enter`),而截图通道 +/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。 +#[derive(Debug, Clone)] +pub(super) enum MotionFixture { + /// 跳到一个一级页(走「换页」那一档)。 + Route(Route), + /// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。 + Detail, +} + +/// `ULL_MOTION_FIXTURE=`。 +pub(super) fn env_motion_fixture() -> Option { + Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() { + "instances" => MotionFixture::Route(Route::Instances), + "download" => MotionFixture::Route(Route::Download), + "logs" => MotionFixture::Route(Route::Logs), + "settings" => MotionFixture::Route(Route::Settings), + "content" => MotionFixture::Route(Route::Content), + "detail" => MotionFixture::Detail, + _ => return None, + }) +} + /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 /// /// ## 为什么这一拍只能「先记、后落」 diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index bf79ccd..184c82b 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -94,7 +94,7 @@ impl App { // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { diag!( - "[tick] n={} frames={} refreshes={} clock={}", + "[tick] n={} frames={} refreshes={} clock={} motion={}", self.ticks, self.frames, self.refreshes, @@ -102,7 +102,10 @@ impl App { "frames" } else { "idle-1hz" - } + }, + // M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率, + // 也是「动画期间重绘成本」的记账口径,见报告) + crate::app::components::motion::samples() ); } } diff --git a/crates/app-iced/src/components/mod.rs b/crates/app-iced/src/components/mod.rs index b5289b6..8c26d97 100644 --- a/crates/app-iced/src/components/mod.rs +++ b/crates/app-iced/src/components/mod.rs @@ -11,6 +11,7 @@ //! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton) //! divider.rs ← COMPBASE(15 §22) //! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容) +//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它) //! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13) //! inputs/ ← 同上(§9/§20/§11/§8) //! feedback/ ← 同上(§12/§14/§7/§15) @@ -52,6 +53,8 @@ pub mod gallery; pub mod inputs; pub mod interaction; pub mod layout; +/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。 +pub mod motion; pub mod navigation; pub mod scroll; pub mod selection; diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs new file mode 100644 index 0000000..0df9607 --- /dev/null +++ b/crates/app-iced/src/components/motion/mod.rs @@ -0,0 +1,152 @@ +//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。 +//! +//! ## 这个模块解决什么 +//! +//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 / +//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的 +//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧** +//! ([`needs_frames`])。 +//! +//! ## 有界泵的契约(三条,都有守卫) +//! +//! | 契约 | 为什么 | 守在哪 | +//! |---|---|---| +//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` | +//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) | +//! | 每帧推进**不密于** [`FRAME_MS`](16 ms ≈ 60/s) | 帧到得再快也只该改这么多次布局 | `pump::tests::frames_are_capped_at_sixty_per_second` | +//! +//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支, +//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳 +//! 天然不会双订阅——它们共享同一个布尔。 +//! +//! ## `MotionScale::NONE` 是**真归零** +//! +//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是 +//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见 +//! `pump::tests::none_scale_never_starts_a_track`。 +//! +//! ## 为什么是 thread-local 而不是 `App` 的字段 +//! +//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要 +//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在 +//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, +//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。 +//! +//! ## 页面怎么用 +//! +//! 只有一个入口:[`enter`](`pages/mod.rs::render` 每帧调一次)+ [`shifted`](把位移套到树上)。 +//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。 +//! +//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。 +//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。 + +mod pump; + +// 本 mission 里这五个只被测试与文档引用(产品路径只经 `enter`/`shifted`,拿不到时长/缓动), +// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 +// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。 +#[allow(unused_imports)] +pub use pump::{offset, spec, Slot, Spec, FRAME_MS}; + +use std::cell::{Cell, RefCell}; +use std::time::Instant; + +use iced::widget::container; +use iced::{Element, Length, Padding}; + +use unlucky_design::motion::MotionScale; + +// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报(本仓 +// 之前没有 thread_local,所以这条是第一次撞上):换过三种写法都照报,属于误报。初始化确实已是 +// 常量,所以按项显式放过(上在宏外面不算,lint 是对展开后的 item 报的)。 +thread_local! { + /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 + #[allow(clippy::missing_const_for_thread_local)] + static PUMP: RefCell = const { RefCell::new(pump::Pump::new()) }; + /// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。 + #[allow(clippy::missing_const_for_thread_local)] + static LAST_FRAME: Cell> = const { Cell::new(None) }; +} + +/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。 +pub fn configure(scale: MotionScale, pin: Option) { + PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); +} + +/// 当前档位。 +pub fn scale() -> MotionScale { + PUMP.with(|pump| pump.borrow().scale()) +} + +/// 已推进的帧数。 +pub fn samples() -> u64 { + PUMP.with(|pump| pump.borrow().samples()) +} + +/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。 +pub fn needs_frames() -> bool { + PUMP.with(|pump| pump.borrow().is_animating()) +} + +/// 页主体入场:`(screen, detail)` 任一标识变了就跑对应槽;返回**取整后的位移**。 +/// +/// 每帧调一次(`pages/mod.rs::render`)。`detail` 是详情页的实例 id(`None` = 没选中任何一行)。 +/// 没有动画在跑时它只做两次比较,不做任何布局。 +pub fn enter(screen: u64, detail: Option<&str>) -> (f32, f32) { + PUMP.with(|pump| pump.borrow_mut().enter(screen, detail)) +} + +/// 推进时间轴(`App::on_frame` 每帧调一次)。 +pub fn advance() { + let now = Instant::now(); + let elapsed_ms = LAST_FRAME.with(|last| { + let previous = last.replace(Some(now)); + previous.map_or(0.0, |previous| { + now.saturating_duration_since(previous).as_secs_f64() * 1000.0 + }) + }); + PUMP.with(|pump| { + pump.borrow_mut().advance(elapsed_ms); + }); +} + +/// 把入场位移套到树上。 +/// +/// 位移为 `(0, 0)` 时**原样返回**(不额外套一层容器):静息态与「没有动效」的代码路径逐像素相同, +/// 这也是既有截图基线一张都不用重出的原因。 +pub fn shifted<'a, M: 'a>( + element: impl Into>, + (dx, dy): (f32, f32), +) -> Element<'a, M> { + let element = element.into(); + if dx == 0.0 && dy == 0.0 { + return element; + } + container(element) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding { + top: dy, + right: 0.0, + bottom: 0.0, + left: dx, + }) + .into() +} + +#[cfg(test)] +pub fn reset() { + PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new()); + LAST_FRAME.with(|last| last.set(None)); +} + +/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。 +#[cfg(test)] +pub fn advance_by(ms: f64) { + PUMP.with(|pump| { + pump.borrow_mut().advance(ms); + }); +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump.rs new file mode 100644 index 0000000..f0bac6a --- /dev/null +++ b/crates/app-iced/src/components/motion/pump.rs @@ -0,0 +1,307 @@ +//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。 +//! +//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际: +//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死, +//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。 +//! +//! ## 三个概念 +//! +//! | 名字 | 是什么 | 谁定 | +//! |---|---|---| +//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 | +//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) | +//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 | +//! +//! ## 为什么规格表在代码里而不是页面里 +//! +//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种 +//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。 + +use std::collections::BTreeMap; + +use unlucky_design::motion::{DurationToken, Easing, MotionScale}; +use unlucky_design::spacing::Space; + +/// 逐帧推进的**最短间隔**(16 ms ≈ 60 次/秒)。 +/// +/// 为什么需要它:`window::frames()` 的到达频率由合成器/渲染器决定,软件渲染实测能到几百帧/秒。 +/// 帧数本身不贵(贵的是整窗重绘,那由「订阅只在动画期间开着」管住),但**每帧都改布局**会让 +/// 页面每毫秒 reflow 一次。所以时间轴按 16 ms 结算:到得再快也只推进这么多次。 +pub const FRAME_MS: f64 = 16.0; + +/// 一个动效槽。 +/// +/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加 +/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对: +/// 新的一次内容变化取代上一次。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Slot { + /// 首屏进入:进程内只在第一帧跑一次。 + Boot, + /// 换页:路由换了(导航轨点选、返回、启动直达)。 + Screen, + /// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。 + Detail, +} + +impl Slot { + /// 全部槽(遍历顺序稳定,写日志/断言用)。 + pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail]; + + /// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。 + #[cfg(test)] + pub const fn name(self) -> &'static str { + match self { + Slot::Boot => "boot", + Slot::Screen => "screen", + Slot::Detail => "detail", + } + } +} + +/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Spec { + /// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。 + pub duration: DurationToken, + /// 缓动曲线。 + pub easing: Easing, +} + +/// 槽 → 规格(**唯一**一张表)。 +/// +/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/ +/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**: +/// +/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为 +/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的; +/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完 +/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。 +pub const fn spec(slot: Slot) -> Spec { + match slot { + Slot::Boot => Spec { + duration: DurationToken::Medium4, + easing: Easing::Standard, + }, + Slot::Screen => Spec { + duration: DurationToken::Medium2, + easing: Easing::Standard, + }, + Slot::Detail => Spec { + duration: DurationToken::Medium3, + easing: Easing::Standard, + }, + } +} + +/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。 +/// +/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子, +/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。 +pub fn offset(slot: Slot) -> (f32, f32) { + match slot { + Slot::Boot => (0.0, Space::X4.dp()), + Slot::Screen => (0.0, Space::X3.dp()), + Slot::Detail => (Space::X2.dp(), 0.0), + } +} + +/// 一条正在跑的轨道。 +#[derive(Debug, Clone, Copy)] +struct Track { + /// 起跑时刻(动画时间轴的坐标,毫秒)。 + start_ms: f64, + spec: Spec, +} + +/// 帧泵:一条时间轴 + 每槽一条轨道。 +/// +/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而 +/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。 +#[derive(Debug)] +pub struct Pump { + scale: MotionScale, + /// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。 + pin: Option, + now_ms: f64, + pending_ms: f64, + tracks: BTreeMap, + screen: Option, + detail: Option, + painted: bool, + samples: u64, +} + +impl Pump { + /// 出厂态:关着、没跑过、没有轨道。 + pub const fn new() -> Self { + Self { + scale: MotionScale::NONE, + pin: None, + now_ms: 0.0, + pending_ms: 0.0, + tracks: BTreeMap::new(), + screen: None, + detail: None, + painted: false, + samples: 0, + } + } + + /// 定档(启动一拍调一次)。 + /// + /// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于 + /// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次 + /// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。 + pub fn configure(&mut self, scale: MotionScale, pin: Option) { + self.scale = scale; + self.pin = pin; + self.painted = false; + self.screen = None; + self.detail = None; + self.pending_ms = 0.0; + if scale.is_off() { + // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 + self.tracks.clear(); + } + } + + /// 当前档位。 + pub fn scale(&self) -> MotionScale { + self.scale + } + + /// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。 + pub fn samples(&self) -> u64 { + self.samples + } + + /// 时间轴(毫秒)。 + #[cfg(test)] + pub fn now_ms(&self) -> f64 { + self.now_ms + } + + /// 现在有没有动画在跑(= 要不要订阅帧)。 + pub fn is_animating(&self) -> bool { + !self.tracks.is_empty() + } + + /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长)。 + /// + /// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 = + /// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。 + pub fn play(&mut self, slot: Slot) { + self.tracks.clear(); + if self.scale.is_off() { + return; + } + // 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」, + // 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。 + let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms }; + self.tracks.insert( + slot, + Track { + start_ms, + spec: spec(slot), + }, + ); + } + + /// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。 + pub fn progress(&self, slot: Slot) -> f32 { + let Some(track) = self.tracks.get(&slot) else { + return 1.0; + }; + let duration = f64::from(self.scale.ms(track.spec.duration)); + if duration <= 0.0 { + return 1.0; + } + let elapsed = self.elapsed_ms() - track.start_ms; + let t = (elapsed / duration).clamp(0.0, 1.0) as f32; + track.spec.easing.sample(t) + } + + /// 当前动画时刻(钉住相位时恒为钉住值)。 + fn elapsed_ms(&self) -> f64 { + self.pin.unwrap_or(self.now_ms) + } + + /// 页主体的入场位移:**每帧调一次**,标识变了才真的起跑。 + /// + /// 三条判据(顺序即优先级): + /// 1. 第一帧 → [`Slot::Boot`](首屏也算一次「东西出现」); + /// 2. 屏变了 → [`Slot::Screen`]; + /// 3. 屏没变但详情变了 → [`Slot::Detail`]。 + /// + /// 返回值按各槽剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚, + /// 末帧三个槽都是 `1.0` ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 + pub fn enter(&mut self, screen: u64, detail: Option<&str>) -> (f32, f32) { + if !self.painted { + self.painted = true; + self.play(Slot::Boot); + } else if self.screen != Some(screen) { + self.play(Slot::Screen); + } else if self.detail.as_deref() != detail { + self.play(Slot::Detail); + } + self.screen = Some(screen); + if self.detail.as_deref() != detail { + self.detail = detail.map(str::to_owned); + } + + let mut dx = 0.0_f32; + let mut dy = 0.0_f32; + for slot in Slot::ALL { + let remaining = 1.0 - self.progress(slot); + let (ox, oy) = offset(slot); + dx += ox * remaining; + dy += oy * remaining; + } + (dx.round(), dy.round()) + } + + /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 + /// + /// 三步:①不满 [`FRAME_MS`] 就不动(帧上限)②到期的轨道**当场删除**(这是「空闲不烧帧」的 + /// 唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 + /// 里就不再有 `window::frames()`)③钉住相位时时间轴不前进(取证专用)。 + pub fn advance(&mut self, dt_ms: f64) -> bool { + if self.tracks.is_empty() { + self.pending_ms = 0.0; + return false; + } + if dt_ms.is_finite() && dt_ms > 0.0 { + self.pending_ms += dt_ms; + } + if self.pending_ms < FRAME_MS { + return false; + } + let step = std::mem::take(&mut self.pending_ms); + if self.pin.is_none() { + self.now_ms += step; + } + self.samples += 1; + + let now = self.elapsed_ms(); + let finished: Vec = self + .tracks + .iter() + .filter(|(_, track)| { + let duration = f64::from(self.scale.ms(track.spec.duration)); + duration <= 0.0 || now - track.start_ms >= duration + }) + .map(|(slot, _)| *slot) + .collect(); + for slot in finished { + self.tracks.remove(&slot); + } + true + } +} + +impl Default for Pump { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/app-iced/src/components/motion/tests.rs b/crates/app-iced/src/components/motion/tests.rs new file mode 100644 index 0000000..647850f --- /dev/null +++ b/crates/app-iced/src/components/motion/tests.rs @@ -0,0 +1,279 @@ +//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。 + +use super::pump::{offset, spec, Pump, Slot, FRAME_MS}; +use super::*; + +/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。 +fn standard() -> Pump { + let mut pump = Pump::new(); + pump.configure(MotionScale::STANDARD, None); + pump +} + +/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。 +fn settle(pump: &mut Pump) -> u64 { + let mut frames = 0; + for _ in 0..1250 { + if !pump.is_animating() { + break; + } + if pump.advance(FRAME_MS) { + frames += 1; + } + } + frames +} + +// ------------------------------------------------------------------ 令牌表 + +/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。 +#[test] +fn every_slot_spec_is_a_registered_token_pair() { + for slot in Slot::ALL { + let spec = spec(slot); + let ms = spec.duration.ms(); + assert!( + (250..=500).contains(&ms), + "{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)", + slot.name(), + ms + ); + // 缓动必须是真的曲线(端点 = 0/1) + assert!((spec.easing.sample(0.0)).abs() < 1.0e-6); + assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6); + // 位移必须来自间距栅格(4 的倍数),且不为零 + let (dx, dy) = offset(slot); + assert!( + dx > 0.0 || dy > 0.0, + "{}: 位移为 0 就没有入场感", + slot.name() + ); + assert_eq!(dx % 4.0, 0.0); + assert_eq!(dy % 4.0, 0.0); + assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name()); + } + // 表本身钉住:谁改了这三档,报告与出图都要跟着改 + assert_eq!(spec(Slot::Screen).duration.ms(), 300); + assert_eq!(spec(Slot::Detail).duration.ms(), 350); + assert_eq!(spec(Slot::Boot).duration.ms(), 400); +} + +// ------------------------------------------------------------------ 有界泵 + +/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。 +/// +/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把 +/// `is_animating` 改成恒真。两条都让本用例红。 +#[test] +fn a_finished_animation_stops_asking_for_frames() { + let mut pump = standard(); + pump.play(Slot::Screen); + assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); + + settle(&mut pump); + assert!( + !pump.is_animating(), + "动画结束必须**当场**退订(占着轨道 = 空闲烧帧)" + ); + assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位"); + + // 再推进也不该有任何采样(订阅表里已经没有帧了) + let before = pump.samples(); + for _ in 0..60 { + assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false"); + } + assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); +} + +/// **帧间隔上限**:帧到得再快,时间轴也只按 16 ms 结算(≈60 次/秒)。 +/// +/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。 +#[test] +fn frames_are_capped_at_sixty_per_second() { + let mut pump = standard(); + pump.play(Slot::Screen); + + // 200 帧、每帧 1 ms(模拟 1000 fps 的合成器) + let mut advanced = 0; + for _ in 0..200 { + if pump.advance(1.0) { + advanced += 1; + } + } + assert!( + advanced <= 13, + "200 ms 内最多 13 次推进(200/16),实测 {advanced} 次" + ); + assert!( + advanced >= 11, + "也别少得离谱(掉帧会看起来卡),实测 {advanced}" + ); + assert_eq!(pump.samples(), advanced); + // 时间轴按真实时间走,不受「帧被合并」影响 + assert!( + (pump.now_ms() - 192.0).abs() <= 16.0, + "时间轴应约 192 ms(12 × 16),实测 {}", + pump.now_ms() + ); +} + +/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。 +#[test] +fn progress_is_monotone_and_lands_on_one() { + let mut pump = standard(); + pump.play(Slot::Detail); + let mut previous = 0.0; + for _ in 0..40 { + let value = pump.progress(Slot::Detail); + assert!(value >= previous, "进度倒退了:{previous} → {value}"); + previous = value; + if !pump.advance(FRAME_MS) { + break; + } + } + assert_eq!(previous, 1.0, "末帧必须到位"); +} + +// ------------------------------------------------------------------ NONE 降级 + +/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。 +/// +/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里 +/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。 +#[test] +fn none_scale_never_starts_a_track() { + let mut pump = Pump::new(); // 出厂即 NONE + assert!(pump.scale().is_off()); + + // 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道 + assert_eq!(pump.enter(1, None), (0.0, 0.0), "关掉动效必须直接落终态"); + assert!(!pump.is_animating()); + assert_eq!(pump.enter(2, None), (0.0, 0.0)); + assert!(!pump.is_animating()); + assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); + assert!(!pump.is_animating()); + + for _ in 0..120 { + assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的"); + } + assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0"); +} + +/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。 +#[test] +fn switching_to_none_mid_flight_settles_immediately() { + let mut pump = standard(); + pump.play(Slot::Screen); + pump.advance(FRAME_MS); + assert!(pump.is_animating()); + + pump.configure(MotionScale::NONE, None); + assert!(!pump.is_animating(), "关掉动效必须当场清空轨道"); + assert_eq!(pump.progress(Slot::Screen), 1.0); +} + +/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。 +#[test] +fn half_scale_finishes_in_half_the_frames() { + let mut full = standard(); + full.play(Slot::Screen); + let full_frames = settle(&mut full); + + let mut half = Pump::new(); + half.configure(MotionScale::HALF, None); + half.play(Slot::Screen); + let half_frames = settle(&mut half); + + assert!(half_frames > 0 && full_frames > 0); + assert!( + half_frames * 2 <= full_frames + 2, + "半速档应约用一半帧数:full={full_frames} half={half_frames}" + ); +} + +// ------------------------------------------------------------------ 触发判据 + +/// 三个槽的触发优先级:首帧 Boot → 换屏 Screen → 同屏换详情 Detail。 +#[test] +fn identity_changes_pick_the_right_slot() { + let mut pump = standard(); + + // 首帧:Boot(位移是纵向 16) + let (dx, dy) = pump.enter(1, None); + assert_eq!(dx, 0.0); + assert!(dy > 0.0, "首屏应有一个向下的入场位移"); + assert!(pump.is_animating(), "首帧要跑 Boot 槽"); + assert_eq!( + pump.progress(Slot::Screen), + 1.0, + "首帧不该顺带起跑 Screen 槽" + ); + settle(&mut pump); + assert_eq!(pump.enter(1, None), (0.0, 0.0), "标识没变 ⇒ 不动"); + + // 换屏:Screen(纵向) + let (dx, dy) = pump.enter(2, None); + assert_eq!(dx, 0.0); + assert!(dy > 0.0); + assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽"); + settle(&mut pump); + + // 同屏换详情:Detail(横向) + let (dx, dy) = pump.enter(2, Some("instance-7")); + assert!(dx > 0.0, "列表↔详情是横向入场"); + assert_eq!(dy, 0.0); + assert!(pump.progress(Slot::Detail) < 1.0); + settle(&mut pump); + assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); +} + +/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。 +#[test] +fn a_new_transition_supersedes_the_previous_one() { + let mut pump = standard(); + let boot_dy = offset(Slot::Boot).1; + assert_eq!(pump.enter(1, None).1, boot_dy, "首帧从 Boot 的最大位移开始"); + + // 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加 + let (_, dy) = pump.enter(2, None); + assert_eq!( + dy, + offset(Slot::Screen).1, + "新的一次内容变化应取代上一次(叠加会让位移变成两档之和)" + ); + assert!(!pump.is_animating() || pump.progress(Slot::Boot) == 1.0); +} + +/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。 +#[test] +fn repeated_paints_never_restart_the_animation() { + let mut pump = standard(); + pump.enter(3, Some("instance-9")); + let frames = settle(&mut pump); + assert!(frames > 0); + + for _ in 0..30 { + assert_eq!(pump.enter(3, Some("instance-9")), (0.0, 0.0)); + } + assert!(!pump.is_animating(), "重复绘制不该起新轨道"); +} + +// ------------------------------------------------------------------ 取证相位 + +/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。 +#[test] +fn pinned_phase_freezes_the_clock() { + let mut pump = Pump::new(); + pump.configure(MotionScale::STANDARD, Some(150.0)); + pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半 + + for _ in 0..10 { + pump.advance(FRAME_MS); + } + let progress = pump.progress(Slot::Screen); + assert!( + (progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6, + "钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}" + ); + assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)"); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 1fb36c1..3828af7 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -143,6 +143,7 @@ use iced::{Element, Length}; use unlucky_design::elevation::MaterialLayer; use unlucky_design::spacing::Space; +use crate::app::components::motion; use crate::theme::Ui; use frame::filled; @@ -243,14 +244,36 @@ fn render<'a>( // M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。 // 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么; // 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。 - match update::banner(ui, &model.update, model.lang.catalog()) { + let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) { Some(banner) => column![banner, page] .spacing(ui.space(Space::X3)) .width(Length::Fill) .height(Length::Fill) .into(), None => page, - } + }; + + // M192:主体入场(首屏 / 换页 / 列表↔详情)。位移由帧泵按令牌表给,**静息恒为 `(0, 0)`** + // 且此时 `motion::shifted` 原样返回,所以关闭动效与「末帧」两条路径都与没有动效时逐像素相同。 + let shift = motion::enter(screen_key(model.screen), detail_key(model)); + motion::shifted(tree, shift) +} + +/// 屏的稳定标识:值变了才跑「换页」那一档([`Screen::ALL`] 的下标 + 1,`0` 留给「还没见过」)。 +fn screen_key(screen: Screen) -> u64 { + Screen::ALL + .iter() + .position(|candidate| *candidate == screen) + .map_or(0, |index| index as u64 + 1) +} + +/// 详情页的稳定标识:实例 id(`None` = 没选中任何一行)。同一个 id 重复绘制不会重启动画。 +fn detail_key(model: &Model) -> Option<&str> { + model + .instances + .detail + .as_ref() + .map(|detail| detail.id.as_str()) } /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。 From 93bd7047861146bf04655586050140308b195500 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 04:52:40 +0800 Subject: [PATCH 25/33] =?UTF-8?q?fix(m192):=20r1=20=E5=A4=8D=E5=AE=A1=20P1?= =?UTF-8?q?-1=E2=80=94=E2=80=94=E5=8A=A8=E6=95=88=E8=B5=B7=E8=B7=91?= =?UTF-8?q?=E6=90=AC=E8=BF=9B=20update=20=E4=BE=A7=20+=20=E5=B8=A7?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E5=85=9C=E5=BA=95=20+=20=E6=8C=89=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=A1=BA=E5=BA=8F=E7=9A=84=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审的机制三步全部复核成立(iced_winit-0.14.1 `src/lib.rs:1222` 调 update、`1338` 在 `update()` 内部求值订阅表、`1233` 之后才 `build_user_interfaces` 调 view)。第一版把 「标识变了 → play」写在 `pages::render`(= view)里,于是:同一拍的订阅表看不见轨道 ⇒ `frames()` 要等下一条消息才打开;而那一帧的 dt 是整段空闲时长 ⇒ 轨道被当场判到期删掉。 用户看到的是「一帧 12/8px 偏移 + 闪跳」,比硬切更差;截图通道因为 `has_screenshot()` 恒真, 结构上不可能发现它。 修法(三件都做) - **起跑搬进 update**:新增 `app/update/motion_sync.rs::App::sync_motion`,在 `App::update` 收尾(`route_message` 之后、`subscription()` 之前)按**路由**算内容标识并 `motion::observe`。 `pages::render` 只留 `motion::sample()`(纯采样)。 顺带:`screen == None`(账户页 / AI 独立页)只记标识不起跑,于是「从它们回到某一屏」也跑换页。 - **帧基准兜底**:`observe` 起跑成功时把 `LAST_FRAME` 重置到「现在」;`Pump::advance` 再加一道 `MAX_STEP_MS`(4 帧 = 64 ms)单次步进上限——掉帧/挂起时宁可把动画放慢,也不许一帧抹掉。 - **没有窗口就不起跑**:`window.is_none()` 且不是截图通道时 `sync_motion` 直接返回(也**不记** 标识)⇒「首屏入场」等窗口到了才算第一眼,启动期那棵空树吃不掉它(原先被 1 Hz 心跳 / 既有 `an_idle_app_...` 用例照出来的就是这个边界)。 - `on_heartbeat` 也推进动效时间轴:窗口被遮挡/最小化/合成器不送绘制时,帧一个都不来,而 「有轨道」会让 1 Hz 推进整段停摆 ⇒ 轨道靠心跳按单次上限收尾(最坏 5 拍内自然退订)。 守卫(新增 5 条,全部按运行时顺序写) - `a_state_change_opens_the_frame_subscription_in_the_same_tick`:**先** `assert!(!needs_frames)` →**一条真消息**(`Message::Page(Navigate(..))`)→ `assert!(needs_frames)`,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开帧」这条假绿)。r1 那条顺序是反的,已删。 - `opening_a_detail_also_opens_the_frames_in_the_same_tick`、`pages_that_render_elsewhere_do_not_start_a_track`、 `without_a_window_nothing_animates`、`a_stale_baseline_cannot_kill_the_track_in_one_sample`。 r1 两条 P2 - P2-1:`pump::FRAME_MS` 与 `mod.rs` 契约表补写「16 ms 只卡采样/布局推进,**整窗重绘跟随渲染器** (软件渲染 ≈970 帧/秒、一次 300 ms 换页 ≈290 次整窗重绘 ≈0.30 核·秒;GPU 档 AutoVsync 40~60)」。 - P2-2:删掉 `app/subscription/tests.rs`,测试并回 `app/subscription.rs` 的 `mod tests` ⇒ 本轮 动过的文件全部落在声明 scope 内(新文件只有 `app/update/motion_sync.rs`,在 `app/update/**` 下)。 真机证据(GPU 档,`ULL_TICK_TRACE=1`,空闲 14 s 后由心跳触发一次**真实**导航, `DevDocs/artifacts/m192/idle-click-evidence-gpu-final.log`): idle: frames=96 clock=idle-1hz motion=14(连续 7 拍不动) fire: clock=frames motion=16 → 17(同一拍订阅表就开了) end : frames=102 clock=idle-1hz motion=20(自己退订) 说明:本机会话没有前台窗口(`GetForegroundWindow()==0`),合成点击(`mouse_event` 与 `PostMessage(WM_LBUTTONDOWN)`)递不进 iced 的输入层,故用「心跳第 N 秒真导航」这条同构路径; 样本次数由**绘制送达**决定(该次动效只收到 ~5 次绘制 ⇒ +6 次采样),正常可见窗口下 60 Hz 呈现 对应 ~19 次。 变异证明 7/7 红(`DevDocs/artifacts/m192/mutate.sh`,含新增 M6「删掉 sync_motion」与 M7「去掉单次步进上限」),还原后自检全绿。 编译:cargo test -p unlucky-app-iced → 0(797 passed / 2 ignored,集成 6 passed); clippy --all-targets -D warnings → 0;fmt --check → 0;build → 0。 出图基线复验:三张 rest 基线与九张序列帧的 sha256 与 r1 **逐字节相同**(常驻的那层 container 零内距)。 --- crates/app-iced/src/app/subscription.rs | 248 +++++++++++++++++- crates/app-iced/src/app/subscription/tests.rs | 118 --------- crates/app-iced/src/app/update.rs | 53 ++-- crates/app-iced/src/app/update/boot_env.rs | 22 +- crates/app-iced/src/app/update/motion_sync.rs | 166 ++++++++++++ crates/app-iced/src/components/motion/mod.rs | 70 +++-- crates/app-iced/src/components/motion/pump.rs | 85 ++++-- .../app-iced/src/components/motion/tests.rs | 118 +++++++-- crates/app-iced/src/pages/mod.rs | 25 +- 9 files changed, 665 insertions(+), 240 deletions(-) delete mode 100644 crates/app-iced/src/app/subscription/tests.rs create mode 100644 crates/app-iced/src/app/update/motion_sync.rs diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 321b67a..d49ce2a 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -101,6 +101,250 @@ impl App { .is_some_and(|deadline| Instant::now() < deadline) } } - #[cfg(test)] -mod tests; +mod tests { + //! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 + //! + //! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训) + //! + //! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是 + //! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。 + //! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表, + //! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。 + //! + //! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 + + use super::*; + use crate::app::components::motion::{Slot, FRAME_MS}; + use crate::cli::Options; + use crate::pages; + use crate::theme::Families; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::router::Route; + use unlucky_presenter::settings_bridge::SettingsBridge; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 + fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + + /// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。 + /// + /// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完, + /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 + fn idle_app() -> App { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + // 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档) + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + run_until_idle(); + assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); + app + } + + /// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 + fn run_until_idle() { + for _ in 0..1250 { + if !motion::needs_frames() { + return; + } + motion::advance_by(FRAME_MS); + } + } + + /// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。 + /// + /// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的 + /// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。 + #[test] + fn a_state_change_opens_the_frame_subscription_in_the_same_tick() { + let mut app = idle_app(); + assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); + + // 用户点了一次导航轨:**一条**消息,之后不再补任何消息 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + + assert!( + app.watch_until.is_none(), + "换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)" + ); + assert!(motion::needs_frames(), "换页必须在同一拍起跑"); + assert!( + app.needs_frames(), + "同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)" + ); + + run_until_idle(); + assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); + assert!( + !app.needs_frames(), + "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" + ); + motion::reset(); + } + + /// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。 + #[test] + fn opening_a_detail_also_opens_the_frames_in_the_same_tick() { + let mut app = idle_app(); + // 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + run_until_idle(); + assert!(!app.needs_frames()); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert!(app.needs_frames(), "点进详情必须当拍订阅帧"); + run_until_idle(); + assert!(!app.needs_frames()); + motion::reset(); + } + + /// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。 + #[test] + fn pages_that_render_elsewhere_do_not_start_a_track() { + let mut app = idle_app(); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts))); + assert!( + !motion::needs_frames(), + "账户页没有 pages 树可动,不许白开帧" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑"); + run_until_idle(); + motion::reset(); + } + + /// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档; + /// 首屏那一条也因此要等窗口到了才算「第一眼」。 + #[test] + fn without_a_window_nothing_animates() { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + assert!(app.window.is_none(), "前提:还没拿到窗口句柄"); + let _ = app.update(Message::Boot); + app.watch_until = None; + assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑"); + + // 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉) + let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique()))); + assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑"); + run_until_idle(); + motion::reset(); + } + + /// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 + /// + /// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。 + #[test] + fn no_scale_requests_no_frames_at_all() { + motion::reset(); + let mut app = headless(); + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + motion::configure(MotionScale::NONE, None); + + // 三种状态变化:换页 / 换详情 / 再换回来 + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅"); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert!(!app.needs_frames()); + run_until_idle(); + + assert!(!motion::needs_frames()); + assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); + motion::reset(); + } + + /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。 + /// + /// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看 + /// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`—— + /// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。 + #[test] + fn there_is_exactly_one_frame_subscription() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + collect_rust_files(&src, &mut files); + assert!( + files.len() >= 40, + "只扫到 {} 个文件:扫描逻辑坏了", + files.len() + ); + + let mut hits = Vec::new(); + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); + let product = text.split("#[cfg(test)]").next().unwrap_or(&text); + let code = product + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join( + " +", + ); + let count = code.matches("window::frames()").count(); + if count > 0 { + let name = path + .strip_prefix(&src) + .unwrap_or(&path) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + hits.push((name, count)); + } + } + + hits.sort(); + let expected = [ + ("app/subscription.rs".to_string(), 1_usize), + ("pages/harness.rs".to_string(), 1_usize), + ]; + assert_eq!( + hits, expected, + "`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\ + 与 `pages/harness.rs`(独立 example 的自有 app),且各一处" + ); + } + + /// 递归收集 `dir/**/*.rs`。 + fn collect_rust_files(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("读目录") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + collect_rust_files(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } + } + + /// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 + #[test] + fn slots_are_the_three_documented_ones() { + assert_eq!(Slot::ALL.len(), 3); + assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); + } +} diff --git a/crates/app-iced/src/app/subscription/tests.rs b/crates/app-iced/src/app/subscription/tests.rs deleted file mode 100644 index fb6291a..0000000 --- a/crates/app-iced/src/app/subscription/tests.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。 -//! -//! 这一组是**接线级**的(走真的 `App::needs_frames`),与 `components::motion::tests` 的算术守卫 -//! 互补:那边证明帧泵会清空轨道,这边证明清空之后订阅表真的跟着变干净。 -//! -//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 - -use super::*; -use crate::app::components::motion::{Slot, FRAME_MS}; -use crate::cli::Options; -use crate::theme::Families; -use unlucky_design::color::SchemeFlavor; -use unlucky_design::motion::MotionScale; -use unlucky_presenter::mock::MockBackend; -use unlucky_presenter::settings_bridge::SettingsBridge; - -const FAMILIES: Families = Families { - text: "Segoe UI", - display: "Segoe UI", - mono: "Consolas", -}; - -/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 -fn headless() -> App { - App::with_presenter( - Options::default(), - FAMILIES, - SchemeFlavor::Light, - Box::new(MockBackend::new()), - SettingsBridge::memory_only(), - ) -} - -/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 -fn run_until_idle() { - for _ in 0..1250 { - if !motion::needs_frames() { - return; - } - motion::advance_by(FRAME_MS); - } -} - -/// **动画期间订阅帧、结束立刻退订**(空闲回到 1 Hz 心跳)。 -/// -/// 变异证明:把 `App::needs_frames` 里的 `motion::needs_frames()` 分支删掉 ⇒ 第二条断言红; -/// 把 `Pump::advance` 的「到期即删除」删掉 ⇒ 第三条断言红(跑完还在订阅)。 -#[test] -fn frames_are_subscribed_while_animating_and_dropped_afterwards() { - motion::reset(); - let mut app = headless(); - app.watch_until = None; - assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); - - motion::configure(MotionScale::STANDARD, None); - // 首帧:页面主体入场(Boot 槽) - motion::enter(1, Some("survival")); - assert!(motion::needs_frames(), "起跑后帧泵必须有轨道"); - assert!(app.needs_frames(), "有动画在跑时必须订阅帧"); - - run_until_idle(); - assert!(!motion::needs_frames(), "动画跑完帧泵必须清空"); - assert!( - !app.needs_frames(), - "动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)" - ); - motion::reset(); -} - -/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 -/// -/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧订阅次数非 0,本用例红。 -#[test] -fn no_scale_requests_no_frames_at_all() { - motion::reset(); - let app = headless(); - motion::configure(MotionScale::NONE, None); - - // 三种状态变化:首屏 / 换页 / 换详情 - assert_eq!(motion::enter(1, None), (0.0, 0.0)); - assert_eq!(motion::enter(2, Some("survival")), (0.0, 0.0)); - assert_eq!(motion::enter(2, Some("creative")), (0.0, 0.0)); - run_until_idle(); - - assert!(!motion::needs_frames()); - assert!(!app.needs_frames(), "关掉动效后一帧都不该订阅"); - assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0"); - motion::reset(); -} - -/// 帧订阅在源码里**只出现一处**:五条理由共享同一个布尔,所以不存在「动画与 Busy 各订一条」。 -/// -/// 这是结构守卫(扫自己的源码):谁另起一条 `window::frames()`,本用例立刻红。 -#[test] -fn there_is_exactly_one_frame_subscription() { - let source = include_str!("../subscription.rs"); - let product = source.split("#[cfg(test)]").next().unwrap_or(source); - let code = product - .lines() - .filter(|line| !line.trim_start().starts_with("//")) - .collect::>() - .join( - " -", - ); - assert_eq!( - code.matches("window::frames()").count(), - 1, - "`window::frames()` 只许出现一处代码(五条理由共享同一个 if)" - ); -} - -/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 -#[test] -fn slots_are_the_three_documented_ones() { - assert_eq!(Slot::ALL.len(), 3); - assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); -} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index d4297b6..80cdd9f 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -56,6 +56,7 @@ mod dialog; mod dialog_fixture; mod download; mod instances; +mod motion_sync; mod page_messages; mod settings; mod update_action; @@ -73,7 +74,18 @@ fn is_escape(event: &iced::keyboard::Event) -> bool { impl App { /// 一条消息进来,改状态并返回后续任务。 + /// + /// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在 + /// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块 + /// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。 pub(super) fn update(&mut self, message: Message) -> Task { + let task = self.route_message(message); + self.sync_motion(); + task + } + + /// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。 + fn route_message(&mut self, message: Message) -> Task { match message { Message::Boot => self.on_boot(), Message::WindowReady(id) => { @@ -273,44 +285,23 @@ impl App { /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 fn on_heartbeat(&mut self) -> Task { + // 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而 + // 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把 + // 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」 + // 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。 + motion::advance(); if !self.needs_frames() { let now = Instant::now(); let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK); self.last_tick = now; self.advance(elapsed); } + // M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒ + // 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。 + self.motion_fixture_after_idle(); heartbeat() } - /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`boot_env::MotionFixture`])。 - fn motion_fixture(&mut self) { - use boot_env::MotionFixture; - let Some(fixture) = boot_env::env_motion_fixture() else { - return; - }; - let route = match (fixture, self.frames) { - // 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。 - (MotionFixture::Detail, 1) => Route::Instances, - (MotionFixture::Detail, 2) => { - let Some(first) = self - .state - .tiles() - .first() - .map(|cell| cell.instance.id.clone()) - else { - return; - }; - Route::Detail(first) - } - (MotionFixture::Route(route), 1) => route, - _ => return, - }; - diag!("[motion] ULL_MOTION_FIXTURE → {route:?}"); - self.state.navigate(route); - self.view_dirty = true; - self.refresh_view(); - } - /// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第 /// [`CAPTURE_AT_FRAME`] 帧出图。 /// @@ -324,7 +315,9 @@ impl App { motion::advance(); // 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。 // 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。 - if self.frames <= 2 { + // `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥, + // 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。 + if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() { self.motion_fixture(); } diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index a4b1789..a075c27 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -83,7 +83,7 @@ pub(super) fn env_motion_pin() -> Option { /// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。 /// -/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::enter`),而截图通道 +/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道 /// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。 #[derive(Debug, Clone)] pub(super) enum MotionFixture { @@ -106,6 +106,26 @@ pub(super) fn env_motion_fixture() -> Option { }) } +/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。 +/// +/// ## 为什么需要它(真机空闲取证) +/// +/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话 +/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与 +/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路: +/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。 +/// +/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()` +/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。 +pub(super) fn env_motion_fixture_at() -> Option { + std::env::var("ULL_MOTION_FIXTURE_AT") + .ok()? + .trim() + .parse::() + .ok() + .filter(|beats| *beats > 0) +} + /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 /// /// ## 为什么这一拍只能「先记、后落」 diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs new file mode 100644 index 0000000..3996dac --- /dev/null +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -0,0 +1,166 @@ +//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。 +//! +//! ## 为什么这条不能在 `view` 里 +//! +//! iced 0.14 的处理顺序是: +//! +//! ```text +//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338) +//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233) +//! ``` +//! +//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render` +//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、 +//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉, +//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真, +//! **结构上**不可能暴露这条。 +//! +//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`], +//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。 +//! +//! ## 标识从哪来 +//! +//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走 +//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑, +//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。 + +use unlucky_presenter::router::Route; + +use crate::app::components::motion; +use crate::pages; + +use super::boot_env::MotionFixture; + +use super::*; + +impl App { + /// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。 + pub(in crate::app) fn sync_motion(&mut self) { + // **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时 + // `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义—— + // 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。 + // 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。 + if self.window.is_none() && !self.has_screenshot() { + return; + } + let route = self.state.route(); + let screen = screen_key(route); + let detail = match route { + Route::Detail(id) => Some(id.as_str()), + _ => None, + }; + motion::observe(screen, detail); + } + + /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。 + pub(in crate::app) fn motion_fixture(&mut self) { + let Some(fixture) = super::boot_env::env_motion_fixture() else { + return; + }; + let route = match (fixture, self.frames) { + // 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。 + (MotionFixture::Detail, 1) => Route::Instances, + (MotionFixture::Detail, 2) => { + let Some(first) = self + .state + .tiles() + .first() + .map(|cell| cell.instance.id.clone()) + else { + return; + }; + Route::Detail(first) + } + (MotionFixture::Route(route), 1) => route, + _ => return, + }; + diag!("[motion] ULL_MOTION_FIXTURE → {route:?}"); + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + } + + /// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。 + /// + /// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有 + /// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。 + /// 返回 `true` = 这一拍真的跳了页。 + pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool { + use std::sync::atomic::{AtomicU32, Ordering}; + /// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。 + static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0); + + let Some(target) = boot_env::env_motion_fixture_at() else { + return false; + }; + let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1; + if beat != target { + return false; + } + let route = match boot_env::env_motion_fixture() { + Some(MotionFixture::Route(route)) => route, + Some(MotionFixture::Detail) => { + let Some(id) = self + .state + .tiles() + .first() + .map(|cell| cell.instance.id.clone()) + else { + return false; + }; + Route::Detail(id) + } + None => Route::Download, + }; + diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}"); + self.state.navigate(route); + self.view_dirty = true; + self.refresh_view(); + true + } +} + +/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。 +/// +/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`), +/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option`,这里 +/// 再折成 `Option` 给帧泵用。 +fn screen_key(route: &Route) -> Option { + let screen = pages::Screen::of(route)?; + pages::Screen::ALL + .iter() + .position(|candidate| *candidate == screen) + .map(|index| index as u64 + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。 + #[test] + fn only_the_five_screens_get_a_key() { + let keys: Vec> = [ + Route::Instances, + Route::Download, + Route::Content, + Route::Settings, + Route::Logs, + Route::Detail("survival".to_string()), + Route::Accounts, + Route::Agent, + ] + .iter() + .map(screen_key) + .collect(); + + assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]); + assert_eq!( + keys[5], + Some(1), + "详情页归「实例」那一屏(与 Screen::of 同源)" + ); + assert_eq!(keys[6], None, "账户页不归 pages 画"); + assert_eq!(keys[7], None, "AI 独立页不归 pages 画"); + } +} diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 0df9607..5919e1c 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -7,13 +7,18 @@ //! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧** //! ([`needs_frames`])。 //! -//! ## 有界泵的契约(三条,都有守卫) +//! ## 有界泵的契约(四条,都有守卫) //! //! | 契约 | 为什么 | 守在哪 | //! |---|---|---| //! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` | //! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) | -//! | 每帧推进**不密于** [`FRAME_MS`](16 ms ≈ 60/s) | 帧到得再快也只该改这么多次布局 | `pump::tests::frames_are_capped_at_sixty_per_second` | +//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` | +//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` | +//! +//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**: +//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**), +//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。 //! //! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支, //! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳 @@ -32,9 +37,17 @@ //! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, //! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。 //! -//! ## 页面怎么用 +//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了 +//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。 +//! +//! ## 谁在什么时候调 +//! +//! | 时机 | 调用 | 在哪 | +//! |---|---|---| +//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) | +//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` | +//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` | //! -//! 只有一个入口:[`enter`](`pages/mod.rs::render` 每帧调一次)+ [`shifted`](把位移套到树上)。 //! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。 //! //! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。 @@ -42,7 +55,7 @@ mod pump; -// 本 mission 里这五个只被测试与文档引用(产品路径只经 `enter`/`shifted`,拿不到时长/缓动), +// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动), // 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 // (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。 #[allow(unused_imports)] @@ -56,9 +69,10 @@ use iced::{Element, Length, Padding}; use unlucky_design::motion::MotionScale; -// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报(本仓 -// 之前没有 thread_local,所以这条是第一次撞上):换过三种写法都照报,属于误报。初始化确实已是 -// 常量,所以按项显式放过(上在宏外面不算,lint 是对展开后的 item 报的)。 +// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报 +// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等 +// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对 +// 展开后的 item 报的)。 thread_local! { /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 #[allow(clippy::missing_const_for_thread_local)] @@ -88,12 +102,30 @@ pub fn needs_frames() -> bool { PUMP.with(|pump| pump.borrow().is_animating()) } -/// 页主体入场:`(screen, detail)` 任一标识变了就跑对应槽;返回**取整后的位移**。 +/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// -/// 每帧调一次(`pages/mod.rs::render`)。`detail` 是详情页的实例 id(`None` = 没选中任何一行)。 -/// 没有动画在跑时它只做两次比较,不做任何布局。 -pub fn enter(screen: u64, detail: Option<&str>) -> (f32, f32) { - PUMP.with(|pump| pump.borrow_mut().enter(screen, detail)) +/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页), +/// `detail` 是详情页的实例 id。 +/// +/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1) +/// +/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces` +/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插, +/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开, +/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。 +/// +/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值, +/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。 +pub fn observe(screen: Option, detail: Option<&str>) { + let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail)); + if started { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } +} + +/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。 +pub fn sample() -> (f32, f32) { + PUMP.with(|pump| pump.borrow().offsets()) } /// 推进时间轴(`App::on_frame` 每帧调一次)。 @@ -112,17 +144,15 @@ pub fn advance() { /// 把入场位移套到树上。 /// -/// 位移为 `(0, 0)` 时**原样返回**(不额外套一层容器):静息态与「没有动效」的代码路径逐像素相同, -/// 这也是既有截图基线一张都不用重出的原因。 +/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的 +/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套, +/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。 +/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。 pub fn shifted<'a, M: 'a>( element: impl Into>, (dx, dy): (f32, f32), ) -> Element<'a, M> { - let element = element.into(); - if dx == 0.0 && dy == 0.0 { - return element; - } - container(element) + container(element.into()) .width(Length::Fill) .height(Length::Fill) .padding(Padding { diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump.rs index f0bac6a..749efee 100644 --- a/crates/app-iced/src/components/motion/pump.rs +++ b/crates/app-iced/src/components/motion/pump.rs @@ -22,13 +22,28 @@ use std::collections::BTreeMap; use unlucky_design::motion::{DurationToken, Easing, MotionScale}; use unlucky_design::spacing::Space; -/// 逐帧推进的**最短间隔**(16 ms ≈ 60 次/秒)。 +/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// -/// 为什么需要它:`window::frames()` 的到达频率由合成器/渲染器决定,软件渲染实测能到几百帧/秒。 -/// 帧数本身不贵(贵的是整窗重绘,那由「订阅只在动画期间开着」管住),但**每帧都改布局**会让 -/// 页面每毫秒 reflow 一次。所以时间轴按 16 ms 结算:到得再快也只推进这么多次。 +/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看: +/// +/// | 什么 | 频率 | 由谁决定 | +/// |---|---|---| +/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 | +/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) | +/// +/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。 +/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排** +/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。 pub const FRAME_MS: f64 = 16.0; +/// 单次推进的**最大步进**(4 帧 = 64 ms)。 +/// +/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果 +/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完 +/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道: +/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。 +pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0; + /// 一个动效槽。 /// /// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加 @@ -187,14 +202,14 @@ impl Pump { !self.tracks.is_empty() } - /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长)。 + /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。 /// /// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 = /// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。 - pub fn play(&mut self, slot: Slot) { + pub fn play(&mut self, slot: Slot) -> bool { self.tracks.clear(); if self.scale.is_off() { - return; + return false; } // 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」, // 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。 @@ -206,6 +221,7 @@ impl Pump { spec: spec(slot), }, ); + true } /// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。 @@ -227,29 +243,52 @@ impl Pump { self.pin.unwrap_or(self.now_ms) } - /// 页主体的入场位移:**每帧调一次**,标识变了才真的起跑。 + /// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。 + /// + /// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释) + /// + /// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→ + /// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`, + /// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表 + /// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里 + /// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。 /// /// 三条判据(顺序即优先级): - /// 1. 第一帧 → [`Slot::Boot`](首屏也算一次「东西出现」); + /// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`]; /// 2. 屏变了 → [`Slot::Screen`]; /// 3. 屏没变但详情变了 → [`Slot::Detail`]。 /// - /// 返回值按各槽剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚, - /// 末帧三个槽都是 `1.0` ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 - pub fn enter(&mut self, screen: u64, detail: Option<&str>) -> (f32, f32) { + /// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**—— + /// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回 + /// `Some`,仍然会跑换页那一档。 + /// + /// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。 + pub fn observe(&mut self, screen: Option, detail: Option<&str>) -> bool { + let mut started = false; if !self.painted { self.painted = true; - self.play(Slot::Boot); - } else if self.screen != Some(screen) { - self.play(Slot::Screen); - } else if self.detail.as_deref() != detail { - self.play(Slot::Detail); + if screen.is_some() { + started = self.play(Slot::Boot); + } + } else if self.screen != screen { + if screen.is_some() { + started = self.play(Slot::Screen); + } + } else if self.detail.as_deref() != detail && screen.is_some() { + started = self.play(Slot::Detail); } - self.screen = Some(screen); + self.screen = screen; if self.detail.as_deref() != detail { self.detail = detail.map(str::to_owned); } + started + } + /// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。 + /// + /// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0` + /// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 + pub fn offsets(&self) -> (f32, f32) { let mut dx = 0.0_f32; let mut dy = 0.0_f32; for slot in Slot::ALL { @@ -263,16 +302,18 @@ impl Pump { /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 /// - /// 三步:①不满 [`FRAME_MS`] 就不动(帧上限)②到期的轨道**当场删除**(这是「空闲不烧帧」的 - /// 唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 - /// 里就不再有 `window::frames()`)③钉住相位时时间轴不前进(取证专用)。 + /// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 / + /// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」 + /// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 + /// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。 pub fn advance(&mut self, dt_ms: f64) -> bool { if self.tracks.is_empty() { self.pending_ms = 0.0; return false; } if dt_ms.is_finite() && dt_ms > 0.0 { - self.pending_ms += dt_ms; + // ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。 + self.pending_ms += dt_ms.min(MAX_STEP_MS); } if self.pending_ms < FRAME_MS { return false; diff --git a/crates/app-iced/src/components/motion/tests.rs b/crates/app-iced/src/components/motion/tests.rs index 647850f..13e09af 100644 --- a/crates/app-iced/src/components/motion/tests.rs +++ b/crates/app-iced/src/components/motion/tests.rs @@ -1,6 +1,8 @@ //! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。 +//! +//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。 -use super::pump::{offset, spec, Pump, Slot, FRAME_MS}; +use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS}; use super::*; /// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。 @@ -39,7 +41,7 @@ fn every_slot_spec_is_a_registered_token_pair() { ms ); // 缓动必须是真的曲线(端点 = 0/1) - assert!((spec.easing.sample(0.0)).abs() < 1.0e-6); + assert!(spec.easing.sample(0.0).abs() < 1.0e-6); assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6); // 位移必须来自间距栅格(4 的倍数),且不为零 let (dx, dy) = offset(slot); @@ -67,7 +69,7 @@ fn every_slot_spec_is_a_registered_token_pair() { #[test] fn a_finished_animation_stops_asking_for_frames() { let mut pump = standard(); - pump.play(Slot::Screen); + assert!(pump.play(Slot::Screen), "起跑必须成功"); assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); settle(&mut pump); @@ -76,6 +78,7 @@ fn a_finished_animation_stops_asking_for_frames() { "动画结束必须**当场**退订(占着轨道 = 空闲烧帧)" ); assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位"); + assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位"); // 再推进也不该有任何采样(订阅表里已经没有帧了) let before = pump.samples(); @@ -85,15 +88,16 @@ fn a_finished_animation_stops_asking_for_frames() { assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); } -/// **帧间隔上限**:帧到得再快,时间轴也只按 16 ms 结算(≈60 次/秒)。 +/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。 /// +/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。 /// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。 #[test] fn frames_are_capped_at_sixty_per_second() { let mut pump = standard(); pump.play(Slot::Screen); - // 200 帧、每帧 1 ms(模拟 1000 fps 的合成器) + // 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器) let mut advanced = 0; for _ in 0..200 { if pump.advance(1.0) { @@ -117,6 +121,38 @@ fn frames_are_capped_at_sixty_per_second() { ); } +/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。 +/// +/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒ +/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与 +/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。 +#[test] +fn a_stale_baseline_cannot_kill_the_track_in_one_sample() { + let mut pump = standard(); + pump.play(Slot::Screen); + + // 60 秒的「空闲间隔」当 dt + assert!(pump.advance(60_000.0)); + assert!( + pump.is_animating(), + "一次超长 dt 不许把轨道当场删掉(那就等于没有动画)" + ); + assert!( + pump.now_ms() <= MAX_STEP_MS + 1.0e-6, + "单次推进最多 {:?} ms,实测 {}", + MAX_STEP_MS, + pump.now_ms() + ); + assert!( + pump.progress(Slot::Screen) < 1.0, + "进度不许一步到位(那就是闪跳)" + ); + + // 正常帧间隔照样把动画跑完(没被抖动卡死) + settle(&mut pump); + assert!(!pump.is_animating()); +} + /// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。 #[test] fn progress_is_monotone_and_lands_on_one() { @@ -146,11 +182,13 @@ fn none_scale_never_starts_a_track() { assert!(pump.scale().is_off()); // 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道 - assert_eq!(pump.enter(1, None), (0.0, 0.0), "关掉动效必须直接落终态"); + assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑"); + assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态"); assert!(!pump.is_animating()); - assert_eq!(pump.enter(2, None), (0.0, 0.0)); - assert!(!pump.is_animating()); - assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); + assert!(!pump.observe(Some(2), None)); + assert_eq!(pump.offsets(), (0.0, 0.0)); + assert!(!pump.observe(Some(2), Some("instance-7"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); assert!(!pump.is_animating()); for _ in 0..120 { @@ -170,6 +208,7 @@ fn switching_to_none_mid_flight_settles_immediately() { pump.configure(MotionScale::NONE, None); assert!(!pump.is_animating(), "关掉动效必须当场清空轨道"); assert_eq!(pump.progress(Slot::Screen), 1.0); + assert_eq!(pump.offsets(), (0.0, 0.0)); } /// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。 @@ -193,67 +232,94 @@ fn half_scale_finishes_in_half_the_frames() { // ------------------------------------------------------------------ 触发判据 -/// 三个槽的触发优先级:首帧 Boot → 换屏 Screen → 同屏换详情 Detail。 +/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。 #[test] fn identity_changes_pick_the_right_slot() { let mut pump = standard(); // 首帧:Boot(位移是纵向 16) - let (dx, dy) = pump.enter(1, None); + assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽"); + let (dx, dy) = pump.offsets(); assert_eq!(dx, 0.0); - assert!(dy > 0.0, "首屏应有一个向下的入场位移"); - assert!(pump.is_animating(), "首帧要跑 Boot 槽"); + assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移"); assert_eq!( pump.progress(Slot::Screen), 1.0, "首帧不该顺带起跑 Screen 槽" ); settle(&mut pump); - assert_eq!(pump.enter(1, None), (0.0, 0.0), "标识没变 ⇒ 不动"); + assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑"); + assert_eq!(pump.offsets(), (0.0, 0.0)); // 换屏:Screen(纵向) - let (dx, dy) = pump.enter(2, None); + assert!(pump.observe(Some(2), None)); + let (dx, dy) = pump.offsets(); assert_eq!(dx, 0.0); - assert!(dy > 0.0); + assert_eq!(dy, offset(Slot::Screen).1); assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽"); settle(&mut pump); // 同屏换详情:Detail(横向) - let (dx, dy) = pump.enter(2, Some("instance-7")); - assert!(dx > 0.0, "列表↔详情是横向入场"); + assert!(pump.observe(Some(2), Some("instance-7"))); + let (dx, dy) = pump.offsets(); + assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场"); assert_eq!(dy, 0.0); assert!(pump.progress(Slot::Detail) < 1.0); settle(&mut pump); - assert_eq!(pump.enter(2, Some("instance-7")), (0.0, 0.0)); + assert!(!pump.observe(Some(2), Some("instance-7"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); +} + +/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。 +#[test] +fn screens_rendered_elsewhere_never_start_a_track() { + let mut pump = standard(); + assert!(pump.observe(Some(1), None), "首屏开机跑 Boot"); + settle(&mut pump); + + assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧"); + assert!(!pump.is_animating()); + assert_eq!(pump.offsets(), (0.0, 0.0)); + + assert!( + pump.observe(Some(1), None), + "从账户页回到某一屏仍然是换页,要跑" + ); + assert_eq!(pump.offsets().1, offset(Slot::Screen).1); } /// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。 #[test] fn a_new_transition_supersedes_the_previous_one() { let mut pump = standard(); - let boot_dy = offset(Slot::Boot).1; - assert_eq!(pump.enter(1, None).1, boot_dy, "首帧从 Boot 的最大位移开始"); + assert_eq!( + pump.offsets().1, + 0.0, + "还没 observe 时没有位移(采样是纯函数)" + ); + pump.observe(Some(1), None); + assert_eq!(pump.offsets().1, offset(Slot::Boot).1); // 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加 - let (_, dy) = pump.enter(2, None); + pump.observe(Some(2), None); assert_eq!( - dy, + pump.offsets().1, offset(Slot::Screen).1, "新的一次内容变化应取代上一次(叠加会让位移变成两档之和)" ); - assert!(!pump.is_animating() || pump.progress(Slot::Boot) == 1.0); } /// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。 #[test] fn repeated_paints_never_restart_the_animation() { let mut pump = standard(); - pump.enter(3, Some("instance-9")); + pump.observe(Some(3), Some("instance-9")); let frames = settle(&mut pump); assert!(frames > 0); for _ in 0..30 { - assert_eq!(pump.enter(3, Some("instance-9")), (0.0, 0.0)); + assert!(!pump.observe(Some(3), Some("instance-9"))); + assert_eq!(pump.offsets(), (0.0, 0.0)); } assert!(!pump.is_animating(), "重复绘制不该起新轨道"); } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 3828af7..ec4f1fe 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -253,27 +253,10 @@ fn render<'a>( None => page, }; - // M192:主体入场(首屏 / 换页 / 列表↔详情)。位移由帧泵按令牌表给,**静息恒为 `(0, 0)`** - // 且此时 `motion::shifted` 原样返回,所以关闭动效与「末帧」两条路径都与没有动效时逐像素相同。 - let shift = motion::enter(screen_key(model.screen), detail_key(model)); - motion::shifted(tree, shift) -} - -/// 屏的稳定标识:值变了才跑「换页」那一档([`Screen::ALL`] 的下标 + 1,`0` 留给「还没见过」)。 -fn screen_key(screen: Screen) -> u64 { - Screen::ALL - .iter() - .position(|candidate| *candidate == screen) - .map_or(0, |index| index as u64 + 1) -} - -/// 详情页的稳定标识:实例 id(`None` = 没选中任何一行)。同一个 id 重复绘制不会重启动画。 -fn detail_key(model: &Model) -> Option<&str> { - model - .instances - .detail - .as_ref() - .map(|detail| detail.id.as_str()) + // M192:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧 + // (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。 + // 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。 + motion::shifted(tree, motion::sample()) } /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。 From 5a52f38f35a241edd319de2be0ca768be675116b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 16:21:19 +0800 Subject: [PATCH 26/33] =?UTF-8?q?feat(content):=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E4=B8=A4=E6=A1=A3=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=B8=8E=E3=80=8C=E5=8F=AA=E9=87=8D=E8=AF=95=E8=BF=99=E4=BA=9B?= =?UTF-8?q?=E3=80=8D(M191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()), 但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里: - DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开); InstallProgressView 加 retry: Vec(project + 档位 + core 原话), 档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind); ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。 - presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法, host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」—— 调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。 install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。 - 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮; 判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。 - i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。 守门(每条都做过变异验证,见报告): ① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]); ② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红; ③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。 数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。 --- crates/app-iced/src/app/update/content.rs | 11 ++ crates/app-iced/src/pages/content.rs | 15 +- crates/app-iced/src/pages/content/demo.rs | 64 +++++++- crates/app-iced/src/pages/content/message.rs | 5 + crates/app-iced/src/pages/content/model.rs | 12 ++ .../app-iced/src/pages/content/model/retry.rs | 149 +++++++++++++++++ .../app-iced/src/pages/content/model/tests.rs | 7 + .../src/pages/content/model/tests/retry.rs | 129 +++++++++++++++ .../pages/content/packui/model/progress.rs | 2 + crates/app-iced/src/pages/content/parts.rs | 2 + .../app-iced/src/pages/content/parts/retry.rs | 139 ++++++++++++++++ crates/app-iced/src/pages/content/tests.rs | 13 ++ crates/app-iced/src/pages/content/view.rs | 7 +- crates/app-iced/src/pages/download/demo.rs | 2 + crates/i18n/src/catalog/content.rs | 24 +++ crates/i18n/src/catalog/golden/tail.rs | 6 + crates/presenter/src/dto/content.rs | 10 ++ crates/presenter/src/dto/content/detail.rs | 13 ++ crates/presenter/src/dto/install.rs | 83 ++++++++++ crates/presenter/src/dto/mod.rs | 5 +- crates/presenter/src/host/addon/install.rs | 100 +++++++++++- crates/presenter/src/host/addon/plan.rs | 140 ++++++++++++++-- crates/presenter/src/host/addon/tests.rs | 8 +- .../presenter/src/host/addon/tests/retry.rs | 150 ++++++++++++++++++ 24 files changed, 1072 insertions(+), 24 deletions(-) create mode 100644 crates/app-iced/src/pages/content/model/retry.rs create mode 100644 crates/app-iced/src/pages/content/model/tests/retry.rs create mode 100644 crates/app-iced/src/pages/content/parts/retry.rs create mode 100644 crates/presenter/src/host/addon/tests/retry.rs diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 4d30049..34e2350 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -104,6 +104,15 @@ impl App { self.dispatch(AppCommand::InstallContent(Box::new(request))); } } + // M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs` + // (`retry_install_request`:载荷带 `retry: true`),这里只做转发。 + pages::content::Message::RetryFailed => { + let catalog = unlucky_i18n::Lang::En.catalog(); + let model = Content::from_snapshot(&self.state, &self.content, catalog); + if let Some(request) = model.retry_install_request() { + self.dispatch(AppCommand::InstallContent(Box::new(request))); + } + } // M113: import/export —— 导入/导出区(细节在 `on_pack`) pages::content::Message::Pack(message) => self.on_pack(message), } @@ -222,6 +231,8 @@ impl App { instance: target.id.clone(), game_version: target.mc_version.clone(), loader: target.loader, + // 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`) + retry: false, }) } } diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index a9a9d32..6d58ce8 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -47,6 +47,16 @@ //! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 | //! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 | //! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 | +//! +//! ## 安装失败之后:两档分组 + 「只重试这些」(M191) +//! +//! 装入失败且**有可重试项**时,页底那块失败面板换成 [`parts::retry_panel`]:按 +//! `InstallFailureKind` 分两段(不兼容 / 取不到,各有自己的词表文案),并给一个「只重试这 N 个」 +//! 的实心按钮。按钮发 [`Message::RetryFailed`],app 层翻成 `AppCommand::InstallContent{retry: true}`, +//! host 拿上一轮的计划调 `DependencyPlan::retry_request()` ⇒ **只请求失败的那几个项目**。 +//! +//! 判据只有一条且在模型里(`RetryPanel::of`):`stage == Failed && !retry.is_empty()`。 +//! 全成功、没有失败项、还在跑,三种情况都得到 `None`——**不渲染**,不是画一个灰按钮。 mod demo; mod layout; @@ -61,7 +71,10 @@ mod view; mod tests; pub use message::Message; -pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; +pub use model::{ + loader_name, Blocker, Content, ContentQueryView, Hit, PageState, RetryGroup, RetryPanel, + Status, Target, +}; // M113: import/export pub use packui::{request_export, Pack, PackMessage, PackPageState}; diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index ca01959..e668369 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -11,7 +11,7 @@ //! | 变量 | 取值 | 作用 | //! |---|---|---| //! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | -//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) | +//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) | //! //! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。 @@ -19,10 +19,47 @@ use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; use super::model::{ - blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow, + blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow, }; use crate::pages::download::Download; +/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。 +/// +/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数, +/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。 +fn retry_failure(instance: &str, catalog: Catalog) -> Option { + use unlucky_presenter::dto::{ + InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage, + }; + let mut progress = InstallProgressView { + version: "Sodium".to_string(), + instance: instance.to_string(), + loader: "Fabric".to_string(), + stage: InstallStage::Failed, + files_done: 1, + files_total: 4, + ..InstallProgressView::new() + }; + progress.retry = vec![ + InstallFailureView::new( + "P7dR8mSH", + InstallFailureKind::Incompatible, + "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", + ), + InstallFailureView::new( + "1eAoo2KR", + InstallFailureKind::Unreachable, + "取版本失败(online_not_found):HTTP 404", + ), + InstallFailureView::new( + "AANobbMI", + InstallFailureKind::Unreachable, + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), + ]; + RetryPanel::of(&progress, catalog) +} + /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 fn targets() -> Vec { vec![ @@ -254,6 +291,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { } else { None }, + // M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据, + // 得到 `None`(连按钮都不渲染)。 + retry: match state.as_str() { + "retryfail" => retry_failure("survival", catalog), + _ => None, + }, versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], available_versions: demo_versions, versions_note: None, @@ -265,3 +308,20 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { pack, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。 + #[test] + fn the_failure_screen_shows_both_tiers() { + let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板"); + assert_eq!(panel.total, 3); + assert!(panel.has_both_tiers(), "{:?}", panel.groups); + assert_eq!( + panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable), + Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + ); + } +} diff --git a/crates/app-iced/src/pages/content/message.rs b/crates/app-iced/src/pages/content/message.rs index a5e9286..e8cad7d 100644 --- a/crates/app-iced/src/pages/content/message.rs +++ b/crates/app-iced/src/pages/content/message.rs @@ -37,6 +37,11 @@ pub enum Message { Retry, /// 装进目标实例 Install, + /// **只重试上次失败的那几项**(M191;不是「整单重装」) + /// + /// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划 + /// 调 `DependencyPlan::retry_request()`,只重解失败项。 + RetryFailed, /// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`) Pack(super::packui::PackMessage), } diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index 51c5d84..8192881 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key}; use crate::pages::download::Download; mod items; +mod retry; pub(crate) use items::HitFacts; pub(crate) use items::{capitalize, thousands}; pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow}; +pub use retry::{RetryGroup, RetryPanel}; /// 内容库页要画的东西。 #[derive(Debug, Clone, PartialEq)] @@ -69,6 +71,11 @@ pub struct Content { /// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树 /// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。 pub panel: Option, + /// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。 + /// + /// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板, + /// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。 + pub retry: Option, /// 游戏版本筛选的候选(来自实例;去重、保持实例顺序) pub versions: Vec, /// 关键词草稿 @@ -187,6 +194,11 @@ impl Content { target_label, blockers, panel: content_install(state, catalog), + // M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有) + retry: state + .install + .as_ref() + .and_then(|progress| RetryPanel::of(progress, catalog)), versions, draft: page.draft.clone(), pack, diff --git a/crates/app-iced/src/pages/content/model/retry.rs b/crates/app-iced/src/pages/content/model/retry.rs new file mode 100644 index 0000000..a6d8f17 --- /dev/null +++ b/crates/app-iced/src/pages/content/model/retry.rs @@ -0,0 +1,149 @@ +//! 「只重试这些」面板的视图模型(M191)。 +//! +//! ## 它画的是什么 +//! +//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」: +//! +//! ```text +//! 安装未完成 —— 3 项没装上 +//! 不兼容所选版本/加载器 +//! P7dR8mSH +//! 取不到(网络 / 项目不存在) +//! 1eAoo2KR、AANobbMI +//! [只重试这 3 个] [关闭] +//! ``` +//! +//! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉) +//! +//! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰); +//! 2. **两档不混**:分组按 [`InstallFailureKind`] 分开,两档各有自己的标题 key; +//! 3. **只列名单里的**:条目原样来自 DTO(`presenter` 从 `DependencyPlan::retry_request()` +//! 的同一份失败名单产出),页面不自己挑、也不补「顺便重装根」。 +//! +//! ## 为什么这里不拼文案 +//! +//! 分组标题是 [`Key`](i18n),条目是项目键(站点数据,不是文案)。拼一句带量的中文句子 +//! 会把语序写死——英文与中文的量词位置不同(`Retry just these 3` / `只重试这 3 个`)。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage, + LoaderKind, +}; + +/// 面板上的一个档位分组。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryGroup { + /// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`]) + pub kind: InstallFailureKind, + /// 这一档的标题 key(不硬编码中文) + pub title: Key, + /// 这一档的项目键(顺序 = 计划里的顺序) + pub items: Vec, +} + +/// 「只重试这些」面板(`None` = 不渲染:没有失败项、或已经全成功)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryPanel { + /// 失败的那次装入的目标实例(重试只对这个实例生效) + pub instance: String, + /// 标题(已按词表渲染:带条数) + pub title: String, + /// 主按钮文字(已按词表渲染:`只重试这 N 个`) + /// + /// 存在这里而不是画的时候现拼:`Button::new` 要的是 `&'a str`,而词表渲染出来的是 + /// `String`——存进模型(与 `Hit` / `VersionRow.line` 同一手法)就不必为了一个标签 + /// 去改组件库的入参类型。 + pub action: String, + /// 按档分组(空组不出现) + pub groups: Vec, + /// 失败项总数(标题与按钮都用这个数) + pub total: usize, +} + +impl RetryPanel { + /// 进度快照 → 面板(**唯一**判据;`None` = 视图里不该出现这块)。 + /// + /// 只认 `Failed` 这一态:`Done` 没有可重试的东西,`Cancelled` 是用户自己停的 + /// (那条路上根本没有失败名单),`Planning`/`Downloading` 还在跑。 + #[must_use] + pub fn of(progress: &InstallProgressView, catalog: Catalog) -> Option { + if progress.stage != InstallStage::Failed || !progress.has_retry() { + return None; + } + let total = progress.retry.len(); + let count = total.to_string(); + let groups = InstallFailureKind::ALL + .into_iter() + .map(|kind| RetryGroup { + kind, + title: title_of(kind), + items: progress + .retry_of(kind) + .map(|item| item.project.clone()) + .collect(), + }) + .filter(|group| !group.items.is_empty()) + .collect(); + Some(Self { + instance: progress.instance.clone(), + title: catalog.render(Key::ContentInstallFailedTitle, &[("count", count.as_str())]), + action: catalog.render(Key::ContentInstallRetryOnly, &[("count", count.as_str())]), + groups, + total, + }) + } + + /// 两档都在吗(「分组」这件事本身的判据:都出现时界面上是两段,不是一段)。 + #[must_use] + pub fn has_both_tiers(&self) -> bool { + self.groups.len() == 2 + } + + /// 某一档的条目(用例断言用)。 + #[must_use] + pub fn items_of(&self, kind: InstallFailureKind) -> Option<&[String]> { + self.groups + .iter() + .find(|group| group.kind == kind) + .map(|group| group.items.as_slice()) + } +} + +/// 档位 → 标题 key(**穷尽 match**:合并两档会少一条文案,用例会红)。 +#[must_use] +pub const fn title_of(kind: InstallFailureKind) -> Key { + match kind { + InstallFailureKind::Incompatible => Key::ContentInstallRetryIncompatible, + InstallFailureKind::Unreachable => Key::ContentInstallRetryUnreachable, + } +} + +impl super::Content { + /// 「只重试这些」那一次请求(M191;`None` = 没有可重试的面板 ⇒ 这个动作不存在)。 + /// + /// 载荷里 `retry: true` 是**唯一**的语义开关:host 收到它就不解根项目,而是拿上一轮的计划 + /// 调 `DependencyPlan::retry_request()`(只请求失败的那几个项目)。其余字段照旧填, + /// 是因为 `ContentInstallRequest` 的判据仍要能读(`project` / `title` 也用于日志); + /// 这些字段在重试路径上**不被 host 读取**——过滤条件与落盘目录都来自那份计划。 + /// + /// 实例取**失败那次的**实例,不是页面上此刻选中的那个:用户可能已经换了目标实例。 + pub fn retry_install_request(&self) -> Option { + let retry = self.retry.as_ref()?; + let hit = self.selected_hit(); + let target = self + .targets + .iter() + .find(|target| target.id == retry.instance); + Some(ContentInstallRequest { + project: hit.map(|hit| hit.id.clone()).unwrap_or_default(), + title: hit.map(|hit| hit.title.clone()).unwrap_or_default(), + kind: self.kind, + source: hit.map_or(ContentSource::Modrinth, |hit| hit.source), + instance: retry.instance.clone(), + game_version: target.map_or_else(String::new, |target| target.mc_version.clone()), + loader: target.map_or(LoaderKind::Vanilla, |target| target.loader), + retry: true, + }) + } +} diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index 2b60022..f929fbf 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -355,3 +355,10 @@ fn loader_names_are_ascii_so_the_english_baseline_stays_english() { "不能画「原版」" ); } + +// M191:安装失败的两档分组与「只重试这些」(按主题分卷,父文件已贴 400 行硬线) +// +// `#[path]` 显式给出:本文件自己是 `#[path = "model/tests.rs"]` 挂上去的,子模块默认按 +// 「父模块所在目录」找,会指到 `model/retry.rs`(视图模型本体)而不是这里的 `tests/retry.rs`。 +#[path = "tests/retry.rs"] +mod retry; diff --git a/crates/app-iced/src/pages/content/model/tests/retry.rs b/crates/app-iced/src/pages/content/model/tests/retry.rs new file mode 100644 index 0000000..d64a3f8 --- /dev/null +++ b/crates/app-iced/src/pages/content/model/tests/retry.rs @@ -0,0 +1,129 @@ +//! M191:安装失败的两档分组 + 「只重试这些」(`super` 按主题分卷,父文件已贴 400 行硬线)。 +//! +//! 三条变异守门:两档不许合并 / 全成功不许渲染 / 重试只针对失败那次的实例。 + +use super::super::{Content, PageState, RetryPanel}; +use super::{hit, results, state_with}; + +use unlucky_i18n::{Key, Lang}; +use unlucky_presenter::dto::{ + InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage, +}; + +/// 一份「两档都有」的失败进度快照。 +fn failed_progress() -> unlucky_presenter::dto::InstallProgressView { + let mut progress = InstallProgressView { + version: "Sodium".to_string(), + instance: "survival".to_string(), + stage: InstallStage::Failed, + files_done: 1, + files_total: 4, + ..InstallProgressView::new() + }; + progress.retry = vec![ + InstallFailureView::new( + "P7dR8mSH", + InstallFailureKind::Incompatible, + "没有匹配的版本", + ), + InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"), + InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"), + ]; + progress +} + +/// 两档**分组**画,各有自己的标题;条目按档分开,不混成一段。 +/// +/// 变异守门②:把两档合并(`kind_of` 都映成 `Unreachable`,或 `title_of` 都返回同一个 key) +/// ⇒ `groups.len()` 变 1 / 两个 title 相同 ⇒ 这条红。 +#[test] +fn the_two_tiers_are_two_groups_with_two_labels() { + let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板"); + assert_eq!(panel.total, 3); + assert!(panel.has_both_tiers(), "{:?}", panel.groups); + assert_eq!( + panel.items_of(InstallFailureKind::Incompatible), + Some(["P7dR8mSH".to_string()].as_slice()) + ); + assert_eq!( + panel.items_of(InstallFailureKind::Unreachable), + Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + ); + let titles: Vec = panel.groups.iter().map(|group| group.title).collect(); + assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个"); + // 文案来自词表(不是硬编码中文):英文目录下这两句必须不同且不含中文 + let incompatible = Lang::En.catalog().text(titles[0]); + let unreachable = Lang::En.catalog().text(titles[1]); + assert_ne!(incompatible, unreachable); + assert!(incompatible.is_ascii(), "{incompatible}"); + assert!(unreachable.is_ascii(), "{unreachable}"); + // 按钮文字带条数,也来自词表 + assert!(panel.action.contains('3'), "{}", panel.action); + assert!(panel.action.is_ascii(), "{}", panel.action); +} + +/// 只有一档失败时只画一段(空组不出现——空的「取不到」标题会让用户以为那里也坏了)。 +#[test] +fn an_empty_tier_does_not_get_a_group() { + let mut progress = InstallProgressView { + stage: InstallStage::Failed, + ..InstallProgressView::new() + }; + progress.retry = vec![InstallFailureView::new( + "P7dR8mSH", + InstallFailureKind::Incompatible, + "没有匹配的版本", + )]; + let panel = RetryPanel::of(&progress, Lang::En.catalog()).expect("有失败项就有面板"); + assert!(!panel.has_both_tiers()); + assert!(panel.items_of(InstallFailureKind::Unreachable).is_none()); +} + +/// **全成功时不渲染**:`Done` 且没有失败项 ⇒ 面板不存在(不是置灰,是连构造都没有)。 +/// +/// 变异守门③:把 `RetryPanel::of` 的判据去掉(恒返回 `Some`)⇒ 这条红。 +#[test] +fn a_fully_successful_install_has_no_retry_surface_at_all() { + let done = InstallProgressView { + version: "Sodium".to_string(), + instance: "survival".to_string(), + stage: InstallStage::Done, + files_done: 4, + files_total: 4, + ..InstallProgressView::new() + }; + assert!(RetryPanel::of(&done, Lang::En.catalog()).is_none()); + // 页面上也一样:状态是 Done ⇒ 页底那块既没有重试面板,也没有失败面板 + let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false); + state.install = Some(done); + let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert!(model.retry.is_none(), "全成功还在页面上画重试面板"); + assert!(model.panel.is_none()); + assert!(model.retry_install_request().is_none()); + + // 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西 + let mut failed = InstallProgressView { + stage: InstallStage::Failed, + ..InstallProgressView::new() + }; + failed.retry.clear(); + assert!(RetryPanel::of(&failed, Lang::En.catalog()).is_none()); +} + +/// 「只重试这些」发出去的是**失败那次的实例** + `retry: true`(不是页面此刻选中的实例, +/// 也不是一次新的根项目安装)。 +#[test] +fn the_retry_action_targets_the_failed_attempt() { + let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false); + // 名单要有,否则这块入口根本不存在 + state.install = Some(failed_progress()); + + let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert!(model.retry.is_some()); + let request = model.retry_install_request().expect("有面板就该有请求"); + assert!( + request.retry, + "重试必须是 retry: true(否则 host 会从根解起)" + ); + assert_eq!(request.instance, "survival"); +} diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index f55d29c..35a7e94 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -100,6 +100,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { eta_secs: 0, // 整合包导入的失败不产生内容库分类(它有自己的报告视图)。 failure_kind: None, + // 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。 + retry: Vec::new(), message: progress.current.clone().unwrap_or_default(), } } diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs index 806492a..0d846ce 100644 --- a/crates/app-iced/src/pages/content/parts.rs +++ b/crates/app-iced/src/pages/content/parts.rs @@ -30,9 +30,11 @@ use super::model::{Content, Hit}; mod detail; mod filters; +mod retry; pub(crate) use detail::detail; pub(crate) use filters::{filters, labelled}; +pub(crate) use retry::maybe as retry_panel; use super::Message as ContentMessage; use crate::pages::frame; diff --git a/crates/app-iced/src/pages/content/parts/retry.rs b/crates/app-iced/src/pages/content/parts/retry.rs new file mode 100644 index 0000000..bbe81ed --- /dev/null +++ b/crates/app-iced/src/pages/content/parts/retry.rs @@ -0,0 +1,139 @@ +//! 安装失败后的「只重试这些」面板(M191)。 +//! +//! ## 为什么这块画在内容库,而不是复用下载页那块失败面板 +//! +//! 下载页的失败面板讲的是「停在哪一步 + 打开日志」,而这里要讲的是**哪几项没装上、属于哪种 +//! 没装上、以及能不能只重试它们**。两件事混在一个块里会得到「失败 + 一堆条目 + 两个关闭」。 +//! 所以:有可重试名单时([`RetryPanel::of`] 给了 `Some`)这块**顶替**通用失败面板, +//! 一屏只有一个失败块。 +//! +//! ## 三个控件行为 +//! +//! | 控件 | 消息 | 落点 | +//! |---|---|---| +//! | 只重试这 N 个 | [`ContentMessage::RetryFailed`] | `AppCommand::InstallContent{retry: true}`(host 只重解失败项) | +//! | 关闭 | [`PageMessage::CancelInstall`] | 清 install 快照(对没在跑的安装是安全 no-op,与下载页同一个出口) | +//! +//! 两个按钮都**按不动就不画**:没有名单时整块不存在(不是画一个灰按钮)。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::Ui; + +use super::super::model::{Content, RetryPanel}; +use super::intent; +use crate::pages::content::Message as ContentMessage; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +/// 整块面板(`model.retry` 为 `None` 时调用方不该调它——判据在模型里,不在这里)。 +pub(crate) fn retry_panel<'a>( + ui: &'a Ui, + catalog: Catalog, + panel: &'a RetryPanel, +) -> Element<'a, PageMessage> { + let head = row![ + frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + panel.title.as_str(), + ui.color(Role::Error), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + let mut body = column![head] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + // 两档分成两段:**各自的标题 + 各自的条目**(合并成一段就说不出「换版本还是检查网络」)。 + // 条目之间用 ASCII 逗号:项目键是站点数据,句读不是文案,不该在这里挑中文的「、」。 + for group in &panel.groups { + let items = group.items.join(", "); + body = body.push( + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + true, + catalog.text(group.title), + ui.color(Role::OnErrorContainer), + ), + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + items, + ui.color(Role::OnErrorContainer), + ), + ] + .spacing(ui.space(Space::X1)), + ); + } + + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentInstallRetryHint), + ui.color(Role::OnErrorContainer), + )); + + let actions = row![ + // 主按钮:只重试这些(这个块存在的**唯一**理由) + buttons::filled( + ui, + Button::new(panel.action.as_str(), intent(ContentMessage::RetryFailed)) + .leading(IconKind::Refresh), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::ContentInstallDismiss), + PageMessage::CancelInstall + ), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + body = body.push(actions); + + container(body) + .width(Length::Fill) + .padding(ui.space(Space::X4)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::ErrorContainer))), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 整页底部那块(判据在 [`Content::retry`];`None` ⇒ 一帧都不画)。 +pub(crate) fn maybe<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Option> { + model + .retry + .as_ref() + .map(|panel| retry_panel(ui, catalog, panel)) +} diff --git a/crates/app-iced/src/pages/content/tests.rs b/crates/app-iced/src/pages/content/tests.rs index e4dcb08..5b1453f 100644 --- a/crates/app-iced/src/pages/content/tests.rs +++ b/crates/app-iced/src/pages/content/tests.rs @@ -85,3 +85,16 @@ fn the_demo_selection_is_always_one_of_the_hits() { ); } } + +/// M191:「全部成功」那一屏(默认态)**没有**重试面板——出图与判据同源,不是手搓的。 +#[test] +fn the_default_demo_screen_has_no_retry_surface() { + for kind in ContentKind::ALL { + let model = demo(kind, Lang::En); + assert!( + model.retry.is_none(), + "{} 默认态不该有重试面板", + kind.slug() + ); + } +} diff --git a/crates/app-iced/src/pages/content/view.rs b/crates/app-iced/src/pages/content/view.rs index 008bd5f..133a1e7 100644 --- a/crates/app-iced/src/pages/content/view.rs +++ b/crates/app-iced/src/pages/content/view.rs @@ -98,7 +98,12 @@ fn body<'a>( .spacing(ui.space(Space::X3)) .width(Length::Fill) .height(Length::Fill); - if let Some(panel) = &model.panel { + // M191:有可重试的失败项时,这块**顶替**下载页那块通用失败面板(一屏只有一个失败块); + // 没有名单时它连构造都不存在(`None`),不是画一个灰按钮。 + let retry = parts::retry_panel(ui, catalog, model); + if let Some(panel) = retry { + stack = stack.push(divider::full(ui)).push(panel); + } else if let Some(panel) = &model.panel { // **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。 stack = stack .push(divider::full(ui)) diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 4658ce1..110eb95 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView { message: String::new(), // 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。 failure_kind: None, + // 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。 + retry: Vec::new(), }, catalog, ) diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 196e20a..e26bd9b 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -95,6 +95,30 @@ macro_rules! content_entries { "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放" ), + // --- M191:安装失败的两档分组与「只重试这些」--- + // 两档**各一条**:不兼容要换版本/加载器,取不到要检查网络,合并成一句会让用户 + // 对着一条重试必然失败的项反复点重试。 + ContentInstallFailedTitle => ( + "Install did not finish — {count} items failed", + "安装未完成 —— {count} 项没装上" + ), + ContentInstallRetryIncompatible => ( + "Not available for this version/loader", + "不兼容所选版本/加载器" + ), + ContentInstallRetryUnreachable => ( + "Could not fetch (network or missing project)", + "取不到(网络 / 项目不存在)" + ), + ContentInstallRetryOnly => ( + "Retry just these {count}", + "只重试这 {count} 个" + ), + ContentInstallRetryHint => ( + "Retry asks again only for the items above; files that already landed stay put.", + "重试只重新解析上面这几项,已落盘的文件不动。" + ), + ContentInstallDismiss => ("Dismiss", "关闭"), // --- M113: pack import / export(PACKUI)--- PackTitle => ("Modpack import & export", "整合包导入 / 导出"), PackImportTitle => ("Import a modpack", "导入整合包"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 6560f69..f9e23d9 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -386,6 +386,12 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), + ("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"), + ("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"), + ("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"), + ("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"), + ("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"), + ("ContentInstallDismiss", "Dismiss", "关闭"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackImportTitle", "Import a modpack", "导入整合包"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), diff --git a/crates/presenter/src/dto/content.rs b/crates/presenter/src/dto/content.rs index 3ef817c..43d3d61 100644 --- a/crates/presenter/src/dto/content.rs +++ b/crates/presenter/src/dto/content.rs @@ -284,6 +284,16 @@ pub struct ContentInstallRequest { pub game_version: String, /// 目标实例的加载器(选文件用) pub loader: LoaderKind, + /// **只重试上次失败的那些项目**(M191)。 + /// + /// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调 + /// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是 + /// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**—— + /// 过滤条件与落盘目录都来自那份计划。 + /// + /// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」: + /// 那正是本字段要避免的事。 + pub retry: bool, } /// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。 diff --git a/crates/presenter/src/dto/content/detail.rs b/crates/presenter/src/dto/content/detail.rs index de3e842..08a4d39 100644 --- a/crates/presenter/src/dto/content/detail.rs +++ b/crates/presenter/src/dto/content/detail.rs @@ -34,6 +34,12 @@ pub enum ContentFailureKind { Server, /// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对) Unsupported, + /// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`) + /// + /// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救, + /// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档), + /// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。 + Incompatible, /// 其它(解析失败、本地 IO……) Other, } @@ -47,10 +53,17 @@ impl ContentFailureKind { Self::NotFound => "not_found", Self::Server => "server", Self::Unsupported => "unsupported", + Self::Incompatible => "incompatible", Self::Other => "other", } } + /// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。 + #[must_use] + pub const fn is_incompatible(self) -> bool { + matches!(self, Self::Incompatible) + } + /// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。 #[must_use] pub const fn is_offline(self) -> bool { diff --git a/crates/presenter/src/dto/install.rs b/crates/presenter/src/dto/install.rs index 527536a..9b72d31 100644 --- a/crates/presenter/src/dto/install.rs +++ b/crates/presenter/src/dto/install.rs @@ -47,6 +47,65 @@ impl InstallStage { } } +/// 一条可只重试的失败的**档位**(M191;与 core 的 `FailureKind` 一一对应)。 +/// +/// ## 为什么不是一个 `bool` / 一句话 +/// +/// 「不兼容所选版本/加载器」与「取不到」要用户做的事不同:前者换版本或换加载器,后者检查网络 +/// 再试。**两档在类型上分开**,界面才能分组画两段、给两条不同的话;合并成一档之后 +/// 「重试」按钮会把一条重试必然失败的项也算进去(M191 的变异守门就钉这一条)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum InstallFailureKind { + /// 站点有这个项目,但目标版本/加载器下没有条目(换版本/加载器才有救) + Incompatible, + /// 取不到(没联网 / 项目不存在 / 站点出错 / 下载失败) + Unreachable, +} + +impl InstallFailureKind { + /// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Incompatible => "incompatible", + Self::Unreachable => "unreachable", + } + } + + /// 两档的固定顺序(界面分组按它排:先「换版本能救」,再「检查网络」)。 + pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable]; +} + +/// 一条「可只重试」的失败项(M191):项目键 + 档位 + core 给的原话。 +/// +/// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键 +/// (`DependencyPlan::retry_request()` 用的同一份)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstallFailureView { + /// 请求它用的项目键(slug 或 id;重试也用它) + pub project: String, + /// 哪一档 + pub kind: InstallFailureKind, + /// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案) + pub detail: String, +} + +impl InstallFailureView { + /// 构造。 + #[must_use] + pub fn new( + project: impl Into, + kind: InstallFailureKind, + detail: impl Into, + ) -> Self { + Self { + project: project.into(), + kind, + detail: detail.into(), + } + } +} + /// 一次安装的进度快照。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstallProgressView { @@ -77,6 +136,12 @@ pub struct InstallProgressView { /// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与 /// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。 pub failure_kind: Option, + /// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。 + /// + /// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是 + /// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出, + /// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。 + pub retry: Vec, } impl Default for InstallProgressView { @@ -101,6 +166,7 @@ impl InstallProgressView { eta_secs: 0, message: String::new(), failure_kind: None, + retry: Vec::new(), } } @@ -159,6 +225,23 @@ impl InstallProgressView { } format!("{}/{} 文件", self.files_done, self.files_total) } + + /// 有可只重试的失败项吗(`false` ⇒ 界面**不渲染**那条入口,不是置灰)。 + #[must_use] + pub fn has_retry(&self) -> bool { + !self.retry.is_empty() + } + + /// 某一档的可重试项(两档分开画时用;顺序沿用计划里的顺序)。 + pub fn retry_of(&self, kind: InstallFailureKind) -> impl Iterator { + self.retry.iter().filter(move |item| item.kind == kind) + } + + /// 某一档有几项。 + #[must_use] + pub fn retry_count(&self, kind: InstallFailureKind) -> usize { + self.retry_of(kind).count() + } } /// 字节数 → 人读(`1.2 MB`)。 diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index a5d6ea6..16f5ec6 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -45,7 +45,10 @@ pub use content::{ ContentSource, ContentState, ContentVersionView, }; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; -pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; +pub use install::{ + derive_speed, human_bytes, InstallFailureKind, InstallFailureView, InstallProgressView, + InstallStage, +}; pub use instances::{ CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index b3d2a4b..aa1d1c5 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -14,12 +14,18 @@ //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! -//! ## 前置的两档失败与重试名单(M186) +//! ## 前置的两档失败与重试名单(M186 / M191) //! //! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 //! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 -//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, -//! `DependencyPlan::retry_request()` 已经把请求备好了。 +//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。 +//! +//! ## 「只重试这些」真的只重试这些(M191) +//! +//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry` +//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`, +//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。 +//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。 //! //! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, //! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 @@ -40,11 +46,11 @@ //! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。 //! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。 -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, + resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -78,7 +84,8 @@ pub(super) fn install( // 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的, // 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。 - if request.kind.dir().is_none() { + // 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。 + if !request.retry && request.kind.dir().is_none() { fail( tx, progress, @@ -134,6 +141,13 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu /// /// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 /// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 +/// +/// 两条解析入口,别混: +/// +/// | 入口 | 请求从哪来 | 发几次 | +/// |---|---|---| +/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS | +/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_plan`]) | 一次,**只含失败的那几个项目** | fn run( env: &HostEnv, request: &ContentInstallRequest, @@ -143,6 +157,38 @@ fn run( epoch: u64, ) -> Result { let instance = find_instance(env, &request.instance)?; + + // 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。 + // 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。 + if request.retry { + let Some((target_dir, previous)) = plan::remembered(env, &request.instance) else { + return Err(local(RETRY_LOST)); + }; + progress.version = previous + .request + .roots + .first() + .cloned() + .unwrap_or_else(|| request.instance.clone()); + let present = plan::present_files(&target_dir); + let core_engine = super::super::engine::engine(env); + let dependency_plan = plan::retry_plan(&previous, present, |retry| { + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + block_on(resolve(&client, retry)) + }); + return collect( + env, + request, + dependency_plan, + &target_dir, + cancel, + progress, + tx, + epoch, + ); + } + let game_dir = game_dir_of(env, &instance)?; let dir = request .kind @@ -165,12 +211,41 @@ fn run( plan::present_files(&target_dir), ); let core_engine = super::super::engine::engine(env); - let mut dependency_plan = { + let dependency_plan = { let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); block_on(resolve(&client, &resolve_request)) }; + collect( + env, + request, + dependency_plan, + &target_dir, + cancel, + progress, + tx, + epoch, + ) +} +/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 +/// +/// 收口的两件事在同一个地方做,是因为它们必须同时发生: +/// +/// | 结果 | 计划 | 可重试名单 | +/// |---|---|---| +/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) | +/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) | +fn collect( + env: &HostEnv, + request: &ContentInstallRequest, + mut dependency_plan: DependencyPlan, + target_dir: &Path, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result { progress.stage = InstallStage::Downloading; progress.files_total = dependency_plan.nodes.len() as u64; progress.files_done = 0; @@ -192,11 +267,12 @@ fn run( epoch, }); + let core_engine = super::super::engine::engine(env); let fetcher = EngineArtifactFetcher::new(&core_engine); // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 let installed = downloads::download_stage( &mut dependency_plan, - &target_dir, + target_dir, &fetcher, cancel, progress, @@ -207,11 +283,19 @@ fn run( // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { + progress.retry = plan::retry_items(&dependency_plan); + plan::remember(env, &request.instance, target_dir, &dependency_plan); return Err(failure); } + progress.retry.clear(); + plan::forget(env, &request.instance); Ok(plan::summary(&dependency_plan, &installed)) } +/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。 +const RETRY_LOST: &str = + "没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。"; + /// 完成态:`files_done` 对齐 `files_total`。 /// /// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index f09986c..50686df 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -17,23 +17,29 @@ //! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 //! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 //! -//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) +//! # 「可只重试这些」的名单也在这里(M191) //! -//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 -//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, -//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 -//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 -//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 +//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 档位 + 原话), +//! 界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的 +//! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。 +//! +//! # 不兼容有自己的一档(M191 起) +//! +//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说 +//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射, +//! **不再借 `Unsupported`**。 -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; use unlucky_core::addon::online::{ DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, ResolveRequest, }; -use crate::dto::ContentFailureKind; +use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView}; +use super::super::env::HostEnv; use super::sources::failure_of; /// 扫目标目录(不递归)里已存在的文件名。 @@ -98,7 +104,8 @@ fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind { return wanted; } } - ContentFailureKind::Unsupported + // 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`) + ContentFailureKind::Incompatible } /// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 @@ -106,10 +113,66 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { match failure.cause { Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 - None => ContentFailureKind::Unsupported, + None => ContentFailureKind::Incompatible, } } +/// 可只重试的失败项 → DTO 名单(M191;界面按档分组画的那几条)。 +/// +/// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。 +/// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。 +#[must_use] +pub(super) fn retry_items(plan: &DependencyPlan) -> Vec { + plan.failures + .iter() + .map(|failure| { + InstallFailureView::new( + failure.requested_as.clone(), + failure_kind_of(failure.kind), + failure.message.clone(), + ) + }) + .collect() +} + +/// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。 +const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind { + match kind { + FailureKind::Incompatible => InstallFailureKind::Incompatible, + FailureKind::Unreachable => InstallFailureKind::Unreachable, + } +} + +/// 下一次解析的请求:**只含失败项**(M191)。 +/// +/// 这就是「只重试这些」的全部实现——请求来自 core 的 +/// `DependencyPlan::retry_request()`(它只把失败项列进 `roots`),过滤条件沿用上次那份。 +/// `present` 必须**重新扫**:这一轮新落盘的文件不在旧名单里,不重扫会被当成「还没有」再下一份 +/// (core 的 `retry_request` 文档明确要求)。 +/// +/// 拿不到计划时(换进程 / 那次是别的实例)调用方**不许**退回「整单重装」:那会把已经装好的 +/// 文件再下一遍,且请求数放大——正是本函数要避免的行为。 +#[must_use] +pub(super) fn retry_request(plan: &DependencyPlan, present: Vec) -> ResolveRequest { + ResolveRequest { + present, + ..plan.retry_request() + } +} + +/// 用「只含失败项」的请求重新解析(**生产**用它;用例注入假解析器断言只请求了哪几个项目)。 +/// +/// 解析器用闭包注入而不是直接调 core:这样「重试请求里到底有哪几个项目、发了几次」在没有网络 +/// 的用例里也能钉住(M191 的变异守门:把 `retry_request()` 换成根项目请求,用例必须红)。 +pub(super) fn retry_plan( + plan: &DependencyPlan, + present: Vec, + resolve: impl FnOnce(&ResolveRequest) -> DependencyPlan, +) -> DependencyPlan { + let request = retry_request(plan, present); + resolve(&request) +} + /// 失败清单(两档各起一段,各自的条数分开数)。 fn report(plan: &DependencyPlan, installed: usize) -> String { let mut lines: Vec = Vec::new(); @@ -199,6 +262,63 @@ fn unresolved_note(plan: &DependencyPlan) -> Option { )) } +/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 +/// +/// ## 为什么要一张静态表(说清楚这笔债) +/// +/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在 +/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与 +/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host +/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。 +/// +/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 +/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 +struct Remembered { + host: PathBuf, + instance: String, + /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) + dir: PathBuf, + plan: DependencyPlan, +} + +static REMEMBERED: Mutex> = Mutex::new(Vec::new()); + +/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 +pub(super) fn remember(env: &HostEnv, instance: &str, dir: &Path, plan: &DependencyPlan) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); + table.push(Remembered { + host, + instance: instance.to_string(), + dir: dir.to_path_buf(), + plan: plan.clone(), + }); +} + +/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。 +pub(super) fn forget(env: &HostEnv, instance: &str) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); +} + +/// 这个实例上一次失败的计划(+ 当时的落盘目录);`None` = 没有可重试的记录。 +pub(super) fn remembered(env: &HostEnv, instance: &str) -> Option<(PathBuf, DependencyPlan)> { + let host = env.layout.home().to_path_buf(); + let table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table + .iter() + .find(|item| item.host == host && item.instance == instance) + .map(|item| (item.dir.clone(), item.plan.clone())) +} + /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 /// /// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 89e6436..9959ab7 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -192,7 +192,10 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { Vec::new(), ); let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); - assert_eq!(failure.kind, ContentFailureKind::Unsupported); + // M191:「不兼容」有了自己的大类(以前借 `Unsupported`,与「缺密钥」共用一档) + assert_eq!(failure.kind, ContentFailureKind::Incompatible); + assert!(failure.kind.is_incompatible()); + assert!(!failure.is_offline()); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); @@ -377,3 +380,6 @@ fn a_required_dependency_without_a_project_id_is_reported() { "{message}" ); } + +// M191:「只重试这些」的名单与重试路径(按主题分卷,父文件已贴 400 行硬线) +mod retry; diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs new file mode 100644 index 0000000..fb45742 --- /dev/null +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -0,0 +1,150 @@ +//! M191:「只重试这些」的两档名单 + 真重试路径(`super` 的用例按主题分卷,这一卷专管重试)。 +//! +//! 三条判据对应的变异守门在各自的文档里写清了「改了什么这条会红」。 + +use super::{failure, node, plan}; +use crate::dto::{InstallFailureKind, InstallFailureView}; +use crate::host::addon::plan::{ + failure_of_plan, forget, remember, remembered, retry_items, retry_plan, retry_request, +}; +use unlucky_core::addon::online::{DependencyPlan, FailureKind, OnlineErrorKind, PresentFile}; + +/// 一份「根装好了、两个前置失败」的计划(重试路径的典型输入)。 +fn plan_with_two_tier_failures() -> DependencyPlan { + plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ) +} + +/// 可重试名单:**两档分在类型上**,每条的 project 就是重试会发出去的那个键。 +/// +/// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。 +#[test] +fn the_retry_list_keeps_the_two_tiers_apart() { + let items = retry_items(&plan_with_two_tier_failures()); + assert_eq!(items.len(), 2); + assert_eq!( + items + .iter() + .map(|item| (item.project.as_str(), item.kind)) + .collect::>(), + [ + ("P7dR8mSH", InstallFailureKind::Incompatible), + ("1eAoo2KR", InstallFailureKind::Unreachable), + ] + ); + // 两档的名字**必须不同**(界面拿它分组、也是日志/用例里的稳定 id) + assert_ne!( + InstallFailureKind::Incompatible.as_str(), + InstallFailureKind::Unreachable.as_str() + ); +} + +/// 全成功 ⇒ 名单是空的(界面据此**不渲染**那条入口,而不是画一个灰按钮)。 +#[test] +fn a_clean_plan_offers_nothing_to_retry() { + let clean = plan( + Vec::new(), + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + assert!(retry_items(&clean).is_empty()); + assert!(failure_of_plan(&clean, 1).is_none()); +} + +/// 「只重试这些」**只请求失败的那几个项目**:根项目不再发一次,解析只跑一次。 +/// +/// 这条是 M191 的变异守门①:把 `retry_request()` 换成「重新装根」(`ResolveRequest::single(root)` +/// 或 `plan.request.clone()`)⇒ `roots` 里会出现 `AANobbMI`,这条红。 +#[test] +fn retrying_asks_for_the_failed_projects_only() { + let plan = plan_with_two_tier_failures(); + let present = vec![PresentFile::new("sodium.jar")]; + let mut seen: Vec = Vec::new(); + let _ = retry_plan(&plan, present, |request| { + seen.push(request.clone()); + // 假解析器:把请求的根原样回成节点(用例只关心「请求了谁、请求了几次」) + DependencyPlan { + request: request.clone(), + nodes: request + .roots + .iter() + .map(|root| node(root, "x.jar", 0, &[])) + .collect(), + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + unresolved_required: Vec::new(), + } + }); + + assert_eq!(seen.len(), 1, "重试只发一次解析,请求数不许放大"); + let roots = &seen[0].roots; + assert_eq!(roots, &["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]); + assert!( + !roots.contains(&"AANobbMI".to_string()), + "根项目已装好,重试不许再解它:{roots:?}" + ); + // `present` 重新扫过(这一轮新落盘的文件要在里面,否则会被当成「还没有」再下一份) + assert_eq!(seen[0].present, vec![PresentFile::new("sodium.jar")]); + // 过滤条件沿用上次那份 + assert_eq!(seen[0].game_version, plan.request.game_version); +} + +/// 计划被记住 / 忘掉:键是「host + 实例」,成功之后不该留可重试的记录。 +#[test] +fn the_last_failed_plan_is_remembered_per_instance() { + let root = std::env::temp_dir().join(format!("ull-m191-retry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let env = crate::host::HostEnv::at_root(&root); + assert!(remembered(&env, "survival").is_none()); + + let plan = plan_with_two_tier_failures(); + let dir = root.join(".minecraft/mods"); + remember(&env, "survival", &dir, &plan); + let (saved_dir, saved) = remembered(&env, "survival").expect("记住了这一次"); + assert_eq!(saved_dir, dir); + assert_eq!(saved.failures.len(), 2); + // 别的实例不受影响(同一台机器上装过两个实例) + assert!(remembered(&env, "modded").is_none()); + + // 重试请求从「记住的计划」里取:仍然只含失败项 + let request = retry_request(&saved, Vec::new()); + assert_eq!(request.roots, vec!["P7dR8mSH", "1eAoo2KR"]); + + forget(&env, "survival"); + assert!(remembered(&env, "survival").is_none()); + let _ = std::fs::remove_dir_all(&root); +} + +/// DTO 名单与「真重试请求」是**同一份**:界面画的 N 个 = 重试会请求的 N 个。 +/// +/// 抓得住:界面名单自己另拼一份(多列/少列一个项目)⇒ 这条红。 +#[test] +fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { + let plan = plan_with_two_tier_failures(); + let items: Vec = retry_items(&plan); + let request = retry_request(&plan, Vec::new()); + assert_eq!( + items + .iter() + .map(|item| item.project.clone()) + .collect::>(), + request.roots + ); +} From 961ff453a04651f897a463046890ef451e1bdf93 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:20:58 +0800 Subject: [PATCH 27/33] =?UTF-8?q?fix(content):=20=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=94=BB=E5=8F=AF=E8=AF=BB=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=AA=E6=9C=89=E7=AB=99=E7=82=B9=20id=20(?= =?UTF-8?q?M191=20r1=20P2-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P2-1:面板原来只画 `P7dR8mSH` 这种项目键,用户拿到的是一串哈希。 可读名其实都在手边,只是没进 DTO——现在补上,按复审给的**最小修法**做,没扩散: - `InstallFailureView` 加 `label: String`(**语言中立**:站点标题或文件名), 配 `line()` 统一决定「名字 (id)」还是「只有 id」(名字为空时不编造)。 `detail`(core 中文散文)**依旧不进界面**——那会踩 i18n R2。 - `plan::label_of` 三个来源,零额外请求: 根项目 → 请求里的 `title`;计划里有节点(下载失败)→ `node.file.filename`; 都没有(解析 404 / 不兼容的前置)→ 空,界面退回只画 id(复审认这条兜底)。 - 计划被记住时连显示名一起记(`plan::remember` 多一个 `title`),于是重试那一轮 面板头与失败名单沿用的是同一个名字(顺手收掉复审 N5 的「重试时面板头显示 id」)。 - N2:删掉新加的 `ContentInstallDismiss`,复用既有的 `DownloadDismiss`(同一句话不立两条 key)。 - 行数/clippy:`collect` 的参数捆成 `Placement{dir,title}`(9 个参数超限); `plan.rs` 加完 `label_of` 到 429 行,把「记住的计划」表纯搬移到 `plan/remembered.rs`(363 + 82)。 守门(真跑红再还原): - 把 `label_of` 置空 ⇒ `the_retry_list_shows_a_readable_name_not_just_the_project_id` 红 (三行全变成 `["AANobbMI","P7dR8mSH","1eAoo2KR"]`); - 审阅指出的另一半判据也补上了:`Done` + 残留名单 ⇒ 必须不画;现在**只去掉** `stage != Failed` 那一半就红(之前只去掉它仍是全绿,报告的表述不准,已改)。 --- crates/app-iced/src/pages/content/demo.rs | 32 ++++-- .../app-iced/src/pages/content/model/retry.rs | 16 ++- .../src/pages/content/model/tests/retry.rs | 39 ++++++- .../app-iced/src/pages/content/parts/retry.rs | 3 +- crates/i18n/src/catalog/content.rs | 1 - crates/i18n/src/catalog/golden/tail.rs | 1 - crates/presenter/src/dto/install.rs | 32 +++++- crates/presenter/src/host/addon/install.rs | 42 ++++--- crates/presenter/src/host/addon/plan.rs | 107 +++++++----------- .../src/host/addon/plan/remembered.rs | 82 ++++++++++++++ .../presenter/src/host/addon/tests/retry.rs | 75 +++++++++++- 11 files changed, 326 insertions(+), 104 deletions(-) create mode 100644 crates/presenter/src/host/addon/plan/remembered.rs diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index e668369..cc16373 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -40,21 +40,26 @@ fn retry_failure(instance: &str, catalog: Catalog) -> Option { files_total: 4, ..InstallProgressView::new() }; + // 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形: + // 根项目 = 请求里的标题、下载失败 = 文件名、解析 404 = 没有名字(只画 id)。 progress.retry = vec![ + InstallFailureView::new( + "AANobbMI", + "Sodium", + InstallFailureKind::Unreachable, + "下载 sodium-fabric-0.6.0.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), InstallFailureView::new( "P7dR8mSH", + "", InstallFailureKind::Incompatible, "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", ), InstallFailureView::new( "1eAoo2KR", + "fabric-api-0.92.2+1.20.1.jar", InstallFailureKind::Unreachable, - "取版本失败(online_not_found):HTTP 404", - ), - InstallFailureView::new( - "AANobbMI", - InstallFailureKind::Unreachable, - "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + "下载 fabric-api-0.92.2+1.20.1.jar 失败(online_not_connected):连不上 cdn.modrinth.com", ), ]; RetryPanel::of(&progress, catalog) @@ -321,7 +326,20 @@ mod tests { assert!(panel.has_both_tiers(), "{:?}", panel.groups); assert_eq!( panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable), - Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + Some( + [ + // 根项目:名字来自请求标题 + "Sodium (AANobbMI)".to_string(), + // 下载失败:名字来自文件名 + "fabric-api-0.92.2+1.20.1.jar (1eAoo2KR)".to_string(), + ] + .as_slice() + ) + ); + // 拿不到名的那一档(解析 404 / 不兼容):只画 id,不编名字 + assert_eq!( + panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible), + Some(["P7dR8mSH".to_string()].as_slice()) ); } } diff --git a/crates/app-iced/src/pages/content/model/retry.rs b/crates/app-iced/src/pages/content/model/retry.rs index a6d8f17..9606944 100644 --- a/crates/app-iced/src/pages/content/model/retry.rs +++ b/crates/app-iced/src/pages/content/model/retry.rs @@ -7,12 +7,16 @@ //! ```text //! 安装未完成 —— 3 项没装上 //! 不兼容所选版本/加载器 -//! P7dR8mSH +//! P7dR8mSH ← 拿不到名字时只画 id(合理兜底) //! 取不到(网络 / 项目不存在) -//! 1eAoo2KR、AANobbMI +//! Sodium (AANobbMI) ← 根项目:名字来自请求标题 +//! fabric-api-0.92.2.jar (1eAoo2KR) ← 下载失败:名字来自文件名 //! [只重试这 3 个] [关闭] //! ``` //! +//! 每行是 `InstallFailureView::line()` 的结果(名字优先、id 次要)。名字是**语言中立**的站点 +//! 标题或文件名,不是 core 的中文散文——那条线(i18n R2)踩了就回不去。 +//! //! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉) //! //! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰); @@ -27,8 +31,8 @@ use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{ - ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage, - LoaderKind, + ContentInstallRequest, ContentSource, InstallFailureKind, InstallFailureView, + InstallProgressView, InstallStage, LoaderKind, }; /// 面板上的一个档位分组。 @@ -38,7 +42,7 @@ pub struct RetryGroup { pub kind: InstallFailureKind, /// 这一档的标题 key(不硬编码中文) pub title: Key, - /// 这一档的项目键(顺序 = 计划里的顺序) + /// 这一档要画的每一行(`名字 (id)`;拿不到名字时就只有 id,顺序 = 计划里的顺序) pub items: Vec, } @@ -80,7 +84,7 @@ impl RetryPanel { title: title_of(kind), items: progress .retry_of(kind) - .map(|item| item.project.clone()) + .map(InstallFailureView::line) .collect(), }) .filter(|group| !group.items.is_empty()) diff --git a/crates/app-iced/src/pages/content/model/tests/retry.rs b/crates/app-iced/src/pages/content/model/tests/retry.rs index d64a3f8..28e2fc5 100644 --- a/crates/app-iced/src/pages/content/model/tests/retry.rs +++ b/crates/app-iced/src/pages/content/model/tests/retry.rs @@ -20,14 +20,22 @@ fn failed_progress() -> unlucky_presenter::dto::InstallProgressView { files_total: 4, ..InstallProgressView::new() }; + // 三种可显示性各一条(与 `plan::label_of` 的三种情况同形): + // 根项目用请求里的标题、下载失败用文件名、拿不到名的只画 id。 progress.retry = vec![ + InstallFailureView::new( + "AANobbMI", + "Sodium", + InstallFailureKind::Unreachable, + "连不上 CDN", + ), InstallFailureView::new( "P7dR8mSH", + "fabric-api.jar", InstallFailureKind::Incompatible, "没有匹配的版本", ), - InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"), - InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"), + InstallFailureView::new("1eAoo2KR", "", InstallFailureKind::Unreachable, "HTTP 404"), ]; progress } @@ -41,13 +49,21 @@ fn the_two_tiers_are_two_groups_with_two_labels() { let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板"); assert_eq!(panel.total, 3); assert!(panel.has_both_tiers(), "{:?}", panel.groups); + // **P2-1 的守门**:有名字的画「名字 (id)」,没有名字的才只画 id assert_eq!( panel.items_of(InstallFailureKind::Incompatible), - Some(["P7dR8mSH".to_string()].as_slice()) + Some(["fabric-api.jar (P7dR8mSH)".to_string()].as_slice()) ); assert_eq!( panel.items_of(InstallFailureKind::Unreachable), - Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + Some(["Sodium (AANobbMI)".to_string(), "1eAoo2KR".to_string()].as_slice()) + ); + // 变异守门:把标签置空 ⇒ 这一行退回裸 id,上面两条立刻红 + assert!( + panel + .items_of(InstallFailureKind::Unreachable) + .is_some_and(|items| items[0].starts_with("Sodium")), + "根项目必须显示请求里的标题,不是 id" ); let titles: Vec = panel.groups.iter().map(|group| group.title).collect(); assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个"); @@ -71,6 +87,7 @@ fn an_empty_tier_does_not_get_a_group() { }; progress.retry = vec![InstallFailureView::new( "P7dR8mSH", + "fabric-api.jar", InstallFailureKind::Incompatible, "没有匹配的版本", )]; @@ -101,7 +118,19 @@ fn a_fully_successful_install_has_no_retry_surface_at_all() { assert!(model.panel.is_none()); assert!(model.retry_install_request().is_none()); - // 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西 + // 判据是**两半**:`Failed` **且** 名单非空。第一半单独钉住——就算快照里残留了名单 + // (不该发生,host 成功时会清),`Done` 也不画(r1 复审指出:只去掉这一半曾全绿)。 + let mut stale = InstallProgressView { + stage: InstallStage::Done, + ..InstallProgressView::new() + }; + stale.retry = failed_progress().retry; + assert!( + RetryPanel::of(&stale, Lang::En.catalog()).is_none(), + "Done 态不许画重试面板(判据是「失败且有名单」,不是「有名单」)" + ); + + // 第二半:失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西 let mut failed = InstallProgressView { stage: InstallStage::Failed, ..InstallProgressView::new() diff --git a/crates/app-iced/src/pages/content/parts/retry.rs b/crates/app-iced/src/pages/content/parts/retry.rs index bbe81ed..7b7c750 100644 --- a/crates/app-iced/src/pages/content/parts/retry.rs +++ b/crates/app-iced/src/pages/content/parts/retry.rs @@ -101,8 +101,9 @@ pub(crate) fn retry_panel<'a>( ), buttons::text( ui, + // 复用下载页那条现有的「关闭」:同一句话不另立一条要同步的 key(r1 复审 N2) Button::new( - catalog.text(Key::ContentInstallDismiss), + catalog.text(Key::DownloadDismiss), PageMessage::CancelInstall ), ), diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index e26bd9b..a7829e3 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -118,7 +118,6 @@ macro_rules! content_entries { "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。" ), - ContentInstallDismiss => ("Dismiss", "关闭"), // --- M113: pack import / export(PACKUI)--- PackTitle => ("Modpack import & export", "整合包导入 / 导出"), PackImportTitle => ("Import a modpack", "导入整合包"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index f9e23d9..b81c3f3 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -391,7 +391,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"), ("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"), ("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"), - ("ContentInstallDismiss", "Dismiss", "关闭"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackImportTitle", "Import a modpack", "导入整合包"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), diff --git a/crates/presenter/src/dto/install.rs b/crates/presenter/src/dto/install.rs index 9b72d31..fd7b8dd 100644 --- a/crates/presenter/src/dto/install.rs +++ b/crates/presenter/src/dto/install.rs @@ -76,17 +76,29 @@ impl InstallFailureKind { pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable]; } -/// 一条「可只重试」的失败项(M191):项目键 + 档位 + core 给的原话。 +/// 一条「可只重试」的失败项(M191):项目键 + 可读标签 + 档位 + core 给的原话。 /// /// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键 /// (`DependencyPlan::retry_request()` 用的同一份)。 +/// +/// ## 为什么还要 `label`(M191 r1 复审 P2-1) +/// +/// `project` 是站点 id(`P7dR8mSH`),直接画在界面上等于给用户一串哈希。可读名其实都在手边, +/// 只是没进 DTO:根项目的名字就在请求的 `title` 里;失败文件的名字在 `PlannedNode.file.filename` +/// 里(下载失败那种)。`label` 收的正是这两个**语言中立**的字符串(站点标题 / 文件名), +/// **不是** `detail` 那种 core 中文散文——那会踩 i18n 的 R2(core 中文不进界面)。 +/// +/// `label` 为空是合法且**必须**保留的一档:项目不存在(404)时确实没有名字可显示, +/// 那时界面只画 id(评审认这条兜底是合理的)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstallFailureView { /// 请求它用的项目键(slug 或 id;重试也用它) pub project: String, + /// 语言中立的可读标签(站点给的项目标题 / 文件名;空 = 拿不到名字) + pub label: String, /// 哪一档 pub kind: InstallFailureKind, - /// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案) + /// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案;**不要**画到界面上) pub detail: String, } @@ -95,15 +107,31 @@ impl InstallFailureView { #[must_use] pub fn new( project: impl Into, + label: impl Into, kind: InstallFailureKind, detail: impl Into, ) -> Self { Self { project: project.into(), + label: label.into(), kind, detail: detail.into(), } } + + /// 界面该画的那一行:有名字就「名字 (id)」,没有就只画 id。 + /// + /// 界面上**名字优先、id 次要**:名字让人认出是哪个项目,括号里的 id 是给「名字对不上时」 + /// 或排查用的。定在这个方法里而不是视图里,是因为「有名字用名字」这条要有用例钉住。 + #[must_use] + pub fn line(&self) -> String { + let label = self.label.trim(); + if label.is_empty() || label == self.project { + self.project.clone() + } else { + format!("{label} ({})", self.project) + } + } } /// 一次安装的进度快照。 diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index aa1d1c5..3009502 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -161,15 +161,11 @@ fn run( // 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。 // 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。 if request.retry { - let Some((target_dir, previous)) = plan::remembered(env, &request.instance) else { + let Some((target_dir, title, previous)) = plan::remembered(env, &request.instance) else { return Err(local(RETRY_LOST)); }; - progress.version = previous - .request - .roots - .first() - .cloned() - .unwrap_or_else(|| request.instance.clone()); + // 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5) + progress.version = title.clone(); let present = plan::present_files(&target_dir); let core_engine = super::super::engine::engine(env); let dependency_plan = plan::retry_plan(&previous, present, |retry| { @@ -181,7 +177,10 @@ fn run( env, request, dependency_plan, - &target_dir, + Placement { + dir: &target_dir, + title: &title, + }, cancel, progress, tx, @@ -220,7 +219,10 @@ fn run( env, request, dependency_plan, - &target_dir, + Placement { + dir: &target_dir, + title: &request.title, + }, cancel, progress, tx, @@ -228,6 +230,14 @@ fn run( ) } +/// 「装到哪、那次叫什么」——这两样总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 +struct Placement<'a> { + /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) + dir: &'a Path, + /// 那次装入的显示名(失败名单里根项目的可读名就是它) + title: &'a str, +} + /// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 /// /// 收口的两件事在同一个地方做,是因为它们必须同时发生: @@ -240,7 +250,7 @@ fn collect( env: &HostEnv, request: &ContentInstallRequest, mut dependency_plan: DependencyPlan, - target_dir: &Path, + placement: Placement<'_>, cancel: &AtomicBool, progress: &mut InstallProgressView, tx: &JobSender, @@ -272,7 +282,7 @@ fn collect( // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 let installed = downloads::download_stage( &mut dependency_plan, - target_dir, + placement.dir, &fetcher, cancel, progress, @@ -283,8 +293,14 @@ fn collect( // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { - progress.retry = plan::retry_items(&dependency_plan); - plan::remember(env, &request.instance, target_dir, &dependency_plan); + progress.retry = plan::retry_items(&dependency_plan, placement.title); + plan::remember( + env, + &request.instance, + placement.dir, + placement.title, + &dependency_plan, + ); return Err(failure); } progress.retry.clear(); diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index 50686df..dd3654e 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -19,18 +19,20 @@ //! //! # 「可只重试这些」的名单也在这里(M191) //! -//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 档位 + 原话), -//! 界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的 +//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 可读标签 + +//! 档位 + 原话),界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的 //! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。 //! +//! 可读标签由 [`label_of`] 定(根项目用请求标题、下载失败用文件名、解析失败留空), +//! **不许**拿 `failure.message` 顶上——那是 core 的中文散文,界面照画会踩 i18n R2。 +//! //! # 不兼容有自己的一档(M191 起) //! //! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说 //! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射, //! **不再借 `Unsupported`**。 -use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::path::Path; use unlucky_core::addon::online::{ DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, @@ -39,9 +41,12 @@ use unlucky_core::addon::online::{ use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView}; -use super::super::env::HostEnv; use super::sources::failure_of; +mod remembered; + +pub(super) use remembered::{forget, remember, remembered}; + /// 扫目标目录(不递归)里已存在的文件名。 /// /// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的 @@ -121,13 +126,18 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { /// /// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。 /// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。 +/// +/// `root_title` 是**用户点的那次装入的显示名**(`ContentInstallRequest.title`,来自检索命中的 +/// 项目标题)。它只用来给「根项目自己失败」那条垫一个可读名(M191 r1 P2-1:面板原来只画 +/// `P7dR8mSH` 这种站点 id,用户拿到的是一串哈希)。 #[must_use] -pub(super) fn retry_items(plan: &DependencyPlan) -> Vec { +pub(super) fn retry_items(plan: &DependencyPlan, root_title: &str) -> Vec { plan.failures .iter() .map(|failure| { InstallFailureView::new( failure.requested_as.clone(), + label_of(plan, failure, root_title), failure_kind_of(failure.kind), failure.message.clone(), ) @@ -135,6 +145,34 @@ pub(super) fn retry_items(plan: &DependencyPlan) -> Vec { .collect() } +/// 一条失败的可读标签(**语言中立**:站点标题或文件名;拿不到就是空串)。 +/// +/// 两个来源,都在手边、零额外请求: +/// +/// | 情况 | 标签 | 为什么 | +/// |---|---|---| +/// | 根项目自己失败 | 请求里的 `title` | 用户刚才点的就是它,界面其它地方用的也是这个名字 | +/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 | +/// | 都没有(解析阶段 404 / 不兼容) | 空 | 站点没给过名字;界面退回只画 id(合理兜底) | +/// +/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 +#[must_use] +fn label_of(plan: &DependencyPlan, failure: &DependencyFailure, root_title: &str) -> String { + if plan + .request + .roots + .first() + .is_some_and(|root| root == &failure.requested_as) + { + return root_title.to_string(); + } + plan.nodes + .iter() + .find(|node| node.requested_as == failure.requested_as) + .map(|node| node.file.filename.clone()) + .unwrap_or_default() +} + /// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。 const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind { match kind { @@ -262,63 +300,6 @@ fn unresolved_note(plan: &DependencyPlan) -> Option { )) } -/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 -/// -/// ## 为什么要一张静态表(说清楚这笔债) -/// -/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在 -/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与 -/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host -/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。 -/// -/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 -/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 -struct Remembered { - host: PathBuf, - instance: String, - /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) - dir: PathBuf, - plan: DependencyPlan, -} - -static REMEMBERED: Mutex> = Mutex::new(Vec::new()); - -/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 -pub(super) fn remember(env: &HostEnv, instance: &str, dir: &Path, plan: &DependencyPlan) { - let host = env.layout.home().to_path_buf(); - let mut table = REMEMBERED - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - table.retain(|item| !(item.host == host && item.instance == instance)); - table.push(Remembered { - host, - instance: instance.to_string(), - dir: dir.to_path_buf(), - plan: plan.clone(), - }); -} - -/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。 -pub(super) fn forget(env: &HostEnv, instance: &str) { - let host = env.layout.home().to_path_buf(); - let mut table = REMEMBERED - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - table.retain(|item| !(item.host == host && item.instance == instance)); -} - -/// 这个实例上一次失败的计划(+ 当时的落盘目录);`None` = 没有可重试的记录。 -pub(super) fn remembered(env: &HostEnv, instance: &str) -> Option<(PathBuf, DependencyPlan)> { - let host = env.layout.home().to_path_buf(); - let table = REMEMBERED - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - table - .iter() - .find(|item| item.host == host && item.instance == instance) - .map(|item| (item.dir.clone(), item.plan.clone())) -} - /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 /// /// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, diff --git a/crates/presenter/src/host/addon/plan/remembered.rs b/crates/presenter/src/host/addon/plan/remembered.rs new file mode 100644 index 0000000..b2f139d --- /dev/null +++ b/crates/presenter/src/host/addon/plan/remembered.rs @@ -0,0 +1,82 @@ +//! 「上一次装入留下的计划」表(M191):`retry_plan()` 的输入。 +//! +//! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。 +//! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变 +//! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。 +//! + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use unlucky_core::addon::online::DependencyPlan; + +use super::super::super::env::HostEnv; + +/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 +/// +/// ## 为什么要一张静态表(说清楚这笔债) +/// +/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在 +/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与 +/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host +/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。 +/// +/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 +/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 +struct Remembered { + host: PathBuf, + instance: String, + /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) + dir: PathBuf, + /// 那次装入的显示名(重试时面板头与失败名单的可读名都沿用它,不再退回项目 id) + title: String, + plan: DependencyPlan, +} + +static REMEMBERED: Mutex> = Mutex::new(Vec::new()); + +/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 +pub(in crate::host::addon) fn remember( + env: &HostEnv, + instance: &str, + dir: &Path, + title: &str, + plan: &DependencyPlan, +) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); + table.push(Remembered { + host, + instance: instance.to_string(), + dir: dir.to_path_buf(), + title: title.to_string(), + plan: plan.clone(), + }); +} + +/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。 +pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); +} + +/// 这个实例上一次失败的计划(+ 当时的落盘目录 + 显示名);`None` = 没有可重试的记录。 +pub(in crate::host::addon) fn remembered( + env: &HostEnv, + instance: &str, +) -> Option<(PathBuf, String, DependencyPlan)> { + let host = env.layout.home().to_path_buf(); + let table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table + .iter() + .find(|item| item.host == host && item.instance == instance) + .map(|item| (item.dir.clone(), item.title.clone(), item.plan.clone())) +} diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs index fb45742..dea9b01 100644 --- a/crates/presenter/src/host/addon/tests/retry.rs +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -36,7 +36,7 @@ fn plan_with_two_tier_failures() -> DependencyPlan { /// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。 #[test] fn the_retry_list_keeps_the_two_tiers_apart() { - let items = retry_items(&plan_with_two_tier_failures()); + let items = retry_items(&plan_with_two_tier_failures(), "Sodium"); assert_eq!(items.len(), 2); assert_eq!( items @@ -63,7 +63,7 @@ fn a_clean_plan_offers_nothing_to_retry() { vec![node("AANobbMI", "sodium.jar", 0, &[])], Vec::new(), ); - assert!(retry_items(&clean).is_empty()); + assert!(retry_items(&clean, "Sodium").is_empty()); assert!(failure_of_plan(&clean, 1).is_none()); } @@ -116,9 +116,11 @@ fn the_last_failed_plan_is_remembered_per_instance() { let plan = plan_with_two_tier_failures(); let dir = root.join(".minecraft/mods"); - remember(&env, "survival", &dir, &plan); - let (saved_dir, saved) = remembered(&env, "survival").expect("记住了这一次"); + remember(&env, "survival", &dir, "Sodium", &plan); + let (saved_dir, saved_title, saved) = remembered(&env, "survival").expect("记住了这一次"); assert_eq!(saved_dir, dir); + // 显示名跟着计划一起记住:重试时面板头与失败名单的可读名都沿用它(不再退回项目 id) + assert_eq!(saved_title, "Sodium"); assert_eq!(saved.failures.len(), 2); // 别的实例不受影响(同一台机器上装过两个实例) assert!(remembered(&env, "modded").is_none()); @@ -138,7 +140,7 @@ fn the_last_failed_plan_is_remembered_per_instance() { #[test] fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { let plan = plan_with_two_tier_failures(); - let items: Vec = retry_items(&plan); + let items: Vec = retry_items(&plan, "Sodium"); let request = retry_request(&plan, Vec::new()); assert_eq!( items @@ -148,3 +150,66 @@ fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { request.roots ); } + +/// **P2-1 的守门**:面板上写的是可读名,不是裸项目 id。 +/// +/// 三个来源各一条(`plan::label_of` 的三种情况),而且都必须**语言中立**(站点标题 / 文件名, +/// 不是 core 的中文散文): +/// +/// | 项 | 情况 | 期望那一行 | +/// |---|---|---| +/// | 根项目 | 它自己失败 | `Sodium (AANobbMI)` —— 名字来自请求的 `title` | +/// | 前置 | 下载失败(计划里有节点) | `fabric-api.jar (P7dR8mSH)` —— 名字来自文件名 | +/// | 前置 | 解析阶段 404(没有节点) | `1eAoo2KR` —— 拿不到名,只画 id(合理兜底) | +/// +/// 变异守门:把 `label_of` 的标签置空(或 `retry_items` 传空串)⇒ 这条红。 +#[test] +fn the_retry_list_shows_a_readable_name_not_just_the_project_id() { + let mut plan = plan( + vec![ + // 根项目自己失败(下载失败):requested_as 与 roots[0] 相同 + failure( + "AANobbMI", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 sodium.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), + // 前置下载失败:计划里有它的节点 ⇒ 用文件名 + failure( + "P7dR8mSH", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), + // 前置解析 404:没有节点、也不是根 ⇒ 没有名字可显示 + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + vec![ + node("AANobbMI", "sodium-fabric-0.6.0.jar", 0, &[]), + node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]), + ], + Vec::new(), + ); + // 根必须是请求里的那个键(`plan::request` 只放一个根) + plan.request = unlucky_core::addon::online::ResolveRequest::single("AANobbMI"); + + let items = retry_items(&plan, "Sodium"); + let lines: Vec = items.iter().map(InstallFailureView::line).collect(); + assert_eq!( + lines, + vec![ + "Sodium (AANobbMI)".to_string(), + "fabric-api.jar (P7dR8mSH)".to_string(), + // 拿不到名的那一档:只画 id,不编造、也不拿 core 的中文 message 顶上 + "1eAoo2KR".to_string(), + ] + ); + // 标签是语言中立的:站点标题/文件名,不是 core 的中文散文 + assert!(!items[0].label.contains("下载"), "{}", items[0].label); + assert!(!items[1].label.contains("失败"), "{}", items[1].label); +} From 26e7b7333ea90231b038a5433e41f0181842e09f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 18:09:11 +0800 Subject: [PATCH 28/33] =?UTF-8?q?fix(content):=20=E4=B8=8D=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=A1=A3=E5=9C=A8=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8A=E5=8F=96=E9=A1=B9=E7=9B=AE=E5=90=8D=20+=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E8=BD=AE=E4=B8=8D=E5=86=8D=E8=B4=B4=E9=94=99=E6=A0=B9?= =?UTF-8?q?=E7=9A=84=E6=A0=87=E9=A2=98=20(M191=20r2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r2 两条 p2,都修了,并按复审给的口径验过红。 **P2-1(续):不兼容档不再只有裸 id** `FailureKind::Incompatible` 的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目), 所以失败路径上值得现取一次名字——`plan::names_for_incompatible` 按失败项去重、一项一次 GET (`Modrinth::project`),`fetch_title` 把任何失败都折成 `None`: **拿名字失败不许影响安装结果**,取不到 / 拿到空标题就退回 id。只有这一档发请求; 「取不到」档 404 那部分项目根本不存在,再发一次只会再吃 404。 **P2-2(新):重试轮把根的标题贴到了前置头上** 根因是判「这条是不是根项目自己失败」用了 `plan.request.roots.first()`,而重试那一轮的 plan 来自 `retry_request()`,它的 `roots` 就是上一轮的失败名单——第一个是前置。 现在判据改成 **id 比对**(`Root{id,title}`,id 由 `remember` 一起记下来): `label_of` 只在这条失败的 id 真的等于原始根 id 时才用根的标题。复审给的那个形状 (roots=["P7dR8mSH","1eAoo2KR"] + root_title="Sodium")现在画 `P7dR8mSH`,不再是 `Sodium (P7dR8mSH)`。 **守门(每条都真跑红过,且在拆完模块的最终代码上复跑)** - 去掉不兼容档的名字来源 ⇒ `an_incompatible_item_shows_the_project_name_once_it_is_known` 红 (`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`)。 - `label_of` 换回 `roots.first()` ⇒ `the_retry_round_does_not_paste_the_root_title_onto_a_dependency` 红 (`left: ["Sodium (P7dR8mSH)","1eAoo2KR"]`,正是复审实测的那一行)。 - 取名字不降级(`expect`)⇒ `a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything` 红。 **行数**:`plan.rs` 加完这套到 414 行、`install.rs` 到 403 行,都越过 400 硬线 ⇒ 把「名字从哪来」整块(`Root`/`Names`/`names_for_incompatible`/`label_of`/`fetch_title`) 纯搬移到 `plan/labels.rs`(111 行):`plan.rs` 336、`install.rs` 388。 --- crates/app-iced/src/pages/content/demo.rs | 8 +- .../src/pages/content/model/tests/retry.rs | 6 +- crates/presenter/src/host/addon/install.rs | 37 +++-- crates/presenter/src/host/addon/plan.rs | 42 +----- .../presenter/src/host/addon/plan/labels.rs | 111 ++++++++++++++ .../src/host/addon/plan/remembered.rs | 48 ++++-- .../presenter/src/host/addon/tests/retry.rs | 138 ++++++++++++++++-- 7 files changed, 313 insertions(+), 77 deletions(-) create mode 100644 crates/presenter/src/host/addon/plan/labels.rs diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index cc16373..60023f4 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -41,7 +41,7 @@ fn retry_failure(instance: &str, catalog: Catalog) -> Option { ..InstallProgressView::new() }; // 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形: - // 根项目 = 请求里的标题、下载失败 = 文件名、解析 404 = 没有名字(只画 id)。 + // 根项目 = 请求里的标题、下载失败 = 文件名、不兼容档 = 失败路径上现取的项目名。 progress.retry = vec![ InstallFailureView::new( "AANobbMI", @@ -51,7 +51,7 @@ fn retry_failure(instance: &str, catalog: Catalog) -> Option { ), InstallFailureView::new( "P7dR8mSH", - "", + "Entity Culling", InstallFailureKind::Incompatible, "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", ), @@ -336,10 +336,10 @@ mod tests { .as_slice() ) ); - // 拿不到名的那一档(解析 404 / 不兼容):只画 id,不编名字 + // 不兼容档:失败路径上现取的项目名(不是 id) assert_eq!( panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible), - Some(["P7dR8mSH".to_string()].as_slice()) + Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice()) ); } } diff --git a/crates/app-iced/src/pages/content/model/tests/retry.rs b/crates/app-iced/src/pages/content/model/tests/retry.rs index 28e2fc5..f5ba6d0 100644 --- a/crates/app-iced/src/pages/content/model/tests/retry.rs +++ b/crates/app-iced/src/pages/content/model/tests/retry.rs @@ -31,7 +31,7 @@ fn failed_progress() -> unlucky_presenter::dto::InstallProgressView { ), InstallFailureView::new( "P7dR8mSH", - "fabric-api.jar", + "Entity Culling", InstallFailureKind::Incompatible, "没有匹配的版本", ), @@ -52,7 +52,7 @@ fn the_two_tiers_are_two_groups_with_two_labels() { // **P2-1 的守门**:有名字的画「名字 (id)」,没有名字的才只画 id assert_eq!( panel.items_of(InstallFailureKind::Incompatible), - Some(["fabric-api.jar (P7dR8mSH)".to_string()].as_slice()) + Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice()) ); assert_eq!( panel.items_of(InstallFailureKind::Unreachable), @@ -87,7 +87,7 @@ fn an_empty_tier_does_not_get_a_group() { }; progress.retry = vec![InstallFailureView::new( "P7dR8mSH", - "fabric-api.jar", + "Entity Culling", InstallFailureKind::Incompatible, "没有匹配的版本", )]; diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 3009502..92bbaee 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -161,14 +161,14 @@ fn run( // 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。 // 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。 if request.retry { - let Some((target_dir, title, previous)) = plan::remembered(env, &request.instance) else { + let Some(saved) = plan::remembered(env, &request.instance) else { return Err(local(RETRY_LOST)); }; // 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5) - progress.version = title.clone(); - let present = plan::present_files(&target_dir); + progress.version = saved.title.clone(); + let present = plan::present_files(&saved.dir); let core_engine = super::super::engine::engine(env); - let dependency_plan = plan::retry_plan(&previous, present, |retry| { + let dependency_plan = plan::retry_plan(&saved.plan, present, |retry| { let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); block_on(resolve(&client, retry)) @@ -178,8 +178,11 @@ fn run( request, dependency_plan, Placement { - dir: &target_dir, - title: &title, + dir: &saved.dir, + root: plan::Root { + id: &saved.root, + title: &saved.title, + }, }, cancel, progress, @@ -221,7 +224,10 @@ fn run( dependency_plan, Placement { dir: &target_dir, - title: &request.title, + root: plan::Root { + id: &request.project, + title: &request.title, + }, }, cancel, progress, @@ -230,12 +236,15 @@ fn run( ) } -/// 「装到哪、那次叫什么」——这两样总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 +/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 struct Placement<'a> { /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) dir: &'a Path, - /// 那次装入的显示名(失败名单里根项目的可读名就是它) - title: &'a str, + /// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供 + /// + /// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」 + /// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。 + root: plan::Root<'a>, } /// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 @@ -293,12 +302,16 @@ fn collect( // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { - progress.retry = plan::retry_items(&dependency_plan, placement.title); + // 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里 + // 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。 + let names = plan::names_for_incompatible(&dependency_plan, |id| plan::fetch_title(env, id)); + progress.retry = plan::retry_items(&dependency_plan, placement.root, &names); plan::remember( env, &request.instance, placement.dir, - placement.title, + placement.root.id, + placement.root.title, &dependency_plan, ); return Err(failure); diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index dd3654e..2e7b8d8 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -43,8 +43,10 @@ use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView}; use super::sources::failure_of; +mod labels; mod remembered; +pub(super) use labels::{fetch_title, names_for_incompatible, Root}; pub(super) use remembered::{forget, remember, remembered}; /// 扫目标目录(不递归)里已存在的文件名。 @@ -126,18 +128,18 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { /// /// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。 /// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。 -/// -/// `root_title` 是**用户点的那次装入的显示名**(`ContentInstallRequest.title`,来自检索命中的 -/// 项目标题)。它只用来给「根项目自己失败」那条垫一个可读名(M191 r1 P2-1:面板原来只画 -/// `P7dR8mSH` 这种站点 id,用户拿到的是一串哈希)。 #[must_use] -pub(super) fn retry_items(plan: &DependencyPlan, root_title: &str) -> Vec { +pub(super) fn retry_items( + plan: &DependencyPlan, + root: labels::Root<'_>, + names: &labels::Names, +) -> Vec { plan.failures .iter() .map(|failure| { InstallFailureView::new( failure.requested_as.clone(), - label_of(plan, failure, root_title), + labels::label_of(plan, failure, root, names), failure_kind_of(failure.kind), failure.message.clone(), ) @@ -145,34 +147,6 @@ pub(super) fn retry_items(plan: &DependencyPlan, root_title: &str) -> Vec String { - if plan - .request - .roots - .first() - .is_some_and(|root| root == &failure.requested_as) - { - return root_title.to_string(); - } - plan.nodes - .iter() - .find(|node| node.requested_as == failure.requested_as) - .map(|node| node.file.filename.clone()) - .unwrap_or_default() -} - /// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。 const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind { match kind { diff --git a/crates/presenter/src/host/addon/plan/labels.rs b/crates/presenter/src/host/addon/plan/labels.rs new file mode 100644 index 0000000..03b2312 --- /dev/null +++ b/crates/presenter/src/host/addon/plan/labels.rs @@ -0,0 +1,111 @@ +//! 「这条失败该显示什么名字」——**语言中立**的标签来源(M191 r1 P2-1 / r2 P2-1 续)。 +//! +//! 从 `plan.rs` 拆出(加完失败路径上取名字那次 GET 之后父文件到 414 行,越过 400 行硬线)。 +//! 拆的口径是「一件事一个文件」:这里只管**名字从哪来**,判定与说话仍在 `plan.rs`。 +//! +//! 三个来源,按可信度排(细节在 [`label_of`]):原始根项目的标题、失败文件的文件名、 +//! 失败路径上现取的项目名;都没有就留空,界面只画项目 id。 +//! +//! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 + +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, +}; +use unlucky_core::download::blocking::block_on; + +use super::super::super::env::HostEnv; + +/// 这一次装入的**原始根项目**(id + 显示名)。 +/// +/// 「是不是根」只能按 `id` 比(r2 复审 P2-2):重试那一轮的 plan 来自 +/// `DependencyPlan::retry_request()`,它的 `request.roots` **就是上一轮的失败名单**—— +/// 第一个是前置,拿它当「根」会把根的标题贴到前置头上。所以判据是「这条失败的 id 是不是 +/// 当初那次装入的根 id」,两个值都由 [`remembered`] / `ContentInstallRequest` 提供。 +#[derive(Clone, Copy)] +pub(in crate::host::addon) struct Root<'a> { + /// 当初那次装入的根项目 id(`ContentInstallRequest.project`) + pub id: &'a str, + /// 它的显示名(`ContentInstallRequest.title`;只配给 `id` 用) + pub title: &'a str, +} + +/// 失败路径上现取的项目名(id → title)。取不到的项目不在表里。 +pub(in crate::host::addon) type Names = Vec<(String, String)>; + +/// 为「不兼容」档现取项目名(**只在失败路径上**,一个项目一次 GET)。 +/// +/// 为什么只给这一档:`FailureKind::Incompatible` 是「站点**有这个项目**,但目标版本/加载器下 +/// 没有条目」——项目确实存在,所以这次 GET 有东西可拿。「取不到」档里 404 的那部分项目根本 +/// 不存在,给它发请求只会再吃一个 404,于是那一档仍显示 id(合理兜底)。 +/// +/// `lookup` 由调用方注入(生产是 `Modrinth::project`,用例是闭包):于是「取名字失败也要能降级」 +/// 这件事在没有网络的用例里也能钉住。取不到 / 拿到空串的项一律**不进**名单。 +pub(in crate::host::addon) fn names_for_incompatible( + plan: &DependencyPlan, + lookup: impl Fn(&str) -> Option, +) -> Names { + plan.failures_of(FailureKind::Incompatible) + .filter_map(|failure| { + let title = lookup(&failure.requested_as)?; + let title = title.trim(); + (!title.is_empty()).then(|| (failure.requested_as.clone(), title.to_string())) + }) + .collect() +} + +/// 一条失败的可读标签(**语言中立**:站点标题、文件名或现取的项目名;拿不到就是空串)。 +/// +/// 三个来源,**按可信度排**: +/// +/// | 情况 | 标签 | 为什么 | +/// |---|---|---| +/// | 这条失败的 id == 原始根 id | 请求里的 `title` | 用户刚才点的就是它 | +/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 | +/// | 不兼容档 | [`names_for_incompatible`] 现取的名字 | 项目存在,一次 GET 拿得到 | +/// | 其余(解析 404 等) | 空 | 项目不存在,显示 id 是合理兜底 | +/// +/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 +#[must_use] +pub(super) fn label_of( + plan: &DependencyPlan, + failure: &DependencyFailure, + root: Root<'_>, + names: &Names, +) -> String { + // ① 根项目自己失败:**按 id 比**,不看 `plan.request.roots`(重试轮里那是失败名单) + if failure.requested_as == root.id { + return root.title.to_string(); + } + // ② 计划里有这个节点(下载失败):文件名最具体 + if let Some(node) = plan + .nodes + .iter() + .find(|node| node.requested_as == failure.requested_as) + { + return node.file.filename.clone(); + } + // ③ 不兼容档:失败路径上现取的名字;取不到就空 ⇒ 界面只画 id + if failure.kind == FailureKind::Incompatible { + return names + .iter() + .find(|(id, _)| id == &failure.requested_as) + .map(|(_, title)| title.clone()) + .unwrap_or_default(); + } + String::new() +} + +/// 取一个项目的显示名(**只在失败路径上**发)。 +/// +/// 失败一律回 `None`:**拿名字失败不许影响安装结果**——这条是「只重试这些」的补充信息, +/// 坏一次网络不该把「装了什么、缺了什么」这整件事变成一条错误。 +pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option { + let core_engine = crate::host::engine::engine(env); + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + let id = id.to_string(); + block_on(async move { client.project(&id).await }) + .ok() + .map(|project| project.title) + .filter(|title| !title.trim().is_empty()) +} diff --git a/crates/presenter/src/host/addon/plan/remembered.rs b/crates/presenter/src/host/addon/plan/remembered.rs index b2f139d..e8bd735 100644 --- a/crates/presenter/src/host/addon/plan/remembered.rs +++ b/crates/presenter/src/host/addon/plan/remembered.rs @@ -3,7 +3,6 @@ //! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。 //! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变 //! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。 -//! use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -12,7 +11,7 @@ use unlucky_core::addon::online::DependencyPlan; use super::super::super::env::HostEnv; -/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 +/// 「上一次装入留下的计划」表的一行(M191):`只重试这些` 的输入。 /// /// ## 为什么要一张静态表(说清楚这笔债) /// @@ -23,23 +22,45 @@ use super::super::super::env::HostEnv; /// /// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 /// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 -struct Remembered { +/// +/// ## 为什么连**原始根项目 id** 一起记(r2 复审 P2-2) +/// +/// 「这一条是不是根项目自己失败」不能靠 `plan.request.roots.first()` 判:重试那一轮的 plan 来自 +/// `DependencyPlan::retry_request()`,它的 `roots` 就是**上一轮的失败名单**——第一个是前置, +/// 拿它跟根的显示名一比,就会把 `Sodium` 贴到一个前置头上。所以判据只能是「这个 id 是不是 +/// 当初那次装入的根 id」,那个 id 得跟计划一起记下来。 +struct Entry { host: PathBuf, instance: String, /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) dir: PathBuf, - /// 那次装入的显示名(重试时面板头与失败名单的可读名都沿用它,不再退回项目 id) + /// 当初那次装入的根项目 id(`ContentInstallRequest.project`,判「是不是根」的唯一依据) + root: String, + /// 当初那次装入的显示名(只配给 `root` 用) title: String, plan: DependencyPlan, } -static REMEMBERED: Mutex> = Mutex::new(Vec::new()); +static REMEMBERED: Mutex> = Mutex::new(Vec::new()); + +/// 取回的那一份记录(拆成结构体而不是四元组:调用点读得懂哪一个是哪个)。 +pub(in crate::host::addon) struct Remembered { + /// 落盘目录 + pub dir: PathBuf, + /// 当初那次装入的根项目 id + pub root: String, + /// 当初那次装入的显示名 + pub title: String, + /// 上一轮的依赖计划(`retry_request()` 的来源) + pub plan: DependencyPlan, +} /// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 pub(in crate::host::addon) fn remember( env: &HostEnv, instance: &str, dir: &Path, + root: &str, title: &str, plan: &DependencyPlan, ) { @@ -48,10 +69,11 @@ pub(in crate::host::addon) fn remember( .lock() .unwrap_or_else(|poison| poison.into_inner()); table.retain(|item| !(item.host == host && item.instance == instance)); - table.push(Remembered { + table.push(Entry { host, instance: instance.to_string(), dir: dir.to_path_buf(), + root: root.to_string(), title: title.to_string(), plan: plan.clone(), }); @@ -66,11 +88,8 @@ pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) { table.retain(|item| !(item.host == host && item.instance == instance)); } -/// 这个实例上一次失败的计划(+ 当时的落盘目录 + 显示名);`None` = 没有可重试的记录。 -pub(in crate::host::addon) fn remembered( - env: &HostEnv, - instance: &str, -) -> Option<(PathBuf, String, DependencyPlan)> { +/// 这个实例上一次失败的计划(+ 落盘目录 + 原始根 id 与显示名);`None` = 没有可重试的记录。 +pub(in crate::host::addon) fn remembered(env: &HostEnv, instance: &str) -> Option { let host = env.layout.home().to_path_buf(); let table = REMEMBERED .lock() @@ -78,5 +97,10 @@ pub(in crate::host::addon) fn remembered( table .iter() .find(|item| item.host == host && item.instance == instance) - .map(|item| (item.dir.clone(), item.title.clone(), item.plan.clone())) + .map(|item| Remembered { + dir: item.dir.clone(), + root: item.root.clone(), + title: item.title.clone(), + plan: item.plan.clone(), + }) } diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs index dea9b01..026300d 100644 --- a/crates/presenter/src/host/addon/tests/retry.rs +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -5,7 +5,8 @@ use super::{failure, node, plan}; use crate::dto::{InstallFailureKind, InstallFailureView}; use crate::host::addon::plan::{ - failure_of_plan, forget, remember, remembered, retry_items, retry_plan, retry_request, + failure_of_plan, forget, names_for_incompatible, remember, remembered, retry_items, retry_plan, + retry_request, Root, }; use unlucky_core::addon::online::{DependencyPlan, FailureKind, OnlineErrorKind, PresentFile}; @@ -36,7 +37,14 @@ fn plan_with_two_tier_failures() -> DependencyPlan { /// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。 #[test] fn the_retry_list_keeps_the_two_tiers_apart() { - let items = retry_items(&plan_with_two_tier_failures(), "Sodium"); + let items = retry_items( + &plan_with_two_tier_failures(), + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); assert_eq!(items.len(), 2); assert_eq!( items @@ -63,7 +71,15 @@ fn a_clean_plan_offers_nothing_to_retry() { vec![node("AANobbMI", "sodium.jar", 0, &[])], Vec::new(), ); - assert!(retry_items(&clean, "Sodium").is_empty()); + assert!(retry_items( + &clean, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ) + .is_empty()); assert!(failure_of_plan(&clean, 1).is_none()); } @@ -116,17 +132,18 @@ fn the_last_failed_plan_is_remembered_per_instance() { let plan = plan_with_two_tier_failures(); let dir = root.join(".minecraft/mods"); - remember(&env, "survival", &dir, "Sodium", &plan); - let (saved_dir, saved_title, saved) = remembered(&env, "survival").expect("记住了这一次"); - assert_eq!(saved_dir, dir); - // 显示名跟着计划一起记住:重试时面板头与失败名单的可读名都沿用它(不再退回项目 id) - assert_eq!(saved_title, "Sodium"); - assert_eq!(saved.failures.len(), 2); + remember(&env, "survival", &dir, "AANobbMI", "Sodium", &plan); + let saved = remembered(&env, "survival").expect("记住了这一次"); + assert_eq!(saved.dir, dir); + // 原始根 id + 显示名跟着计划一起记住:重试时面板头用它,判「是不是根」也用它(r2 P2-2) + assert_eq!(saved.root, "AANobbMI"); + assert_eq!(saved.title, "Sodium"); + assert_eq!(saved.plan.failures.len(), 2); // 别的实例不受影响(同一台机器上装过两个实例) assert!(remembered(&env, "modded").is_none()); // 重试请求从「记住的计划」里取:仍然只含失败项 - let request = retry_request(&saved, Vec::new()); + let request = retry_request(&saved.plan, Vec::new()); assert_eq!(request.roots, vec!["P7dR8mSH", "1eAoo2KR"]); forget(&env, "survival"); @@ -140,7 +157,14 @@ fn the_last_failed_plan_is_remembered_per_instance() { #[test] fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { let plan = plan_with_two_tier_failures(); - let items: Vec = retry_items(&plan, "Sodium"); + let items: Vec = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); let request = retry_request(&plan, Vec::new()); assert_eq!( items @@ -198,7 +222,14 @@ fn the_retry_list_shows_a_readable_name_not_just_the_project_id() { // 根必须是请求里的那个键(`plan::request` 只放一个根) plan.request = unlucky_core::addon::online::ResolveRequest::single("AANobbMI"); - let items = retry_items(&plan, "Sodium"); + let items = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); let lines: Vec = items.iter().map(InstallFailureView::line).collect(); assert_eq!( lines, @@ -213,3 +244,86 @@ fn the_retry_list_shows_a_readable_name_not_just_the_project_id() { assert!(!items[0].label.contains("下载"), "{}", items[0].label); assert!(!items[1].label.contains("失败"), "{}", items[1].label); } + +/// **r2 P2-2 的守门**:重试那一轮,第一条失败项**不许**被贴成原根项目的名字。 +/// +/// 形状就是复审给的那个:重试轮的 plan 来自 `retry_request()`,它的 `roots` 是上一轮的失败名单 +/// (第一个是**前置**),而 `root` 记的是原始根 `AANobbMI / Sodium`。老实现拿 `roots.first()` +/// 判「是不是根」⇒ 画出 `Sodium (P7dR8mSH)`;现在按 id 比 ⇒ 第一条是它自己的名字(或 id)。 +/// +/// 变异守门:把 `label_of` 的判据换回 `plan.request.roots.first()` ⇒ 这条红。 +#[test] +fn the_retry_round_does_not_paste_the_root_title_onto_a_dependency() { + let mut plan = plan_with_two_tier_failures(); + // 重试请求:roots = 上一轮的失败名单 + plan.request = unlucky_core::addon::online::ResolveRequest { + roots: vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()], + ..plan.request.clone() + }; + // 重试轮里没有被安装的节点(上一轮装好的不会再进计划),于是标签只能来自 id + let items = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); + let lines: Vec = items.iter().map(InstallFailureView::line).collect(); + assert_eq!(lines, vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]); + assert!( + !lines.iter().any(|line| line.contains("Sodium")), + "重试轮把根的标题贴到前置上了:{lines:?}" + ); +} + +/// **r2 P2-1 续的守门**:不兼容档要显示项目名,不是 id。 +/// +/// 那一档的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),所以失败路径上值得 +/// 现取一次名字。变异守门:把这次获取去掉(`names` 传空)⇒ 第一条退回 `P7dR8mSH`,这条红。 +#[test] +fn an_incompatible_item_shows_the_project_name_once_it_is_known() { + let plan = plan_with_two_tier_failures(); + let root = Root { + id: "AANobbMI", + title: "Sodium", + }; + // 生产路径:`names_for_incompatible` 用注入的查询取名字(这里模拟"站点有回应") + let names = names_for_incompatible(&plan, |id| { + (id == "P7dR8mSH").then(|| "Entity Culling".to_string()) + }); + assert_eq!( + names, + vec![("P7dR8mSH".to_string(), "Entity Culling".to_string())], + "只给不兼容档取名字(404 那条不发请求)" + ); + + let items = retry_items(&plan, root, &names); + assert_eq!(items[0].line(), "Entity Culling (P7dR8mSH)"); + // 「取不到」那档不受影响:没有节点、没有名字 ⇒ 只画 id + assert_eq!(items[1].line(), "1eAoo2KR"); +} + +/// **降级**:取名字本身失败,不许把安装结果搞坏,也不许编名字——照旧画 id。 +/// +/// 变异守门:让 `project_title` 的错误往外冒(`?` / `expect`)⇒ 这条连编译都过不了, +/// 或者(退回成「拿不到就 panic」)在这里红。 +#[test] +fn a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything() { + let plan = plan_with_two_tier_failures(); + // 站点 404 / 没网 / 坏响应:`lookup` 全是 `None` + let names = names_for_incompatible(&plan, |_| None); + assert!(names.is_empty(), "{names:?}"); + let items = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &names, + ); + assert_eq!(items[0].line(), "P7dR8mSH", "取不到名字就画 id,不编造"); + // 拿到空串(站点给了个空标题)同样不算名字 + let blank = names_for_incompatible(&plan, |_| Some(" ".to_string())); + assert!(blank.is_empty(), "{blank:?}"); +} From 476bf92f97b02e64b58d51efc6b606d35d48b661 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 18:48:49 +0800 Subject: [PATCH 29/33] =?UTF-8?q?test(addon):=20=E7=BB=99=E3=80=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84=E5=8F=96=E5=90=8D=E5=AD=97?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E7=94=9F=E4=BA=A7=E6=8E=A5=E7=BA=BF=E4=B8=8E?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E8=A1=A5=E5=AE=88=E5=8D=AB=20(M191=20r3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r3 p2:那两处都只靠消费层用例撑着,生产接线上删掉/改坏也全绿。补两条能真红的守卫。 **接线层**:`install.rs` 里那次 `names_for_incompatible(..)` 原来不可驱动。现在下载器与 取名字器从 `Ports` 参数进来(生产 = 引擎 + `plan::fetch_title`,用例 = 假实现), 于是用例能驱动**生产那个 `collect`**:一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的 计划跑完,断言不兼容那条拿到了名字、且**只**给不兼容档问过一次。 变异(把那一行短路成空名单)⇒ `the_failed_install_path_really_fetches_names_for_the_incompatible_items` 红:`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`。 **降级层**:把「一次查询 → 有名字/没名字」的折叠抽成 `title_from(query, id)` (`fetch_title` 只剩「建客户端 + 调它」)。参数化之后三种「没名字」都能离线钉住: 查询失败 / 空标题 / 全空白 ⇒ `None`,不 panic。 变异 ①`ok` 换 `expect` ⇒ `a_failed_or_blank_lookup_yields_no_name` 红 (panic:`带断网`);②去掉空串过滤 ⇒ 同一条红(空白档)。 **顺手**:`fetch_title` 真发一次 HTTP 的那一跳没进用例(本轮实测它确实取到了真数据 `P7dR8mSH → "Fabric API"`,但把网络调用放进 `cargo test` 会变脆,所以只把可坏的那一步——折叠—— 参数化测掉,报告里如实写「HTTP 这一跳仍无用例」)。 **行数**:`install.rs` 加到 415、`tests/retry.rs` 到 436,都越过 400 硬线 ⇒ 两步纯搬移: `collect` + `Ports` + `Placement` → `install/collect.rs`(117 行,`install.rs` 321); r3 用例 → `tests/retry/wire.rs`(115 行,父文件 332)。 --- crates/presenter/src/host/addon/install.rs | 111 ++++------------- .../src/host/addon/install/collect.rs | 117 ++++++++++++++++++ .../presenter/src/host/addon/plan/labels.rs | 72 ++++++++++- .../presenter/src/host/addon/tests/retry.rs | 3 + .../src/host/addon/tests/retry/wire.rs | 115 +++++++++++++++++ 5 files changed, 324 insertions(+), 94 deletions(-) create mode 100644 crates/presenter/src/host/addon/install/collect.rs create mode 100644 crates/presenter/src/host/addon/tests/retry/wire.rs diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 92bbaee..991aadc 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -46,11 +46,11 @@ //! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。 //! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。 -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, + resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -63,6 +63,10 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; +mod collect; + +pub(super) use collect::{collect, Placement, Ports}; + use super::{downloads, plan}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 @@ -173,8 +177,15 @@ fn run( let client = Modrinth::new(&transport); block_on(resolve(&client, retry)) }); - return collect( + let fetcher = EngineArtifactFetcher::new(&core_engine); + let title = |id: &str| plan::fetch_title(env, id); + let ports = Ports { env, + fetcher: &fetcher, + title: &title, + }; + return collect( + &ports, request, dependency_plan, Placement { @@ -218,8 +229,15 @@ fn run( let client = Modrinth::new(&transport); block_on(resolve(&client, &resolve_request)) }; - collect( + let fetcher = EngineArtifactFetcher::new(&core_engine); + let title = |id: &str| plan::fetch_title(env, id); + let ports = Ports { env, + fetcher: &fetcher, + title: &title, + }; + collect( + &ports, request, dependency_plan, Placement { @@ -236,91 +254,6 @@ fn run( ) } -/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 -struct Placement<'a> { - /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) - dir: &'a Path, - /// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供 - /// - /// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」 - /// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。 - root: plan::Root<'a>, -} - -/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 -/// -/// 收口的两件事在同一个地方做,是因为它们必须同时发生: -/// -/// | 结果 | 计划 | 可重试名单 | -/// |---|---|---| -/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) | -/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) | -fn collect( - env: &HostEnv, - request: &ContentInstallRequest, - mut dependency_plan: DependencyPlan, - placement: Placement<'_>, - cancel: &AtomicBool, - progress: &mut InstallProgressView, - tx: &JobSender, - epoch: u64, -) -> Result { - progress.stage = InstallStage::Downloading; - progress.files_total = dependency_plan.nodes.len() as u64; - progress.files_done = 0; - progress.message = if dependency_plan.nodes.is_empty() { - "没有要下载的文件".to_string() - } else { - format!( - "{} 个文件(含 {} 个前置)", - dependency_plan.nodes.len(), - dependency_plan - .nodes - .iter() - .filter(|node| !node.is_root()) - .count() - ) - }; - let _ = tx.send(HostTask::Install { - progress: Box::new(progress.clone()), - epoch, - }); - - let core_engine = super::super::engine::engine(env); - let fetcher = EngineArtifactFetcher::new(&core_engine); - // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 - let installed = downloads::download_stage( - &mut dependency_plan, - placement.dir, - &fetcher, - cancel, - progress, - tx, - epoch, - )?; - - // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), - // 并列出「可只重试这 N 个」。 - if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { - // 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里 - // 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。 - let names = plan::names_for_incompatible(&dependency_plan, |id| plan::fetch_title(env, id)); - progress.retry = plan::retry_items(&dependency_plan, placement.root, &names); - plan::remember( - env, - &request.instance, - placement.dir, - placement.root.id, - placement.root.title, - &dependency_plan, - ); - return Err(failure); - } - progress.retry.clear(); - plan::forget(env, &request.instance); - Ok(plan::summary(&dependency_plan, &installed)) -} - /// 没有可重试的记录时那句话(**不**退化成「整单重装」)。 const RETRY_LOST: &str = "没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。"; diff --git a/crates/presenter/src/host/addon/install/collect.rs b/crates/presenter/src/host/addon/install/collect.rs new file mode 100644 index 0000000..199bfcb --- /dev/null +++ b/crates/presenter/src/host/addon/install/collect.rs @@ -0,0 +1,117 @@ +//! 解析之后的公共尾巴:进度 → 逐个下载 → 收口(失败名单 / 计划留存)。 +//! +//! 从 `install.rs` 拆出(r3 复审的接线守卫加完 `Ports` 之后父文件到 415 行,越过 400 行硬线)。 +//! 拆法同既有 `-p1/-p2`:**纯搬移**,调用方路径不变(`install.rs` 仍 `pub(super) use` 这三个名字)。 +//! +//! 为什么不把 `Ports` 再往里藏:`collect` 是**要能在用例里真跑**的那一段(r3 复审 p2: +//! 「失败路径真的去取了名字」原来零覆盖)。下载器与取名字器从参数进来,假实现就能驱动整条 +//! 收口路径,不碰网络。 + +use std::path::Path; +use std::sync::atomic::AtomicBool; + +use unlucky_core::addon::online::{ArtifactFetcher, DependencyPlan}; + +use crate::dto::{ContentFailure, ContentInstallRequest, InstallProgressView, InstallStage}; + +use crate::host::env::HostEnv; +use crate::host::job::{HostTask, JobSender}; + +use super::{downloads, plan}; + +/// `collect` 要用的「外部世界」:宿主环境 + 下载器 + 取名字器。 +/// +/// 三样都从这里进,是为了让**收口那段能在用例里真跑**:假下载器 + 假取名字器就能驱动 +/// 「下载 → 收口 → 失败名单」整条路,于是「失败路径真的去取了不兼容档的名字」这件事有用例 +/// 钉住(r3 复审 p2:那一行原来删掉也全绿)。 +pub(in crate::host::addon) struct Ports<'a> { + /// 宿主环境(记「上一轮的计划」要用它的数据根) + pub(in crate::host::addon) env: &'a HostEnv, + /// 下载器(生产 = 引擎;用例 = 假实现,不碰网络) + pub(in crate::host::addon) fetcher: &'a dyn ArtifactFetcher, + /// 取项目显示名(生产 = [`plan::fetch_title`];用例 = 假实现) + pub(in crate::host::addon) title: &'a dyn Fn(&str) -> Option, +} + +/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 +pub(in crate::host::addon) struct Placement<'a> { + /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) + pub(in crate::host::addon) dir: &'a Path, + /// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供 + /// + /// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」 + /// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。 + pub(in crate::host::addon) root: plan::Root<'a>, +} + +/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 +/// +/// 收口的两件事在同一个地方做,是因为它们必须同时发生: +/// +/// | 结果 | 计划 | 可重试名单 | +/// |---|---|---| +/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) | +/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) | +pub(in crate::host::addon) fn collect( + ports: &Ports<'_>, + request: &ContentInstallRequest, + mut dependency_plan: DependencyPlan, + placement: Placement<'_>, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result { + progress.stage = InstallStage::Downloading; + progress.files_total = dependency_plan.nodes.len() as u64; + progress.files_done = 0; + progress.message = if dependency_plan.nodes.is_empty() { + "没有要下载的文件".to_string() + } else { + format!( + "{} 个文件(含 {} 个前置)", + dependency_plan.nodes.len(), + dependency_plan + .nodes + .iter() + .filter(|node| !node.is_root()) + .count() + ) + }; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + + // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 + let installed = downloads::download_stage( + &mut dependency_plan, + placement.dir, + ports.fetcher, + cancel, + progress, + tx, + epoch, + )?; + + // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), + // 并列出「可只重试这 N 个」。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { + // 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里 + // 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。 + let names = plan::names_for_incompatible(&dependency_plan, ports.title); + progress.retry = plan::retry_items(&dependency_plan, placement.root, &names); + plan::remember( + ports.env, + &request.instance, + placement.dir, + placement.root.id, + placement.root.title, + &dependency_plan, + ); + return Err(failure); + } + progress.retry.clear(); + plan::forget(ports.env, &request.instance); + Ok(plan::summary(&dependency_plan, &installed)) +} diff --git a/crates/presenter/src/host/addon/plan/labels.rs b/crates/presenter/src/host/addon/plan/labels.rs index 03b2312..458b2a8 100644 --- a/crates/presenter/src/host/addon/plan/labels.rs +++ b/crates/presenter/src/host/addon/plan/labels.rs @@ -9,7 +9,8 @@ //! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 use unlucky_core::addon::online::{ - DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, + DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, OnlineError, + ProjectDetail, }; use unlucky_core::download::blocking::block_on; @@ -97,15 +98,76 @@ pub(super) fn label_of( /// 取一个项目的显示名(**只在失败路径上**发)。 /// -/// 失败一律回 `None`:**拿名字失败不许影响安装结果**——这条是「只重试这些」的补充信息, -/// 坏一次网络不该把「装了什么、缺了什么」这整件事变成一条错误。 +/// 一次 GET,折成「有名字 / 没名字」的那一步在 [`title_from`](那里才是能坏的地方,因此那一步 +/// 单独可测;本函数只有「怎么发这一枪」)。 pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option { let core_engine = crate::host::engine::engine(env); let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); - let id = id.to_string(); - block_on(async move { client.project(&id).await }) + title_from(|id| block_on(async { client.project(id).await }), id) +} + +/// 把「一次项目查询」折成「有名字 / 没名字」——**降级就在这一层**(r3 复审 p2 要的守卫)。 +/// +/// 三种「没名字」都算 `None`,一个都不许往外冒: +/// +/// | 情况 | 结果 | +/// |---|---| +/// | 查询失败(没网 / 404 / 坏响应) | `None`(**不是 panic,也不是错误**) | +/// | 站点给了空标题 | `None` | +/// | 站点给了全空白标题 | `None` | +/// +/// 为什么单独抽出来:这条链路上唯一会「坏」的就是这一句折叠。抽成参数化的纯函数之后, +/// 「查询失败仍退回 id」与「空白不算名字」都能在没有网络的用例里钉住(变异:`ok` 换 `expect` +/// ⇒ 红;去掉空串过滤 ⇒ 红)。 +fn title_from( + query: impl FnOnce(&str) -> Result, + id: &str, +) -> Option { + query(id) .ok() .map(|project| project.title) .filter(|title| !title.trim().is_empty()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn detail(title: &str) -> ProjectDetail { + ProjectDetail { + id: "P7dR8mSH".to_string(), + title: title.to_string(), + ..ProjectDetail::default() + } + } + + /// **降级层**:查询失败 / 空标题 / 全空白 ⇒ 一律 `None`(界面退回只画 id),不 panic。 + /// + /// 抓得住:把 `.ok()` 换成 `expect`(查询失败即 panic)⇒ 第一条红; + /// 去掉 `.filter(..)`(空白也算名字)⇒ 后两条红。 + #[test] + fn a_failed_or_blank_lookup_yields_no_name() { + // ① 查询失败(没网 / 404 / 坏响应) + assert!(title_from(|_| Err(OnlineError::offline("断网")), "P7dR8mSH").is_none()); + // ② 站点给了空标题 + assert!(title_from(|_| Ok(detail("")), "P7dR8mSH").is_none()); + // ③ 站点给了全空白标题 + assert!(title_from(|_| Ok(detail(" ")), "P7dR8mSH").is_none()); + // ④ 正常情况:原样给出 + assert_eq!( + title_from(|_| Ok(detail("Entity Culling")), "P7dR8mSH"), + Some("Entity Culling".to_string()) + ); + // 传进去的 id 就是查询用的那个(不要拿别的键去问) + let mut asked = String::new(); + let _ = title_from( + |id| { + asked.push_str(id); + Ok(detail("X")) + }, + "P7dR8mSH", + ); + assert_eq!(asked, "P7dR8mSH"); + } +} diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs index 026300d..d4429a5 100644 --- a/crates/presenter/src/host/addon/tests/retry.rs +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -327,3 +327,6 @@ fn a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything() { let blank = names_for_incompatible(&plan, |_| Some(" ".to_string())); assert!(blank.is_empty(), "{blank:?}"); } + +// r3 复审 p2:生产接线与降级守卫(按主题分卷,父文件已贴 400 行硬线) +mod wire; diff --git a/crates/presenter/src/host/addon/tests/retry/wire.rs b/crates/presenter/src/host/addon/tests/retry/wire.rs new file mode 100644 index 0000000..6cfc285 --- /dev/null +++ b/crates/presenter/src/host/addon/tests/retry/wire.rs @@ -0,0 +1,115 @@ +//! r3 复审 p2:**生产接线**与**降级**的守卫(`super` 按主题分卷)。 +//! +//! 这一卷的用例驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器, +//! 于是「失败路径真的去取了不兼容档的名字」这件事在没有网络的用例里也能钉住。 + +use std::sync::atomic::AtomicBool; +use std::sync::Mutex; + +use unlucky_core::addon::online::{ + ArtifactFetcher, FetchArgs, OnlineError, OnlineErrorKind, OnlineFuture, +}; + +use super::plan_with_two_tier_failures; +use crate::dto::{ + ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, LoaderKind, +}; +use crate::host::addon::install::{collect, Placement, Ports}; +use crate::host::addon::plan::Root; + +/// 假下载器:一律「写一行字节成功」——本组用例只关心收口那一半(下载阶段另有自己的用例)。 +struct QuietFetcher; + +impl ArtifactFetcher for QuietFetcher { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + std::fs::write(args.dest, b"jar").map_err(|error| { + OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}")) + })?; + Ok(4) + }) + } +} + +fn request_for(instance: &str) -> ContentInstallRequest { + ContentInstallRequest { + project: "AANobbMI".to_string(), + title: "Sodium".to_string(), + kind: crate::dto::ContentKind::Mod, + source: ContentSource::Modrinth, + instance: instance.to_string(), + game_version: "1.20.1".to_string(), + loader: LoaderKind::Fabric, + retry: false, + } +} + +/// **接线层守卫**(r3 复审 p2 第 1 条):安装失败路径**真的**去取了不兼容档的名字。 +/// +/// 这条驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器, +/// 于是一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的计划跑完后: +/// +/// - 不兼容那条**拿到了名字**(`Entity Culling`); +/// - 取名字器只被**不兼容**那条问过一次(404 那条不发请求)。 +/// +/// 变异守门:把 `collect` 里那行 `names_for_incompatible(...)` 删掉/短路(传空名单)⇒ 红。 +#[test] +fn the_failed_install_path_really_fetches_names_for_the_incompatible_items() { + let root = std::env::temp_dir().join(format!("ull-m191-wire-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join(".minecraft/mods"); + std::fs::create_dir_all(&dir).expect("建目录"); + let env = crate::host::HostEnv::at_root(&root); + + let plan = plan_with_two_tier_failures(); + let asked: Mutex> = Mutex::new(Vec::new()); + let title = |id: &str| { + asked.lock().expect("锁没中毒").push(id.to_string()); + (id == "P7dR8mSH").then(|| "Entity Culling".to_string()) + }; + let fetcher = QuietFetcher; + let ports = Ports { + env: &env, + fetcher: &fetcher, + title: &title, + }; + let (tx, _rx) = crate::host::job::channel(); + let cancel = AtomicBool::new(false); + let mut progress = InstallProgressView::new(); + let request = request_for("survival"); + + let result = collect( + &ports, + &request, + plan, + Placement { + dir: &dir, + root: Root { + id: "AANobbMI", + title: "Sodium", + }, + }, + &cancel, + &mut progress, + &tx, + 1, + ); + + assert!(result.is_err(), "有失败项就该回一条失败"); + let incompatible = progress + .retry + .iter() + .find(|item| item.kind == InstallFailureKind::Incompatible) + .expect("不兼容档要在名单里"); + assert_eq!( + incompatible.line(), + "Entity Culling (P7dR8mSH)", + "失败路径没有把取到的名字贴上去" + ); + assert_eq!( + *asked.lock().expect("锁没中毒"), + vec!["P7dR8mSH".to_string()], + "只该给不兼容档取名字(404 那条不发请求)" + ); + let _ = std::fs::remove_dir_all(&root); +} From 1441a240fe45be7c0536d202fc4e69398b8df59b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 15:58:46 +0800 Subject: [PATCH 30/33] =?UTF-8?q?feat(m195):=20=E5=AF=BC=E5=85=A5=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF=E5=8F=96=E6=B6=88=E4=BB=A4=E7=89=8C=E4=B8=8E=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=9B=9E=E6=BB=9A=EF=BC=88=E5=8F=96=E6=B6=88=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M190 复核 §3 A-2:导入管线没有取消令牌,「取消导入」只改界面状态,后台仍跑完并写盘 (半包 / 覆盖风险,丢数据档)。本轮把取消做成管线级: - core 新增 `import/cancel.rs`:`ImportCancel` 令牌(可 `from_flag` 接管既有 `Arc`)+ 内部 `Stopped::{Cancelled, Failed}` 两条停止路线; - 结果类型单列 `ImportStatus::Cancelled`(`error` 恒为 None、退出码 130、文案 key 独立), 与 `Failed` 是两条路——UI 不弹红、脚本不会把「改主意」记成「装坏了」; - 七个检查点:入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 合并态逐文件 → 登记前; 解压 / 远端下载各逐条检查;单条下载在飞不可中断(fetcher 无取消参数)已在文档里说准; - 取消后不留半包 / 不覆盖已有实例:暂存目录整个删、合并态 journal 逆序回滚(备份改回原名)、 落盘后取消连内容一起回滚;`files / conflicts / missing / bytes_written` 清零,不留假账; - presenter:同一个取消标记现在真交给 core(`with_cancel`),取消后不再「后台跑完并登记」; CLI 增 `cancelled → 130` 一档与三档人读名。 守门三条各带变异证明(见 mission 报告):删回滚 → 红、删登记前检查 → 红、把取消并进 `fail()` → 红。 没验:超大整合包的真实 IO 下取消响应延迟(最坏 = 当前一条文件读完/下完 + 一次回滚)、 单条下载在飞不可中断、macOS / Linux 未跑。 --- crates/cli/src/commands/pack.rs | 14 + crates/core/src/addon/import.rs | 42 +- crates/core/src/addon/import/cancel.rs | 152 ++++++++ crates/core/src/addon/import/cancel_tests.rs | 365 ++++++++++++++++++ .../import/cancel_tests/report_shaping.rs | 111 ++++++ crates/core/src/addon/import/complete.rs | 12 +- crates/core/src/addon/import/phase.rs | 90 ++--- crates/core/src/addon/import/phase/settle.rs | 61 +++ crates/core/src/addon/import/pipeline.rs | 143 ++++--- crates/core/src/addon/import/prepare.rs | 65 ++++ crates/core/src/addon/import/report.rs | 98 ++++- crates/core/src/addon/import/report/keys.rs | 6 + crates/core/src/addon/import/scan.rs | 3 +- crates/core/src/addon/import/stage.rs | 95 ++++- crates/core/src/addon/import/stage/commit.rs | 12 + crates/presenter/src/host/addon/pack.rs | 21 +- .../presenter/src/host/addon/pack/convert.rs | 15 +- .../presenter/src/host/addon/pack/import.rs | 28 +- 18 files changed, 1144 insertions(+), 189 deletions(-) create mode 100644 crates/core/src/addon/import/cancel.rs create mode 100644 crates/core/src/addon/import/cancel_tests.rs create mode 100644 crates/core/src/addon/import/cancel_tests/report_shaping.rs create mode 100644 crates/core/src/addon/import/phase/settle.rs create mode 100644 crates/core/src/addon/import/prepare.rs diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index a3620ee..8045004 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -11,6 +11,11 @@ //! | `success` | `0` | 全部落地、无冲突 | //! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) | //! | `failed` | `1` | 致命失败,**已整包回滚** | +//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) | +//! +//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令, +//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时 +//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。 //! //! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看 //! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、 @@ -208,6 +213,15 @@ fn status_label(report: &ImportReport) -> &'static str { Some(RollbackStage::Incomplete) => "失败(回滚不完整)", None => match report.status { ImportStatus::Success => "成功", + // 取消**不是失败**:不说"失败",也不说"部分成功"(那会让人以为装了一半)。 + // 但回滚不完整时也不许说"与导入前一致"——与失败路径同一套诚实口径。 + ImportStatus::Cancelled => { + if report.rolled_back { + "已取消(目标目录与导入前一致)" + } else { + "已取消(回滚不完整,目标目录可能与导入前不同)" + } + } _ => "部分成功", }, } diff --git a/crates/core/src/addon/import.rs b/crates/core/src/addon/import.rs index 3ceb8c7..3231335 100644 --- a/crates/core/src/addon/import.rs +++ b/crates/core/src/addon/import.rs @@ -29,6 +29,14 @@ //! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 | //! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 | //! +//! # 取消(M195):每条停下路线都回滚,且**取消不是失败** +//! +//! 用户点「取消」之后管线真的停手:每个阶段边界与长循环内都有检查点,取消后目标目录与导入前 +//! 一致(先写临时目录、最后 `rename` / journal 合并),**不登记实例**。结果类型单列一档 +//! [`ImportStatus::Cancelled`](`error` 为空、CLI 退出码 `130`、UI 不弹红),与 +//! [`ImportStatus::Failed`] 是两条路。逐段检查点表(含"哪几段没有检查点、为什么")在 +//! [`cancel`] 模块文档里。 +//! //! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住) //! //! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义; @@ -55,11 +63,13 @@ //! ```ignore //! let engine = /* core::download::DownloadEngine */; //! let fetcher = DownloadEngineFetcher::new(&engine); +//! let cancel = ImportCancel::new(); // 交给「取消」按钮:cancel.cancel() //! let request = ImportRequest::new(pack_path, instance_dir) //! .with_fetcher(&fetcher) +//! .with_cancel(&cancel) //! .with_registration(&launcher_layout, None); //! let report = download::blocking::block_on(import_pack(&request)); -//! match report.status { /* Success / Partial / Failed */ } +//! match report.status { /* Success / Partial / Failed / Cancelled */ } //! ``` //! //! # 刻意不做的事(分工边界) @@ -76,8 +86,13 @@ //! //! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、 //! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。 +//! +//! 取消这一条另有三个欠账(M195 报告同样登记):①超大整合包(几千文件 / 数 GB)在真实磁盘 +//! 上的**取消响应延迟**没测——最坏要等"当前这一条文件读完/下完 + 一次回滚";②**单条下载在飞 +//! 不可中断**([`fetch::PackFetcher`] 没有取消参数),只能等它返回;③macOS / Linux 未验。 pub mod baseline; +pub mod cancel; pub(crate) mod complete; pub mod decision; pub mod error; @@ -86,11 +101,13 @@ pub mod merge; pub mod phase; pub mod pipeline; pub mod plan; +pub(crate) mod prepare; pub mod report; pub mod scan; pub mod stage; pub use baseline::{PackBaseline, BASELINE_FILE}; +pub use cancel::ImportCancel; pub use decision::ConflictDecisions; pub use error::{ImportError, ImportErrorKind}; pub use fetch::{ @@ -98,8 +115,8 @@ pub use fetch::{ FetchedFile, ModrinthFileMeta, PackFetcher, }; pub use merge::{MergeAction, MergeDecision}; -pub use pipeline::baseline_relative; pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant}; +pub use prepare::baseline_relative; pub use report::{ keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue, ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile, @@ -156,6 +173,11 @@ pub struct ImportRequest<'a> { pub instance_name: Option<&'a str>, /// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。 pub decisions: ConflictDecisions, + /// 取消令牌;缺省是永不取消的独立令牌(没挂令牌的调用方不必写 `Option`)。 + /// + /// 语义与检查点位置见 [`cancel`] 模块文档:取消后**不得**留下半包 / 覆盖已有实例,也不许 + /// 登记实例;判据是报告里的 [`report::ImportStatus::Cancelled`](**不是**失败)。 + pub cancel: ImportCancel, /// 包读取限额(条数 / 单条体积 / 解压总量)。 pub limits: PackLimits, /// 进度回调。 @@ -173,6 +195,7 @@ impl<'a> ImportRequest<'a> { register: None, instance_name: None, decisions: ConflictDecisions::default(), + cancel: ImportCancel::default(), limits: PackLimits::default(), progress: None, } @@ -227,9 +250,22 @@ impl<'a> ImportRequest<'a> { self.progress = Some(sink); self } + + /// 挂取消令牌(与调用方手里的那份**共享同一个开关**)。 + /// + /// 取消的结果是 [`ImportStatus`] 里的 `Cancelled`(不是失败);停在哪一段、哪些段落没有 + /// 检查点,见 [`cancel`] 模块文档的逐段表。 + #[must_use] + pub fn with_cancel(mut self, token: &ImportCancel) -> Self { + self.cancel = token.clone(); + self + } } -/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。 +/// 导入一个整合包。**永不返回 `Err`**:三态 + 取消 + 逐项原因都在 [`ImportReport`] 里。 pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport { pipeline::run(request).await } + +#[cfg(test)] +mod cancel_tests; diff --git a/crates/core/src/addon/import/cancel.rs b/crates/core/src/addon/import/cancel.rs new file mode 100644 index 0000000..70b4559 --- /dev/null +++ b/crates/core/src/addon/import/cancel.rs @@ -0,0 +1,152 @@ +//! 导入的**取消令牌**(M195):用户点「取消」之后,后台真的停手并回滚,而不是跑完再假装停了。 +//! +//! # 取消不是失败(这是本模块存在的唯一理由) +//! +//! | 现象 | 取消 | 失败 | +//! |------|------|------| +//! | 结果类型 | [`super::report::ImportStatus::Cancelled`] | [`super::report::ImportStatus::Failed`] | +//! | `ImportReport::error` | `None` | 必有 | +//! | CLI 退出码 | `130` | `1` | +//! | UI | 面板收回 / 「已取消」,**不弹红** | 红色结果块 | +//! +//! 管线内部因此有**两条**停止路线:[`Stopped`]。它故意不是 [`super::error::ImportError`] 的 +//! 第 N 个大类——把两者并成一个变体,调用方就再也分不清"用户改主意"与"这包装坏了", +//! 而这两件事的收尾文案、退出码、重试语义全都不同。 +//! +//! # 取消到底停在哪一段(逐段点名,没有检查点的段落要说明为什么) +//! +//! | 段 | 代码 | 检查点 | 为什么是这个粒度 | +//! |----|------|--------|------------------| +//! | 读包 | `PackArchive::open` → `PackRegistry::read` → `ImportPlan::build` | 入口前、**解析后**各一次 | 一次 `open` 是**不可分割**的(zip 目录 + 四检 + 限额一次扫完),取消等它返回;解析完立刻有一次,所以"用户刚点取消时包已经读完"也不会白写盘 | +//! | 校验 / 补全 | `pipeline::load_baseline`、`complete::complete` | 补全循环**每条目**一次 | 补全是逐条网络请求(按 sha512 反查 sha1),条目之间是天然的取消点 | +//! | 解压 / 写入(暂存目录) | `phase::extract_overrides` | **每条**一次 | 只写临时目录,取消 = 临时目录整个删掉,目标目录一个字节都没动 | +//! | 远端下载(暂存目录) | `phase::fetch_remote` | **每条**一次 | 单条 `PackFetcher::fetch_file` **在飞不可中断**(fetcher 接口没有取消参数),取消在它返回后的下一次检查生效 | +//! | 落盘 | `Staging::commit` | 提交前一次;合并态**逐文件**一次 | 全新导入是一次 `rename`(单步原子,之后无可取消);合并态是逐文件 `rename` + journal,中途取消**逆序回滚**(新文件删掉、旧备份改回原名) | +//! | 登记实例 | `game::install::register` | 落盘后、登记前一次 | 内容已进目标目录,取消必须**连内容一起回滚**,否则留下"有目录没档案"的半个实例 | +//! | 提交前清中间产物 | `Staging::purge_engine_artifacts` | 与"提交前"共用一次 | 它只是暂存目录里的一次本地遍历,与 `commit` 同一条边界 | +//! +//! **预检([`super::scan`])不接取消**:它只读盘、不写一个字节,取消了也不会留下任何东西; +//! 给它加令牌只是让"用户点完取消还得等预检跑完"这件事更难看。 +//! +//! # 与 M186 的下载取消不是一套(不许照搬) +//! +//! M186 的口径是"解析后查一次 + 下载循环每次迭代前查一次",因为那边是**一批独立文件**的下载 +//! 队列,随时可以在文件之间停。导入是**单包解压 + 两阶段落盘**:真正的风险不是"少下几个文件", +//! 而是"临时目录里的半包被提交进目标目录"。所以这里多了两处 M186 没有的检查点——**落盘逐文件** +//! 与**登记前**——它们对应两种只能靠回滚才能收场的状态(合并态已搬了一半 / 内容已落盘但档案 +//! 还没建)。照搬 M186 的两处检查会漏掉这两段,正是"取消后仍写盘"的原始 finding 形态。 +//! +//! # 目标的形态(取消后的机器位) +//! +//! 报告的收尾在 [`super::report::ImportReport::cancel`](三档注记与 `rolled_back` 都在那里): +//! +//! - 取消发生在**落盘之前** ⇒ 临时目录已删,`rolled_back = true`、`rollback` 为 `None`; +//! - 取消发生在**落盘之中/之后** ⇒ 回滚已做,`rollback` 里有结果,`rolled_back` 只在**完整** +//! 回滚时为 `true`(回滚本身失败时如实为 `false` 并逐项列出,与失败路径同一套口径)。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use super::error::ImportError; + +/// 取消令牌:一个可跨线程共享的布尔开关。 +/// +/// [`Default`] 造出的是**永不取消**的独立令牌(没挂令牌的调用方零成本,不必到处写 `Option`)。 +/// 要用既有 `Arc`(presenter 的 `cancel_pack` 就是这么存的)时走 +/// [`ImportCancel::from_flag`],两边共享同一个开关。 +#[derive(Debug, Clone, Default)] +pub struct ImportCancel { + flag: Arc, +} + +impl ImportCancel { + /// 一个新的、未取消的令牌。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 接管一个既有开关(共享同一份状态:外面置位,这里立刻看得见)。 + #[must_use] + pub fn from_flag(flag: Arc) -> Self { + Self { flag } + } + + /// 底层开关(要交给别的线程持有时用)。 + #[must_use] + pub fn flag(&self) -> Arc { + Arc::clone(&self.flag) + } + + /// 置位(幂等)。 + pub fn cancel(&self) { + self.flag.store(true, Ordering::SeqCst); + } + + /// 是否已被取消。 + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.flag.load(Ordering::SeqCst) + } + + /// 一个检查点:已取消 ⇒ [`Stopped::Cancelled`](由 `?` 冒泡到编排层)。 + pub(crate) fn stopped(&self) -> Result<(), Stopped> { + if self.is_cancelled() { + Err(Stopped::Cancelled) + } else { + Ok(()) + } + } +} + +/// 管线内部的两条停止路线:**取消**与**失败**。 +/// +/// 分开的理由见模块文档:"用户改主意"不是"这包装坏了"。`From` 让所有既有的 +/// `?`(IO / 穿越 / 摘要不符 / 限额)继续直接冒泡成 [`Stopped::Failed`],不用把每个调用点 +/// 都改成手写映射。 +#[derive(Debug)] +pub(crate) enum Stopped { + /// 用户在检查点之前点了取消。 + Cancelled, + /// 一次必须回滚的失败。 + Failed(ImportError), +} + +impl From for Stopped { + fn from(error: ImportError) -> Self { + Self::Failed(error) + } +} + +impl From for Stopped { + fn from(error: crate::addon::pack::PackError) -> Self { + Self::Failed(error.into()) + } +} + +impl From for Stopped { + fn from(error: super::fetch::FetchError) -> Self { + Self::Failed(error.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_token_is_shared_only_with_the_flags_it_was_built_from() { + let token = ImportCancel::new(); + assert!(!token.is_cancelled()); + token.cancel(); + assert!(token.is_cancelled(), "自己置位自己要看得见"); + + let foreign = ImportCancel::new(); + assert!(!foreign.is_cancelled(), "默认令牌之间不共享状态"); + + let flag = Arc::new(AtomicBool::new(false)); + let shared = ImportCancel::from_flag(Arc::clone(&flag)); + flag.store(true, Ordering::SeqCst); + assert!(shared.is_cancelled(), "外部置位要被看见"); + } +} diff --git a/crates/core/src/addon/import/cancel_tests.rs b/crates/core/src/addon/import/cancel_tests.rs new file mode 100644 index 0000000..dddae12 --- /dev/null +++ b/crates/core/src/addon/import/cancel_tests.rs @@ -0,0 +1,365 @@ +//! 导入取消(M195)的守门用例:**取消不是失败**、**取消不留半包 / 不覆盖已有实例**、 +//! **取消后不登记实例**。 +//! +//! # 每条用例挡哪种回归(变异证明见 mission 报告) +//! +//! | 用例 | 删掉哪一处就红 | +//! |------|----------------| +//! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 | +//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环里的检查点 | +//! | `a_cancel_in_the_middle_of_the_merge_restores_the_users_file` | 取消时的 `rollback_merge` | +//! | `a_cancel_right_before_registering_rolls_the_whole_instance_back` | 登记前的检查点 | +//! | `cancel_and_failure_are_two_different_result_types` | 把取消并进 `fail()` 那条路 | +//! +//! 取消的触发点全部走**进度回调**(回调里置位 = 用户此刻点了取消,没有 sleep、没有竞态)。 +//! 全部离线:手造 mrpack(`files` 为空,内容全在 `overrides/`)。 + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::cancel::ImportCancel; +use super::report::{ImportProgress, ImportProgressSink, ImportStage, ImportStatus}; +use super::{import_pack, ImportRequest}; +use crate::addon::temp::TempRoot; +use crate::download::blocking::block_on; + +/// 一份只含覆盖内容的 mrpack 清单(`files` 为空 ⇒ 全部内容来自 `overrides/`,无远端条目)。 +const INDEX: &str = r#"{ + "game": "minecraft", + "formatVersion": 1, + "versionId": "1.0.0", + "name": "Cancelable", + "files": [], + "dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"} +} +"#; + +/// 手造一个 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。 +fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) { + let root = TempRoot::new(tag); + let source = root.join("src"); + write_file(&source.join("modrinth.index.json"), INDEX.as_bytes()); + for (path, bytes) in entries { + write_file(&source.join("overrides").join(path), bytes); + } + let pack = root.join("pack.mrpack"); + zip_dir(&source, &pack); + (root, pack) +} + +/// 三个文件的包。 +fn three_files(tag: &str) -> (TempRoot, PathBuf) { + mrpack( + tag, + &[ + ("mods/a.jar", b"pack a"), + ("mods/b.jar", b"pack b"), + ("mods/c.jar", b"pack c"), + ], + ) +} + +fn write_file(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, bytes).expect("写文件"); +} + +fn zip_dir(source: &Path, target: &Path) { + let file = std::fs::File::create(target).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let mut pending = vec![PathBuf::new()]; + while let Some(relative) = pending.pop() { + let dir = source.join(&relative); + let mut children: Vec<_> = std::fs::read_dir(&dir) + .expect("读目录") + .collect::, _>>() + .expect("目录项"); + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + let relative = relative.join(child.file_name()); + if child.file_type().expect("类型").is_dir() { + pending.push(relative); + continue; + } + let bytes = std::fs::read(child.path()).expect("读夹具文件"); + let entry = relative.to_string_lossy().replace('\\', "/"); + writer.start_file(entry, options).expect("写 zip 条目"); + writer.write_all(&bytes).expect("写 zip 内容"); + } + } + writer.finish().expect("收尾 zip"); +} + +/// 在**指定阶段的第一条进度事件**上置位取消(= 用户此刻点了取消)。 +fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink { + let token = token.clone(); + Arc::new(move |progress: &ImportProgress| { + if progress.stage == stage { + token.cancel(); + } + }) +} + +/// 在**合并态第二条逐文件进度**上置位取消。 +/// +/// 合并是**排序后**逐文件搬:第 0 条是基线(`.ull/pack-baseline.json`,`.` 排在最前),第 1 条 +/// 才是 `mods/a.jar`。所以 `done == 1` 正好是"用户那个被覆盖的文件已经搬进去、后面还有"的 +/// 窗口——journal 回滚要接住的正是这一刻(`total > 0` 顺带把阶段开始那条 0/0 排除掉)。 +fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink { + let token = token.clone(); + Arc::new(move |progress: &ImportProgress| { + if progress.stage == ImportStage::Commit && progress.done == 1 { + token.cancel(); + } + }) +} + +/// 目标目录里**全部文件**的实例内相对路径(排序后),用来钉"取消后与导入前逐文件相同"。 +fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { + pending.push(path); + continue; + } + let relative = path + .strip_prefix(root) + .map(|tail| tail.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + out.push(relative); + } + } + out.sort(); + out +} + +/// 父目录里有没有遗留的暂存目录(`..ull-import--`)。 +fn stray_staging(parent: &Path) -> Vec { + std::fs::read_dir(parent) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .filter(|name| name.contains(".ull-import-")) + .collect() + }) + .unwrap_or_default() +} + +#[test] +fn a_cancel_before_the_first_write_touches_nothing() { + // 入口检查点:包还没读,目标目录还没建。 + let (root, pack) = three_files("cancel-entry"); + let target = root.join("instance"); + let token = ImportCancel::new(); + token.cancel(); + + let report = block_on(import_pack( + &ImportRequest::new(&pack, &target).with_cancel(&token), + )); + + assert_eq!(report.status, ImportStatus::Cancelled); + assert!( + report.error.is_none(), + "取消不是失败:{}", + report.status.as_str() + ); + assert!(report.pack_name.is_none(), "停在入口 = 包都没读"); + assert!(report.files.is_empty()); + assert!(report.rolled_back, "一个字节都没写,目标目录与导入前一致"); + assert!(!target.exists(), "目标目录不许出现"); + assert!( + stray_staging(root.path()).is_empty(), + "{:?}", + stray_staging(root.path()) + ); +} + +#[test] +fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() { + // 解压循环里的检查点:第一条进度事件上取消 ⇒ 第一条写进临时目录,第二条之前停下, + // 临时目录整个删掉 ⇒ 目标目录**不出现半成品**,报告里也不许有"已落地 N 条"的假账。 + let (root, pack) = three_files("cancel-extract"); + let target = root.join("instance"); + let token = ImportCancel::new(); + + let report = block_on(import_pack( + &ImportRequest::new(&pack, &target) + .with_cancel(&token) + .with_progress(cancel_on(&token, ImportStage::Extract)), + )); + + assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error); + assert!(report.error.is_none(), "取消不许带错误"); + assert!(!target.exists(), "不许留下半包:{}", target.display()); + assert!( + report.files.is_empty(), + "取消前的处理记录一条都没落地,报告不许冒充已装:{:?}", + report.files + ); + assert_eq!(report.bytes_written, 0, "写入量要归零"); + assert!(report.registered.is_none()); + assert!(report.rolled_back); + assert!( + stray_staging(root.path()).is_empty(), + "{:?}", + stray_staging(root.path()) + ); +} + +#[test] +fn a_cancel_in_the_middle_of_the_merge_restores_the_users_file() { + // 合并态(目标目录已有内容):提交是逐文件 rename + journal。取消落在两条搬动之间 ⇒ + // **逆序回滚**必须把用户被覆盖的文件改回来,且不留备份、不留暂存目录。 + let (root, pack) = three_files("cancel-merge"); + let target = root.join("instance"); + write_file(&target.join("mods/a.jar"), b"user a"); + write_file(&target.join("mods/user.jar"), b"user only"); + let token = ImportCancel::new(); + + let report = block_on(import_pack( + &ImportRequest::new(&pack, &target) + .with_cancel(&token) + // 覆盖档:让 a.jar 真的走"备份 + 覆盖"那条路,取消后必须恢复用户内容。 + .with_conflict_policy(super::ConflictPolicy::Overwrite) + .with_progress(cancel_on_second_merged_file(&token)), + )); + + assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error); + assert!(report.error.is_none()); + assert_eq!( + std::fs::read(target.join("mods/a.jar")).expect("用户文件要还在"), + b"user a", + "取消后必须恢复被覆盖的用户文件(回滚被删掉就会读到包内字节)" + ); + assert_eq!( + std::fs::read(target.join("mods/user.jar")).expect("用户文件要还在"), + b"user only" + ); + assert!(!target.join("mods/b.jar").exists(), "取消后不许有半包"); + assert!(!target.join("mods/c.jar").exists(), "取消后不许有半包"); + assert_eq!( + files_under(&target), + vec!["mods/a.jar".to_string(), "mods/user.jar".to_string()], + "目标目录必须与导入前**逐文件相同**(多出基线 / 少掉用户文件都是错)" + ); + assert!( + std::fs::read_dir(target.join("mods")) + .expect("读 mods") + .filter_map(Result::ok) + .all(|entry| !entry.file_name().to_string_lossy().contains(".ullbak-")), + "备份必须被清掉,不许在实例里留 .ullbak-*" + ); + assert!( + stray_staging(root.path()).is_empty(), + "{:?}", + stray_staging(root.path()) + ); + // 回滚做过,且完整 ⇒ 机器位与文案位都说"与导入前一致"。 + assert!(report.rolled_back, "{:?}", report.rollback); + assert_eq!( + report + .rollback + .as_ref() + .map(super::stage::RollbackOutcome::failure_count), + Some(0) + ); +} + +#[test] +fn a_cancel_right_before_registering_rolls_the_whole_instance_back() { + // 登记前的检查点(**取消后不得登记实例**):取消在"内容已落盘、档案还没建"的窗口里落下 + // ⇒ 连内容一起回滚。删掉这条检查,实例就会被登记上(本用例立刻红)。 + let (root, pack) = three_files("cancel-register"); + let target = root.join("instance"); + let home = root.join("data"); + let layout = crate::platform::layout::LauncherLayout::new(&home); + let token = ImportCancel::new(); + + let report = block_on(import_pack( + &ImportRequest::new(&pack, &target) + .with_cancel(&token) + .with_registration(&layout, Some("cancelled pack")) + .with_progress(cancel_on(&token, ImportStage::Register)), + )); + + assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error); + assert!(report.error.is_none()); + assert!(report.registered.is_none(), "取消后不许登记实例"); + let profiles = crate::game::install::instance::read_profiles(&layout).expect("读档案"); + assert!( + profiles.profiles.is_empty(), + "profiles.json 里不许出现被取消的实例:{:?}", + profiles.profiles + ); + assert!(!target.exists(), "内容已提交也要连内容一起回滚"); + assert!(report.rolled_back); + assert!( + stray_staging(root.path()).is_empty(), + "{:?}", + stray_staging(root.path()) + ); +} + +#[test] +fn cancel_and_failure_are_two_different_result_types() { + // 条款③:取消与真失败必须产生**不同的结果类型**(状态变体、错误位、退出码、文案 key、 + // JSON 的 status 全不同)。把两条路合并成同一个错误,这里就红。 + let (root, pack) = three_files("cancel-vs-fail"); + let token = ImportCancel::new(); + token.cancel(); + let cancelled = block_on(import_pack( + &ImportRequest::new(&pack, &root.join("cancelled")).with_cancel(&token), + )); + + // 真失败:清单坏掉的包(开包成功、读清单失败)。 + let broken = root.join("broken.zip"); + { + let file = std::fs::File::create(&broken).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("manifest.json", options).expect("写条目"); + writer.write_all(b"{\"manifestType\":").expect("写内容"); + writer.finish().expect("收尾"); + } + let failed = block_on(import_pack(&ImportRequest::new( + &broken, + &root.join("failed"), + ))); + + assert_eq!(cancelled.status, ImportStatus::Cancelled); + assert_eq!(failed.status, ImportStatus::Failed); + assert!(cancelled.error.is_none(), "取消不是错误"); + assert!(failed.error.is_some(), "失败必须带错误"); + assert_ne!( + cancelled.status.exit_code(), + failed.status.exit_code(), + "退出码必须能区分「用户取消」与「装坏了」" + ); + assert_eq!(cancelled.status.exit_code(), 130); + assert_eq!(failed.status.exit_code(), 1); + assert_ne!(cancelled.status.message_key(), failed.status.message_key()); + let cancelled_json = serde_json::to_value(&cancelled).expect("序列化"); + let failed_json = serde_json::to_value(&failed).expect("序列化"); + assert_eq!(cancelled_json["status"], serde_json::json!("cancelled")); + assert_eq!(failed_json["status"], serde_json::json!("failed")); + assert!( + cancelled_json["error"].is_null(), + "取消的 JSON 里不许有 error:{}", + cancelled_json["error"] + ); +} + +mod report_shaping; diff --git a/crates/core/src/addon/import/cancel_tests/report_shaping.rs b/crates/core/src/addon/import/cancel_tests/report_shaping.rs new file mode 100644 index 0000000..fb0845e --- /dev/null +++ b/crates/core/src/addon/import/cancel_tests/report_shaping.rs @@ -0,0 +1,111 @@ +//! 取消报告的**三档诚实口径**:`ImportReport::cancel` 的直接用例(不需要跑管线)。 +//! +//! 从 `cancel_tests.rs` 拆出来只为行数:那边装下五个端到端用例就够满了。 + +use crate::addon::import::report::{FileAction, FileSource, ImportReport, ImportedFile}; +use crate::addon::import::stage::RollbackOutcome; +use crate::addon::import::ImportStatus; + +fn base() -> ImportReport { + ImportReport::empty(std::path::PathBuf::from("instance")) +} + +fn notes(report: &ImportReport) -> Vec<&str> { + report + .warnings + .iter() + .map(|note| note.message.as_str()) + .collect() +} + +#[test] +fn a_cancel_before_writing_never_claims_the_target_was_touched() { + let mut report = base(); + report.cancel(RollbackOutcome::default()); + + assert_eq!(report.status, ImportStatus::Cancelled); + assert!(report.error.is_none(), "取消不许带错误"); + assert!(report.rolled_back); + assert!(report.rollback.is_none(), "没做过回滚就不该有回滚记录"); + let notes = notes(&report); + assert!( + notes.iter().any(|line| line.contains("未被写入")), + "{notes:?}" + ); + assert!( + !notes.iter().any(|line| line.contains("恢复为导入前")), + "{notes:?}" + ); +} + +#[test] +fn a_cancel_after_commit_says_it_rolled_back_exactly_once() { + let mut report = base(); + report.cancel(RollbackOutcome { + attempted: 2, + failed: Vec::new(), + }); + + assert_eq!(report.status, ImportStatus::Cancelled); + assert!(report.rolled_back); + assert_eq!( + report.rollback.as_ref().map(RollbackOutcome::failure_count), + Some(0) + ); + let notes = notes(&report); + assert!( + notes.iter().any(|line| line.contains("恢复为导入前")), + "{notes:?}" + ); + assert!( + !notes.iter().any(|line| line.contains("未被写入")), + "{notes:?}" + ); +} + +#[test] +fn an_incomplete_cancel_rollback_never_claims_consistency() { + let mut report = base(); + report.cancel(RollbackOutcome { + attempted: 3, + failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()], + }); + + assert_eq!(report.status, ImportStatus::Cancelled); + assert!(!report.rolled_back, "回滚不完整时不许宣称一致"); + let notes = notes(&report); + assert!( + notes + .iter() + .any(|line| line.contains("未能回滚") || line.contains("未完成")), + "{notes:?}" + ); + assert!( + !notes + .iter() + .any(|line| line.contains("恢复为导入前的样子") || line.contains("未被写入")), + "{notes:?}" + ); +} + +#[test] +fn cancelling_clears_the_per_file_ledger_it_never_landed() { + // 取消前的 `files` 是"处理过"而不是"落地过":留着就是假账(UI 会照它画"已装 N 个")。 + let mut report = base(); + report.bytes_written = 4096; + report.files.push(ImportedFile { + path: "mods/a.jar".to_string(), + source: FileSource::Overrides, + sha1: None, + bytes: 4096, + disabled: false, + action: FileAction::Installed, + note: None, + }); + + report.cancel(RollbackOutcome::default()); + + assert!(report.files.is_empty(), "取消后不许留下「已落地」的账"); + assert_eq!(report.bytes_written, 0); + assert_eq!(report.installed(), 0); +} diff --git a/crates/core/src/addon/import/complete.rs b/crates/core/src/addon/import/complete.rs index c19468e..bdb6f78 100644 --- a/crates/core/src/addon/import/complete.rs +++ b/crates/core/src/addon/import/complete.rs @@ -77,7 +77,13 @@ pub(crate) async fn complete_and_note( plan: &mut ImportPlan, report: &mut ImportReport, ) { - if let Some(note) = complete(request, plan).await.note("") { + let completion = complete(request, plan).await; + // 取消时这条注记会撒谎(剩下的条目**没试过**,不是"补不出"):取消收尾的注记已经说清 + // 停在哪一步,这里不再补一句计数。 + if request.cancel.is_cancelled() { + return; + } + if let Some(note) = completion.note("") { report.warnings.push(note); } } @@ -94,6 +100,10 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) return state; }; for entry in plan.remote.iter_mut() { + // 取消检查点:补全是**逐条网络请求**,条目之间是天然的落点;已补的只改内存,不写盘。 + if request.cancel.is_cancelled() { + break; + } if entry.file.sha1().is_some() { continue; } diff --git a/crates/core/src/addon/import/phase.rs b/crates/core/src/addon/import/phase.rs index 0f83f61..c420307 100644 --- a/crates/core/src/addon/import/phase.rs +++ b/crates/core/src/addon/import/phase.rs @@ -7,26 +7,33 @@ use std::path::Path; use super::super::pack::{PackArchive, PackError}; use super::baseline::PackBaseline; +use super::cancel::Stopped; use super::error::ImportError; use super::fetch::{FetchError, FetchRequest}; -use super::merge::{self, MergeAction}; -use super::pipeline::tell; +use super::merge; use super::plan::{curse_class_dir, ImportPlan, PlanRemote}; -use super::report::{ - ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportReport, ImportStage, - ImportedFile, MissingFile, MissingReason, -}; +use super::report::tell; +use super::report::{ImportReport, ImportStage, ImportedFile, MissingFile, MissingReason}; use super::stage::Staging; use super::ImportRequest; use crate::download::verify::{self, digest_bytes}; use crate::download::HashAlgo; +mod settle; + +use settle::{conflict, settle}; + /// 阶段一:覆盖条目落暂存 + 三方合并。 /// /// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**: /// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记 /// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现 /// "两条 Installed、前一条的哈希其实没落地"。 +/// +/// # 取消检查点 +/// +/// 解压阶段**每条一次**(循环开头):只写临时目录,取消 = 临时目录整个删掉,目标目录一个 +/// 字节都没动。被 `?` 冒泡出去的 [`Stopped::Cancelled`] 与失败**不是同一条路**。 pub(crate) async fn extract_overrides( request: &ImportRequest<'_>, archive: &PackArchive, @@ -35,7 +42,7 @@ pub(crate) async fn extract_overrides( staging: &Staging, report: &mut ImportReport, next: &mut PackBaseline, -) -> Result<(), ImportError> { +) -> Result<(), Stopped> { let live: Vec<&super::plan::PlanOverride> = plan .overrides .iter() @@ -44,6 +51,8 @@ pub(crate) async fn extract_overrides( let total = live.len(); let target = staging.target().to_path_buf(); for (index, entry) in live.iter().enumerate() { + // 取消检查点(每条一次):只写临时目录,停下就是"什么都没发生"。 + request.cancel.stopped()?; tell( &request.progress, ImportStage::Extract, @@ -94,6 +103,12 @@ pub(crate) async fn extract_overrides( } /// 阶段二:远端条目补全 + 下载 + 同一张合并表。 +/// +/// # 取消检查点 +/// +/// 每条远端条目一次(循环开头)。**单条下载在飞不可中断**([`super::fetch::PackFetcher`] 没有 +/// 取消参数),所以取消在最坏情况下要等"当前这一条下载返回";返回后下一次检查立刻生效, +/// 已下到临时目录的字节随临时目录一起删掉。 pub(crate) async fn fetch_remote( request: &ImportRequest<'_>, plan: &ImportPlan, @@ -101,10 +116,12 @@ pub(crate) async fn fetch_remote( staging: &Staging, report: &mut ImportReport, next: &mut PackBaseline, -) -> Result<(), ImportError> { +) -> Result<(), Stopped> { let total = plan.remote.len(); let target = staging.target().to_path_buf(); for (index, entry) in plan.remote.iter().enumerate() { + // 取消检查点(每条一次):上一条下载返回之后立刻生效。 + request.cancel.stopped()?; let display = entry.path.clone().unwrap_or_else(|| entry.file.key()); tell( &request.progress, @@ -185,7 +202,8 @@ pub(crate) async fn fetch_remote( return Err(ImportError::integrity(format!( "{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})", resolved.path - ))); + )) + .into()); } } let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref()); @@ -284,60 +302,6 @@ async fn resolve( })) } -/// 合并判定 + 决策 → 实际动作。 -/// -/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]): -/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。 -fn settle( - decision: merge::MergeDecision, - disk_exists: bool, - picked: ConflictChoice, -) -> (FileAction, ConflictChoice) { - let overwrite = picked == ConflictChoice::Overwrite; - match decision.action { - MergeAction::Install => ( - if disk_exists { - FileAction::Replaced - } else { - FileAction::Installed - }, - ConflictChoice::Overwrite, - ), - MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser), - MergeAction::KeepUser if overwrite => ( - if disk_exists { - FileAction::Replaced - } else { - FileAction::Installed - }, - ConflictChoice::Overwrite, - ), - MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser), - } -} - -/// 组装一条冲突记录。 -fn conflict( - path: String, - reason: ConflictReason, - choice: ConflictChoice, - pack_sha1: &str, - pack_size: u64, - base_sha1: Option, - disk_sha1: Option, -) -> ConflictFile { - ConflictFile { - path, - reason, - choice, - message_key: reason.message_key(), - pack_sha1: pack_sha1.to_string(), - pack_size, - base_sha1, - disk_sha1, - } -} - /// 组装一条缺失记录(条目本身补不出来)。 fn missing_for(entry: &PlanRemote, fetcher_present: bool) -> MissingFile { let reason = if fetcher_present { diff --git a/crates/core/src/addon/import/phase/settle.rs b/crates/core/src/addon/import/phase/settle.rs new file mode 100644 index 0000000..bdf4936 --- /dev/null +++ b/crates/core/src/addon/import/phase/settle.rs @@ -0,0 +1,61 @@ +//! 合并判定 + 决策 → 实际动作,以及冲突记录的组装。 +//! +//! 从 [`super`] 拆出来的唯一原因是行数上限(400 行),行为逐字未变:这两个函数是**纯函数**, +//! 不碰盘、不看时钟,所以搬动它们不影响任何判据。 + +use super::super::merge::{self, MergeAction}; +use super::super::report::{ConflictChoice, ConflictFile, ConflictReason, FileAction}; + +/// 合并判定 + 决策 → 实际动作。 +/// +/// `picked` 是这一次的处置(逐项决策或整批档,见 [`crate::addon::import::decision::ConflictDecisions`]): +/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。 +pub(super) fn settle( + decision: merge::MergeDecision, + disk_exists: bool, + picked: ConflictChoice, +) -> (FileAction, ConflictChoice) { + let overwrite = picked == ConflictChoice::Overwrite; + match decision.action { + MergeAction::Install => ( + if disk_exists { + FileAction::Replaced + } else { + FileAction::Installed + }, + ConflictChoice::Overwrite, + ), + MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser), + MergeAction::KeepUser if overwrite => ( + if disk_exists { + FileAction::Replaced + } else { + FileAction::Installed + }, + ConflictChoice::Overwrite, + ), + MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser), + } +} + +/// 组装一条冲突记录。 +pub(super) fn conflict( + path: String, + reason: ConflictReason, + choice: ConflictChoice, + pack_sha1: &str, + pack_size: u64, + base_sha1: Option, + disk_sha1: Option, +) -> ConflictFile { + ConflictFile { + path, + reason, + choice, + message_key: reason.message_key(), + pack_sha1: pack_sha1.to_string(), + pack_size, + base_sha1, + disk_sha1, + } +} diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index 14620cd..607e016 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -26,35 +26,58 @@ //! //! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑 //! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。 +//! +//! # 取消(M195):七个检查点 +//! +//! 编排层负责**阶段边界**的检查点(入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 登记前), +//! 长循环里的**逐条**检查在各自实现里(解压/下载在 [`super::phase`],合并落盘在 +//! [`super::stage`])。逐段表(含"哪几段没有检查点、为什么")见 [`super::cancel`]。 +//! 每次停下都按"回滚做过什么"收尾:没写盘 ⇒ 删暂存目录;写过 ⇒ 回滚结果如实进报告。 +//! **取消不走 [`failed`]**:它走 [`ImportReport::cancel`],结果类型是 `Cancelled` +//! 而不是 `Failed`。 -use super::super::pack::{PackArchive, PackError, PackRegistry}; use super::baseline::PackBaseline; +use super::cancel::Stopped; use super::error::ImportError; use super::phase; use super::plan::ImportPlan; -use super::report::{ - keys, ImportIssue, ImportProgress, ImportReport, ImportStage, RegisteredInstance, -}; -use super::stage::{RollbackOutcome, Staging}; +use super::prepare::{account_plan, probe_and_plan, write_baseline}; +use super::report::{keys, tell, ImportIssue, ImportReport, ImportStage, RegisteredInstance}; +use super::stage::{CommitStop, RollbackOutcome, Staging}; use super::ImportRequest; -/// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。 +/// 跑一次导入。**永不返回 `Err`**:成功 / 部分成功 / 失败 / 取消全在报告里。 pub async fn run(request: &ImportRequest<'_>) -> ImportReport { let mut report = ImportReport::empty(request.target.to_path_buf()); - + // 检查点①:入口(用户可能在上一次点开面板之后、线程起来之前就取消了)。 + if request.cancel.is_cancelled() { + return cancelled(report, RollbackOutcome::default()); + } tell(&request.progress, ImportStage::Probe, 0, 0, None); let (archive, mut plan) = match probe_and_plan(request) { Ok(pair) => pair, Err(error) => return abandoned(report, error), }; account_plan(&mut report, &plan); + // 检查点②:解析后(包读完了,目标目录还一个字节都没写——停下最便宜)。 + if request.cancel.is_cancelled() { + return cancelled(report, RollbackOutcome::default()); + } super::complete::complete_and_note(request, &mut plan, &mut report).await; + // 检查点③:补全后(补全是逐条网络请求,条目之间已经在 `complete` 里停过一次)。 + if request.cancel.is_cancelled() { + return cancelled(report, RollbackOutcome::default()); + } let baseline = { let (baseline, warnings) = load_baseline(request.target, &plan); report.warnings.extend(warnings); baseline }; + // 检查点④:开暂存目录前(此后磁盘上就有临时目录了)。 + if request.cancel.is_cancelled() { + return cancelled(report, RollbackOutcome::default()); + } let staging = match Staging::begin(request.target) { Ok(staging) => staging, Err(error) => return abandoned(report, error), @@ -73,12 +96,27 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { ) .await?; phase::fetch_remote(request, &plan, &baseline, &staging, &mut report, &mut next).await?; - write_baseline(&staging, &plan, &next) + write_baseline(&staging, &plan, &next)?; + Ok::<(), Stopped>(()) } .await; - if let Err(error) = staged { + match staged { + Ok(()) => {} + // 停在暂存阶段:临时目录整个删掉,目标目录与导入前一致(一个字节都没写过)。 + Err(Stopped::Cancelled) => { + staging.abort(); + return cancelled(report, RollbackOutcome::default()); + } + Err(Stopped::Failed(error)) => { + staging.abort(); + return abandoned(report, error); + } + } + + // 检查点⑤:提交前(与"清中间产物"同一条边界)。 + if request.cancel.is_cancelled() { staging.abort(); - return abandoned(report, error); + return cancelled(report, RollbackOutcome::default()); } // 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。 @@ -95,13 +133,24 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { } tell(&request.progress, ImportStage::Commit, 0, 0, None); - let guard = match staging.commit() { + // 检查点⑥:落盘(全新导入只在 rename 之前;合并态在 `apply_merge` 里逐文件)。 + let token = request.cancel.clone(); + let guard = match staging.commit(&token, &request.progress) { Ok(guard) => guard, - Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback), + Err(CommitStop::Cancelled(outcome)) => return cancelled(report, outcome), + Err(CommitStop::Failed(failure)) => { + return record_rollback(failed(report, failure.error), failure.rollback); + } }; if let Some(layout) = request.register { tell(&request.progress, ImportStage::Register, 0, 0, None); + // 检查点⑦:登记前。内容已经落进目标目录,所以取消要**连内容一起回滚**,否则留下 + // "有目录没档案"的半个实例——这正是"取消后不得登记实例"的判据。 + if request.cancel.is_cancelled() { + let outcome = guard.rollback(); + return cancelled(report, outcome); + } let name = request .instance_name .filter(|name| !name.trim().is_empty()) @@ -138,31 +187,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { report } -/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。 +/// 取消收尾:把「用户取消」写进报告(**不是失败**),返回收好尾的报告。 /// -/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致, -/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。 -pub(super) fn probe_and_plan( - request: &ImportRequest<'_>, -) -> Result<(PackArchive, ImportPlan), ImportError> { - // 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。 - let archive = PackArchive::open(request.pack, request.limits)?; - let (format, manifest) = PackRegistry::builtin().read(&archive)?; - let plan = ImportPlan::build(&archive, format.id(), manifest)?; - Ok((archive, plan)) -} - -/// 把计划里的静态信息抄进报告。 -fn account_plan(report: &mut ImportReport, plan: &ImportPlan) { - report.format = Some(plan.format.clone()); - report.pack_name = Some(plan.manifest.name.clone()); - report.pack_version = plan.manifest.version.clone(); - report.game_version = Some(plan.mc_version.clone()); - report.loader = plan.loader.clone(); - report.version_id = Some(plan.version_id.clone()); - report.max_memory_mb = plan.max_memory_mb; - report.rejected_components.clone_from(&plan.rejected); - report.warnings.extend(plan.notes.iter().cloned()); +/// 三档注记与 `rolled_back` 的判据在 [`ImportReport::cancel`](那里也是失败路径的同形三档)。 +fn cancelled(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport { + report.cancel(outcome); + report } /// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。 @@ -233,47 +263,6 @@ fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> Import report } -/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。 -fn write_baseline( - staging: &Staging, - plan: &ImportPlan, - next: &PackBaseline, -) -> Result<(), ImportError> { - let mut baseline = next.clone(); - baseline.pack_name.clone_from(&plan.manifest.name); - baseline.format.clone_from(&plan.format); - let bytes = baseline.to_bytes().map_err(PackError::io)?; - staging.write(&baseline_relative(), &bytes)?; - Ok(()) -} - -/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见 -/// [`PackBaseline::relative_path`])。 -#[must_use] -pub fn baseline_relative() -> String { - PackBaseline::relative_path() - .to_string_lossy() - .replace('\\', "/") -} - -/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。 -pub(super) fn tell( - sink: &Option, - stage: ImportStage, - done: usize, - total: usize, - current: Option, -) { - if let Some(sink) = sink { - sink(&ImportProgress { - stage, - done, - total, - current, - }); - } -} - /// 失败收尾:**只落错误,不宣称目标目录状态**。 /// /// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。 diff --git a/crates/core/src/addon/import/prepare.rs b/crates/core/src/addon/import/prepare.rs new file mode 100644 index 0000000..fba827b --- /dev/null +++ b/crates/core/src/addon/import/prepare.rs @@ -0,0 +1,65 @@ +//! 导入管线的**准备段**:读包 → 组计划 → 抄进报告 → 写基线(一个字节都还没进目标目录)。 +//! +//! 从 [`super::pipeline`] 拆出来的是"碰盘之前"的那一段,行为逐字未变:编排文件(`pipeline.rs`) +//! 要装下取消的七个检查点与回滚收尾,把这一段移到同级模块是为了让它仍在一屏之内。 +//! +//! 这里的函数**都不查取消令牌**:整段落在"解析后一次检查"之前/之后的两侧,而 `open` 是 +//! 不可分割的(zip 目录 + 四检 + 限额一次扫完)——见 [`super::cancel`] 的逐段表。 + +use super::baseline::PackBaseline; +use super::error::ImportError; +use super::plan::ImportPlan; +use super::report::ImportReport; +use super::stage::Staging; +use super::ImportRequest; +use crate::addon::pack::{PackArchive, PackError, PackRegistry}; + +/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。 +/// +/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致, +/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。 +pub(super) fn probe_and_plan( + request: &ImportRequest<'_>, +) -> Result<(PackArchive, ImportPlan), ImportError> { + // 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。 + let archive = PackArchive::open(request.pack, request.limits)?; + let (format, manifest) = PackRegistry::builtin().read(&archive)?; + let plan = ImportPlan::build(&archive, format.id(), manifest)?; + Ok((archive, plan)) +} + +/// 把计划里的静态信息抄进报告。 +pub(super) fn account_plan(report: &mut ImportReport, plan: &ImportPlan) { + report.format = Some(plan.format.clone()); + report.pack_name = Some(plan.manifest.name.clone()); + report.pack_version = plan.manifest.version.clone(); + report.game_version = Some(plan.mc_version.clone()); + report.loader = plan.loader.clone(); + report.version_id = Some(plan.version_id.clone()); + report.max_memory_mb = plan.max_memory_mb; + report.rejected_components.clone_from(&plan.rejected); + report.warnings.extend(plan.notes.iter().cloned()); +} + +/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。 +pub(super) fn write_baseline( + staging: &Staging, + plan: &ImportPlan, + next: &PackBaseline, +) -> Result<(), ImportError> { + let mut baseline = next.clone(); + baseline.pack_name.clone_from(&plan.manifest.name); + baseline.format.clone_from(&plan.format); + let bytes = baseline.to_bytes().map_err(PackError::io)?; + staging.write(&baseline_relative(), &bytes)?; + Ok(()) +} + +/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见 +/// [`PackBaseline::relative_path`])。 +#[must_use] +pub fn baseline_relative() -> String { + PackBaseline::relative_path() + .to_string_lossy() + .replace('\\', "/") +} diff --git a/crates/core/src/addon/import/report.rs b/crates/core/src/addon/import/report.rs index 9181c2d..2d94141 100644 --- a/crates/core/src/addon/import/report.rs +++ b/crates/core/src/addon/import/report.rs @@ -32,7 +32,11 @@ use serde::Serialize; use super::error::ImportError; use super::stage::RollbackOutcome; -/// 导入三态。 +/// 导入结果(三态 + 一档"用户取消")。 +/// +/// 取消**单列一档、不是第四种失败**(M195):它有自己的变体、自己的文案 key、自己的退出码, +/// 且 `ImportReport::error` 恒为 `None`。把两者并成一个错误变体,UI 就会把"用户自己点的" +/// 画成红的,脚本也会把"我改主意了"当成"装坏了"。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ImportStatus { @@ -42,6 +46,8 @@ pub enum ImportStatus { Partial, /// 致命失败,已整包回滚。 Failed, + /// 用户主动取消:管线在检查点停下,目标目录与导入前一致(`error` 为 `None`)。 + Cancelled, } impl ImportStatus { @@ -52,6 +58,7 @@ impl ImportStatus { Self::Success => "success", Self::Partial => "partial", Self::Failed => "failed", + Self::Cancelled => "cancelled", } } @@ -62,16 +69,21 @@ impl ImportStatus { Self::Success => keys::STATUS_SUCCESS, Self::Partial => keys::STATUS_PARTIAL, Self::Failed => keys::STATUS_FAILED, + Self::Cancelled => keys::STATUS_CANCELLED, } } - /// CLI 退出码:`0` / `3` / `1`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。 + /// CLI 退出码:`0` / `3` / `1` / `130`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。 + /// + /// `130` 是「被用户中断」的惯例码(`128 + SIGINT`):**与失败不同码**,脚本不会把 + /// 「用户改主意」记成「装坏了」,也不会误判成成功。 #[must_use] pub const fn exit_code(self) -> i32 { match self { Self::Success => 0, Self::Partial => 3, Self::Failed => 1, + Self::Cancelled => 130, } } } @@ -134,6 +146,27 @@ pub struct ImportProgress { /// 进度回调。 pub type ImportProgressSink = std::sync::Arc; +/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。 +/// +/// 放在这里是因为它是 [`ImportProgress`] 的唯一出口:编排、阶段实现、提交事务都走它, +/// 谁都不许自己拼一条进度事件。 +pub(crate) fn tell( + sink: &Option, + stage: ImportStage, + done: usize, + total: usize, + current: Option, +) { + if let Some(sink) = sink { + sink(&ImportProgress { + stage, + done, + total, + current, + }); + } +} + /// 一次导入的完整结果。 #[derive(Debug, Clone, Serialize)] pub struct ImportReport { @@ -240,4 +273,65 @@ impl ImportReport { self.error = Some(error); self.status = ImportStatus::Failed; } + + /// 取消收尾:状态落到 [`ImportStatus::Cancelled`],并说准**目标目录现在的样子**。 + /// + /// 与 [`ImportReport::fail`] 是**两条路**(M195 的守门条款③):取消不是失败,`error` 保持 + /// `None`,UI 不弹红、CLI 退出码 `130`。把两者并成一个错误变体,这一条就守不住。 + /// + /// `outcome` 是取消之前已经做过的回滚动作(落盘之前取消 ⇒ `attempted == 0`,即"没写过")。 + /// 三档文案与失败路径**同形**:没写过 / 回滚干净 / 回滚没做完——回滚不完整时同样不许宣称 + /// "与导入前一致"。 + pub fn cancel(&mut self, outcome: RollbackOutcome) { + // 取消前"处理过"的条目一条都没进目标目录(暂存目录已删 / 已回滚):留着它们会让 + // `installed()` / `bytes_written` 谎报"装了多少",所以清空并把这笔账写进注记。 + let handled = self.files.len(); + self.files.clear(); + self.conflicts.clear(); + self.missing.clear(); + self.bytes_written = 0; + self.error = None; + self.status = ImportStatus::Cancelled; + + if outcome.attempted == 0 { + // 落盘之前就停了:目标目录一个字节都没动。 + self.rolled_back = true; + self.warnings.push(ImportIssue::new( + keys::NOTE_CANCELLED, + format!( + "已取消:目标目录未被写入({});已处理的 {handled} 条文件未落地", + self.target.display() + ), + )); + return; + } + + if outcome.is_complete() { + self.rolled_back = true; + self.warnings.push(ImportIssue::new( + keys::NOTE_CANCELLED, + format!( + "已取消:已回滚 {} 项,目标目录恢复为导入前的样子({})", + outcome.attempted, + self.target.display() + ), + )); + } else { + // 回滚本身失败:与失败路径同一口径——不许在"可能没回干净"时宣称一致。 + self.rolled_back = false; + self.warnings.push(ImportIssue::new( + keys::NOTE_ROLLBACK_INCOMPLETE, + format!( + "取消时回滚有 {} 项未完成(目标目录可能与导入前不同):{}", + outcome.failure_count(), + outcome.failed.join(";") + ), + )); + self.warnings.push(ImportIssue::new( + keys::NOTE_CANCELLED, + format!("已取消:已处理 {handled} 条文件、回滚未完整(详见 rollback.failed)"), + )); + } + self.rollback = Some(outcome); + } } diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index c6cd980..0848773 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -24,6 +24,8 @@ pub const STATUS_SUCCESS: &str = "pack.import.status.success"; pub const STATUS_PARTIAL: &str = "pack.import.status.partial"; /// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。 pub const STATUS_FAILED: &str = "pack.import.status.failed"; +/// 状态:**用户取消**(不是失败,`error` 为空)。 +pub const STATUS_CANCELLED: &str = "pack.import.status.cancelled"; /// 注记:`server-overrides/` 客户端不解压。 pub const NOTE_SERVER_OVERRIDES_SKIPPED: &str = "pack.import.note.server_overrides_skipped"; @@ -51,6 +53,8 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped"; /// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。 pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete"; +/// 注记:用户取消,管线在检查点停下(说明停在哪一步、已处理的条目未落地)。 +pub const NOTE_CANCELLED: &str = "pack.import.note.cancelled"; /// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。 pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed"; /// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。 @@ -87,6 +91,7 @@ pub const ALL: &[&str] = &[ STATUS_SUCCESS, STATUS_PARTIAL, STATUS_FAILED, + STATUS_CANCELLED, NOTE_SERVER_OVERRIDES_SKIPPED, NOTE_UNKNOWN_DEPENDENCY, NOTE_ENV_UNSUPPORTED, @@ -100,6 +105,7 @@ pub const ALL: &[&str] = &[ NOTE_BASELINE_PACK_MISMATCH, NOTE_ENGINE_ARTIFACTS_DROPPED, NOTE_ROLLBACK_INCOMPLETE, + NOTE_CANCELLED, NOTE_OVERRIDE_SHADOWED, NOTE_REMOTE_PATH_UNKNOWN, NOTE_REMOTE_COMPLETED, diff --git a/crates/core/src/addon/import/scan.rs b/crates/core/src/addon/import/scan.rs index 0ea938e..de6a04e 100644 --- a/crates/core/src/addon/import/scan.rs +++ b/crates/core/src/addon/import/scan.rs @@ -42,8 +42,9 @@ use super::baseline::PackBaseline; use super::decision::ConflictDecisions; use super::error::ImportError; use super::merge; -use super::pipeline::{load_baseline, probe_and_plan}; +use super::pipeline::load_baseline; use super::plan::ImportPlan; +use super::prepare::probe_and_plan; use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget}; use super::ImportRequest; use crate::download::verify; diff --git a/crates/core/src/addon/import/stage.rs b/crates/core/src/addon/import/stage.rs index 23dee9d..a5899a5 100644 --- a/crates/core/src/addon/import/stage.rs +++ b/crates/core/src/addon/import/stage.rs @@ -10,6 +10,15 @@ //! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的 //! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。 //! +//! # 取消落在这层怎么办(M195) +//! +//! | 起点 | 取消点 | 收场 | +//! |------|--------|------| +//! | 全新导入 | **只在 `rename` 之前** | 删暂存目录,目标目录从未出现 | +//! | 合并态 | 每条搬动之前 | journal **逆序**撤回(新文件删掉、旧备份改回原名) | +//! +//! 合并态逐文件报进度(`ImportStage::Commit`):既是界面该有的动静,也是取消能落下的地方。 +//! //! # 两道路径检查都在这层再走一遍 //! //! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` / @@ -19,14 +28,17 @@ pub mod commit; -pub use commit::{CommitFailure, CommitGuard, RollbackOutcome}; +pub use commit::{CommitFailure, CommitGuard, CommitStop, RollbackOutcome}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use super::super::pack::path::normalize_file; use super::super::pack::PackError; +use super::cancel::{ImportCancel, Stopped}; use super::error::ImportError; +use super::report::tell; +use super::report::{ImportProgressSink, ImportStage}; use crate::platform::atomic::write_atomic; use crate::download::atomic::{META_SUFFIX, PART_SUFFIX}; @@ -177,38 +189,62 @@ impl Staging { /// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。 /// + /// `cancel` 是取消令牌,`progress` 是进度回调(合并态**逐文件**报一条:几千文件的合并本来 + /// 就该看得见动静,而"取消"也正需要文件之间的落点)。 + /// /// # Errors /// - /// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误 - /// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。 - pub fn commit(self) -> Result { + /// - [`CommitStop::Cancelled`]:取消落在提交前 / 合并中途。**回滚已经在这条路径上做过**, + /// 结果一并返回(调用方把它写进报告的 `rollback` 机器位); + /// - [`CommitStop::Failed`]:`rename` 失败、合并态逐文件搬动失败。回滚同样已经做过,其结果 + /// 随错误返回([`CommitFailure::rollback`]):调用方不许假设"失败 = 已回滚干净"。 + /// + /// 全新导入只有一次 `rename`:那是单步原子操作,**取消点只在它之前**——之后不存在"半个 + /// 实例"这种中间态,也就没有"取消"这一说。 + pub fn commit( + self, + cancel: &ImportCancel, + progress: &Option, + ) -> Result { match self.mode { - Mode::Fresh => match std::fs::rename(&self.temp, &self.target) { - Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())), - Err(error) => { + Mode::Fresh => { + if cancel.is_cancelled() { + // 还没 rename:目标目录一个字节都没动,删掉暂存目录就是零残留。 self.abort_inner(); - Err(CommitFailure::io( - format!( - "把 {} 改名成 {} 失败:{error}", - self.temp.display(), - self.target.display() - ), - RollbackOutcome::default(), - )) + return Err(CommitStop::Cancelled(RollbackOutcome::default())); } - }, + match std::fs::rename(&self.temp, &self.target) { + Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())), + Err(error) => { + self.abort_inner(); + Err(CommitStop::Failed(CommitFailure::io( + format!( + "把 {} 改名成 {} 失败:{error}", + self.temp.display(), + self.target.display() + ), + RollbackOutcome::default(), + ))) + } + } + } Mode::Merge => { let mut applied: Vec = Vec::new(); - match self.apply_merge(&mut applied) { + match self.apply_merge(&mut applied, cancel, progress) { Ok(()) => Ok(CommitGuard::merge( self.target.clone(), self.temp.clone(), applied, )), - Err(error) => { + Err(Stopped::Cancelled) => { let rollback = rollback_merge(&mut applied); self.abort_inner(); - Err(CommitFailure::new(error, rollback)) + Err(CommitStop::Cancelled(rollback)) + } + Err(Stopped::Failed(error)) => { + let rollback = rollback_merge(&mut applied); + self.abort_inner(); + Err(CommitStop::Failed(CommitFailure::new(error, rollback))) } } } @@ -244,15 +280,32 @@ impl Staging { } /// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。 - fn apply_merge(&self, applied: &mut Vec) -> Result<(), ImportError> { + /// + /// **逐文件取消点**:每条搬动之前查一次令牌,已取消 ⇒ `Err(Stopped::Cancelled)`,调用方 + /// 按 journal 逆序回滚(新文件删掉、旧备份改回原名)。 + fn apply_merge( + &self, + applied: &mut Vec, + cancel: &ImportCancel, + progress: &Option, + ) -> Result<(), Stopped> { let mut staged: Vec = Vec::new(); collect_files(&self.temp, &mut staged)?; staged.sort(); - for source in staged { + let total = staged.len(); + for (index, source) in staged.into_iter().enumerate() { + cancel.stopped()?; let relative = source .strip_prefix(&self.temp) .map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?; let relative_text = relative.to_string_lossy().replace('\\', "/"); + tell( + progress, + ImportStage::Commit, + index, + total, + Some(relative_text.clone()), + ); // 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。 let normalized = normalize_file(&relative_text)?; let dest = self.target.join(&normalized); diff --git a/crates/core/src/addon/import/stage/commit.rs b/crates/core/src/addon/import/stage/commit.rs index 25a1e38..690cd53 100644 --- a/crates/core/src/addon/import/stage/commit.rs +++ b/crates/core/src/addon/import/stage/commit.rs @@ -207,6 +207,18 @@ pub(super) fn backup_path(dest: &Path) -> PathBuf { dest.with_file_name(name) } +/// 提交阶段的两条停止路线(M195):**取消不是失败**,所以不共用 [`CommitFailure`]。 +/// +/// 两条路都**已经把回滚做过**(全新导入是 `abort`,合并态是 journal 逆序撤回): +/// 调用方拿到它们只管写报告,不必再自己清一遍。 +#[derive(Debug)] +pub enum CommitStop { + /// 用户取消(回滚结果一并交回,可能不完整)。 + Cancelled(RollbackOutcome), + /// 真失败(回滚结果连同错误一起交回)。 + Failed(CommitFailure), +} + /// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。 /// /// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。 diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index df0c498..96e2722 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -24,19 +24,20 @@ //! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。 //! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。 //! -//! ## 取消是**界面级**的 +//! ## 取消是**管线级**的(M195 起) //! -//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1: +//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1,而**同一个标记** +//! 从 M195 起就是 core 的取消令牌(`ImportRequest::with_cancel`): //! //! 1. 面板立刻收回(不是等后台回一句话); //! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉; -//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到 -//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给 -//! core 加 cancel token(已登记 finding,报告里同样划出来); -//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」, -//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」, -//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成 -//! 「导入会在后台继续跑完 —— 本次结果作废」这句。 +//! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、 +//! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的 +//! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表; +//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」, +//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建 +//! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案 +//! 只承诺「本次结果作废 + 目标目录已回滚到导入前」,不承诺「一条命令都没执行」。 mod convert; mod diagnose; @@ -151,7 +152,7 @@ impl Drop for InFlight { /// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 /// /// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消** -/// (后台会跑完并登记实例,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。 +/// (M195 起 core 真的停手并回滚,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。 pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool { let key = host_key(env); { diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 4d167e9..672dba6 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -100,10 +100,21 @@ const fn reason_of(reason: ConflictReason) -> ConflictReasonView { } /// 一次导入结果 → DTO。 +/// +/// # `Cancelled` 为什么在这里没有对应档(M195) +/// +/// DTO 的 `ImportStatusView` 只有三档,取消走的是 [`crate::dto::PackEvent::Cancelled`] +/// (`import::run` 拿到 `ImportStatus::Cancelled` 就不发报告事件了)。所以本函数只会在 +/// "取消恰好落在两个检查点之间、报告仍然是成功/部分成功"时被调用。真收到 `Cancelled` +/// 时按 `Partial` 落地:**绝不是** `Failed`——取消不是失败,不许画成红的。 pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView { ImportReportView { - status: ImportStatusView::from_slug(report.status.as_str()) - .unwrap_or(ImportStatusView::Failed), + status: match report.status { + ImportStatus::Cancelled => ImportStatusView::Partial, + other => { + ImportStatusView::from_slug(other.as_str()).unwrap_or(ImportStatusView::Failed) + } + }, format: report.format.as_deref().and_then(PackFormatView::from_slug), pack_name: report.pack_name.clone(), pack_version: report.pack_version.clone(), diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index aebd0f9..49f80b3 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -7,15 +7,17 @@ //! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导; //! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的 //! 路径在 core 侧落到"保留"; -//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。 +//! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线 +//! 在检查点停手并回滚,不再"后台跑完并登记"(逐段检查点见 +//! `core::addon::import::cancel`)。取消后不再打扰界面:进度回调与最终报告都先看取消标记。 use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::addon::import::{ - import_pack, scan_pack_online, DownloadEngineFetcher, ImportProgress, ImportProgressSink, - ImportRequest, + import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress, + ImportProgressSink, ImportRequest, }; use unlucky_core::download::blocking::block_on; @@ -181,8 +183,11 @@ pub(super) fn run( let engine = super::super::super::engine::engine(env); let fetcher = DownloadEngineFetcher::new(&engine); + // 这个开关从 M195 起是**真令牌**:core 的每个检查点都看它(不只是挡界面事件)。 + let token = ImportCancel::from_flag(Arc::clone(&cancel)); let mut core_request = ImportRequest::new(&request.pack, &target) .with_fetcher(&fetcher) + .with_cancel(&token) .with_progress(sink) // 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。 .with_decisions(convert::decisions_of(&request.decisions)); @@ -191,15 +196,20 @@ pub(super) fn run( } let report = block_on(import_pack(&core_request)); - if report.status == ImportStatus::Failed && register { - // 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话 - // 下一次重试会撞「directory already exists」(复审 r1 P2-9)。 + if (report.status == ImportStatus::Failed || report.status == ImportStatus::Cancelled) + && register + { + // 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清 + // 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。 remove_if_empty(&target); } - if cancel.load(Ordering::SeqCst) { + if report.status == ImportStatus::Cancelled || cancel.load(Ordering::SeqCst) { // 取消之后不再打扰界面(面板已经收回,代次也已经作废)。 - // 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 —— - // 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。 + // + // M195 起这条是**真的停下来了**:core 在检查点回滚(暂存目录已删,落盘过的已撤回, + // 实例没有登记)。所以提示语可以照实说「已取消:目标目录未改动」; + // `cancel.load(..)` 单独留一条是因为取消也可能**恰好落在两个检查点之间**(管线已经 + // 跑完并登记),那时同样不再打扰界面,但别对用户说"没装"。 return; } send( From db5b08b9ab1edb6a33960a8d28ac0d1459cbb50a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 16:35:03 +0800 Subject: [PATCH 31/33] =?UTF-8?q?fix(m195):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=B8=89=E9=A1=B9=20=E2=80=94=E2=80=94=20=E8=A7=A3=E5=8E=8B?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=E7=9C=9F=E5=AE=88=E5=8D=AB=E3=80=81?= =?UTF-8?q?presenter=20=E6=8E=A5=E7=BA=BF=E6=B5=8B=E8=AF=95=E3=80=81?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=96=87=E6=A1=88=E6=94=B9=E5=9B=9E=E7=9C=9F?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(解压循环检查点没有真守卫):那条用例原来的断言(status/target/files/bytes/无残留暂存) 在删掉 `extract_overrides` 的检查点后**依然成立**——提交前检查点⑤会替它停一次,于是几 GB 的包 会一直解到结束而测试全绿。改成数 `ImportStage::Extract` 事件:有检查点 = 1 条、删掉 = 3 条 (两面实测)。模块文档同时如实登记"还没有真守卫"的检查点:②③④⑤、全新导入 commit 的取消 分支、phase.rs 远端下载逐条、complete.rs 补全循环的 break。 P2-2(presenter 的真令牌接线零测试):新增 `pack/tests/cancel.rs`,直接调 `import::run` 并把 传入的 `Arc` **预先置 true**,判据只能是盘上事实(目标目录不存在 + profiles.json 零实例)——"没收到 Report"那种判据两条路都成立,`run` 早退条件里的 `cancel.load()` 会把 "没接线"完全遮住。 P2-3(用户可见文案说反了话):`PackCancelled` 中英双语从「导入会在后台继续跑完 / Import keeps running in the background…已经写下的文件会留下」改为「导入已取消 —— 后台已停手并回滚:没有留下 半包,也没有覆盖已有文件」;golden 金表同步;`model/tests.rs` 那条把旧文案钉死的用例改成钉新 语义(必须说清停手+回滚,且**禁止**出现 background);`model.rs` 的 `cancelled` 字段文档与 `dispatch.rs` 的取消注释同族修正。 验证:`cargo test -p unlucky-core -p unlucky-presenter` 1480 passed / 0 failed; i18n 31 / 0;app-iced packui 28 / 0;三 crate clippy 零 warning。 --- .../src/pages/content/packui/model.rs | 5 +- .../src/pages/content/packui/model/tests.rs | 16 ++--- crates/core/src/addon/import/cancel_tests.rs | 57 +++++++++++++--- crates/i18n/src/catalog/content.rs | 4 +- crates/i18n/src/catalog/golden/tail2.rs | 2 +- crates/presenter/src/host/addon/pack.rs | 2 +- crates/presenter/src/host/addon/pack/tests.rs | 4 +- .../src/host/addon/pack/tests/cancel.rs | 67 +++++++++++++++++++ crates/presenter/src/host/dispatch.rs | 5 +- 9 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 crates/presenter/src/host/addon/pack/tests/cancel.rs diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index 2f9907f..ccb0882 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -196,8 +196,9 @@ pub struct Pack { pub running: Option, /// 探测失败诊断 pub probe: Option, - /// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌), - /// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。 + /// 用户取消过这次导入:面板收回,**后台也真的停手并回滚了**(M195 起 core 有取消令牌: + /// 暂存目录删掉、落盘过的撤回、实例不登记)。这句话仍要留在界面上——取消不是"什么都没 + /// 发生",而是"按你的意思停下来了",别让用户以为点空了。 pub cancelled: bool, /// 取消提示的正文 pub cancelled_note: &'static str, diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index ac8e77c..8a8e876 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -382,19 +382,17 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() { } #[test] -fn cancelled_state_is_visible_and_says_the_import_keeps_running() { - // 复审 r1 P1-2:面板收回之后,界面上必须留一句「后台还在跑」 +fn cancelled_state_is_visible_and_says_the_import_stopped() { + // M195:取消是管线级的(core 停手并回滚),旧文案「后台还在跑」已是假话。 let import = unlucky_presenter::dto::ImportState { cancelled: true, ..unlucky_presenter::dto::ImportState::default() }; let export = unlucky_presenter::dto::ExportState::default(); let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog()); - assert!(model.cancelled); - assert!( - model.cancelled_note.to_lowercase().contains("background"), - "取消提示必须说清后台还在跑:{}", - model.cancelled_note - ); - assert!(!model.is_idle(), "取消态不能被当成「什么都没有」"); + let note = model.cancelled_note.to_lowercase(); + let stopped = note.contains("stopped") && note.contains("rolled back"); + assert!(model.cancelled && !model.is_idle(), "取消态不能当成空闲"); + assert!(stopped, "文案要说清停手并回滚"); + assert!(!note.contains("background"), "文案不许说后台跑"); } diff --git a/crates/core/src/addon/import/cancel_tests.rs b/crates/core/src/addon/import/cancel_tests.rs index dddae12..ebc1375 100644 --- a/crates/core/src/addon/import/cancel_tests.rs +++ b/crates/core/src/addon/import/cancel_tests.rs @@ -6,16 +6,26 @@ //! | 用例 | 删掉哪一处就红 | //! |------|----------------| //! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 | -//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环里的检查点 | +//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环检查点(判据 = 解压进度条数:有 = 1、删掉 = 3) | //! | `a_cancel_in_the_middle_of_the_merge_restores_the_users_file` | 取消时的 `rollback_merge` | //! | `a_cancel_right_before_registering_rolls_the_whole_instance_back` | 登记前的检查点 | //! | `cancel_and_failure_are_two_different_result_types` | 把取消并进 `fail()` 那条路 | //! -//! 取消的触发点全部走**进度回调**(回调里置位 = 用户此刻点了取消,没有 sleep、没有竞态)。 -//! 全部离线:手造 mrpack(`files` 为空,内容全在 `overrides/`)。 +//! # 解压那条必须数事件,其余没有真守卫的检查点 +//! +//! 解压只写**暂存目录**,所以"status == Cancelled / 目标目录不存在 / `files` 为空 / 无残留暂存" +//! 在删掉循环检查点后**依然成立**(提交前检查点会替它停一次)——只有"解了几条"能区分两者。 +//! +//! 有变异证明的只有上表五处。**没有**:检查点②解析后 / ③补全后 / ④开暂存目录前 / ⑤提交前(删掉 +//! 任一处,别的检查点会接住同一次取消,`pipeline.rs` 的断言仍绿)、全新导入 `commit` 的取消分支、 +//! [`super::phase`] 远端下载逐条检查点、[`super::complete`] 补全循环的 `break`——补它们要两段式 +//! 夹具或更多计数探针,本轮没做。 +//! +//! 触发点全部走**进度回调**(置位 = 用户此刻点了取消,无 sleep、无竞态);全部离线。 use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use super::cancel::ImportCancel; @@ -35,7 +45,7 @@ const INDEX: &str = r#"{ } "#; -/// 手造一个 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。 +/// 手造 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。 fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) { let root = TempRoot::new(tag); let source = root.join("src"); @@ -105,11 +115,28 @@ fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink { }) } -/// 在**合并态第二条逐文件进度**上置位取消。 -/// -/// 合并是**排序后**逐文件搬:第 0 条是基线(`.ull/pack-baseline.json`,`.` 排在最前),第 1 条 -/// 才是 `mods/a.jar`。所以 `done == 1` 正好是"用户那个被覆盖的文件已经搬进去、后面还有"的 -/// 窗口——journal 回滚要接住的正是这一刻(`total > 0` 顺带把阶段开始那条 0/0 排除掉)。 +/// 数**解压阶段**的进度条数,并在第一条上置位取消(这是解压循环检查点唯一的可观测判据: +/// 有它 ⇒ 第一条之后立刻停 = 1;删掉 ⇒ 一路解完 = 文件条数)。 +fn cancel_on_first_extract_counting( + token: &ImportCancel, +) -> (ImportProgressSink, Arc) { + let token = token.clone(); + let count = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&count); + let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| { + if progress.stage != ImportStage::Extract { + return; + } + if seen.fetch_add(1, Ordering::SeqCst) == 0 { + token.cancel(); + } + }); + (sink, count) +} + +/// 在**合并态第二条逐文件进度**上置位取消。合并是排序后逐文件搬:第 0 条是基线 +/// (`.ull/pack-baseline.json`,`.` 排最前),第 1 条才是 `mods/a.jar`——`done == 1` 正好是 +/// "用户那个被覆盖的文件已经搬进去、后面还有"的窗口,journal 回滚要接住的正是这一刻。 fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink { let token = token.clone(); Arc::new(move |progress: &ImportProgress| { @@ -119,7 +146,7 @@ fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink { }) } -/// 目标目录里**全部文件**的实例内相对路径(排序后),用来钉"取消后与导入前逐文件相同"。 +/// 目标目录里全部文件的实例内相对路径(排序后):钉"取消后与导入前逐文件相同"。 fn files_under(root: &Path) -> Vec { let mut out = Vec::new(); let mut pending = vec![root.to_path_buf()]; @@ -193,14 +220,22 @@ fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() { let (root, pack) = three_files("cancel-extract"); let target = root.join("instance"); let token = ImportCancel::new(); + let (sink, extract_events) = cancel_on_first_extract_counting(&token); let report = block_on(import_pack( &ImportRequest::new(&pack, &target) .with_cancel(&token) - .with_progress(cancel_on(&token, ImportStage::Extract)), + .with_progress(sink), )); assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error); + // **这一条才是解压循环检查点的判据**:有它 ⇒ 第一条之后立刻停(3 个文件的包里只报了 1 条 + // 解压进度);删掉它 ⇒ 一路解完(3 条)。其余断言在删掉检查点后仍然成立,挡不住回归。 + assert_eq!( + extract_events.load(Ordering::SeqCst), + 1, + "取消必须在第一条解压之后立刻生效(包里有 3 个文件,解完再停 = 解压段没有检查点)" + ); assert!(report.error.is_none(), "取消不许带错误"); assert!(!target.exists(), "不许留下半包:{}", target.display()); assert!( diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index a7829e3..1716c4f 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -179,8 +179,8 @@ macro_rules! content_entries { ), PackReportRejectedNote => ("not supported by the client", "客户端不支持"), PackCancelled => ( - "Import keeps running in the background — this result is discarded. Files already written stay.", - "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。" + "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", + "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。" ), PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"), PackOverwriteNote => ( diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index a7570c7..65310fc 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -29,7 +29,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"), ("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"), ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), - ("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"), + ("PackCancelled", "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。"), ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"), ("PackMissingOffline", "offline import", "离线导入"), diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 96e2722..d453ab5 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -126,7 +126,7 @@ pub(in crate::host) fn in_flight(env: &HostEnv) -> usize { /// /// 复审 r2 P2-1 的教训:手工 `finished()` 只在 `block_on(import_pack(..))` 之后调用, /// 而失败早退(探测失败 / 目标目录不可用)都在它之前 ⇒ 计数永远留在「在跑」,于是下一次 -/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在后台跑」的假话。 +/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在跑」的假话。 /// /// 现在计数由句柄的 `Drop` 负责:**任何**退出路径(早退、报错、`?`、panic 展开)都会减一。 pub(super) struct InFlight { diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index feb2da5..eaf6d5d 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -9,9 +9,11 @@ //! | [`a_failed_new_instance_directory_can_be_reused_for_retry`] | 失败残留的空目录不挡重试 | //! | [`pack_epoch_is_per_host_and_is_its_own_counter`] | 代次按 host 分开,不再跨 host 串扰 | //! | [`a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 | +//! | [`cancel::a_pre_cancelled_flag_really_stops_the_core_pipeline`] | 取消标记**真的**交给 core(盘上零残留、档案零实例) | //! //! 夹具(临时目录、写文件、泵 host)在 [`harness`]。 +mod cancel; mod harness; mod scan; @@ -388,7 +390,7 @@ fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact() !pack_events(&events) .iter() .any(|event| matches!(event, PackEvent::Cancelled)), - "此刻没有导入在跑,不该给内容库发「导入还在后台跑」" + "此刻没有导入在跑,不该给内容库发「导入已取消」" ); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/presenter/src/host/addon/pack/tests/cancel.rs b/crates/presenter/src/host/addon/pack/tests/cancel.rs new file mode 100644 index 0000000..0c7728f --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests/cancel.rs @@ -0,0 +1,67 @@ +//! presenter 的**真令牌**接线(M195 复审 P2-2):取消标记必须真的交给 core。 +//! +//! # 为什么判据只能是盘上的事实 +//! +//! 只断言"没收到 `PackEvent::Report`"是**两条路都成立**的伪证明:`import::run` 的早退条件是 +//! `report.status == Cancelled || cancel.load()`,后者会把"根本没接线"完全遮住——core 照旧跑完 +//! 并登记实例,界面只看到面板收回。那正是本 mission 要消灭的形态,所以本用例盯的是**目标目录 +//! 与 `profiles.json`**:标记一进来就是 true ⇒ 目录里一个文件都不该有、实例一个都不该登记。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::dto::{ConflictDecisionsView, ImportRequestView}; + +use super::harness::{temp_dir, write}; + +/// 一个能走完管线的最小 MultiMC 包(目录形态:不需要 zip 写入器)。 +fn multimc_pack(dir: &std::path::Path) -> std::path::PathBuf { + let source = dir.join("Better MC"); + write( + &source.join("mmc-pack.json"), + br#"{"formatVersion": 1, "components": [ + {"uid": "net.minecraft", "version": "1.20.1"}]}"#, + ); + write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]); + write(&source.join(".minecraft/config/options.txt"), b"fov=90\n"); + source +} + +#[test] +fn a_pre_cancelled_flag_really_stops_the_core_pipeline() { + let dir = temp_dir("cancel-wired"); + let source = multimc_pack(&dir); + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + let env = crate::host::HostEnv::at_root(&data); + let request = ImportRequestView { + pack: source, + instance: None, + instance_name: Some("Cancelled Pack".to_string()), + decisions: ConflictDecisionsView::default(), + }; + // 目标目录与"是否登记"都按 host 的真口径算(判据不能自己另算一份)。 + let (target, register) = super::super::import::target_of(&env, &request).expect("目标目录"); + assert!(register, "走新实例这条路才覆盖「登记」这一环"); + + let (tx, _rx) = crate::host::job::channel(); + let cancel = Arc::new(AtomicBool::new(true)); + super::super::import::run(&env, &tx, &request, 11, Arc::clone(&cancel)); + drop(tx); + assert!(cancel.load(Ordering::SeqCst), "用例前提:标记一直是 true"); + + assert!( + !target.exists(), + "取消后目标目录不该存在(core 在入口检查点就停手,暂存目录也删了):{}", + target.display() + ); + let profiles = + unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案"); + assert!( + profiles.profiles.is_empty(), + "取消后不许登记实例:{:?}", + profiles.profiles + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 081d9fd..1bfc4e0 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -72,9 +72,10 @@ impl CoreHost { if let Some(cancel) = &self.install_cancel { cancel.store(true, std::sync::atomic::Ordering::SeqCst); } - // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次 + // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次。 // 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号 - // (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。 + // (它说的是「安装已取消、实例未创建」,而导入的取消是**管线级**的:core 在检查点 + // 停手并回滚,文案见 `PackCancelled`;M195 之前它会在后台跑完并登记实例)。 let pack_was_running = addon::cancel_pack(&self.env); if pack_was_running { self.send(CoreEvent::Pack(Box::new(PackEvent::Cancelled))); From b1524635ba7c8ada8a5699e22d3875111f10cc40 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:17:16 +0800 Subject: [PATCH 32/33] =?UTF-8?q?fix(m195):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=8F=96=E6=B6=88=E7=9A=84=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=BB=93=E5=B1=80=E5=BF=85=E9=A1=BB=E5=88=B0=E8=BE=BE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=88=E4=B8=89=E6=A1=A3=20+=20=E7=BB=88?= =?UTF-8?q?=E6=80=81=E7=A9=BF=E9=80=8F=E4=BB=A3=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r2 P2-1:界面无条件断言「已停手并回滚:没有留下半包,也没有覆盖已有文件」,而两条路径下 这句话是假的——①取消来得太晚(落在最后一个检查点之后)时管线已经跑完,用户的同名文件被覆盖、 实例已登记;②回滚本身没做完(`rolled_back=false`)。core 一直是诚实的,是 presenter 把报告丢了: `cancel.load()` 早退 + `cancel_pack` bump 代次后 `handle` 按代次丢弃。 按"接通真相"做(不是改软话): - **终态一定发出**:`import::run` 去掉 `cancel` 的无条件早退,收尾抽成 `finish`(清自建空目录 + 无条件 `send(PackEvent::Report)`);取消标记在管线返回后**再读一次**,作为"太晚"的唯一证据。 - **终态穿透代次**:`HostPackState::terminal_epochs` 记下 `cancel_pack` 作废的那个代次, `handle` 对它**只放行一条 `PackEvent::Report`**(进度事件照旧按代次丢,`fresh_cancel` 起新 一轮时清空)。这是路径 A 的成因与修法。 - **DTO 三档**(tower 批准扩 scope):`ImportStatusView` 加 `Cancelled`;新增 `ImportCancelView { NotRequested, RolledBack, RollbackIncomplete, TooLate }` 挂在 `ImportReportView::cancel` 上——已回滚 / 回滚没做完 / 取消太晚,三档各自可判。 `PackEvent::Report` 到达时清掉 `ImportState.cancelled`(它现在只是"取消后还没有结果"的占位)。 - **界面按结局分支**:标题 `PackStatusCancelled`(已取消 — 已回滚)/ `PackCancelIncomplete` (已取消 — 回滚没做完)/ 取消太晚时保留真实状态标题并另起一句 `PackCancelTooLate` (实例会保留、同名文件可能已被覆盖);占位的 `PackCancelled` 改成"正在停手,真实结果随后给出"。 图标:`Close`(描边,页面对填充字形有守门)/ 回滚不完整用 `Warn`,一档都不用 `Role::Error`。 守门与变异: - 新增路径 A 用例 `a_late_cancel_still_delivers_the_real_outcome_to_the_ui`:真管线把取消置在 **最后一条 Commit 进度**上(之后没有检查点)⇒ core 报 `Success`/未回滚、用户的 `mods/a.jar` 确实被覆盖;再 `cancel_pack`(代次 +1 并记终态)→ `finish` → `handle`,断言界面收到 `status == Success` + `rolled_back == false` + `cancel == TooLate`,且同代次的进度事件仍被丢。 - 变异①:`handle` 恢复"代次不符一律丢" ⇒ 该用例红("取消太晚时,真实结果必须到达界面")。 - 变异②:`finish` 恢复"取消就早退" ⇒ 该用例红(另带 P2-2 那条也红)。 - app-iced 新增 `model/tests/cancel.rs`:三档标题互不相同 + 「太晚」必须有那句补充说明 + 已回滚不许显示成「部分成功」。 验证(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-194): `cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2185 passed / 0 failed; `-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。 --- .../app-iced/src/pages/content/packui/demo.rs | 8 +- .../src/pages/content/packui/model.rs | 5 +- .../src/pages/content/packui/model/report.rs | 16 +- .../src/pages/content/packui/model/tests.rs | 25 +-- .../content/packui/model/tests/cancel.rs | 140 +++++++++++++++++ .../pages/content/packui/model/tests/stage.rs | 3 +- .../app-iced/src/pages/content/packui/view.rs | 3 +- .../src/pages/content/packui/view/report.rs | 34 +++- crates/i18n/src/catalog/content.rs | 13 +- crates/i18n/src/catalog/golden/tail2.rs | 5 +- crates/presenter/src/dto/mod.rs | 8 +- crates/presenter/src/dto/pack.rs | 6 +- crates/presenter/src/dto/pack/event.rs | 10 +- crates/presenter/src/dto/pack/import.rs | 69 +++----- .../presenter/src/dto/pack/import/cancel.rs | 43 +++++ .../src/dto/pack/import/failure_stage.rs | 42 +++++ crates/presenter/src/host/addon.rs | 2 +- crates/presenter/src/host/addon/pack.rs | 48 +++++- .../presenter/src/host/addon/pack/convert.rs | 28 ++-- .../src/host/addon/pack/convert/cancel.rs | 27 ++++ .../presenter/src/host/addon/pack/import.rs | 54 +++++-- .../src/host/addon/pack/tests/cancel.rs | 148 ++++++++++++++++++ crates/presenter/src/host/handle.rs | 10 +- 23 files changed, 627 insertions(+), 120 deletions(-) create mode 100644 crates/app-iced/src/pages/content/packui/model/tests/cancel.rs create mode 100644 crates/presenter/src/dto/pack/import/cancel.rs create mode 100644 crates/presenter/src/dto/pack/import/failure_stage.rs create mode 100644 crates/presenter/src/host/addon/pack/convert/cancel.rs diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs index 33fd07f..2a150be 100644 --- a/crates/app-iced/src/pages/content/packui/demo.rs +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -19,9 +19,10 @@ use std::path::PathBuf; use unlucky_i18n::Lang; use unlucky_presenter::dto::{ ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, - ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView, - ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState, - ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView, + ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportCancelView, + ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, + ImportState, ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, + PackFormatView, }; use crate::pages::content::Target; @@ -212,6 +213,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView { bytes_written: 3_400_000, rolled_back: true, failure_stage: None, + cancel: ImportCancelView::NotRequested, }; if conflicts { report.conflicts = vec![ diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index ccb0882..ac015ba 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -196,9 +196,8 @@ pub struct Pack { pub running: Option, /// 探测失败诊断 pub probe: Option, - /// 用户取消过这次导入:面板收回,**后台也真的停手并回滚了**(M195 起 core 有取消令牌: - /// 暂存目录删掉、落盘过的撤回、实例不登记)。这句话仍要留在界面上——取消不是"什么都没 - /// 发生",而是"按你的意思停下来了",别让用户以为点空了。 + /// 用户点过取消、**结果还没回来**(面板已收回)。它只作占位:真实结局在 + /// `report.cancel` 里(已回滚 / 回滚没做完 / 取消太晚),所以文案不许在这里替结局下结论。 pub cancelled: bool, /// 取消提示的正文 pub cancelled_note: &'static str, diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs index b2f2016..ec78d4c 100644 --- a/crates/app-iced/src/pages/content/packui/model/report.rs +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -14,7 +14,7 @@ use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{ - ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, + ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportCancelView, ImportErrorView, ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView, }; @@ -124,6 +124,11 @@ pub struct Report { pub can_overwrite: bool, /// 「两者都留改名」为何不支持(有冲突时如实写) pub overwrite_note: Option, + /// 这次运行与取消的关系(M195 r3:标题与图标按它分档,不许一句打天下) + pub cancel: ImportCancelView, + /// 取消的**补充说明**:只有「取消太晚」有——那种情况标题照常是"已导入/部分成功", + /// 必须另起一句说清"你的取消没赶上,实例会保留、同名文件可能已被覆盖"。 + pub cancel_note: Option, } impl Report { @@ -202,6 +207,9 @@ pub(super) fn report_of( && page.instance.is_some(), overwrite_note: (!report.conflicts.is_empty()) .then(|| catalog.text(Key::PackOverwriteNote).to_owned()), + cancel: report.cancel, + cancel_note: (report.cancel == ImportCancelView::TooLate) + .then(|| catalog.text(Key::PackCancelTooLate).to_owned()), } } @@ -215,6 +223,12 @@ fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str { match report.status { ImportStatusView::Success => catalog.text(Key::PackStatusSuccess), ImportStatusView::Partial => catalog.text(Key::PackStatusPartial), + // 三档取消各有各的话:已回滚(什么都没留下)/ 回滚没做完(要对一眼目录)/ + // 取消太晚(标题是真实状态,另有一句 cancel_note 说明"没赶上")。 + ImportStatusView::Cancelled => match report.cancel { + ImportCancelView::RollbackIncomplete => catalog.text(Key::PackCancelIncomplete), + _ => catalog.text(Key::PackStatusCancelled), + }, ImportStatusView::Failed => match report.failure_stage { Some(ImportFailureStageView::NotStarted) => { catalog.text(Key::PackStatusFailedNotStarted) diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index 8a8e876..fc8a39c 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -3,6 +3,7 @@ //! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、 //! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。 +mod cancel; mod pick; mod stage; @@ -11,8 +12,9 @@ use std::path::PathBuf; use unlucky_i18n::Lang; use unlucky_presenter::dto::{ ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, - ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView, - ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView, + ExportPreviewView, ImportCancelView, ImportErrorView, ImportProgressView, ImportReportView, + ImportStageView, ImportStatusView, InstallStage, MissingFileView, MissingReasonView, + PackFormatView, }; use super::progress::{panel_progress, panel_stage, stage_line}; @@ -228,6 +230,7 @@ fn report_keeps_tolerable_and_fatal_apart() { bytes_written: 0, rolled_back: true, failure_stage: None, + cancel: ImportCancelView::NotRequested, }; let block = report_of(&report, &PackPageState::default(), catalog()); assert!(block.fatal.is_some(), "致命块必须单独存在"); @@ -265,6 +268,7 @@ fn conflicts_default_to_keeping_the_user_copy() { bytes_written: 0, rolled_back: true, failure_stage: None, + cancel: ImportCancelView::NotRequested, }; // 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例 let page = PackPageState { @@ -363,6 +367,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() { bytes_written: 0, rolled_back: true, failure_stage: None, + cancel: ImportCancelView::NotRequested, }; let new_instance = PackPageState::default(); @@ -380,19 +385,3 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() { "导入到已有实例时才给翻档动作" ); } - -#[test] -fn cancelled_state_is_visible_and_says_the_import_stopped() { - // M195:取消是管线级的(core 停手并回滚),旧文案「后台还在跑」已是假话。 - let import = unlucky_presenter::dto::ImportState { - cancelled: true, - ..unlucky_presenter::dto::ImportState::default() - }; - let export = unlucky_presenter::dto::ExportState::default(); - let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog()); - let note = model.cancelled_note.to_lowercase(); - let stopped = note.contains("stopped") && note.contains("rolled back"); - assert!(model.cancelled && !model.is_idle(), "取消态不能当成空闲"); - assert!(stopped, "文案要说清停手并回滚"); - assert!(!note.contains("background"), "文案不许说后台跑"); -} diff --git a/crates/app-iced/src/pages/content/packui/model/tests/cancel.rs b/crates/app-iced/src/pages/content/packui/model/tests/cancel.rs new file mode 100644 index 0000000..d051f0f --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/tests/cancel.rs @@ -0,0 +1,140 @@ +//! 取消的**三档真实结局**在界面上的分支(M195 r3)。 +//! +//! 口径:一句「已停手并回滚」打天下是假话——取消可能停在提交前(已回滚)、可能停在回滚没收尾 +//! (目标目录可能变了)、也可能**太晚**(管线已经跑完,实例保留、同名文件可能已被覆盖)。 +//! 这一族用例钉的就是"三档各说各的话",以及"占位文案不再替结局下结论"。 + +use std::path::PathBuf; + +use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_presenter::dto::{ + ImportCancelView, ImportReportView, ImportStatusView, PackFormatView, +}; + +use super::super::report::report_of; +use super::super::PackPageState; + +fn catalog() -> Catalog { + Lang::En.catalog() +} + +fn report( + status: ImportStatusView, + cancel: ImportCancelView, + rolled_back: bool, +) -> ImportReportView { + ImportReportView { + status, + format: Some(PackFormatView::Multimc), + pack_name: Some("Better MC".to_string()), + pack_version: None, + game_version: Some("1.20.1".to_string()), + loader: None, + version_id: Some("1.20.1".to_string()), + target: PathBuf::from("C:/tmp/instance"), + files: Vec::new(), + conflicts: Vec::new(), + missing: Vec::new(), + rejected: Vec::new(), + warnings: Vec::new(), + error: None, + registered: None, + bytes_written: 0, + rolled_back, + failure_stage: None, + cancel, + } +} + +#[test] +fn a_rolled_back_cancel_says_it_is_cancelled_not_partially_installed() { + // 取消停在提交前:已回滚 ⇒ 标题是「已取消 —— 已回滚」,**不是**「部分成功」(那等于说装了一半), + // 也不能出现「取消太晚」那句补充说明。 + let view = report( + ImportStatusView::Cancelled, + ImportCancelView::RolledBack, + true, + ); + let block = report_of(&view, &PackPageState::default(), catalog()); + + assert_eq!(block.title, catalog().text(Key::PackStatusCancelled)); + assert_ne!( + block.title, + catalog().text(Key::PackStatusPartial), + "已回滚的取消不许显示成「部分成功」" + ); + assert!(!block.installed(), "取消不是「装上了」"); + assert!(block.cancel_note.is_none(), "已回滚不需要「太晚」那句"); +} + +#[test] +fn an_incomplete_cancel_rollback_says_the_directory_may_differ() { + let view = report( + ImportStatusView::Cancelled, + ImportCancelView::RollbackIncomplete, + false, + ); + let block = report_of(&view, &PackPageState::default(), catalog()); + + assert_eq!(block.title, catalog().text(Key::PackCancelIncomplete)); + assert_ne!( + block.title, + catalog().text(Key::PackStatusCancelled), + "回滚没做完 ≠ 已回滚(这两句话不能混)" + ); +} + +#[test] +fn a_too_late_cancel_says_the_import_already_finished() { + // 取消来得太晚:状态是真实的 `Success`,另加一句「实例会保留、同名文件可能已被覆盖」。 + let view = report(ImportStatusView::Success, ImportCancelView::TooLate, false); + let block = report_of(&view, &PackPageState::default(), catalog()); + + assert_eq!(block.title, catalog().text(Key::PackStatusSuccess)); + assert_eq!(block.status, ImportStatusView::Success); + assert_ne!( + block.status, + ImportStatusView::Cancelled, + "太晚 ≠ 已回滚(状态是真的 Success/Partial)" + ); + assert_eq!( + block.cancel_note, + Some(catalog().text(Key::PackCancelTooLate).to_owned()), + "必须另外说清「取消没赶上」" + ); +} + +/// 三档的标题**互不相同**(把任意两档并成一句,这里就红)。 +#[test] +fn the_three_cancel_outcomes_have_three_different_titles() { + let titles = [ + report_of( + &report( + ImportStatusView::Cancelled, + ImportCancelView::RolledBack, + true, + ), + &PackPageState::default(), + catalog(), + ) + .title, + report_of( + &report( + ImportStatusView::Cancelled, + ImportCancelView::RollbackIncomplete, + false, + ), + &PackPageState::default(), + catalog(), + ) + .title, + report_of( + &report(ImportStatusView::Success, ImportCancelView::TooLate, false), + &PackPageState::default(), + catalog(), + ) + .title, + ]; + let unique: std::collections::BTreeSet<&String> = titles.iter().collect(); + assert_eq!(unique.len(), 3, "三档必须各说各的话:{titles:?}"); +} diff --git a/crates/app-iced/src/pages/content/packui/model/tests/stage.rs b/crates/app-iced/src/pages/content/packui/model/tests/stage.rs index d30ed6e..e8582d2 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests/stage.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests/stage.rs @@ -3,7 +3,7 @@ //! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与 //! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。 -use unlucky_presenter::dto::ImportFailureStageView; +use unlucky_presenter::dto::{ImportCancelView, ImportFailureStageView}; use super::*; @@ -39,6 +39,7 @@ fn the_two_failure_stages_never_share_one_sentence() { bytes_written: 0, rolled_back: true, failure_stage: stage, + cancel: ImportCancelView::NotRequested, } } diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs index f282664..b98126d 100644 --- a/crates/app-iced/src/pages/content/packui/view.rs +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -98,7 +98,8 @@ pub(crate) fn section<'a>( } else if let Some(report) = &model.report { block = block.push(report_block(ui, catalog, report)); } else if model.cancelled { - // 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2) + // 取消过、**结果还没回来**:先留一句"正在停手"的占位(真实结局随后的报告给出, + // 已回滚 / 回滚没做完 / 取消太晚三档见 `model::report`) block = block.push( column![ frame::text_role( diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index 29789e6..5266371 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -57,6 +57,14 @@ pub(super) fn report_block<'a>( report.written_line.as_str(), ui.color(Role::OnSurfaceVariant), ), + // 「取消太晚」的那句补充说明(标题仍是真实的 `已导入 / 部分成功`)。 + frame::text_role( + ui, + TypeRole::BodySmall, + false, + report.cancel_note.as_deref().unwrap_or(""), + ui.color(Role::Tertiary), + ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill); @@ -292,17 +300,31 @@ pub(super) fn conflict_row<'a>( } pub(super) fn status_icon(report: &Report) -> IconKind { + use unlucky_presenter::dto::{ImportCancelView, ImportStatusView}; match report.status { - unlucky_presenter::dto::ImportStatusView::Success => IconKind::Check, - unlucky_presenter::dto::ImportStatusView::Partial => IconKind::Warn, - unlucky_presenter::dto::ImportStatusView::Failed => IconKind::Error, + ImportStatusView::Success => IconKind::Check, + ImportStatusView::Partial => IconKind::Warn, + ImportStatusView::Failed => IconKind::Error, + // 取消**不是错误**(M195):停手用「关闭(叉)」这个描边字形(页面骨架只允许描边, + // 令牌层的 Play/Stop/More 是填充字形,见 `tests/no_hardcoded_tokens.rs` 的守门), + // 回滚没做完才升级成 Warn,都不上红。 + ImportStatusView::Cancelled => match report.cancel { + ImportCancelView::RollbackIncomplete => IconKind::Warn, + _ => IconKind::Close, + }, } } pub(super) fn status_role(report: &Report) -> Role { + use unlucky_presenter::dto::{ImportCancelView, ImportStatusView}; match report.status { - unlucky_presenter::dto::ImportStatusView::Success => Role::Primary, - unlucky_presenter::dto::ImportStatusView::Partial => Role::Tertiary, - unlucky_presenter::dto::ImportStatusView::Failed => Role::Error, + ImportStatusView::Success => Role::Primary, + ImportStatusView::Partial => Role::Tertiary, + ImportStatusView::Failed => Role::Error, + // 取消一档也不做成 `Role::Error`:用户自己点的,不该看起来像装坏了。 + ImportStatusView::Cancelled => match report.cancel { + ImportCancelView::RollbackIncomplete => Role::Tertiary, + _ => Role::OnSurfaceVariant, + }, } } diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 1716c4f..60b71e1 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -179,8 +179,17 @@ macro_rules! content_entries { ), PackReportRejectedNote => ("not supported by the client", "客户端不支持"), PackCancelled => ( - "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", - "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。" + "Cancel requested — the pipeline is stopping now; the real outcome follows.", + "已请求取消 —— 后台正在停手,真实结果随后给出。" + ), + PackStatusCancelled => ("Cancelled — rolled back", "已取消 —— 已回滚"), + PackCancelIncomplete => ( + "Cancelled — rollback incomplete: the target directory may differ from before the import.", + "已取消 —— 回滚没做完:目标目录可能与导入前不同。" + ), + PackCancelTooLate => ( + "The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.", + "取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。" ), PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"), PackOverwriteNote => ( diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 65310fc..2daa388 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -29,7 +29,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"), ("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"), ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), - ("PackCancelled", "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。"), + ("PackCancelled", "Cancel requested — the pipeline is stopping now; the real outcome follows.", "已请求取消 —— 后台正在停手,真实结果随后给出。"), + ("PackStatusCancelled", "Cancelled — rolled back", "已取消 —— 已回滚"), + ("PackCancelIncomplete", "Cancelled — rollback incomplete: the target directory may differ from before the import.", "已取消 —— 回滚没做完:目标目录可能与导入前不同。"), + ("PackCancelTooLate", "The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.", "取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。"), ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"), ("PackMissingOffline", "offline import", "离线导入"), diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 16f5ec6..961dafb 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -62,10 +62,10 @@ pub use pack::{ ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView, ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView, - ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView, - ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState, - ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent, - PackFormatView, PackState, ProbeView, + ImportCancelView, ImportConflictView, ImportErrorView, ImportFailureStageView, + ImportProgressView, ImportReportView, ImportRequestView, ImportScanView, ImportStageView, + ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, + PackCommand, PackEvent, PackFormatView, PackState, ProbeView, }; pub use settings::{SettingControl, SettingGroupView, SettingRowView}; // M159: 自更新 diff --git a/crates/presenter/src/dto/pack.rs b/crates/presenter/src/dto/pack.rs index af82210..a6bf806 100644 --- a/crates/presenter/src/dto/pack.rs +++ b/crates/presenter/src/dto/pack.rs @@ -57,7 +57,7 @@ pub use export::{ }; pub use format::PackFormatView; pub use import::{ - FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView, - ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView, - MissingFileView, MissingReasonView, ProbeView, + FileActionView, ImportCancelView, ImportErrorView, ImportFailureStageView, ImportProgressView, + ImportReportView, ImportRequestView, ImportStageView, ImportState, ImportStatusView, + ImportedFileView, MissingFileView, MissingReasonView, ProbeView, }; diff --git a/crates/presenter/src/dto/pack/event.rs b/crates/presenter/src/dto/pack/event.rs index b32ae9c..d7a4051 100644 --- a/crates/presenter/src/dto/pack/event.rs +++ b/crates/presenter/src/dto/pack/event.rs @@ -64,8 +64,11 @@ pub enum PackEvent { Exported(Box), /// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因) ExportFailed(String), - /// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌), - /// 所以留一位给界面说清「还在跑、结果作废」。 + /// 用户取消了导入(面板立刻收回)。 + /// + /// 它只表示"取消后还没有结果":M195 起 core 有取消令牌,管线会在检查点停手并回滚,**真实 + /// 结局随后的 [`PackEvent::Report`] 才是准话**(`ImportReportView::cancel` 三档:已回滚 / + /// 回滚没做完 / 取消太晚),所以 `Report` 一到就清掉这一位。 Cancelled, /// 关掉所有面板 Cleared, @@ -99,6 +102,9 @@ impl PackState { self.import.progress = None; self.import.probe = None; self.import.scan = None; + // M195 r3:取消的**真实结局**在 `report.cancel` 里,`cancelled` 只作"取消后 + // 还没有结果"时的占位;结果一到就清掉,免得它跨到下一次导入上撒谎。 + self.import.cancelled = false; self.import.report = Some(*report); } PackEvent::Probe(probe) => { diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs index f1c8c4f..0be448e 100644 --- a/crates/presenter/src/dto/pack/import.rs +++ b/crates/presenter/src/dto/pack/import.rs @@ -1,7 +1,7 @@ //! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。 //! -//! 三态(`Success` / `Partial` / `Failed`)与 core 一一对应,**`Partial` 不是 `Success`**; -//! 容忍域([`MissingFileView`])与致命域([`ImportErrorView`])分属不同类型,UI 不可能混成一个列表。 +//! 状态档(`Success` / `Partial` / `Failed` / `Cancelled`)与 core 一一对应,**`Partial` 不是 +//! `Success`**、**取消不是失败**;容忍域与致命域分属不同类型,UI 不可能混成一个列表。 use std::path::PathBuf; @@ -12,6 +12,12 @@ use crate::dto::InstanceId; use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView}; use super::format::PackFormatView; +mod cancel; +mod failure_stage; + +pub use cancel::ImportCancelView; +pub use failure_stage::ImportFailureStageView; + /// 导入阶段(与 core 的 `ImportStage` 一一对应)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum ImportStageView { @@ -62,43 +68,6 @@ impl ImportStageView { } } -/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。 -/// -/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖 -/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的 -/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被 -/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种 -/// (回滚有失败项,目标目录可能与导入前不同)。 -/// -/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ImportFailureStageView { - /// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。 - NotStarted, - /// 已经写过目标目录,回滚**完整**完成(零残留)。 - RolledBack, - /// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。 - RollbackIncomplete, -} - -impl ImportFailureStageView { - /// 稳定 slug(日志 / 测试用;**不要改名**)。 - #[must_use] - pub const fn slug(self) -> &'static str { - match self { - Self::NotStarted => "not_started", - Self::RolledBack => "rolled_back", - Self::RollbackIncomplete => "rollback_incomplete", - } - } - - /// 本次失败到底动没动目标目录。 - #[must_use] - pub const fn touched_target(self) -> bool { - !matches!(self, Self::NotStarted) - } -} - /// 导入三态(`Partial` **不是** `Success`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ImportStatusView { @@ -112,6 +81,10 @@ pub enum ImportStatusView { /// 也可能是写完又回滚干净,还可能是回滚没做完——三种由 /// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。 Failed, + /// **用户取消,管线停手并回滚**(M195 r3):不是失败,也不是「部分成功」。 + /// 「回滚干净 / 没做完」由 [`ImportReportView::cancel`] 再分;取消**太晚**(管线已跑完) + /// 不落这一档——那时实例真装上了,状态是 `Success`/`Partial`,只在 `cancel` 记 `TooLate`。 + Cancelled, } impl ImportStatusView { @@ -122,6 +95,7 @@ impl ImportStatusView { Self::Success => "success", Self::Partial => "partial", Self::Failed => "failed", + Self::Cancelled => "cancelled", } } @@ -132,14 +106,15 @@ impl ImportStatusView { "success" => Some(Self::Success), "partial" => Some(Self::Partial), "failed" => Some(Self::Failed), + "cancelled" => Some(Self::Cancelled), _ => None, } } - /// 实例可用吗(`Success` / `Partial` 都是「装上了」)。 + /// 实例可用吗(`Success` / `Partial` 是「装上了」;取消与失败都没装上)。 #[must_use] pub const fn installed(self) -> bool { - !matches!(self, Self::Failed) + matches!(self, Self::Success | Self::Partial) } } @@ -340,6 +315,11 @@ pub struct ImportReportView { pub rolled_back: bool, /// 失败发生在哪一步(`None` = 这次不是致命失败)。 pub failure_stage: Option, + /// 这次运行与「取消」的关系(M195 r3:取消的**真实结局**,界面按它选文案)。 + /// + /// 只靠 `status` 表达不了三档:已回滚 / 回滚没做完都在 [`ImportStatusView::Cancelled`] 里, + /// 而「取消太晚、已经跑完」的状态是 `Success`/`Partial`——三档见 [`ImportCancelView`]。 + pub cancel: ImportCancelView, } impl ImportReportView { @@ -379,8 +359,11 @@ pub struct ProbeView { pub struct ImportState { /// 正在跑的进度(`None` = 没在跑)。 pub progress: Option, - /// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上—— - /// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。 + /// 用户点了取消、**结果还没回来**(M195 r3 收窄了它的含义)。 + /// + /// 它只作"取消后还没有结果"的占位:真实结局随后的 `report.cancel` 才是准话(已回滚 / + /// 回滚没做完 / 取消太晚)。所以 `PackEvent::Report` 一到就清掉这一位,免得它跨到下一次 + /// 导入上替结局下结论。 pub cancelled: bool, /// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。 pub scan: Option, diff --git a/crates/presenter/src/dto/pack/import/cancel.rs b/crates/presenter/src/dto/pack/import/cancel.rs new file mode 100644 index 0000000..337a6cf --- /dev/null +++ b/crates/presenter/src/dto/pack/import/cancel.rs @@ -0,0 +1,43 @@ +//! 一次导入与「取消」的关系(**真实结局**,不是"用户点没点过取消")。 +//! +//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。 +//! +//! 三档各有各的话要说,混成一句就是撒谎(M195 r3): +//! - [`ImportCancelView::RolledBack`]:停手了,目标目录与导入前一致; +//! - [`ImportCancelView::RollbackIncomplete`]:停手了,但回滚没做完,目标目录可能与导入前不同; +//! - [`ImportCancelView::TooLate`]:**没来得及停**,管线已经跑完——实例会保留,同名文件可能已被覆盖。 + +use serde::{Deserialize, Serialize}; + +/// 一次导入与「取消」的关系(**真实结局**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum ImportCancelView { + /// 这次运行没被取消过。 + #[default] + NotRequested, + /// 取消生效,管线停手并**完整**回滚(目标目录与导入前一致,实例没登记)。 + RolledBack, + /// 取消生效,但回滚没做完(目标目录可能与导入前不同,逐条在 `warnings` 里)。 + RollbackIncomplete, + /// 取消到得太晚:管线已经跑完,实例保留,同名文件可能已被覆盖。 + TooLate, +} + +impl ImportCancelView { + /// 稳定 slug。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::NotRequested => "not_requested", + Self::RolledBack => "rolled_back", + Self::RollbackIncomplete => "rollback_incomplete", + Self::TooLate => "too_late", + } + } + + /// 用户这次取消有没有**真的生效**(前两档是真停手了)。 + #[must_use] + pub const fn stopped(self) -> bool { + matches!(self, Self::RolledBack | Self::RollbackIncomplete) + } +} diff --git a/crates/presenter/src/dto/pack/import/failure_stage.rs b/crates/presenter/src/dto/pack/import/failure_stage.rs new file mode 100644 index 0000000..8a36ef4 --- /dev/null +++ b/crates/presenter/src/dto/pack/import/failure_stage.rs @@ -0,0 +1,42 @@ +//! 致命失败发生在哪一步(M161:`rolled_back` 一个人说不了两件事)。 +//! +//! 从父模块拆出来只为行数;判据与文案口径原样。 + +use serde::{Deserialize, Serialize}; + +/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。 +/// +/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖 +/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的 +/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被 +/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种 +/// (回滚有失败项,目标目录可能与导入前不同)。 +/// +/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImportFailureStageView { + /// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。 + NotStarted, + /// 已经写过目标目录,回滚**完整**完成(零残留)。 + RolledBack, + /// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。 + RollbackIncomplete, +} + +impl ImportFailureStageView { + /// 稳定 slug(日志 / 测试用;**不要改名**)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::NotStarted => "not_started", + Self::RolledBack => "rolled_back", + Self::RollbackIncomplete => "rollback_incomplete", + } + } + + /// 本次失败到底动没动目标目录。 + #[must_use] + pub const fn touched_target(self) -> bool { + !matches!(self, Self::NotStarted) + } +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 92e6073..ff2eb56 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -42,7 +42,7 @@ mod plan; mod pack; mod sources; -pub(super) use pack::{cancel_pack, pack_epoch}; +pub(super) use pack::{cancel_pack, pack_epoch, take_terminal_epoch}; #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index d453ab5..79c304b 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -34,6 +34,13 @@ //! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、 //! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的 //! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表; +//! +//! **收尾永远把真实结局送到界面**(M195 r3):取消可能落在最后一个检查点之后 —— 那时管线 +//! 已经跑完(用户的同名文件被覆盖、实例已登记),也可能回滚没做完。这两种结果以前被 +//! `cancel` 的早退 + 代次过滤一起丢掉,界面只剩一句无条件的"已停手并回滚"。现在 +//! `import::finish` 无条件发 `PackEvent::Report`,`HostPackState::terminal_epochs` 给 +//! "刚被取消的那一次"留一条**只放行 Report** 的一次性通道([`take_terminal_epoch`]), +//! DTO 的 `ImportReportView::cancel` 三档把区别带到界面; //! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」, //! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建 //! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案 @@ -78,10 +85,17 @@ use super::super::job::{HostTask, JobSender}; /// /// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价, /// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。 -#[derive(Default, Clone, Copy)] +#[derive(Default)] struct HostPackState { epoch: u64, in_flight: usize, + /// 「刚被取消、还欠界面一份终态报告」的那几个代次(M195 r3)。 + /// + /// `cancel_pack` 会把代次 +1,于是被取消那次运行交上来的 `PackEvent::Report` 会被 `handle` + /// 按代次丢掉——可那份报告才是**真实结局**(停手并回滚 / 回滚没做完 / 取消太晚其实已装完)。 + /// 记在这里的代次允许**一条** `Report` 穿透代次过滤,取走即销;新一轮导入(`fresh_cancel`) + /// 清空整表,旧运行的报告不会污染新面板。 + terminal_epochs: Vec, } static PACK_STATE: Mutex> = Mutex::new(Vec::new()); @@ -163,12 +177,42 @@ pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool { cancel.store(true, Ordering::SeqCst); } } + let running = in_flight(env) > 0; + if running { + // 记下**马上要被作废的那个代次**:那次运行随后交上来的报告是它的真实结局,必须能穿透 + // 代次过滤(否则"取消太晚、其实已经装完"在界面上永远不成立)。重复取消只记一次。 + let stale = pack_epoch(env); + with_state(env, |state| { + if !state.terminal_epochs.contains(&stale) { + state.terminal_epochs.push(stale); + } + }); + } bump_pack_epoch(env); - in_flight(env) > 0 + running +} + +/// 这个代次是不是「刚被取消、还欠一份终态报告」的那一次(**取走即销**:只放行一条)。 +/// +/// 只给 `PackEvent::Report` 用:取消之后的**进度**事件仍旧一律按代次丢掉(面板早就收回了, +/// 再点亮它就是「点了取消没反应」的旧病)。 +pub(in crate::host) fn take_terminal_epoch(env: &HostEnv, epoch: u64) -> bool { + with_state(env, |state| { + match state.terminal_epochs.iter().position(|seen| *seen == epoch) { + Some(index) => { + state.terminal_epochs.remove(index); + true + } + None => false, + } + }) } /// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。 +/// +/// 顺手清掉「还欠终态报告」的代次:新一次运行占住面板,上一次的结局不该再冒出来。 fn fresh_cancel(env: &HostEnv) -> Arc { + with_state(env, |state| state.terminal_epochs.clear()); let cancel = Arc::new(AtomicBool::new(false)); let key = host_key(env); let mut table = PACK_CANCEL diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 672dba6..640f89f 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -8,6 +8,8 @@ //! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None` //! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。 +mod cancel; + use std::path::{Path, PathBuf}; use unlucky_core::addon::export::{ExportReport, FileClass, FileReason}; @@ -101,20 +103,18 @@ const fn reason_of(reason: ConflictReason) -> ConflictReasonView { /// 一次导入结果 → DTO。 /// -/// # `Cancelled` 为什么在这里没有对应档(M195) -/// -/// DTO 的 `ImportStatusView` 只有三档,取消走的是 [`crate::dto::PackEvent::Cancelled`] -/// (`import::run` 拿到 `ImportStatus::Cancelled` 就不发报告事件了)。所以本函数只会在 -/// "取消恰好落在两个检查点之间、报告仍然是成功/部分成功"时被调用。真收到 `Cancelled` -/// 时按 `Partial` 落地:**绝不是** `Failed`——取消不是失败,不许画成红的。 -pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView { +/// `cancel_requested` = 这次运行**被点过取消**(presenter 手里的那个开关)。它只用来区分 +/// "取消太晚"那一档:管线已经跑完时报告是 `Success`/`Partial`,但那不是一次正常导入—— +/// 用户的取消没赶上,实例会保留、同名文件可能已被覆盖(M195 r3)。 +pub(super) fn report_view( + report: &ImportReport, + target: &Path, + cancel_requested: bool, +) -> ImportReportView { ImportReportView { - status: match report.status { - ImportStatus::Cancelled => ImportStatusView::Partial, - other => { - ImportStatusView::from_slug(other.as_str()).unwrap_or(ImportStatusView::Failed) - } - }, + status: ImportStatusView::from_slug(report.status.as_str()) + .unwrap_or(ImportStatusView::Failed), + cancel: cancel::cancel_view(report, cancel_requested), format: report.format.as_deref().and_then(PackFormatView::from_slug), pack_name: report.pack_name.clone(), pack_version: report.pack_version.clone(), @@ -330,6 +330,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I rolled_back: true, // 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。 failure_stage: Some(ImportFailureStageView::NotStarted), + // 这一路根本没进管线,谈不上取消。 + cancel: crate::dto::ImportCancelView::NotRequested, } } diff --git a/crates/presenter/src/host/addon/pack/convert/cancel.rs b/crates/presenter/src/host/addon/pack/convert/cancel.rs new file mode 100644 index 0000000..f4f1a8d --- /dev/null +++ b/crates/presenter/src/host/addon/pack/convert/cancel.rs @@ -0,0 +1,27 @@ +//! 「取消的**真实结局**」→ DTO 三档(M195 r3)。 +//! +//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。 + +use unlucky_core::addon::import::report::ImportStatus; +use unlucky_core::addon::import::ImportReport; + +use crate::dto::ImportCancelView; + +/// core 的「取消真实结局」→ DTO 的三档(M195 r3:**不许**把三档混成一句)。 +/// +/// - core 说 `Cancelled` ⇒ 管线真的停手了;回滚完整与否看 `rolled_back`(两档分开说); +/// - core 说别的(`Success`/`Partial`/`Failed`)但这次被点过取消 ⇒ **取消太晚**:报告是真结局, +/// 但它不是"停下来了",界面必须照实说「实例会保留、文件可能已被覆盖」。 +pub(super) fn cancel_view(report: &ImportReport, cancel_requested: bool) -> ImportCancelView { + match report.status { + ImportStatus::Cancelled => { + if report.rolled_back { + ImportCancelView::RolledBack + } else { + ImportCancelView::RollbackIncomplete + } + } + _ if cancel_requested => ImportCancelView::TooLate, + _ => ImportCancelView::NotRequested, + } +} diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 49f80b3..eac39ec 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -8,8 +8,9 @@ //! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的 //! 路径在 core 侧落到"保留"; //! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线 -//! 在检查点停手并回滚,不再"后台跑完并登记"(逐段检查点见 -//! `core::addon::import::cancel`)。取消后不再打扰界面:进度回调与最终报告都先看取消标记。 +//! 在检查点停手并回滚(逐段检查点见 `core::addon::import::cancel`)。取消之后的**进度**不再 +//! 打扰界面(回调与代次都挡),但**终态报告一定发出**:取消可能太晚(管线已跑完)或回滚 +//! 没做完,那两种结局必须让用户看见(见 [`finish`])。 use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -196,26 +197,49 @@ pub(super) fn run( } let report = block_on(import_pack(&core_request)); - if (report.status == ImportStatus::Failed || report.status == ImportStatus::Cancelled) - && register + // 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是 + // "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。 + let cancel_requested = cancel.load(Ordering::SeqCst); + finish(tx, &target, register, epoch, cancel_requested, &report); +} + +/// 收尾:清掉我们自建的空目录 + 把 core 的报告变成事件。 +/// +/// # 取消也照发(M195 r3) +/// +/// 以前这里 `cancel.load()` 是对**所有**事件的早退,于是"取消太晚、后台其实已经装完并登记" +/// 这件事永远到不了界面,界面只能照着旧文案说「已停手并回滚」。现在报告无条件发出:它就是 +/// 真实结局——core 说 `Cancelled`(停手并回滚 / 回滚没做完),或者说 `Success`/`Partial` +/// (取消没赶上),由 DTO 的 `cancel` 三档把区别带到界面。 +/// +/// 代次仍用**出发时**那个(`epoch`):`handle` 给"刚被取消的那一次"留了一次性终态通道 +/// ([`super::super::addon::pack::take_terminal_epoch`]),进度事件照旧按代次丢。 +pub(super) fn finish( + tx: &JobSender, + target: &std::path::Path, + register: bool, + epoch: u64, + cancel_requested: bool, + report: &unlucky_core::addon::import::ImportReport, +) { + if register + && matches!( + report.status, + ImportStatus::Failed | ImportStatus::Cancelled + ) { // 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清 // 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。 - remove_if_empty(&target); - } - if report.status == ImportStatus::Cancelled || cancel.load(Ordering::SeqCst) { - // 取消之后不再打扰界面(面板已经收回,代次也已经作废)。 - // - // M195 起这条是**真的停下来了**:core 在检查点回滚(暂存目录已删,落盘过的已撤回, - // 实例没有登记)。所以提示语可以照实说「已取消:目标目录未改动」; - // `cancel.load(..)` 单独留一条是因为取消也可能**恰好落在两个检查点之间**(管线已经 - // 跑完并登记),那时同样不再打扰界面,但别对用户说"没装"。 - return; + remove_if_empty(target); } send( tx, epoch, - PackEvent::Report(Box::new(convert::report_view(&report, &target))), + PackEvent::Report(Box::new(convert::report_view( + report, + target, + cancel_requested, + ))), ); } diff --git a/crates/presenter/src/host/addon/pack/tests/cancel.rs b/crates/presenter/src/host/addon/pack/tests/cancel.rs index 0c7728f..92e44f1 100644 --- a/crates/presenter/src/host/addon/pack/tests/cancel.rs +++ b/crates/presenter/src/host/addon/pack/tests/cancel.rs @@ -65,3 +65,151 @@ fn a_pre_cancelled_flag_really_stops_the_core_pipeline() { let _ = std::fs::remove_dir_all(&dir); } + +/// **路径 A:取消来得太晚**(M195 r3 的核心用例)。 +/// +/// 三段拼起来,全部确定性、没有 sleep: +/// 1. **core 真管线**跑一次合并态导入,把取消置在**最后一条** `ImportStage::Commit` 进度上—— +/// 之后没有任何检查点,于是管线一路跑完(用户的 `mods/a.jar` 被覆盖),报告是 +/// `Success` + `rolled_back == false`(core 一直是诚实的); +/// 2. `cancel_pack` 把代次 +1(这就是"取消"),并记下被作废的那个代次; +/// 3. `finish` 照发 `PackEvent::Report`,`handle` 让它**穿透代次**——界面因此拿到「已完成 / +/// 未回滚 / 取消太晚」,而不是旧行为里那句无条件的「已停手并回滚」。 +/// +/// 变异:把终态通道去掉(`handle` 恢复"代次不符一律丢")⇒ 本用例立刻红(报告到不了界面)。 +#[test] +fn a_late_cancel_still_delivers_the_real_outcome_to_the_ui() { + use std::sync::atomic::AtomicUsize; + + use unlucky_core::addon::import::{ + import_pack, ConflictPolicy, ImportCancel, ImportProgress, ImportProgressSink, + ImportRequest, ImportStage, ImportStatus, + }; + use unlucky_core::download::blocking::block_on; + + use crate::dto::{AppEvent, Backend, CoreEvent, PackEvent}; + + let dir = temp_dir("cancel-late"); + // 包里有 `mods/a.jar`,目标目录里也有(内容不同)⇒ 合并态"被覆盖"是真的会发生。 + let source = dir.join("Path A Pack"); + write( + &source.join("mmc-pack.json"), + br#"{"formatVersion": 1, "components": [ + {"uid": "net.minecraft", "version": "1.20.1"}]}"#, + ); + write(&source.join(".minecraft/mods/a.jar"), b"pack a"); + let target = dir.join("instance"); + write(&target.join("mods/a.jar"), b"user a"); + + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + let env = crate::host::HostEnv::at_root(&data); + + // ① core:取消置在最后一条逐文件进度上 ⇒ 之后没有检查点 ⇒ 管线跑完。 + let token = ImportCancel::new(); + let sink_token = token.clone(); + let seen = Arc::new(AtomicUsize::new(0)); + let seen_by_sink = Arc::clone(&seen); + let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| { + if progress.stage != ImportStage::Commit || progress.total == 0 { + return; + } + seen_by_sink.fetch_add(1, Ordering::SeqCst); + if progress.done + 1 == progress.total { + sink_token.cancel(); + } + }); + let report = block_on(import_pack( + &ImportRequest::new(&source, &target) + .with_cancel(&token) + // 覆盖档:让用户的 `mods/a.jar` 真的被覆盖(缺省是"保留用户那一侧")。 + .with_conflict_policy(ConflictPolicy::Overwrite) + .with_progress(sink), + )); + assert!(token.is_cancelled(), "用例前提:取消确实置位了"); + assert!( + seen.load(Ordering::SeqCst) > 1, + "确实逐文件走到了底(合并态)" + ); + assert_eq!( + report.status, + ImportStatus::Success, + "取消置在最后一条之后 ⇒ 管线已经跑完:{:?}", + report.error + ); + assert!( + !report.rolled_back, + "没有回滚就别说回滚过(core 的机器位是诚实的)" + ); + assert_eq!( + std::fs::read(target.join("mods/a.jar")).expect("读被覆盖的文件"), + b"pack a", + "路径 A 的前提:用户的同名文件**已经被覆盖**" + ); + + // ② 用户点取消:代次 +1,被作废的那个代次进了终态通道。 + let epoch = super::super::pack_epoch(&env); + let _in_flight = super::super::InFlight::begin(&env); + assert!( + super::super::cancel_pack(&env), + "取消应当看到一条在跑的导入" + ); + assert_ne!( + super::super::pack_epoch(&env), + epoch, + "取消会作废本次代次(这正是旧实现丢报告的成因)" + ); + + // ③ 收尾照发报告;同代次上的**进度**事件仍按代次丢掉。 + let mut host = crate::host::CoreHost::headless(env.clone()); + let (tx, rx) = crate::host::job::channel(); + super::super::import::finish(&tx, &target, false, epoch, true, &report); + tx.send(crate::host::job::HostTask::Pack { + event: Box::new(PackEvent::Progress(None)), + epoch, + }) + .expect("送一条同代次的进度事件"); + drop(tx); + for task in rx.iter() { + host.handle(task); + } + let events = host.drain_events(); + + let delivered: Vec<&crate::dto::ImportReportView> = events + .iter() + .filter_map(|event| match event { + AppEvent::Core(CoreEvent::Pack(inner)) => match inner.as_ref() { + PackEvent::Report(view) => Some(view.as_ref()), + _ => None, + }, + _ => None, + }) + .collect(); + let view = delivered + .first() + .expect("取消太晚时,真实结果必须到达界面(丢掉就是路径 A 的假话)"); + assert_eq!( + view.status, + crate::dto::ImportStatusView::Success, + "界面拿到的结局是「已完成」" + ); + assert!( + !view.rolled_back, + "界面拿到的结局是「未回滚」,不是「已回滚」" + ); + assert_eq!( + view.cancel, + crate::dto::ImportCancelView::TooLate, + "必须标成「取消太晚」,不能标成「已回滚」" + ); + assert!( + !events.iter().any(|event| match event { + AppEvent::Core(CoreEvent::Pack(inner)) => + matches!(inner.as_ref(), PackEvent::Progress(_)), + _ => false, + }), + "终态通道只放行报告:取消后的进度事件仍按代次丢掉" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 1b81354..493dd92 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -49,7 +49,15 @@ impl CoreHost { // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 // 「关一次导入面板」把下载页正在跑的安装静默丢掉。 if epoch != addon::pack_epoch(&self.env) { - return; + // 唯一例外(M195 r3):**刚被取消的那一次**交上来的终态报告。取消会把代次 + // +1,若连报告一起丢,界面就永远学不到"取消太晚、其实已经装完/回滚没做完" + // 这个真相——只剩一句无条件的"已停手并回滚"。 + // 只放行 `Report`、且同一代次只放行一条;进度事件照旧按代次丢。 + if !(matches!(&*event, PackEvent::Report(_)) + && addon::take_terminal_epoch(&self.env, epoch)) + { + return; + } } // 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1), // 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。 From 59011b2d567bba58b05921a0d61b6d4902f39324 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:39:12 +0800 Subject: [PATCH 33/33] =?UTF-8?q?fix(m195):=20=E5=A4=8D=E5=AE=A1=20r3=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E8=AF=B4=E6=88=90=E3=80=8C=E5=8F=96=E6=B6=88=E5=A4=AA?= =?UTF-8?q?=E6=99=9A=E3=80=8D=EF=BC=8C=E5=B9=B6=E7=BB=99=E4=B8=89=E6=A1=A3?= =?UTF-8?q?=E8=A1=A5=E6=8E=A8=E5=AF=BC=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(界面撒谎):`cancel_view` 的 `_ if cancel_requested => TooLate` 把 `Failed` 也吞进去了。 「点取消的同时摘要不符」这类失败(取消置位之后、下一个检查点之前的非容忍失败)会得到 `status=Failed cancel=TooLate rolled_back=true`,于是同一屏上红卡说「已回滚 —— 目标目录零残留」, 下面还有一句「取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖」——后半句是假的, 而且与红卡直接对立。改成**只有** `Success | Partial + cancel_requested` 才判 `TooLate`; `Failed` 一律 `NotRequested`(失败卡自己用 `failure_stage` 分「没写过 / 回滚干净 / 回滚没做完」, 真相已经说全)。 另外 app-iced 的 `cancel_note` 加一道双保险:只有 `status` 是 `Success/Partial` 时才出那句话, 推导万一回归也不会贴到红卡上。 P2-2(推导零覆盖):此前只有「给定这一档、文案怎么画」的用例,把 `RollbackIncomplete` 的推导 删掉也全绿。现在 `convert/cancel.rs` 带三条推导用例:`Cancelled + !rolled_back ⇒ 回滚没做完` (含对照 `rolled_back ⇒ 已回滚`)、`Failed + 取消 ⇒ NotRequested`(含对照 `Success/Partial ⇒ TooLate`)、 没点过取消 ⇒ `NotRequested`。 变异实测: - P2-1:把 `Failed` 也判 `TooLate` ⇒ `a_failed_run_is_never_dressed_up_as_a_late_cancel` 红 (`left: TooLate / right: NotRequested`,"失败的导入没有「跑完」这一说")。 - P2-2:删掉「回滚没做完」的推导(`Cancelled` 一律按已回滚)⇒ `an_incomplete_rollback_is_its_own_bucket` 红 (`left: RolledBack / right: RollbackIncomplete`)。 验证:`cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2188 passed / 0 failed; `-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。 --- .../src/pages/content/packui/model/report.rs | 11 +- .../src/host/addon/pack/convert/cancel.rs | 103 +++++++++++++++++- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs index ec78d4c..593051c 100644 --- a/crates/app-iced/src/pages/content/packui/model/report.rs +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -208,8 +208,15 @@ pub(super) fn report_of( overwrite_note: (!report.conflicts.is_empty()) .then(|| catalog.text(Key::PackOverwriteNote).to_owned()), cancel: report.cancel, - cancel_note: (report.cancel == ImportCancelView::TooLate) - .then(|| catalog.text(Key::PackCancelTooLate).to_owned()), + // 「取消太晚」那句只说给**真的跑完**的导入(复审 r3 P2-1):推导那边已经保证 + // `Failed` 不会落到 `TooLate`,这里再看一眼状态是双保险——万一推导回归,也不会让 + // 红卡("零残留")与"已经跑完、文件可能被覆盖"同时出现在一屏上。 + cancel_note: (report.cancel == ImportCancelView::TooLate + && matches!( + report.status, + ImportStatusView::Success | ImportStatusView::Partial + )) + .then(|| catalog.text(Key::PackCancelTooLate).to_owned()), } } diff --git a/crates/presenter/src/host/addon/pack/convert/cancel.rs b/crates/presenter/src/host/addon/pack/convert/cancel.rs index f4f1a8d..55bddc2 100644 --- a/crates/presenter/src/host/addon/pack/convert/cancel.rs +++ b/crates/presenter/src/host/addon/pack/convert/cancel.rs @@ -10,8 +10,11 @@ use crate::dto::ImportCancelView; /// core 的「取消真实结局」→ DTO 的三档(M195 r3:**不许**把三档混成一句)。 /// /// - core 说 `Cancelled` ⇒ 管线真的停手了;回滚完整与否看 `rolled_back`(两档分开说); -/// - core 说别的(`Success`/`Partial`/`Failed`)但这次被点过取消 ⇒ **取消太晚**:报告是真结局, -/// 但它不是"停下来了",界面必须照实说「实例会保留、文件可能已被覆盖」。 +/// - core 说 `Success`/`Partial` 而这次被点过取消 ⇒ **取消太晚**:报告是真结局,但它不是 +/// "停下来了",界面必须照实说「实例会保留、文件可能已被覆盖」; +/// - core 说 `Failed` ⇒ **一律 `NotRequested`**(复审 r3 P2-1):失败卡自己已经把真相说全了 +/// (`failure_stage` 分「没写过 / 回滚干净 / 回滚没做完」),再贴一句「取消太晚、已经跑完、 +/// 文件可能被覆盖」就是与红卡**直接对立**的第二句假话——失败的导入没有"跑完"这一说。 pub(super) fn cancel_view(report: &ImportReport, cancel_requested: bool) -> ImportCancelView { match report.status { ImportStatus::Cancelled => { @@ -21,7 +24,99 @@ pub(super) fn cancel_view(report: &ImportReport, cancel_requested: bool) -> Impo ImportCancelView::RollbackIncomplete } } - _ if cancel_requested => ImportCancelView::TooLate, - _ => ImportCancelView::NotRequested, + ImportStatus::Success | ImportStatus::Partial if cancel_requested => { + ImportCancelView::TooLate + } + ImportStatus::Success | ImportStatus::Partial | ImportStatus::Failed => { + ImportCancelView::NotRequested + } + } +} + +#[cfg(test)] +mod tests { + //! 三档的**推导**(不是"给定这一档、文案怎么画"):什么输入会推出哪一档。 + //! + //! 这一族用例是复审 r3 P2-1/P2-2 的直接产物:此前只有下半段(文案)有用例,把推导删掉 + //! 也全绿。 + + use unlucky_core::addon::import::report::ImportStatus; + use unlucky_core::addon::import::ImportReport; + + use crate::dto::ImportCancelView; + + use super::super::report_view; + + fn core_report(status: ImportStatus, rolled_back: bool) -> ImportReport { + let mut report = ImportReport::empty(std::path::PathBuf::from("instance")); + report.status = status; + report.rolled_back = rolled_back; + report + } + + fn cancel_of( + status: ImportStatus, + rolled_back: bool, + cancel_requested: bool, + ) -> ImportCancelView { + report_view( + &core_report(status, rolled_back), + std::path::Path::new("instance"), + cancel_requested, + ) + .cancel + } + + #[test] + fn an_incomplete_rollback_is_its_own_bucket() { + // 推导:`Cancelled` + `rolled_back == false` ⇒ **回滚没做完**(不是"已回滚")。 + // 把这条推导删掉(一律按已回滚)这里就红——那正是复审 r3 P2-2 说的零覆盖。 + assert_eq!( + cancel_of(ImportStatus::Cancelled, false, true), + ImportCancelView::RollbackIncomplete + ); + // 对照:回滚完整才是「已回滚」——两档不许并成一个。 + assert_eq!( + cancel_of(ImportStatus::Cancelled, true, true), + ImportCancelView::RolledBack + ); + } + + #[test] + fn a_failed_run_is_never_dressed_up_as_a_late_cancel() { + // P2-1:失败 + 点过取消(例如"点取消的同时摘要不符")⇒ 失败卡自己说全了真相, + // 不许再贴「取消太晚、已经跑完、文件可能被覆盖」——那两句直接对立。 + assert_eq!( + cancel_of(ImportStatus::Failed, false, true), + ImportCancelView::NotRequested, + "失败的导入没有「跑完」这一说" + ); + assert_eq!( + cancel_of(ImportStatus::Failed, true, true), + ImportCancelView::NotRequested + ); + // 对照:成功/部分成功 + 点过取消,才是「太晚」。 + for status in [ImportStatus::Success, ImportStatus::Partial] { + assert_eq!( + cancel_of(status, false, true), + ImportCancelView::TooLate, + "{status:?} + 取消 ⇒ 太晚" + ); + } + } + + #[test] + fn an_untouched_run_carries_no_cancel_verdict() { + for status in [ + ImportStatus::Success, + ImportStatus::Partial, + ImportStatus::Failed, + ] { + assert_eq!( + cancel_of(status, false, false), + ImportCancelView::NotRequested, + "没点过取消就不该有取消结论" + ); + } } }