402 Commits
Author SHA1 Message Date
AskaEth e987e02755 docs(termux): 根目录新增适配方案与合并指南
TERMUX-ADAPTATION.md:方案(平台识别/JDK/字体/横屏/GPU/MC)、逐文件改动表、
兼容性说明(Termux gate、x86_64 不变)、合并步骤与 CI 预期、验证矩阵、
后续路线与按组回滚策略。README.termux 顶部链过去。
2026-11-07 09:05:37 +08:00
AskaEth 38d302428c docs(termux): README 校正——GPU 默认、横屏截图尺寸、数据根说明
- 构建描述从 CPU-only 改为 GPU 构建(wgpu + CPU 兜底)
- 窗口证据改为横屏 1657x1067、首帧 ~60 ms
- 快速开始不再强制 --software-renderer:默认 auto,驱动缺失自动降级
- 补数据根说明(Portable 按 exe 目录,--data-dir 暂忽略)
2026-11-07 09:04:58 +08:00
AskaEth 7d8b3b99e6 docs(termux): MC 1.20.4 真机跑通结论与剩余项
- README:MC 标 ✅(glibc JRE + arm64 natives + softpipe);后续收敛为
  zink/DRI3、剪贴板/IME、narrator/音频
- 构建文档 §4.2 补真机启动命令、窗口尺寸与日志位置、非致命项
2026-11-07 08:54:12 +08:00
AskaEth 5bd566f92f feat(termux): launch 侧同样改写 arm64 natives;真机跑通 MC 1.20.4
- Library::linux_arm64_native_name 收敛改写规则,install 与 launch 共用
- presenter/CLI 的 classpath 组装:1.19+ 的独立 natives 条目按 arm64 坐标
  找文件并进 classpath;≤1.18 的 natives 表条目仍归 natives::collect
- 真机验证:ull install 1.20.4(740MB)→ glibc Temurin 21 + arm64 natives +
  softpipe GL,MC 主菜单在 termux-x11 中渲染(窗口 854x480)
- 已知非致命:narrator flite 缺失、LWJGL JNI 版本告警、oshi cpuVendor NPE
2026-11-07 08:53:24 +08:00
AskaEth aa963345bd fix(termux): 现代清单的 LWJGL natives 条目也改写为 arm64
1.20.x 把 natives 作为独立库条目(org.lwjgl:lwjgl:3.3.2:natives-linux),
不走 natives 表。Linux/aarch64 上按坐标改写成 natives-linux-arm64,
URL 兜底到阿里云 Maven(Mojang 对 arm64 一律 404)。
真机 1.20.4 的 dry-run:7 个 natives 全部变成 arm64;x86_64 不变。
2026-11-07 08:45:16 +08:00
AskaEth 147280d12a feat(termux): MC 启动接线——Linux arm64 natives + softpipe 子进程环境
- Library::native_classifier:Linux/aarch64 上把 LWJGL3 的 natives-linux
  改写成 natives-linux-arm64(Mojang 清单只有 x86_64)
- install::libraries:arm64 分类器在清单里没有下载信息时,按 Maven 相对路径
  兜底到阿里云镜像(实测 Mojang libraries 404、Aliyun 200);
  单测覆盖 arm64 兜底与 x86_64 不变
- launch::defaults:Termux glibc 构建启动 MC 时默认带
  MESA_LOADER_DRIVER_OVERRIDE=swrast / LIBGL_ALWAYS_SOFTWARE=1(用户可覆盖)
- 文档:JRE patchelf 一次性步骤与接线现状;README 同步
- 验证:patched Temurin java 直接执行;LWJGL arm64+GLFW 探针 gl-ok
2026-11-07 08:39:25 +08:00
AskaEth 29e49dd7a4 docs(termux): 补 MC 运行时结论(glibc JRE + LWJGL arm64 + softpipe GL)
- §4.2:Temurin 21 JRE 在 glibc 下跑通;AWT/X11 正常;
  LWJGL 3.3.3 arm64 + GLFW + GLX/softpipe 端到端探针通过(GL 3.3)
- zink 仍卡 DRI3;softpipe 可用(软件 GL)
- README 同步:MC 标 🟡(栈已验证,启动器接线待做),后续改为接线 + DRI3
2026-11-07 08:32:33 +08:00
AskaEth 64fe489a04 feat(termux): glibc 构建自动启用自编 Mesa 26 turnip ICD(GPU 打通)
- app/boot/termux_gpu:glibc 构建 + Termux 且随附 ICD 存在时,在探测前设
  VK_ICD_FILENAMES/VK_DRIVER_FILES;用户显式设置不覆盖
- 文档:Mesa 26.2.6 + KGSL 的完整构建/安装/验证步骤(含 shebang、
  --wrap-mode=nofallback、C++/链接参数三处坑)
- 实测:wgpu 选中 Adreno 840(turnip Mesa 26.2.6),截图首帧 61 ms
- README.termux:GPU 标 ✅,MC 标 🟡(glibc JRE+AWT 已验证,GLX 待 GL 构建)
2026-11-07 08:28:36 +08:00
AskaEth aaba339499 docs(termux): GPU 结论落地——glibc 侧被驱动版本挡住
实测结论:
- glibc Vulkan loader + 自写 ICD 能加载
- Termux glibc Mesa 24.2.6 不认识 Adreno 840(chip 0x44050A31 unsupported)
- Arch vulkan-freedreno 26.2.6 能加载但 Linux/DRM 构建找不到设备(缺 KGSL,
  /dev/dri 对 untrusted_app 被 SELinux 拒绝)
- GPU 变体会打印具名错误并自动降级 tiny-skia

README.termux 同步:GPU 标 ⛔、MC 走 glibc JVM 的结论与后续路线
2026-11-07 07:57:24 +08:00
AskaEth 7cbb354972 feat(termux): 横屏自适应窗口、安装后清理 apt 工作区、补分支 README
- app/boot/termux_window:Termux 上默认窗口按 X 屏幕等比自适应
  (横屏铺满、竖屏不溢出,最大 2×;日志 source=termux-screen),
  ULL_WINDOW_SIZE 覆盖与 1180×760 验收基准不变;5 条单测
- termux::install 成功后清理 $PREFIX/tmp/ull-java-termux(一次安装约 100 MB)
- 构建文档补 clippy/rustfmt 组件、cargo-clippy 目标被带成 android 的坑、
  门禁命令、横屏行为与 openjdk-17 实测
- 新增 README.termux.md(分支定位/已验证矩阵/快速开始/边界/路线图),
  两份主 README 的 Termux 条目链过去
2026-11-07 07:50:33 +08:00
AskaEth 38f4682dba fix(java): Termux 幂等安装报「未走网络」,vendors 在 Termux 上展示 termux 行
- termux::install 返回 (home, reused),复用时 downloaded=None,
  CLI 不再显示「0 字节 / 0 个文件」
- java vendors 在 Termux 上把 termux 插到顺序最前(仅展示,默认回退序不变)
2026-11-06 23:12:10 +08:00
AskaEth c9efdf0fd1 feat(java): Termux 上自动安装系统 openjdk(内置镜像 + 隔离 apt 状态)
- 新增 game::java::termux:内置 SAU/TUNA/USTC/官方四个 termux-main 镜像,
  按顺序 apt-get update+install;lists/cache 隔离在 $PREFIX/tmp/ull-java-termux,
  不改用户 apt 配置;装完从 $PREFIX/lib/jvm 定位,幂等(已装则不走网络)
- JdkVendor 增加 termux(17/21/25),--vendor termux 只走系统包;
  Termux 上缺省安装先试系统包,失败再走归档链并带出每一跳原因
- standard_roots 已能扫 $PREFIX/lib/jvm(上一条提交),装完即被 java list 发现
- CLI/presenter 的 vendor 展示与帮助文案同步;设置白名单与契约测试同步
- 文档补 Termux 上的 Java 行为
2026-11-06 22:54:53 +08:00
AskaEth 16c61ca8b7 docs(packaging): 记录 Termux glibc 构建与 termux-x11 运行配方
新增 packaging/docs/termux-glibc-build.md:glibc 工具链(Rust gnu 组件 +
patchelf + clang/lld + Debian libgcc crt)、构建环境(LD_PRELOAD/CA/USTC 镜像)、
termux-x11 运行与截图取证、GPU 现状与已知边界。
支持矩阵四处同步:Termux 行从「GUI 未验证」改为「源码自建、CPU 渲染单机验证过」,
仍标注实验级、不在发布矩阵内。
2026-11-06 22:32:09 +08:00
AskaEth 76ddc9b3e9 fix(fonts): Termux 上注册 Android 系统 CJK 字体,消除中文方框
glibc 构建跑在 Termux 里时 target_os 仍是 linux,fontdb 只扫 Linux 常规
字体目录,/system/fonts 不在其中,中文实例名与界面文案全渲染成方框。
在字体求值入口按运行时识别 Termux,把 NotoSansCJK 等候选注册进
cosmic-text 的字体库(OnceLock 只加载一次,找不到就静默放过)。
app-iced 直接依赖 unlucky-core 复用平台识别,不再本地复制环境判断。
2026-11-06 22:31:11 +08:00
AskaEth 898e052c2d feat(platform): 识别 Termux 宿主并把 $PREFIX/lib/jvm 纳入 JDK 扫描
- 新增 platform::termux:用 TERMUX_VERSION / 默认 PREFIX 识别 Termux,
  推导 $PREFIX 与 $PREFIX/lib/jvm,不把路径写死进调用方
- java::discover 的 standard_roots 在 Unix 分支追加 Termux 的 JVM 根,
  环境可注入(standard_roots_in),补上「Termux 装了 openjdk 但没设
  JAVA_HOME/PATH 就发现不了」的盲点
- 单测覆盖识别、负例与扫描结果
2026-11-06 22:31:03 +08:00
AskaEth 87ab8783a3 Merge branch 'feat/m149-msabake-bake-approved-client-id-int'
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 21:21:30 +08:00
AskaEth 5597a4cc18 feat(release,docs): M149 发布构建烘入微软登录 id(仓库 Secret),文档改为「官方构建开箱即用」
构建侧:release-build.yml 声明 on.workflow_call.secrets.ULL_MSA_CLIENT_ID(required: false),
cargo build 步以 env 注入它,option_env! 把它烘进二进制;release.yml 的 build job 按名显式透传
(与 PUBLISH_TOKEN 同一模式,不用 inherit)。真值放仓库 Secret,不进任何 git 文件;Secret 未录入
或 Actions 之外求值为空串时,client_id.rs 的 blank_env_value_counts_as_missing 保证「空白=没配」,
不会拿空 id 去联网。

文档:README 两版 / help.rs 的 accounts topic / packaging/release-notes.md /
packaging/docs/build-and-ci.md 统一成「官方构建已内置登录所需配置、开箱即用,仍可用环境变量
ULL_MSA_CLIENT_ID 覆盖;自建想自带就构建时注入」的功能性口径 —— 不出现 id 本身,也不出现归属描述。
client_id.rs 的模块注释与测试注释同步(原文写「id 不进仓库(用户定案)」,已随 Secret 方案改写),
并记下「别把 ULL_MSA_CLIENT_ID 导出到跑 cargo test 的 shell」——presenter 的 M132 入口锚会在
已烘焙 id 的构建里故意红。

实测:ULL_MSA_CLIENT_ID=<id> cargo build --bin ull 后,清空 env 跑 ull accounts add msa 打出
用户码与链接(无「未配置」提示);同二进制设一个假 env id 则 AADSTS700016 点名该假 id(env 覆盖生效)。
单测:core auth 28、unlucky-cli 全套、i18n 30、presenter 222 全绿;M132 锚在烘焙构建里按设计红、
在不带 id 的构建里绿。cargo fmt --all --check 通过。
2026-11-06 21:08:53 +08:00
AskaEth 1c53c29f76 Merge branch 'feat/m147-cifix-repair-ubuntu-red-checks-and'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 20:30:08 +08:00
AskaEth 166342b021 fix(core,design): M147 修掉 test(ubuntu) 剩下的两条红(M144 被 fail-fast 挡住没看见)
`cargo test --workspace --all-targets --no-fail-fast` 在 docker 里(Debian 12 / rustc 1.99.1)
第一次跑全:app-iced 那条修好后,还有两个 target 各有一条 **Linux 专有**的确定性失败——
M144 只看到第一条,因为 cargo 默认在第一个失败 target 就停。两条都是「夹具/断言按 Windows
语义写死」,不是产品逻辑错。

1. `unlucky-core`:`platform::wallpaper::windows::tests::current_theme_expands_local_app_data_env_var`
   (crates/core/src/platform/wallpaper/windows.rs:286,460 passed / 1 failed)
   夹具用了**混合分隔符**:`LOCAL_APP_DATA` 写 `C:/Users/u/AppData/Local`,而 `CUSTOM_THEME`
   写 `C:\Users\u\AppData\Local\…`。探针把 `%LOCALAPPDATA%` 展开后拼上 `\Microsoft\…`,得到
   `C:/Users/u/AppData/Local\Microsoft\…`;`FakeIo` 却按 `CUSTOM_THEME` 建键。Windows 的
   `Path` 把 `/` 与 `\` 都当分隔符 ⇒ 两个 PathBuf 相等;Linux 不 ⇒ 查不到文件 ⇒ 0 个候选。
   修法:`LOCAL_APP_DATA` 改成反斜杠原样(`r"C:\Users\u\AppData\Local"`),展开结果与
   `CUSTOM_THEME` 逐字节相同。测试仍能在 Linux 跑,不丢覆盖(不是加 cfg 门躲开)。

2. `unlucky-design`:`color::palettes::tests::accent_on_fixed_neutral_only_moves_the_neutral_hue`
   (crates/design/src/color/palettes.rs,162 passed / 1 failed)
   `assert_eq!(Palettes, Palettes)` 做 f64 位相等,而两侧算中性色相的路径不同:一侧取
   `NEUTRAL_HUE_DEG` 字面量,一侧经 `atan2()` 现算——glibc 与 UCRT 差 1 ulp
   (239.51420577630955 vs …964)。这正是 palettes.rs:361-365 那条注释自己警告过的坑。
   修法:把「固定中性色相」喂成**种子色相的实测值**(`per_seed.neutral.hue_deg()`),两侧同源
   ⇒ 断言保持**逐位相等**(比放宽容差更紧),零额外行。`NEUTRAL_HUE_DEG` 与种子色相的绑定仍由
   隔壁 `neutral_hue_is_the_default_seed_hue` 用 1e-9° 容差钉着。
   ⚠️ 这里没有照 tower 建议的「1e-9° 容差逐项比」:那个写法在 rustfmt 下要多 9 行,而本文件正好
   400 行、顶在 guards.yml 的红线上。改完 399 行(比改前还少 1 行)。

验证(命令与 ci.yml 逐字一致,容器 ull-linux-check:bookworm):
- `cargo test --workspace --all-targets --no-fail-fast` → **exit 0**(原 exit 101,2 targets failed)
- `cargo clippy --workspace --all-targets -- -D warnings` → exit 0
- Windows:`cargo test -p unlucky-core --lib` → 464 passed / 0 failed;
  `cargo test -p unlucky-design --lib` → 163 passed / 0 failed(不回归)
2026-11-06 20:19:03 +08:00
AskaEth fdb9ebb014 build(packaging): M147 新增 linux-check-docker.sh,本机复跑 ci.yml 的两条 ubuntu 门禁
`linux-smoke-docker.sh` 复刻的是 runtime-smoke(能不能真跑起来),本脚本复刻的是**编译期
门禁**(能不能编过 / 测过):`cargo clippy --workspace --all-targets -- -D warnings` 与
`cargo test --workspace --all-targets --no-fail-fast`,命令与 ci.yml 逐字一致。

为什么值得单独一条脚本:M144 的两条 ubuntu 红只在「Linux + --all-targets + -D warnings」
三件事凑齐时才炸,Windows 本机永远复现不了(那些 `#[cfg(target_os = "windows")]` 分支在
Windows 上都有使用者)。本脚本是「本机提前挡住它们」的唯一通道。

实现:在现成的 `ull-linux-smoke:bookworm` 上只加一层 `cmake` + `ninja-build`(`aws-lc-sys`
缺 cmake 编不过,M144 实测),缓存卷挂 `registry` 子目录(直接挂 /usr/local/cargo 会盖掉
镜像里的 cargo 本体,M62 踩过),Git Bash 路径用 `cygpath -m` 归一。

- `ULL_CHECK_STAGES`(默认 `clippy test`):两阶段各自跑完再汇总退出码,一处红不掩盖另一处;
  `ULL_CHECK_FAIL_FAST=1` 恢复首错即停。
- `ULL_CHECK_TEST_ARGS`(默认照抄 ci.yml):快查用 `-p unlucky-app-iced --lib`。
- 每阶段打印 `<命令> → exit=<code>`,便于与 CI 注解里的码对照。

诚实边界写在脚本头:基础镜像 Debian 12 / runner Ubuntu 24.04,只对齐编译期判定,不覆盖
发行版差异面;也不跑 fmt / build / runtime-smoke。

M147 实测(本机 Docker / ull-linux-check:bookworm / 2026-11-06):
- `ULL_CHECK_STAGES=clippy` → exit=0(1m27s,原 14 条错)
- `ULL_CHECK_STAGES=test ULL_CHECK_TEST_ARGS='-p unlucky-app-iced --lib'` → 623 passed / exit=0
2026-11-06 20:07:04 +08:00
AskaEth ae1410f342 ci: M147 给两个平台的 test 步加取证能力(--no-fail-fast + 注解 + 退出码回显)
三条红里 `test (windows)` 至今只有一个「Process completed with exit code 1」——而 cargo
自己的失败码是 101,那个 1 的来源不明(pwsh 包装层洗码 / 进程被杀,两说都未排除)。job log
要登录才能读(匿名 403),但**注解匿名可读**(GET /check-runs/<id>/annotations → 200)。

所以把一条 `cargo test --workspace --all-targets` 拆成两个平台的取证版本,各做三件事:
1. `--no-fail-fast`:cargo 默认第一个失败的 target 就停(M144 实测 Linux 只跑了 7 个 test
   binary 就退),后面还有没有第二条失败完全看不到;
2. 失败时把 `error` / `failures:` / `test result: FAILED` / panicked 行升成 `::error::`
   注解(`%` 转 `%25`,那是 workflow command 的保留字符)——拿不到 job log 时注解是匿名
   侧唯一能取到的失败正文;
3. 显式 `echo "cargo exit=<code>"`,把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
   只给码、零诊断」三种情形分开。

两平台的码从哪来(不能混用):
- Linux 走 bash(默认 shell 带 `-e` + `pipefail`):先 `set +e` 再自己取 `${PIPESTATUS[0]}`
  ——那是 cargo 的码,不是 `tee` 的码;不关 `-e` 的话管道一失败就在 tee 之后退出,后面三行
  永远跑不到。
- Windows 走 pwsh:`PIPESTATUS` 是 bash 语法,改用 `$LASTEXITCODE`(`Tee-Object` 是 cmdlet,
  不会改它)。注解的正则不锚 `error:` 那一支,因为 pwsh 把原生 stderr 折成 ErrorRecord 后
  行首可能多一截前缀,锚死会漏行。

两条都以 `exit <code>` 结尾,退出码不再经过管道传递。
2026-11-06 20:07:00 +08:00
AskaEth 8bfabafe8a fix(app-iced): M147 修掉 Linux 侧 clippy 的 14 条错与写死平台的 α 断言
两条红的成因都是「Windows-only 的东西没有平台门」:Linux + `--all-targets` + `-D warnings`
三件事凑齐时,Windows 专有的 DWM/穿透代码在 Linux 上变成未用导入 + 死代码。

clippy(14 条错,docker 内 exit 101 → 0):
- dwm.rs `set_preference` 的 `#[cfg(not(windows))]` 分支以 `return` 结尾 ⇒ needless_return;
  那个块本来就是该 cfg 下函数体的尾巴,去掉 `return`。
- dwm.rs 三常量 + 四读函数在非 Windows 上没有使用者(唯一调用方 `probe::run_one` 的
  Windows 版被门着,`transparency.rs` 只 re-export 三项)⇒ dead_code。用**模块级**
  `#![cfg_attr(not(target_os = "windows"), allow(dead_code))]`:
  · 逐项 `cfg(target_os = "windows")` 会把它们从 Linux 构建里删掉,本文件那几条与平台
    无关的单测(属性号照 Win32 头文件、无效句柄不许表现成成功)就连编译都过不了;
  · 逐项 `cfg_attr` 要多 7 行,本文件 394 行 → 会顶破 guards.yml 的 400 行红线(现 398 行)。
  代价写明在文件头:非 Windows 上本模块的 dead_code 不再报。
- exstyle.rs `use super::raw_ex_style`:唯一使用者 `click_through` 自己按平台门着 ⇒ 补 cfg。
- probe.rs 的 `use super::dwm::{…}`:`CornerOutcome` 与平台无关(`ProbeRow` 字段 + 非 Windows
  的 `empty_row`),其余九个符号只服务于 Windows 的 `run_one` ⇒ 拆成两条 use。
- window_alpha.rs `use …RawWindowHandle`:只有 `RawWindowHandle::Win32` 那条臂用它 ⇒ 补 cfg。

test(docker 内 622 passed / 1 failed → 623 passed / 0 failed):
`opaque_or_refused_alpha_never_touches_the_window_style` 里「无效句柄 ⇒ Failed」是按 Windows
语义写死的;非 Windows 走 `transparency::set_layered` 的空实现,如实返回 `Unsupported`。
按平台拆成两条断言(Windows 断 `Failed`,其它断 `Unsupported`),两边都不许表现成
`Applied`/`Opaque`。顺带说明:`dispatch` 的 `layered=false` 那半条断言与平台无关,保留在
两条之外照跑。

验证(本机 Windows + Docker Debian 12,命令与 ci.yml 逐字一致):
- docker `cargo clippy --workspace --all-targets -- -D warnings` → exit 0
- docker `cargo test -p unlucky-app-iced --lib` → 623 passed / 0 failed
- Windows `cargo check -p unlucky-app-iced --all-targets` → 无 error/warning
- Windows `cargo test -p unlucky-app-iced --lib` → 624 passed / 0 failed / 2 ignored
2026-11-06 20:06:56 +08:00
AskaEth 2e16df0191 Merge branch 'feat/m145-pkgsplit-split-release-workflow-and'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 19:32:47 +08:00
AskaEth f4cab82c65 ci(release): M145 复审 P2-2 修正过时上下文名(不可达分支的报错文案) 2026-11-06 19:30:10 +08:00
AskaEth 374e168334 docs(packaging): M145 按主题拆分 README(索引 + docs/,节号不变)
packaging/README.md 也是 400 行整,且安装包与自动更新马上要动发布线。按主题分成
「索引 + 5 份承载文件」,每份 ≤124 行:

- README.md 只留真相源声明、§1–§12 的索引表、§11、§12
- docs/artifacts-and-version.md:§1 产物矩阵 + §2 版本来源
- docs/build-and-ci.md:§3 怎么构建
- docs/runtime-deps.md:§4 运行时依赖 + §5 软渲染兜底
- docs/profiles-and-measurements.md:§6 构建档位 + §7 实测
- docs/compliance-support-boundaries.md:§8 合规 + §9 支持矩阵 + §10 已知边界

节号沿用原编号,所以全仓「packaging/README.md §N」式引用(根 README / README.zh-CN /
make_artifacts.py / release-notes.md / ci-runtime-smoke.md / release*.yml 注释)都不用改。
搬家由脚本按原文件切片生成,逐行可查:旧 README 400 行全部出现在新位置,唯一改写的
旧行是 §12 清单里那两行(文件清单必须反映拆分后实况)。新增行 15 条:索引段、§3 的
M145 说明、§12 的 5 份新文件行。
2026-11-06 19:29:47 +08:00
AskaEth e3ee404bd5 ci(release): M145 把 release.yml 拆成编排 + 两个可复用工作流
release.yml 已是 400 行整,下一处改动必撞全仓行数红线(guards.yml 的 file-size)。
拆成「触发 + 槽位表 + 编排」的主工作流与两份 `workflow_call`:

- release-build.yml:原 build job 的 11 个步骤逐字搬过去,只有四个矩阵键换成同名
  inputs(slot / os / build_dir / rustflags);矩阵仍由调用方 plan 生成并展开。
- release-publish.yml:原 release job 的 6 个步骤逐字搬过去(PUBLISH_SLOTS 白名单、
  裁剪、重算 SHA256SUMS、渲染说明、create/edit 幂等);调用方只留 needs / tag 门禁 /
  contents: write 的授予,token 走显式透传的 secret,pre-release 三态改读
  github.event.inputs.prerelease(dispatch 的 boolean 输入无法表达「空值」)。

on / env / 顶层 permissions / concurrency 与 plan 的槽位表一字未动。
校验:YAML 可解析、actionlint 全仓零告警、12 段 run 脚本全部 bash -n 通过、
逐步骤比对(归一化后)与拆分前完全相同;120 / 233 / 151 行。
2026-11-06 19:29:47 +08:00
AskaEth 57082219ea Merge branch 'feat/m146-updcore-update-core-without-binding'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 19:27:03 +08:00
AskaEth 2e718af711 feat(core): 新增 update 模块,做与更新源无关的自更新内核
更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、
渠道准入、下载校验、原子替换 + 回滚。源抽象成 UpdateSource trait(清单 + 包体两个
方法),core 里没有任何 CDN 常量;内置的 DirSource 吃本地目录或 file://,既是测试
实现也是将来 HTTP 源的对照物。

取舍:
- 版本比较严格按 semver 2.0 §11:数值比(0.9 < 0.10)、预发布 < 同号正式版、
  预发布标识逐段比(alpha < beta < rc 是 ASCII 序的自然结果,不另立阶段权重表)、
  构建元数据不参与排序与相等。
- 渠道准入两道判据:清单自报渠道的阶梯比较 + 版本号里的预发布阶段。清单可能写错,
  版本号不会,所以正式版渠道即使拿到一份自称 stable 的 rc 也不会提示更新。
- 校验两道判据且顺序有意:先比长度(抓截断,不必读全文件)再比摘要(抓内容错),
  长度要求**恰好**相等;失败即删暂存文件。StagedPackage 只能由 stage() 产出,
  于是「未校验的包」在类型上就进不了安装路径。
- 替换跨平台统一成「旧文件改名挪开 → 新文件搬进来 → 失败把备份搬回去」:Windows 上
  覆盖运行中的 exe 不存在,改名是唯一可用动作,而改名也可能被不共享删除的句柄拒绝,
  所以失败必须能回滚。Unix 也照这个顺序跑,避免回滚路径只有 Windows 被真正跑过。
- core 零 UI 依赖不变:只有 serde/serde_json + 复用的 sha1/sha2,无新依赖,
  Cargo.toml 未改;不接 presenter、不接 UI。

单测 62 条(crates/core/src/update/tests/):版本边界、清单校验、渠道准入、
摘要不符、包体截断(短了/多了)、无权限写目标目录、Windows 占用文件、回滚与备份清理。
2026-11-06 19:16:15 +08:00
AskaEth 156abfe38b Merge branch 'feat/m143-readmearm-mark-windows-arm64-as-not'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 12:35:48 +08:00
AskaEth 152e677b15 docs(readme): M143 下载表把 windows-arm64 标为暂未发布
依据 `.github/workflows/release.yml:291` 的 `PUBLISH_SLOTS: "windows-x64"`:arm64 槽位仍在
矩阵里构建取证,但不进 Release(同文件 300-313 行还会把白名单外的产物裁掉)。用户照旧表去下
`..._windows_arm64.exe` 会 404。

- 两版 README 的 Shape 列加「暂未发布 / not yet published」,行不删(架构支持意图仍在)
- 表后补一段可核的理由:CI 的 ARM64 runner 缺 Windows SDK 的 ARM64 导入库,已定位、在修;
  并点明 PUBLISH_SLOTS 才是「进不进 Release」的开关,windows-x64 是当前唯一可下载的 Windows 资产
- 不写日期/版本承诺;windows-x64 命名规则与体量描述未动;packaging/README.md 与
  release-notes.md 未碰
2026-11-06 12:35:11 +08:00
AskaEth e4d8b75723 Merge branch 'feat/m142-xi64only-unblock-release-ship-x64-a'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 12:33:51 +08:00
AskaEth a70e901bea ci(release): M142 发布白名单只留 windows-x64 + 修掉诊断脚本在 -e 下先退出
1) PUBLISH_SLOTS 去掉 windows-arm64:白名单齐全性检查是「名单内少一档就 fail」,
   arm64 连试四轮未成 ⇒ Release 永远 fail、x64 编成功也发不出去。arm64 仍留在矩阵里
   照跑取证(诊断注解保留),红不挡发布;修好后把键加回这一行即可(一行可逆)。

2) 修 M141 自毁 bug:`shell: bash` 是 `bash -eo pipefail`,脚本里的 `set -uo pipefail`
   关不掉 -e,于是 `vc_lib=$(find 不存在的根 | head -n1)` 被 pipefail 判非零后 errexit
   当场退出,一条注解都没打(上一轮只剩 exit code 1)。现在显式 `set +e`、每处探测
   `|| true`、问题记进 ERRORS、4 行实况快照无论齐不齐都先打完,再由显式 exit 定红绿;
   齐全时快照降级为 notice(绿跑也留「探测没坏」的证据)。

3) 新增 arm64 失败收尾注解步骤(`if: failure()`),红在其他步骤时也能说清红在哪、不影响 x64。

4) packaging/README.md §1/§3/§4/§7.5/§9 与 release-notes.md 的 Downloads/Support matrix/
   Install 同步为「当前只发布 windows-x64,arm64 待修」。
2026-11-06 12:30:07 +08:00
AskaEth cc2ecd61e6 Merge branch 'feat/m141-arm64sdk-diagnose-and-fix-missing-a'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 12:18:57 +08:00
tower 5d5011f66c ci(release): M141 arm64 体检改成逐行注解 + 缺 ARM64 SDK 时就地补装组件
- 诊断全部走 ::error:: 注解(job 日志要 token,注解不要),快照固定 4 行:
  探测根存在性 / 每个 SDK Lib 版本目录的 um 架构 / MSVC lib 架构 / 两个关键库路径。
- SDK 根放宽到 4 个((x86)、Program Files、Microsoft SDKs、VS 自带的 SDK in VS),
  kernel32.lib 探测深度 4→6;MSVC find 兜底深度 7 覆盖 lib 目录。
- 缺库时先就地补装(vs_installer modify --wait),组件 ID 按已装 SDK build 反推,
  优先补一个尚未安装的 SDK 版本(同版本重装是 no-op,补不出 um/arm64);补不上才 fail。
- 门禁不降级:PUBLISH_SLOTS 与槽位表原样(windows-arm64 仍在)。
- 为守住 400 行上限,压缩了其余纯注释块与两处等价写法,逻辑未变。
2026-11-06 12:15:10 +08:00
AskaEth 241a258e45 Merge branch 'feat/m140-arm64ci-make-windows-arm64-buildabl'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 12:00:01 +08:00
AskaEth 5678683561 fix(release): M140 r1 复审三条 p2 —— vswhere -find 单值、根因措辞、SDK 双根
P2-1(真缺陷):`-find` 是单值参数,老写法 `... "${find_args[@]}"` 一次传 4 个
`-find`,官方定位器只认其中一个(复现:last-wins 下取到 Hostx64),
所以 round-1 新加的「ARM64 优先 → Hostarm64」在 vswhere 分支上从未执行。
改成每个 pattern 各起一次定位器,不赌取值规则;注释同步为真话。

P2-2(措辞):README §4 不再声称 v0.2.1 那次是「x64 镜像缺库」——windows-2025
镜像清单里 VC.Tools.ARM64 与 SDK 26100 都在,根因未定论,更像自检的路径探测;
§7.4 的 dumpbin 门禁补一句覆盖 windows-latest 与 windows-11-arm 两个镜像。

P2-3(放宽):arm64 库体检的 SDK 侧也改成两个安装根(Program Files (x86) 与
Program Files),把「路径猜错 → fail-closed 误报」这个正是本次事故的形态再降一档。

行数:release.yml 400 / README 400(均 ≤400);PUBLISH_SLOTS 未动。
2026-11-06 11:59:31 +08:00
AskaEth b27e86c65b ci(release): M140 windows-arm64 改用原生 windows-11-arm runner
交叉编译路线在 CI 上从未成功过一次:v0.2.1 首次真跑即红在
「Windows ARM64 交叉工具链体检」(windows-latest 上探不到 VC/SDK 的
ARM64 库),故按文档标准 runner 表把 arm64 槽位改成原生
windows-11-arm(公开仓免费,4C/16G),产物回到 target/release。

- 槽位表:windows-arm64 os=windows-11-arm、build_dir=target/release;
  无交叉槽位后 target 键与 rustup target add / cargo --target 一并删掉
- 体检步骤重写为「Windows ARM64 工具链体检」:失败时打印实际探测到的
  VS 根、VC arm64 lib 目录、SDK 的 um/arm64 目录;SDK 不再只认
  tail -n1 挑出的单一版本(全版本里找)
- dumpbin 定位链补 Hostarm64/arm64 分支,按 RUNNER_ARCH 选宿主工具族
  (x64 上绝不挑 arm64 二进制),vswhere/find/PATH 三级与硬门禁不变
- packaging/README.md §1/§3/§4/§7.5 同步原生化与冒烟真跑的说法
2026-11-06 11:51:01 +08:00
AskaEth 1a290a15a3 Merge branch 'feat/m139-ciwin-trim-release-ci-to-two-window'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-06 11:39:14 +08:00
AskaEth 9de51e16af ci(release): M139 发布 CI 只跑两个 Windows 槽位,整表六槽位改为手动取证
build 矩阵从六个槽位裁到 windows-x64 / windows-arm64 两个:
- 矩阵改由新增的 `plan` job 生成(`fromJSON(needs.plan.outputs.matrix)`),槽位表只在该
  job 里有一份,`mode: ci` 默认编、`mode: probe` 只有 `all_slots=true` 才编;
- `workflow_dispatch` 新增 `all_slots` 布尔输入(默认 false = 只跑 Windows),
  Linux/macOS 四档保留完整定义与打开办法(改 mode 一行),取证能力没删;
- `PUBLISH_SLOTS` 不变(两个 Windows 槽位);齐全性检查仍按下载下来的产物判,
  白名单槽位没编/编挂就 fail,不会因矩阵变小而永远过;
- packaging/README.md §1/§3/§4/§9 与 release-notes.md 的 Downloads / Support matrix
  同步成「四档默认不构建」,不留「六槽位都构建」的旧说法。
2026-11-06 11:33:52 +08:00
AskaEth f617f60134 Merge branch 'feat/m137-rel021-bump-to-0-2-1-and-ship-windo'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 21:30:34 +08:00
AskaEth 54b81cbb7f chore(release): M137 版本升到 0.2.1,发布只发两个 Windows 槽位
版本:`[workspace.package] version` 0.2.0 → 0.2.1,并把全部 intra-workspace path
依赖的 version 约束一并对齐(`"0.2"` → `"0.2.1"`,M121 的教训)。Cargo.lock 里只有
8 个 unlucky-* 成员条目变化。`crates/app` 是 workspace `exclude` 的 Slint 退役树(manifest
连解析都过不了),不参与解析,故原样保留。

release.yml:新增 `PUBLISH_SLOTS` 白名单(唯一开关)= `windows-x64 windows-arm64`。
六槽位一律构建取证,release job 先按白名单裁掉 Linux/macOS 四档产物再重算 SHA256SUMS,
所以 tag 触发的 Release 只有 4 个资产 + SHA256SUMS.txt。release job 改成
`always() && !cancelled()`:未验证槽位构建失败不连坐 Windows 发布,真正的门禁是白名单
齐全性检查。将来某槽位真机验过后把它加进那一行即打开。

顺手修掉一处从未生效的门禁:齐全性检查的 glob 用的是连字符形态
(`dist/$prefix-*-$slot*`),对 D49 之后的下划线资产名永远匹配不上,改成
`dist/${prefix}_*_${stem}*`。资产命名规则 / SHA256SUMS / 合规文本并列资产 /
`--version` 与 tag 交叉校验一律未动。

packaging/release-notes.md:清掉 v0.1.0 / Slint 时代的说明,Downloads 与 Support matrix
只描述本版实际发布的 Windows 两个槽位(其余标「built by CI, not published」)。
packaging/README.md §1/§3/§9 同步 gate(矩阵加「本版发布」列、§3 写清白名单与打开办法)。
起手顺带把示例 tag 从 v0.2.0 更到 v0.2.1,并把「winit / femtovg」更正为「winit / wgpu」。
2026-11-05 21:30:13 +08:00
AskaEth a03e90fc1a Merge branch 'feat/m138-readmepub-reshape-readme-for-the-pu'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 20:59:39 +08:00
AskaEth a3f26d424b docs(readme): r1 复审 P2 两条措辞修正(dry-run 口径、导入取消的真实含义)
P2-1:`--dry-run` 原来写成「file list, total size, source per file」,比实现强。人类
可读输出是按角色的文件数与字节数 + 合计 + 每类一个源链样本
(crates/cli/src/commands/install/report.rs:161-201),逐文件列源是被刻意否决的设计
(crates/core/src/game/install/plan_view.rs:46-49:「只取每类的首个文件做样本……逐文件
列源选择既没用也没法读」)。整份逐文件清单只在 --json 的 data["files"] 里。

P2-2:「安装是真取消」原来挂在整合包条目下,读者会读成「整合包导入也能真停」。实际
导入取消是界面级的:面板收回 + 结果作废,后台导入跑到底(crates/presenter/src/host/
addon/pack.rs:28-42 模块文档原话「取消是界面级的……core 的 ImportRequest 没有取消
令牌」)。真取消是版本安装那条路(crates/presenter/src/host/dispatch.rs:61-88:在飞
文件就地掐断、段边界收手、不登记实例),已移到「版本管理」条目。

中英两版同步改,未动结构。
2026-11-05 20:59:16 +08:00
AskaEth 8ad9792abd docs(readme): 重排成面向第一次打开的人的次序,并补一张真截图
发布前把两份 README 的次序改成:一句话定位 → 截图 → 现在能用什么(含「还没做」)
→ 下载与运行 → 从源码构建 → 架构/模块地图 → 许可与致谢。原来的边界说明没删,
拉到对应功能旁边;「还没做」独立成节并写实。

同时按用户令清掉所有 v0.1.0 / Slint 时代的内容(版本考古段、历史 bin 行、Slint
Royalty-Free 义务段、crates/app 的模块地图行与构建说明、「翻牌还欠 bin 改名」)。
NOTICE 未动。

内容上纠正了几处「写得比实现强」:
- 启动器内的助手没有接真实模型(transport 是脚本化的),原句「chat 在启动器里」
  会让读者以为能用;现在明说占位,并把真的那部分(MCP server)单独讲清。
- 「界面文案统一走一份目录」改成本地化边界:守门只扫实例页,且外观面板老控件
  仍是硬编码英文。
- 实例页的 新建/导入/复制/打开文件夹/删除、日志页的 打开文件夹、设置帧的
  保存/重置/加覆盖 都是没有后端的占位按钮(pages/mod.rs 偏离 #8),写进「还没做」。
- Java 回退链按地区不同,补上 java.vendorPriority 与区间差异。
- 补 ULL_CF_API_KEY(CurseForge 检索的前置,此前全仓文档都没提)。

截图:docs/images/instances-en-light.png(DevDocs/artifacts/m120/03-instances-en-light.png,
1180×760、浅色、英文界面;实例名与档案名均为测试数据,无路径/令牌)。
2026-11-05 20:53:30 +08:00
AskaEth 9cc3260644 Merge branch 'feat/m136-msadoc-update-docs-now-that-missing'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 20:13:33 +08:00
AskaEth 3bc6d76b84 docs(readme,release-notes): M136 缺 client id 的对外文案改成现行行为
M132 之后界面已经直接说「微软登录未配置 + 要设 ULL_MSA_CLIENT_ID」并刻意不给重试按钮,
而 README / README.zh-CN / release-notes 还写着「只会弹通用登录失败 + Code: other」——
那是修前行为。三处改成:标题/正文/Code: msa_not_configured/无重试,并保留「其余失败
仍只给分类文案 + 码,原文只进日志」这半句(那是真的,也是有意的)。
2026-11-05 20:10:10 +08:00
AskaEth 9dedaa6098 Merge branch 'feat/m132-msanotcfg-surface-missing-client-id'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 20:08:55 +08:00
AskaEth 119b49cf83 test(presenter): M132 烘焙了 client id 的构建里让入口锚直接红(r2 P2-1)
r2 复审实测:`ULL_MSA_CLIENT_ID=fake-0000-test-id` 构建 + 同一处变异 → `ok. 22 passed`。
原因是我上一条写成了 `if is_configured() { assert!(CLIENT_ID_BUILD.is_some_and(..)); return; }`
——`remove_var` 之后 `is_configured()` 等价于 `CLIENT_ID_BUILD` 非空,随后的 `assert!` 判的是
同一条件的反面,恒真、无失败路径,于是锚在那种构建里**绿着失效**。这正是本 mission 要防的
「守门空跑」形态,而我报告里还把它写成了「只允许是这个原因」,与代码不符。

改成 `if let Some(source) = client_id_status().source() { panic!(..) }`:环境变量摘掉后仍
报告已配置,就带着 `source=` 和两条出路(不带 id 构建 / 给 `resolve_client_id` 加接缝)直接
红,逼人做决定。没有 `#[ignore]`、没有开关;正常的「两处都没有 id」构建照旧跑两条真登录流。

验证(贴原生输出):
- 注入 id + 变异 → FAILED(panic,`source=Build`),旧代码此处是 `ok. 22 passed`;
- 注入 id + 无变异 → 也 FAILED(这才是重点:不是靠变异才红,而是拒绝空跑);
- 无 id + 变异 → `220 passed; 1 failed`,红的正是这条锚;
- 无 id 正常 → `221 passed; 1 ignored`。
2026-11-05 20:08:52 +08:00
AskaEth f2149048fb test(presenter): M132 把「缺 client id → 原因码」那行钉进测试(r1 P2-1)
复审 r1 实测:把 `start_login` / `start_browser_login` 里的 `auth_failure(&error)` 改回
`LoginFailure::Other`,编译通过、884 条测试全绿——也就是说「缺 id 会变成 MsaNotConfigured」
这件事此前只有代码可读。这条正是本 mission 要防的回退,补一条能抓它的锚。

新锚 `a_missing_client_id_reaches_the_login_panel_as_a_config_gap` 不只测 `auth_failure`,
而是**真的起两条登录流**(设备码 + 授权码),从 `JobSender` 收到的第一条 `HostTask::Login`
上断言 `failure == MsaNotConfigured`,并断言原文(含 `ULL_MSA_CLIENT_ID`)仍只在 `message`
里、页面画不出来。零网络:`from_env` 在 id 判定处就地早退,事件立刻到,不需要任何桩。

环境变量是进程级的,所以照 core 的做法串行 + panic 也还原;构建期注入过 id 的构建里
(本仓库 CI/本地都不注入)显式断言「只能是这个原因」后返回,不是 ignore/开关。

测试模块从 `accounts.rs` 拆成 `host/accounts/tests.rs`(`#[cfg(test)] mod tests;`,先例
`dto/accounts.rs`、`host/install.rs`):`accounts.rs` 400 → 299 行,拆完两边都远离 400 行硬线。
scope 已由塔台批准扩到 `crates/presenter/src/host/accounts/**`。

变异验证(最终代码上重跑):
- 两处 `auth_failure(&error)` → `LoginFailure::Other`:`221 → 220 passed / 1 failed`(只有新锚红);
- 还原:222 全绿。

顺手把 fn 指针数组换成一个 `assert_config_gap(name, start)` 辅助函数,
`cargo clippy -D warnings` 的 `type_complexity` 不放过它。
2026-11-05 20:08:52 +08:00
AskaEth af39efd23b fix(presenter,app-iced,i18n): M132 缺 MSA client id 上屏成「未配置 + 怎么补」
缺 client id 原来整条链路只给 `LoginFailure::Other`:界面画「Sign-in failed /
完整原因在启动器日志里 / Code: other」,用户得翻日志才知道要设 `ULL_MSA_CLIENT_ID`。

落点定在**账户页登录面板**,不是启动流程:这个失败只发生在 `microsoft_auth()`(两条
登录流的入口);没配 id 的发布版建不出微软账户,`identity::resolve` 那条启动路径用户
撞不到(只有「有 id 的构建里登录过、之后 id 被摘掉」才可达)。

- presenter:新增 `LoginFailure::MsaNotConfigured`(`msa_not_configured`)。`microsoft_auth`
  改为回 `AuthResult`,`AuthError::Unsupported`(`from_env` 唯一的产出点,零网络)单独成码,
  其余失败照旧走 `classify`。诊断原文仍只进日志。
- i18n:账户域新增 `AccountLoginNotConfigured`(标题)/ `AccountFailureMsaNotConfigured`
  (正文:设 `ULL_MSA_CLIENT_ID=<id>` 或构建时注入,改完重启),golden 同步。
- app-iced:`failure_title_key` 给这一类专属标题;正文按码查表;这一类**不给「重试」**
  (进程内的环境变量改不了,重试必然是同一个错)。
- 测试:`a_missing_client_id_tells_the_user_what_to_configure` 断言 UI 模型里出现「未配置」
  + 变量名 + `<id>` 占位 + `msa_not_configured`,且没有那句「去翻日志」;反向锚
  `a_network_failure_does_not_look_like_a_missing_config` 与
  `only_a_missing_client_id_becomes_the_config_gap_code` 钉住别的失败不许跟着变。

为什么这一类可以上屏:上屏的是 catalog 固定文案(无凭据、无 token、无内部细节),
`DevicePanel::Failed` 里没有 `message`/`detail` 字段,原文在类型上就画不出来;
理由是它的原因本身就是用户的下一步。M117/M119 的「诊断不上屏」约定不动。
2026-11-05 20:08:52 +08:00
AskaEth 01ca8033be Merge branch 'feat/m135-deadlabel-drop-dead-launch-loader-l'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 19:15:59 +08:00
AskaEth 321617861a chore(m135): 删掉死代码 launch::loader_label,修 M134 之后变假话的三处文档
M134 复审点名、且都在它 scope 外的收尾。

- `presenter/src/host/launch.rs::loader_label` 删除:全仓零调用点(唯一提及是
  i18n 的一条注释),函数体逐字重复 `dto::LoaderKind::label()` 并硬编码「原版」——
  它既是第二份显示词表,又绕过 i18n;留着等于给下一个人示范「照这样写就行」。
  随之无用的 `LoaderKind` import 一并收掉。
- `presenter/src/lib.rs` 的 dead_code 豁免说明不再点名已删函数(否则那行注释指向
  一个不存在的符号)。
- `i18n/src/bridge.rs:65` 注释改指向唯一那份显示词表(`dto::LoaderKind::label()`)。
- `core/src/game/loader.rs` 与 `presenter/src/dto/instances.rs` 两处「显示路径
  没有守卫(已知缺口)」改成 M134 之后的真形态:显示路径是委派(拿 core 的稳定 id
  反查 DTO 词表,没有手写 match 可糊),新种类先在 `dto::LoaderKind::from_core` 编
  不过,糊错的后果由 `loader_kind_ids_are_the_released_contract` 与按 `ALL` 遍历的
  `every_core_loader_kind_reaches_the_tile_as_itself` 钉住。
- `presenter/src/host/game.rs` 无需改动:M134 当时已把它的文档改成新形态,读下来是
  真话(`dto_kind` 的 doc 与两个测试的 doc 都对得上现在的代码)。

验证:fmt --all --check 通过;`cargo test -p unlucky-presenter -p unlucky-i18n`
= i18n 29 passed / presenter 220 passed(1 ignored);clippy 两 crate --all-targets
`-D warnings` 零告警。删后全仓(含 workspace 外的 crates/app)已无
`launch::loader_label` 引用。
2026-11-05 19:09:53 +08:00
AskaEth ffb608c854 Merge branch 'feat/m134-dtoshape-guard-the-presenter-displa'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 19:02:17 +08:00
AskaEth c14543a112 fix(m134): 显示路径的 core→DTO 种类改成委派,不再留手写映射臂
M126 r3 复审实测:core 加第八家加载器、对外契约与 help 都补齐、只在
host::game::dto_kind 那一支写 `=> Vanilla`,三个 crate 全绿(402/220/42)
而磁贴静默显示「原版」——M122 修掉的用户可见 bug 复活。

- dto_kind 不再是自己一张七支手写 match:种类由 core 的稳定 id 反查 DTO 词表
  (LoaderKind::parse → dto::LoaderKind::from_core,全仓唯一那份映射)。落笔处没了:
  想糊 `=> Vanilla` 的人到这儿没有 match 可写,新种类必须先过 from_core。
- 反查不到(core 的 as_str 与 parse 自相矛盾,物理上做不到)就 panic,不退回「原版」。
- 测试从手抄七支清单换成按 core 的 ALL 遍历:加一家自动进圈,不用记得补一行。
2026-11-05 19:02:15 +08:00
AskaEth fb44a264ee Merge branch 'feat/m133-devguard-run-cheap-guards-on-dev-pu'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-05 18:15:40 +08:00
AskaEth e0e1f90aed ci(workflows): 让日常线 dev 也跑「快且会静默腐化」的守门
本仓日常开发线是 dev,日常合并不走 PR;而 ci.yml / guards.yml 只挂在
`push: branches: [main]` + `pull_request` 上 ⇒ fmt / clippy / 文件行数≤400 /
三条零依赖守门在日常线上一次都没跑过。可见后果:packaging/README.md 涨到 401 行
超全仓红线,直到文档普查才被发现(M131 才修回 398)。

做法(新增 dev-guards.yml,不改 main/PR 判据):
- guards 作业用 `uses: ./.github/workflows/guards.yml` 复用**同一份**六道守门脚本
  (guards.yml 只加了 `workflow_call:` 触发,其余一字未动),避免两份 unsafe 词法器
  各自演进造成「dev 绿、main 红」的双真相;
- fmt / clippy 各写一份,命令与 ci.yml 逐字一致(ci.yml 整份复用会把 build/test/
  runtime-smoke 矩阵也拖上 dev);
- 刻意不上 dev:全量 cargo test(--all-targets 的 clippy 已覆盖「测试代码编译不过」
  这类腐化,「断言暂时红」在施工中间态是常态)、build release、runtime-smoke。

成本控制:workflow 级 concurrency 取消同 ref 旧运行;clippy 在「本次推送只碰文档」
(*.md / LICENSE / NOTICE / .gitignore 白名单)时跳过编译,判据保守——非 push、
before 全 0、base 不可解析、diff 为空、出现任何白名单外路径一律照跑。
2026-11-05 18:15:37 +08:00
AskaEth 969d5ea450 chore(git): 从分支摘掉误入库的 .pyc 快照,并把 __pycache__/ 与 *.pyc 加进 .gitignore
03a5de9(TowerSpawn 的 WIP 快照)把主检出里未跟踪的
packaging/__pycache__/make_artifacts.cpython-312.pyc(18459 字节二进制)一并提交了。
这里用 git rm --cached + 忽略规则把它挡在主线之外:跑过 make_artifacts.py 就会再长
一次,不堵根因的话每次 spawn 都会重新污染主检出并触发 WIP 快照。
2026-11-05 18:15:36 +08:00
AskaEth 7764863142 tower: snapshot of uncommitted base checkout changes (worktree wt-133) 2026-11-05 18:15:34 +08:00
AskaEth 217c8ee19c Merge branch 'feat/m126-loaderstr-unify-loader-kind-stable' 2026-11-05 18:09:06 +08:00
AskaEth f6b7759aa8 docs(m126 r3): 收窄「唯一一份」的说法,并把 ③ 的承诺改成真话
纯文档,零逻辑改动(`git diff` 里每一行都是 `//!` / `///`)。

P2-1「唯一一份」不成立:`cli/src/cli/help.rs` 里 `ull help instances` 的词表行是
**会打印给用户看的第二份逐字副本**。四处说法收窄为「源头表在 core + 另一处逐字副本在
help.rs,由 `help_lists_every_stable_id` 按 `ALL` 遍历守着」:`game/loader.rs` 的模块
文档(原「生产代码里这七个串只有一处」)与 `as_str` doc(原「生产代码里唯一一份 id
字面量」)、`cli/commands/instances.rs` 的模块文档(原「全仓唯一一份 id 字面量」)与
`loader_json` doc(原「两样都只该有一份」)、`presenter/dto/instances.rs` 的 as_str doc
与契约测试 doc、`addon/loader/mod.rs` 的两处「唯一真相源」。

P2-2「③ 拦得住静默变原版」对显示路径不成立:`presenter/src/host/game.rs::dto_kind` 里
糊一支 `=> Vanilla`、同时把契约与 help 补齐,三个 crate 全绿而磁贴静默显示「原版」。
现在写的是「③ 拦的是**对外词表**(加种类、改词表都红),但**拦不住显示路径**,那处目前
没有守卫(已知缺口,另行记账)」。

顺带只做排版级的行数回收:loader.rs 400 → 397(≤400,仍留余量)。
门禁:cargo fmt --all --check → FMT_OK(本轮按要求只跑 fmt)。
2026-11-05 18:09:00 +08:00
AskaEth 14ac0dc248 test(m126 r2): 加一支枚举不更新对外契约现在必然红(集合断言 + help 守卫)
r2 复审指出「加一家加载器会被逼着表态」只做到一半:编译期只拦「漏 match」,三处消费方
用最省事的 `=> Vanilla` 糊过去既编得过、测试也全绿——新加载器会静默显示成原版,正是本
mission 要消灭的形态(r1 里「Rift 会红」是负样本名字撞上的巧合,不是守卫)。

- presenter 的 `loader_kind_ids_are_the_released_contract` 增加**第 ② 段**:把 core 的
  `LoaderKind::ALL.map(as_str)` 与独立手写的七个 id 做**集合相等**比较。加一支而不更新
  契约 → 红;改词表 → 红。(r1 删掉 `assert!(ALL.len()==7)` 后缺的正是这一环:数组宽度
  只保证「表里那一行写成 7 个」,不保证枚举没多一支。)
- 新增 `cli::commands::instances::help_vocabulary_tests::help_lists_every_stable_id`:
  按 `LoaderKind::ALL` 遍历 `ull help instances` 的词表行,漏写一个新 id 必红(此前
  `help.rs:67` 那段词表没有任何测试,而旧的 `help_documents_the_loader_field_contract`
  只查关键词,加一家新加载器时它照样绿)。
- `addon::loader::tests::vocabulary` 补 `installable_list_matches_the_from_core_mapping`:
  把唯一还手写的 `LoaderKind::all()` 与 `from_core` 认可的集合对齐(不重抄字面量)。
- 模块文档第 ③ 步改成真话:契约测试会拦「改词表」与「加新种类」;但 `=> Vanilla` 这种
  糊法**编得过**,拦住它的是那条集合断言,不是编译器(改名同理,只有契约测试会红)。
- 一并修掉 presenter 那条测试 doc 里指向已被删除的 `MARKERS` /
  `markers_cover_every_kind_that_has_one` 的引用,改指向现在真实存在的守卫。
- loader.rs 从 398 压到 395(≤400,留 5 行余量;拆测试到子模块需要 scope 放宽到
  `crates/core/src/game/loader/**`,超出本任务文件清单,已按规矩记账)。

验收实测(临时加 `Abyss` + 三处 `=> Vanilla` 糊过去,已还原):presenter 契约测试红
(left 8 项 / right 7 项),CLI help 测试红(漏了 `abyss`);core 往返测试与安装侧清单
守卫、CLI 的 `json_rows_*` 仍绿(它们本就管别的事)。
2026-11-05 18:08:59 +08:00
AskaEth a322cfcbfe refactor(core): M126 r1 返工:词表改由宏单表生成,安装侧解析收口
复审 r1 的三条:

P1(CI 会红):`const MARKERS: [(LoaderKind, &'static str); 6]` 触发
clippy::redundant_static_lifetimes(`error: constants have by default a 'static
lifetime`)。返工直接**删掉 MARKERS**:标记串就取 `LoaderKind::as_str()`,候选顺序取
`LoaderKind::ALL`,认不认标记看 `has_marker()`——解析表既没有独立字面量,也没有独立清单。

P2-1:「漏加 ALL 不会被任何东西拦住」是真话,之前的 doc 覆盖声明是假的。返工用
`loader_kind_vocab!` 宏从**一张表**一次生成枚举 / `ALL` / `as_str` / `has_marker`:
「加了一支却忘了进清单」在物理上不可能(没有第二份清单可写)。尺寸锚随之删除(`[Self; N]`
的宽度本来就由表决定),`parse` 留在宏外的独立 impl 里。

P2-2:「全仓唯一一份 id 字面量」原先不成立——安装侧 `LoaderKind::parse` 还手写了一份反向
匹配表,并带一个只进不出的 `neo-forge` 别名。返工让 install 侧 `parse` **委派**
`core::game::LoaderKind::parse`,只额外收 `neo-forge`(Modrinth 拼法)这一个输入别名,
并新增 `addon::loader::tests::vocabulary` 覆盖「委派没接错 + 别名只进不出 + 装不了的几家
返回 None」。

测试:core 的 `markers_cover_every_kind_that_has_one` 换成
`every_stable_id_parses_back_to_its_kind`(往返 + 顺序优先级),仍是「改名不可见、只有契约
测试能抓」的分工,doc 里写明。

门禁:fmt OK;clippy -p unlucky-core -p unlucky-presenter -p unlucky-cli --all-targets
-- -D warnings 干净;三 crate 测试 core lib 401 / presenter 220 / cli 41 全 ok。
文件行数:loader.rs 397(≤400)。
2026-11-05 18:08:57 +08:00
AskaEth be37c17962 refactor(core): M126 加载器稳定 id 词表收敛到 game::LoaderKind::as_str
M122 把「id → 加载器」的解析收到 core::game::loader::InstanceLoader::parse,但
「种类 → 对外稳定 id」的命名词表还散在多处,其中 install 侧那一份只靠一句「不要改名」
的注释约束,MARKERS 更是第四张无守卫字面量表。

- core::game::LoaderKind 新增 as_str(**全仓唯一一份 id 字面量**)、has_marker
  (穷尽 match:新种类必须表态认不认 id 标记)、parse(as_str 的反查)与 ALL(清单,
  顺序即标记优先级);MARKERS 的标记串改为取自 as_str,不再是独立字面量。
- addon::loader::LoaderKind::as_str 改成委派(结构映射,无字面量):安装侧拼出的
  <mc>-<kind>-<loader> 与解析侧从此同源。
- presenter::dto::instances::LoaderKind 的 as_str/parse 委派给 core(to_core/from_core
  是结构映射),删掉第四份词表;CLI 删掉 loader_kind_id 翻译层,直接调 core。
- 测试:core 新增 markers_cover_every_kind_that_has_one(MARKERS↔ALL↔has_marker↔as_str
  逐格对齐 + 按标记串 round-trip + neoforge 先于 forge);presenter 新增
  loader_kind_ids_are_the_released_contract(七个 id 逐字钉住 + 反查 + 别名隔离)。
- 对外输出未变:真二进制对同一组 7 类实例的 `instances list --json` 输出逐字节相同
  (md5 0341752658ca28e020ba48c31a51a124),人读输出同样逐字节相同。

file 行数:core/game/loader.rs 337→399(≤400 硬约束,文档按密度重排、事实未丢)。
2026-11-05 18:08:56 +08:00
AskaEth 84f85bbc79 Merge branch 'feat/m130-readmedoc-fix-readme-claims-about-c' 2026-11-05 18:05:41 +08:00
AskaEth 72ee57f0a8 docs(readme): M130 r1 修正产物名的槽位说明与自相矛盾的 GUI 二进制称谓
- P2-1:`[.exe]` 只对两个 Windows 槽成立,四个 Linux/macOS 槽是 .tar.gz
  (make_artifacts.py:76-82 SLOTS、:243 stage_archive;packaging/README.md §1)。
  三处产物名统一写成 `unlucky-launcher_<version>_<platform>_<arch>` + 按槽位说明形态。
- P2-2:删掉「`unlucky-launcher` once extracted or downloaded」与段尾「Windows 裸资产文件名带
  版本段」的互相拆台;改成归档解出叫 `unlucky-launcher`、Windows 裸资产叫
  `unlucky-launcher_<version>_<platform>_<arch>.exe`。中英同步。
2026-11-05 18:05:39 +08:00
AskaEth 95131d856e docs(readme): M130 补微软登录的 client id 前提,删「十四页」计数,把 i18n 守门边界与产物名写成实测口径
- Accounts:发布版二进制不含 Azure 公共客户端 id(CI 0 处注入),需设 ULL_MSA_CLIENT_ID;
  没配时点登录只拿到通用「登录失败 / Code: other」。
- 删「feature-complete: fourteen pages」:代码里 Route 8 个变体、Screen::ALL 5 个,没有读法得到 14;
  改成按 Route 列举并说明 Screen::ALL 为何更小。
- i18n:删「视图层没有硬编码字符串」,改成白名单守门抓英文、中文与其它页扫不到的已知边界。
- 自报名例子改成 argv[0] 实测口径;翻牌措辞区分「cargo bin 名未翻」与「产物名已翻」。
- 中文版补「一壳两形」highlight,账户与 auth 模块行补第三方社区服 / 本地档案。
- 顺手改掉「ull 能跑在 Termux」:Linux 资产 glibc ≥ 2.35,Termux 不在发布矩阵内。
2026-11-05 18:05:38 +08:00
AskaEth 7092469f90 Merge branch 'feat/m131-packdoc-shrink-packaging-readme-fix' 2026-11-05 18:03:23 +08:00
AskaEth 592f0c26f6 docs(packaging): M131 压回 400 行内,删 Slint 遗留祈使句/软渲染块,修 smoke 脚本 bin
- packaging/README.md 401 -> 398 行(回归 file-size 红线内)
- §5 删 SLINT_BACKEND=winit-software 命令块;现役入口改注 iced 的 --software-renderer
- §8 删「关于页保留 AboutSlint、不得移除」两句祈使句,只留 v0.1.0 资产事实段
- §7 抬头标明连字符产物名属哪次实测(现名形态写下划线 0.2.0),§7.3/§7.5 同步标注
- §11 删「该脚本现在会失败」的过期提醒,记录 M131 真跑证据
- linux-smoke-docker.sh: --bin/产物路径/target 全改 unlucky-launcher-iced(cargo clean -p 同步改
  unlucky-app-iced,原来的 unlucky-app 已被 workspace exclude、清不掉陈旧产物)
2026-11-05 18:03:20 +08:00
AskaEth 5b217ae32d Merge branch 'feat/m129-relnotes-rewrite-release-notes-to-c' 2026-11-05 18:01:29 +08:00
AskaEth 5a9d67530a docs(packaging): M129 r1 P1-1 —— MSA 那段改成 GUI 现有行为,不再写「界面会告诉你怎么配」
复审 r1 把链路走了一遍:core 缺 id 早退(零网络)成立,但 presenter 的 fail() 只把原文
eprintln 进日志,事件只带原因码 Other;账户页据此画通用横幅「Sign-in failed」+「Code: other」,
完整原因在日志里。LaunchMsaNotConfigured 那条 i18n 目前只有定义/单测/golden,GUI 零调用点。
所以上一版「the app says "not configured" (with the command to fix it)」是把设计意图写成了现有行为。

改成分段如实描述:无 id 时在联网之前就停;GUI 显示通用横幅 + Code: other(原因在日志);
`ull accounts add msa` 会打出可照抄的修复命令(map_auth_error 的 hint,含变量名与 cargo build)。
另按 r1 P2-1 给 macOS 补 shasum 校验行(sha256sum 不是 macOS 自带)。
2026-11-05 18:00:55 +08:00
AskaEth 59eefdb052 docs(packaging): M129 发布说明重写到现行事实(下划线资产名 / MSA client id / 删 Termux 下载引导)
资产名全部改成 make_artifacts.py::asset_stem() 的 `名字_版本号_平台_架构`(下划线):
六行矩阵、ull 资产、tar 包名与解压出的顶层目录、PowerShell 校验命令。旧的连字符名
按文档下载会 404(D49 2026-11-05 改名,文档没跟)。

删掉「微软登录等 Mojang 白名单审批」整条(白名单 2026-11-03 已批准并真机验签),
换成真缺口:发布版不带 Azure 公共客户端 id,要 ULL_MSA_CLIENT_ID(环境变量或构建期
注入),未配置时界面说「未配置」而不是静默失败。不写任何 id 值。

Termux:删掉「下 ull 资产」的引导(ull 的 Linux 资产是 glibc 版,Android/bionic 上跑
不起来),矩阵行只留「构建自源码、GUI 未验证、不在本发布矩阵」。

顺带清掉三处过期陈述:Windows 数据根(原文说有 `portable.marker` 就便携、否则落
%APPDATA%——实际 portable 是默认模式,首次运行在 exe 旁建 .ULL/ 与 .minecraft/);
CLI 子命令表漏了 `pack import`;arm64 行里的内部决策编号 D21。124 → 107 行。
2026-11-05 17:54:18 +08:00
AskaEth 494a45fe12 Merge branch 'feat/m127-shotci-make-pixel-guards-actually-r' 2026-11-05 17:39:59 +08:00
AskaEth 02c7910ad5 ci(app-iced): M127 把下载页四条像素守门接进 CI(仅 Windows),缺图在 CI 里从 SKIP 改成失败
- 新增 tests/options_shot/feed.rs:四条断言共用一条「喂图」策略
  · CI(CI 已设置)且 Windows 上缺 ULL_DL_*_SHOT ⇒ 失败并提示「守门未喂图」
  · 非 Windows 上喂了图 ⇒ CI 里也失败(断言按 Segoe UI 标定的行高/墨迹宽度)
  · 本机缺图仍可跳过,但每次都留一句提示
- ci.yml 的 test 作业(Windows 槽):pages_demo 现出 ready700/chips700/compact736/wide1180
  四张图 → 写进 $GITHUB_ENV → cargo test -p unlucky-app-iced --lib options_shot
  出图与断言两步都带 if: runner.os == 'Windows';失败时上传四张图便于复盘
- 顺带订正五处文档数字:main 7 整行 / r1 4 整行 / r2 7 整行,并写清第 8 条墨迹是 hint
  (rows.rs ×3、options_shot/ready.rs、tests/view.rs),不动任何断言期望值
2026-11-05 17:17:43 +08:00
AskaEth 631d4647dc Merge branch 'feat/m125-railfix-chips-tier-crushed-list-and' 2026-11-05 17:05:05 +08:00
AskaEth 0f26ed3753 fix(app-iced): M125 r2 —— chips 档「还没选版本」不再对半切;宽度表换成实测值
r1 复审 p2-1:`TailShare::of` 只判档位,于是 chips 档「刚进页面」(面板只有一行
提示)也被五五分——700×760 下列表 8 个版本只显示 4 个、下半块 ≈160px 空画布,
而修前(main)这一态是 7 整行。修法:判据加**面板形态**,`Hint` 在任何档位都钉死,
`Chips` + `Options`/`Install` 才共享。实测回到 7 整行(与 main 同一水位)。

守门:新增 `options_shot/ready.rs`(`ULL_DL_READY_SHOT`,不设 `ULL_DL_DEMO` 出的
700×760 图):列表区至少读出 6 个版本号。它在 r1 的图上**红**(4 个,原生输出见
mission 报告),在修后图上绿(7 个)。三条旧守门照旧(chips / compact / wide),
chips 与 compact 换修前图仍红。

r1 复审 p2-2:宽度表的实测值改正(同一张 736 图逐项量):
`1.21` 26(余 6)、`1.20` 28(余 4)、`24w` **29**(余 3)、`All versions` 墨迹画到
x=247(裁切边 248,只差 1px)⇒ 桶标签余量是 **3~6px** 而不是 6;「短标签『全部』= 32
零余量」这条论证的对比强度随之写准(比桶标签的 3~6px 更薄)。
`rails/major.rs` 的表格、`options_shot/compact.rs` 的测试文档一并改成实测值;
「墨迹右边界」那条反例也从 216..244 改成实测 216..247。

r1 复审 p3-1:第二级「全部版本」的图标从网格(与第一级同字形同药丸、像素级同形)
换成文档/清单字形 `IconKind::Logs`,理由与「图标集里没有版本/历史字形」写进
`major_shape` 文档。

r1 复审 p3-2:chips 档最多三层滚动(列表 / 面板 / loader 菜单)作为**已知风险**
登记在 `install.rs` 文档里,并写明为什么「把实例名移出滚动区」本轮不做(要拆
`options::section`,动三个文件,而且改完仍无法验证滚轮冒泡——没有通道)。
2026-11-05 16:43:35 +08:00
AskaEth 9c623a6fd3 style(app-iced): M125 拆文件时留下的孤儿文档片段(clippy 的 empty_line_after_doc_comments) 2026-11-05 16:03:10 +08:00
AskaEth 70dea995b7 refactor(app-iced): M125 主版本栏与选项区像素守门各拆一层(守 400 行红线)
两处拆分,只为过 `gate::every_source_file_stays_under_the_line_limit`:

- `download/rails.rs` 435 行 → `rails.rs` 311 + `rails/major.rs` 157:主版本桶那一栏
  (`major_rail` / `major_entries` / `major_shape` / `MajorEntry` / `MajorShape`)搬进子模块,
  `rail_title` 留在父模块(两条栏共用)。`Key::VersionMajorTitle` 的落点跟着搬,
  `tests/i18n.rs` 的死 key 扫描表补上 `rails/major.rs`(漏扫会把活 key 判成死 key)。
- `tests/options_shot.rs` 475 行 → 345 + `options_shot/chips.rs` 75 + `options_shot/compact.rs` 67。

顺带修 `options_shot::bands()` 的一处既有缺口:它按三栏算列表列左缘(112+0+24+0+24 = 160),
chips 档没有侧栏时这个左缘是错的,右缘跟着冲出画面 22px——M125 的 chips 守门正好踩到
「扫描越界读到下一行像素」,`first_rule_y` 因此恒返回 None。现在 chips 档直接取页左缘
(112),三栏三档的数值一个不变(M123 那些 140/140、缝 24 的断言照旧)。

守门实跑(本机,tiny-skia):
- 解 ignore 的 `chips::a_chips_tier_shot_…` 在修后图绿、**修前图红**(实测 18px ≠ 44);
- `compact::a_compact_tier_shot_…` 修后图绿、修前图红(实测 44px 文字行 ≠ 40px 图标按钮);
- chips 那条换深色图同样绿(断言不依赖主题色)。
2026-11-05 16:01:03 +08:00
AskaEth 822f98022d fix(app-iced): M125 chips 档列表不被选项区压成缝;Compact 档主版本首项不再裁字
两条缺陷都是 M123 取证时在真图上拍出来的,塔台另行核过图:

缺陷 1(medium):chips 档(窗口 < 736,可用宽 < 600)里选项区按内容高
≈316px 钉在列表下面,列表只剩 18px(0.4 行,行高 44)——版本号读不出来、
换不了版本。修法:`rows::TailShare` 两档,chips 档改成「列表与面板各吃一半、
面板自己可滚」(列表拿到 ≈182px = 4 整行,面板顶端那个实心「安装」与五个
loader chip 仍在屏内);三栏三档照旧钉死(760 高下列表还有 3 整行、面板
整块在屏内,两样都对)。判据按档位而不是按高度:页面模型里只有 window_width,
窗口高度没接进页面(边界写进 TailShare 文档与报告)。

缺陷 2(medium):Compact 档主版本栏宽 64,`ListItem` 两侧内边距各 16 ⇒ 文本格
32,首项「All versions」要 ≈84 ⇒ 图上只剩「All v」;中文「全部版本」= 4 全角字
= 64,32 里只剩「全部」且正好顶到裁切边。修法:图标条档的「全部版本」不画文字,
改画与本页第一级那个「全部」同一个网格图标(40×40 Tonal 药丸 + 全称 tooltip);
桶标签(1.21/24w,实测墨迹 26)放得下,照旧画原文。`rails.rs` 那条「64 宽里
1.21 仍完整」的注释改成实测宽度表,并写清它对首项不成立。

顺带:列表与尾部面板之间补上分隔线(三态一致),`rows::content` 与两个装配点
按档位传 `TailShare`。

守门:
- `tests/options_shot.rs` 解掉 M123 那条 `#[ignore]`(期望值一个字没改,只加
  「列表区 ≥ 3 整行」与「面板仍在屏内、没被底边切」两条);
- 新增 `a_compact_tier_shot_draws_an_icon_for_the_first_major_entry`:缺陷 2 的
  像素守门(首项是 40×40 居中药丸,不是 64×44 通栏文字行)。复审建议的
  「墨迹右边界 ≤ 栏右内边界」在坏图上也是绿的(修前墨迹 216..244,边界 248),
  所以改钉形状,理由写在测试文档里;
- `tests/rails.rs`:中文与英文都走 Icon 分支、以及 32px 文本格的宽度账
  (「全部版本」= 64 > 32 是精确值,「全部」= 32 零余量所以不选短标签);
- `tests/view.rs`:档位 → TailShare 的决策面 + 两个装配点都按档位传。

未改产品文案、未改窗口最小宽、未动 M123 的任何断言期望值。
2026-11-05 15:48:57 +08:00
AskaEth df0e98d811 Merge branch 'feat/m123-evidshot-close-three-acceptance-evi' 2026-11-05 15:46:28 +08:00
AskaEth 4a41263357 test(app-iced): M123 安装选项区 × 三栏同屏的宽度与行裁剪守门
安装选项区(`download/options`)与三栏(`download/rails`)各自都有断言,
但它们**同屏**这件事从来没验过:选项区一高列表就矮,窗口一窄两条栏一收列表
就窄,这两种挤压都不会让任何既有断言变红。

- `tests/options_screen.rs`:七档宽度(1180/1024/900/880/812/736/700)× 三种
  选项态(原版 / Fabric / NeoForge 不兼容)。三栏数字按 `layout/rails.rs` 的
  数值表逐格钉死;列表列在每一档都 ≥ 424(行内四样仍在一行);两条栏与选项
  面板在同一份模型里、树能一次构建。
- `tests/options_shot.rs`:真 PNG 上的像素断言(需 `ULL_DL_OPTIONS_SHOT`)。
  两栏同屏且像素宽度 = 裁决值 140/140、栏间 24 的缝真空着、第一行版本行整行
  44px、选项区最后一行墨迹离底边还有余量。另有一条 `#[ignore]` 的 Chips 档
  回归:700×760 下列表被压成 18px(现状红,缺陷已单列报告与 finding)。

出图(38 张)在 `DevDocs/artifacts/m123/`(git 忽略,不入库);旧的
`DevDocs/artifacts/m109/`(内容库页早于 M108 改名,导航轨写的是 `Download`)
已按任务 ② 删除,内容库 12 态在 m123 里按当前 tip 重出。

不动产品行为:本提交只有测试与模块声明。
2026-11-05 15:46:26 +08:00
AskaEth c25e85b461 Merge branch 'feat/m124-cliloader-expose-parsed-loader-in-i' 2026-11-05 15:37:02 +08:00
AskaEth 9ead4ae892 feat(cli): M124 instances list --json 补 loader 字段,只加不改
`ull instances list` 的人读输出历来只逐字打 `version_id`,脚本/agent 要判断
「这是不是 Fabric」只能自己猜 id 形状——正是 M122 收敛掉的第二份真相。本提交只补
输出面:

- 每行实例加 `loader: {"kind": <稳定 id>, "version": <版本段>}`,解析一律调
  `unlucky_core::game::InstanceLoader::parse`(M122 的全仓唯一入口),CLI 里没有
  任何新的 id 匹配;CLI 只做「种类 → 稳定 id」的穷尽翻译。
- 原版恒为 `{"kind":"vanilla","version":""}`:键恒存在、不为 null,脚本不必判空。
- `kind` 是识别口径(7 值,含只能继承不能安装的 optifine/liteloader),不是
  `install --loader` 的安装口径(5 值)——这条连同「vanilla 只表示 id 里没有加载器
  痕迹」一起写进模块文档与 `ull help instances`(`--json` 是事实对外契约,字段说明
  跟着契约走)。
- 向后兼容:`data.instances[]` 原有七个键一个没动,人类可读输出逐字不变。
- 测试(`commands::instances::tests`,走真入口 `crate::run`):四家自家 id + 原版 +
  两家第三方前缀的真值表、原版非 null 的反向锚、人读输出与旧字段不变的兼容锚、
  help 里有契约说明的文档锚。
2026-11-05 15:23:37 +08:00
AskaEth e4b819a4cc Merge branch 'feat/m122-loaderid-fix-loader-from-id-to-reco' 2026-11-05 15:06:50 +08:00
AskaEth 081ce65596 fix(core): M122 版本 id → 加载器只留一份解析,并认全自家 id 口径
`presenter::host::game::loader_from_id` 只认 `fabric-loader-…` / `quilt-loader-…`,
而本启动器自己生成的 id 是 `<mc>-<kind>-<loader>`(`1.20.4-fabric-0.16.5` /
`1.20.4-quilt-0.27.0`),于是刚装好的 Fabric / Quilt 实例在实例页显示成「原版」。

- 新增 `core::game::loader`:`InstanceLoader::parse` 是全仓唯一的 id 解析入口
  (按 `-` 切段、段前缀匹配标记、跳过独立的 `loader` 段、砍老 HMCL 的 MC 尾巴)。
  覆盖自家四种口径(生成端 `addon::loader::LoaderKind::version_id`)、老 HMCL / CF
  口径、OptiFine / LiteLoader 第三方前缀;标记按整段前缀匹配,不再用「包含子串」。
- presenter 只留 DTO 翻译:`dto_kind` 穷尽 match(`dto/` 里仍不许出现 core 类型)。
- 测试:真值表**从生成端取 id**,反向锚(普通版本 id 不被认成加载器),映射单射,
  以及端到端快照(`instances()` 的磁贴数据对四个加载器各自的版本号与徽标)。
2026-11-05 14:46:22 +08:00
AskaEth 829bd9043c Merge branch 'feat/m121-verbump-0-2-0-workspace-version-and' 2026-11-05 14:08:39 +08:00
AskaEth bf6b0c4126 build(release): workspace 版本 0.1.0 → 0.2.0,README 的 tag 事实改成 v0.2.0 口径
- Cargo.toml [workspace.package] version = 0.2.0;Cargo.lock 由 cargo 重新解出一致
  (只有 8 个 unlucky-* 成员条目变动,第三方版本一字未动)
- 各 crate 的 `unlucky-*` path 依赖 `version = "0.1"` 同步提到 "0.2",否则工作区
  根本解析不出来(cargo metadata 会报 `failed to select a version for
  unlucky-core = "^0.1"`);crates/app 虽被 exclude、cargo 不读,也一并跟上避免将来
  重新纳入时立刻解析失败
- core::launch::LauncherConfig 兜底的 launcher_version 改成 env!("CARGO_PKG_VERSION"),
  不再是把版本写死一份(CLI / presenter 本来就这么写)
- README.md / README.zh-CN.md:删掉「v0.1.0 是目前唯一的 tag / the only tagged release」
  这类随 v0.2.0 发布即变假的陈述,改写成「v0.1.0 是最后一个 Slint 版 tag,v0.2.0 是
  重写后的第一个 tag」;所有讲 v0.1.0 资产 / Slint Royalty-Free 义务的历史记录原样保留
- packaging/README.md:命名与校验示例跟当前 tag 走(v0.2.0);§7 实测记录里的 0.1.0
  输出加一句「属实测记录、不回填」,并把过期的「四个 workspace member」改成「任何一个」
2026-11-05 14:08:37 +08:00
AskaEth 7235cf08e4 Merge branch 'feat/m120-insti18n-instance-page-and-stage-la' 2026-11-05 13:36:55 +08:00
AskaEth d0a8f68607 docs(m120): 页面模块的守门说明改成白名单口径 2026-11-05 13:36:45 +08:00
AskaEth f86be2bd38 fix(app-iced): M120 r1 —— 徽标判据改回「类型是原版」;旧串黑名单翻成白名单
r1 复审 p2-1(真回归,我引入的):`loader_badge` 把「非原版 + 空版本号」也送进了
`Key::LoaderVanilla`。而 `host::game::loader_from_id("1.20.1-forge")` 这类 id 取不到版本段
(它按 `forge-` 找,id 却是 `-forge` 结尾),`LoaderInfo::badge()` 对它们给的正是品牌名
`Forge` / `NeoForge` / `OptiFine` —— 于是这三个显示成「原版 / Vanilla」。

判据改成「**加载器类型本身是原版** ⇒ 过词表,其余交给 `badge()`」:
- 修的是判据,不是加分支:函数注释本来就写着「只有原版这一支要过词表」,代码与注释相矛盾。
- 新回归测试 `the_loader_badge_keeps_the_brand_when_the_version_is_missing`:
  `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 三条 id 各断言列表副文案与 LOADER
  卡的值都是品牌名(中英两套语言各跑一遍,品牌名两套语言相同)。
  变异实测:把判据改回旧写法 ⇒ 立刻红(`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`,
  与复审给的实证逐字相同)。

r1 复审 p2-2(不阻塞,按塔台要求顺手做):源码反扫从**黑名单 29 条旧串**翻成**白名单**。
- 先数过:五个页面源码里一共 13 个字面量,其中带 ASCII 单词的只有 6 条,全是专有名词 /
  技术标识 / 占位符名 / 族名前缀(`{date} {time} UTC` / `isolated` / `shared` / `pid` /
  `reason` / `Instances`),逐条写了理由。
- 判据反过来之后,往页面里**新写**一句英文也会红(复审抓到的哑点)——变异实测:
  写进 `"Ready"` ⇒ 红;写回 `"ALL INSTANCES"` ⇒ 红。
- 反向锚保留:扫描器必须真扫到 ≥6 条、`Texts::of(` / `Header::new(texts.title())` /
  `Key::Instances` / `Key::InstancesStage` 必须在用(防止断言空转)。
2026-11-05 13:36:45 +08:00
AskaEth 1a4e1fee12 feat(app-iced): M120 实例页与启动阶段标签跟着界面语言走
来源:M106 r1 复审点名——中文机器上首屏(实例页)仍是英中混排(页头 / New instance /
Import / ALL INSTANCES / VERSION / Present on disk / Running · pid / Launch failed)。
用户的原话是「我系统语言是中文 为什么打开软件是英文」。

改了什么
- i18n:catalog/instances.rs 从 6 条死 key 补到 34 条(页头 / 列表列头 / 详情动作 /
  窄窗说明 / 元信息标签与值 / 隔离档位 / 运行状态 / 四个启动阶段)。族在装配链第 2 位,
  新 key 追加在本族末尾,golden 对应位置插入(不是文件末尾追加)。
- 页面:新增 pages/instances/text.rs(Texts)——页面唯一的取词入口,实例页里不再有
  任何可见英文字面量;detail / demo 也改从它取。
- 阶段标签:view/instances.rs 的 Running / Launch failed / 四阶段全部走目录;
  **数字与分隔符不随语言变形**(运行中 · pid 1234 与 Running · pid 1234 只换前半句)。
- 顺手修掉两处「语言反向泄漏」:原版徽标此前直接画 presenter 的中文「原版」(英文界面
  会漏中文),现在走 LoaderVanilla;隔离档位 isolated/shared 也从 core 的稳定 id 换成人话。
- 删掉 InstancesLastPlayed:M117 加的整句 key,从未接线,形状与「标签 + 值」的元信息卡
  不符,由 InstancesMetaLastPlayed + InstancesNeverPlayed 取代。

守门(pages/instances/tests/i18n.rs + app/view/tests.rs)
- 中文态无 ASCII 英文单词(品牌 Java / 技术标识 pid 白名单);
- 每一条 Instances* 的两套译文必须不同(切语言时真的变);
- Running 的数字与 · pid 分隔符在两套语言里逐字保留;
- 页面源码里 29 条旧英文基线不许回来;每条 key 都必须被页面引用(死 key)。

出图:DevDocs/artifacts/m120/01~12(有实例 / 空态 / 运行中 × 浅深 × 中英)。
2026-11-05 13:36:44 +08:00
AskaEth 2f491c31f9 refactor(app-iced): M120 把实例页的展示转换从 view.rs 拆出去(纯搬移)
view.rs 是「快照 → 视图模型」的唯一入口,实例页那一段(列表副文案 / 运行状态 /
元信息卡 / 时间戳)占了它近一半。M120 要在这批文案上接 i18n,先按功能拆成
view/instances.rs(account_page / logs / rail 是同一个先例),本次一字未改行为。

唯一的非搬移改动:view/tests.rs 显式 import InstanceRuntime / LaunchStage
(它们原来是从 view.rs 的 use 里顺带借到的,拆分后不再可见)。
2026-11-05 13:36:43 +08:00
AskaEth 6a5c54102e build(packaging): 产物改名成 名字_版本号_平台_架构
用户令:产物名不带实现细节字样(不出现 iced),格式统一成
`<名字>_<版本号>_<平台>_<架构>`,下划线分段。

- make_artifacts.py 新增 asset_stem() 作为唯一真相源;Windows 裸 exe 那条
  路径原来还硬写着旧格式,一并改掉
- release.yml 的 sha256sum 与 assets glob 从 unlucky-launcher-* 改
  unlucky-launcher_*(不改会因匹配不到而漏传产物)
- packaging/README.md 矩阵六行同步,并记下:Linux/macOS 仍为 tar.gz,
  理由是许可文本随包 + tar 保留 755 可执行位

本地已跑通:unlucky-launcher_0.1.0_windows_x64.exe 14.44 MiB +
ull_0.1.0_windows_x64.exe 6.18 MiB + SHA256SUMS.txt。
2026-11-05 11:54:18 +08:00
AskaEth 43ab6c0a83 ci(guards): README 不适用 400 行红线
README 是对外介绍,不是可拆分的模块——拆成多份反而更难读。用户令
(2026-11-05):不用 400 行压它,但要保证介绍不臃肿、逻辑完备。

豁免范围只限仓库根的 README.md / README.<lang>.md;docs 下的长文
仍按模块拆分,其余规则不变。
2026-11-05 11:44:48 +08:00
AskaEth 1315b37f62 docs(readme): 状态段改成当前事实,模块地图补齐三个 crate
状态段原来停在「iced 版每个页面都是占位」——早就不成立了。改成:
十四页功能已齐、文案跟随系统语言、外观三件套、整合包导入导出与安装选项,
以及明确列出「还没做」的四项(安装器与自动更新、macOS 定型复核、
逐文件冲突策略、原生文件选择框)。

模块地图补上 crates/presenter、crates/i18n、crates/palette-extract,
并写明分层:app-iced → presenter → core/agent,design 与 i18n 不得反向依赖渲染框架。
2026-11-05 11:36:39 +08:00
AskaEth 4a212760b6 Merge branch 'feat/m119-apiscan-wire-presenter-accounts-and' 2026-11-05 11:09:56 +08:00
AskaEth 0e48ea15b8 fix(app-iced): M119 r2 —— 四处「模拟」文案全由 simulated 驱动;确认条不再替用户 arm 键盘
复审 r2 的两条 p2 都是「上一轮各差一步」:字段/焦点都真的做了,但**另有三处没接上**、
**键盘闸是代码自己打开的**。

## P2-1:四处文案只认一个开关

r1 只有标题胶囊由 `simulated` 驱动;副标题 / 输入框占位符 / 空态正文是**无条件取词**,
而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后这三句会**反向**留着
(`simulated == false` 时界面还在声明「没接真实模型」)。而且没有一条测试钉得住那个声明。

现在:

- 新增 `pages/agent::Disclaimer { simulated, subtitle, placeholder, empty_body }`,
  `Disclaimer::of(catalog, simulated)` 一次算好;`Conversation.simulated: bool` 换成
  `Conversation.note: Disclaimer`,header / composer / transcript 三处各自从它取词——
  **视图里没有第二种取词路径**;
- i18n 配成对:模拟态用既有的 `AgentSubtitle` / `AgentPlaceholder` / `AgentEmptyBody`,
  真实态新增 `AgentSubtitleReal` / `AgentPlaceholderReal` / `AgentEmptyBodyReal`(中性话)。
  (没有把三句改成中性话:那句告知本身有价值,接真模型后它才该消失。)
- 证据:`crates/app-iced/src/pages/agent/tests/disclaimer.rs`
  —— `all_four_disclaimers_appear_when_the_backend_is_simulated`(四处都提模拟)、
  `no_disclaimer_survives_when_a_real_backend_is_wired`(四处都不提,且不许用空串糊)、
  `the_two_states_use_different_keys`(两态不能共用同一个 key)。

**golden.rs 的 400 行边界**:+3 条会破线。按「按功能拆、不压注释」处理——把这张表的
**说明文档**(35 行)原样搬到 `catalog.rs` 里 `mod golden;` 的文档上(一个字的说明都没删),
`golden.rs` 只留表数据 + 3 行指针。现在 `golden.rs` 374 行、`catalog.rs` 328 行。

## P2-2:键盘闸不许由代码替用户打开

r1 在 `sync_confirm_focus`(`on_agent` 的无条件尾巴,`Events` 又由心跳驱动)里顺手
`keyboard_mode = true`——于是**只要冒出待确认,全局键盘闸就开了**;用户发完那句话接着打下一句,
第一个空格就成了「批准写操作」。复审用临时用例复现过(`rail.confirm` 变 `None`)。

现在:

- `sync_confirm_focus` **只挪焦点**到「允许」,不再碰 `keyboard_mode`(字段文档里写清了为什么);
- 键盘用户的表态是 `Tab`:`AgentUi::step_focus` 加了一条 **arming 规则**——待确认、焦点已在
  确认条上、但键盘闸还关着时,这一次 `Tab` **只点亮焦点环、不移动焦点**(否则第一次 Tab 会把
  「允许」挪到「拒绝」,用户按 Enter 就是反着裁决);之后再 Tab 才逐格走;
- 反向回归:`a_stray_space_or_enter_never_approves_a_pending_write`(打字 → 空格 / 回车都不得裁决,
  审计也不许留);`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` 改成
  「确认到达时未 armed → Tab 点亮 → Enter 允许;第二轮 Tab·Tab 到拒绝 → Space」。

## 出图

`DevDocs/artifacts/m119/` 18 张全部重出(新增取证夹具 `ULL_AGENT_FIXTURE=armed`:
跑一轮真提交后再按一次 Tab,专供「焦点环可见」那张图):

- `08-agent-simulated-{light,dark}`:`simulated = true` 下四处「模拟」同时可见;
- `09-agent-confirm-focus-{light,dark}`:**按过 Tab 之后**的确认条(焦点环在「允许」上);
- `05-agent-confirm-{light,dark}`:确认条刚出现、**还没 arm**(无焦点环)——与 09 成对,
  正好把 r2 的行为差异拍下来。

## 门禁(真实退出码,最终树)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(605 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2281 passed / 0 failed / 11 ignored)|

全树受检文件无 >400(`golden.rs` 374、`catalog.rs` 328、`pages/agent/mod.rs` 371)。
2026-11-05 10:43:22 +08:00
AskaEth a22baebe70 fix(app-iced): M119 r1 —— 常驻「模拟」标识 + 确认条的键盘通路
复审 r1 的两条 p2,都是「说成真的、其实是假的」与「能走进死路」这个级别的问题。

## P2-1:界面必须自己说清「这是模拟」

原判:生产默认后端是**脚本化 rig**(`ULL_AGENT_SCRIPT` 的 6 种剧情),而「后端未接 / 模型还没
接上」两句诚实文案被我改成了中性话,理由写着「由端点标签说」——**那条端点标签在壳里根本不存在**。
于是默认配置下用户敲一句话得到一段像是模型编的回答。判据:没读过代码的用户,打开右栏、敲一句话,
必须能自己看出来这不是真模型(不接受 tooltip、不接受藏在设置里)。

修法(①恢复诚实文案 + ②常驻可见标记,并用):

| 位置 | 文案 / 形态 | 谁给的 |
|---|---|---|
| 标题右侧**常驻胶囊**(`SurfaceVariant` tonal)| `Key::AgentSimulated`(Simulated / 模拟)| `AgentHost::is_simulated()` → `Session.simulated` → 视图 |
| 标题下副标题 | 「模拟助手 · 还没接真实模型」 | `Key::AgentSubtitle`(原本是「未接」被改成中性的「会话」,现在写回来且更明确)|
| 空会话正文 | 「…当前是模拟助手,没有接真实模型。」 | `Key::AgentEmptyBody` |
| 输入框占位符 | 「给模拟助手发消息…」 | `Key::AgentPlaceholder` |

**标记不是硬编码在视图里**:`AgentHost.simulated` 是事实字段(今天 transport 只有 rig ⇒ 恒 true)。
接真 `AgentTransport` 那天把它传 `false`,四个位置的「模拟」措辞会一起消失——这是为什么用字段而不是
写死一句话。

## P2-2:确认条必须有键盘通路

原判:Allow/Deny 只有鼠标路径 ⇒ 键盘用户在写操作确认处**永久卡死**(`busy` 锁发送、
`Stop` 走主后端、清不掉 presenter 的 pending confirm)。

- 焦点环多两格:`Focus::ConfirmAllow` / `Focus::ConfirmDeny`,**只在有待确认时**存在
  (鼠标用户不会 Tab 到不存在的按钮);
- 待确认出现 ⇒ `sync_confirm_focus()` 把焦点直接落到「允许」并**打开键盘巡航**(阻塞态主动亮环),
  消失 ⇒ 焦点收回输入框、巡航关闭;
- `Enter` 与 `Space` 都映射到 `Activate`(`Space` 不抢输入框:`keyboard_mode` 那道闸在
  鼠标点进输入框时是关的);
- 焦点环不是 10% 状态层——在**实心主色**按钮上那层几乎看不见(出图实测过一版就是没有)。
  改成 2dp `Primary` 描边 + 3px 留白,两个按钮恒定加这层 padding,所以焦点变化不移动布局。

测试(`crates/app-iced/src/app/update/agent/tests.rs`):
`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` —— 全程只有键盘消息:
确认到达后断言 `focus == ConfirmAllow && keyboard_mode`,发一条 `KeyPressed(Enter)` 完成允许;
第二轮 `Tab`(走到 Deny)+ `KeyPressed(Space)` 完成拒绝;两轮各记一条审计、焦点都收回 Composer。
另有 `the_default_backend_reports_itself_as_simulated` 钉住 P2-1 的事实来源。

## 视图入参顺带收成结构体

`docked`/`page` 本来是 6 个形参(`ui, agent, rail, failure, catalog, width`),这一轮还要加
`simulated` ⇒ 7 个。按塔台登记的可维护性口径改成 `Session { agent, rail, failure, simulated }`:
两个宿主各 3 个形参,以后再加展示事实只改 `Session`。

## 出图(18 张,浅深各一)

`DevDocs/artifacts/m119/`:新增 `08-agent-simulated-*`(空会话 + 四处「模拟」措辞)与
`09-agent-confirm-focus-*`(确认条 + 焦点环落在「允许」);其余 7 组全部重出(文案与焦点环都变了)。

## 门禁(真实退出码)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(601 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2277 passed / 0 failed / 11 ignored)|

`golden.rs` 400 行(+1 条 `AgentSimulated` 正好顶线);全树受检文件无 >400。
2026-11-05 09:17:50 +08:00
AskaEth b5e96c61ac fix(app-iced): M119 出图抓到的两处:右栏文案没跟 lang 走、确认条文字被压成竖缝
两张验收图(`DevDocs/artifacts/m119/`)暴露的问题,都是测试抓不住、只有图能看出来的那类:

1. **同屏两语**:`pages/agent/mod.rs` 里还留着 M104 的 `const LANG: Lang = Lang::En`,所以右栏的
   标题/按钮恒英文,而 M119 新加的失败横幅走 `App::lang()`(这台机器是 zh-CN)⇒ 一屏里
   「Assistant / Collapse」和「连不上助手后端」并排。现在删掉那个常量,`docked`/`page` 收一个
   `Catalog` 形参,由 `App::lang().catalog()` 给——与设置页/账户页同一条口径(M106 的真值源)。

2. **确认条没有字**:`chip + 文字 + 两个按钮` 挤在同一条 `Row` 里,而右栗只有 280~360:320 宽下
   文字列被压成一条竖缝,标题整段看不见(图 05 第一版就是这个样子)。改成文字行与动作**分行**,
   动作右对齐——窄栏里才是可读的 M3 告警形态。

顺带:`Script::WriteOp` 补一句增量,免得那条助手气泡是空壳(出图更接近真后端)。

`cargo test -p unlucky-app-iced --lib`:599 passed / 0 failed;14 张图重出后逐张看过。
2026-11-05 08:11:14 +08:00
AskaEth d4fd675935 feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳
M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。

## 接线层(新增 `crates/app-iced/src/presenter/`)

| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |

物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。

## 事件回流

`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。

## 会话内容只留一份

`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。

## 视图

- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
  **写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。

## 未读口径

presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。

## 账户页

账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。

## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)

- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
  确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
  M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。

顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
2026-11-05 08:02:58 +08:00
AskaEth 6505493d4e feat(i18n): M119 追加 6 条 AI 助手错误/确认文案,并订正两条已成假话的译文
接线(M119)之后右栏才第一次有「失败态」与「写操作确认」这两块真 UI,而 catalog 里
一条对应的 key 都没有——任务书要求「失败必须显示**具名**错误」,没有键就只能硬编码,
和 M106 立的「界面文案一律走 catalog + lang」直接冲突。所以这批条目是修不成就做不成。

新增(按金表规矩追加在 `agent_entries` 族尾、`golden.rs` 同序追加):

| key | 用途 |
|---|---|
| `AgentErrorTitle` | 失败横幅标题 |
| `AgentErrorUnavailable` | AI 端点不可达(原因码 `unreachable`) |
| `AgentErrorNotImplemented` | 该路由还没接线(原因码 `not_implemented`) |
| `AgentConfirmRequest` | 确认条的句子(`{summary}` = presenter 已脱敏的请求摘要) |
| `AgentConfirmAllow` / `AgentConfirmDeny` | 确认条两个按钮 |

订正两条**已成假话**的译文(接线前它们陈述「后端未接」):

- `AgentSubtitle`:`Conversation shell · no backend yet` → `Conversation`
- `AgentEmptyBody`:去掉 `No model is wired up yet.` 一句

真接的是什么由端点标签与失败横幅说,不在标题下重复。`golden.rs` 现在 399 行(+6 行后仍 ≤400)。
2026-11-05 08:02:49 +08:00
AskaEth 3c61a2397e refactor(app-iced): 拆 state.rs(400 行) 与 update.rs(399 行) 为功能子文件
两个文件都贴着 400 行硬线、零余量,M119 要往里加字段与一行 drain。**这一个提交只搬不改**
(下一次提交才是改动),搬的位置按功能切:

| 原位置 | 现在 | 内容 |
|---|---|---|
| `app/state.rs` | `state.rs`(172) | 只剩 crate 文档 + 常量 + `struct App` 的字段与注释 |
| 同上 | `state/construct.rs` | 两个构造器 + 字段初始化表 |
| 同上 | `state/refresh.rs` | `refresh_view`(快照 → 视图模型的唯一转换点) |
| 同上 | `state/theme.rs` | `Ui` 重算 / 主题档 / `has_screenshot` / `ui()` |
| `app/update.rs` | `update/clock.rs` | 推进一拍 / 派发 / 抽干事件 / 自动选中 |

一处**非字面**的改动:搬进子模块之后 `pub(super)` 的语义从「crate::app」变成「crate::app::state」,
所以这些方法改成 `pub(in crate::app)`——可见范围与搬家前逐字相同,只是写法得跟着模块路径走。
除此之外照抄原文(含原有注释与文档)。

搬完 `cargo check -p unlucky-app-iced --all-targets` 零 error 零 warning。
2026-11-05 08:02:45 +08:00
AskaEth 1b77a36d3d Merge branch 'feat/m117-msacfg-accurate-wording-and-local-c' 2026-11-05 07:31:57 +08:00
AskaEth 8780f26308 fix(auth): M117 微软登录缺 client id 说「未配置」而不是「未支持」
用户实跑 debug 二进制看到「本期未支持」,误以为功能没做;实际是构建/启动时
没注入 Azure 公共客户端 id(登录三轨已实现、白名单已批、真机验签过)。

- core/auth/microsoft/client_id.rs(新):id 的来源判定(env > build,空白值不算配)、
  `ClientIdStatus`/`log_line()` 那行 `[auth] msa client_id=… source=…`、以及
  「未配置 + 怎么配(含可照抄命令)」的共用文案 `MISSING_CLIENT_ID_MESSAGE`。零网络。
- core/auth/error.rs:`AuthError::Unsupported` 不再统一拼「本期未支持:」前缀——该变体
  全仓唯一构造点就是缺 client id,硬拼前缀让「没配置」被读成「没实现」。`code()` 仍是
  `unsupported`,枚举形状与其它分支不动(tower 批的最小改动)。
- core/auth/microsoft/mod.rs:`from_env()` 走 `resolve_client_id()`,失败措辞取自上面那条
  文案,且在网络之前早退(连 HTTP 客户端都不建);导出 `client_id_status()`。
- cli:`map_auth_error` 的 Unsupported hint 给两条能照抄的命令;`accounts` 帮助说清
  「不是功能缺失」与两种注入方式;新增 `msa_client_id_log_line()` 给 GUI 侧出面。
- app-iced:`on_boot` 打一行 `[auth] msa client_id=… source=…`(判据来自 core,不重算)。
- i18n:`LaunchMsaNotConfigured` 中英各一条,golden 按 `Key::ALL` 顺序在
  `LaunchFailedWithCode` 之后追加一行。

本地便利路径(gitignored,随报告交付):`DevDocs/ops/ull-msa-run.sh` 读本地 id 文件
或 `azure-msa-app.md` 注入环境变量再起 GUI,脚本本身不含 id。
2026-11-05 07:31:55 +08:00
AskaEth 7ddcec4941 Merge branch 'feat/m116-winround-window-corner-policy-with' 2026-11-05 07:28:03 +08:00
AskaEth dc1fa8d082 fix(app-iced): M116 r5 —— 圆角状态从「换色正文」升级成 tonal notice 容器
r5 复审的第二轮意见(改前那条是一行紫灰正文,与说明段只差色相、中间没有间距,读起来还是
「一段话的前两句」)。按 M3 惯例改成**容器**:

- 新增 `pages/appearance/mod.rs::notice`:`Role::SurfaceContainerHighest` 底 +
  `ShapeScale::Small` 圆角 + 左右 `Space::X2` / 上下 `Space::X1` 内边距,文字
  `Role::OnSurfaceVariant` + 加粗。**只用既有角色与间距档位**,不新增令牌、不引入图标。
  (放在模块根而不是 `section.rs`:后者已 395 行,400 行是硬顶。)
- 状态行与下面的说明段之间再补 `Space::X1`(列间距 X1 + 这里 X1 = X2 总量),
  让它与说明段在**形态**上分开,而不是靠色相。
- 出现条件不变:仅「跟随系统 + α<1」;档位照常显示用户选的值。
- 像素实测(浅色 α=85,1180×760 / 100% DPI):容器底色 `(216,228,239)`、卡片底
  `(235,245,255)`;容器内文字 `(58,72,83)`;容器 y=372..411,说明段首行 y=428
  ⇒ ink 间距 16px(布局 8px + 行高留白);α=100 两张图该底色出现次数为 0。

门禁:fmt --check 0 / `cargo test -p unlucky-app-iced --lib -j 2` 0(475 passed)。
2026-11-05 06:35:21 +08:00
AskaEth 67deb103b0 fix(app-iced): M116 r5 —— 圆角状态行独立成「状态」(换色加粗)+ 说明段去掉重复并压短
r5 复审 p2-1(图上)、p2-2(证据文档两处)。

## P2-1:状态行要被当成「状态」看见
- 说明段 `SettingsAppearanceCornerHint` 里那句 α 说明**删掉**,只留平台分工(Win11/Server 2025
  请求系统圆角、Win10 直角、macOS/Linux 看桌面合成器、我们不自绘)——英文从约 470 字符 /
  7 行压到 3 行(1180 宽、100% DPI 实测)。
- `SettingsAppearanceCornerSuspended` 改成**只由状态行说**,且状态行不再是「说明段的续行」:
  紧贴在档位行下面,颜色换成设计令牌 `Role::Tertiary`、字重加粗(`note_line` 保持原形,
  新增 4 行的 `state_line` 助手)。档位本身照常显示用户选的值,不改成「直角」。
- 出图复核(四张,浅/深 × α=100/85):α=85 两张能看到一条**加粗的第三色**状态行,
  α=100 两张没有;状态行墨色由 `(63,76,87)`(与说明段同色)变为 `(116,108,140)`(Tertiary)。

## P2-2:证据文档两处
- `DevDocs/artifacts/m116/probe-table.md` 的 `readback` 归因:`0x04000000`=`WS_CLIPSIBLINGS`
  是 **`CreateWindowExW` 自己补的**(建完就在),`ShowWindow` **只补** `0x10000000`=`WS_VISIBLE`;
  按 r5 复审的最小实验写清(req `0x00CF0000` → before `0x04CF0000` → after `0x14CF0000`),
  读数一行未动。
- `corner/tests.rs` 里「本 mission 没构建 bin」改成指向 2026-11-04 的两次真跑记录
  (`DevDocs/artifacts/m116/README.md` 的 `[corner] boot …` 两行)。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-116-r2`,`-j 2`,跑完已删):
fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(475 passed)/ `-p unlucky-i18n --lib` 0(24 passed)。
2026-11-05 06:35:21 +08:00
AskaEth 91f723a019 docs(app-iced): M116 r4 —— 日志/模板示例改用真进程实测值(frame=overlapped)
真进程两跑(`--software-renderer`,同一个 bin,只改整窗 α)拿到:
  α=1.0 → … frame=overlapped layered=off
  α<1   → … frame=overlapped layered=on suppressed_by=window_alpha
所以把两处仍拿 `frame=popup` 当示例的文档改成实测值:
- `pages/appearance/mod.rs` 的取证模板:`frame=overlapped layered=on … suppressed_by=window_alpha`,
  并注明真进程两条在 `DevDocs/artifacts/m116/README.md`、`frame=` 恒为 `overlapped`/`other`。
- `transparency/dwm.rs` 的 `style_summary` 示例:同上,并点明 `popup` 只属于带 owner 的弹出窗。

注释级改动,行为与断言零改动。
2026-11-05 06:35:20 +08:00
AskaEth 1cb36da1e2 docs(presenter): M116 r4 P3 —— settings_bridge 的「三个键」漂移对齐到四个字段
AppearanceBase 自 M116 起有四个字段(整窗 α / 标题栏三态 / 配色来源 / 窗口圆角),
load_base 的文档与测试名还写着「三个键」。只改注释与测试函数名,行为零改动。
2026-11-05 06:35:19 +08:00
AskaEth abc51a957b fix(app-iced): M116 r4/r5 —— 产品窗口不是 WS_POPUP(真凶是整窗 α 的 layered)+ 裁定 A 落地
r4 复审 p1(证据链前提错误)+ 裁定 A(D47:整窗透明度优先于 DWM 圆角)。

## 事实订正(四处错前提 + 两处残留断言)
- `probe.rs`:删掉「winit 在 decorations=false 下给 WS_POPUP」与「popup+layered = 本产品
  窗口的完整形状」。产品样式按 winit `window_state.rs::to_window_styles` 的来源写成
  `PRODUCT_STYLE`(`WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU|WS_THICKFRAME|
  WS_MINIMIZEBOX|WS_MAXIMIZEBOX` = `0x04CF0000`,**不含 WS_POPUP**),并新增
  `product` / `product+alpha` 两行(五行实验)。裸 popup 行改名为 `probe:popup*`,
  明确标注「对照,不是产品形状」。
- `corner.rs`:平台矩阵第 ③ 条改成「调整带只给带 WS_CAPTION|WS_THICKFRAME 的窗口;
  裸 popup 假形状上的 `+0,+0` 不能解释产品」;补第 ④ 条「整窗 α 会关掉圆角(裁定 A)」
  并说明日志里 `pref=` 有值即证明请求确实发出。
- `app/update/appearance.rs` 与 `transparency/{dwm.rs,transparency.rs}` 的注释:把「第一嫌疑是
  无边框 WS_POPUP」改成「整窗 α 引入的 WS_EX_LAYERED」;`frame=` 的真进程取值写明是
  `overlapped`(关 min/max 则 `other`),**永远不会是 popup**。
- 实测复核(`m116_corner_controlled_experiment`,本机 Server 2025 / RDP 断开):
  `product` 行 `Δframe = +0,-7`(与 `decorated` 同格),`product+alpha` 也是 `+0,-7`
  ⇒ 分层位不改变那条带子,`Δframe` 不能解释圆角失效。

## 裁定 A 落地
- 日志新增中立观测栏 `suppressed_by=window_alpha`(`format_line` 多一个参数;进 `CornerApplied`
  指纹,α 1.0↔<1 各触发一次重下/一行日志)。只陈述「分层位来自我们的整窗 α」。
- 设置页:外观说明句写明「开启整窗透明度时 Windows 不会给窗口圆角,回到 100% 自动恢复」;
  新增 `SettingsAppearanceCornerSuspended` 一档,仅在「跟随系统 + α<1」时作为**当前状态提示**
  出现;档位本身照常显示用户选的值(不偷偷改成「直角」)。中英双份 + golden。
- 未动:per-pixel α、D36 的 LWA_ALPHA 实现、α 与圆角的优先级。

## 顺带
- `presenter/prefs.rs` 的 `AppearanceBase` 文档「三个键/只读」→「四个键」(r4 P3)。
- 证据目录(DevDocs/artifacts/m116/{README.md,probe-table.md})按上面重写:五行真值表原样贴出、
  产品行标明「跑出来的不是手写的」、旧的「无边框 ⇒ 没有边框带」结论改为「只在假形状上成立,
  产品不用这个形状」。

门禁:check 0 / fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(448 passed, 0 failed,
2 ignored)。ignore 探针实跑:`PROBE_RC=0`,两行产品读数见报告。
2026-11-05 06:35:19 +08:00
AskaEth bc3a2b56a4 docs(app-iced): M116 r3 复审 —— 只对齐注释里的「只读下层键」数量与取证日志模板
复审 r3:p2 ×2,纯注释/文档漂移,**行为零改动、断言零改动**。

1. `app/appearance.rs:8,15-20,68` 与 `app/state.rs:170-171`:M116 之后
   `AppearanceBase` 是**四个**字段(整窗 α / 标题栏三态 / 配色来源 / 窗口圆角),
   走 `AppCommand::SetSetting` 的是**三个**键(标题栏三态还没有 UI 写者,D37 接线未落地)。
   两处数字与列举按 grep 到的实际代码改(`AppearanceBase` 的 `pub` 字段数 = 4;
   `on_appearance` 的 match 守卫 = WindowOpacity / PaletteSource / Corner)。
2. `pages/appearance/mod.rs:41` 的取证件模板补 `corner=`:`[appearance]` 行确实有这一栏,
   圆角还有一条独立管线 `[corner] <ctx> corner=policy=… source=… os=… excluded_reason=…
   radius=system pref=… readback=… read_hr=… frame=… layered=…`(取值形状取自探针实跑,
   见 corner/tests.rs 的格式单测),模板已按此补齐,并注明「看上去圆不圆」同样不可信。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-116`, `-j 4`,退出码逐个落盘):
check 0 / fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(447 passed, 0 failed, 2 ignored)。
行数:appearance.rs 130、state.rs 343、pages/appearance/mod.rs 180(均 ≤400)。
2026-11-05 06:35:18 +08:00
AskaEth 41746d1612 fix(app-iced): M116 r1 复审 —— 口径降级为「不承诺」、修掉伪造读数与两处错注释、圆角下发去重
四条 P1(全是「口径与证据不符」,行为未变):

1. 把「Win11 一定有系统圆角、边缘平滑」从事实降级:`corner.rs` 的平台矩阵改成
   「实测 / 解释 / 推断 / 未验」四列,并明写本机只测到「写入被接受 / 回读 E_INVALIDARG /
   无边框窗口没有 DWM 调整带」;设置页与 golden 的说明句改成「我们**请求**系统圆角,
   是否圆、边缘什么样由合成器决定」,不再承诺结果。
2. 删掉证据目录里那条伪造读数 `readback=2`:本机三种样式回读全是 `E_INVALIDARG`,
   该值物理上产不出来,也不可能由 `format_line` 产出。改成显式的「合成示例(非实测)」,
   并把每一栏标注成真实/示例来源。probe-table 的「没有一条是推断」标题一并拆开,
   结论 4/5 明确标为**解释**(并写明 Δframe 之差是样式位的同义反复,不能反推 DWM 决策)。
3. `keys.rs` 的「旧读新」语义按实际链路改写:1.4.0 读 1.5.0 文件走
   `SchemaCompat::check_url` → `ReadOnlyPreserveSchema` → `SchemaCompat::access` →
   `FileAccess::ReadOnly`,**整份配置只读**(值照读、`store.set` 报 `SettingError::ReadOnly`、
   `save` 跳过),不是「只是少一档」。
4. 两处错注释订正:
   - `hresult_ok` 的理由改成 `S_FALSE` 的通用语义,并明写 `0x7FF8FFA9` **不是** DWM 的返回值
     (本机实测是 `E_INVALIDARG 0x80070057`),那是我早期用 `unsigned_abs()` 打印 HRESULT 的产物;
     对应单测也把该常量标为「合成值,只用来钉规则」。
   - `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 的文档改成「本机三样式三态实测全 1,不能当判据,
     仅作诊断读数」。

两条裁定:
- CPU 档在 Win11 仍走 DWM(tower 裁定保留):理由写进 `plan_corner` 文档——D36 约束的是我们自己的
  渲染路径,DWM 圆角是合成器侧的,不增加我方负担;设置页说明同步加了「软件渲染也一样」。
- 「圆角窗口上拖边缩放仍命中」实测未做(RDP 断开、无指针注入手段):mission 里以 note 记为 dropped
  (store 的 task_drop 对已完成任务不生效,工具层面无法取消勾选,已在 note 里写清)。

六条 P2:
1. probe-table 拆「实测 / 解释 / 未验」并删掉绝对化标题。
2. CPU 档裁定见上(任务书由 tower 侧调整)。
3. 拖边缩放任务记账更正见上。
4. **圆角下发去重**:新增 `corner::{CornerApplied, needs_redispatch}`(指纹 = 策略 / 来源 /
   原因码 / 有没有句柄,不含只有真下发才拿得到的读数),`App::apply_window_corner` 改成
   `&mut self` 并在判定未变时**直接返回**(不动窗口、不打日志);`App` 新增 `corner_applied` 字段。
   新增测试 `the_same_policy_is_dispatched_only_once`:连续 20 次同策略只下发/记 1 次,
   换档与「首次拿到句柄」必须重下。
5. `dwm_get_rect` 的 `hr == 0` 改成 `hresult_ok(hr)`,「判据只有一处」名副其实。
6. 报告口径修正(诊断已产出、UI 接线待做——`SettingsBridge` 未暴露 doc,接线要改 presenter 的
   `settings_bridge.rs`,不在本 mission scope),本轮不动代码。

门禁:fmt --check exit 0;clippy(5 个 crate)--all-targets -D warnings 零 warning;
lib 测试 app-iced 419(+1 去重测试)/ core 266 / design 163 / i18n 24 / presenter 129 全绿;
改动文件仍全部 ≤400 行(最大 keys.rs 398)。
2026-11-05 06:35:17 +08:00
AskaEth 25d860b2b3 refactor(app-iced): M116 把圆角单测搬出 corner.rs、日志行收敛到 format_line
- `platform/win32/corner.rs` 从 413 行降到 200 行(AGENTS.md §3-1 的 400 行硬线):
  单测搬到 `corner/tests.rs`。
- 日志行的**形状**收敛成一个纯函数 `corner::format_line(context, plan, policy, outcome, style)`,
  于是「`excluded_reason` 这一栏还在不在」不依赖真窗口也能被断言。
  新增 `the_log_line_shape_is_pinned_with_the_measured_readings`:用本机探针窗口的真实读数
  (Server 2025 / 26100、读回 E_INVALIDARG、frame=popup)逐字段钉死,
  并把 policy × 系统 × 渲染档四种 `excluded_reason`(none / user / os-build / cpu-tier / unknown-os)全覆盖。
- `set_layered` 补一段「这里是 GetLastError 的 DWORD 语义,`unsigned_abs()` 有意保留;
  DWM 那侧是 HRESULT 语义、必须 `as u32`」的注释 + 对应用例(tower 2026-11-03 裁定:两者不同源,不许统一)。

门禁:fmt --check ✓ / clippy -D warnings(5 个改动 crate)✓ /
lib 测试 core 266 + design 163 + i18n 24 + presenter 129 + app-iced 418 全绿。
2026-11-05 06:35:03 +08:00
AskaEth 88dbf6d955 feat(app-iced): M116 窗口外框圆角策略(跟随系统/直角)+ DWM 读写的受控对照实验
用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
Dwm* / SetWindowRgn。本 mission 先做实测,再按平台分开落策略。

判据(纯函数、可真值表):
- `platform::plan_corner(策略 × 系统版本 × 渲染档)` → `source=dwm|square` + `excluded_reason`。
  Win11 / Server 2025(build ≥ 22000)→ DWM;Win10 及更早 → 直角(不做 SetWindowRgn 毛边默认);
  探测不到版本(macOS / X11 / Wayland)→ 直角 + `unknown-os`(「不承诺」而不是「做不到」)。
  CPU 档在 Win11 上照样用 DWM(D36 限制的是我们自己的合成技巧,不是系统合成器)。

落点:`platform/win32/transparency.rs` 仍是全仓唯一 unsafe 文件,新增 DWM 的 FFI 与安全包装;
`alpha.rs` / `dwm.rs` / `probe.rs` 三个新子模块零 unsafe、零 extern(沿 exstyle.rs 的既有形状)。

实测(本机 Server 2025 build 26100、RDP 会话断开、同进程三窗口对照):
- `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 在 WS_OVERLAPPEDWINDOW / WS_POPUP /
  WS_POPUP|WS_EX_LAYERED(α=0.8) 上**全部成功** ⇒ 无边框与整窗 α 不会让属性设不上;
- `DwmGetWindowAttribute` 同一属性三种样式**全部 E_INVALIDARG** ⇒ 本机不支持回读,与样式无关;
- `DWMWA_EXTENDED_FRAME_BOUNDS − GetWindowRect`:有边框 = +0,−7,popup = +0,+0
  ⇒ DWM 只给有边框窗口加不可见调整带,我们的窗口没有可画的圆角区域(结构性解释)。
- 像素(角上透明/方块)**未验**:RDP 断连导致 CopyFromScreen / gdigrab 全废。

同时修掉一个自己踩到的判据错误:`i32::unsigned_abs()` 不能用来打印 HRESULT
(E_INVALIDARG 0x80070057 会被印成 0x7FF8FFA9)。现在 HRESULT 用 `hr as u32` + 严重性位,
GetLastError 的 DWORD 语义保持 `unsigned_abs` 并写死注释(两者不同源,别统一)。

配置:新键 `appearance.window_corner ∈ {system, square}`(出厂 system),schema 1.5.0 只推 minor、
不写迁移:老文件缺键 = 取默认 = 跟随系统;旧版本读新文件时未知键被登记并原样写回(`RenderMode::Full`),
只在显式 `store.set` 该键时才 `UnknownKey`。

设置 UI:外观组只留两档(跟随系统 / 直角)+ 一句按平台说清的说明,文案走 i18n 中英。
日志:`[corner] <ctx> corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
(形状由 `corner::format_line` 的格式化单测钉住,用探针窗口真实读数构造)。

证据:DevDocs/artifacts/m116/{README.md,probe-table.md}
2026-11-05 06:35:03 +08:00
AskaEth aace0c5836 Merge branch 'feat/m114-accountui-accounts-page-three-track' 2026-11-05 06:34:09 +08:00
AskaEth 0fdc889af6 refactor(presenter): M114 rebase 后守 400 行 —— 实时日志滚动窗口搬进 state/live.rs(纯搬移) 2026-11-05 05:37:43 +08:00
AskaEth a183adc072 refactor(app-iced, presenter): M114 rebase 后守 400 行 —— 四处纯搬移(不动语义、不压注释、不挪断言)
主线六条合入后,四个被我碰过的文件越过 400 行;按模块拆开,只搬不改:

- `presenter/host/mod.rs` 421→366:账户刷新/皮肤三段(refresh_accounts / attach_skins /
  request_skins)搬进 `host/accounts/refresh.rs`(`impl CoreHost`,pub(in crate::host))
- `presenter/state.rs` 410→392:`TileCell` + `grid_positions` 搬进 `state/tiles.rs`,
  `state.rs` 用 `pub use` 再导出,老路径不变
- `app-iced/app/state.rs` 401→394:账户页重建那一段搬进 `app/view/account_page.rs`
  的 `App::rebuild_account_page`
- `app-iced/app/update.rs` 407→388:`ULL_START_ROUTE` 取值表搬进 `app/update/boot_env.rs`
  (纯函数、无状态)
2026-11-05 05:13:54 +08:00
AskaEth 0f69eec64d refactor(presenter): M114 mock 的「取消登录」搬进 mock/accounts.rs(守 400 行) 2026-11-05 05:10:48 +08:00
AskaEth f3e20b670e fix(app-iced, presenter): M114 r4 —— 登录面板 Close 不再是死按钮 + 面板态删掉原文字段(把「画原文」变成编译错)
复审 r2 P2-1:`Account::Close` 只在 `phase.busy()` 时派 `CancelDeviceLogin`,而
`Page::build` 只要非 Idle 就强制回 Microsoft ⇒ Failed/Succeeded 下 Close 点了没反应。
- 判据改成「两条轨任一非 Idle 就取消」;host 侧 `CancelDeviceLogin` 本来就把两条轨收成 Idle
- mock 补 `AppCommand::CancelDeviceLogin` 分支(按真 host 契约发 Idle),否则 mock 下不可验
- 新增 headless 回归闸:失败/成功/第三方轨三种态 → Close → `panel == None`
  (变异实证:判据退回 `busy()` ⇒ 该用例红,打印 left: Microsoft / right: None)

复审 r2 P2-2:`DevicePanel`/`AuthlibPanel` 删掉 `message`/`detail` 字段。
视图没有材料可画,「把后端原文画上屏」从测试问题变成**类型错误**;
`tests_text.rs` 的 docstring 改成真实边界(只覆盖模型 + i18n 查表,视图靠类型 + 出图)。

顺带(塔看图标出):alert 的 `Code: …` 与标题同排,省一整行高度,
避免 1180×760 把 All accounts 第三行挤到折线下;新增 `closed` 取证场景出「关掉之后」的对照图。
2026-11-05 05:10:47 +08:00
AskaEth bc71834bc3 fix(app-iced): M114 中文取证图里视图模型也跟着换语言 + 离线面板去掉重复的限定句
- `App::lang()` 统一语言来源(产品 En;截图通道读 `ULL_LANG`),
  `Page::build` 与页面树用同一份 → 中文截图的「添加于/微软正版/服务器」不再混英文
- 离线面板:标题下不再复读与警告条一模一样的那句限定(1180×760 下像重复渲染)
- 出图脚本 cd 深度修正(DevDocs/artifacts/m114 → ../../../)
2026-11-05 05:10:46 +08:00
AskaEth 9e35f9fb9d style(app-iced): M114 去掉 tests.rs 里已搬走的两个 import(clippy -D warnings 抓到的) 2026-11-05 05:10:15 +08:00
AskaEth e09411f975 refactor(presenter): M114 离线名字契约拆到 dto/accounts/name.rs(守 400 行,补边界用例)
dto/accounts.rs 因搬入 valid_offline_name 涨到 404 行;按模块拆分惯例独立成文件,
并补 16 位上限 / 17 位超限 / 空串 / 非 ascii 的边界用例。
2026-11-05 05:10:14 +08:00
AskaEth 178f6ebbff fix(presenter): M114 错误分类只看结构化字段(status/kind/server_message),不再 Debug 整条错误
- `AuthError::Response` 的判据从 `format!("{detail:?}")`(连中文 what/digest 一起扫)
  改成读 `ResponseDetail{status, kind, server_message, digest}`
- 5xx 与拦截页(Html/Text)归 `Unreachable`(「连不上」而不是「服务器拒绝了你」),
  AADSTS/consent/whitelist 标记仍优先判 `NotWhitelisted`
- 补两条分类用例:AADSTS 优先于一般拒绝、5xx/拦截页归不可达
2026-11-05 05:10:14 +08:00
AskaEth 73badb8a02 test(app-iced, presenter): M114 出图补三个取证态(第三方站失败 / 离线失败 / 中文错误码)
- mock 增 `authlib-failed` 场景:AuthlibLoginChanged(Failed, NotAuthlibServer),原文只进日志
- `ULL_ACCOUNT_STATE=notice` 现在同时打开离线面板(截图能看到「表单 + 失败横幅」同屏)
- 新增**截图专用**语言开关 `ULL_LANG=zh`(仅在 `--screenshot` 下生效):D30 要求错误码文案
  中英各一张图,交互模式仍钉死 Lang::En(与产品行为一致)
2026-11-05 05:10:13 +08:00
AskaEth 9d0d985b1b fix(app-iced, presenter): M114 r3 —— 登录成功提示走 i18n key+参数,离线名字提交判据与 host 同源
- 新增 Key::AccountLoginSucceeded(Signed in as {name} / 已登录 {name}):
  presenter 不再拼「已登录:{name}」这类人类可读句子(复审 r1 P1-1 的收尾)
- valid_offline_name 从 host 搬到 dto(UI 与 host 共用的同一个判据),
  Form::offline_ready 改用它:按钮不再「亮着但必然失败」
- 视图模型测试:成功提示按 i18n 渲染进 on_screen;离线提交判据加「太短也拒绝」断言
2026-11-05 05:09:52 +08:00
AskaEth 8d8552113a fix(app-iced, presenter): M114 r2 —— 登录失败走结构化错误码 + i18n,修 4 条 p2 与 3 条看图意见
p1(语言泄漏):后端诊断串不再上屏
- presenter 新增 LoginFailure 原因码(denied / whitelist_pending / unreachable /
  not_authlib_server / bad_credentials / no_profile / store_failed / other)
- 页面按码查 i18n 出标题+正文+「Code: …」,原文只进日志/详细信息
- 进行中(Requesting)也走 i18n,不再复读后端中文状态串
- presenter/state 不再发中文 toast(成败由页面按 i18n 画)
- 新增守门测试:注入带 AADSTS 的失败事件,断言英文视图无 CJK 且含 code

p2-1 复制:on_account 返回 Task,page_messages 把 iced::clipboard::write 交回 runtime
p2-2 可见失败态:新增 AccountNotice(added/removed/switched/名字非法/重名/落盘失败/
      找不到),页面用 M3 内联 alert 画(不是 toast);离线名字先按 3–16 位规则校验,
      失败不再清空输入
p2-3 重新登录按账户类型分派:微软→授权码流;第三方→预填服务器/登录名并提示补密码;
      离线→明确说明没有可重登的服务器账户
p2-4 入口收敛:删 chip 行;空态=三卡片;有账号时三卡收进 Add an account(按钮不再绑死微软轨)

看图意见/tower 三条:
- 失败横幅改成 M3 alert(SurfaceContainerHigh + 描边,errorContainer 只作图标块,
  不再通栏实心红);色值本身经复审像素采样确认是 90/30 档,未改角色
- 当前账户卡补齐与列表一致的「服务器/登录名」行;无皮肤时明说 No skin available
- 页面级 scrollable 加 8px 右 gutter,滚动条不再压卡片
- 倒计时改 mm:ss(15:00 left),数字与冒号语言无关

p3(顺带):AuthlibRequest / Form 手写 Debug 脱敏密码;HTTP 响应体与 base64 输出加上限;
离线提示文案与行为对齐;删除无人使用的 key;皮肤测试断言帽子层真的 blend

测试:app-iced lib 418 绿、presenter 151 / i18n 24 绿(workspace 全量另跑)
2026-11-05 05:09:51 +08:00
AskaEth 1ff00bbe50 feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验)
- 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、
  本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃)
- 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条)
- 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证)
- presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、
  皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml)
- i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
2026-11-05 05:08:35 +08:00
AskaEth e7b04c4afe Merge branch 'feat/m113-packui-modpack-import-and-export-su' 2026-11-05 04:59:48 +08:00
AskaEth 134b0a2aa4 fix(packui): M113 r2 复审整改 —— RAII 在跑句柄、per-host 代次、注释与代码对齐
复审 r2(p2-3items / fix-then-merge)逐条整改。**P2-6 与 P3-3/4/5 按塔台指示不动**,只登记。

## 必修 1:`PACK_RUNNING` 在早退路径不清零(跨功能泄漏)

改前:`fresh_cancel()` 置 bool,`super::finished()` 在 `block_on(import_pack(..))` **之后**才清;
探测失败 / 目标目录不可用两条 `return` 都在它之前 ⇒ bool 永久留在「在跑」。
后果:之后**任何**一次 `CancelInstall`(包括下载页的「取消安装」)①不再发 `InstallCancelled`、
②还给内容库发 `PackEvent::Cancelled`(一句「导入还在后台跑」的假话)。

改后:`InFlight` **RAII 句柄**(`Drop` 减一)取代手工 `finished()`;计数用「每个 host 一格」的
`in_flight: usize` 而不是 bool(连点两次 Import 各自进出)。
`InFlight::begin(&env)` 在**派发点**创建、随线程移动到出口 ⇒ 正常收尾、两条早退、`?`、panic
展开**任何**退出路径都会减一,以后再加早退分支也不用记得补一行。

## 必修 2:`dir_name` 注释与实测不一致

改前:注释与报告写「`Better MC` → `Better-MC`(不加哈希)」,而 `faithful` 只认 `[A-Za-z0-9_-]`,
空格不算 ⇒ 实际 `Better-MC-c7c9c149`;测试只钉了无空格形态。

改后:**选「空格算 faithful」这边**,注释、代码、测试三处对齐 —— 名字只含 ASCII 字母数字、
空格、`-`、`_` 时保持原样(空格折成 `-`);折过**别的**字符(中文、`.`、括号…)才补 `-<hash8>`。
测试钉住四例:`Better-MC` → 原样、`Better MC` → `Better-MC`(不加哈希)、`pack` → `pack`、
`Survival 1.20.1` → `Survival-1-20-1-<hash>`(折过 `.` 必须补)。

## 必修 3:两处注释与代码相反

- `host/job.rs` 的 `HostTask::Pack`:原写「代次与 `HostTask::Install` 共用同一计数器」,改为
  「pack 自己的一条(`PACK_EPOCH`),不与 `install_epoch` 共用」并写明为什么(P2-7 的教训)。
- `host/addon/pack/import.rs` 的 `target_of`:`<slug>` → `<dir_name>`,并注明中文名不会被折成同一个 `pack`。

## 必修 4:两条 P3

- `04` 图里「Back」出现两次:有预检结果时字段区那行只留「预检」,`Back` 只由预览块给一个
  (没有预检时才在字段区给 `Back`)。
- 导入侧 `rejected` 列表补 `bounded`(与 missing / conflicts 同一口径)。

## 必修 5:app 级用例(P2-1 的护栏)

新增 `a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`:
`CoreHost::headless` + 真派 `PackCommand::Import`(包不存在 ⇒ 探测早退)→ 泵到 `Probe` →
断言 `in_flight == 0` → 再派 `CancelInstall` → 断言**仍会**发 `InstallCancelled` 且**不发**
`PackEvent::Cancelled`。

## P3-6(裸静态量):按塔台要求正面回答并已改掉

不再用裸静态量:`PACK_EPOCH` / `PACK_RUNNING` / `PACK_CANCEL` 换成**按 host 键控**的一张表
(键 = `LauncherLayout::home()`),理由是实测撞到过串扰 —— `host/cancel_tests` 派一次
`CancelInstall` 就 bump 了全局代次,我这条在飞的导入事件被按代次丢掉(新用例因此红过)。
为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在 scope)。这张表等价,`host/mod.rs`
一旦进 scope,搬过去只是把表删掉 —— 报告里也这么写了。
新增用例 `pack_epoch_is_per_host_and_is_its_own_counter` 钉住「另一个 host 的代次不受影响」。

## 顺手的两条

- `pack/tests.rs` 加完用例后 429 行超线 ⇒ 夹具(临时目录 / 写文件 / 泵 host)拆到
  `pack/tests/harness.rs`,`tests.rs` 回到 3xx 行。
- clippy `-D warnings` 抓到 `== false`、两处多余的 `&`、一个未用的再导出,都改掉了。

门禁:`cargo fmt --all --check` 通过;clippy 三 crate `--all-targets -- -D warnings` EXIT=0;
`cargo test -p unlucky-app-iced --lib` **507 passed**;`cargo test -p unlucky-presenter --lib`
**185 passed**(新增的 app 级用例在内,且是在**全量并行**下跑的);改动文件全部 ≤400 行。
2026-11-05 04:12:39 +08:00
AskaEth 99538e45b9 fix(packui): M113 r1 复审整改 —— 2 处「界面说假话」+ 7 条必修 P2
复审 r1(p1-2items / fix-then-merge)逐条整改。**P2-6(core 的本地化契约)按塔台指示本轮不动**,
已登记为独立 mission 的债。

## P1

1. **「Save to」是死输入框**:`ExportRequestView` 的唯一构造点硬编码 `target: None`,用户敲的路径
   从不进请求。现在构造收成纯函数 `packui::model::export::request_of`(预检与确认共用同一份),
   `target` 由字段去空白后给出(空 ⇒ `None`,交给 host 给缺省)。
2. **取消导入的 toast 撒谎**:下载页那条说「安装已取消、实例未创建」,而导入会在后台跑完并
   **登记实例**。现在 `cancel_pack()` 回报「刚才有没有导入在跑」,`dispatch` 据此改发
   `PackEvent::Cancelled`(不再发 `InstallCancelled`),界面与提示改成
   「导入会在后台继续跑完 —— 本次结果作废;已写下的文件会留下」,并把这句话**留在屏上**
   (`ImportState.cancelled` + 面板位)。

## P2 必修(7 条)

3. **`Dismiss` 误伤下载页安装**(跨功能污染,最高优先):pack 原来共用 `CoreHost::install_epoch`,
   关一次导入面板会把下载页正在跑的安装静默丢掉。现在 pack 有自己的
   `PACK_EPOCH`(模块级 `AtomicU64`),`handle` 的 pack 分支只比它,`Dismiss`/取消只动它。
4. **中文实例名塌成 `pack`**:新增 `convert::dir_name` —— 名字全 ASCII 安全字符时保持原样
   (`Better MC` → `Better-MC`),一旦折过东西(中文/空格/符号)就补 `-<hash8>`(FNV-1a),
   保证两个中文名得到不同目录。
5. **失败回滚后空目录挡住重试**:`target_of` 对「已存在但空」的目录**复用**(非空才拒),
   并在失败回滚后清掉自己建的空目录;`failed_report` 的 `target` 语义改正
   (以前把包路径塞进「目标实例目录」)。
6. **新实例流程下「以包为准」必然失败**:`can_overwrite` 追加 `page.instance.is_some()` ——
   新建实例那条路上不再显示一个必然失败的按钮。
7. **阶段词打架且文档说修好了**:`progress.rs` 的模块文档改成与屏幕一致(粗/细两层 + 已知重叠),
   并写清彻底解决要靠「安装面板提成共享组件」那条 finding。
8. **导入侧标题计数与行数对不上**:被拒 component 有了自己的标题(`The client does not support
   these: {count}`);冲突标题改成中性的 `Conflicts: {count}`(逐条自带成因与处置)。
9. **预检「没进包」列表没有有界滚动**:13 条是真实形态。列表改成有界滚动(110px),
   并且**把动作按钮提到摘要下面**(`Write the pack` / `Back`)—— 明细再长也顶不掉按钮。
10. **导入成功后不刷新实例列表**:`handle` 的 pack 分支在收到 `Report` 时调 `refresh_instances()`
    (与 `InstallDone` 同一条),新实例不用等下一次无关的 `RefreshAll`。

## 证据

- 新增测试(都跑真管线/真函数):`the_save_to_field_really_reaches_the_request`、
  `pack_epoch_is_its_own_counter`、`chinese_instance_names_get_distinct_directories`、
  `a_failed_new_instance_directory_can_be_reused_for_retry`、
  `overwrite_is_offered_only_when_importing_into_an_existing_instance`、
  `cancelled_state_is_visible_and_says_the_import_keeps_running`。
- 出图重出 12 张(6 态 × 浅深),新增 `06-cancelled-*`;`04-export-*` 用 13 条 skipped 的真实形态。

门禁:`cargo fmt --all --check` 通过;`cargo clippy -p unlucky-app-iced -p unlucky-presenter
-p unlucky-i18n --all-targets -- -D warnings` EXIT=0;`cargo test -p unlucky-app-iced --lib`
507 passed;`cargo test -p unlucky-presenter --lib` 184 passed;`cargo test -p unlucky-i18n --lib`
24 passed;全部改动文件 ≤400 行。
2026-11-05 04:06:42 +08:00
AskaEth 27bbc74eaa fix(packui): M113 收口 —— 导出的「Save to」字段形状与「没进包」标题承诺
复审看图挑出的两处,只改这两处,其余不动。

1. **Save to 字段**:原来是 `label("Save to (empty = suggested)")` + 空值,看起来像「这句提示就是字段值」,
   与上面 Pack name / Version 的「标签在上、值在下」不一致。改成短标签 `Save to` + 占位
   `empty = suggested path`(新增 `PackExportOutputPlaceholder` 一个键)。

2. **`Not in the pack (listed one by one)` 与 `...and 6 more files` 自相矛盾**:13 条没进包的其实全列了,
   但紧随其后的「还有 6 个」说的是**内联样例**的截断,两句挨着会被读成一件事。
   改法:「没进包」标题带真实计数(`Not in the pack: {count}`),内联样例加自己的标题
   `Copied into overrides/ (first {shown}):`,截断那句改成 `…and {count} more files (not shown here)`
   —— 谁被截断、截了几条,写在它自己的那一块里。

`tolerable_title` 因此改成收 `String`(计数是运行时 format 出来的),`golden.rs` 同步三个键。

门禁:`cargo fmt --all --check` 通过;`cargo test -p unlucky-app-iced --lib -j 2`
504 passed / 0 failed(TEST_EXIT=0);`cargo check -p unlucky-app-iced --all-targets` 零 warning。
出图:`04-export-light.png` / `04-export-dark.png` 已重出(其余 8 张未受影响、未改动)。
2026-11-05 04:06:40 +08:00
AskaEth 5f09b8113a feat(packui): M113 phase 2 —— 导入导出真正接线(三个共享枚举各只多一个变体)
搬到 M109 rebase 后的新 main(26e764e)之后按**新 main 的形状**写一次,不写两遍。

## 接线

- `dto/pack/event.rs`(新):`PackCommand` / `PackEvent` / `PackState::apply`。
  整条流程在三个最挤的共享枚举里各只占**一个**变体(`AppCommand::Pack` / `CoreEvent::Pack` /
  `HostTask::Pack{event, epoch}`),子枚举承载细节 —— rebase 时不用替别人定序。
- `events.rs` / `job.rs` / `handle.rs` / `dispatch.rs` / `state.rs`:变体与分支,全部带
  `// M113: import/export` 锚点。`state.rs` 只多 1 个字段 + 1 条 apply 分支(399 行)。
- `host/addon/**`(新目录,父文件只加 `mod pack;` + 再导出):
  - `pack.rs`:`CoreHost::pack` 落点 + 取消标记 + 实例目录/快照/格式/缺省路径解析
  - `pack/import.rs`:探测 → 目标目录 → `import_pack` → 报告(默认 KeepUser,显式才 Overwrite)
  - `pack/export.rs`:预检(`scan_instance`,**不写盘**)与写盘(`export_instance`)
  - `pack/convert.rs`:core 报告 → DTO,逐字段照搬(三态/五阶段/四处置/三成因/十一原因)
  - `pack/diagnose.rs`:探测诊断「像 X 缺 Y」(按标记文件的有无,不看文件名)
- `app/update/content.rs`:导入 / 以包为准 / 预检 / 确认写盘 / 关面板真的派指令;
  预检时先记下那份请求,确认时**原样发回**(防「预检 A、写 B」)。
- `pages/content/model.rs`:读 `state.pack`(不再是默认空态)。
- 面板接口按新形状改:`install_panel_model(&InstallProgressView, catalog) -> Option<Download>`
  (`InstallView` 不再从 `pages/download` 导出)。

## 取消(如实)

`CancelInstall` 现在同时置导入的取消标记 + 代次 +1:面板立刻收回、旧事件按代次丢弃。
但 core 的 `import_pack` 没有取消令牌、进度回调也拿不到控制流 ⇒ **后台那次导入会跑完**。
所以「取消」的真实含义是「不再显示 + 结果作废」,已在模块文档与报告里写清,并单开 finding。
取消标记用 `Arc<AtomicBool>` 而不是裸静态量:裸静态量会在并行测试间泄漏取消状态(实测到过)。

## 图看出来的三处(塔台要求)

1. `no download source (CurseForge file API not wired)` → `no download source available yet`,
   API 名只留在 DTO 的 detail 与日志里;顺带把 `sha512` / `projectID/fileID` 也改成用户话。
2. 右栏 `Installing modpacks is not available yet` → `Installing modpacks from the library into
   an instance is not available yet`(那句在 i18n 词表里,不在 M109 页面代码里,所以我自己改)。
3. 结果面板接线后可以关:`Dismiss` / `AbortExport` 发 `PackCommand::Dismiss` → host 回
   `PackEvent::Cleared` → 回到入口/浏览态,不再常驻。

## 证据(三条都是真跑,不是假数据)

- `bad_packs_get_a_named_diagnosis`:把文本改名成 .mrpack → 有具名诊断(不是静默、不 panic);
  目录形态有 overrides/ 无 manifest.json → 说得出「缺哪个标记」。
- `a_real_pack_runs_through_the_pipeline_end_to_end`:造一个真 MultiMC 实例目录当包,
  走 `import::run` 真路径,断言事件流里有进度 + 结果、格式=MutliMC、报告有写盘文件、文件真落地、
  代次原样带回。
- `preview_counts_match_the_written_report`:同一实例上跑预检与 `export_instance`,
  inline / skipped / remote **三项计数逐项相等**,且逐条路径的归类一致。

门禁:cargo check ✓ / clippy -D warnings(三 crate)EXIT=0 /
cargo test(app-iced 504 + 6 守卫,i18n 24,presenter 181)全绿 / rustfmt --check ✓ /
所有改动文件 ≤400 行(`state.rs` 399、`addon.rs` 106,pack 子目录最大 338)。
2026-11-05 04:06:39 +08:00
AskaEth 2129d8cee6 fix(packui): M113 第一批修正 —— 一次只摊开一件事、有界滚动、阶段词不再打架
D30 出图实测暴露的四个问题,都在本批范围内修掉:

1. **动作按钮被顶出屏幕**:内容库页正列没有整页滚动(只有结果列表自己滚),入口 + 报告 +
   导出三块同时摊开时,「确认写盘 / 关闭 / 以包为准重新导入」落在 900px 以下。
   改成一态一块(导出面板 > 进度 > 结果 > 入口);拖拽是窗口级事件,收起入口不丢功能。
2. **阶段词打架**:复用面板自带的阶段词是安装语义(Building the version),与
   「Step 2/5 · Unpacking overrides」并排时两种说法。现在面板阶段词留空、由步骤行说真话
   —— 仍然只有一份进度条 / 计数 / 取消按钮,没有第二套进度 UI。
3. **列表过长**:缺文件 / 冲突 / 预检样例与已知损失都套上「超过 n 条才滚」的有界容器
   (packui/view/parts.rs 的 bounded),少量条目时不浪费空间。
4. **英文单复数**:计数行改成「标签:值」(Files written: 2、cannot export: 1)。
   PackPathPlaceholder / PackFormatModrinth 的中文与英文雷同,被
   chinese_catalog_is_actually_chinese 抓到,一并改掉。

另:app/update.rs 加完拖拽分支后 408 行超线 → 两个钩子改成返回 Task、三个分支各一行,
现在 397 行。新增致命态出图夹具(ULL_CONTENT_PACK=failed),用来证明「容忍的缺文件」与
「致命回滚」在视觉上可区分。

门禁:cargo check ✓ / clippy -D warnings ✓ / cargo test -p unlucky-app-iced(467+6)✓ /
cargo test -p unlucky-i18n(24)✓ / rustfmt --check ✓ / wc -l 全表 ≤400 ✓
2026-11-05 04:06:38 +08:00
AskaEth fbeff94e55 feat(packui): M113 第一批 —— 内容库页的导入/导出区(界面 + DTO + i18n + 拖拽落点)
导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**,
数据面镜像 M111/M112 的管线报告(presenter DTO),进度整块复用下载页的安装面板。

## 落了什么

- pages/content/packui*:入口 / 进度块 / 结果块 / 导出预检块(每文件 ≤400)
  - 拖拽区(hover 变主色描边)+ 路径输入框 + 目标实例 chips
  - 进度:一行「步骤 n/5 · 解包覆盖内容」+ **下载页那块安装面板**(不另画进度条)
  - 结果:三态(Success/Partial/Failed)+ 缺文件逐条(Warn/Tertiary)+ 冲突逐条
    (成因 + 本次处置 + 可展开三方 SHA-1)+ 致命块(Error/ErrorContainer)
  - 导出:mrpack / CF 二选一 + 包名/版本/路径 + 预检「将内联 N 个文件 · 无法导出 M 项」
    + 逐条无法导出项 + 已知损失 + 确认写盘
- presenter/src/dto/pack{,/format,/import,/conflict,/export}.rs:镜像 core 报告的 DTO
  (三态、五阶段、三分类、十一条原因,全部 from_slug 与 core 稳定码逐字对齐)
- i18n:81 条 Pack* 键(content_entries! 尾部追加;golden.rs 同序同文)
- app/update.rs:FileDropped / FileHovered / FilesHoveredLeft → 内容库页(本版唯一的选文件通道)
- M109 的四个文件各一处插入:mod 声明 + Pack(..) 变体 + pack 字段 + 正列插入点

## 边界(如实)

- 没有系统文件选择框:依赖树里无对话框依赖,加它要动根 Cargo.toml + 许可门禁 +
  Linux 后端选型(影响单文件产物)。入口是拖拽 + 路径输入,原因写在界面上。
- 冲突没有逐文件决策:core 的 ConflictPolicy 是整次导入一个全局档,ConflictFile 是事后账。
  本批给「逐文件看 + 整体翻档(以包为准重新导入)」,文案明写「两者都留(改名)暂不支持」。
  默认一律 KeepUser——绝不默认覆盖用户改过的文件。
- 本批按钮半通:拖拽/路径/格式/实例/展开都真生效;「点导入真的开跑」「预检/确认写盘」
  要等第二批的 AppCommand 与事件通道(M109 rebase 到新 main 后按新形状写一次)。
2026-11-05 04:06:37 +08:00
AskaEth 0367073d32 Merge branch 'feat/m109-contentdl-content-library-page-modp' 2026-11-05 04:06:09 +08:00
AskaEth 1c5283058b refactor(m109): rebase 到 5adac23 后按功能拆两条超 400 行的文件
rebase 到 M107/M108 之后的 main(5adac23),并集解冲突后 `app/state.rs`(401)与
`app/view.rs`(402)双双顶破 400 硬线。按功能拆,不压注释:

- `app/state/boot_probe.rs`:启动期取证两件——`app_options`(启动参数 → 后端选项)与
  `log_first_frame`(首帧耗时一行)+ 它们的两个全局量 `STARTED` / `FIRST_FRAME_LOGGED`;
  `state.rs` 用 `pub(crate) use` 再导出,`app/boot.rs`(`use super::state::STARTED`)与
  `app/view.rs`(`log_first_frame`)的路径一字未变。state.rs 401 → 376。
- `app/view/tests.rs`:`view.rs` 的守门测试独立成文件(`#[path] mod tests;`)。**内容取自
  当前 main 的测试体**(M106 的 `Lang` 形参 / `at()` 入口都在),不是旧分支那一份;view.rs 402 → 255。
- `presenter/src/state/content.rs` 与 `presenter/src/state/content_tests.rs`:上一轮已拆,本轮不变。

门禁(新 tip,`-j 2`,逐条前台跑并落盘退出码):fmt 0 / clippy -D warnings 0 /
`cargo test -p unlucky-app-iced --lib` 0(522 passed)/
`cargo test --workspace --exclude unlucky-app` 0。
2026-11-05 03:39:50 +08:00
AskaEth 42099d4336 fix(app-iced): M109 r1 —— CF 来源装入闸门、has_more 读最近一页、末行切半与文案/图标
复审 p1-2items 的两条 P1 与三条 P2,逐条收口。

`blockers_of` 只看版本/加载器/目标实例,不看来源;而 host 的 `resolve_file` 对 CF 直接
返回「未接通」⇒ 切到 CF 源后每条都必然失败,UI 一声不响。

- 新增 `Blocker::SourceNotWired` + 文案 `ContentSourceNotWired`(中英);
- `blockers_of` 的入参从 `Option<(&[String], &[String])>` 收成 `Option<HitFacts>`(版本 /
  加载器 / **来源**),两处调用点(产品路径、演示数据)一起改;
- 判据同构于整合包的 `NeedsPipeline`:**点之前**禁用并给一条原因;CF 文件端点接通后删掉这条。

DTO 的 `has_more()` 判「本页装满」是对的,但 reducer 在 `offset>0` 时先 extend 再判,
被判定的是**累计条数** ⇒ 首页满 20 条之后恒真:「加载更多」永不消失、「没有更多了」在
真实翻页路径上不可达,且每点一次白发一次网络请求。

- `ContentState` 新增 `last_page_filled`(**先**按这一页记一位,再并进累计列表);
- 页面的 `has_more` 读它(`&& !pending`);换条件(offset 归零)时清位;
- 新增 `crates/presenter/src/state/content_tests.rs` 四条用例(state.rs 贴 400 行上限,拆文件):
  `a_short_last_page_clears_has_more` / `an_empty_last_page_keeps_has_more_false` /
  `changing_the_query_resets_the_page_flag` / `a_stale_batch_does_not_touch_the_page_flag`;
- 变异自证:把判据改回「读累计」后前两条红(`末页不满 ⇒ 不许说还有更多`、`空页 ⇒ 没有更多`),
  改回绿。

- **末行被 footer 切半**(复审定位的真因):`view.rs` 的 body 列漏了 `.spacing(..)`,而列表
  纵向内边距是 0 ⇒ 视口底边正好切在半行上。补 `Space::X2`,并说明为什么必须给。
- **文案去掉开发者术语与内部编号**:`ContentSourceUnavailable` 不再写 `ULL_CF_API_KEY`
  (改「CurseForge 需要在设置里配置密钥才能搜索」);`ContentModpackNotReady` 不再写
  mission 编号(改「整合包安装尚未开放」);host 侧那条 `InstallProgress` 失败文案同步去编号。
- **图标**:四类改为 `Folder` / `Plus` / `Restore` / `Expand`——不借用导航五枚(同屏撞形,
  复审 P2-3)、不用填充字形(`no_hardcoded_tokens` 门禁);偏离表按**实际取值**重写,并补一条
  「CF 的装入也是禁用态」的登记。
- 演示数据加 `ULL_CONTENT_STATE=cf` 一屏,出图证明 P1-1 的禁用态(`12-cf-not-wired-light.png`)。

门禁(逐条落盘退出码,不走管道):fmt 0 / clippy `-D warnings`(app-iced+presenter+i18n)0 /
`cargo test -p unlucky-app-iced --lib` 0(451 passed)/ `cargo test -p unlucky-presenter` 0
(174 passed)/ `cargo test --workspace --exclude unlucky-app` 0。改动文件最大 397 行。

取证:`DevDocs/artifacts/m109/`(12 张,全部重出)
2026-11-05 03:20:22 +08:00
AskaEth 6b161227d8 feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)
用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。

- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
  ——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
  否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
  「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
  比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
  (Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
  翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
  等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
  整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
  一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
  core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
  自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
  `ULL_CONTENT_STATE` 供出图矩阵选类型与状态

取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)

门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
2026-11-05 03:20:21 +08:00
AskaEth 5adac230fe Merge branch 'feat/m108-versionmgmt-rename-download-page-to' 2026-11-05 03:13:18 +08:00
AskaEth bbf96de9b3 feat(app-iced): M108 下载页显示名改成「版本管理 / Version management」
用户令(2026-11-04):当前下载页改名叫版本管理,下载页只负责下载整合包和 Mod/光影。
本 mission 只改显示层,标识符(Route::Download / Screen::Download / NavItem::Download /
文件名)一个都没动,标识符改名另登 finding 后置。

i18n(catalog/nav.rs + catalog/download.rs,金表同步):
- NavDownload = Versions/版本管理 —— 88px 收起轨用短名
- NavDownloadExpanded = Version management/版本管理 —— 240px 展开抽屉用全名
- DownloadTitle = Version management/版本管理 —— 页标题
- DownloadSubtitle = Manage versions and loaders: install, upgrade, repair/
  管理版本与加载器:安装、升级、修复
拆两条 key 的理由:18 字符英文 ≈101px 塞不进 88px 轨(实测两通道都被裁:产品通道只显示
Version,组件版轨被切成 Version manage);中文 4 字实测 48.0px(四种 CJK 字体同值)放得下,
所以中文两条同值、不缩。

- pages/nav.rs:NAV 第 3 列 &str → Key;label_key(label, expanded) 是两态切换点
  (用 bool 参数而非 enum:240 态在产品里还没有渲染路径,枚举变体永远构造不到会被
  -D warnings 的 dead_code 拦下)。
- shell/nav.rs:NavItem::Download 显示 Versions(产品路径的轨;i18n 接线归 M106,
  本 mission 只换文案值)。
- 守门:pages/tests.rs 断言导航/页标题取值 + 两态切换 + 目录里凡指向这一页的文案都不许再说
  「下载」(豁免 DownloadStageDownloading);shell/nav.rs 断言标签是 Versions 且 ≤12 字符。
2026-11-05 02:54:04 +08:00
AskaEth c31036dea8 Merge branch 'feat/m107-dlvrail-version-management-page-dua' 2026-11-05 02:35:50 +08:00
AskaEth c560b66df6 chore(app-iced): M107 rebase 到 M102 之后的收尾(文件行数 + 未用 import)
解 rebase 冲突时把 M102 的 `extra_width_goes_to_the_page_block_*` 留在
`download/tests.rs` 会让该文件到 404 行(超 400 硬线),挪到 `download/tests/rails.rs`
(同一批判据,模块路径改 `crate::pages::frame::body_width`);顺手去掉挪走后多余的
`measure_cap_px` import。

无行为变化:只搬测试 + 删 import。门禁见报告(fmt 0 / clippy 0 / lib 481 passed / workspace 0)。
2026-11-05 02:02:22 +08:00
AskaEth f408bf5312 fix(app-iced): M107 r2 复审 —— clippy needless_lifetimes + 搜索中点桶立刻生效
- **P1-1**:`group::active_bucket` 的显式生命周期是多余的(`needless_lifetimes`),省掉之后
  `cargo clippy -p unlucky-app-iced -p unlucky-i18n -p unlucky-presenter --all-targets
  -j 2 -- -D warnings` 才真的	exit 0。上一轮报告的那条「clippy 本轮未复跑」正是红的根因——
  本轮把 clippy 纳入每次门禁(见报告)。
- **P2-1(b)**:搜索期间第二级侧栏原来「可点但没反应」(一致性放宽让桶失效,点击要等清空搜索
  才生效)。现在**点桶 = 退出搜索 + 按该桶过滤**:`SelectMajor` 发现 `has_query()` 就先落选择
  再 `rebuild_after_query("")`,一次点击做完「我要看这个桶」这件事。
  判据:app 层 `clicking_a_bucket_during_a_search_leaves_the_search`(432 passed)。

门禁(真实退出码,无管道):
`cargo fmt --all --check` = 0;`clippy … -D warnings -j 2` = 0;`cargo test -p unlucky-app-iced --lib -j 2` = 432 passed / 0 failed
2026-11-05 02:01:12 +08:00
AskaEth 45544791e5 fix(app-iced): M107 r1 复审五条 p2 —— 搜索与主版本桶一致、chips 可滚、单复数、端到端
复审 r1(p2-5items / fix-then-merge)的五条可达问题,逐条落点:

- **P2-1** 搜索命中数与主版本桶不再自相矛盾:`group::active_bucket` 让第二级在搜索期间
  **自动放宽到「全部」**(选择本身留在模型里,清空搜索后回来),侧栏点亮「全部版本」⇒ 没有幽灵选中;
  `rebuild_after_query` 回填时校验桶仍存在(新桶列表里没有的选择直接丢弃)。
  判据:`a_search_hit_never_lands_on_an_empty_list`(4 分类 × 5 桶值 × 4 查询词的矩阵)+ 出图 11
- **P2-2** chips 档的主版本行套**横向滚动**(显式滚动条),竖栏与 chips 吃同一份 `major_entries`;
  22 桶夹具断言最后一个桶 `1.0` 在列表里,出图 12 看得到滚动条
- **P2-3** 只做搜索这条路径(`rebuild_after_query` + 校验 + app 层测试);`app/state.rs::refresh_view`
  的全量修法**留到 M105 之后**(不在 scope,且 M105 正改同一片文件)——报告里明写
- **P2-4** `1 matches` 修掉:新增 `VersionSearchHitsOne` / `VersionSearchElsewhereOne` 两条单数 key,
  「在别处」用独立标题(不再拿命中数当标题,同屏一个数只报一次)
- **P2-5** 搜索链路补 4 条 **app 层端到端**(键入 → 快照 → 重建 → 列表);变异自证:
  删 `self.state.download_query = query` ⇒ 2 条红;`from_snapshot` 忽略 query ⇒ 8 条红;
  去掉 `active_bucket` 的放宽 ⇒ 4 条红
- 顺带:64 宽图标条每项挂 tooltip(复审建议);`install.rs` 那段自相矛盾的文档订正

门禁:fmt 0 / app-iced lib 431 passed / workspace --exclude unlucky-app 全绿(见报告)
2026-11-05 02:01:11 +08:00
AskaEth 89a51a63b1 feat(app-iced): M107 DLVRAIL —— 版本管理页三栏侧栏 + 跨类型搜索
用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 tab + 平铺列表」
改成**页面内三栏**:版本类型栏 + 主版本栏 + 列表/详情,并加搜索框。

- **三栏几何**(`pages/layout/rails.rs`,纯函数 + 数值表):类型栏 140/64、主版本栏 140/64、
  间距 24、列表列下限 424(行宽红线)。四档由**可用宽**裁决:Wide ≥888 / Narrow ≥676 /
  Compact ≥600 / Chips <600,三个门槛都是 `栏宽 + 间距 + 424` 算出来的,不是拍的
- **主版本分组规则**(`download/group.rs`):正式版/预发布按 `major.minor`(`1.21.9-pre3` → `1.21`)、
  周更按年桶(`24w14a` → `24w`)、远古版**没有主版本**(`rd-132211`/`a1.2.6` → 第二级只有「全部」);
  判据用真实版本样本钉住,不按「第一个点」切
- **跨类型搜索**(`search_bar` + 清空 + 命中数):查询词写进 `AppState.download_query`(页面无状态,
  只能这么活),`hits` 是**跨分类**读数;三条口径分开:「有命中」/「没有匹配」/「命中在别的类型里」
- 两级侧栏都用 `navigation::list_item` / `tabs` / `chip` / `buttons::icon`:Tab 可达、hover/pressed/
  选中态全部来自组件,页面不自己写状态层
- 右栏展开(M104 `content_width` 852)⇒ 可用宽 716 ⇒ Narrow 档,列表 464(任务书那句 492 漏扣
  96px 的页边距与栏间距,报告里给了完整数值表)
- 文案 11 条中英进 `crates/i18n`(新域宏接在装配链末尾 ⇒ `Key::ALL` 只追加,金表只追加)
- 护栏:两层侧栏都存在 + 列表按第二级选中态过滤(变异可证)、三档门槛、424 下限扫描、
  版本行单行宽账、搜索条真的装着
- `install.rs` 只动了「已装」分支(加一句「已安装」解释禁用按钮),没碰 M105 的选项区

取证:`DevDocs/artifacts/m107/`(10 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版)
2026-11-05 01:59:13 +08:00
AskaEth 72b0c9d465 Merge branch 'feat/m106-i18nfollow-ui-follows-system-langua' 2026-11-05 01:39:57 +08:00
AskaEth 954a129e01 fix(app-iced): M106 r2 —— 两处「改了行为、旧口径还在树里」(复审 p2-1 / p2-2)
p2-1(它在撒谎):`renderer/acceptance/locale.rs::Chain::line()` 还打 `ui_text=pinned-en`,
注释写着「pages/mod.rs::render 里下载页固定传 Lang::En」、`setting` 回退 `<未接线>`——M106 之后
两句同时为假,于是 `ULL_ACCEPTANCE=1` 时启动日志两行互相矛盾(旧行说「文案钉在英文」,新行说
「生效中文」)。修法:`Chain` 增 `machine`(只看平台源 + 环境变量,供 `[env] language=` 用)与
`detection`(含 `ui.language` 手选,与 App 的 `[i18n] choice=…` 同源);`host()` 真读
`SettingsBridge::load_ui_language()`,`line()` 删掉 `ui_text`、`setting` 打真实值。
实测三行现在一致:
  [i18n] setting=<未设置=跟随系统> platform=registry:zh-CN env=env:<未设置> -> lang=zh-CN locale=zh-CN source=registry
  [i18n] choice=system lang=zh-CN locale=zh-CN source=registry effective=zh-CN
`locale_tests.rs` 把钉假串的那条改成「不许出现 pinned-en」的回归绊线,并新增
`i18n_line_honours_the_explicit_setting_without_rewriting_the_machine_language`。

p2-2(doc-only):`pages/model.rs` 的「只是留口,产品路径先给 Lang::En」与 `pages/download.rs`
的「产品路径现传 Lang::En,接上探测结果时只改调用点」都已被实际代码推翻(`app/state.rs` +
`refresh_view` 在填、调用点已是 `model.lang`),按事实重写。顺带修掉 `fonts/cjk.rs` 里同样的
「设置层的读取点尚未接线」。

门禁:`cargo fmt --all -- --check` 退出码 0;`cargo test -p unlucky-app-iced --lib` 退出码 0
(409 passed, 0 failed)。全改动文件 ≤400 行(最大 269)。
2026-11-05 01:17:36 +08:00
AskaEth 89dcb6bdf3 feat(app-iced,i18n): M106 —— 界面跟随系统语言 + 设置页真实语言行
用户原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English(system)」。
两处根源:①M92 的探测链一直是对的,但 `pages::Model.lang` 在 `app/view.rs` 里写死成
`Lang::En`(`pages::render` 还把下载页写死成英文),探测结果没人用;②设置页语言行是硬编码
字面量 `("Language", "English (System)")`。

新增/改动(按层):
- i18n:新域 `catalog/general.rs`(24 条中英双份 key,装配链尾追加)+ `catalog/golden.rs`
  只追加不重排;`choice.rs`(`LangChoice`:跟随系统/手选 + D28 四级优先级真值表 + 字体优先级真值表)。
- app-iced 语言入口 `src/ui.rs` + `src/ui/lang.rs`:`LangState` = 用户档位 + 探测结论;
  启动判定 `ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文;两行日志(探测/生效 + 中文策略)。
- `app/state.rs`:`App` 持有 `LangState`,切语言 = 换目录 + 落盘 + 重建视图模型;
  `app/view.rs` 的 `model()` 收 `lang`,不再写死英文。
- 设置页:语言行换真控件(跟随系统 / 简体中文 / 英文,回显「界面当前使用:简体中文」);
  页头、左列分组名、逐实例空态、导航轨标签全部走 catalog。
- presenter:`settings_bridge_lang.rs`(`load_ui_language` / `persist_ui_language`)。
  「跟随系统」= **删键**(schema 允许值只有 zh_cn/en_us),读的是 `doc().get()` 而不是
  `effective_str`(后者会把默认值 `zh_cn` 当成用户手选,英文机器一启动就被切成中文)。

验证:408 条 app-iced lib 测试 + 28 条 i18n 测试 + 全仓 `cargo test --workspace --exclude
unlucky-app` 全绿;fmt / clippy `-D warnings` 干净;出图 6 张(en/zh × 浅/深 + 下载页对照)
在 `DevDocs/artifacts/m106/`(该目录被 .gitignore 忽略,按仓内惯例作为本地验收产物)。
2026-11-04 22:50:09 +08:00
AskaEth 777d02e7cb Merge branch 'feat/m105-dlflow-install-options-step-and-can' 2026-11-04 22:41:08 +08:00
AskaEth 609bcb3044 fix(dlflow): M105 r1 —— 取消后不再逐个派发剩余文件;取消证据补强;取消提示进 i18n
复审三条(p2-3items / fix-then-merge,其中两条按 p1 对待):

**P2-1 取消后仍为每个剩余文件各发一次请求(p1)**
`select` 先轮询左支 ⇒ `install_one` 第一次 poll 就把 `engine.download(…)` 派出去了;
只把标志检查写在右支,等于取消后「每一个剩余 item 都创建并至少 poll 一次」
(一次整文件 sha1 + 一次请求),盘上已就绪的还会第一 poll 就 `Ready` 并回调 `sink`。
修:`async move` 第一句就 `if is_cancelled(cancel) { return None; }`,这些 item 连
`install_one` 都不进。守门用例 `cancelling_mid_run_stops_creating_remaining_files`
(三文件全在盘上、并发 1、sink 首次回调即置标志 ⇒ 数 sink 回调次数):
修后 1,删掉那两行变异后 **3**(见报告,红/绿均实跑)。
模块文档同步改成与实现一致,并把复审点名的固有边界写进注释:取消瞬间最多有
`concurrency`(GUI 传 16)个 future 已经过了那道门,其中「盘上已就绪」的仍会跑完自己的
回调(靠 presenter 的安装代次丢掉这些进度事件),真在下载的会在同一次 poll 被取消。

**P2-3 真网络装置的断言比声称的弱,且有一条探的是恒不成立的路径(p1)**
① `.ullpart` 原来只断言「5 s 后不再增长」⇒「留下一个不增长的非 0 中间文件」会绿;
改成 `assert_eq!(parts_after, 0)`。
② 实例登记原来探 `<game>/instances`(根本不存在 ⇒ 恒 false,什么也没证明);
改成读 `LauncherLayout::profiles_config()` 的 `read_profiles()` 并断言 0 条。
③ 模块文档里那条探针的路径一并订正。
变异自证:留一个 20 字节 `.ullpart` ⇒ 红(`left: 20 / right: 0`);
走真 `instance::register()` 造一条 profile ⇒ 红(`profiles.json 里 1 条实例`)。

**P2-2 取消文案仍是硬编码中文,与新选项区同屏一中一英**
`presenter` 没有 i18n 依赖,所以不是搬常量能解决的。改成发稳定信号:
新增 `CoreEvent::InstallCancelled`(不带文本),`AppState::install_cancelled` 只计数;
app 层在 `refresh_view` 里比对计数、按 `Key::DownloadCancelled` 现求文本弹一次 Toast
(`pages::download::cancelled_toast`)。`dispatch.rs` 的中文 Toast 删掉。
语言入口收成 `app::view::ui_lang()` 一处(现恒为英文;M106 接上语言只改这里,
M106 合并前中文译文在产品路径不可达)。守门:`cancelling_announces_once_in_the_page_language`
(只弹一条、文案来自目录、不含中文、重复 refresh 不重复弹)。

门禁(真实退出码):fmt=0、clippy(--workspace --all-targets -j 2 -D warnings)=0、
`cargo test -p unlucky-app-iced --lib`=0(440 passed)、`cargo test -p unlucky-core`=0、
`cargo test --workspace --exclude unlucky-app -j 2`=0(57 个 target 全 ok,0 failed)。
2026-11-04 22:12:22 +08:00
AskaEth 30b1ff6f48 feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停
用户实跑反馈两条,本轮各自定位到根因并修掉:

1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
   `AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
   安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
   Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
   `AppCommand::CreateInstance`。
   - 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
     `AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
     缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
   - 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
     1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
   - 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
     模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。

2. 「点取消没反应」= 真 bug,两跳各断一半:
   - 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
     刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
     修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
     `InstallDone` 带代次回来,代次不符一律丢。
   - 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
     新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
     CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
     drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
     返回新的 `InstallErrorKind::Cancelled`。
   - 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
     「哪些留着、哪些一定没发生」说清(D21)。

实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。

文案全部进 i18n(中英,9 条新 key + 金表同步)。
2026-11-04 22:12:22 +08:00
AskaEth d690d09b84 Merge branch 'feat/m112-packexport-mrpack-and-curseforge-ex' 2026-11-04 22:01:07 +08:00
AskaEth 90b233088f feat(core): M112 整合包导出(mrpack 优先 + CF 清单,逐文件远程判定与内联兜底)
新增 addon::export(实例目录 → .mrpack / CF manifest.json):
- 逐文件三分:远程引用 / 内联进 overrides/ / 跳过(读不了、符号链接、路径不安全)。
  「能否被远程替代」= 目录能证明身份(Modrinth SHA-1、CF 指纹)**且**目标格式表达得了;
  证明不了的一律内联,绝不丢、绝不编,逐条进结构化报告(ExportReport)。
- game / dependencies 来自实例快照:versions/<id>/<id>.json 的继承链「叶子」+ 库坐标推加载器,
  不解析 <mc>-<loader>-<ver> 字符串(社区实例名不统一,那是猜)。
- .disabled 走路径后缀(不是 env.client=optional:后者装回来默认启用,跨启动器不保真)。
- CF files[] 只写能确定 projectID/fileID 的项;禁用文件 CF 表达不了,内联并记原因。
  CF modLoaders[].id 按官方写法:NeoForge 在 1.20.1 上写 neoforge-1.20.1-<ver>。
- 尺寸护栏:fileSize 超 u32 报 pack_limits;整包撞 ZIP64 阈值由 format::write 拒绝。

配套改动:
- pack/curseforge/fingerprint:MurmurHash2-32 + 0x09/0x0A/0x0D/0x20 过滤、seed=1、
  长度用过滤后长度、哨兵 811513880 短路;金标准由一份独立 Java 实现算出(双向验证)。
- pack/format/payload:PackPayload 加 with_sources(包内条目名 → 实例源文件),
  让「实例里的 mods/x.jar → 包里的 overrides/mods/x.jar」不用拼路径。
- pack/format:写盘时按未压缩总量守 ZIP64 阈值。
- pack/modrinth:禁用位改写路径后缀;fileSize 按 u32 校验。
- pack/manifest:PackLoader::curse_id_for 处理 NeoForge 的 MC 前缀特例。

测试:导出管线 10 条 + 往返保真 4 条(三种格式夹具;允许的差异逐条在测试文件头登记,
并在报告 notes 里给出)。M111 未合并,故本轮「导入」用 M110 的 read + 同形实例目录,
真 import_pack → export → import_pack 端到端留作欠账。
2026-11-04 21:32:37 +08:00
AskaEth 3ffb1c4fee Merge branch 'feat/m111-packimport-curseforge-mrpack-multim' 2026-11-04 21:30:40 +08:00
AskaEth 29f531db23 fix(addon): M111 r6 —— 失败文案按 rolled_back 分叉,补「回滚不完整」报告级用例
复审 r6 的两条(都在同两个文件里十来行):

1. 文案位自相矛盾
- 改前:`pipeline::failed()` **无条件**推「目标目录保持导入前的样子」,`record_rollback()`
  随后可能再推「有 N 项未能回滚(目标目录可能与导入前不同)」——同一份 warnings 自相矛盾;
  CLI `status_label` 也恒写「失败(已回滚)」。
- 改后:`failed()` 只落错误、**不宣称目标目录状态**;口径拆成两个具名入口——
  `abandoned()`(失败发生在碰目标目录之前,才说「目标目录未被写入」)与
  `record_rollback()`(完整 ⇒ `rolled_back=true` + 「恢复为导入前的样子」;不完整 ⇒
  `rolled_back=false` + 逐项列失败项)。CLI `status_label` 按 `rolled_back` 分叉成
  「失败(已回滚)」/「失败(回滚不完整)」;`keys.rs` 的状态文档与 pipeline 模块文档表
  同步改口径。

2. 新接线零测试(这是 fix-then-merge 的理由)
- 新增 `pipeline::tests` 三条报告级用例:
  `incomplete_rollback_never_claims_the_target_is_untouched`(`rolled_back=false` + warnings
  逐项列出 + **不含**任何「保持导入前的样子 / 未被写入 / 恢复为导入前」)、
  `complete_rollback_claims_consistency_exactly_once`、
  `pre_commit_failure_claims_untouched_only_via_abandoned`。

变异自证(本轮实测,均已还原):
- A:把 `record_rollback` 的分叉整体跳过(no-op)→ 2 条红(`失败项要逐条列出:[]`、
  `assertion failed: report.rolled_back`)。
- B:把 `failed()` 恢复成无条件推「保持导入前的样子」→ 2 条红,报错原文即自相矛盾现场:
  `["…目标目录保持导入前的样子…", "有 1 项未能回滚(目标目录可能与导入前不同)…"]`。
2026-11-04 21:19:58 +08:00
AskaEth 7f657d225c fix(addon): M111 r5 —— 基线读写同源(.ull)、plan.rs 拆回 400 内、中间产物/回滚/包身份
复审 r5 两条 P1 + 三条 P2。

P1-1 基线读、写走了两条路径(ext4 上是两个文件,Windows/macOS 上假绿)
- 写侧 pipeline 用 `LAUNCHER_HOME_DIR`(`.ULL`,启动器**数据根**的名字),读侧
  `PackBaseline::path()` 用 `GameLayout::ull_root()`(`.ull`,实例**游戏根**内的私有目录)。
- 现在读写都从 `ull_root()` 派生:`PackBaseline::path()`(绝对)+ 新增
  `PackBaseline::relative_path()`(相对,空根求 `ull_root()`),pipeline 的
  `baseline_relative()` 只做 `/` 分隔,不再出现第二份目录名字符串;`LAUNCHER_HOME_DIR`
  的引用从导入链路里删掉。
- 新断言 `baseline_read_and_write_paths_are_the_same_file`:`path(root) ==
  root.join(relative_path())`、`read.parent() == ull_root()`、写侧字符串不含 `.ULL`
  (路径比较是大小写敏感的,所以在大小写不敏感的系统上也抓得住)。
- 选 `.ull` 的理由:基线住在**实例的游戏根**里,那是 `GameLayout` 的地盘(且
  `crates/core/tests/game/layout.rs:45` 早已钉死 `.ull`);`.ULL` 是 portable 模式下
  exe 旁边的启动器数据根(`config/ data/ cache/ logs/`),层级不同,不能混用。
- 同时清掉 4 个测试里手抄的 `.ULL/pack-baseline.json`,改用 `baseline_rel()`。

P1-2 `import/plan.rs` 401 行撞穿 file-size 守门
- 把 `suffix_note()`(后缀注记)连同它的单测搬进 `plan/paths.rs`(那里本来就有
  `suffix_variant`),plan.rs 401 → 373;paths.rs 119 → 157。没有压注释换行数。

P2-1 引擎中间产物会被当成内容提交
- `Staging::purge_engine_artifacts()`:提交前递归剔除 `.ullpart-*` 与 `.ullmeta.json`
  (判据 `is_engine_artifact`,后缀取自 `core::download::atomic`),删掉的个数进报告注记。
- 测试:`StubFetcher::with_engine_artifacts()` 像真引擎那样在每次"下载"后留下残片,
  断言实例目录里没有任何 `.ullpart` / `.ullmeta`。

P2-2 回滚失败被 `let _ =` 吞掉
- 新增 `RollbackOutcome { attempted, failed }`:`rollback_merge` 逐项记账,
  `CommitGuard::rollback()` 返回它,提交阶段失败时 `CommitFailure { error, rollback }`
  把两者一起交回调用方;`ImportReport` 新增 `rollback` 字段,`rolled_back` 改成
  "回滚完整 / 从未写过目标目录"才为 true,不完整时 `false` 并在 warnings 里列失败项。
- CLI 人读输出相应分叉(`回滚未完整:N 项没回去`),不再无条件宣称一致。
- `Drop` 那条兜底路径仍拿不到调用方(文档写明是"防漏"不是"承诺")。
- 单测:`complete_rollback_has_no_failures` 与 `rollback_failures_are_reported_not_swallowed`
  (用非空目录占住目标位置,让 remove/rename 都失败,跨平台确定)。

P2-3 基线不校验包身份
- `PackBaseline::matches_pack(format, name)`:同一目录先后导入两个包时,别人的基线一律
  不拿来当 base(否则"用户改过"会被误判成"整合包升级"而静默覆盖),降级为"无基线"并
  记 `pack.import.note.baseline_pack_mismatch`。
- 测试 `baseline_of_another_pack_is_not_used_as_base`:断言告警 key + 冲突降级为
  pre_existing + 用户内容保留。

门禁(落盘读 $?):fmt 0 / clippy(workspace,all-targets,-D warnings) 0 /
test -p unlucky-core 0(363 lib)/ test --workspace --exclude unlucky-app 0(1993 passed)。
改动文件最大 373 行。core 依赖表未动(core-no-ui 不受影响)。
2026-11-04 20:54:05 +08:00
AskaEth 4760714743 feat(addon): M111 整合包导入管线(CF / mrpack / MultiMC)+ ull pack import
接 M110 的 pack 模型与安全闸,做导入:三格式 → 实例目录,落 D10 §2 三条硬要求。

core(`addon::import`):
- 三方合并:`merge::decide` 判据表(磁盘 × 基线 × 包内),base 侧落在
  `<实例根>/.ULL/pack-baseline.json`;无基线时磁盘同名文件算用户已有内容 ⇒ 不覆盖。
  冲突逐条进报告(user_modified / user_deleted / pre_existing + 可选处置)。
- 两阶段:先 overrides 再补远端;失败分级只有一条判据(是不是"某个文件搞不到"):
  缺文件 ⇒ Partial;穿越/符号链接/限额/清单坏/摘要不符/IO ⇒ Failed 且整包回滚。
- 事务:`Staging` 临时目录 + `CommitGuard`(全新导入 rename 整目录、合并态 journal 逆序
  回滚、Drop 即回滚),基线作为最后一个暂存文件同生共死。
- 三格式语义:mrpack `client-overrides` 胜 `overrides`、`server-overrides` 不解压;
  `.disabled`/`.old` 按路径后缀识别并原样保留;MultiMC `formatVersion != 1` 整份拒绝、
  非加载器 component 逐项进报告;CF `classId` 认不出就不猜。
- 加载器只做翻译(`LoaderTarget`:kind + 原版 + 加载器版本 + 版本 id,含 CF NeoForge
  `1.20.1-47.1.106` 的 MC 前缀剥离),网络全部经注入的 `PackFetcher`
  (生产实现 `DownloadEngineFetcher` 只走 core::download,无第二个 reqwest client)。
- 报告:三态 + 逐项原因 + 稳定文案 key 表(`report::keys`,含 ALL 守门表)。

CLI(`ull pack import <file> [--target] [--name] [--overwrite] [--no-register]`):
- 退出码三态 0 / 3 / 1(`Outcome::exit_code` + runner 透传,其它命令仍 0);
- `--json` 恒有信封(`data.status` / `data.error.code` / `data.stages`),Failed 不抛栈。

测试:29 条 core(三格式端到端、合并 6 条、必须先失败 8 条含双检互补样本与确定性
提交失败回滚、报告契约 5 条)+ 5 条 CLI(三态退出码、JSON 契约、既有命令回归、help)。
fmt / clippy(core+cli, -D warnings) 绿;文件全部 ≤400 行。
2026-11-04 20:54:04 +08:00
AskaEth 78bcc3038b Merge branch 'feat/m102-pagelayout-fix-column-alignment-row' 2026-11-04 20:19:45 +08:00
AskaEth 4b26a0a9dc test(app-iced): M102 r5 行距护栏追绑定(M4 假绿做死)+ 等价重构不再假红(M3/M6)
r4 复审的结论是「产品代码没问题,两条 p1 都在护栏的『抓得住』与实测不符」:

- **M4(真正的假绿,做死)**:`rows = rows.spacing(ui.space(Space::X1));` 只要另起一条语句
  就能绕过「只锁构建链」的旧护栏。现在 `list_column_spaces_rows_only_from_the_shared_constant`
  用 `function_region` 取整段函数,再用 `source::call_arguments` 拿每一处 `rows.spacing(…)`
  的实参,并用新增的 `source::derived_from` 顺 `let` / `Some(x) =` 绑定往回追(最多三跳):
  实参必须追回 `ROW_SPACING`。于是
  · 直接 `ui.space(spacing)`(来自 `if let Some(spacing) = ROW_SPACING`)→ 绿;
  · `let spacing = ROW_SPACING;` 再中转(H3)→ 绿;
  · 先把令牌挪进 `let extra = Space::X1;` 再 `rows.spacing(ui.space(extra))`(M4)→ 红。
- **M3(假红)**:日志接线护栏改用 `function_region`,于是 `wrap::breakable` 外面套一层同义
  包装(`fn soft(…) { mono_owned(ui, wrap::breakable(…)) }`)不再假红;整条链路都没碰
  `wrap::breakable` 照样红。
- **M6(假红)**:新增 `source::region_across`(多文件转调跟踪,`frame.rs` + `layout/block.rs`),
  把盒子原样搬进 `block::body_box` 再转调不再假红;`align_x` 改判**实参**(`Alignment::Start`
  与 `iced::Alignment::Start` 同等放行),于是搬进别的模块后路径写全也不假红。
- **M1/M2 按塔的裁定不加断言**:与 `frame::body` 逐像素等价的另写就是等价重构;docstring 写明
  「抓得住:语义变化;抓不住:逐像素等价的另写」。

最终变异矩阵(本轮自跑、每条跑完 `git checkout --` 回滚、白名单已核):
基线 429 passed / 0 failed;M4 红(只红 list_column_spaces_rows_only_from_the_shared_constant);
M3 绿;M6 绿;G 红(只红 single_column_pages_call_the_body_box_not_the_centred_one);
I 红(只红 the_page_wires_the_soft_breaks_into_both_rendering_paths);
旧 p1 复跑 A 红、D 红,等价重构 J 绿。
2026-11-04 19:53:45 +08:00
AskaEth f499961e5b test(app-iced): M102 r3 护栏补到「页面调用点」——回退 body/软断点接线一律红,等价重构不再假红
r2 复审抓的两条 p1 都是「守卫只到函数内部」:把页面调用点改回 frame::single_column(变体 G)、
或把日志页两处 wrap::breakable 接线摘掉(变体 I),420 条全绿,而视觉复审 ① / ③ 原样回来。
r3 用 r2 自建的 layout/source.rs 把守卫推进到调用点,并把三条 p2 的假红/假强一并修掉:

- 新增 `single_column_pages_call_the_body_box_not_the_centred_one`:四个页面文件
  (download / logs / instances / instances/detail,共 7 个调用点)必须出现 `frame::body(`、
  不许出现 `frame::single_column(`;narrow_detail 单独再断言一次。(变体 G → 红)
- 新增 `the_page_wires_the_soft_breaks_into_both_rendering_paths`:扫 `lines::entry` 与
  `diagnostic::summary_block`,必须含 `wrap::breakable(`(变体 I → 红);
  同时把 `log_text_carries_soft_breaks_but_keeps_the_original` 的 docstring 改成它实际守的东西
  (断点表 + 原文可还原),不再冒充「抓得住接线」。
- p2-1:`frame_body_is_built_from_the_block_quantities` 改用新的
  `source::function_region`(函数体 + 本文件内被转调的函数,最多两层)⇒ 把 body 拆成
  `body` → `body_impl` 的等价重构不再假红(H1 → 绿),换回 centered 仍红(A → 红)。
  顺带修掉 region 的泛型签名 bug:早先拿 `fn {name}(` 当定义 needle,
  遇到 `fn body_impl<'a>(` 会 panic(护栏自己假红);现在按 `fn {name}` 找定义,并补了一条
  `the_region_follows_delegation_into_a_generic_function` 自测。
- p2-2:`list_column_spaces_rows_only_from_the_shared_constant` 的禁词收窄到
  `let mut rows = column(…)` **这一条**构建链(新工具 `source::statement`,停在第一个 `;`),
  于是 `column![head, scroller]` 上加合法间距不再假红(J → 绿),行容器叠间距仍红(D → 红)。
- p2-3:`frame::body` 的 align_x 改成「出现就必须是 `.align_x(Alignment::Start)`」,
  不再被 `.align_y(Alignment::Start)` 顺手满足;「干脆不写 align_x」(默认 Start)视为等价重构放行。
- p2-4(措辞):`layout/block.rs` 文档写明 1440 上限是**有意行为变化**(≥1576 窗宽),
  1180/900 上才是零像素变化。

变异矩阵(本轮自跑,逐条回滚,`git status` 已核):A/C/D/E/G/I 全红且各自只红对应那一条;
H1(body_impl 转调)/H2b(删显式 align_x)/H3(ROW_SPACING 经局部变量)/J(head/scroller 加合法间距)
全绿。基线 `cargo test -p unlucky-app-iced --lib` = 426 passed / 0 failed。
2026-11-04 19:53:43 +08:00
AskaEth 824bb36f8e test(app-iced): M102 r2 护栏改成「抓得住页面换摆法」——块偏移/行距/散文接线全部有源码结构断言
r1 复审的两条 P1 都是假护栏:把修复原样回滚(A:frame::body 换回居中 424;
C:内缩 X2→X1;D:行距加回 4px)后测试仍全绿,因为断言只喂 frame_metrics() 与常量重算,
既不观察 frame::body,也不引用页面常量。本轮把「摆法」做成可断言的东西:

- 新增 `pages/layout/block.rs`:body_width(pane 宽封顶 1440)、body_offset(恒 0 = 贴左)、
  prose_width(= metrics.column ≤424)三个纯量 + 单测。frame::body / frame::prose 从它们建盒,
  frame::body_width 改成 pub use(不另算一份)。
- 新增 `pages/layout/source.rs`(cfg(test)):把某个函数的实现文本切出来。iced 的 Element
  没有几何内省,量不到「块摆在哪」,只能断言「照哪个数建的」;仓里已有 no_hardcoded_tokens
  扫源码、download/tests 用 include_str! 扫 key 的先例。
- 新护栏(每条都做过变异实测,见报告):
  · frame_body_is_built_from_the_block_quantities(A 变异红)
  · frame_prose_is_built_from_the_prose_width(E 变异红)
  · list_column_spaces_rows_only_from_the_shared_constant(D 变异红)
  · instances 几何断言改用页面真源 LIST_BOTTOM_INSET.dp() / ROW_SPACING(C 变异红)
  · the_detail_note_goes_through_the_prose_box(散文接线)
- P2:wrap::strip 的「会删原文自带 U+200B」写成限界 + 一条断言;docs 口径从 layout.rs
  挪到 layout/block.rs(layout.rs 399 → 389)。

零像素变化:改后 9 张图与 r1 的 m102-after 逐张 cmp 完全相同;2560×760 新出三张证明块宽封顶
1440(112..1551)且仍贴左。
2026-11-04 19:53:41 +08:00
AskaEth 89d98184c1 fix(app-iced): M102 页面几何 —— 正文块贴左吃满页宽、列表末行不再被裁半行、日志按词断行
① 正文列与页头不对齐(下载 / 日志):根因是 `frame::single_column` 把**行宽上限 424**
当成**整页块宽度**并水平居中(1180 下 1044 里只摆 424,左缘 422 对页头标题 112)。
现在两个量分开:`layout` 的 `column` 只管成段散文(≤424),页面块宽走新的
`frame::body_width`(= available / main_pane)。新增 `frame::body`(贴左吃满)与
`frame::prose`(散文锁 424)。1180×760 下下载 / 日志的块左缘 = 112 = 页头标题左缘。

② 实例列表末行被裁半行:页级行距 `Space::X1`(4) 让行节奏变成 64、与可视高度除不尽,
且滚动区直贴卡片 1px 圆角描边。行距归零(下载页同改)+ 滚动区底部内缩 `Space::X2`(8)
⇒ 可视区 580−32−8 = 540 = 9×60,Redstone Lab 及其副标题完整可见,第 10 行整体在裁切线外。

③ 日志断行把单词劈开(`graphics.gl_o` / `peration_failure`):新增
`layout::wrap::breakable`,在 `.` `_` `:` `/` `\` 后插零宽空格(U+200B 软断点),
日志行正文与一行摘要都过它;悬挂缩进不变(前缀仍是独立文本控件),
`Line::text` 与 `Logs::summary()`(剪贴板)仍是原文。工具函数放 `pages/layout/`:
断点是「列宽之后」的另一半排版判据,纯函数、可单测、不碰控件树。

护栏:`body_block_starts_at_the_header_title_and_fills_the_page`、
`list_viewport_fits_whole_rows_and_still_overflow`(把 580/32/8/540/60 五个数钉住)、
`wrap::tests`(断点位置 / 不重复插 / 可还原 / 幂等)、`log_text_carries_soft_breaks_but_keeps_the_original`。
`download/tests.rs` 的「宽窗留白」口径按新分工改成「宽度归块、不归散文列」。
2026-11-04 19:53:38 +08:00
AskaEth 8574d6ba2d Merge branch 'feat/m104-agentrail-right-side-agent-rail-thr' 2026-11-04 12:34:21 +08:00
AskaEth 9253684387 fix(app-iced): M104 r2 —— Route::Agent ⟺ Detached 的不变式 + 复审 P3 六条
p2-1(唯一 p2):`Route::Agent` 与 `RailState::Detached` 之前没有绑定——从左侧导航第五项
(本 mission 点名的入口)进独立页时档位还是 Collapsed/Expanded,于是页头出现「Open assistant」
「Collapse/Pop out」、挂着不化的未读胶囊、Esc 也收不走页面(复审用产品 bin 复现)。

- **单一入口**:`App::sync_agent_route()`(update/agent.rs)在 `refresh_view` 开头调用,
  两条对向规则:在独立页 ⇒ Detached(顺带清未读);离开独立页而档位还是 Detached ⇒ 收回右栗。
  每一次路由变化都经过 refresh_view,所以导航 / ULL_START_ROUTE / detach / back 共用这一处。
- **结构性兜底**:`conversation::actions` 先按宿主分流,`Host::Page` 的动作恒为「Dock back」。
- **启动顺序**:`boot_agent()`(窗口尺寸 adopt + 拆出档直达独立页)必须在 `dispatch(RefreshAll)`
  之前——那次 dispatch 会 pump→auto_select 把路由拉到实例详情,同步器随即把档位收回(实测踩到)。
- 测试:`entering_the_agent_route_detaches_the_rail`(走 `Message::Select(NavItem::Ai)` 真路径,
  含未读清零 / Esc 离开 / 从页上点别的导航收回)、`the_page_navigation_entry_also_detaches`。
  变异自证:把 `sync_agent_route` 改成 no-op ⇒ 3 红(含这两条)。
- 图 13/14:导航入口(折叠档带 2 条未读 / 展开档)进页后都是 Dock back、无未读胶囊、副标题单行。

P3-2 Enter/Shift+Enter 有测试了:分流抽成纯函数 `composer::binding_for`,
`enter_sends_and_shift_enter_breaks_the_line` 覆盖「发送 / 换行 / 未聚焦不抢 / 其它键交回默认」;
`AgentUi::edit` 里与 fallthrough 等价的冗余 Enter 分支删掉;三档测试改走 `agent.edit(...)`。
P3-4 自证式断言换字面量(320+8+852=1180 等);变异 RESIZE_GUTTER 8→0 ⇒ 6 红(含这条)。
P3-5 两条死文案真用上:8px 拖拽缝有了 tooltip(AgentResize + AgentWidthLabel「当前多宽」)。
P3-6 `on_boot` 的两块逻辑搬进 `update/agent.rs::boot_agent`:update.rs 372→383(+11,见报告逐行)。
P3-7 拖拽层补 on_press/on_exit:窗外松手不再僵住(窗内按下→清态;离窗→收在当前值)。
P3-9 顺手:只有几何/路由会变的消息才重建视图模型(每个字符、每次指针移动不再重建)。

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / 全文件 ≤400(最大 view.rs 373 之外
conversation.rs 383、update.rs 383)
2026-11-04 00:21:33 +08:00
AskaEth 8db1c5ab16 test(app-iced): M104 数值表补两行(900 Detached = 404、1180@360 = 812)
报告里贴的那张表要和测试里的表逐行对上:新增的两行证明「拆出后中栗拿回整窗宽,实例详情列 404」
与「360 档把实例页挤成单列(812 < 816 的两列门槛)」这两条结论都是断言的,不是手算的。
2026-11-04 00:21:32 +08:00
AskaEth 6eb2846313 fix(app-iced): M104 App::width 改名 rail_width,别撞 tests/no_hardcoded_tokens 的尺寸字面量门禁
那条门禁扫 src/{shell,pages,app},把任何 `.width(数字)` 都当成「布局尺寸写死」拦下来
——测试里 `agent.width(1180.0)` 被误判成控件调用点。改名后语义还更准(它返回的是右栗宽度,
不是「这个控件多宽」)。cargo test -p unlucky-app-iced(含集成测试)418 + 6 全绿。
2026-11-04 00:21:32 +08:00
AskaEth ecb3d08445 fix(i18n): M104 折叠条标签的中文改成「助手」,别让 AgentStripLabel 两套译文相同
chinese_catalog_is_actually_chinese 抓到的(en == zh 就是漏译):`("AI", "AI")` 在 48px 折叠条上
两边都能排下,但中文表里塞一条与英文逐字相同的条目等于漏译。中文用「助手」(12px 下 24px 宽,条上放得下)。
2026-11-04 00:21:32 +08:00
AskaEth c3f20784a8 feat(app-iced): M104 AGENTRAIL —— 右栗三档 + 布局数学 + 独立页复用同一会话视图
用户定案(D16):右侧常驻可折叠、允许拆分成独立页、中栗宽度可调(磁铁吸附)。
本 mission 只做壳:三档状态 + 布局数学 + 一份会话视图;真数据仍归 M103,一行 presenter 都不调。

- 右栗三档:Collapsed(48) / Expanded(280~360,默认 320,锚=令牌 ai_panel_width) / Detached(Route::Agent);
  会话、草稿、未读在切档时一条不丢(拆出去再收回,话说了一半还在)
- 布局数学:中栗 = 窗口 − 右栗 − 拖拽缝(8),下限 472(= 行宽上限 424 + 两侧页边距);
  右栗一展开,页面按更窄的宽度重新排版(收缩而不是溢出);拖拽磁铁吸附到 280/320/360,超上限夹紧
- 独立页与右栗共用 conversation::view 这一份实现(输入=纯数据,输出=agent::Message)
- 控件:多行输入框(Enter 发送 / Shift+Enter 换行,走 text_editor::key_binding 的 modifiers 分流)、
  发送禁用态、流式中断、未读点 + 可读计数、⌄ 收起 / ⇥ 拆出 / ⇤ 收回;Esc 收栗、Tab 焦点环、Enter 激活
- 文案全部进 crates/i18n 的新域 catalog/agent.rs(+ golden 追加 20 行),零硬编码中文
- 取证:ULL_AGENT_RAIL / ULL_AGENT_FIXTURE / ULL_AGENT_WIDTH(截图走产品 bin,标题栏与三键可见)
- 消息不走 pages::Message(外壳级视图),走 app::Message::Agent(..) → app/update/agent.rs
- 删掉 M68 骨架回落(shell::view + shell/fallback.rs):Route::Agent 现在有真页面

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / app-iced lib 418 条全绿
2026-11-04 00:21:32 +08:00
AskaEth 875ac159ee Merge branch 'feat/m103-agentstream-presenter-agent-rail-mo' 2026-11-04 00:21:22 +08:00
AskaEth a2b1a06901 test(presenter): M103 r2 —— 出站脱敏补守卫,另三处站点与 Bearer 右边界
P1(守卫缺失,产品代码是对的):
- 新增 RecordingTransport + outbound_prompt_is_scrubbed_before_it_reaches_the_backend:
  断言真正发给后端的那份 prompt 里没有 JWT(rail 侧也脱敏,「界面干净」证明不了出站安全)
- 变异:删掉 agent_submit 的出站 scrub ⇒ 该测试红(输出见完成报告)

另三处同类站点(r2 P2-1):
- confirm_tool_name_is_scrubbed(ConfirmRequest.tool)
- tool_message_from_the_backend_has_its_tool_view_scrubbed(MessageAppended 的 ToolCallView 分支)
- outbound_error_text_is_scrubbed(Err 文案,provider 401 常回显 key)
- 三条各自变异后都变红

其他:
- bearer_right_boundary_is_pinned(tests.rs):钉住 mask_bearer 的停字符集,收窄成 [' '] 即红
- 文档订正:rail.rs 模块 §2 改成「两道闸门(出站/显示)各守一侧」+ 与 agent 侧的实际关系
  (名单逐字相同、path 语义相同、mask_bearer 是行为不完全等价的自写迁移;重复实现是欠账,
  不读成「已无差异」也不读成「已经漂移」)
- agent_set_draft 与 rail_text 各写明「draft 不在断言覆盖范围」及原因
- tests_red_line.rs 4 行 405 → 380;各文件均 ≤400
2026-11-04 00:21:19 +08:00
AskaEth 06461df91b fix(presenter): M103 r1 —— 脱敏闸门补守卫,P2 五条带走
P1(守卫缺失,产品代码本来是对的):
- 新增 agent/tests_red_line.rs:MessageAppended(绕过 agent_submit)、
  ToolCallFinished(name+summary)、非 JWT 形状的 details(键名 / 嵌套 / 数组 /
  值随 path 敏感)各一条;变异 A/H/C2 复跑确认变红
- tests.rs 拆出红线组(547 → 334 行,单文件 ≤400)

P2:
- scrub_details 补「值随 path 敏感」规则(对齐 agent::redact::redact_args)
- scrub_text 补短 Bearer 掩码(对齐 agent::redact::mask_bearer)
- pending_confirm_forces_busy_without_yolo 先 finish_stream 再断言(不再空过),
  变异 F 现在两条测试同时变红
- MessageDelta 越界下标 debug_assert;ToolCallFinished 没有 pending 时忽略,
  不再改写已收敛的历史条目
- Presenter::state_mut() 收窄成 #[cfg(test)],换成 navigate/back/toggle_ai/
  prefs_mut/agent_set_yolo/agent_set_draft 等窄入口
- agent_submit 成功后立刻 begin_turn() 置忙,事件投影完 settle_streaming() 重算,
  异步后端空窗不会再让用户点两次发送
- 补测:begin_turn 置忙、静默后端不留假忙、工具收尾不覆盖历史
2026-11-04 00:21:19 +08:00
AskaEth a8d4cce3e2 feat(presenter): M103 AI 助手栏视图模型与事件投影
- 新增 crates/presenter/src/agent.rs(Presenter 门面:agent_rail / agent_submit /
  agent_mark_read / agent_confirm + AgentTransport 端口 + 裁决审计镜像)
- 新增 agent/rail.rs:AgentRail(messages / unread / busy / draft / confirm / streaming /
  yolo / endpoint_label / error),四类 AgentEvent 的投影与唯一脱敏闸门
- 新增 agent/mock.rs:脚本化后端(正常问答 / 流式中途出错 / 需确认的写操作)
- dto/agent.rs:ConfirmRequest / Sensitivity + AgentEvent::{Failed, ConfirmRequested,
  ConfirmResolved};AgentEvent 去掉 Eq(details 含 serde_json::Value)
- state.rs:AiSession 迁成 AgentRail,apply_agent 走 rail.ingest + 未读规则(可见性 =
  独立页或栏展开);navigate/toggle_ai 展开时清未读
- 测试:agent/tests.rs 14 条(四条路径 + JWT 红线 + YOLO 语义 + 提交前置条件),
  state_tests.rs 补 4 条(Failed / ConfirmRequested / 未读可见性 / 用户消息不计未读)
- 两行声明补进 lib.rs(pub mod agent)与 dto/mod.rs(重导出 ConfirmRequest/Sensitivity)
2026-11-04 00:21:19 +08:00
AskaEth 15eec1160d Merge branch 'feat/m110-packmodel-canonical-pack-manifest-a' 2026-11-04 00:20:27 +08:00
AskaEth 34f3db359a fix(core): M110 r2 —— duplicate_override_paths 两种冲突同时报,补 mrpack 同侧分支用例
- p2-2:`duplicate_override_paths` 改成「先按 path 分组,再在组内报 (侧集合, 条目份数)」,
  判据 `侧数 > 1 || 份数 > 侧数`。r1 修好了跨侧(Both×Client)但按侧去重把「同侧两个组
  撞同一路径」这条旧能力换掉了(实测返回 []);现在两种冲突同时报得出来,返回值升为
  `Vec<(String, Vec<PackSide>, usize)>`(M111 注意:这是最后一次改形状的窗口)。
- p3-a:mrpack `check_overrides` 的「每侧只能有一个覆盖目录」分支补用例(此前零测试,
  删掉它 54 条全绿;现在删掉即红)。
- p3-b:把 ReadBudget 的「每次调用一份额度,不是整包一份」写进 `override_files` /
  `ReadBudget` 文档(mrpack 连调三次 ⇒ 理论上限 3×;要整包共用额度就走
  `override_files_with`;`open` 阶段的声明总量仍是整包一次)。
- 顺带(复审点名、各一行):mrpack 非表达目录的 hint 里 21 个连续空格;
  curseforge 文档里 `.minecraft/.minecraft/…` 的说法按 CF 语义订正(overrides 是 zip 内前缀,
  内容铺开不丢;要不要多拷一层取决于导入器)。
2026-11-04 00:20:25 +08:00
AskaEth 6dd652aced fix(core): M110 r1 复审修复:导出安全闸、跨侧重名、第二检用例、半包与失败分级
按 tower 裁定清单逐条改,不扩战场(模型与扩展点未动)。

- p2-1 跨格式导出不再静默丢内容:`PackFormat::check_overrides` 在默认 `write` 写第一个
  字节前判定"这份模型的覆盖目录集合能否被本格式表达"。mrpack 要求目录名由 side 决定
  (overrides / client-overrides / server-overrides)、CF 只接受单一 Both 侧组;表达不了
  即 `pack_unsupported` + hint。`format.rs` 模块文档写死"默认 write 只保证同格式往返",
  并列出 M112 必须补的四件事(目录映射、side 合并、加载器 id 规范化、extra 分桶)。
- p2-2 `duplicate_override_paths` 改为按 `path` 跨侧计数,返回 `(路径, 侧集合)`;
  Both×Client 同路径这条 mrpack 真实冲突现在查得出来。
- p2-3 补 `#[cfg(windows)]` 第二检用例(CON / NUL / COM1.txt / `a.` / 尾空格 / 通配符),
  并订正 `archive.rs` 文档里"保留设备名这里不拦"的自相矛盾(实际由 enclosed_name 在
  Windows 宿主上拦,第一检放行)。
- p3-1 导出改临时文件 + rename,失败清理临时文件:目标路径不再留半个 zip。
- p3-2 三个 `detect` 不再 `.ok().flatten()` 吞错误:清单在但读不出来 = `pack_metadata`;
  注册表汇总时"命中优先于失败",一个都不命中才报,最后才退化成 `pack_unsupported`。
- p3-3 `env_extra`、CF `files[].extra`、`PackLoader::extra` 各补一条读→render→写→再读用例。
- p3-4 新增 `ReadBudget`(Cell<u64>):`override_files` 持有累计预算,按实际读出字节
  逐条扣减,声明大小撒谎的包不能靠"很多条各自合规"绕过 `max_uncompressed_bytes`。
- CF 读侧始终产出一个 Both 侧 overrides 组(字段缺省 `overrides`),与 render 对称。

每处修复都有变异证据(改坏 → 对应用例红):p2-1/p2-2/p2-3/p3-1/p3-2/p3-3a-c/p3-4 共 9 条。
门禁:fmt 0 / clippy --workspace --all-targets -D warnings 0 / cargo test --workspace -j 4
exit 0(58 个 test binary,passed 1930,failed 0);全部改动文件 ≤400 行(最大 389)。
2026-11-04 00:20:24 +08:00
AskaEth b34e86bba5 feat(core): 整合包格式无关模型与格式扩展点(M110)
从零建 `core::addon::pack`:格式无关中间模型 + 格式扩展点 + 安全读取层,
不碰网络、不碰 UI(core-no-ui 依赖表未变)。

- `PackManifest` 无损表达 CF / mrpack / MultiMC 三方字段:包名/版本/作者/简介/
  格式版本/游戏版本/加载器依赖/文件条目/覆盖目录;`files[].hashes` 保留整张摘要表
  (mrpack 允许 sha1+sha256+sha512+md5 共存),`disabled` 记语义(导出按 D10 映射成
  env.client=optional,CF 落 required=false)。
- 未知字段按条目用 serde flatten 接住(清单顶层 / CF `minecraft` 对象 / 文件条目 /
  env / 加载器条目),导出时并回且已知字段优先;两格式各有一条「读→写→再读相等」测试。
- `trait PackFormat`(id / label / metadata_entry / detect -> Option<证据> /
  read / render / 默认 write)+ `PackRegistry`:注册表顺序即优先级,写死
  CurseForge → Modrinth → MultiMC;同一份包命中多格式时 `also_matched` 暴露歧义。
  测试里用一个 toy 格式(单文件、一行注册)证明扩展点真的可扩展。
- `PackArchive` 把目录与 zip 统一成规整条目表:路径穿越双检(自研七条规则 +
  zip crate 的 enclosed_name)、符号链接条目拒绝、条目数/解压体积/单条目上限、
  规整后重名拒绝、篡改载荷不当场吞掉;每条都有恶意样本测试。
- 格式实现:CF 可读写(缺路径如实记 None)、mrpack 可读写(强制 sha512 形状、
  env 三态、三个覆盖目录成三侧)、MultiMC 只读(.minecraft 当覆盖目录,
  组件顺序与非加载器组件全保留)。
- 三种格式各一个手造夹具(含截断 TOML / 半截 jar / 零字节 jar),来源在
  `tests/fixtures.rs` 里声明为手造,不冒充真实导出。
2026-11-04 00:20:24 +08:00
AskaEth 40a4c06d42 Merge branch 'feat/m118-slintexit-drop-legacy-slint-crate-f' 2026-11-04 00:09:41 +08:00
AskaEth 3e01e83c61 fix(docs): M118 r3 —— 撤回「AboutSlint 从未落地」的假前提,改为中性陈述 + 不作法律判断
复审 r3 的唯一 P1:r2 为了论证「未履行」,断言「`AboutSlint` 部件一直只有排期、从未落地」。
**这条是错的**,我先自己核了源码再改:

```
$ git grep -n 'AboutSlint' crates/app/
crates/app/ui/pages/settings.slint:4:// 关于页内嵌 AboutSlint —— Slint RF 2.0 许可义务(01c §8:关于页放官方部件)。
crates/app/ui/pages/settings.slint:15:import { AboutSlint, ScrollView } from "std-widgets.slint";
crates/app/ui/pages/settings.slint:274:                        AboutSlint { }
$ git grep -n 'AboutSlint' v0.1.0 -- crates/
(同上三行,v0.1.0 这个 tag 里就有)
$ sed -n '272,275p' crates/app/ui/pages/settings.slint
if root.section == SettingSection.about : Rectangle { height: 120px; AboutSlint { } }
$ sed -n '129p' crates/app/ui/pages/settings.slint   → SectionRow { label: "关于"; … }
$ grep -n 'RouteKind.settings' crates/app/ui/components/nav.slint → :114/:116(侧栏入口)
$ grep -n compile crates/app/build.rs → slint_build::compile("ui/main.slint")
$ grep -n SettingsPage crates/app/ui/shell.slint → :23 import / :273 route==settings 时渲染
```

即:部件真实存在、经 build.rs 编译进二进制、侧栏 → 设置页「关于」一点即到;`packaging/README.md:317`
「(已在 UI 内)」本来就与代码一致,本轮未改。r2 的依据只是三份文档的自述(旧 NOTICE「P2 排期」、
README「still outstanding」),那份自述不可靠。

改法(三处同一句,`packaging/release-notes.md` 不动):
- 删掉「只有排期 / 从未落地 / 尚未以任一形式落地」这类断言;
- 改成中性陈述:义务挂在每次「随应用分发」上;`v0.1.0` 的设置页「关于」分组里**确实内置了**
  `AboutSlint` 部件(`crates/app/ui/pages/settings.slint`,从侧栏进入);该部件是否满足 §2(a)、
  以及 v0.1.0 发布页上是文字链接而非 §2(b) 官方徽章图这两点,**本文件不作法律判断**,以 Slint
  官方许可原文为准;NOTICE / 发布页说明 / 本节都只是记录,不构成履行声明。
- 顺手按本机 registry 里 Slint 1.18.1 随包的 RF-2.0 原文把 §2(a) 补全:还允许「没有关于页时显示在
  启动画面」这一变体(原文 `In the absence of such a screen or dialog, display the widget in the
  "Splash Screen"`),r2 只写了前半句。

diff:`git diff --name-only` = NOTICE, README.md, README.zh-CN.md(3 份文档、零 .rs)。
2026-11-04 00:09:39 +08:00
AskaEth e827f7a0ef fix(docs): M118 r2 —— 发布页第二形态换成实测可用的 --renderer=cpu,NOTICE 停止声称 RF-2.0 已履行
复审 r2 的 2 条 P1 + 3 条 p2 小账,全部只动文档(5 份,0 个 .rs)。

P1-1(`SLINT_BACKEND` → `ICED_BACKEND` 换了个名字的死开关):
`packaging/release-notes.md` 的「No GPU」小节原来把 `ICED_BACKEND=tiny-skia` 当作
`--software-renderer` 的等价替代命令给用户。实测该值会被本进程无条件覆盖:
- `ICED_BACKEND=tiny-skia ./unlucky-launcher-iced.exe --screenshot …`
  → `[probe] … request=auto -> ICED_BACKEND=wgpu` / `[shot] … renderer=wgpu` / `[backend] final=wgpu`
- `--software-renderer --screenshot …` → `request=cpu -> ICED_BACKEND=tiny-skia` / `renderer=tiny-skia`
- `--renderer=cpu --screenshot …`      → `request=cpu -> ICED_BACKEND=tiny-skia` / `renderer=tiny-skia`
所以改成两条都实测可用的形态(`--software-renderer` / `--renderer=cpu`),不再把环境变量当用户开关。

P1-2(RF-2.0 履行方式的措辞与上游 §2 冲突):
`NOTICE` 第 1 条原写「由 v0.1.0 的 NOTICE、发布页说明与本文件的历史记录共同承担」——§2 只承认
(a) AboutSlint 部件 / (b) 公开下载页的官方徽章两种履行,NOTICE 文本不是履行。改为:义务仍挂在
v0.1.0 已发布资产上(条件挂在每次分发,不挂版本号);§2 只承认两种形式;**按本仓库记录,该项义务
在 v0.1.0 上尚未以任一形式落地**(部件一直只有排期,发布页那行是文字链接不是徽章图);NOTICE /
发布页说明 / 本节都只是记录。

p2 小账:
- `NOTICE` 第 3 条的依赖许可:`tiny-skia` 从「Apache-2.0 / MIT」改正为 BSD-3-Clause,并把
  winit(Apache-2.0)/ wgpu(MIT OR Apache-2.0)分别写明;README(中英)同款写法一并对齐。
- `packaging/README.md` §4:`libGL/libEGL` 行改成「Slint 版 femtovg / iced 版 wgpu(推断,未实测)」,
  并把限定语改成「除标注『推断,未实测』的那一行外,整表是 Slint 版实测」,消除表内与限定语打架。
- `README.md` / `README.zh-CN.md` 的 Slint 义务段补 `v0.1.0` 限定并按 §2 口径重写,避免读者以为
  「Made with Slint」说的是下一版。

证据:`git diff --name-only` = NOTICE, README.md, README.zh-CN.md, packaging/README.md,
packaging/release-notes.md;`packaging/README.md` 仍 399 行(守卫上限 400);release body 模板用
`{{VERSION}}=0.2.0` 渲染通过(5956 字符,全篇 Slint 命中 4 行,均在「本版不含 Slint」的解释段)。
2026-11-04 00:09:39 +08:00
AskaEth 7704a75424 fix(docs): M118 r1 —— release notes / NOTICE 停止自称 Slint 版,packaging README 补两处旧口径限定语
复审 r1 的两条 P2,都只动文档(`packaging/release-notes.md`、`NOTICE`、`packaging/README.md`)。

P2-1(发版阻断项):`packaging/release-notes.md` 是 Release body 模板,`NOTICE` 装在每个 tar.gz 里,
两者都仍自称 Slint 版:
- release notes:首段 `Slint + Metro UI` → `iced 0.14 + Material 3 Expressive UI`;「No GPU」小节从
  femtovg / `SLINT_BACKEND=winit-software` 改成 wgpu + tiny-skia / `ICED_BACKEND=tiny-skia`;
  删掉页面的 `Made with Slint` 徽章声明,换成「本版所有资产都由 iced 构建、不含 Slint 代码;
  Slint 版源码仅作历史参考、不构建不发布;RF-2.0 徽章义务跟着链接 Slint 的二进制走,本版没有」。
- NOTICE:第 1 条标题改「Slint —— 历史源码,不随任何产物分发」,正文明确「当前 GUI 框架是 iced、
  二进制不含 Slint 代码;crates/app 已被 workspace exclude,不参与构建/测试/发布」;RF-2.0 义务改成
  「对当前产物不适用,仍挂在 v0.1.0 那批已发布资产上」。**没有整条删除**:仓库里确实还有基于 Slint
  写的历史源码,且将来若重新链接 Slint 义务要恢复,留记录比删掉更安全——依据写进报告。
  另在第 3 条补一句「当前 GUI 框架:iced 0.14(MIT)+ winit / wgpu / tiny-skia」。

P2-2:`packaging/README.md`(净零行,仍 399 ≤400)
- §4 的 `libGL/libEGL` 说明从 femtovg 改成 wgpu 的 GL/EGL 后端 + tiny-skia 软渲染,并加一段
  「M118 口径」:那张表与 DT_NEEDED 结论是 Slint 版实测,iced 版**尚未重测**,按「同一族库、细节待验」读。
- §11 的本地复跑命令补限定语:`linux-smoke-docker.sh` 仍编/跑 Slint 版 bin,现在会失败;在它换成
  `unlucky-launcher-iced` 之前,本地照 ci.yml 的 runtime-smoke 三步手跑。段落未删。

证据:`git diff --name-only` 仅这三份;release-notes 的 `{{VERSION}}`(15) / `{{SUMS}}`(1) 占位符仍在,
模板渲染走查通过;`packaging/README.md` 399 行(守卫上限 400)。另按复审要求二次单跑
`cargo test -p unlucky-core --test launch pipeline_records_crash_report_artifact` → ok(0.16s)。
2026-11-04 00:09:39 +08:00
AskaEth cf8c6912ec chore(workspace): M118 —— crates/app 退出默认构建,发布改打 iced bin(Slint 版停发)
M118(D26):Slint 退役树 `crates/app` 不再进 workspace。目录与源码原样保留(不删一个文件),
只是配置上不再可编。

- 根 Cargo.toml:`[workspace] exclude = ["crates/app"]`。摘掉的是整棵 slint 1.18 + usvg 0.48
  依赖树(本机单路冷编 ≈14 分钟;并行超配时表现为 rustc 退 1 且零诊断,见踩坑记录 p2 T7)。
  Cargo.lock 由 cargo 自己重算:8639 → 6343 行(-2389/+93),`name = "slint"` 1→0、
  `name = "i-slint` 11→0、`name = "slint-build"` 1→0。锁必须一起提交:旧锁配 `--locked`
  会直接 `cannot update the lock file`(release.yml 与 ci.yml 的冒烟都带 `--locked`)。
- ci.yml:clippy/test/build 的 `--workspace` 自然不再覆盖 unlucky-app;runtime-smoke(Linux 与
  macOS 探针)打的是 Slint 版 `--bin unlucky-launcher`,该 bin 已不存在 → 换成 iced 版
  `unlucky-launcher-iced`(它的 cli.rs 照抄 Slint 版口径,`--data-dir` 仍被接受)。
- release.yml:**Slint 版停发**。旧写法 `--bin unlucky-launcher` 打的是 crates/app 的 bin,
  也就是说流水线一直在发 Slint 版;改为 `--bin unlucky-launcher-iced --bin ull`,Windows
  导入表自检的 EXE 指向构建产物 `unlucky-launcher-iced.exe`。对外产物文件名不变。
- make_artifacts.py:拆成 `LAUNCHER_BIN = "unlucky-launcher-iced"`(找产物)与
  `LAUNCHER_ASSET = "unlucky-launcher"`(落盘名),tar 内文件名/权限随之按对外名走。
- guards.yml / README(中英)/ packaging/README.md:逐条写明排除后的覆盖口径,历史实现段落
  全部保留并加限定语(不提供构建方式,也不暗示自救)。

门禁:fmt exit 0;clippy --workspace --all-targets -j 4 -- -D warnings exit 0(44.6s);
check --workspace --all-targets exit 0(5m20s,slint 命中 0 行);cargo deny check exit 0。
2026-11-04 00:09:38 +08:00
AskaEth 3f228ae092 Merge branch 'feat/m101-settingsmsg-settings-group-message' 2026-11-04 00:05:46 +08:00
AskaEth d3e959e754 docs(app-iced): M101 r4 —— 订正共享点成本数字(3 / 不可行 / 3 处)与三处 p3 措辞
r3 复审:那段「仍然共享」的成本数字与代码事实不符(三条填页线据此取 scope,故按 p1 处理)。
本轮**只改文档/注释**,无产品代码与测试逻辑改动(唯一非注释改动是 t2 断言的 message 字符串)。

- 第 4 条「读页外真值」:`States` 没有 `Default`,加字段会让 4 处 struct literal 全部 E0063
  (`pages/mod.rs:154/177/232` + `app/view.rs:70`)⇒ 把 model 塞进 `States` 是 **3 个共享文件**
  (含 M104 的落点 `app/view.rs`);改推「给 `settings::view` 加 `model` 形参」这个**真·2 个文件**
  的形状。并订正「在 `app/state.rs` 用真值初始化(1 个文件)」——今天没有语言真值源
  (`AppearancePrefs` 无 language 字段,唯一值是 `app/view.rs:128` 硬编码 `Lang::En`)。
- 第 5 条第 ② 条路(改 `Body` 返回 `Task`)**删掉**:`settings::view` → `pages::render` → `App::view`
  一路返回 `Element`(iced 0.14 的 view 亦然,且 `view(&self)` 改不了状态),view 期间产出的 `Task`
  没有出口;真做要 `settings/mod.rs` + `pages/mod.rs` + `app/view.rs` 三处且另造旁路,明确写「不建议」。
  只留第 ① 条(`app/update.rs` 的 `Message::Boot` → `on_boot`,1 个文件)。
- 第 6 条与 `state.rs::demo_groups` 文档:「harness 侧改两处」→ **三处**
  (`pages/mod.rs` 的 `page` / `page_with_appearance` / `app_with_appearance`,行 156/179/234)。
- p3:`message.rs` 里守门路径改成 `app/update/settings/tests.rs`(并点名 `settings.rs` 只是 mod 声明点);
  `appearance_section.rs` 的「绞线」错字改「绊线」;断言 message 的 `Some(appearance::body)` 改成源码
  实际的 `Some(body)`(本地 fn);`app/update/settings/tests.rs` 的「抓不住 #2」改成两种洞分开说
  (`|` 续行放行;花括号形状的多行 pattern 是**静默跳过**,不是误红)。
2026-11-03 23:59:07 +08:00
AskaEth c166944c8e docs(app-iced): M101 r3 —— 补「页外值 / 首帧取数」共享点,守门收窄,文档口径统一
r2 复审 P1:报告与代码文档的「仍然共享」清单只写了 3 项,漏了「读页外真值 / 首帧取数」这半边。
本轮按复审给的第一条路(文档/契约层面诚实交代,不动结构):

- `settings/mod.rs` 的「仍然共享」扩到 6 条,新增:
  4. 读页外真值 —— `States` 只有 appearance + groups,General 要显示 `Model.lang` 就得改
     `settings/state.rs` + `pages/mod.rs`(2 个共享文件)或在 `app/state.rs` 用真值初始化
     `Groups.language_appearance`(1 个);给出建议形状:一次性给 `States` 加 `model: &'a Model`。
  5. 首帧取数 / 异步发起 —— `Body` 返回不了 `Task`,组自己发不了首帧探测;两条路(`Boot` 分支派发
     1 个共享文件;或把 `Body` 返回类型加一条 `Task` 旁路,1 个文件一次性)与「不改的代价」都写明。
  6. 隐性代价:`Groups: Sync + Default`(`demo_groups` 的 `static LazyLock` 撑 harness),
     非 `Sync` 字段会让编译红在共享的 `settings/state.rs`;给了改法。
- `general.rs` 文档改成正确指引(消息加在本线的 `language_appearance.rs`,落点
  `app/update/settings/language_appearance.rs`,**不要**改 `pages/mod.rs` / `pages/message.rs` /
  `app/update/page_messages.rs`),并指向上面第 4、5 条。
- `tests/mod.rs` 的「接真控件改 3 个文件」订正为 2 个,并删掉对**已被删除**的
  `only_the_appearance_group_carries_a_real_control_body` 的点名;`appearance_section.rs` 的
  模块文档与断言消息同步订正(`body = Some(appearance::body)`)。
- 守门 `the_group_dispatch_has_no_catch_all_arm` 判据**结构化收窄**:用大括号深度计数只看
  `match message {` 本层(深度 1)的臂,嵌套 match 整段跳过、`|` 折行 or-pattern 续行放行;
  只有「本层里含 `=>` 且不以该组变体路径开头」才算兜底。新增两条守门自测:
  `the_guard_ignores_nested_matches_and_folded_or_patterns`(复审 P2-3 的假红探针)与
  `the_guard_still_catches_both_catch_all_shapes`(`_ =>` 与 `other =>` 仍要红);
  误伤类别如实写进测试 doc 的「抓不住什么」。
- P3:`java::Message::Ping` 的文档改成指向真实存在的绊线(`tests/java_section.rs` 的
  `the_java_group_is_still_demo_rows`);`demo_groups()` 文档写明 `Sync + Default` 的隐性要求。
2026-11-03 22:48:05 +08:00
AskaEth 029ece3f17 feat(app-iced): M101 r2 设置页状态漏斗 + pages/mod.rs 拆分 + r1 复审订正
r1 复审的 P1:消息漏斗只解决了「加一支消息」,一组要画真值仍要改 `settings/mod.rs`
(`Group.body` 与 `view` 只认 `appearance::State`)。本 commit 补齐与消息漏斗同构的状态漏斗:

- 新增 `pages/settings/state.rs`:`Groups`(一个组一个字段,字段类型在组自己文件里)+
  `States`(按值传给渲染器的只读视图:外观状态 + 组状态)+ `demo_groups()`(harness 的
  `'static` 默认状态)。
- `Group.body` 签名改成 `for<'a> fn(&'a Ui, States<'a>) -> Option<Element>`:每组自己从
  `States` 取自己要的数据,「没有可画的状态」返回 `None` 由 `form` 退回演示行。
  `appearance` 组照旧接 `pages/appearance/section.rs`(M93 路径行为不变)。
- `App.settings: pages::settings::Groups`(`app/state.rs` 字段列表末尾)+ `app/view.rs` 构造
  `States` 走新入口 `pages::page_with_settings`。加/改某一组的字段只改那一组的文件。
- 四个组各自的 `State` 落在各自文件;`java::Message` 留一支只有测试构造的 `Ping`,配
  `app/update/settings/tests.rs` 的 `a_group_message_reaches_its_handler_through_the_funnel`
  ——消息漏斗第一次有**运行时**证据。

r1 其余条目:
- P2-1 删掉 `only_the_appearance_group_carries_a_real_control_body`(被四条 per-group 断言蕴含),
  并在同处写明删掉之后「唯一性」没人守;三份 section 文档从「改 3 个文件」订正为 2 个。
- P2-2 `pub mod settings` → `pub(crate) mod settings`(四个消息口模块 `pub(crate)`),
  并订正两处理由(lint 按类型自身可见性判,不是可达性)。
- P2-3 `pages/mod.rs` 397 行 → 拆出 `pages/message.rs` / `pages/screen.rs`(纯搬移,`pub use`
  保证 `pages::Message` / `Action` / `Screen` 路径不变),并删掉 M101 自己那 18 行重复文档。
- P3-1 订正共享点清单(`Key` 由宏从域文件累积生成,加 key 不改 `catalog.rs`)。
- P3-2 守门改成「除已知臂外任何含 `=>` 的行都算兜底」(`other =>` 也拦),并加「恰好四臂」断言。
- P3-3 `ai.rs` → `ai_about.rs` 单独一条 R100 改名提交,`git log --follow` 不断链。
- P3-4 `message.rs` 的「加一支消息改 1 个文件」订正为 2 个。
2026-11-03 20:57:08 +08:00
AskaEth 423ea77758 refactor(pages): ai.rs 纯改名 ai_about.rs(内容零变化,M101 r2 让 git log --follow 不断链) 2026-11-03 20:57:01 +08:00
AskaEth bcb277c1dd Merge branch 'feat/m100-settingsprep-harness-rest-state-set' 2026-11-03 20:00:56 +08:00
AskaEth f67b24cc7b fix(app-iced): M100 r4 P2-1 —— 终态判据加「取证模式」前提,交互预览关窗不再 exit 1
复审在 `d298691` 的导出上实测:交互预览模式(不给 `--screenshot`)关窗会被新判据判成失败,
exit 1 + 一句「取证结束但没有任何图落盘(窗口提前消失 / 落盘失败)」的**假消息**。
那是把「从未要求出图」判成「取证失败」,退出码因此失去区分力
(真崩溃 / 真取证失败 / 正常关个预览窗全是 1)。

- `no_evidence(capture_mode, written) = capture_mode && written == 0`;调用点传
  `request.screenshot.is_some()`。判据里写明:交互预览模式下 `subscription` 是 `None`、
  永远不会有 `Captured`,所以「没写盘」在那里不是失败。
- 注释补一条「为什么用计数器而不是 `path.exists()`」:旧 PNG 会让 exists 骗过判据。
- 断言改名 `finishing_without_a_single_write_is_a_failure_only_while_capturing`,
  三条:`(false,0)=false`(这次的红线)/ `(true,0)=true` / `(true,1)=false`。

顺手 p3:
- p3-1 `CAPTURE_GIVE_UP_FRAMES` 注释删掉「约 3~4 秒(~120ms/帧)」这个没测准的数字,
  改成写明帧间隔量级差异(复审实测 2~3ms/帧 vs 本机 `[perf]` 的 ≈200~300ms/帧),
  「要调按第 3 帧派发→第 4~5 帧落盘的结构关系调,别按秒」。
- p3-2 旧字段名 `writes` 的注释改成「这个计数器(旧字段名叫 `writes`)」;顺带把
  `capture_stalled` 的形参 `writes` 改名 `written`,与字段对齐。
- p3-3 `examples/pages_demo.rs` 的 usage 漏 `--appearance`/`--passthrough`:该文件**不在本
  mission scope**(scope 只有 `pages/harness*`),不扩 scope,欠账仍记在已开的 finding 里。

实测:
- 交互预览(WM_CLOSE 关窗,取真实退出码):最终二进制 5/5 有效跑 exit=0、`没有任何图落盘` 行 0;
  另 2 次 exit=0xC000013A(STATUS_CONTROL_C_EXIT,控制台 Ctrl 事件打断,日志停在半途)不计。
  修前同场景 4/4 exit=1。
- M3 变异(第 3 帧 `window::screenshot` → `window::close`)3/3 exit=1 + 具名行(没被误伤)。
- M4 变异(`CAPTURE_GIVE_UP_FRAMES` 30→0)2/2 exit=1 走「采样超时」(没被误伤)。
- 正常取证 `--appearance=real` ×6:exit=0 / png=yes / `落盘次数=1` / `passthrough_ext=on` /
  错误行 0 / sha256 `414a71fcb77e9b38…`(与 r1~r4 基线逐字节相同,像素没动)。
2026-11-03 19:20:50 +08:00
AskaEth d298691c17 fix(app-iced): M100 r3 P2-1 —— 判据从「帧超时」升级成「终态无证据即失败」
复审在 `95530a5` 的导出上用变异实测证明:把第 3 帧的采样换成 `window::close(id)` 时,
进程 **exit 0 且没有 PNG**,日志与成功跑同一形状只少了出图那几行 —— 只看退出码的脚本会读到
「成功 + 零证据」。

根因:`window::frames()` 只在 `Event::Window(RedrawRequested)` 时产消息,窗口消失 ⇒ 不再有帧
⇒ `Message::Frame` 不再产生 ⇒ `capture_stalled` **永远求值不到**;同时 iced_winit 对「最后一个
窗口 Destroyed」直接 `Control::Exit`,`run()` 返回 `Ok(())` ⇒ 例子返回 SUCCESS。
我原来在 `CAPTURE_GIVE_UP_FRAMES` 的注释里把「窗口消失」写进了覆盖清单,实测是**说反的**。

改法(后置不变量,比超时强):
- 落盘计数改成共享 `Arc<AtomicU32>`,**只在 `shot::save` 成功后**自增(U1:不是「打算写」)。
- `run()` 里 `outcome?;` 之后加终态判据 `no_evidence(written) == 0` ⇒ 打具名错误并 `exit(1)`。
- 帧超时兜底保留(管「有帧但采样回不来」);注释订正为三路:后端报错 ⇒ Err(本就非 0)、
  窗口消失 ⇒ 帧不再产生 ⇒ 由终态判据兜住、有帧但采样卡住 ⇒ 帧超时。

实测:
- 正常路径(最终二进制 ×6):exit 0、PNG 存在、`落盘次数=1`、`passthrough_ext=on`、sha `0d7b4f5b`、错误行 0。
- **M3 变异复现**(采样换成 `window::close(id)`,×3):**exit=1**、**PNG 不存在**、
  `[harness] 取证结束但没有任何图落盘(窗口提前消失 / 落盘失败),按失败退出`。
- **M4 变异**(`CAPTURE_GIVE_UP_FRAMES` 改 0,×2):仍 exit=1 + `采样超时` ⇒ 旧兜底没被改坏。
- 新断言 `finishing_without_a_single_write_is_a_failure` 守终态判据(变异:删掉 `run()` 里那条
  检查不会让它红——它守的是判据本身,不是调用点,报告里写清了「抓不住」)。
2026-11-03 17:34:14 +08:00
AskaEth 95530a5311 fix(app-iced): M100 r2 p3-b —— 采样回不来时红着退出,不再挂死
r1 的一次性闸门把「每帧重试」换成了「只试一次」,于是若 `window::screenshot` 的 Task 根本
产不出 `Captured`(窗口消失 / 后端报错),harness 既不再采样也不 `exit()` ⇒ CI 上表现成
**超时**而不是红灯。超时会被当成基础设施抖动重跑过去,红才会被人看。

- 新增 `CAPTURE_GIVE_UP_FRAMES = 30`(实测 ~120ms/帧 ⇒ 约 3~4 秒,远大于正常路径
  「第 3 帧派发 → 第 4~5 帧落盘」)。
- 新增纯判据 `capture_stalled(frames, writes) = writes == 0 && frames > CAPTURE_AT_FRAME + N`:
  不区分「一直没派发」与「派发了没回来」——两者对使用者的表现都是「没有图」,都该红着退出。
- 触发时打一行具名错误并用 `std::process::exit(1)` 退出:iced 0.14 没有「带退出码结束」的入口
  (`iced::exit()` 恒 0),而这条的全部意义就是让 CI 看到红。

实测:
- 正常路径 ×5:exit 0、`落盘次数=1`、`passthrough_ext=on`、sha `0d7b4f5b`,`采样超时` 行 0 次。
- 变异(N 改 0):两条新单测红(399 passed / 2 failed),真二进制跑出
  `[harness] 采样超时:已过 CAPTURE_AT_FRAME+0 帧(当前 4 帧)仍未收到 Captured,放弃并红着退出`
  且 **exit=1**(×3)。改回 30 后单测 401 passed、二进制 exit 0。
2026-11-03 16:23:13 +08:00
AskaEth 839a142db0 fix(app-iced): M100 r1b —— 把 unsafe 收回唯一标记文件(IsWindow 搬回 transparency.rs)
复审实测抓到:`transparency/exstyle.rs` 里写了 `extern "system" { fn IsWindow }` 与
`unsafe { IsWindow(hwnd) }`,而该文件**没有** `#![allow(unsafe_code)]`。它编得过是因为 lint
级别沿模块树继承;`guards.yml` 的 unsafe-scope 判的是「**带放行属性**的文件集合恰好等于
transparency.rs」⇒ 门禁照样绿,但「全仓唯一允许 unsafe 的文件」这个**性质**已经破了。
这正是「门禁通过 ≠ 性质成立」。

做法(与既有 `raw_ex_style` 同款,不加新面):
- `transparency.rs` 的 `extern "system"` 块里补 `IsWindow` 声明,并新增安全包装
  `pub(super) fn window_exists(hwnd) -> bool`(0 / 无效句柄 → false)。
- `exstyle.rs` 改成 `if hwnd == 0 || !super::window_exists(hwnd) { return None }`,
  自身**零 unsafe、零 extern**;文档里也不再出现 `unsafe {` 字面量,好让「全仓 unsafe 块
  位置集合 ⊆ {transparency.rs}」这句话能直接用 grep 复核。
- 行数:transparency.rs 385 / exstyle.rs 107(都 <400);测试仍 399 条,无增删。

三态行为不变(重跑对照):默认 `passthrough_ext=on` ×5、`--passthrough=off` 为 `off` ×3;
两者 sha256 都是 `0d7b4f5b…`(再次印证 hash 对密闭零判别力);真控件图仍 `414a71fc…`。
2026-11-03 15:48:09 +08:00
AskaEth 380fac0324 fix(app-iced): M100 r1 返工 —— 一次一采样 + OS 层密闭指标 + 文档口径订正
三条复审意见 + 六条 p3,全部落在原有文件里(另加两个拆分文件)。

P2-1 截图落盘「最后写入者说了算」(真竞态)
  `Message::Frame` 以前在 `frames >= CAPTURE_AT_FRAME` 之后每帧都发一次 `window::screenshot`,
  `Captured` 每次都覆盖同一文件才 `exit()`;已派发的采样不会因 exit 撤回 ⇒ 落盘内容是调度结果
  (复审实测 12 次里 7 次写了两次;把窗口在两次采样之间改成 900×600,落盘就是 900×600)。
  现在闸门 = `should_request_capture(frames, dispatched, capture_mode)`,派发即置位、只开一次;
  `Captured` 计数落盘次数并打进日志。实测 12/12 次「[shot] -> 文件」行恒为 1、`落盘次数=1`。

P2-2 密闭证据换成 OS 层
  `platform/win32/transparency.rs` 加安全 getter `click_through`(unsafe 仍只在既有唯一落点);
  该文件因超 400 行又拆出 `transparency/exstyle.rs`(子模块继承父模块的 `#![allow(unsafe_code)]`,
  `unsafe-scope` 门禁判据不受影响)。日志三态 `[caps] passthrough_ext=on|off|unknown`:
  `None` 记 `unknown`,绝不记 `off`。实测默认 on 12/12、`--passthrough=off` 为 off 3/3。

P2-3 头注释「下放」→「新增」,并订正「接真控件改 3 个文件」
  (section 文件里的 body 绊线 + 该 section 的 `GROUP` 文件 + `tests/mod.rs` 跨 section 绊线)。

p3 六条:shell 断言说明改准(默认值那条归 cli 的测试)、harness 文档给「指针事件=0」加对照限定、
数字订正(纯搬移 234 / 新增 134)、三文件口径、ai+about 对本文件的两处必改、`Rest` 与真 Disabled
撞语义写进「抓不住」。

文档口径:「8 连跑全稳」不再当密闭证据——同 argv 开/关穿透 hash 相同(实测 0d7b4f5b 两边一样),
它只说明「帧静止 + 落盘确定」。密闭主证据是 `[caps] passthrough_ext=`。

顺带:`run()` 从 `harness.rs` 拆到 `harness/app.rs`(272 / 190 行)。搬移中丢过一条既有断言
`ensure_layered_refuses_a_missing_or_invalid_window`,被编译器 unused-import 警告抓出并复原。
2026-11-03 15:22:55 +08:00
AskaEth 2061296615 feat(app-iced,pages): M100 取证密闭 + harness 参数拆分 + 设置页测试按 section 下放
四件前置事,都是为了设置页正文四条 section 线能并行(不写任何 section 正文):

1. 取证密闭(两条通道各有各的机制,证据分开算)
   - 产品 bin:`cli::ForcedState` 加 `Rest`(静息态),给了 `--screenshot` 且没显式
     `--force-state` 时默认 `Some(Rest)`;交互模式仍是 `None`(`shell::overridden`
     与 `app/view.rs` 的 `model()` 各补一支)。
   - harness:截图窗口开 `window::enable_mouse_passthrough`(winit 的
     `set_cursor_hittest(false)`)⇒ 窗口收不到指针事件 ⇒ 全树控件恒为 Active。
     为什么用通道级而不是逐控件灌 forced:设置页「外观」区的滑块/文本框/裸 button 都不消费
     forced,逐控件做要改十几个组件文件,而新产品组件随时会漏。
   - harness 同时数指针事件并打进日志(`指针事件=N`):让「这张图见过几个鼠标事件」变成
     可读数字,替代「连跑几次 hash 一样」那种恒真的假绿。

2. harness 的外观真状态入口:`--appearance=real` 让设置页走 `appearance: Some(..)`
   (工厂态 + `ULL_APPEARANCE_*`),新入口 `pages::app_with_appearance`。
   `--screen=settings` 之前就有了,但 `appearance` 恒为 None,D30 要的那一屏根本不存在。

3. `pages/harness.rs` 400 行 → 拆 `pages/harness/request.rs`(`#[path]` 范式),
   纯搬移部分 240 行 + 新增密闭代码。拆分等价性:同一 argv 的 PNG sha256 与拆分前一致。

4. `pages/settings/tests.rs` 按 section 下放成 `tests/{general,appearance,java,download}_section.rs`,
   mod 层只留跨 section 断言(每条写清守什么、为什么不能下放)。
   给某组加一条新断言 = 只改它自己那个文件。

顺带:`shell/mod.rs` 在 HEAD 就 399 行、`cli.rs` 394 行,两处都按同一范式把 tests 搬成
`shell/tests.rs` / `cli/tests.rs`(断言一字不动,逐字节可 diff)。

变异验证:把四处实现各改坏一处(默认 Rest、passthrough 默认值、overridden 的 Rest 分支、
view 的 Rest 映射),对应 4 条断言全红(390 passed / 4 failed),恢复后 394 passed。
2026-11-03 14:22:40 +08:00
AskaEth 2ad808d55c Merge branch 'feat/m99-settingsframe-split-settings-page-na' 2026-11-03 13:19:20 +08:00
AskaEth 330f7216fd refactor(pages,i18n): M99 纯搬移 —— 导航表抽 pages/nav.rs、设置页拆目录、catalog 按域拆开
- pages/mod.rs 400 -> 356 行:NAV 表与组件版 rail 搬进 pages/nav.rs(NAV 可见性变 pub(crate),
  只为让 pages/tests.rs 的三条断言原样保留)
- pages/settings.rs -> pages/settings/{mod,frame,general,appearance,java,download,ai,tests}.rs:
  mod.rs 只留「分组注册表 + 选中分组 + 内容分发」,一组一个文件,为四条 section 线各留位置
- i18n catalog.rs 310 -> 273 行:条目表按域拆到 catalog/<域>.rs,catalog.rs 留 catalog! 宏、
  装配链、Catalog 与对外 API;新增 catalog/golden.rs(拆分前 key->译文 全量快照)守门
- 行为/文案/样式零变化:pages_demo 的 instances/settings × 浅/深 四张图逐字节相同
2026-11-03 13:19:17 +08:00
AskaEth 4b1471a60d Merge branch 'feat/p6-macos-acceptance-one-shot-matrix-diag' 2026-11-03 13:18:48 +08:00
AskaEth 8153e54f87 fix(acceptance): M98 r1 —— [window] 绑定真 settings、语言维度补断言、boot.rs 拆分
复审 r1(reviewer-accscope-code,p2-2items / fix-then-merge)的两条阻塞 + 四条顺手的 p3。

## p2-1 `[window]` 的三个窗口属性曾是格式串常量(U1:常量冒充观测结果)

第一版把 `resizable=true decorations=false transparent=false` 写在格式化串里,与交给 iced 的
`window::Settings` 没有任何绑定;定向变异把 `settings.decorations` 改成 `true`,**371 个测试全绿**,
而验收包里 `[caps]` 与 `[window]` 会互相矛盾。

- 新增 `acceptance::window::WindowFacts` + `from_settings()`:字段只能从**真实 settings** 抽出来,
  没有手抄入口;`window_text()` 逐字段打印。
- 窗口设置收成单一产地 `app/boot/diagnostics::window_settings()`,`launch()` 与 `[window]`/`[caps]`
  两行日志共用同一个 `&window::Settings`;`pages/harness.rs` 同样从自己那份 settings 取。
- 守门测试 `window_settings_match_what_the_acceptance_logs_claim`,含「from_settings 必须逐字段
  搬运」的反向断言。变异实证:`window_settings` 的 `decorations → true` ⇒ 红并点名;
  `from_settings` 里写死 `decorations: false` ⇒ 红(第一次写测试时这条漏抓,补强后实测红)。

## p2-2 语言维度空转、且没有任何结论断言

`lang::resolve` 是 setting → 平台源 → env,macOS 的 AppleLanguages 几乎恒有值 ⇒ `LANG=zh_CN` /
`LANG=en_US` 两档同值,而原脚本只断言「`[i18n]` 这行存在」——整条链退化成
`source=default lang=en` 时核心集一条都不红。

- 脚本加两条结论断言:`^\[i18n\].*platform=applelanguages:[A-Za-z-]+` 与 ` source=applelanguages`
  (退化日志上实测红、健康日志上实测绿)。
- `REPORT.md` 语言行下注明:env 优先级低于平台源,正常 Mac 上两档同值,这一维验的是「平台源
  真读到了 + 中文字体回退」,不是中英对照。

## p3(复审 + 第二路自跑复核同源的三条)

- `[perf] boot probe=0ms` → `probe=skipped`(没计时就不报数;降级重开那一轮改为**真计时**)。
  变异实证:把 skipped 写成 0ms ⇒ `perf_lines_carry_measured_millis` 红。
- `[env] gpu=` 排序改成 `DiscreteGpu > IntegratedGpu > 其它`:本机原来报核显,而 iced 实际选的是
  独显(现在报 `AMD Radeon RX 6600 XT [DiscreteGpu]`,与 `Selected:` 一致)。
- `[i18n] setting=<未设置>` → `<未接线>`(配置侧根本没读取点,写「未设置」等于把「我们没读」
  说成「用户没设」)。
- `[backend] final=` 失败行:`refuse` / `none` 两条路是 `final=none`(什么都没跑起来),只有
  `respawn` 才是兜底名;`pages/harness.rs` 不再写死 `via="refuse"`,改用共用的 `failure_via()`。
  实跑负例:`[backend] final=none result=failed primary=wgpu fallback=tiny-skia via=refuse`。
- 脚本 `[tick]` 那处唯一未锚定的 `field()`:加 `^` 锚定、正则改 `clock=[a-z0-9-]+`(真值是
  `idle-1hz`,旧 `[a-z]+` 把它截成 `idle`,靠前缀巧合通过);断言也改成 `clock=idle-1hz`。
- 脚本 `[backend] final` 取值改 `field_backend()`:优先 `result=started`(respawn 场景一条日志里
  有父 failed + 子 started 两条,旧 `head -1` 会把「这次没跑起来」写进报告)。
- 脚本 `exec 3>&1` 全程留痕(终端提示也进 `logs/00-run.log`)、跑前清空 `$OUT/shots/`、
  python3 缺失时在 REPORT 里写明「几何列未采」而不是留空。
- `renderer/mod.rs` 补一段依据:为什么「单项 ICED_BACKEND ⇒ 没有静默回退」成立(`iced_tiny_skia`
  的 `with_backend` 只接受 `tiny-skia`),以及它依赖 iced 实现细节的两层缓解。

## 结构

`app/boot.rs` 400 行零余量 → 拆成 `boot.rs`(248) + `boot/fallback.rs`(117,兜底/重开/退出码) +
`boot/diagnostics.rs`(110,窗口设置与四段报告);`run()` 回到「探测 → 字体 → 打印 → 起 iced」。
`acceptance` 再把 `[window]` 拆成 `window.rs`(142)。

门禁:`cargo fmt --check` / `clippy --all-targets -- -D warnings` / `test`(373 lib + 6 integration)全绿。
2026-11-03 12:59:57 +08:00
AskaEth f986981c12 feat(acceptance): M98 —— 八类诊断日志 + 一次运行的全覆盖验收矩阵
验收要的是「一次运行、多角度覆盖、输出能直接判读」,而不是把朋友当测试机反复问 7 个问题。
这一轮把产品进程能**自己观测**的那部分日志补齐,并把人工问题压缩到 3 条。

新增 `renderer/acceptance/`(三块,全部 ≤400 行):

- `mod.rs`:`[backend]`(plan = 判据,final = **结果**:`result=started|failed` + 具名错误 +
  `via=respawn|refuse|none`)、`[perf]`(boot 三段实测 / session / scene 帧率只算首帧之后的间隔)、
  `[theme]`(来源 = 哪个参数决定,系统那条写 `pending` 等异步真值)、`[window]`(**请求值**,
  字段名自证 `requested` / `scale_requested`);开关 `ULL_ACCEPTANCE=1`、尺寸覆盖
  `ULL_WINDOW_SIZE=1520x900` 与它的解析/夹取(含 `size_source=` 四态)。
- `locale.rs`:`[env]`(真适配器枚举 + 硬件判定;显示器写 `needs-window` 不猜)、`[i18n]`
  (四级链每一级的原始值 + 结论; macOS 走 `defaults read -g AppleLanguages`)。
- `visual.rs`:`[font] chain`(令牌声明的回退链,与已有的实际落点 `[font] platform=` 互补)、
  `[caps]`(`bundle` / `menu_bar_name` 从可执行文件路径**读**出来;`controls=self-drawn` 由
  `decorations=false` 推出)。

接线:`app/boot.rs` 在正确的时刻把真值喂进去(探测产物 / 窗口设置 / 实测 `Instant`),
`pages/harness.rs` 同一套前缀(含 `[perf] scene`),`main.rs` 用法里写出两个开关。

反模式的防线(M97 那类「在需要结果的地方打印请求」):每一格都有能改坏的断言
(`acceptance/tests.rs` + `locale_tests.rs`,共 20 条新增),失败路径的 `error="…"` 会把
wgpu 自带的双引号换成单引号,保证单行可解析。

本机(Windows)实测:auto → `[backend] final=wgpu result=started` + `Selected: AdapterInfo`;
cpu → `tiny-skia`;`WGPU_BACKEND=metal` 负例 → exit 1 + 无图 + `via=refuse`;宽屏覆盖生效。
macOS 分支(Metal / AppleLanguages / 菜单栏名)需真机复核,期望字符串见 `renderer/mod.rs` 文档。

验收脚本不入库(用户要求):落在 gitignore 的 `DevDocs/ops/macos-acceptance.sh`,
由 tower 拷到分发目录。
2026-11-03 01:09:25 +08:00
AskaEth 18e36c6514 Merge branch 'feat/p6-fix-macos-gpu-probe-silently-falls-ba' 2026-11-03 00:27:04 +08:00
AskaEth 53b27eac45 fix(renderer): M97 r2 —— 退出码透传拆成可测单元(构造 / 执行),4 个变异全抓
上一轮把降级重开的 env 钉住了,但 `respawn` 里还剩一段不可测的接线:`status.code()`
→ `process::exit`。它的失效方式不止「崩溃」,而是**安静地给出一个错结论**——CI 把失败
当成功(或反过来),然后有人基于它做决定。这类和今晚一直在打的东西同类,所以补上。

## 拆法

- `exit_code(Option<i32>) -> i32`:纯函数。`None`(子进程被信号杀死,只有 Unix 会这样)
  走兜底码 2,**不 panic、不当成功**。在 Windows 上也能测(测的正是「拿不到码」那一刻)。
- `fallback_command(program, args, fallback) -> Command`:program / args 是参数而不是
  在函数里读 `std::env`,唯一目的是让测试能喂假命令(`cmd /c exit 7`)验退出码。
  三路 stdio **显式** `inherit()`——`Command` 默认就是继承,但显式写出来,将来有人为了
  「安静」加 `.stderr(Stdio::null())` 时 diff 里看得见;降级那次的子进程日志必须和父进程
  同一路输出,否则验收脚本抓不到。
- `respawn` 只剩 `current_exe()` + 日志 + `exit_code(status.code())`。

## 新增测试(4 条,全部变异验证过)

| 变异 | 结果 |
|---|---|
| X1 `unwrap_or(0)`(拿不到码时当成功) | `signal_death_falls_back_to_a_nonzero_code` FAILED |
| X2 `Some(_)` 一律 0(不透传) | `fallback_command_really_passes_the_child_exit_code` FAILED(真起 `cmd /c exit 7`,父进程拿到 7) |
| X3 重开时丢掉 argv | `fallback_command_passes_program_argv_and_envs` FAILED |
| X4 重开时不传 env | 同上 FAILED |

复原后 `cargo test -p unlucky-app-iced --lib` = 337 passed。

**stdio 继承没法用 getter 断言**(`Command` 在 stable 上没有 stdio getter),所以它的证据是
运行时观察:交互降级那次,父进程的 `[gpu] 降级 → …` 与子进程的 `[probe] 本次是降级重开`
落在**同一个 stderr 文件**里(见报告 §运行时复核)。

## 顺带

- `boot.rs` 超 400 行(492),按仓库既有做法把测试拆到 `src/app/boot/tests.rs`
  (`#[path = "boot/tests.rs"]`,同 `pages/harness/tests.rs`):boot.rs 311 行、tests.rs 187 行。
- `tests.rs` 模块文档里留了**变异验证的工具纪律**:只在函数体内替换、把测试里的同一字面量
  排除在外——全局 `sed` 会连断言一起改掉,于是「实现和期望被换成同一个错值」,测试照样绿,
  **验证的是变异脚本自己**。M97 r1 第一次跑就这么被骗了三个变异(假绿第十种形态),
  这条写给将来改这套测试的人。
2026-11-03 00:26:56 +08:00
AskaEth fa9dd170d3 fix(renderer): M97 r1 —— 降级/取证两条路改成可被变异测试抓到的纯函数
复审在 `git archive 72f20d8` 上做了变异:删掉 `respawn` 的
`.env("ICED_BACKEND", fallback.env_value())`,**327 passed / 0 failed**,那条名为
`retry_fallback_flips_the_tier_to_cpu` 的测试照样绿。

名字说它守住了「降级后档位翻转」,实际它只把 `"wgpu"` / `"tiny-skia"` 两个字面量喂给
`backend_from_env` 和 `effective_panel_alpha`,**`respawn` 一行都没执行**(它 `-> !`,
走 `process::exit`)。这和 M97 要修的那行常量日志是同一个结构:**在需要结果的地方验了旁边的东西**。

## 改了什么

1. `respawn` 的两组 env 抽成纯函数 `fallback_env(fallback)`,返回值是
   **`[(&'static str, &'static str); 2]`**——数量由类型钉住(删一条直接编译不过,
   比测试更早的闸门),键与值由测试钉住。
2. 子进程那一轮的决策抽成 `renderer::decision::fallback_round(kind)`,**兜底恒为 `None`**
   (防无限递归重开),`boot_probe` 不再就地拼 `Decision`。
3. 失败分流抽成纯函数 `on_failure(decision, evidence)` → `Respawn(Kind) | Refuse`:
   交互降级 / 取证拒绝两条路各自被测试钉住(判据表第 8 行 vs 第 9 行)。
4. 补上复审点名的两处 0 覆盖:`renderer::strict`(剥兜底)与 `renderer::run_once`
   (传下去的是 `decision.primary`,且进程内只跑一次)。
5. 改名并改文档 `retry_fallback_flips_the_tier_to_cpu` →
   `each_backend_value_maps_to_its_tier_and_alpha`,明确写「本测试**不**守翻转」,
   并指到真正守它的那条。

## 变异验证(8/8 被抓到,全部在实现行上做,测试里的同一字面量不动)

| 变异 | 结果 |
|---|---|
| M1 删掉 `ICED_BACKEND` 那条(=复审的变异) | **编译期**红:`expected an array with a size of 2, found one with a size of 1` |
| M2 键打错 `ICED_BAKEND` | `fallback_env_carries_the_backend_and_the_gate` FAILED |
| M3 闸门键打错 `ULL_GPU_FALLBACK_TYPO` | 同上 FAILED |
| M4 闸门值写死(不跟 fallback 走) | 同上 FAILED |
| M5 `fallback_round` 又给兜底 | `fallback_round_never_degrades_again` FAILED |
| M6 取证启动改成也降级 | `evidence_start_refuses_instead_of_degrading` FAILED |
| M7 `strict` 忘了清兜底 | `strict_strips_the_fallback` FAILED |
| M8 `run_once` 传死值 | `run_once_passes_the_decision_primary` FAILED |

复原后 `cargo test -p unlucky-app-iced --lib` = 334 passed。第一次跑变异时 A/B/C 三个
「照样绿」是我脚本自己的 bug:`sed` 把测试期望里的同一字面量一起改了(自指式变异盲区),
改成只在函数体内替换才暴露真实结果——这条也记进报告。

## 顺带(p3-1)

`boot.rs` 模块文档里「字体栈不重算」是旧实现的残留:改成换进程之后,子进程会把字体栈、
配置、presenter 全部重算(实测首帧 493~531 ms),文档已按实际行为重写,并把判据表
第 8/9 行两条路的差异写进模块头部。

运行时行为未变(refactor 只做抽取),已复测:取证启动 + wgpu 不可用 → exit 1、无 PNG、
具名错误 + 「拒绝降级出图」;交互启动 → 具名错误 + 换进程降级 + `[probe] 本次是降级重开`。
2026-11-03 00:26:56 +08:00
AskaEth 31cdfd5bbd fix(renderer): M97 后端判据 —— 静默降级改具名错误,取证通道拒绝降级出图
M97 的入口是一份 macOS M1 真机报告:探测认出了 Apple M1(adapters=1 /
hardware=true),日志却写着 `renderer = auto (wgpu -> tiny-skia)`,看起来
像在跑 CPU。读源码发现那行是**常量**:`probe.rs` 按 `ICED_BACKEND` 未设就把
`"auto (wgpu -> tiny-skia)"` 打出来,与实际后端无关。真正的失败被 iced 吞掉:
`iced_renderer-0.14.0/src/fallback.rs` 的 `Compositor::with_backend` 把 wgpu 的
错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。

## 判据改为单项,不再交给 iced 回退

`ICED_BACKEND` 现在**永远是单项**(`wgpu` / `tiny-skia`):能跑起来就必定是它;
起不来就必然是 `iced::Error::GraphicsCreationFailed` 里的具名错误。判据是纯函数
`renderer::decide(request, probe, gpu_compiled)`,落在 `renderer/decision.rs`,
表驱动测试逐行钉死 1~6 行 + 「除强制 CPU / 未编 wgpu 外必须留兜底」的守门。
判据表(条件 / 选哪个 / 日志该打什么 / 谁能验证)在 `renderer/mod.rs` 顶部。

## 安全网改成换进程

第一版是「打印错误 → 在本进程再 run() 一次」,Windows 实测直接 panic:
`Create event loop: RecreationAttempt`(winit 一个进程只能建一次事件循环)。
改成 `respawn`:子进程只换 `ICED_BACKEND` + `ULL_GPU_FALLBACK`,父进程透传它的
退出码。子进程看到 `ULL_GPU_FALLBACK` 必须跳过探测,否则会无限递归重开。

## 取证通道不许自己降级

带 `--screenshot`(含 `--screen=gallery`)时 wgpu 起不来就**不出图**、退出码 1,
因为换了后端出的图会撒谎;交互启动才允许降级。每张图的日志现在打**实际**后端
(`[shot] … renderer=wgpu`),以前打的是请求值 `auto`——同一个病(在需要结果的
地方打请求)在仓库里出现了两次,这是第二次。

## 可观测性

- `log` 桥(`renderer/log_bridge.rs`)把 `iced_wgpu` 的
  `Selected: AdapterInfo{…}` 接到 stderr:这是本仓库第一次能自证「真在用 GPU」。
  按 target 分级(`iced_wgpu*` Info、`wgpu*` Warn),否则 Vulkan loader 会刷 90 行噪声。
- `renderer/preflight.rs`(默认关,`ULL_PREFLIGHT=1`):开窗前拿 wgpu 的具名错误。
  实测 Windows RX 6600 XT 上 took=717ms,所以不进默认路径。
- `app/backend.rs` 的档位判据跟着改成读单项;**真降级到 CPU 的那一次面板 α 现在会
  正确强制 1.0**(以前静默降级后仍按 GPU 档判)。

Windows 实测:auto → wgpu + `Selected: AdapterInfo{…backend: Vulkan}` 出图;
`--renderer=cpu` 逐字不变;`WGPU_BACKEND=metal` 造失败 → 具名错误 → 交互路径降级
不崩(首帧 531ms)/ 取证路径拒绝出图 exit 1。macOS Metal 路径需真机复核,
命令与期望日志行写在 `renderer/mod.rs` 的「真机验收」一节。
2026-11-03 00:26:55 +08:00
AskaEth 45cd44f756 Merge branch 'feat/p6-build-profile-fast-bins-and-ci-flags' 2026-11-03 00:23:29 +08:00
AskaEth f6e55c5c7e docs(build): 修正三处「文档说法与实测不符」的注释(M96 复审 P2-1/2/3)
只改措辞与注释,**profile 数值一个没动**(release.package 仍是 opt-level="z" + cu16,
fast 仍是 opt-2/thin/cu16/unwind)。

P2-1 `panic = "unwind"` 的理由原本写错了:原注释说「漏了这一行 `cargo test --profile fast`
起不来(test harness 依赖 unwind)」。实测三连否掉:`cargo build --profile release -v` 的
rustc 参数里**有** `panic=abort`、`--profile fast` 里没有(这行确实生效,可作证据);但
`cargo test --profile release -p unlucky-i18n --no-run`(abort 档)照样 Finished 并产出测试
二进制,`-v` 里 test 单元**没有** `-Cpanic=`,真跑 23 个测试全过 —— cargo 对 test 目标自己强制
unwind。所以理由改成「让 fast 档编出来的**二进制**走 unwind」,并把「能跑 cargo test」明确标为
**没有区分度**的证据。顺带记下 fast 继承 `strip="symbols"` ⇒ 能 unwind 但没有符号回溯。

P2-2 `[profile.fast.package."*"]` 的注释把机制说反了:原注释说「package 覆盖按 profile 分别
生效,release 的不影响 fast」。实测删掉这块后,fast 档的**外部依赖**直接变成 `opt-level="z"`
(`slotmap`:1 那次 3 个单元 opt-2 → 删除后 3 个单元 opt-z),说明 `inherits` 会把 release 的
package 覆盖一起带过来。所以这块是**承重墙**,删了依赖会静默退回 z。注释按实测重写。

P2-3 「收益全在 `codegen-units = 16`」删掉:三组数据的唯一自变量是 opt-level(两臂都是 cu16),
cu1→cu16 的收益本轮**没有同条件对照**,已明确标成未测、留待后续归因。同时补一句:GUI 相对旧
基线 +12.9% 里「代码增长」与「依赖 cu16」未分离(ull 只 +1.7%,在 +1~3% 口径内)。

注:P2-1 那条原始错误表述来自 tower 的 mission 任务书,不是 worker 的臆造。

验证:`cargo build --release --bins` rc=0(16m21s,产物字节与 §7.1 记录完全一致:
6,256,128 / 13,910,528 / 16,479,232),`cargo metadata` 通过,README 仍为 400 行(未破门禁)。
2026-11-03 00:23:26 +08:00
AskaEth b9eae2bb73 perf(build): 加 fast 构建档、依赖并行 codegen、验收脚本一次 --bins
三条慢因里两条落在构建配置上,发布档(release)的体积取向不动:

1. `[profile.fast]`(inherits = release):opt-level 2 / lto thin / codegen-units 16,
   日常迭代与验收用它,发布仍走 release。`panic = "unwind"` 是**必须显式写**的一行——
   release 是 panic = "abort",inherits 会照抄,漏了它 `cargo test --profile fast` 起不来
   (test harness 依赖 unwind)。
2. `[profile.release.package."*"]`:依赖的 codegen-units 放到 16(并行 codegen),
   **opt-level 保持 "z"**。最初按「优化等级高 = 编译快」把依赖抬到 opt-level 3,
   同机冷编译实测反了:opt-3 是 22m45s / GUI 19.46 MiB,opt-"z" 是 16m01s / 15.72 MiB——
   又慢 29.6% 又大 19~24%,收益全部来自 codegen-units 而不是 opt-level。
   只调优化强度,不碰 panic / strip(那两项改的是整个二进制的运行时行为)。
3. `[profile.fast.package."*"]` 显式写一遍:package 覆盖按 profile 分别生效,不赌 inherits
   会不会连覆盖一起继承(实测 594 个编译单元全部 opt-level=2 + cu16,含依赖)。

实测写进 packaging/README §6/§7.1(同机、删空 target 的冷编译、无并发负载):
release 16m01s(GUI 15.72 / iced 13.27 / ull 5.97 MiB),fast 24m27s(36.41 / 32.02 / 15.23 MiB)。
**fast 档在本机冷编译没有收益甚至更慢**,瓶颈是两个大 UI crate + 链接 + 机械盘 IO,
它的定位是 SSD 机器与增量重编——这条照实写进文档,不为了贴近预期挑数字。

DevDocs/ops/macos-acceptance.sh 的构建段改动(DevDocs 被 gitignore,无法入库)见完成报告。
2026-11-03 00:23:26 +08:00
AskaEth b16932a312 Merge branch 'feat/p5-logs-page-real-components-diagnostic' 2026-11-02 22:51:32 +08:00
AskaEth 4fde22067e chore(app-iced): M95 解 rebase 冲突的收尾 —— pages/mod.rs 收到 400 行
M94 合入后 `pages/mod.rs` 两侧增量叠加到了 **406 行**(全仓硬上限 400)。本提交只做三件
等价收紧,没有行为变化:

1. `demo_model` 的三个 `if` 收成一个 `match screen`(「哪页带演示数据」也更好读);
2. 我给 `Message::CopySummary` 写的 3 行 doc 收成 1 行(M94 的变体 doc 都是单行);
3. 删掉 `#[cfg(test)] mod tests;` 与 `pub use` 之间的一行空行。

顺带把 `demo_model` 的「只有实例页带数据」改成事实(实例/下载/日志三页都有演示数据——
这句在 M94 合入后就不成立了)。

收完 **正好 400 行**,仓库自己的守门测试(`components::gate` 的
`every_source_file_stays_under_the_line_limit`,扫 `src/**`)仍然绿。
2026-11-02 22:31:47 +08:00
AskaEth 0eb069fa37 fix(app-iced): M95 r4 —— 归属判据回归 / 置信度档 / 运行中页头
代码复审 r3 的四条 p2,修三条、登记一条(P2-4 页脚余量 13px,后置)。

**P2-1(回归修复)**:r3 把归属判据写成「有选中就只认选中」,把**导入崩溃文件**与 `open_session`
这两条路径的诊断一并关掉了——`559ba20` 是**无条件**画的,所以这是 r3 引入的**代码级回归**,
不是既有缺口(今天 iced 侧没有 dispatch 站点,那些还在遗留的 Slint shell 里,故复审未判 p1)。
判据改成三分支(`app/view/logs.rs`):

| live_session | selected_session | 报告算数的条件 |
|---|---|---|
| Some(s) | 任意 | `report.session == s`(只有它在线时日志行是它在追加,别人的报告挂上来就是串场) |
| None | Some(s) | `report.session == s` **或** 报告不属于任何已知会话(导入用 `file:<文件名>`,永不在索引里) |
| None | None | 全部算数(此时日志行只可能来自 `open_session`/导入,与报告同一次调用发出) |

新增两条测试(都走真实 reducer):`an_imported_crash_file_still_shows_its_findings`、
`a_live_run_says_it_is_running_instead_of_nothing_runs`。
残留(今天不可达,已登记):`open_session(A)` 在 `selected_session` 仍停在上场 B 时会被挡掉——
真要修得让 presenter 在 `LogLinesChanged` 记下「日志行属于哪一场」。

**P2-2 置信度档(视觉变更)**:`Severity`(借 core 的 `critical/warning/notice` 词汇)→
`Confidence`(`High/Medium/Low`)+ 新 key `LogsConfidenceHigh/Medium/Low`(中英齐,标题明确写
「高/中/低置信度」)。阈值仍是 80/50,但它现在是**档位定义**而不是「严重度代理」。
摘要里的 `findings=rule:high(85)` 同步换档位 slug。DTO 补齐 `severity` 之后应换回真严重度(finding)。

**P2-3 运行中的页头**:session 索引只在**结束**时追加,运行中的那一场不在 `log_sessions` 里 ⇒
拿不到实例名与结局 ⇒ r3 之前页头在日志滚动时一直说「当前没有运行中的实例」(demo 伪造了身份,
截图照不出来)。`Logs` 新增 `live: bool`(`live_session.is_some()`),`with_status` 三级回退:
有实例+结局 → `实例 · 时间 · 结局`;运行中但没结局 → 新 key `LogsSubtitleLive`(「Running now —
logs stream as they arrive」);都没有 → `LogsSubtitleIdle`。
新增 `a_live_run_says_it_is_running_instead_of_nothing_runs`(真实 reducer:只有
`LogAppended(running=true)`,断言副标题不是 Idle 文案且含 `Running now`)。
新增 demo 状态 `ULL_LOGS_FIXTURE=running` 用于出图。

**变异实测**(每条:回退 → 红 → 放回 → 绿):见报告。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净;
`cargo test` **280 + 6 + 23 passed / 0 failed**;行数最大 `pages/mod.rs` 367、`app/view.rs` 324、
`app/view/logs.rs` 337。
2026-11-02 22:27:01 +08:00
AskaEth 5ed2bf3a7d fix(app-iced): M95 r3 摘要页脚占比 4:2 + 演示数据自洽 + 样例解析不再 panic
视觉复审 r2 的 P2-A 与两个顺手的 p3。

**P2-A 摘要页脚占比过大(3:2 → 4:2)**:1180×760 下诊断卡 338px,摘要页脚固定吃掉 ~150px
(标签+按钮 40 + 摘要折 5 行 80 + 间距/分割 30),滚动视口只剩 141px,而一条完整结论
(分组标题 → 建议动作 → 置信度)要 165px ⇒ 首屏看不到「建议动作」,那是任务清单 T1/T3 要求的
可见项。改成 `DIAGNOSIS_SHARE = 4`(`LOGS_SHARE` 仍 2)。**数值实测**(`r3-crash-light.png`
逐像素量,非估算):诊断卡 376px、滚动视口 **178px**(y 201–378,两侧 1px 分割线的位置)、
一条完整结论块 **165px**、底部余量 **13px** —— 建议动作与 `Confidence 85%` 整块在首屏可见。

**p3 演示数据自相矛盾**:`live` 状态页头写「Finished normally」却配崩溃日志尾行
(`启动失败:exit code 1`)。新增 `sample_live.log`(一场正常跑完的会话:加载 → 进世界 →
停服),`State::Live` 用它,不再复用崩溃样例。

**p3 `demo::parse` 对非 ASCII 首字符 panic**:`raw.split_at(1)` 会切在多字节序列中间。
改成按字符取前缀(`chars().next()` + `chars.as_str()`);顺带把它提成 `pub(super)` 以便测。
新增 `the_fixture_parser_survives_a_non_ascii_first_char`:非 ASCII 首字符不 panic(静默跳过),
并钉住 `I/W/E` 前缀与注释/空行的解析。

测试:**278 passed / 0 failed**(lib)+ 6(`no_hardcoded_tokens`)+ 23(i18n)全绿;
`fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净。
出图:`C:\Temp\ull-shots-m95\r3-{crash-light,crash-dark,live-light,empty-light}.png`(1180×760)。
2026-11-02 22:27:00 +08:00
AskaEth 62d4af150e fix(app-iced): M95 r3 跨会话串场 —— 诊断结论只在归属会话就是当前会话时才画
代码复审(绑 559ba20)抓的 p2:r2 的 `logs_model` 先按 `state.diagnosis` 定「这是哪一场」,
而日志行来自当前那一场;`state.diagnosis` 全仓**只写不清**(`apply_core` 里
`CoreEvent::DiagnosisReady` 是唯一赋值点,没有清零点),host 也只在崩溃 / 打开会话 / 导入时
发它。于是「崩溃一场 → 再启动一场正常跑」时:页头把新会话标成上一场的 `The game crashed`、
诊断卡挂着上一场的结论,而用户复制去贴 issue 的摘要里 `lines=` 是**新的**那一场的行数 ——
结论与行数不同源,用户会把错诊断递出去。

修法(只在页面侧判别,不动 presenter 的写入口):

- **身份**只认 `live_session.or(selected_session)`(正在跑 → 打开/刚结束的那一场),
  **不从 `diagnosis` 反推**;
- 诊断结论只在 `diagnosis.session == 身份` 时画;身份为空时才信任报告(那时日志行只可能来自
  `open_session`,它与报告是同一次调用发的);
- 实例/时间/结局的描述同样是身份优先,身份为空才退到报告的 session。

测试(`app/view/logs.rs` 内,走**真实数据路径**):
`crashed_only()` 用 `apply_core` 依次灌 `LogSessionsChanged` → 第一场 `LogAppended(running)`
→ `DiagnosisReady` → `LaunchFinished(Crashed)`,`after_a_new_run()` 再灌第二场 `LogAppended(running)`;
`state.diagnosis` 全程不清(这就是线上串场的条件)。

- `a_new_run_does_not_inherit_the_previous_crash`:断言 outcome=Ok、页头不含 `The game crashed`、
  findings 为空、摘要 `outcome=ok` 且不含旧规则且 `lines=` 与当前行数一致。
- `the_crash_itself_is_still_reported`(对照,防「永远不画诊断」骗过上面那条):崩溃当场必须
  findings=1 且摘要含 `gpu.driver_old:critical(92)`。
- `the_hosts_no_rule_matched_placeholder_is_not_a_finding`:host 的 `rule: ""` 占位报告不画;
  换成真规则就必须画(证明过滤不是把诊断一起关了)。

变异实测:把 `report` 的过滤回退成 r2 的 `state.diagnosis.as_ref()` → 上面第一条**红**并点名
`Finding { rule: "gpu.driver_old", … }`(0 passed / 1 failed);放回 → **277 passed / 0 failed**。
2026-11-02 22:27:00 +08:00
AskaEth ee4d82e3d4 refactor(app-iced): M95 r3 把日志页的展示转换从 app/view.rs 拆出来(纯搬移,行为零变化)
`app/view.rs` 已经 392 行(全仓硬上限 400),而日志页的转换还要加判别与测试,先拆。

- `app/view.rs`:392 → **324** 行,只留 `impl App`、`model()` 与实例页的展示转换,
  另加 `mod logs; use logs::logs_model;`(调用点仍是 `logs_model(state).with_status(lang)`,未改)。
- 新增 `app/view/logs.rs`:**80 行**,逐行搬入 `logs_model` / `log_level` / `log_outcome` /
  `diagnosis_of`,实现体一行未动(含 r2 的「先按 `diagnosis.session` 定身份」写法,
  那是下一个 commit 才改的行为)。

等价比证:搬移前后 `cargo test -p unlucky-app-iced -p unlucky-i18n` 都是
**274 + 6 + 23 passed / 0 failed**(lib + no_hardcoded_tokens + i18n),数量与结果一致。
除模块声明与 import 调整外没有任何逻辑改动。
2026-11-02 22:26:59 +08:00
AskaEth 1bccf89e5c fix(app-iced): M95 r1 五条视觉复审(悬挂缩进 / 会话身份 / 摘要可见 / 贴底 / 描边)
- P2-1 **悬挂缩进真做了**(之前文档在骗人,代码里只有 `row![bar, text]`):
  每行拆「前缀 + 正文」两个文本控件(前缀 = 第一个空格及其之前,含空格;
  `split_prefix` 在第一个「词」>20 字符时不拆),正文带 Fill 宽度自己折行 ⇒
  续行落在前缀宽度之后。于是 `at com.mojang…(GLX.java:64)` 不再拆成「`at` 独占一行」。
  文档同步写清代价:续行缩进量 = 该行前缀宽度。
- P2-2 会话身份上页头:`Logs::with_status(lang)` 用 `LogsStatus` 模板拼
  `{instance} · {when} · {outcome}`,结局文案走 `LaunchOutcomeNormal/Crash/Silent`
  三 key(bridge 文档里那条「LogOutcome 三态 → 三 key」的结构化出口,此前 UI 侧零调用点)。
  中文模板差在全角竖线(守卫要求 zh≠en,也确实更符合中文排版)。
- P2-3 摘要钉成诊断卡**固定页脚**(不进滚动区):「One-line summary」+ Copy summary
  紧邻它要复制的那段等宽文本;卡片头不再放复制按钮(避免两处入口)。
- P2-4 日志卡改 `panel::scroll_tail`(`Anchor::End`):偏移按「距底多远」记,内容增长时
  跟随尾部;「用户上滚后是否冻结」的边界写在 `panel::scroll_tail` 文档里(结论:不冻结,
  冻结要 app 层回灌滚动状态,已登记)。
- P2-5 两张主卡补 1px 描边:`panel::card` 改用 `Component::OutlinedCard.spec().shape` +
  `ui.outlined(..)`,与 `frame::card` 同一套令牌。

测试:新增 `a_line_is_split_into_prefix_and_body_for_the_hanging_indent`(含 CJK 行与
「不拆」的两种边界)与 `status_line_names_the_session_with_the_launch_outcome_keys`;
key 清单补 `LogsStatus` 与三个 `LaunchOutcome*`;demo 的 state.build 改为收 `Lang`。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced --all-targets -D warnings` 干净;
`test -p unlucky-app-iced` 274+6 全绿、`-p unlucky-i18n` 23 全绿。
图:`.screens/logs-{empty,live,crash}-{light,dark}.png`(1180×760)、
`r2-live-tall-light.png`(1180×1080,栈帧可见,P2-1 证据)。
2026-11-02 22:26:59 +08:00
AskaEth 063a1dba39 feat(app-iced): M95 日志页接真组件 + 诊断面板(等宽折行 / 按严重度分组 / 一行摘要)
日志页从 M84 占位换成真组件,数据全部来自 presenter 快照(页面不读盘):

- 日志行列表:`pages/logs/lines.rs`,等宽(`BodySmall` 12/16 + mono 族)+ 按词或字形
  折行 + 等级色条;**选折行不选横滚**的理由写在模块文档(424px × 200 字符的现实 +
  mono 族的 CJK 回退会毁掉横滚唯一的收益「列对齐」)。
- 诊断面板:`pages/logs/diagnostic.rs`,按严重度分组(CRITICAL → WARNING → NOTICE),
  每条给规则 id / 结论 / 说明 / 建议动作 / 置信度;卡片头钉住「Copy summary」,
  摘要是稳定单行的 `ULL-LOGS v1 | …`(崩溃日志换 issue 时直接粘)。
- 空态:真组件 `feedback::EmptyState`(图标块 + 标题 + 说明 + CTA 回实例页)。
- 两块都在 `pages/logs/panel.rs` 里**定高 + 内部独立滚动**(`FillPortion(3):(2)`):
  第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(实拍复现后改的)。
- 视图模型:`pages/logs/model.rs`(纯展示字段)+ `app/view.rs::logs_model` 做快照转换。
- 接线(只追加):`pages/mod.rs` 的 `pub use logs::…` / `page()` 那行 /
  `Message::CopySummary` / `demo_model` 一行;`pages::Model` 加 `logs` 与 `lang` 两个字段;
  `app/update.rs` 把 `CopySummary` 落到 `iced::clipboard::write`、`ClearLogs` 清 presenter 的
  `log_lines`(呈现层操作,不造命令)。
- i18n:`crates/i18n/src/catalog.rs` 自己锚点下 16 个 key(中英同表);按塔裁决把
  `#[cfg(test)] mod tests` 逐字外置到 `crates/i18n/src/catalog/tests.rs`
  (catalog.rs 415 → 257 行,tests.rs 164 行;7 个用例逐字未动、i18n 23 个用例全绿)。

验证:`fmt --check` / `clippy -p unlucky-app-iced --all-targets -- -D warnings` 干净;
`test -p unlucky-app-iced` 272 + 6 全绿、`-p unlucky-i18n` 23 全绿;截图
`.screens/logs-{empty,crash,live}-{light,dark}.png`(1180×760,本地 gitignore)。

遗留(已报 finding):`dto::Diagnosis` 缺 core 的 `severity` 与规则表 `cause.key`;
`Action::OpenLogs` 没有对应 `AppCommand`(点了只有占位日志)。
2026-11-02 22:26:53 +08:00
AskaEth c0396544c5 Merge branch 'feat/p5-download-page-real-components-and-ins' 2026-11-02 21:59:22 +08:00
tower 5340908882 refactor(download): rebase 到 M93 之后 app/update.rs 破 400 → 下载页四支拆到 update/download.rs
M93(外观三件套接线)合入后 `app/update.rs` 从 348 涨到 410 行,再加上下载页的四个 arm
会顶破 ≤400 硬线。把下载页那几支(SelectVersion / InstallVersion / CancelInstall /
SetCategory / Action::RefreshSources)挪到 `app/update/download.rs`(`app/update/**` 在本
mission scope 内),`on_page` 入口只留一句 `if self.on_download(&message) { return; }`,
match 末尾补一条 `_ => {}` 做穷尽性。

行为零变化:指令、视图模型写法、`refresh_view` 顺序都照搬;M93 的 `Appearance` arm /
`env_start_route` / `on_appearance` 一行没动。

行数:update.rs 410 → 397,download.rs 57。
2026-11-02 21:45:40 +08:00
tower 6453c76750 fix(download): M94 r2 —— 安装终态出口 + 分类真机过滤(复审 P1-1 / P2-1)
P1-1 安装一旦收尾就再也装不了第二个版本(终态面板没有出口)
- 页面侧:`Download::from_snapshot` 把 **Done / Cancelled** 当结束(`InstallView::finished`),
  面板让回列表 —— 装完一个版本可以直接选下一个继续装(图:r2-done / r2-done-next)。
  不清 `Failed`:失败态带着用户要的东西(停在哪个文件 + 去日志页)。
- 面板加真出口:失败态「Open logs」(实心)+「Dismiss」(文本 → `Message::CancelInstall`,
  host 侧对没在跑的任务是安全 no-op),修掉「失败后连重试都不可达」。
- 决策抽成纯函数 `install::panel_state()`(Hint / Ready / Install),`panel()` 只是它的画法,
  于是「能不能再装」可以被单测钉死,不用开图形环境。
- 两条新测试**不走 MockBackend**(它从不发 `InstallProgress`,终态永远跑不到 ⇒ 假绿):
  直接构造 `AppState` + `apply(CoreEvent::InstallProgress(..))`。

P2-1 分类 chip 在真 host 上是空控件
- `from_snapshot(state, category, catalog)` 收分类并按 `Category::matches` **本地过滤**
  (真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单);
- 分类不再从列表反推(`Category::of` 只留给演示首帧),`app/view.rs::model` 收分类、
  `refresh_view` 把上一份视图模型的分类带回去 ⇒ 事件回来 chip 不会跳回「全部」;
- `update.rs` 的 `SetCategory` 写分类后**立刻重建**(chip 与列表同一个谓词),再派真指令。

p3(顺带)
- 失败态与占位态共存:列表空/加载中时若安装还在跑,面板照样出现在底部(r2-installing-empty);
- 修 `install.rs` 的坏 rustdoc 链接;`install.rs` / `model.rs` 的测试模块外置到子文件守住 400 行。

测试文件:`pages/download/{model,install}/tests.rs`(新),`pages/download/tests.rs` 里的
`from_snapshot` 调用补分类参数。

实证(回退法):删掉 `.filter(|view| !view.finished())` → 两条终态测试红并点名
「Done 之后不许再占着安装面板」;删掉 `.filter(|category.matches(..))` → 两条分类测试红
(「只剩正式版」left=3 right=1)。放回修复 → 全绿。

门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 280 + 集成 6 + i18n 23 全绿(-j 4)。
2026-11-02 21:36:28 +08:00
tower 9d6ee70301 fix(download): M94 r1 —— 五条复审(P2-1/2/3/4/6)
P2-1 占位规则统一:加载态原先贴顶、空态居中 → 两态都走同一套「内容在可用区居中」
  (依据:components::feedback::empty_state 的既有口径 12b §15.4;两态互切不再纵向跳)。
  实测两个状态的内容中心线 y=449 / y=445(差 4px)。

P2-2 文案只说真能做到的事:
  - 删「The list appears after the first successful fetch; this page never goes blank.」
    (工程话术 + 承诺了 UI 做不到的「永空白」)→「如果一直停在这里,点右上角的刷新。」
    (对应页头那个真的 Refresh 按钮)
  - 空态「Switch the category or clear the filter」删掉「清筛选」(页面没有筛选控件)
    →「换一个分类,或刷新一次列表。」
  - 副标题「Version list from the official manifest」去掉 manifest 这种工程词 →「可以安装的版本」

P2-3 去掉静止的 indeterminate 进度条:页面没有帧订阅,indeterminate 只会拿到一个固定相位
  (看起来像 17% 卡住)。加载态改成纯文字;安装面板只在进度已知时画 determinate。
  两帧证据:215/512 → 条填 177px(42%),409/512 → 338px(80%),条随事件走而不是动画。

P2-4 失败态与安装中彻底不同构:失败不再画进度条,改成整块 ErrorContainer 面板
  (ErrorContainer 底 + Error 图标 + Error 色标题 + 停在哪个文件 + 说明 + 实心「打开日志」)。
  实测底色:浅色 (255,218,214) / 深色 (147,0,10),安装中面板底色仍是页面底色。

P2-6 死 key 与假守卫:
  - 删掉没用的 DownloadSelected;
  - 用真的守卫替换原来那条「前缀遍历 + 条数下限」:every_download_key_is_referenced_by_the_page
    用 include_str! 把页面源码编进来,目录里每个 Download* key 必须在源码里出现 Key::Xxx。
    实证:把 DownloadSelected 放回去 → 该测试红并点名 key;删掉 → 绿。
    注释里也写清抓不住的三种情况(反向是编译错误 / 译文完整性归 i18n 自己的测试 / 宏拼 key 能骗过它)。

门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 276 + i18n 23 全绿;
tests.rs 384→383 行(不再贴顶),其余文件均 ≤400。
2026-11-02 21:36:26 +08:00
tower 57e8380b24 feat(download): M94 下载页换成真组件 + 真快照 + 真指令(PAGEDL)
页面(crates/app-iced/src/pages/download*):
- Tabs(分类)/ ListItem(版本行)/ filled 安装按钮 / progress / EmptyState / divider
  全部取自 components/ 的真组件;页头动作换成 buttons::text
- 数据只吃 pages::download::Download(app/view.rs 由 AppState 快照转,installed_only 与
  download_query 照用);页面自己不留业务状态,交互只发意图
- 状态:加载(不确定进度 + 文案)/ 空(EmptyState + 刷新 CTA)/ 有数据 / 安装中 / 安装失败
  (失败给可读文案 + 「打开日志」出口)

接线(追加式):
- pages::Message 加 SelectVersion / InstallVersion / CancelInstall / SetCategory
- app/update.rs 翻成 AppCommand::{InstallVersion, CancelInstall, ListVersions}
  (SelectVersion 只改视图模型;「刷新」= 当前分类重新拉,全部走 RefreshAll)
- pages::Model 加 download 字段

文案:28 个 Download* key 进中英两套 catalog(一行一个 key 的全语言);页面零中文字面量,
  core 的中文阶段串(InstallStage::label / message)一律不进界面

i18n 结构:catalog.rs 的既有测试模块原样外置到 catalog/tests.rs(392 行 → 282 行,
  逐字未改),新 key 才装得下 400 行硬上限

测试:app-iced 276 + i18n 23 全绿;clippy -D warnings 与 fmt --check 干净
2026-11-02 21:36:22 +08:00
AskaEth b3f99652bb Merge branch 'feat/p5-appearance-wire-window-panel-alpha-ba' 2026-11-02 19:13:11 +08:00
AskaEth da2da4cd9b fix(app-iced,presenter): M93 r4 —— Float 键写不回 / prefs.rs 超限 / CPU 档判定 / 平铺退化
复审(代码)r1 的两条 P1 + 两条必修。

P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值)
- `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**,
  不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进
  `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**,
  α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。
- 测试(presenter 层,`host/settings_tests.rs`):
  ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8;
  ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有**
  `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。

P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试
- `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export
  一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。

P2-2 CPU 档判定只在 `--renderer=cpu` 时成立
- 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND`
  候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。
  `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上**
  也会走「面板 α / 玻璃强制 1.0」。
- 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。
- 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→
  `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。

P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`)
- `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale`
  完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。
- 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。
- 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句
  「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。
- 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1,
  断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。
- 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、
  `r5-tile-big.png`。

顺手(复审 P2-3/P2-4/P2-5)
- P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc`
  320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode`
  (`TooLarge` 这条分支此前无测试)。
- P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与
  自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag`
  走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。
- P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`;
  测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。
- 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。
2026-11-02 18:46:43 +08:00
AskaEth 5c40c64b5f fix(app-iced): M93 r3 —— 复审 r1 四条返工(Canvas 不吃 α / 平铺补缝 / 诊断上移 / 文档)
先 `git rebase main`(M92 `eae7ae0` 已在主线;`Cargo.lock` 与 `crates/app-iced/Cargo.toml`
自动合并,`image-without-codecs` 与 M92 的 `unlucky-i18n` 两段都在)。

P2-2(口径违约)Canvas 层不许吃面板 α
- `Ui::layer()` 改成按令牌 `MaterialLayer::allows_translucency()` 判定:Canvas 恒不透明,
  只有 Card / Overlay 乘 `panel_alpha`。判据取令牌的不变量,三个调用点(标题栏 / 导航轨 /
  页头带)没有各自特判。
- 守门:`the_canvas_layer_stays_opaque_while_panels_take_the_alpha`(α=0.55 时 Canvas.a==1.0、
  Card/Overlay.a==0.55;CPU 档连卡片都是 1.0)。

P2-1 平铺不许留缝
- `background::tiles()` 超 `MAX_TILES` 时改成**放大图块步长**(`Tiles { cols, rows, scale }`,
  按 1.15 逐级放大到块数进预算),而不是减列/减行;`MAX_TILE_SCALE` 提到 1024 只是安全阀
  (1×1 的图在 1180×760 上需要 ~118 倍才能用 64 块盖满)。
- `picture::tiled()` 用 `ContentFit::Fill` + 格子尺寸 = `源尺寸 × scale`(同比例,不变形):
  `None` 会按图片原生尺寸画、比格子小就留空,正是那道缝。
- 守门:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 120×120 与 1×1,
  断言 `covered >= viewport` 且 `count <= MAX_TILES`);背景层底下本来就有不透明画布底色兜底。

P2-3 诊断上移到首屏
- 诊断块(读盘失败红字 / CPU 降级 / 配色回落 / 当前配色层级)从卡片末尾移到「背景图路径」
  紧下方:1180×760 首屏可见(截图 `r4-broken-path.png` 里红字在 y≈594)。

P2-4 文档对齐
- `transparency.rs`:`WS_EX_LAYERED` 的归属改成「本文件的 `ensure_layered` 负责」,
  窗口创建侧只拿句柄;`platform/mod.rs` / `lib.rs` 的模块表同步。
- 删掉「马赛克取样」这类已删实现的现在时描述(`background` / `picture` / `app` 的注释),
  保留一句 r1→r2 的历史说明;`app/tests/appearance.rs` 的测试意图跟着改。

门禁(工作区级,最终树):`cargo fmt --all --check` 干净;
`cargo clippy --workspace --all-targets -- -D warnings` 无输出;
`cargo test --workspace` 全绿(app-iced 299 lib + 6 集成守门、presenter 126、其余 crate 全过)。
出图:`r4-canvas-opaque-panel55{,-dark}.png`(Canvas 不透明 + 面板透底)、
`r4-tile-120.png`(120×120 平铺右缘无裸白)、`r4-broken-path.png`(诊断首屏可见)。
2026-11-02 18:18:37 +08:00
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00
AskaEth b8ab46662c feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源
把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。

整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
  (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
  `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
  先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。

面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
  CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。

背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
  iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。

设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
  背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
  bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
  走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
  `window_opacity` / `palette_source`。

取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
  截图通道不读用户配置(D30)。
2026-11-02 18:07:31 +08:00
AskaEth eae7ae0ff4 Merge branch 'feat/p5-i18n-crate-language-probe-font-strate' 2026-11-02 17:42:58 +08:00
AskaEth ce2c42032b fix(i18n): M92 r1 —— Windows 真 locale 走注册表子进程(零 FFI)+ 删掉假守卫
复审 p1:`detect_env()` 在 Windows 上恒为 `en`(`LC_ALL/LC_MESSAGES/LANG` 都不存在),
D28 整条中文链(探测 → 扫 CJK → 需下载)在基线平台永不触发。复审在本机(zh-CN)实测
`lang=en raw=None`,这条我认。

**p1 修法(tower 定路线:子进程读注册表,不碰 FFI、不加依赖)**
- 新增 `crates/app-iced/src/platform/locale.rs`:`SystemLocale` 实现
  `unlucky_i18n::lang::LocaleSource`,内部 `reg.exe query "HKCU\Control Panel\International"
  /v LocaleName`(本机实测 `REG_SZ zh-CN`),读不到时退 `/v Locale` 的十六进制 LCID
  (本机 `00000804`),用一张 20 条的短表翻成标签,表外语言返回 `None`(宁可英文,不猜语种)。
  先例同 M73 壁纸探测(`core::platform::wallpaper` 的 `reg.exe` 路线)。
- 子进程走注入的 `RegIo` 接口,单测用 `FakeReg`(不 spawn);夹具是**本机真机抓的原文**
  (含 CRLF / 前导空白),另有一条 `#[cfg(windows)]` 真机测试证明 `reg.exe` 这条路真的通。
- i18n 仍是纯函数:`lang::resolve(setting, platform, env)` 是四级优先级的唯一实现——
  **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**,`Detection` 新增 `origin`
  (`setting` / 源自报的 `label()` / `env` / `default`),启动日志因此能写清 `source=registry`。
  配置值不认识时**跳过**继续往下走,不当成任何一种语言。
- `fonts::cjk::probe_with(setting, faces)` 把 `ui.language` 那一级的位置先留好(目前传 `None`:
  设置层还没有读取点,写在模块文档的诚实边界里);`probe()` 走全链。
- `platform::mod` 的模块表补一行说明 locale 也是「零 unsafe」那一类。

**本机(zh-CN)实跑证据**(`--software-renderer --screenshot …`,exit 0):
`[font] … | lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei UI"`
——探测出中文、且走的是「扫 CJK 字体」而不是 `not-needed`。

**p2**:删掉 `catalog` 那条 `compile_fail` 守卫(复审两个变异仍全绿 ⇒ 它守的是「这段代码
编译不过」,不是「编译期校验还在」)。改为陈述事实:「`Key` 是枚举,拼错 `E0599`」,
并写明这里**没有**守卫测试及原因。

未动 `ui.language` 默认值(`core` schema 不在本 mission scope,tower 已另记)。
2026-11-02 16:58:09 +08:00
AskaEth e77a5df6e8 feat(i18n): 建国际化地基——语言探测 / 中英消息目录 / 中文字体策略(D28)
M92 只建地基、不接页面(页面接线是后续 mission,避免「先硬编码再全量重写」)。
新建的 crates/i18n 是**零依赖** crate:`cargo tree -p unlucky-i18n --edges normal`
只打印它自己,所以 iced 版、将来的别的壳、presenter 都能依赖它而不锁死渲染框架。

- lang:`Lang::{En, ZhCn}` + 可注入的 `LocaleSource`(`EnvLocale` 按 POSIX 优先级读
  LC_ALL > LC_MESSAGES > LANG)。未知语种一律英文——把 ja/fr 猜成中文会连带触发中文字体
  下载,比英文界面更糟。配置值(`ui.language` 的 zh_cn/en_us)走严格解析,locale 走宽松
  解析,两条路刻意分开。**已知缺口**:Windows 上这三个变量通常都不存在,
  `GetUserDefaultLocaleName` 属于 platform 的 FFI(后续 mission)。
- catalog:`Key` 枚举(拼错的 key = 编译错误,附 compile_fail 文档测试守着)
  + `catalog!` 宏「一行 = 一个 key 的全部语言」⇒ 内置两套目录缺译在结构上不可能发生。
  缺译诊断(`Catalog::diagnose`)留给注入目录与回归守门:`get` 严格返回 None、
  `text` 返回 ⟪missing⟫、`render` 返回 ⟪missing:KeyName⟫——**不回落**到另一种语言。
  带变量的句子走 `{name}` 占位符,缺参时占位符原样留在文案里(可见,不静默变空)。
- font:`decide(lang, installed)` 是纯函数。英文环境恒 `NotNeeded`(D28 的零字体/零网络);
  中文环境按候选族名单命中系统字体,扫不到就发 `NeedsDownload(Noto Sans SC)` 信号,
  **本 crate 不联网**;`Fetcher` 是注入式接口,网络实现留给后续 mission。
  URL/哈希显式留白(`Source::PendingPin`)——不编造 CDN 路径,猜错的地址会被后面所有人当事实用。
- bridge:文案边界规则全文(R1 不许拼句 / R2 core 中文串不进 UI / R3 未知串不许画原文)
  + `classify()` 示例契约:过渡表把已知漏到界面的中文串映射到 key,品牌专名显式放行,
  其余一律 `Untranslated`(UI 只许显示兜底 key)。
- app-iced/fonts:新增 `fonts/cjk.rs` 适配层,把系统字面表喂给 `decide`;读不到字体库时给
  `Unprobeable` 而不是「需下载」(否则会误触发一次 CDN 下载)。结论并入同一行启动日志:
  `[font] … | lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`。

测试:26 条(i18n 22 + app-iced fonts/cjk 4),每条断言都写了「抓得住什么 / 抓不住什么」。
2026-11-02 16:13:22 +08:00
AskaEth 1331d90ff4 Merge branch 'feat/p4-iced-app-split-presenter-wire-one-pag' 2026-11-02 15:57:07 +08:00
AskaEth 6b66adff44 fix(app-iced): M90 r3 —— 空闲 1Hz 心跳恢复配置热切 + 导航轨口径订正 + 文档收口
复审 P2-2(空闲态掐时钟 ⇒ config_poll_due 再也不跑):
- 空闲态改用 1 Hz 自续心跳(Task::perform(sleep(1s)) → Message::Heartbeat),忙碌态仍用
  window::frames() + 33ms 闸门;两条时钟不并行(心跳在 needs_frames() 为真时只续排不推进)。
- ticks 改成「33 ms 一拍」记账(1 s 心跳记 30 拍),config 轮询判据从「整除」改成「跨越」——
  实测整除版在 ticks=137 之后每拍 +30 永远不命中,配置轮询静默死掉。
- 顺手修一个真 bug:Message::SystemTheme 原来无条件 set_flavor,会把配置文件里的显式主题盖掉
  (优先级现在是 --theme > 配置文件 > 系统),否则「改 launcher.json 主题 1 秒内生效」验收根本看不到。
- 实测:14 s 交互跑,空闲段 [tick] clock=idle-1hz 每秒一行、frames 冻结在 2947、refreshes 冻结在 6;
  t≈7s / t≈11s 两次改 launcher.json,各自 ≤1 s 内打出 [settings] 配置文件变了 + [theme] 翻转。

复审 P2-1(导航轨口径):
- pages/mod.rs 交付表改成事实(产品路径的 rail 由 shell/nav.rs 画,组件版只给 harness/单测,
  像素等价是复审判的),补偏离表第 10 条(两套 rail 各测各的,漂移没人拦);
- pages/tests.rs 的 the_real_rail_agrees_with_the_layout_system 注释同步订正。

P3 文档五条:shell 的「8px 拖动热区」改成事实(拖动面=整条 48px 标题栏到第一个 caption;
缩放热区 4px)、state/update 的「每拍重建视图模型」改成 view_dirty 门控、message 的 Frame 注释
改成「只在忙碌时订阅」、pages/model 的 From<&AppState> 改成自由函数 model(..)、pages/tests 的守门
目录补 src/app。

r3 浅/深产品图与 r2 逐字节相同(视觉零变化)。
2026-11-02 15:56:59 +08:00
AskaEth 022778a5a7 fix(app-iced): M90 r2 —— 找回窗口装饰 + 按需节拍 + 数据根一致 + 假绿守卫
复审 P1-1(产品页丢窗口装饰):shell 加内容槽位 view_with_content(ui, nav, title, content, forced),
app/view.rs 把 pages::page(...) 塞进真外壳;shell::view 保留给 Route::Agent 回落(删了会让私有
view/nav::rail 变死代码,-D warnings 红)。content() 拆到 shell/fallback.rs 让 mod.rs 回到 365 行。
1180 浅/深新图右上角三个 caption 键实测:ink 中心 y=23.5(栏高 48 的一半)、x 中心 1064.5/1110.5/
1156.5 = 46px 分档的 1065/1111/1157(±0.5px),close 命中区右缘 = 1180 窗角。
偏离 #7 改写成「交互面缺失」并注明本轮已修。

P1-2(harness.rs 402 行):tests 外置到 pages/harness/tests.rs(343 + 64 行);
components/gate.rs 的行数守门从走查 components/ 扩到 src/**(新测试当场抓出 app/tests.rs 416 行,
已按同样办法拆成 app/tests.rs + app/tests/presenter.rs)。

P2-1(常驻重绘 + 每拍重建模型):subscription 只在「截图 / 有 Busy-Running / 有 toast / 3 s 派发
观察窗」时订阅 window::frames();refresh_view 加 view_dirty 门。实测交互模式 9 s:只有 2 行 tick
trace(n=30/60,观察窗内),之后 6.5 s 一条都没有;refreshes 全程停在 7。

P2-2(两个数据根):--data-dir 时 SettingsBridge::with_path(config_path(root)),与后端同根。

P3-2(假绿):新增 RecorderBackend(dispatch 只记账、drain_events 恒空)+ launching_only_dispatches
_and_never_echoes_locally;植入本地回显后该测试实测转红(status 变 Downloading · 0%、can_stop 翻
true),撤回后转绿。
P3-3:尺寸字面量守门目录加 app;P3-1:--force-state 经 pages::Model::forced 作用到产品页控件
(hover 图里页头/列表/动作行/rail/caption 全部可见状态层);视觉 P2-2:加载器名统一走 badge()。

gallery 回归改口径:r2 与「主线 + r1」的 gallery 逐字节相同(cmp IDENTICAL);与 r1 记的
c825e1a 基线不同,差异来自主线 M86/M88 的令牌收口,不是本 mission。
2026-11-02 15:56:58 +08:00
AskaEth b484998fc9 feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端
拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
  每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
  可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。

presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
  (iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
  首屏自动选中第一行(list-detail 桌面惯例),只做一次。

实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
  (Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。

版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
2026-11-02 15:56:57 +08:00
AskaEth 270026338f Merge branch 'feat/p4-iced-components-feedback-progress-sna' 2026-11-02 15:56:52 +08:00
AskaEth d4e73aacdd fix(iced/feedback): 逐环改固定窗判据 + 贴顶边距 + Dialog 下限单测(复审 r3 P2-3/P2-1/P2-2)
D45 封顶轮的最后一改,只动这三条:

- **P2-3 逐环断言(假绿)**:环本来就在固定 x 栅格(24 + 56·i、宽 40),判据从「主色连通簇数
  非空」改成**六个窗逐个测**「窗内墨迹(轨道 ∪ 激活并集)外接成 40(±2)×40(±2)、且不越窗」,
  外加「1..5 号窗必须有激活色弧」。簇数只打印不再当判据。G18-i 从「欠账」改成**已收口**,
  并删掉模块文档里「要么把 6 个环摆成固定 x 栅格」那句错话(它们本来就在栅格上)。
  环带定位也换过两版(「每行墨点 4..20」会把 40 切成 32;「首行..末行」在深色档扩到 274 行),
  最终用「第一个窗里连续有墨、高度≈40 的行组」。
  变异:涂掉 4 个环 → `第 1 个窗里没有任何环墨迹:环丢了` 红;25% 环缩成 20×20 →
  `第 1 个窗里的环应 40×40(±2),实测 20×20` 红。
- **P2-1 贴顶边距**:新增「框顶行 → 条顶行 = 36±1」与「取景框高 = 96±1」两条。
  变异(`HOST_SHORT` 96 → 72,重编重拍):`D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 12` 红;
  还原后绿。—— 之前只量「条底距框底」,宿高回退成 72 时确实抓不住。
  顺带修了取景框判据本身:条自身(深色)比画布更接近框色,会把条自己认成框(实测差值 1),
  现在要求「框色连续段比条本身更宽」。
- **P2-2 Dialog 下限**:补单测 `effective_width(200.0) == 280.0`(360/800 两点一并钉住),
  并把「不低于官方 280」写进 `Dialog::width` 的文档。
- 欠账登记(只写表、不改代码):G18-j(动作色用全图合计,单卡漂移可能被别卡盖住)、
  G18-k(再 4 处文档残留,不为凑数扩大 diff)、G18-l(取景框判据会把条的抗锯齿底行算成框,
  余量 0.0008,方向是误报;加了「框高 96」后触发概率下降)。
2026-11-02 15:56:48 +08:00
AskaEth 11d7b64a5e fix(iced/feedback): 逐卡宽度/动作色精确判据、贴底边距、Dialog 下限与 icon 走 view_at
复审 r3 的 A/G/D/E 与随之冒出来的 clippy 项:

- **A(p1,假绿)**:`7.6 Dialog 动作色` 原来量的是 `DialogActions::None` 那张卡的 bbox,且判据
  是「离 Primary 更近」——标题/正文字形都被算进去(旧值 1790px)。现在:①判据换**精确色**;
  ②加两条反向证据(无动作那张卡里精确 Primary 必须为 0;有动作那张卡的下半区必须有字形行);
  ③量的是全图的动作字形(实测 362px,与复审独立复算一致)。变异:把 350 个动作字形涂成
  `OnSurfaceVariant` → `7.6 实测只命中 12 px` → 红。
- **G**:Dialog 宽度改**逐卡**量(列剖面,排掉 scrim 色,深色档不会再把遮罩算成 1180 宽的假卡)。
  环的逐环断言**登记为 G18-i**(实测 6 个环只有 3~4 个能被精确色簇干净命中,按簇数断言会把
  「相位换了」误报成「环丢了」,理由写进 `probe/circular.rs` 模块文档)。
- **D**:两个 bottom 宿主 72 → 96,并给窄宿主画 1px 取景框;probe 新增「条底距宿主底 = 12」
  (实测 `底边距 [11, 12]`)。取景框的描边不参与布局,所以宿主宽不变、条宽仍是 344/300。
- **E**:`DialogGeometry::effective_width` = `min(480, 可用宽).max(280)`(尊重官方下限);
  gallery 加一格 `Dialog::width(360)`;probe 逐卡断言实测 `[360, 480, 480, 480, 480]`。
- **C(按 tower 修正版)**:空态图标改用 M86 的 `icons::view_at(.., 32)`(先 rebase 到 main)。
- 顺带:`action.rs` 的 24 图标也走 `view_at(24)`——`icons.rs` 的守门测试按「逐文件调用点计数」
  要求不在白名单里的文件 `icons::view` 为 0,本文件按盒尺寸烘一份(输出逐像素相同)。
- clippy:去掉 `needless_range_loop` 与未用变量,`-D warnings` 已绿。
2026-11-02 15:56:47 +08:00
AskaEth 5d7426b6c9 fix(iced/feedback): 空态图标改用 M86 的 view_at 渲染 32(复审 C 修正版)
tower 修正指令:M86(e377989)把 `icons::view_at(kind, ink, px)` 并进 main 了,所以 C 项
从「登记值改成 24」改为「登记值保持 32 + 用 view_at 真渲 32」:

- `empty_state.rs`:`icons::view(..)` → `icons::view_at(.., geometry.icon)`,盒仍 96、
  图标 32;`view_at` 按 32 **重新烘焙 stroke**,视觉线宽仍是 1.5px(不会把描边拉粗)。
  早先 `view` 把 svg 钉成 `Fixed(24)`,32 的盒子只居中不拉伸 —— 实渲 24、墨迹 20×20。
- `panel.rs`:`EMPTY_ICON = 32.0`(`IconSize::Empty`),删掉 G18-h 欠账说明(已消解)。
- `probe/panels.rs`:图标墨迹判据 16..26 → **22..34**(32 盒)。
- `tests.rs`:`geometry.icon == 32` 保留,并写明它守的是「登记值 = 交给 view_at 的 px」,
  抓不住「盒子 32 而 svg 仍钉 24」那类(渲染层由 probe 的墨迹断言兜)。
- 顺带清掉 `panels.rs` 里两个未用 import(`-D warnings` 会红)。

已 `git rebase main`(1f4bc1b,含 M86),无冲突。
2026-11-02 15:56:47 +08:00
AskaEth a4cdd61fe5 fix(iced/feedback): 复审 r3 的 A/B/D/E/F 项 + C 项待 rebase 后改 view_at
(先落一个中间提交,rebase main 之后再按 tower 的修正把空态图标改成 32/view_at)
2026-11-02 15:56:46 +08:00
AskaEth 5d9e75efd8 test(iced/feedback): 环形旋转周期断言打印实测数值(跨周期跳变 / 每周期圈数)
给复审用的一行 `--nocapture` 输出:`[rotation] turns(0.9999)=… 跳变=… 圈(…°)· 每周期 4 圈 = 1440.0°`,
代码本身不变。
2026-11-02 15:56:46 +08:00
AskaEth 10d4f86fdf fix(iced/feedback): 圆角/描边带来的 probe 阈值订正 + Dialog 宽度逃生口接线
上一轮被 2 小时上限打断前的工作,逐条落地(都已在本地编译 + 单测 17 passed + 8 组 probe 中
6 组通过、empty-light 差 ±1 阈值):

- `keyframes::sample` 的 debug_assert 收窄成「首点不在 0ms **且** 首值不是 0」才炸:
  原先 `debug_assert_eq!(first.ms, 0)` 会把 `SEG_TAIL_1/2`(首点 250/900ms,值为 0)也判红,
  而那一类的「保持首值」与 Compose 的「动画初值 0」等价,是合法的。真正危险的是 ROTATION 那种
  「首值 ≠ 0」(会画出不存在的静止段),现在只拦这一种。
- `empty_state::block_layer()` 改成 `pub(super) const BLOCK_LAYER`:函数版在非 test 构建里
  是 dead_code(`-D warnings` 会红),改成常量后组件与 probe 共用一个来源。
- `probe/panels.rs`:图标块尺寸容差放到 ±2(块上补的 1px `OutlineVariant` 描边各占 1 行,
  浅色实测块色行 94、深色 97);顺带去掉没用到的 `matches` import。
- `tests/progress.rs`:三处期望值按实现对齐 —— 旋转每周期 **4 圈**(不是 1 圈)、
  右段宽 **0.505**(0.355..0.860,手算 0.516 偏了 0.01)、单调扫描只扫 `[0,1)`(phase==1.0
  就是下一周期的 0,回绕正常)。
- `dialog.rs`:`Fill + max_width(480)` **实测不生效**(Row 给 Fill 子项的 limits 是
  min=max=分配额,`Limits::max_width()` 的 `.max(self.min.width)` 会把 480 顶回去,
  两列并排实测渲染成 548)⇒ 改回 `Fixed(min(可用宽, 480))`,并加 `Dialog::width(available)`
  让页面递可用宽;gallery 里接线用 `HOST_WIDE`(=800 ⇒ 仍取 480)。能力缺口登记在注释里
  (与 G17-d 同一堵墙,报告里有 548 的实测)。

未完成(下一轮/合并前补):`cmp/dialog-*` 并排图要按 REF_H≈420 重出(动作行被裁掉)、
empty-light 的 ±2 阈值要复跑确认、clippy/test 门禁要贴真实 `test result:` 行。
2026-11-02 15:56:45 +08:00
AskaEth 38b1e7a4dd docs(iced/feedback): 偏离编号、图注 L3、[估] 口径与深色 scrim 观察项
复审 r1 的文档/证据订正(都是「图注与登记不一致」这类,但视觉 reviewer 正是照图注看图):
- gallery 图注「SurfaceContainerHigh + L3」→ 补「(本项目不画阴影,见 D-1)」;
- `snackbar.rs` 的「登记为偏离 D-3」→ D-4(D-3 是进度条波浪降级,之前混号了);
- `mod.rs`「三条偏离」→ 四条,并把深色档 scrim 的观察项登记成 D-5;
- `empty_state.rs` 开头「本文件数值全部是 [估]」→ 按表逐项区分 `[估]/[规格]/[源]`;
- `action.rs` 模块文档补一句「禁用色的底必须是这两个动作件自己的容器角色」。
2026-11-02 15:56:44 +08:00
AskaEth c723d1faff fix(iced/feedback): 空态间距双算、图标块对比度、Dialog 宽度与动作禁用底色(P2-1/2/4)
- P2-1 空态间距双算:`Column::spacing(8)` 与显式 `gap(16/24)` 叠加 ⇒ 实际渲染 32/40、
  条目总高 264,而登记的是 16/8/24、232。现在 `spacing(0.0)`,间距只有一个来源(`gap()`);
  断言从 `gap > 0.0` 改成**精确等于令牌档位** 16/8/24。
- P2-2 空态图标块几乎没有边界:`SurfaceContainerLow` 与画布 ΔRGB 只有 11/5/0(≈1%),
  浅色下看不出块。换 `SurfaceContainerHigh`(材质层 Overlay)+ 1px `OutlineVariant` 描边,
  并用 `block_layer()` 让 probe 与组件取同一个层(不再各写一份角色)。
- P2-1 Dialog 恒 480 宽:`Length::Fixed(480)` 在窄宿主会溢出,`.max_width(560)` 对 Fixed 是
  空操作。改成 `Fill + max_width(480)` = `min(宿主, 480)`(iced 无 min-width,见 G17-d)。
- P2-4 动作禁用底色取错:`interaction::disabled_content_over_role(..., false)` 把 38% 内容色
  固定叠在 **Surface** 上,而 Snackbar 的底是 `InverseSurface`、Dialog 是 `SurfaceContainerHigh`
  ⇒ 真出现禁用态会是「38% 深字叠深底」。改为 `content_color()` 按真实容器角色合成(同令牌 α)。
- probe:Dialog 上内距判据 `(24..=40)` 收紧到 `(24..=30)`;空态新增「块↔标题 ≈16」与
  「条目总高 ≈232」两条渲染层断言。
2026-11-02 15:56:44 +08:00
AskaEth e9af27c8f1 fix(iced/feedback): 不定态两条 p1 —— 线性先排序合并(P1-A)+ 环形补全局旋转(P1-B)
复审 r1 的两条 p1 都是「报告写了 ✓、图上没有」,逐条修:

P1-A(视觉)线性不定态把两段画成一段:
- 根因:`phase_segments` 的两段**不保证有序**(第一线前缘抢跑 250ms、第二线从 0 起),
  `linear()` 却按返回顺序单调铺 `FillPortion`,且份额之和在 34/100 相位上 >1(最大 1.99),
  iced 按总和归一 ⇒ phase 0.55 的两段被压成一段 100px 台阶。
- 修:新增 `layout_spans`(排序 + 合并重叠 + 显式铺间隙)与 `portion_shares`(累计舍入,
  份额之和**恒为 1000**),`linear()` 只消费这两者。
- 断言:`raw_segments_are_unordered_and_layout_sorts_them`(原始输出不有序、排序后位置对得上
  A1 keyframes)、`portion_shares_always_sum_to_the_full_track`(200 个相位全 = 1000)、
  以及渲染层 `12.4:phase 0.55 必须数出两段`(旧实现会数到 1 段)。

P1-B(代码)环形不定态少一条全局旋转 + keyframes 首点语义:
- 官方是两条旋转相加:`CircularGlobalRotationDegreesTarget = 1080f`(`tween(6000, LinearEasing)`,
  javap 偏移 5125)与 8 关键帧旋转(0→360°),绘制时 `fadd` 后 rotate ⇒ 每周期 1440°(4 圈)。
  我们只做了 360°,转速是官方的 1/4。
- `ROTATION` 首点在 300ms/90°:Compose 会自己补 t=0(值 = 动画初值 0),我们没补 ⇒ `[0,0.05)`
  恒 90°,且周期末尾 360° → 下周期 90° 瞬时倒跳 270°。现在显式补 `KeyPoint{ms:0, value:0}`
  并在 `Keyframes::sample` 加 `debug_assert!(首点 ms == 0)`,同一类坑不再靠自觉。
- `CircularGeometry::indeterminate` 改返回 `(弧长, 圈数)`,圈数 = 3.0×phase + 关键帧(可为 >1),
  `progress.rs` 直接乘 360、不做 rem_euclid。
- 断言:`circular_indeterminate_rotation_is_four_turns_and_continuous`(每周期 4 圈、
  跨周期跳变 ≤ 一帧 = 4/60 圈,修前是 0.75 圈 = 270°、旋转单调)。

probe 拆成 `probe.rs` + `probe/circular.rs`(新断言会把单文件顶过 400 行)。
2026-11-02 15:56:43 +08:00
AskaEth 36f58d7904 feat(iced): 反馈类家族四件(Progress / Snackbar / Dialog / EmptyState)
D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message,
数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。

- Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与
  circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。
  环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。
- Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、
  action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。
  iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。
- Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 +
  标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。
- EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。
- action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。
- gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar|
  dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。
- feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。
- feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。

gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings ·
test -p unlucky-app-iced(144 通过 / 0 失败)
2026-11-02 15:56:42 +08:00
AskaEth 27f0d2a551 Merge branch 'feat/p4-appearance-base-keys-titlebar-mode-wi' 2026-11-02 15:43:13 +08:00
AskaEth 5d58301e2e fix(ci): M91 r4 —— 属性内复用主状态机(终结属性内 raw string / 字符字面量盲区)
根因:属性外的状态机(普通/行注释/块注释/字符串/raw/字符)只覆盖属性外;属性内另写了一套
「遇 " 跳到下一个 "」,于是属性内的 raw string 与字符字面量完全不认识:
- 假绿:`#[doc = r#"a "b"#]` 之后跟一个真 `#[allow(unsafe_code)]` → exit 0(r2 能抓到,是新盲区)
- 误红:`#[doc = r#"x" allow(unsafe_code) is banned "y"#]`(纯文档)→ 被报「多出来的 unsafe 落点」

修法(结构性,不是补分支):把逐字符分派抽成 step(),主循环与属性解析**共用同一个函数**;
属性解析只多保留一层「[ ] 深度决定何时离开属性」。属性内的字符串/raw/字符字面量因此与
属性外同一套识别,两处永不发叉。

- g_plain 标记「该位置是否处于普通态代码」:注释与字符串内容既不参与属性起点判定,
  也不参与属性内的 [ ] 计数与文本匹配(所以纯文档里的 allow 文本不会误报)
- 回归用例写进 yml 注释:四个形态(含 CRLF)+ 反方向纯文档例,改词法器前先跑
- 已验:gawk 5.4.0 / gawk --posix / mawk 1.3.4(debian:bookworm-slim)三种实现对四个
  回归用例与全仓 601 个文件给出完全一致的结果(恰好一个落点)
2026-11-02 15:33:48 +08:00
AskaEth 03ce813c32 fix(ci): M91 r3 —— unsafe-scope 门禁换成词法器(括号字面量/expect/块注释三处假绿根治)
必修 1:不配对括号字面量把 r2 的「全文净深度合并」带偏,文件尾部永久不可见
- r2 用「方括号净深度」决定续行合并,但 ['[', '(']、"[2"、starts_with("[2") 这类字面量
  会让深度永久偏离 0(实测 601 文件里 10 个扫到 EOF 仍非 0),于是该文件后面的一切都被跳过:
  在 gate.rs 尾部追加作用域 allow,r2 门禁 exit 0(假绿)
- 换成逐字符词法器:普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 / 字符字面量,
  只在普通态认 `#` 或 `#!` 紧跟 `[` 的属性起点,只在这一个属性内部累计到 `]` 配平;
  数组字面量跨行不再参与合并

必修 2:`#![expect(unsafe_code)]` 既绕过门禁也绕过 workspace 的 deny
- 判据从 allow 放宽到 (allow|expect):expect 同样放行 unsafe(rustc -D unsafe_code 实测通过)

必修 3:块注释里的属性文本被当成第二个落点(与「注释刻意不算」的承诺相反)
- 词法器带块注释状态机(含嵌套),行注释/块注释/字符串里的属性文本都不算

顺带
- 已知边界对齐事实:宏体里直接写出的属性**会**被扫到;真正漏的是「宏在别处定义、这里只调用」;
  补两条:注释/字符串里的属性文本刻意不算、manifest 侧 [lints.rust] unsafe_code 不在扫描面
- 候选文件用 git grep -l unsafe_code 预筛(标识符不会被空白拆开,不会漏),整个 job 约 1~2 秒
2026-11-02 15:16:14 +08:00
AskaEth fa2c6560fd fix(appearance): M91 r2 —— 真交叉断言 + unsafe 门禁跨行扫描 + α 上取整
P2-1「与 design 码值一致」的守门此前不存在
- 新增 crates/app-iced/src/platform/mod.rs::palette_source_codes_match_design_level_codes:
  三个格子对照 PaletteSource::as_str() 与 unlucky_design::palette::Level::code(),
  并覆盖反方向(from_key_value(level.code()) 必须还原同一档)与「码值在允许值表里」
- core 侧那条测试改名为 value_tables_are_self_consistent,注释改成事实:
  core 不依赖 design,只能钉自己的字面量;一致性由 app-iced 的那条对照断言守
- 实测:把 design 的 Level::Wallpaper 码值改成 "desktop" → 新断言红(design 自己 178 个测试仍全绿)

P2-2 unsafe-scope 门禁:行级正则 → 跨行扫描
- 去注释 + 按方括号闭合把续行并成逻辑行,再逐个属性组判断 allow(...unsafe_code)
- 抓得住原来两种绕过:多行属性 #![allow(\n unsafe_code\n)]、同行两属性 #[inline] #[allow(unsafe_code)]
- 门禁注释补全已知边界(不再声称「依赖 fmt job 兜底」——已不依赖)

O-1 α 量化方向:round → ceil
- 0.08×255=20.4,round 得 20 → 实际下发 20/255=0.0784,低于 D36 名义下限
- ceil 得 21 → 0.0824;新增 applied_alpha_is_never_below_the_floor 断言 下发字节/255 ≥ 0.08

O-7 契约测试补三个新键
- appearance_keys_are_all_ai_writable_and_registered 的 audited 清单加 window_opacity /
  titlebar_mode / palette_source
2026-11-02 14:28:36 +08:00
AskaEth 7406785325 feat(appearance): M91 —— 外观下层三键 + win32 整窗 α FFI + unsafe 落点门禁
core(D36/D37/D38 的键与范围)
- appearance.window_opacity(0.08..=1.0,默认 1.0,越界钳位且不报错;下限 D36 定案)
- appearance.titlebar_mode(auto|always|never,默认 auto;判定逻辑不在本 mission)
- appearance.palette_source(background-image|wallpaper|preset,与 design 的 Level::code() 同名)
- schema 1.3.0 → 1.4.0:纯增量、不加迁移步骤(新键出厂值 = 老文件缺键)
- appearance 拆成目录 mod/modes/tests(单文件 ≤400 行),非法枚举由 diagnostics() 给可读回落说明

presenter(只读语义,不破 M89 零 UI 门禁)
- AppearanceBase { window_opacity, titlebar_mode, palette_source } + from_document()
- 单测:缺键→出厂值、越界→钳位、非法枚举→回落且合法值照读

app-iced(D36 唯一 unsafe 落点)
- platform::effective_window_alpha(backend, requested) 纯函数:≤0/非有限 → None(拒绝整窗穿透),
  其余夹到 0.08..=1.0;CPU 档同样允许窗口级 α(D36 对 D27 的修订),4 组真值表测试
- platform/win32/transparency.rs:SetLayeredWindowAttributes(LWA_ALPHA) 的 FFI +
  alpha_byte 量化 + apply_window_alpha;非 Windows 同签名空实现返回 Unsupported

CI
- guards.yml 新增 unsafe-scope job:全仓 allow(unsafe_code) 文件集合必须恰好等于
  crates/app-iced/src/platform/win32/transparency.rs(集合相等,不是黑名单命中)

版本号绊线
- isolation_keys / java_install_keys:字面量 1.3.0 → 1.4.0(绊线保留字面量)
- store / document 的两个关系夹具改成相对 launcher_version() 构造(「更高 minor 只读」/
  「同 minor 更高 patch 保留 $schema」),下次 bump 不再假绿或假红
2026-11-02 13:58:33 +08:00
AskaEth 006eb6869a fix(app-iced): 图标守门白名单补导航族(M79 进主线后的语义错配)
M86 的 sub_24_boxes_go_through_view_at 写于 M79 之前,白名单「未列出 = 0」把
导航族四处 view 调用判成违规。实测这四处 geometry.icon = IconSize::Toolbar = 24,
view 是正确入口,故补白名单(逐文件精确计数,仍能抓「同文件多一处」),总数 8→13。
icons.rs 399 行。
2026-11-02 13:40:21 +08:00
AskaEth 1f4bc1b80d Merge branch 'feat/p4-card-fixed-cool-neutral-hue-for-prese' 2026-11-02 13:12:47 +08:00
AskaEth 13248ddc9b test(design): 新增走 resolve_source 的接线守护测试(复审 r1 p1-1)+ 两条小修
**p1-1**:新增 `resolved_image_scheme_keeps_tinting_from_the_seed`——走产品公开入口
`resolve_source(Some(ImageInput::pixels(..)), None, Some(CardKey::Sky/Teal), Light)`,断言
`provenance.level == Level::BackgroundImage`、`surface == #FFF8F7`(暖白,跟着图走)、色相
≈22°(远离固定的 239.51°)、两张卡下主色与底色都相同、且与天蓝卡的 surface 不同。

上一版只有 `image_scheme_ignores_the_preset_neutral_hue`,它测的是 `Seed::scheme` 生成器层:
把 `resolve::try_level` 的取色分支改成固定中性时 8 条测试全绿(复审已实证)。那条保留并补了
⚠️ 注明「不覆盖接线」,避免下次又把生成器层当成接线守护。

**小修 1**:`every_card_scheme_meets_wcag_aa_under_the_fixed_neutral` 的 on-surface/surface
量级断言按 `flavor` 分开(原来 `(16.30..=16.40) || (14.28..=14.36)`,浅/深两值互换也能过)。

**小修 2**:测试文件拆分以守住 ≤400 行——`tests/card_neutral.rs`(crate 根,400 行)+
`tests/card_neutral/common.rs`(改前路径复原 / 模式列表 / 色相环距离,27 行)+
`tests/card_neutral/dump.rs`(出图数据导出 + 改前/改后实测表,68 行,无断言)。crate 根的子模块
默认在 `tests/` 下找,所以用 `#[path]` 指明目录。

`cargo test -p unlucky-design --test card_neutral` → 9 passed(8 + 新 1 条)。
2026-11-02 13:12:44 +08:00
AskaEth 4b059201fd fix(design): 中性色相常量改容差断言 + 取色接线注释指向真正的守护测试(复审 r1 p2)
- `palettes.rs::neutral_hue_is_the_default_seed_hue`:`to_bits()` 位相等 → 1e-9° 容差。
  色相来自 `atan2(a).to_degrees()`,`NEUTRAL_HUE_DEG` 的字面值是在 Windows(UCRT) 上取的,
  CI 的 ubuntu(glibc) 理论上可差 1 ulp 就让位相等在另一个 OS 上红;而这条要抓的是「换了
  `DEFAULT_SEED` 却没复核常量」(偏几十度),1e-9° 足够且不受平台末位差影响。
  `tests/card_neutral.rs::sky_card_is_bit_identical_to_the_product_baseline` 是**同平台内**
  两个算出来的值互比,继续用位相等(p1 硬约束的钉子,不动)。
  常量文档同步说明「为什么不用 `to_bits`」。
- `resolve/mod.rs` 取色分支的注释:原来只写「`tests/card_neutral.rs` 有反证」,而那条用例
  测的是 `Seed::scheme` 生成器层、覆盖不到这里(复审 r1 p1-1 的病根)。改成点名
  `resolved_image_scheme_keeps_tinting_from_the_seed`——走的就是本函数的公开入口。

`palettes.rs` 仍是 400 行(本轮改 doc 同时压缩了相邻说明,没突破上限)。
2026-11-02 13:12:43 +08:00
AskaEth b748986aae test(design): 钉住 D43 的固定中性色相(八卡同底色 / 天蓝不动 / 动态路径不退化)
`tests/card_neutral.rs`(新文件,8 条用例):

1. `sky_card_is_bit_identical_to_the_product_baseline`:默认档天蓝的 49 个角色 ==
   `Scheme::baseline`,两套模式逐值相等(M88 的 p1 级硬约束)。
2. `preset_cards_share_one_neutral_hue_and_chroma`:八张卡 surface 的 CAM16 色相
   一致(±1°)、实测彩度落在 ±1.5 带宽内,且更强的一条——八张卡的 surface 是同一个
   hex(实测差 0.00° / 0.00)。
3. `the_old_per_card_tinting_really_was_a_ninefold_gap`:反证,证明上面的带宽量得出
   问题——改前浅色档彩度差 9.3 倍(rose 1.29 / teal 12.03),改后 1.0 倍;深色档改前
   彩度本来就一致(tone 6 装得下 12),问题在色相横跨 285°,改后色相跨度 0。
4. `cards_only_change_the_accent_families`:强调族不靠手抄清单,而是实测「换强调色会
   变的角色」(深色 26 个 / 浅色 23 个——浅色 on-primary/secondary/tertiary 的 tone 是
   100,任何色相都是纯白),其余角色必须逐值留在基线。
5. `card_scheme_matches_from_accent_when_chroma_is_the_default`:色卡方案 ==
   `Scheme::from_accent(卡种子)`,把 D43 接到 `tests/official_accent.rs` 的官方对账上。
6. `image_scheme_ignores_the_preset_neutral_hue`:动态路径的守护测试——同一张图在 sky /
   teal 卡下强调色相同,中性调色板色相来自图像种子(精确口径查 `Palettes::neutral`,
   另加一条观感口径),且远离 `NEUTRAL_HUE_DEG`;谁把取色也改成固定中性就会红。
7. `every_card_scheme_meets_wcag_aa_under_the_fixed_neutral`:8 卡 × 浅/深 × 26 对全部
   ≥ 4.5:1(`palette::wcag::check` 全表),并把 on-surface/surface 钉在实测量级。
8. `dump_card_palette_for_the_color_card`:出图数据(`CARD|…` 行,改前/改后并排),
   渲染脚本只做排版。

实测:8 卡 surface 改前 `#EBFEF8`~`#FFF8F4`(浅)/`#061613`~`#1F0F10`(深)→ 改后统一
`#F6FAFF` / `#09151C`;全表最差比值浅 5.44 / 深 4.98,均远高于 AA 的 4.5。

门禁:`cargo test -p unlucky-design` 231 条全绿(216+5 doc → 226+5 doc,+10 条增量:
本文件 8 条 + `color::palettes` 2 条)。
2026-11-02 13:12:42 +08:00
AskaEth de9d5337ac feat(design): 内置色卡改用固定中性色相,切卡只换强调色(D43)
色卡路径原先走 TonalSpot「中性色也跟种子染色」,八张卡的中性色相 = 种子色相,
浅色档 surface 的实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处
暖相色域装不下彩度 12,同一个请求值在暖相上只兑现 1.29。9 倍的底色跳变让用户在
设置页逐张试色时觉得「每张卡都像换了个应用」。

- `color::palettes::NEUTRAL_HUE_DEG`(新常量)= 出厂默认种子 `#3C8DBC` 的 CAM16
  色相 239.51420577630955°,出处与「换默认色时要复核什么」写在常量文档里;
  `neutral_hue_is_the_default_seed_hue` 用位相等钉住它与 `DEFAULT_SEED` 的绑定。
- `Palettes::accent_on_fixed_neutral(accent, neutral_hue, neutral_chroma)`:强调三族
  跟 accent 色相,中性两族固定在给定色相;其余常数(36/16/24/25°/84、tone 表、
  gamut 映射)一行未动。私有 `tonal_spot_with_neutral` 承载实现,`tonal_spot` 只是
  它的「中性色相 = 种子色相」特例。
- `Card::scheme` 改走上述入口(`NEUTRAL_HUE_DEG` + 卡自己的中性彩度)。默认档天蓝的
  49 个角色**逐值不变**(固定在等于它自己中性色相的那个值上,属「沿用」而非「另选」)。
- 动态取色路径**刻意不动**:`resolve::try_level` 仍按图像种子逐色染色、只借色卡的中性
  彩度定浓淡;两处注释都写明「两条路径不同,别统一处理」。
- 订正 `palette/card.rs` 的模块文档:原文把「请求值相同」当成「观感相同」,现在改成
  可直接当契约读的三条(表面与描边八卡逐值相同 / 只有强调族跟卡走 / 天蓝卡 == baseline)。

门禁:fmt 干净、clippy -D warnings 通过、`cargo test -p unlucky-design` 全绿、
`cargo tree -p unlucky-design --edges normal` 仍是单行(零第三方依赖)。
2026-11-02 13:12:41 +08:00
AskaEth e377989327 Merge branch 'feat/p4-token-clean-icon-size-and-disabled-al' 2026-11-02 13:12:22 +08:00
AskaEth 7c274afd7c fix(design,app-iced): 守门改成调用形状+逐调用点计数;filled-tonal 事实纠正(M86 r2 P1)
P1-1(守门漏网):sub_24_boxes_go_through_view_at 原判据只认带 turbofish 的 icons::view::,
且 plain && !sized 让「同文件有 view_at」整文件放行,checked 还数文件不数调用点。现在:
- 先剥注释(注释里写 icons::view 不再误报);
- 按调用形状匹配:icons::view( / view::< / crate::icons::· / super::icons::· / use …view as v
  与 {view} 之后的 v( / view(;
- 逐调用点计数,每文件必须恰好等于白名单期望(未列出 = 0)——所以「同文件改对 3 处漏 1 处」也红。

P1-2(事实错误):FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f 是存在的,
javap -p -constants 可见;它是编译期常量、被内联进 getDefaultFilledTonalButtonColors,所以在
<clinit> 里看不到——我上一轮把「没在 clinit 出现」误写成「字段不存在」。同时把「TonalButtonTokens」
从 0.1f 家族的依据里剔除(它在整个 AAR 里零消费者,正是本 mission 警告的死令牌坑),改为只引用
ButtonDefaults 真的调了 getDisabledContainerOpacity 的 Filled/Elevated/Outlined,并注明
TextButton(容器透明)与 Baseline(基准表、运行时无消费者)。
2026-11-02 13:12:18 +08:00
AskaEth 896edf07fe test(app-iced): 修 sub_24_boxes 白名单与 18px 线宽断言的格式化口径
- 白名单路径补全(shell/nav.rs 不在 components/ 下);扫描跳过 icons.rs 自身;
- SVG 的 stroke-width 用 {} 渲染 f32,2.0 会打印成 "2",所以断言改成
  比数值(stroke_width_for(18,1.5) == 2.0)再比它渲染出的字符串,别写死 "2.0"。
app-iced --lib: 168 passed, 0 failed。
2026-11-02 13:12:16 +08:00
AskaEth 7627462593 test(design): 两族禁用 α 的令牌层钉值(硬编码期望,去自指)
M86 r1 P2:app-iced 那条 surface_family 断言原本从被测常量算期望,改坏常量也绿。除了把它改成
硬编码,令牌层自己也钉一条(cargo test -p unlucky-design 就能红,不必等 app-iced 链接):
DISABLED_CONTAINER_ALPHA == 0.10、DISABLED_SURFACE_ALPHA == 0.12、内容 0.38,且 surface > button。
2026-11-02 13:12:16 +08:00
AskaEth da1fe3aa4f fix(app-iced): 盒 ≠ RENDER_PX 的图标改走 view_at,线宽回到 1.5px(M86 r1 P2)
r1 视觉/代码复审撞上同一件事:本轮把按钮/chip/switch 的盒子改成 18/18/16,但 SVG 仍按 24
烘焙、靠布局盒缩下去 ⇒ 实测线宽 1.04~1.25px(令牌要 1.5px)。label 的对照证据是同屏 caption
(20px,走了 view_at)实测正好 1.50px——差别只在有没有走按盒烘焙。

- buttons/assemble.rs(前置/后置)、selection/chip.rs(前置/后置)、selection/switch.rs 滑块勾
  共 5 处改为 icons::view_at(kind, color, geometry.icon);
- 新增单测 view_at_bakes_the_requested_size:source(18) 必须是 width=18 + stroke-width=2.0
  (视觉 1.5px),并反证「24 烘焙塞进 18 盒只有 1.125px」;
- 新增源码级守门 sub_24_boxes_go_through_view_at:icons::view 只允许出现在图标盒=24 的文件,
  否则红——谁把上面 5 处改回 view 立刻被抓;
- 三处会带偏人的文字订正:icons.rs 模块文档改成「24 盒走 view、其余一律 view_at」;
  selection/mod.rs 的「图标一律 icons::view」同改;token_contract.rs 明说那条只保证烘焙路径。
2026-11-02 13:12:14 +08:00
AskaEth fa40d798e2 docs+test(design,app-iced): filled-tonal 的禁用容器偏离写进代码;禁用色断言去自指(M86 r1 P2)
r1 复审两条:
- 我在报告里说「filled-tonal 的 0.12 偏离已写进代码」,实际一个字都没写,而 state.rs:47 还把
  TonalButtonTokens 列进 0.1f 家族,等于让人误以为已对齐。现在 state.rs 的常量文档与
  disabled.rs 的模块文档都写清:官方 getDefaultFilledTonalButtonColors 硬编码 0.12f
  (FilledTonalButtonTokens 无该字段),我们的入口不区分变体、tonal 实际画 0.10,属已登记偏离。
- surface_family_disabled_colors_are_the_12_percent_class 的期望值原本从被测常量自己算,
  把 DISABLED_SURFACE_ALPHA 改成 0.10 也照样绿(自指断言)。现在期望值硬编码 0.12/0.38 并
  先钉常量本身;按钮族那条同改。
2026-11-02 13:12:13 +08:00
AskaEth b566097107 fix(design,app-iced): 图标尺寸与禁用 α 收敛到官方字节码,两族拆开(M86)
图标:IconSize::Button 16→18(AAR ButtonDefaults.<clinit> 的 bipush 18);新增
IconSize::SwitchThumb=16(SwitchTokens.Selected/UnselectedIconSize)承接开关滑块,按钮与
滑块不再借同一档;IconSize::Nav 的值不动但文档订正(无消费,官方导航/列表图标是 24)。
ButtonSmallTokens.IconSize=20 在整包 AAR 里零消费(javap 搜 getIconSize 无外部调用),
是死令牌,不再作为取值依据。

禁用 α:官方本来就分两族——按钮容器 0.10(*ButtonTokens.DisabledContainerOpacity)与
面状控件 0.12(chip 描边 / slider 轨道 / switch 轨道)。原来的缺陷是一个全局 12% 同时
服务两族(按钮那半边一直错、另半边一直对),现拆成 disabled_button_colors/…_content
与 disabled_colors/disabled_content_over_role 两个入口;interaction 首次超 400 行,
禁用口径与测试搬到 interaction/disabled.rs。

标题栏 caption 三键:官方 SmallIconButtonTokens.IconSize=24,按用户点名缩到 20
(容器 46×48 不动),登记为项目偏离;icons.rs 加按令牌取尺寸的入口 view_at,按
(种类,尺寸) 分别烘焙 SVG,20px 的 stroke 取 1.8 保视觉线宽 1.5px。
2026-11-02 13:12:11 +08:00
AskaEth c825e1aa3d Merge branch 'feat/p4-iced-components-navigation-listitem-t' 2026-11-02 11:56:11 +08:00
AskaEth 111955efae fix(app-iced): M79 r3 —— 分隔线守卫的三处「假绿」根治(众数参考色 / 角色身份校验 / 锚定行无条件打印)
代码复审 r3 = p1-2items,两条都是判据自身的假绿,产品代码零改动。

1. **参考色取「跨度中点那一个像素」→ 缺口落在中点就假绿**
   `line_segments` 改用该行**墨迹众数色**(`modal_color`):242px 的线 vs ≤4px 的缺口,
   众数天然是线自己的颜色。反例 `gap_at_mid`(r2 图在 x=144..145 挖 2px 孔):
   改前 `ok. 1 passed`(假绿),改后 **2 段 red**。

2. **行锚定可被「更靠上的另一条细低对比横线」抢走**
   `find_thin_line` 增加 `expected` 身份色 + `blend_coverage`:线色必须落在
   `Surface → SurfaceVariant` 的插值线上(覆盖率 0.30~1.05,三通道 t 最大差 ≤0.25)。
   这比「单通道差 ≤20」稳得多:实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界;
   hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 散开 0.98;两者都被拒。
   反例 `decoy_outline_gap` / `decoy_hover_gap`:改前 `ok`(假绿,锚到 y=400/401),
   改后打印 `divider skip y=… (不是预期角色色的插值,拒绝)` 并退回真分隔线 → **3 段 red**。

3. **锚定行无条件打印**(不管红绿):
   `[probe] divider anchored at y=471 span=x24..266 color=[221, 233, 245] segments=1 (light, 1180x760)`
   ——「锚错行」从此不再静默。

4. **深色档不再是零覆盖**:`divider::check` 浅色必跑,`tabs-dark.png` 存在就跑深色
   (实测 `y=471 color=[46,60,70] segments=1 (dark)`)。判据/锚定逻辑抽到 `probe/divider.rs`,
   `probe.rs` 393 行仍 <400。

9 张图全跑(退出码即判据):
r2 rc=0(1段) · before rc=101(3段@471) · mutB rc=101(2段) · mutC rc=101(2段) ·
mutE rc=101(3段@471) · mutF rc=101(3段@471) · **gap_at_mid rc=101(2段)** ·
**decoy_outline_gap rc=101(3段@471,y=400 被身份拒)** · **decoy_hover_gap rc=101(3段@471,y=401 被身份拒)**。

已知边界写进 `probe/scan.rs` 模块文档:≤25% 覆盖的淡线 → **loud red**(锚定失败 panic,
不会假绿);身份校验绑定 `SurfaceVariant`(换角色色会 loud red,改 `divider.rs` 一行);
只认横线;深色图缺失时跳过深色断言但不失败。

门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · gate 6 passed · probe 5 passed ·
`clippy --all-targets -D warnings` 干净。变异图在 `target/m79/{mutB,mutC,mutE,mutF,gap_at_mid,decoy_outline_gap,decoy_hover_gap}/`,不入库。
2026-11-02 11:56:08 +08:00
AskaEth dffae2c429 fix(app-iced): M79 r2 —— 分隔线守卫换成「锚定细线行 + 整段跨度数段」
代码复审 r2 P1-1:上一条守卫只认 r1 那种「半透明 AA 缝」,四处假通过。
两个结构性原因与对应修法:

1. **窗口被截成 150px**:`longest_h_line` 凑满 150px 就 return,`line_segments` 只在那
   150px 里数段 → 分隔线实长 242px,只看前 62%;缺口在窗口外看不见、在窗口内则被
   「跳到缺口之后重新起跑」滑过。现在 `find_thin_line` 返回的是**该行整段墨迹跨度**,
   `line_segments` 在这个跨度上数段。
2. **行的锚定不稳**:原来「第一条 ≥150px 的连续带」能被五态行的 40px 高底色带、
   `Primary` 指示条顶替(mutE/mutF 会量到完全不同的元素)。现在四条判据一起锚定:
   跨度 ≥200 · 近乎实心(墨迹 ≥ 跨度×4/5)· 薄(跨中点纵向墨迹 ≤3 行)· 低对比
   (线色与底色单通道差 ≤120)。

`TOL=6` 的口径写进注释:**单通道**最大可辨差;40% 覆盖的抗锯齿行实测差 `(9,6,4)`,
6 能看见它又挡住 ±3 渲染噪声;相邻那行是满覆盖(线色差 25/17/10),所以行锚定不会丢。

五次验收(真跑 probe,退出码即判据;变异图在 `target/m79/{mutB,mutC,mutE,mutF}/`
由 r2/r1 图改像素生成,不入库):

| 图 | 结果 | 实测 |
|---|---|---|
| r2 原图 | **rc=0 绿** | 1 段 |
| `before/`(r1 逐 tab + AA 缝) | **rc=101 红** | 3 段,y=471 x24..265 |
| mutB(缺口 x=200..201,旧窗口外) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutC(缺口 x=100..101,旧窗口内) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutE(r1 两个缝位 102/192 挖真缝) | **rc=101 红** | 3 段,**y=471 x24..266**(仍是分隔线那行,不是 y=503 的五态带) |
| mutF(额外:r1 分段线 + 上方 200px Primary 带) | **rc=101 红** | 3 段,y=471(锚定没被 Primary 带顶替) |

门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · 全套 probe 对 r2 图 5 passed ·
`clippy --all-targets -D warnings` 干净。未改任何组件实现。
2026-11-02 11:56:06 +08:00
AskaEth 55a775fca7 test(app-iced): M79 r2 —— probe 复跑暴露的两处修正(分隔线判据 / 可见性警告)
真跑 `navigation::probe` 才发现的两件事,都只影响测试代码:

1. **Secondary 分隔线判据错了**:原来按 `SurfaceVariant` 的**精确值**找块,
   但 1px 的线画在分数像素上,真实像素是「线色 ⊗ 页面底色」的混合值
   (实测 `(221,233,245)`,不是令牌原值)→ 找到 **0 段**,断言假红。
   改成:先找「与页面底色不同、横跨 >150px 的那一行」,再用**该行自己的颜色**数连通段
   (`probe/scan.rs` 新增 `longest_h_line` / `line_segments` / `differs`)。
   现在:r1 基线图 → 实测 **2 段**(红);r2 新图 → **1 段**(绿)。
2. `matches` 原来被写成 `differs` 的正向委托(语义反了),以及 `pub(super) fn shot` 暴露了
   更私有的 `Shot`(`private_interfaces` 警告,`-D warnings` 会红):前者改回
   `!differs(..)`,后者把 `shot`/`shot_dir` 收回私有(子模块 `probe::long` 仍可见)。

其他:`probe/long.rs` 的 `filter(..).next()` → `find(..)`(clippy::filter_next)。

复跑(真实输出):
- `ULL_NAV_DIR=target/m79/r2 cargo test -p unlucky-app-iced navigation::probe` → **5 passed**
- `cargo test -p unlucky-app-iced` → **197 passed**(+ gate 6 passed,1 ignored)
- `cargo clippy -p unlucky-app-iced --all-targets -- -D warnings` → 干净
2026-11-02 11:56:04 +08:00
AskaEth a36f542f28 fix(app-iced): M79 r2 —— 四条 review 修复(对齐/单行/可比对象/整行分隔线)
代码 r1 P2-1 = 视觉 r1 P2-1(同一处,必修)
- `app_bar::action`:容器只给了 `align_y` → 24px 图标贴 40×40 命中区左缘(实测前导墨迹
  `38..58`,应 `44..68`;hover 的圆形状态层也露在图标一侧)。补 `align_x(Center)`;
  同时页头内容行补 `align_y(Center)`(`Container` 默认 Top,整行原本贴顶)。
- 把 Q21 升格成规则并全目录复查(结论写进 `navigation/mod.rs` 的表):
  app_bar 命中区/页头行、tabs tab 内容、drawer 药丸 = 两轴显式居中;
  list_item 文本块与 drawer 分组标题 = 左上起排(`Start` 是对的,不需要居中)。
  回归钉子:`navigation::probe` 的 appbar 断言量「左内缩 == 右内缩 ±2」。

代码 r1 P2-2(必修):四件文本都没有单行约束,容器高度却是固定值
- `app_bar`(标题/副标题)、`list_item::line`、`drawer::line`、`tabs` 标签统一
  `.wrapping(Wrapping::None)` + 外层 `clip(true)`;页头/ListiItem/drawer 的文本块改成
  `Fill + clip`,长文本切在文本区里,不折行、不顶穿、不压 trailing 动作。
- iced 没有 ellipsis → 按 `inputs/menu.rs` 的 D19 先例登记 **D-M79-5**
  (官方要单行 ellipsis,我们单行 + 超长直接切割)。
- 取证:gallery 新增 `ULL_NAV_PART=long`(长实例名整块)+ `navigation/probe/long.rs`
  量「行高仍 44/60/76 / 页头仍 56 / 标题墨迹右缘 < trailing 左缘」。取证块按
  `selection::matrix` 的先例收进 `navigation/matrix.rs`(gallery 单文件贴 400 红线)。

视觉 r1 P2-2(必修):ListItem 三列内容不同 → 「选中 trailing = Primary」没有可比对象
- 三列都挂 `trailing(settings)`,只有 selected 列走 `Primary`(其余 `OnSurface` / 38%)。

视觉 r1 P2-2(可选,已做):Secondary 分隔线逐 tab 分段 + 0.5 圆角留了 1px 亮缝
- 改成**整行一条**:`Stack` 把一条 `Fill` 的 1px `SurfaceVariant` 压在 tab 行底下
  (底座是 tab 行本身 → 线长 = tab 行总宽,横向滚动跟着走)。
- `TabsGeometry::bar_radius(bool)` 收成 `indicator_radius()`(只有 3px 的 Primary 条要圆角),
  单测同步;probe 新增「Secondary 分隔线必须 1 段」的断言。

门禁:`cargo fmt --all --check` ✓ · `cargo build -p unlucky-app-iced --lib` 零 warning ✓
(截图/探针/测试的实时输出见完成报告;本轮宿主机被多条并行线的 rustc 打满,
bin 链接排队严重,如未取到会如实标注未完成项。)
2026-11-02 11:56:02 +08:00
AskaEth 81e34d77da feat(app-iced): M79 导航家族 —— ListItem / Tabs / NavigationDrawer / AppBar
四件组件 + gallery 状态矩阵 + 渲染层几何断言,全部只落在
`components/navigation/**` 与 `gallery/navigation.rs`(不改 mod.rs / interaction / layout)。

- ListItem(12b §4):一行/两行/三行 44/60/76(A1 56/72/88,登记偏离)、leading/trailing 槽位、
  hover/focus/pressed 只叠状态层(内容色不变)、禁用是修饰器且内容角色取 A1 的 `OnSurface`@38%。
- Tabs(12b §6):Primary 3px 指示条(用 `Stack` 分层让 Fill 条拿到「该 tab 的宽」)、
  Secondary 1px `SurfaceVariant` 分隔线、带图标时容器 64、横向滚动;选中色按变体分(Primary / OnSurface)。
- NavigationDrawer(15 §21 + D33):**同一个容器两个宽度** —— rail 88 ⇄ drawer 240;
  药丸从容器宽派生(56×32 / 216×56,左右各缩 16 / 12,A1 `ItemPadding`);
  分组标题 TitleSmall、组间 1px OutlineVariant;drawer 项高 56(G24)。
- AppBar small(12b §16):页头 56(A1 64,登记偏离)、TitleLarge emphasized + LabelMedium 副标题、
  leading/trailing 槽位、滚动只换 `SurfaceContainer` + L2(A1 无 OnScroll…Height)。
- gallery:`ULL_NAV_PART=list|tabs|drawer|appbar|all` 分块出图;四块矩阵(ListItem 三行高 × 三态、
  Tabs 三个选中位置 + 五态、rail/drawer 同尺度并排、AppBar 静息/滚动)。
- `navigation/probe.rs`:读真截图量像素的 4 条断言(行高 44/60/76、禁用 OnSurface@38%、
  指示条宽 = 标签墨迹 + 2×16、rail 88 / drawer 240 与药丸内缩 16/12、AppBar 56),
  已用两次变异(行高改 56/72/88;drawer 内缩改 16)验证能被打红。

门禁:fmt ✓ · clippy -p unlucky-app-iced --all-targets -D warnings ✓ · test -p unlucky-app-iced 195 passed ✓
2026-11-02 11:55:59 +08:00
AskaEth 22636668d0 Merge branch 'feat/p5-ci-guards-for-presenter-zero-ui-and-d' 2026-11-02 11:19:20 +08:00
AskaEth e9034a40c8 fix(guards): design 零依赖门禁覆盖 dev/build 依赖表(M89 复审 p2-2)
cargo tree --edges normal 看不到 dev-dependencies / build-dependencies,而 design
对外承诺的是「零第三方依赖」,不分 normal/dev/build。新增一步「依赖表必须为空」:
按表头切出 dependencies / dev-dependencies / build-dependencies / target.*.dependencies
(含 [dependencies.serde] 子表写法,表头携带包名),有任何一个键即失败;原「依赖树只允许
出现自身」那步保留,两道互补。

实测:基线绿;[dev-dependencies] serde 红(同一时刻 tree step 仍绿,正是原来的盲区);
[build-dependencies] digest 红。老 job(core-no-ui / file-size / licenses)解析后仍与 main 相等。
2026-11-02 11:19:16 +08:00
AskaEth 48061c3f31 fix(guards): presenter 依赖表检查覆盖 [dependencies.slint] 子表写法(M89 复审 p2-1)
原判据要求表头是 `[dependencies]`,`[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
这类合法子表既不匹配、表头又被 next 吃掉,整段内容完全不查。配合「子表 + optional +
非默认 feature」可让依赖表与 cargo tree 两步同时绿。

- 表头正则加 `(\.[^]]*)?`,并把带子表后缀的表头本身纳入检查(表头携带包名)。
- 实测:基线绿;`[dependencies.slint]` 红;子表+optional+feature 红;target.* 变体红;
  `[dependencies.serde_json]` 不误报(仍绿)。
2026-11-02 11:19:15 +08:00
AskaEth 56bc52756b ci(guards): 新增 presenter 零 UI 与 design 零第三方依赖两道门禁(M89)
- presenter-no-ui:照 core-no-ui 的「依赖表 + cargo tree」双查,名单一致
  (slint/egui/eframe/iced/winit/gtk/javafx/tauri/fltk/druid/relm)。
  依赖表检查按表头切出依赖表再查键名——presenter 的 description 合法地写着
  「Slint 版与 iced 版共用」,core-no-ui 那种整文件 grep 在这里会误报。
- design-zero-dep:cargo tree -p unlucky-design --edges normal 只允许出现自身一行,
  把 README 明写的「零第三方依赖」卖点从人肉守变成 CI 守。
- 两条都 fail-closed:cargo tree 自身失败(crate 改名/解析异常)同样红;
  失败信息给出命中清单与放哪里的建议。
- 未改任何已有 job 的语义(core-no-ui / file-size / licenses 原样)。
2026-11-02 11:19:14 +08:00
AskaEth 1d0ff70908 Merge branch 'feat/p4-palette-source-trio-image-wallpaper-p' 2026-11-02 11:10:59 +08:00
AskaEth 8be91a9cc9 docs(design)+test(palette-extract): 修正「种子一定是原图真实颜色」的过度承诺
自测发现:400×400 红蓝棋盘在 MAX_EDGE=256(适配层 thumbnail 缩放)与 512(design 块平均)
下都取到 #704070,都不在原图上——「≤MAX_SAMPLES ⇒ 真实颜色」只对「传进 analyze_rgba 的
那个缓冲」成立,不是整条链的性质。
- Seed::color 文档写明边界 + 上游缩放同样会破坏该性质(附实测例)
- 结构性断言的注释/断言文案改为「design 不再叠第二层块平均」,不再声称能保住真实颜色
2026-11-02 11:05:06 +08:00
AskaEth 09b716ae79 docs(design)+test(palette-extract): r2 三条口径修正与常数绑定
- LowChroma 文档:删掉「彩度必然 < 候选门槛 5」(人气门槛也会淘汰高彩度小面积,
  实测 0.5% 纯红报 113.36),改为与契约文档 §6 一致的口径;代码臂注释与 score::most_chromatic 同步
- Seed::color / share 改成有边界的说法:≤MAX_SAMPLES 才是图上真实色、share 的分母是有效采样点
- palette-extract 新增 max_edge_stays_within_the_design_sample_budget:
  MAX_EDGE² ≤ MAX_SAMPLES(256² 当前余量为零,调大就红)
- card.rs:把 6.43~6.50 / 7.70~7.76 写成断言(原来只有 ≥4.5 下限,文档却宣称区间)
- LowChroma.color 字段注释改成「最接近可用的候选色」(113 那条并不「能用」)
2026-11-02 11:05:03 +08:00
AskaEth f4c48752c2 fix(design): Outcome::NotReached / 色相环回绕断言 / LowChroma 口径统一 / 文档指向(复审 r1 p2-4 + 色彩 r1 p2-2/3/4)
- Outcome 新增 NotReached:区分「用户没提供」与「更高级成功所以没轮到」,
  UI 不会再对着用户已选的卡说「色卡:未选择」;新增 not_reached_is_distinguishable_from_not_provided
- cards_are_visually_distinct 补色相环回绕间隔(原来只比排序相邻差,350° 的卡会静默通过)
- Failure::LowChroma 的 color 与 chroma 现在永远来自同一个簇(原来一条路径拿 A 簇的色值配 B 簇的彩度);
  两条路径的口径写进文档注释,并提醒 UI 别把数字写成「彩度 X < 门槛 8」
- card.rs:测试名指向修正(card_keys_are_stable_and_unique,测试就在本文件)、
  「对比度 8.4:1」的假示例换成实测区间(light 6.43~6.49 / dark 7.70~7.75)、
  中性彩度那段补上「请求值相同 ≠ 实际表面同冷度」的事实与 D43 去向
2026-11-02 11:05:02 +08:00
AskaEth 948a3685cd test(design): 钉住 D40 的彩度门槛 8(复审 r1 p2-3)
MIN_SEED_CHROMA 8.0 → 6.0 的变异原本全绿(那条断言比的是常量自己)。
新增两条用实测 chroma≈7.23 的灰蓝(#7E8489):
- seed::tests::chroma_just_below_the_gate_is_rejected(门槛之上/之下夹住)
- resolve::tests::faint_colour_falls_back_to_the_selected_card(回落 + 落点是选中卡 + 方案逐值等于该卡)
顺带把 resolve 的单测拆到 resolve/tests.rs(400 行上限)
2026-11-02 11:05:01 +08:00
AskaEth 579f3ae271 feat(palette-extract): 补 bmp 解码器(复审 r1 p2-2,定案加)
M73 的 Windows 壁纸探测只读注册表/.theme 里的路径,不筛扩展名,.bmp 壁纸真会出现;
image 的 bmp 是独立 feature 且无额外传递依赖(bmp = []),代价≈0。
- features 加 "bmp",Cargo.toml 与 lib.rs 注释的口径对齐(原来注释说支持 bmp 但没开)
- 新增 bmp_is_supported(真编一张 BMP 再解码取色)
2026-11-02 11:05:00 +08:00
AskaEth 2ce394fbfc fix(design): 混合透明度不再被误判为「全透明」(复审 r1 p2-1)
先按 alpha 过滤像素、再做块平均(整块全透明则跳过)。原实现先平均再比门槛,
块内 255 与 0 的整数除法得 127 < 128 → 512×512 里 131072 个不透明像素被报
NoOpaquePixels,UI 会告诉用户「背景图几乎全透明」,与事实相反。
- 新增 half_transparent_image_keeps_its_opaque_colour(红)
- 新增 mostly_transparent_image_still_samples_the_visible_part
- 模块文档加一行「混合透明度」的处置与踩坑说明
2026-11-02 11:04:59 +08:00
AskaEth 36c1cce00d test(design): 补「先量化再打分」的端到端反证(复审 r1 p1)
reviewer 实测变异:把 analyze_rgba 改成不透明像素算术平均,150 个测试全绿——
因为反证只测了 quantize/score 中间层,接线没人覆盖。新增:
- seed::tests::end_to_end_picks_the_five_percent_accent(95% 米白 + 5% 珊瑚 → #FF6B5A)
- seed::tests::end_to_end_picks_the_three_percent_accent(97/3 → #FF8800)
- resolve::tests::a_five_percent_accent_drives_level_one(一级成功、种子是珊瑚、≠ 回落色卡)
顺带把 seed 的单测拆到 seed/tests.rs(410 行 → 220 + 190,恢复 ≤400 纪律)
2026-11-02 11:04:58 +08:00
AskaEth 80314f4830 docs(design): hint_key 的文案映射表搬进 provenance.rs 的文档注释
DevDocs/ 是 gitignore 的本地文档,契约里的「键 → 文案」表review 时看不到,
所以在 hint_key 的 rustdoc 里放同样一张表(差异只有措辞建议,结构一致)。
2026-11-02 11:04:57 +08:00
AskaEth 31ee69100a refactor(design): resolve 拆成目录模块(单文件 ≤400 行),同步文档口径
- palette/resolve/{mod.rs,provenance.rs}:编排函数与 Provenance 类型分开,路径不变
- color/mod.rs + palettes.rs:原来的「取色/色卡库不在本 crate、还没实现」改成指向 crate::palette
- 取色 gap 登记改为「单种子已实现,多 source 色仍未实现」
2026-11-02 11:04:55 +08:00
AskaEth 1aac62f6f0 feat(palette-extract): 图片解码适配层(D40 第 2 层,唯一引入 image 的地方)
- 新 crate unlucky-palette-extract:路径/字节 → RGBA → design 的种子
- image 只开 png + jpeg(关 default-features,不拖 rayon/avif/webp)
- 解码前按 Limits 拒超大尺寸(解压炸弹),解码后最长边缩到 256
- input(&Result<Rgba8, _>) 把失败翻成 ImageInput::Undecodable(回落链靠它区分「没设」与「坏了」)
- 11 个单测覆盖纯色/JPEG/抄错扩展名/垃圾字节/截断/缺文件/1px/灰图/大图/超限/零拷贝
2026-11-02 11:04:52 +08:00
AskaEth 18327fb038 feat(design): 配色来源纯数学层(Monet 式量化取色 + 色卡 + 三级回落 + 对比度收敛)
D40 分层:design 保持零第三方依赖,只吃已解码的 RGBA 像素。
- palette/quantize:5bit 直方图 + CIELAB k-means(QuantizerCelebi/Score 的形状)
- palette/score:官方 Score 口径(CUTOFF_CHROMA 5 / TARGET 48 / 权重 0.7+0.3)
- palette/seed:seed_from_rgba + D40 彩度门槛 8,灰白图回落而非凭空上色
- palette/card:8 张内置色卡,key 为持久化键
- palette/wcag:26 对文字对比度门禁 + 沿 tone 轴收敛并记录修正
- palette/resolve:纯函数三级来源编排 + Provenance(原因码/提示键)
2026-11-02 11:04:10 +08:00
AskaEth c1fc5b8086 Merge branch 'feat/p5-presenter-extract-host-layer-for-iced' 2026-11-02 11:02:29 +08:00
AskaEth d03ba13c0f test(presenter): 修正 tile_viewport 单测的期望值(760/128 量化到 640 而非 768)
迁移后首次跑 test 时这条自测断言写错:函数行为与原 ui_bind 的实现逐字一致,
错的是期望值。修完 `cargo test -p unlucky-presenter` = 122 passed。
2026-11-02 10:11:55 +08:00
AskaEth f159529936 feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate,
Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。

- 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`),
  workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、
  iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。
- `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、
  prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、
  mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等),
  内部实现零改动。
- 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS /
  SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport /
  system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。
- crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge|
  toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*`
  (64 处),backend.rs 的 config_path/build_backend 提为 pub。
- crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。
- prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
2026-11-02 09:29:31 +08:00
AskaEth 00f132d8ce Merge branch 'feat/cli-unify-launcher-binary-subcommands-wi' 2026-11-02 08:08:20 +08:00
AskaEth fb3d3f46c9 docs(readme): 未知子命令的提示改成 unlucky-launcher --help(M82 r4 复审 P2)
两版 README 的「未知子命令是错误,不是开窗」一段写的是提示 `ull --help`,与真实输出相反:
GUI 二进制跑出来是 `unlucky-launcher --help`(只有 `ull` 自己跑才是 `ull --help`),
而且同文 87/77 行已经写着「自称的名字跟着你敲的那个二进制走」——正文自相矛盾。

只改这两处;全文其余 `ull` 都是指 `ull` 那个产物本身(对照表、§ 说明、`ull --help` 的对比句),
按语境保留。
2026-11-02 08:08:18 +08:00
AskaEth 8ca1e1e965 fix(cli): 程序名占位符在构造时渲染,两条出口(人类可读 / --json)不再分叉(M82 r3 复审 P2)
r2 只在人类可读通道渲染 `%prog%`,JSON 两条出口(错误信封的 message/hint、Report 的
warnings)直接序列化未渲染文本,于是 `--json lst` 吐出 `"hint":"%prog% --help 查看命令列表"`
——连 `ull` 自己的 JSON 都被这轮改动弄坏了。

改成**构造时渲染**,一个入口管住所有出口:
- `CliError::usage/user/internal/with_hint` 统一过 `render()`(错误只有这一处成形);
- `Report::new` 渲染 human 行,`Outcome::with_warnings` 渲染 warnings;
- `print_report` / `print_error` 不再各自渲染(渲染点只有一个,不会再有第三条出口漏掉)。

回归测试三条:`json_error_envelope_never_leaks_the_placeholder`、
`json_warnings_never_leak_the_placeholder`、`human_lines_render_the_placeholder_too`
(后者同时断言「账户名恰好叫 ull」这种用户数据不被改)。
2026-11-02 08:08:16 +08:00
AskaEth 2f8bbd1746 fix(cli): 程序名只替换模板占位符,不再改写用户数据(M82 r2 复审 P2)
r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。

改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。

同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。

回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。

占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
2026-11-02 08:08:13 +08:00
AskaEth c9e6236a87 refactor(cli): 判据收敛到 unlucky_cli::cli,banner 按被调用的二进制名自称(M82 复审)
去重:`looks_like_cli(args) -> bool` 落在 `unlucky_cli::cli`,两个 GUI 的 main.rs 只调用它,
判据从两份变一份。cli crate 里**不**出现 GUI 的 flag 表——语义仍是「首个 token 是裸词就接管;
首个 token 是 flag 时只有解析出真命令才接管,解析失败不接管」。原来两个 main.rs 里的 5+5 条
单测合并成 cli crate 的一份(用例集按复审要求原样搬,含 --screenshot/--renderer/--diff/
--mock/--force-state/--scale-factor、--help/-h/--version/-V、10 个已知子命令、--json 前置、
未知子命令),两个 main.rs 的旧测试已删除。

banner:新增 cli::program 模块,从 argv[0] 的 basename 取程序名(剥目录、剥 .exe、空值回落
ull),把帮助/版本/错误提示里独立出现的 `ull` 换成它——`unlucky-launcher --version` 不再自称
`ull`,也不会把人指去敲 `ull --help`。`run()` 签名未改;`ull` 自己跑时输出逐字不变(替换是
恒等),打包脚本的 `ull --version` 冒烟不受影响。
2026-11-02 08:08:12 +08:00
AskaEth fecff989e7 docs: 修掉「把没量的东西标成实测」的四处引用(M82 复审 P2)
- 两个 GUI main.rs:把「不 dlopen 任何图形库」改成「不依赖任何图形库(缺失时不影响)」,
  并把「实测见 §7」换成「§7.4 是依赖取证;耗时是结构性判断,没重量(§10-8)」
- packaging/README §1:§7.6(不存在)→ §7.4
- packaging/README §4:实测引用 §7.1 → §7.4(ELF 取证在 §7.4)
- packaging/README §7.4 标题补 ELF DT_NEEDED,并记下复审在 bookworm-slim 里的真跑结果
- packaging/release-notes:ull 示例前补 chmod +x(Release 资产不带可执行位)
2026-11-02 08:08:11 +08:00
AskaEth 0d4628f85e docs(packaging): 如实记下 M82 的体积/首帧没有重量
本机当时三个 worker 同时在编,全量 release profile(codegen-units=1 + fat LTO)要
重跑 400+ crate,这两个数留给空闲窗口补。写清结构性判断(分发在 MainWindow::new()
与后端探测之前,GUI 路径只多一次 argv 解析)与它的性质:不是实测。
2026-11-02 08:08:10 +08:00
AskaEth 1c7285cea9 feat(cli): GUI 二进制内建 CLI 子命令分发,ull 纳入发布资产(M82)
一个二进制两种用法:两个 GUI 的 main.rs 在进 UI 之前分发子命令,命中就把控制权交给
unlucky_cli::run —— 不建窗口、不探后端、不扫字体、不 dlopen 图形库。

分发判据只有一条,子命令表不重复:crates/cli 的解析器是唯一真相源。
- 首个 token 是裸词 → 子命令位置,交 CLI(未知子命令由 CLI 报错 + 提示 --help,退出 1,
  不静默开窗)
- 首个 token 是 flag → 只有 CLI 解析出真命令才算 CLI 调用;--data-dir X 解析成 Welcome、
  --screenshot / --renderer / --diff 等 GUI 参数解析失败,一律仍走 GUI(CI 脚本零改动)

顺带:unlucky-launcher --version 以前会直接开窗,现在走 CLI 分发报版本。

packaging:ull 作为独立裸资产随每个槽位发布(make_artifacts.py + release.yml 的 assets
数组与 SHA256SUMS glob),矩阵完整性检查改成 GUI/CLI 各查一次。

app-iced 的 x11 / wayland / linux-theme-detection 收进 target 专属的 cfg(target_os) 段。

门禁实跑:cargo fmt --all --check 通过;cargo clippy --workspace --all-targets -D warnings
通过(exit 0);cargo test --workspace 通过(51 个测试二进制 / 1369 个用例,含本次新增的
5 + 5 条分发判据用例)。
2026-11-02 08:08:09 +08:00
AskaEth ef3212021a Merge branch 'feat/p4-iced-components-inputs-textfield-sear' 2026-11-02 07:45:42 +08:00
AskaEth 05fefc8a7b fix(app-iced): M77 r1 —— 菜单项 trailing 槽位靠右(视觉 r5 P1)
`item_row` 的 Row 早先是 `leading → label → trailing`,中间没有撑开的空间,于是
trailing(快捷键 / 子菜单箭头 / 勾)**紧贴 label 右边**,一列菜单里读不出一条竖线,
也与 M3「trailing 落在行右内边距上」不符。

改法(只动 `menu.rs` 的行装配):
- label 与 trailing 之间插一个 `space::horizontal()`(宽度 Fill),**只在有 trailing 时插**——
  没有 trailing 的项仍然左边起排,不被居中/右对齐;
- 把 Row 包进 `container(..).width(Fill).height(Fill).align_y(Center)`:Fill 空间要有宽度可撑
  (Q8 的教训:撑开靠容器,不靠子元素),顺带把内容垂直居中(`button` 只把内容放左上角);
- trailing 的右缘现在落在 item 右内边距上,与 leading 左内边距对称(12 = 4 内缩 + 8 button pad);
- 分组分隔线 / 禁用项的行为未动(同一函数的其它分支没改)。

渲染层断言(`probe/menu.rs`):带 trailing 的项,其 trailing 墨迹右缘 = `menu_right − 17`
(= 4 指示器内缩 + 12 内边距 + `Check` 字形在 24 viewBox 里约 5 的边界)±1。
变异(删掉那行 `space::horizontal()`)后实测 `menu_right=559、墨迹右缘=438 ⇒ 偏 121`,断言红;
修复后偏 **17**。重出 `menu-{light,dark}.png`,勾已贴行右缘成一条竖线。
2026-11-02 07:45:39 +08:00
AskaEth 21f2734981 test(app-iced): M77 渲染层 probe —— 逐行几何断言(5 条,能被变异打红)
M68 的教训:纯函数返回对、图可能是错的。`inputs/probe.rs` 对着**真窗截图**量像素,
没给 `ULL_INPUTS_DIR` 就大声跳过(与 `gallery::probe` 同一口径)。

量什么(每条对应一次真实可能画错的形态):
- 文本框高**精确 56**(320 宽格)、Filled 指示线 静息 1dp / 聚焦 2dp 的墨量比;
- Outlined 描边 1dp / 2dp(描边墨量随亚像素相位在 1.0~1.67 浮动,所以用**比值**判);
- 错误态:框内标签与框下辅助文本都是 `Error` 墨迹;
- 搜索条高 56、前置图标墨迹偏移 20(= pad 12 + offset 4 + viewBox ≈4)、
  docked 面板 宽 360 / 头 56 / 1px `Outline` 分隔 / 结果区 240;
- Slider 竖条 4×44、0% 贴左、50% 分界在正中、100% 无未激活段(thumb 不越界);
- 菜单容器 161(= 8 + 48×3 + 1 + 8)、项高 48、选中指示器内缩 4、分组线 1dp。

像素工具因 `gallery::scan` 是私有模块而自带一份最小读图器(约 60 行),已报 finding。
2026-11-02 07:45:38 +08:00
AskaEth ddd234b3c8 feat(app-iced): M77 输入家族 —— TextField / Search / Slider / Menu(含 gallery 登记)
D39 五家族并行的「输入类」这一条线。四个组件全部对消息类型泛型(`M: Clone`,不碰
`app::Message`),状态一律走 `components::interaction`,精确尺寸走 `components::layout`,
零尺寸字面量(取不到的集中登记 G16~G20 在 `inputs/geometry.rs`)。

- TextField(12b §9):Filled / Outlined;56 高(无标签 40)、4 圆角、内边距 16/16、
  前置图标可视边 12(文本从 48 起)、指示线/描边 静息 1dp → 聚焦与错误 2dp、
  错误态线/标签/辅助文本全 Error、禁用 12%/38%(Filled 容器官方 4%,令牌只有 12% → D10)。
  标签常浮起(无上浮动画,D12);A1 无 Pressed token,press 按 hover(D11)。
- Search(15 §20):SearchBar(收起)+ SearchView(展开 docked)分开实现。bar 消费
  `ComponentMetrics::SEARCH_BAR`,图标可视边 = pad 12 + icon_offset 4 = 16(只做加法,
  不写 16/20),聚焦 1px Secondary;view 头部 56 + 1px **Outline** 分隔 + 结果区 240。
- Slider(12b §11):自绘 `advanced::Widget`——轨道 16 full、竖条 4×44(focus/pressed 收窄到 2)、
  激活 Primary / 未激活 SecondaryContainer、命中 48、离散停靠点 4(未激活轨在点两侧留 6 空隙)。
  位移区间取 handle/2..W−handle/2,所以 0%/100% 竖条不越界。
- Menu / DropdownMenuItem(12b §8):SurfaceContainer + L2 阴影(Elevation 令牌)、项 48、
  水平 12、上下 8、选中 SecondaryContainer(指示器内缩 4、圆角 8)、分组分隔线复用
  `components::divider`。圆角取令牌表 4(12b 说本项目 12 → D18)。
- gallery/inputs.rs:四组件状态矩阵登记(五态 × 两式 + 空/有值/错误、0%/50%/100%、菜单展开态),
  按 `part` 拆成四段(整段比 760 高的窗口还高)。

单文件 ≤400 行:四个组件的单测与 Slider 的纯几何分别拆到 `<name>/tests.rs` 与 `slider/track.rs`。
2026-11-02 07:45:36 +08:00
AskaEth 78667953f5 Merge branch 'feat/p4-iced-components-selection-checkbox-ra' 2026-11-02 07:14:48 +08:00
AskaEth 9519ce8e1b test(selection): 状态层的两种 α 都要查(复审 P2-4 补,第二次变异曾漏网)
原来只查 hover(8%)那一色,于是「把 pressed 列的状态层画成方块」照样绿——实测变异
mut-one-layer-box 当时是 passed。现在 hover(8%) 与 pressed/focus(10%) 两种颜色各查一遍,
每一块的四个角都不许有墨。

变异证据(六个变异全部转红,见报告):
- mut-one-box-22     → 18.3:Primary 块不得超过 21,实测 22×22 @ (541,222)
- mut-one-layer-box  → 18.5:Pressed 状态层必须是圆(四角不该有墨)@ (532,212)
- mut-chip-34        → 13.1:filter chip 应恰好四块,实测 3
2026-11-02 07:03:13 +08:00
AskaEth 4155429f45 test(selection): 像素判据从「存在性」改成「逐处都对」(复审 P2-4)
- 20(±1) 的 Primary 块:**每一块**都不许超过 21(22 = 描边画到框外),且总数恰好 12
- 状态层:**每一块** Ø40 都要四角无墨(原来只看两块角点),数量 ≥5
- switch 轨道 **恰好一块** 52×32、chip **恰好一块** 32 高(原来 any(...) 就算过)
- 新增 P2-1 / P2-3 的像素断言:禁用 chip 4 块完整宽度 + filter·on 仍填充;20×20 禁用块不许是 12%
- 断言与像素扫描搬到 selection/probe.rs + selection/pixel.rs:gallery/selection.rs 只剩「摆矩阵」
  (原文件 458 行,超 400 红线)
2026-11-02 06:55:34 +08:00
AskaEth 0c9d4049b3 fix(selection): Checkbox / Radio 命中区恒 48×48(复审 P2-2)
- 无标签时行宽只有状态层的 40dp(A1 的 minimumInteractiveComponentSize 是 48)
- ControlGeometry::hit_padding() = (48−40)/2 = 4,view 侧左右各留这段透明内边距
- 单测钉住 40 + 2×4 == 48(checkbox 与 radio 各一遍)
2026-11-02 06:55:31 +08:00
AskaEth c5765c57e7 fix(selection): Chip 禁用改成修饰器,只清消息、不删图标与选中态(复审 P2-1)
- 旧 API Chip::disabled(label, variant) 强制 leading/trailing/selected = None/false:实测
  assist 78→54、filter·on 74→50(连填充和勾都没了),禁用列里 filter·on 与 filter 逐像素同形
- 新 API Chip::assist(label, msg).leading(k).disabled():只清 on_press,视觉信息全保留
  (research/12b §13 的禁用态是「容器/描边 12%、文字与图标 38%」——只降不删)
- 矩阵里禁用格改用修饰器,图标与选中态一并保留
2026-11-02 06:55:28 +08:00
AskaEth 00dbb5a5d5 fix(selection): 选中禁用的 Checkbox 容器改用 OnSurface@38%,并补逐格断言(复审 P2-3)
A1:SelectedDisabledContainerColor=OnSurface + SelectedDisabledContainerOpacity=0.38(不是普通禁用容器的
12%)。断言同时钉住三件事:fill == 38% 叠色、fill != 12% 容器色、未选中禁用描边与它是同一个 38%。
(同一提交还带着 P2-2 的命中区单测:两处断言都在 tests.rs,无法按 hunk 切分。)
2026-11-02 06:55:25 +08:00
AskaEth 17394b73ff refactor(app-iced): paint.rs 按组件拆成 paint/ 目录(复审 P2-5)
- paint.rs 原本正好 400 行、零余量:再加一个态就得动它(和 app.rs 同一个问题)
- 拆成 mod.rs(Frame / 状态层 / 禁用色 / 标签色)+ check.rs / radio.rs / switch.rs / chip.rs
- 对外 API 一字不改(selection::paint::* 路径全部保持)
- 顺带把「选中且禁用的 Checkbox 容器」从 12% 改成 A1 的 38%(复审 P2-3):这一行就在被移动的
  check.rs 里,单独切 hunk 只会让历史不完整;断言在下一条提交
2026-11-02 06:52:10 +08:00
AskaEth b69a2cdd7f feat(app-iced): M76 选择类家族 —— Checkbox / Radio / Switch / Chip(15 §18/§19 + 12b §10/§13)
- selection/:geometry(令牌→像素,缺档登记 G16)/ paint(Interaction×选中×错误→角色)/
  shell(48 透明外壳 + 帧内状态通道 + 勾形 SVG)/ checkbox·radio·switch·chip / matrix(12×5 状态矩阵)/ tests
- gallery/selection.rs:小节 + **渲染层像素断言**(20×20 选中与未选中同尺寸 · Radio 内点 10 ·
  Ø40 状态层是圆 · 勾形 14.8 · 短横 · 轨道 52×32 · Chip 32 · 禁用 38%),按颜色+墨迹块定位、不假设行号
- gallery/mod.rs:新增 ULL_GALLERY_SECTION 段落过滤(tower 批准,M77 零成本复用)+ 四个 helper 放宽到 pub(crate)

A1 证据(本机 AAR javap):未选中 hover/focus/pressed 描边是 OnSurface(不是 OnSurfaceVariant)、
Radio 选中态 hover 仍 Primary、Checkbox SelectedDisabledIconColor=Surface、Switch hover 滑块 PrimaryContainer。
2026-11-02 06:11:21 +08:00
AskaEth 35345f3399 Merge branch 'feat/p4-iced-component-base-buttons-gallery-h' 2026-11-02 04:51:10 +08:00
AskaEth 4d7b198822 fix(app-iced): M75 r6 —— probe 删 19 行重复块 + 补第 8 行(icon·tonal) + 三处注释按 visual-r4 P2-2 更正
① 19 行重复块(第 4 次报):probe.rs 218-236 与 198-216 逐字重复(on_primary/left_ink 双层循环/
   icon_pad/assert 各两遍,靠 shadowing 编过),已删;粘在一起的注释头随之消失。
   滑动窗口脚本:改前 'DUP at 199 and 219' → 改后 'no 19-line duplicate'。
② 逐行护栏补到 8 行:(7, OnSurface, true, "icon·tonal");汇总打印 '8 行通过'。
   变异(该行墨迹涂回满档色)→ probe 红。
③ 三处过期注释按 review 的 §P2-2 逐条改:
   - probe 模块文档 药丸顶行宽 ≈9 → 左内缩 ≈14;
   - 圆角推导改成像素中心闭式 inset(k)=r−√(r²−(r−k−0.5)²),r=20,k=1 给 12.40;
   - gallery/mod.rs 列宽注释 132 → 160(cell_width 的实际值)。
2026-11-02 04:51:07 +08:00
AskaEth 4954ba3463 chore(app-iced): M75 r5 —— 删掉被 count_in 取代的 count_pixels(clippy dead_code) 2026-11-02 04:51:06 +08:00
AskaEth b563c6ac44 docs(app-iced): M75 r5 —— 三处过期注释按 A1 更正(会被四条家族线当规格抄)
- buttons/mod.rs 表行 1.8:禁用内容角色「按变体」(Variant::disabled_content),容器 α 注明 A1 是 10%
- buttons/mod.rs 表行 2.7:IconButton 禁用内容 = OnSurface@38%(A1),落点改到 disabled_content_over_role
- interaction α 表 / disabled_colors 文档:容器 0.12 与 A1 的 0.10f 差异点名 D8;内容默认为 OnSurfaceVariant
- overlay_color 的 content 说明从 OnSurface 改成 OnSurfaceVariant(与 A1 一致)
2026-11-02 04:51:04 +08:00
AskaEth eed1c9d3f7 fix(app-iced): M75 r5 —— 禁用内容角色按变体(A1)+ 删 probe 重复块 + 逐行护栏
1) A1 逐变体(AAR javap):Filled/Elevated/Outlined/Text = OnSurfaceVariant@0.38;
   FilledTonal = OnSurface@0.38(FilledTonalButtonTokens.DisabledLabelTextColor);
   Standard/Filled IconButton = OnSurface@0.38(*IconButtonTokens.DisabledColor)。
   新增 Variant::disabled_content() / IconVariant::disabled_content(),
   interaction::disabled_content_over_role(ui, role, on_container) 成唯一入口。
2) probe.rs 删掉被两个 reviewer 点名的重复块(disabled_colors 调用两次、
   disabled_container 绑定两次),并补 grep 证据。
3) 护栏改成**逐行**:矩阵 7 个有内容的变体行各自断言 disabled 列的 38% 墨迹 > 40 px
   (浅/深同一套阈值,单行回退就会红),取代原来按浅色标定的全局 500 阈值。
6) 删掉 interaction/mod.rs 里挂在 mod tests 头上的悬挂文档。
2026-11-02 04:51:03 +08:00
AskaEth b31a758cda test(app-iced): M75 r4b —— 无容器禁用墨迹的护栏收窄到 disabled 列 + 阈值 500(变异实测会红)
整图计数会把别处抗锯齿像素算进来(正常 800/1966 px,涂回满档色后仍剩 348 px),
护栏实际上拦不住 code-r2 说的「text/standard 那两行」。改为只在矩阵最右 1/3 列内计数,
阈值 500(浅 800 / 深 1966 正常,变异后 348)——变异实测:红。
2026-11-02 04:51:02 +08:00
AskaEth a878fc6a58 refactor(app-iced): M75 r4 —— interaction 拆成 mod.rs(287) + tracker.rs(159),恢复被压掉的文档
interaction.rs 连压三笔才回到 400 行(靠删文档腾空间),而它会被四条家族线各扩一次。
- interaction/mod.rs:五态枚举 + overlay_color / disabled_colors / disabled_content_over(态→颜色)
- interaction/tracker.rs:事件→态的状态机 + 它的两条单测;Tracker 用法示例(ignore 代码块)恢复
- 对外路径不变:components::interaction::* 照旧(mod.rs 里再导出 Tracker)
- 两份文件都写了「加东西前先看行数余额」
顺带修两处门禁:注释里去掉 hex 色值(守门连注释一起扫)、再导出显式 allow。
2026-11-02 04:51:01 +08:00
AskaEth ab5f5802a8 test(app-iced): M75 r3f —— 去掉一条与上一行重复的断言,interaction.rs 400 行 2026-11-02 04:50:59 +08:00
AskaEth 95a6f56060 docs(app-iced): M75 r3d —— interaction.rs 再收 2 行,确保 ≤400 2026-11-02 04:50:58 +08:00
AskaEth a3b3c9eba7 docs(app-iced): M75 r3c —— interaction.rs 文档收口(Tracker 用法/α 表压行,回到 400 行内) 2026-11-02 04:50:57 +08:00
AskaEth 86f7a31d9a fix(app-iced): M75 r3b —— IconButton 高度断言按抗锯齿实测改为 39..=40 + interaction.rs 收回到 400 行内
exact-color 的连通块高度会排除底部抗锯齿行,实测 40×39;宽度仍精确 40。
区间收到 39..=40 仍能抓住 ±2px 回归(42→41、38→37 都落区间外)。
2026-11-02 04:50:55 +08:00
AskaEth 5fd1f3ca26 fix(app-iced): M75 r3 —— 禁用内容角色按 A1 改 OnSurfaceVariant + 两项语汇 + probe 双色护栏
A1 定案(本机 gradle 缓存 material3.aar + javap):
  FilledButtonTokens.<clinit>: DisabledContainerColor=OnSurface, DisabledContainerOpacity=0.1f
    DisabledIconColor/DisabledLabelTextColor=OnSurfaceVariant, 两者 Opacity=0.38f
  OutlinedButtonTokens/TextButtonTokens: LabelTextColor=OnSurfaceVariant(12b §1.9 原本就对)

据此:
- interaction::disabled_content_over / disabled_colors 的内容角色从 OnSurface 改成
  OnSurfaceVariant(α 仍取令牌 DISABLED_CONTENT_ALPHA=0.38);设计层 color::state::disabled_content()
  用的是 OnSurface,与 A1 不符 —— 令牌线修好前这里按 A1 直接求值,已登记。
- content_color 的「底」按有无容器分开:有容器叠 12% 容器、无容器叠 surface(视觉 r2 P2-1)。
- probe 加第二条护栏:无容器三式的 38% 墨迹单独计数,避免只护住有容器那四式(code r2 p2-1)。
- gallery 样例图标改描边(Download + ChevronRight),并加单测钉「同控件不混用 Fill/Stroke」(P2-3)。
- 滚动条:透明轨 + OutlineVariant thumb(P2-4;宽度 10px 是 iced 内部常量,只能改颜色)。
- probe 文档修好被 //! 打断的命令、shot_path 注释归位、去掉空转的重复断言(P2-5)。
2026-11-02 04:50:54 +08:00
AskaEth d075feb77d test(app-iced): M75 复审 P1-B —— 渲层断言收紧到 2px 级(变异实测会红)
- 高度断言改 assert_eq!(true_height, 40):变异成 42 实测变红(证据 target/m75/evidence.sh)
- 补「带图标左内边距 16」断言:墨迹 ≈21(16→21 / 24→29 / 8→13 三档互不重叠)
- 分隔线:行数 2..=4 + 单条墨量覆盖率 0.8..=1.3(1.5dp 会到 ~1.5),
  只统计「连续非底色 > 900」的长横线行,避免把文字墨量算进去
- 两条判据都踩过误报:白色与浅色页面底色在容差内(padding 量出 3)、
  圆角透出的底色被当成满档 OnPrimary;已改成「内带 + nearer 判定」并注明原因
2026-11-02 04:50:52 +08:00
AskaEth e905fa3922 fix(app-iced): M75 复审 P1-A/P1-B/P2 —— 内容色跟态走 + 断言收紧 + 矩阵排版
P1-A 禁用内容色没下发(视觉 reviewer:text/standard 的 enabled 与 disabled 逐像素相同):
  assemble 里内容色恒取 ui.color(content) 再被 text().color() 钉死,Style.text_color 的 38%
  永远下发不出去。新增 assemble::content_color(ui, forced, content),文字按钮与 IconButton
  共用;probe 补两条像素断言(禁用 38% 墨迹必须存在、禁用容器内不得有满档 OnPrimary)。

P1-B 渲染层断言抓不住 2px 级回归(reviewer 变异验证:高度 42 仍绿):
  高度改成精确 == 40;补「带图标左内边距 16(墨迹 ≈21)」断言;分隔线从「行数」改成
  「墨量覆盖率 ≈1.0」(1.5dp 会到 ≈1.5)。

P2-1 probe 给了图却读不到时硬失败(只有完全没给 ULL_GALLERY_SHOT 才跳过)。
P2-2 Geometry::stroke 真正接到 Border.width;IconGeometry::icon 改由 side−2×padding 推导。
P2-3 矩阵 cell 宽 192→160(总宽 1040<1180,disabled 列不再被裁);禁用格保留前后图标。
P2-4 补注:snap 仅 wgpu 实现,CPU 验收图不受它影响。
2026-11-02 04:50:51 +08:00
AskaEth 8072f1cc7e test(app-iced): M75 —— probe 分隔线判据改为「非底色长横线」+ 修 forced 改成 Option 后的两处测试
线在 fractional y 上被抗锯齿摊到两行,按精确 OutlineVariant 色匹配抓不到(实测 0 行);
改为「非 Surface 的长横线」计数,断言 2..=6 行(两条线 × 各 1~2 行)。
2026-11-02 04:50:49 +08:00
AskaEth 5f85891e7e fix(app-iced): M75 —— 截图取证必须能强制「静息」态(光标污染修掉)
StyleSpec.forced 从 Interaction 改成 Option<Interaction>:Some(Enabled) 与 None 语义不同——
前者强制画静息,后者才跟随 button::Status。修前光标恰好停在某个格子上时,gallery 的
enabled 列会被画成 hover(表现为 text 行出现不该有的灰底容器,看着像 Text 按钮自带容器)。
2026-11-02 04:50:47 +08:00
AskaEth 5861881073 refactor(app-iced): M75 —— probe 拆出 scan 工具 + clippy 全绿 + 三个字面/生命周期修正
- gallery/{probe,scan}.rs 拆分:像素扫描(连通块/外接框/顶行宽度)与判据分离,
  后续四条家族线可以自带 probe 复用扫描逻辑
- probe 的实心块判据跳过字形割出的小碎片(area < 800),阈值写明原因
- app.rs / gallery::mod 的 clippy 修正(needless_lifetime / field_reassign_with_default / collapsible_if)
- cargo test -p unlucky-app-iced 与 cargo clippy --all-targets -D warnings 已绿(见报告)
2026-11-02 04:50:46 +08:00
AskaEth 4774e7b788 WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness
已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)

待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
2026-11-02 04:50:44 +08:00
AskaEth 22ddc74784 Merge branch 'feat/p4-iced-page-frame-layout-system-breakpo' 2026-11-02 04:44:53 +08:00
AskaEth 831bd00620 docs(app-iced): SIM 常量口径精确化(字符最多行 402 / 全段最宽行 411)
视觉 r3 的不计入裁决的精度提醒:`SIM_SAFE_MAX_INK = 402` 其实是**字符最多**那一行的 ink,
全段**墨迹最宽**的行是 411px(渲染 410),所以「余量 22px」实际是 13px。

- 拆成两个常量并分别断言:`SIM_SAFE_MAX_CHARS_INK = 402`(67 字符那行)、
  `SIM_SAFE_WIDEST_INK = 411`(墨迹最宽那行,新加断言 `cap >= 411`,并在失败信息里打印余量);
- 文档写明「两者不是同一行:402 那行字符多但字形窄」。

证据:`cargo test -p unlucky-app-iced --no-default-features`
→ `test result: ok. 86 passed; 0 failed` / `test result: ok. 6 passed; 0 failed`。
2026-11-02 04:10:05 +08:00
AskaEth 589f51f4fb docs(app-iced): 订正测试注释(1180 上限是 424,不是 435)
r3 复审:`layout/tests.rs` 的注释说「现在必须是 435」,同一函数的断言却是 424。

证据:`grep -n 435 crates/app-iced/src/pages/layout/tests.rs`
- 第 229 行(旧):`// 1180 —— M68 就是在这条上翻的车(712px),现在必须是 435` → 改成 **424**;
- 剩下两处 435 都是**平均口径上界**的正确引用,不是残留:
  第 176 行注释「不是『平均步进 × 72』的算术值 435」、
  第 180 行 `assert_eq!(measure_average_bound_px(), 435.0)`。
2026-11-02 03:52:28 +08:00
AskaEth 87d5dd8f67 docs(app-iced): 订正 meta_grid 注释(1180 与 900 都是 2 列)
r3 复审:`meta_grid` 的文档还写着「1180 → 3 列,900 → 2 列」,但本轮起两者都是 2 列。

证据:`grep -n "3 列" crates/app-iced/src/pages/instances.rs`
- 旧:`/// 元信息网格:列数由 [...] 决定(1180 → 3 列,900 → 2 列)。`
- 新:写明「1180 与 900 都是 2 列;3 列需要 3×160 + 2×12 = 504px > 行宽上限 424,数学上不可行;
  硬塞回 3 列会让 NeoForge 20.4.237(118px)折行、同排卡底不齐」。
- 现在文件里唯一出现「3 列」的地方就是这条「不可行」的说明(第 275/276 行)。
2026-11-02 03:52:24 +08:00
AskaEth 0de8e63ad6 fix(app-iced): 必修1 跟进 —— 旧断言 435→424 + clippy 常量断言
必修1 改完 cap 之后跑门禁抓到的两处自身的漏改:

1. `settings_pane_keeps_the_drawer_width_and_a_readable_form_column` 里还留着
   `assert_eq!(measure_cap_px(), 435.0)`(P2-1 轮加的锚点),cap 收到 424 后它必红。
   已改 424 —— 这正是「锚点断言」该有的行为:常量一改就红,而不是悄悄跟着漂。
2. clippy `assertions_on_constants`:`assert!(SIM_SAFE_MAX_CHARS <= MAX_MEASURE_CHARS)`
   两边都是 const,改成 `const { assert!(..) }`。

门禁(本机私有 target,CPU 档):86 lib passed + 6 集成 passed;clippy -D warnings 干净。
2026-11-02 02:42:58 +08:00
AskaEth c5c543ba43 docs(app-iced): 必修3 504→435 一轮留下的三处文档漂移
视觉 r2 逐条点出来的三处「偏离登记与对外口径没跟着改」:

1. `pages/mod.rs` 偏离 #1:详情列右缘写 976 / 分歧 180px —— 行宽收到 424 后实测是
   页头右缘 **1156**、详情列右缘 **896**(472 + 424),分歧 **260px**;
2. `pages/mod.rs` 偏离 #3:单列页占比写 48%(435/1044)—— 现在是 **40.6%(424/1044)**;
3. `frame/pieces.rs` 的 `scroll_style` 函数头仍写「滑块 `Outline`、尺寸取 iced 默认」——
   实现早已是 `OutlineVariant` + 6px 轨 + 2px 内缩,文档跟上。

三处都在 D30 要求「逐条写清」的偏离登记/口径里,属于对外承诺,不是内部注释。
2026-11-02 02:04:32 +08:00
AskaEth ddf6937b2b fix(app-iced): 必修1 「72 字符」改成逐行口径,上限 435 → 424
代码 r2 的复核:`435 / 6.0526 = 71.9` 是**整段平均步进**换来的数,不是每行都 ≤72;
它实测交付图里就有 **73 字符**行(`guess. Nothing on this page is wired to real data yet: the layout is, the`,
ink 428px)。上一轮是系数错,这一轮是**把平均当成了逐行上界**。

选 **(a)**:不变式改成逐行可验证,cap 跟着压到能满足它的地方。依据是 FreeType 量宽 + 贪心词折行的模拟表
(同一段 361 字符正文扫列宽):

| 列宽 | 最长一行 | 该行 ink | 判定 |
|---|---|---|---|
| 435(= 平均口径上界) | 73 字符 | 428px | ✗ |
| 428 | 73 字符 | 428px | ✗ |
| 426 | 71 字符 | 426px | ✓ 只剩 1 字符余量 |
| **424**(4px 栅格) | **67 字符** | **402px** | ✓ 取这个 |

- `measure_cap_px()` = `min(逐行安全宽 424, 平均口径上界 435)` = **424**;平均口径另立
  `measure_average_bound_px()`(435)并在文档里标明「只是上界,不是取值」。
- 单测 `line_length_is_capped_at_72_characters` 改成**两侧夹逼**:`402 ≤ cap < 428` ——
  放大到 428 会重新出现 73 字符行(红),压到 402 以下白丢宽度(也红);另断言
  `cap < measure_average_bound_px()`,防止有人把平均口径当上限。
- §3 新增「逐行口径」小节:模拟表 + **作用域**(承诺只覆盖 14px 正文;12px 辅助文案是 1~2 行短句,
  不做逐行承诺;CJK 全角 1em 另行定)。
- 四页文档与 `pages/mod.rs` 的 ≤435 同步改 424。
2026-11-02 02:04:23 +08:00
AskaEth a650f8184b fix(app-iced): 必修2 元信息卡最小宽改由内容推(136 → 160),1180 下网格 2 列
视觉 r2 抓到的回归:上一轮为塞进三列把 `META_CARD_MIN_WIDTH` 收到 136,只算外框、忘了
`card_padding = 20`,卡内只剩 96px —— demo 里 `NeoForge 20.4.237`(实测 118px)与
`NeoForge 21.1.72`(110px)都会按词折成两行,同排另两张卡仍一行 → **卡底不齐**。

- 最小宽改由**卡内最长内容**反推:118(Segoe UI 14px 实测)+ 2×20(令牌 `card_padding`)= 158
  → 取 4px 栅格 **160**(值上等于最初的 160,但这次是内容推出来的,不是凑上限凑出来的)。
- 于是元信息网格在 1180 宽落到 **2 列**(2×160 + 12 = 332;3 列要 504 > 行宽上限),
  卡内可排 **166px** ≥ 118,所有值单行放下;6 格 = 3 行 2 列。
- 新增单测 `meta_card_is_wide_enough_for_the_longest_value`(此前 39 条 pages 测试没有一条覆盖它):
  ① 夹具 `NeoForge 20.4.237` 必须仍在 DEMO 里(数据变了就红,提醒重新量);
  ② `META_CARD_MIN_WIDTH − 2×card_padding ≥ 118`;③ 实际两列布局下每卡内宽也 ≥ 118。
- `meta_grid_drops_columns_as_the_detail_narrows` 跟着改成「上限处 2 列」(1180 与 900 同为 2 列,
  差别落在详情列宽 435 vs 404)。
2026-11-02 02:04:04 +08:00
AskaEth dc67be529d docs(app-iced): P2-2 r2 712 是 ink 不是列宽 + 补偏离登记
代码 reviewer 把 P2-2 说得更准,我上一轮只改了数字没改算式,这里按它的复核改到底:

- **712 是那一行的墨迹宽度,不是列宽**。早期文档写「1180 − 88 − 2×24 = 712」,这个减法得 **1044**;
  M68 的卡片是 x=112..1156(1044px),卡片内文本区 **1004px**,那一行 ink 才是 712px(x=133..845)。
- 交叉验算对上了:137 字符 × 5.19px(12px × 实测 0.4323em)= **711px** ≈ ink 712。
  所以事故是「1004px 的文本区里,一行排了 137 字符」,不是「列宽 712」。
- 订正记录改成两条(① 712 是 ink ② 字符数按实测),并写明早期口径的错法。

顺带把视觉 reviewer 的「请登记」落地:`pages/mod.rs` 新增**偏离登记表** 6 条
(页头右缘与详情右缘 180px 分歧、空态标题比页标题大、单列页只用 48% 内容宽、空态字形 40、
返回入口无箭头、实例空详情态未出图),每条写了「为什么」与「什么时候收口」。
2026-11-02 01:05:27 +08:00
AskaEth c495fccd6c fix(app-iced): 视觉 P2-2/3 滚动条变细变淡 + 图标语汇统一成描边
视觉复审两条:

- **P2-2 滚动条偏重**:原来直接用 iced 默认 10px 轨 + `Role::Outline`(那份灰绿在浅色下压过列表内容)。
  改 **6px 轨 + 2px 外边距 + `Role::OutlineVariant` 滑块**(比 `Outline` 弱一档的容器描边角色),
  圆角由 full 按 6px 折算成 3。功能不变(仍是列表列独立滚动、仍只有这一条 scrollable)。
- **P2-3 图标线宽语汇不一致**:设置页「Java」分组用的是令牌里的 `Play`——它是**填充**字形,
  同屏其它图标都是描边。改成描边的 `IconKind::Folder`(Java 分组讲的是运行时安装位置,语义也对),
  并在 `pieces.rs` 写明规矩:页面骨架**只用描边图标**,将来若要「主操作允许实心图标」,
  由 `components/` 统一定义并只留一处。守门见上一条 commit 的 `pages_use_only_stroked_icons`。
2026-11-02 00:39:58 +08:00
AskaEth a0ccf79b2b test(app-iced): P2-3 尺寸硬编码守门扩到 pages/(并补图标语汇守门)
代码 reviewer 指出:`no_hardcoded_tokens` 的**尺寸**守门只扫 `src/shell`,`pages/` 是新增目录、
**根本没被扫到**,页面里写 `.width(24)` 没人会发现(颜色守门扫全 `src/**`,所以颜色那条是覆盖的)。

- `shell_takes_every_size_from_tokens` → `layout_sources_take_every_size_from_tokens`:
  扫描面改成 `src/shell` + `src/pages`,并把「至少 2 个文件」的哨兵断言提到 **≥10 个**(防扫描逻辑坏掉)。
- 实测结果:**扩了之后 `pages/` 是干净的(0 处)**——所有尺寸都走 `layout::*` 常量 / `LayoutMetrics` /
  `Space` / `TypeStyle`,没有需要修的违规。
- 顺带补 `pages_use_only_stroked_icons`:`pages/` 里不许出现令牌层的填充字形(`Play`/`Stop`/`More`),
  这是视觉 P2-3「图标语汇不一致」的机器化守门(修法见下一条 commit)。
- 文件顶到 403 行 → 压到 399(仓库硬限 400)。
2026-11-02 00:39:48 +08:00
AskaEth 367100848a docs(app-iced): P2-2 M68 事故口径订正(实测 137 字符,不再引用估算值)
代码 reviewer 指出:712px ≈ 「118 字符」的那个数本身不是实测,是拿 0.5em 估的步进换算的,
和 P2-1 是同一个根因(高估了步进)。按实测重写事故描述:

- M68 那张卡里的正文是 `BodySmall` **12px**,步进 = 14px 实测值 6.0526 × 12/14 = **5.19px/字符**,
  所以 712px **实测 137 字符**(不是 118);
- 补一条**订正记录**说明早期数错在哪:既高估了步进、也对错了字号,两条叠加把事故说轻了 16%;
- `pages/mod.rs` 的历史判词同步改成 712px + 137 字符;
- 全 `pages/` 已无「118 字符」这个数(`grep` 为空)。
2026-11-02 00:13:42 +08:00
AskaEth fae1b61d9e fix(app-iced): P2-1 「72 字符」改成实测口径(上限 504 → 435)
两位 reviewer 都量到:`CHAR_ADVANCE_EM = 0.5` 是估的,14 × 0.5 × 72 = 504px 实际能放 83 字符,
「72 字符」只活在名字里。改成实测:

- 口径:`BodyMedium` 14px / Segoe UI Regular / 无 letter-spacing(iced 0.14 不支持字距),
  样本 = 实例页详情那段正文(361 字符,大小写/标点/空格/`→` 混排),FreeType getlength
  实测 **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**(旧 0.5 高估 15.7%);
  交叉验证:同段首行 78 字符渲染墨迹 484px,FreeType 对同一串 479px,差 1%(抗锯齿外扩)。
- `CHAR_ADVANCE_EM` 0.5 → **0.43233**;`measure_cap_px()` 加 **floor** → **435**
  (435.79 四舍五入成 436 就会让最后一两个字符挤不进 72 字符的承诺);下限 315 → 272。
- 版面连带重算:`META_CARD_MIN_WIDTH` 160 → **136**(3×136 + 2×12 = 432 ≤ 435,否则 1180 宽下
  元信息网格会从 3 列掉到 2 列)。**816 / 720 两个两列阈值不受影响**(它们由 LIST/DETAIL/FORM 宽度算,
  与行宽上限无关),单测里的绝对值断言原样成立。
- 单测:`line_length_is_capped_at_72_characters` 里新增 tower 要求的断言 —— 用**实测步进**
  (样本字符数 + 实测宽度,独立于常量)反算「上限列宽能放多少字符」≤ 72(实际 71.87),
  并加一条回归护栏:同一实测步进下旧口径 504px = 83.3 字符,改回 0.5em 立刻红。
- 文档:`layout.rs` §3 补「怎么重新量」(字体、样本、工具、交叉验证、CJK 不适用)与全部数字;
  `mod.rs` / 四页文档里的 ≤504 同步改 435。
2026-11-02 00:13:20 +08:00
AskaEth 2c442585b1 feat(app-iced): M84 页面骨架与布局系统(断点 / 行宽上限 / 三种版面)
视觉复审在 M68 的判词是不能把「全宽单卡」当页面模板(1180 宽下正文单行 712px ≈ 118 字符)。
本 mission 把它落成一套**布局系统**,四个页面只是它的用例:

- `pages/layout.rs`:官方 600 / 840 两个档位 → 三种版面(单列 / supporting-pane / list-detail),
  阈值由列宽和算出(设置 240+24+320 → 720;实例 336+24+320 → 816 ≤ 840),行宽硬上限
  14 × 0.5em × 72 = **504px**,全部是纯函数,`pages/layout/tests.rs` 用绝对值和边界表钉死。
- `pages/frame.rs`:页头(56 / 两行 84)+ 内容列(≤504)+ 两列骨架 + 空态位;
  `pages/frame/pieces.rs` 是占位控件(卡片 / 分隔线 / 文本按钮 / 图标),令牌驱动、无完整状态机,
  待 M75 的 components 家族合入后替换。
- 四页:实例(list-detail,列表列 336 独立滚动,窄窗退化成整页切换)、
  设置(supporting-pane,240 分组导航 + ≤504 表单列)、下载 / 日志(单列,两行页头)。
- `pages/harness.rs` + `examples/pages_demo.rs`:自带 `--screen/--width/--screenshot-theme`,
  不碰 `cli.rs` / `app.rs`(M75 在改)。

偏离与说明:
- 布局用「页边距 16/24」而非 16/24/32,官方 expanded 也是 24(少一条偏离登记);
- 实例空详情态一屏会有 5 档字号(22/16/24/12 + 14 的操作按钮),未截图的状态,已知并登记;
- `lib.rs` 只加一行 `pub mod pages;`。
2026-11-01 23:27:28 +08:00
AskaEth 352dd7937e Merge branch 'feat/p4-core-platform-wallpaper-source-detect' 2026-11-01 21:18:02 +08:00
AskaEth 69d0025d09 fix(core): M73 r1 —— Windows 壁纸来源改为「当前主题优先」,通配主题包降为末档
复审 p2-1(medium):Win10+ 上 `%APPDATA%\Microsoft\Windows\Themes\` 通常只有
`TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,
指向 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。

- 新增第二步 `windows-current-theme`:`reg.exe query ... /v CurrentTheme` →
  归一化(含 `%LOCALAPPDATA%` 展开)→ 读该 `.theme` 的 `Wallpaper=`;
- `%APPDATA%\...\Themes\*.theme` 通配扫描从「第二步」降为第四步兜底,
  evidence 如实标注「用户另存的主题包,可能未启用」;
- 新顺序:TranscodedWallpaper → CurrentTheme → 注册表 WallPaper → 另存主题包;
- `HostRoots` 增加 `local_app_data`(`%LOCALAPPDATA%`,退 `~/AppData/Local`)。

复审 p2-2(low):`transcoded_wallpaper_hit_comes_first` 补一条字面量路径断言,
防止夹具与生产代码用同一构造函数一起拼错。

复审 p2-3(low):`HostIo::run` 现在检查退出码(`!output.status.success()` 也返回
`None`),与四处「非零退出 → 失败」的文案对齐;`io.rs` 补一条夹具用例
(有 stdout 但退出码非零)证明旧行为会被挡掉。

行长:`windows.rs` 371 → 拆出纯解析到 `wallpaper/windows/parse.rs`(100 行),
全部 7 个文件 ≤400 行。真机复跑 `detect()`:第 1 条 transcoded、第 2 条
`windows-current-theme` → `C:\Windows\web\wallpaper\Windows\img0.jpg`(与注册表同源,
去重后只剩一条),`%APPDATA%` 下无主题包的 note 照旧。
2026-11-01 21:17:55 +08:00
AskaEth d3e9a62b21 feat(core): M73 桌面壁纸路径探测(D38 第②级输入)
新增 platform::wallpaper:只做「桌面壁纸可能是哪个文件」的路径探测,
不解码图片、不取色(莫奈取色与三级降级留给 crates/design 的后续 mission)。

- 唯一入口 detect() / 注入式 detect_with(io, env, platform):返回有序候选列表
  + 各步失败原因,全部找不到时是空列表而不是错误;
- Windows 三步:TranscodedWallpaper(无扩展名,靠魔数认)→ *.theme 的
  Wallpaper= → reg.exe query HKCU\Control Panel\Desktop /v WallPaper;
- Linux 四步:gsettings picture-uri(-dark) → KDE Image= → hyprpaper/sway
  配置 → XFCE xfce4-desktop.xml,每步失败不影响下一步;
- macOS 两步:com.apple.wallpaper/Store/Index.plist 字符串扫描(不引 plist
  依赖,兼容 bplist00)→ defaults read com.apple.desktop Background;
  代码与文档明确标注「未在真机验证」;
- 零 unsafe / 零 FFI(core 是 #![forbid(unsafe_code)]),文件读取与子进程
  全部经 ProbeIo 注入,环境变量表经 EnvSource 注入;三平台采集逻辑不带 cfg,
  CI 的 Linux 上也能跑 Windows / macOS 夹具用例;
- 不新增第三方依赖;6 个文件均 ≤400 行,32 条单测覆盖三平台的
  正常命中 / 多候选 / 全部失败。
2026-11-01 21:17:52 +08:00
AskaEth 614bd253c8 Merge branch 'feat/docs-remove-affiliation-disclaimer-foote' 2026-11-01 20:26:14 +08:00
AskaEth 9e30d2cf15 docs(readme): 删掉文末「与 Mojang / Microsoft 无关联」免责脚注
中英两份都删,不补替代句;文件以感谢段落 + 单个换行结束。
2026-11-01 20:24:14 +08:00
AskaEth 6cfac9c823 Merge branch 'feat/docs-tone-pass-remove-defensive-wording' 2026-11-01 20:02:10 +08:00
AskaEth 4069204379 docs(readme): 复审收尾两处——ull 行补回分发状态,平台独立性改正面陈述
- bin 表 `ull` 行:State 列补回「未随 v0.1.0 资产分发」/ "not in the v0.1.0 assets",
  只看表格的人也读得到分发状态
- 平台基线末条:去掉「而非独立产物链」这半句对没人提的质疑的反驳,
  改为「每个平台跑各自的测试矩阵」/ "Each platform runs its own test matrix"
2026-11-01 20:00:41 +08:00
AskaEth 0cb1454067 docs(readme): 致谢改为平实感谢,去掉「欠人情」的债务叙事
原段落把作者写成亏欠方(「这个项目欠它们一份很大的人情」/ "exists very much in
their debt"),并把 HMCL / PCL2 称作 "old-timers"。两者都是 GPL-3.0 开源项目,
阅读与借鉴本就在许可之内,不需要愧疚来偿付。

- 债务词全部移除:欠 / 人情 / debt / owe / old-timers / enormous amount
- 保留感谢本身,改成「它们做得久 + 本项目从中受益」两句话
- 中文补一次断行以保持 ≤100 显示列
2026-11-01 19:55:55 +08:00
AskaEth aca280fd30 docs(readme): 删掉「合规声明」整节,账户信息归位到特性表
这是项目的 README,不是审核材料:把单独开列的合规节去掉,信息放回它该在的位置。

- 删除整节 `## Compliance statement` / `## 合规声明`(含 5 条 bullet)
- 「Microsoft 账户登录 + OAuth device code → XBL → XSTS → Minecraft Services + 令牌只存本机」
  并入特性表新增的「账户 / Accounts」一条(功能说明,不是专项保证)
- 「不分发游戏文件」「联机需账户与权益」两条删除(常识范围 / 与前一句重复)
- 「与 Mojang、Microsoft 无关联」降为文末一行普通脚注
- 平台基线:标准工具链那条去掉三重「无」,改为平铺陈述
- 平台基线:删掉与「构建 → 渲染开关」重复的渲染兜底条目(第三处),
  文件内不再出现「完整界面」/ "the full UI"
- bin 表:`ull` 状态列去掉与 Track A bullet 重复的「未随资产分发」,保留「同源同版本构建」
- UI 一条:「驱动真实引擎」→「由 core 引擎驱动」/ "drive the real engine" → "run on the core engine"
2026-11-01 19:54:50 +08:00
AskaEth 5a44870de5 docs(readme): 语气体检——去掉「正版登录」及同类防御腔与自我声明
- zh 模块表 auth 行:「Microsoft 正版登录」→「Microsoft 账户登录」,与英文 "Microsoft sign-in" 对齐
- zh/en:CLI 行去掉「完整可用」/ "is fully usable"(要读者相信的形容词)
- zh/en:CPU 软渲染兜底不再承诺「完整界面」/ "the full UI",改为陈述渲染结果
- zh/en:轨道 B 首句去掉「没有任何…任何下载都拿不到它」/ "Nothing…no download contains it" 的双重否认,
  并与紧邻的「只能自己从源码编」合并为一句
- zh:轨道 A 小标题去掉「真能」的强调语气
2026-11-01 19:51:46 +08:00
AskaEth a426cb5dcb Merge branch 'feat/docs-refresh-readme-for-iced-rewrite-and' 2026-11-01 19:49:27 +08:00
AskaEth 9d9ab3e7e0 docs(readme): 合规段第一条去掉防御腔 —— 平静陈述「用 Microsoft 账户登录」
- 英文:Microsoft sign-in only → Sign-in with your Microsoft account;删掉 There is no other
  account provider(过度否认本身是信号)
- 中文:只走 Microsoft 正版登录 → 用 Microsoft 账户登录;删掉「没有别家账户通道」,
  并避开「正版」这个词(中文圈里该词天然与「破解」对照,带防御姿态)
- 事实不变:device code → XBL → XSTS → api.minecraftservices.com,用玩家自己的账户与权益
- 第三条「联机游玩需要 Microsoft 账户与有效权益」保留独立一条(事实陈述,非否认)
2026-11-01 19:49:08 +08:00
AskaEth d0d24d42e7 docs(readme): 按用户令删除全部离线档案描述;门禁口径改为 main 推送 + PR
- 合规条里「Offline profiles cannot play online / 本地离线档案仅限本地与隔离网络测试」整条删除,
  只保留正面陈述「联机游玩需要 Microsoft 账户与有效的游戏权益」
- 模块地图 auth 行去掉「local offline profiles / 本地离线档案」,只留 Microsoft 正版登录全链路
- 全文 grep offline|离线|隔离网络 零命中;不做「不支持离线」的反向承诺,也不暗示别的账户通道
- 顺手修 nit:CI 段「Every push」→「Every push to main and every pull request」
  (ci.yml on.push 限 main 分支,非 main 且无 PR 时门禁不跑)
2026-11-01 19:47:40 +08:00
AskaEth a4bd4d2b38 docs(readme): r1 复审修复 —— 去掉 ull 随包分发的假话、macOS 探针降级为 advisory、离线档案正面陈述、合规段提到第一屏
- P1: ull 与 v0.1.0 同源同版本但发布资产只有 GUI 单文件(make_artifacts.py 只搬 launcher、
  release.yml assets 只 glob unlucky-launcher-*)→ 4 处改为「自行 cargo build --release --bin ull」
- P2-1: macOS 运行期探针 if workflow_dispatch + continue-on-error,属 advisory → 与 Linux 冒烟拆句
- P2-2: 离线档案去掉「不能连校验账户的服务器」这种可反读的限定,标题改「联机游玩必须使用 Microsoft 账户」
- P2-3: 合规声明挪到引言之后、特性 bullet 之前(第一屏可见),文字不变
- nit: 三份合规文本每个 Release 都挂(Windows 因 exe 装不下才并列);Slint 版无显式探测,改为两套栈
  各自回退到 CPU 的准确表述
2026-11-01 19:44:07 +08:00
AskaEth 2a0493c3eb docs(readme): 两份 README 改写为 iced/M3 Expressive 现状(双 bin 并存 + 两轨 Status)
- UI 描述:已发布 v0.1.0 = Slint + Metro;下一代 = iced 0.14 + Material 3 Expressive,dev 上施工中
- 双 bin 并存逐条讲清:unlucky-launcher(Slint,v0.1.0 已发布)/ unlucky-launcher-iced(施工中)
  / ull(CLI),并说明翻牌前不改名的原因(同 target 目录不能同名 bin)
- 模块地图补 crates/design(真 HCT 令牌、零 UI 依赖)与 crates/app-iced;crates/app 保留到等价验收
- Status 重写为两轨:Track A 已发布(含 v0.1.0 资产范围与 CLI 命令集,取自 release.yml 与 cli/help.rs)
  / Track B 施工中(已完成=令牌+骨架;未完成=组件库/页面/i18n,页面全是占位)
- 删除全部过期统计数字(983+ tests 等),改为行为性门禁表述;无测试数、无产物体积
- 合规段落只留 Microsoft 正版登录 / 凭据只存本机且 AI 不可访 / 离线不能联机 / 不分发不镜像不代理
  / 与 Mojang-Microsoft 无关联;无第三方账户服务字样
- 许可:Slint RF 2.0 归属保留并补 iced(MIT)、MCU(Apache-2.0)、其余主要依赖
- 致谢 HMCL / PCL2 改为口语化语气,不加粗、不撇清
- 开头保留 GitHub 官方主页行(英文自指、中文醒目指引 + Releases 链接),全文无 gitea 地址
- 平台基线补 macOS 未签名需清 Gatekeeper 隔离标记
2026-11-01 18:53:26 +08:00
AskaEth cf52751124 Merge branch 'feat/p4-design-cool-sky-blue-default-seed-can' 2026-11-01 18:13:28 +08:00
AskaEth 40864d93d0 fix(design): M70 r4 —— 偏离登记补齐「neutral-variant 8→14」+ 文档口径与 pin 可读性
复审 P2-1:neutral-variant 的那一半偏离漏登记,且文档把 nv 说成官方相对式。
- `color/mod.rs`:标题改「两处有意偏离(同一处偏离的两半)」;偏离登记把
  neutral 6→12 与 neutral-variant 8→14 分列,写明官方 `getNeutralVariantPalette`
  是**绝对值 8**(不是 neutral+2)、影响角色含 outline/outline-variant/surface-variant,
  以及「nv 钉回 8 会让表面(12)比中性变体(8)更有色」的倒挂理由
- `palettes.rs`:`NEUTRAL_VARIANT_OFFSET` 文档改述为「官方绝对值 8,本实现相对式=有意偏离」;
  `DEFAULT_NEUTRAL_CHROMA` 的矛盾区间改正为「数值安全区间 6..=13(14 才撞 secondary),产品取 12」;
  `from_seed_with_neutral_chroma` 加调用方约束(越界静默退化:<0/NaN → 彩度 0;≥14 → nv 撞 secondary);
  新增 `product_default_neutral_chroma_is_a_registered_deviation`(钉 neutral 12 / nv 14,官方锚点仍 6/8)
- nit1:`sky_seed_candidates_meet_wcag_aa` 的「三档」→「四档(208 项)」
- nit2:`product_default_is_the_adopted_candidate` 改成比 hex 文案,失败时可直读「期望 #3C8DBC、实测 #87CEEB(③)」
- nit4:`lib.rs` 的 `DesignTokens::baseline` 文档改准(产品默认 ④ 天蓝,官方口径走 `Scheme::official_baseline`)

无物理行为变更:算法、tone 表、gamut、所有色值一个没动。
2026-11-01 18:09:14 +08:00
AskaEth e37b452345 feat(design): M70 r2 —— 出厂默认定为 ④ 天蓝 #3C8DBC(用户 2026-11-01 拍板)
- `palettes::DEFAULT_SEED`:`#87CEEB`(③ 浅冷蓝)→ `#3C8DBC`(④ 天蓝,主色 `#236488`);
  `DEFAULT_NEUTRAL_CHROMA` 保持 12
- `tests/skyseed_candidates.rs`:`product_default_is_the_recommended_candidate` →
  `product_default_is_the_adopted_candidate`,绑定 `CANDIDATES[3]`(④);四档 pin 不变
- 文档:`color/mod.rs` / `palettes.rs` 的「临时默认 ③ / 待用户确认」全部换成
  「出厂默认 ④(用户 2026-11-01 拍板)」,③ 标注为备选(值仍 pin 住,可一行换回)
- 文档新增定位(D38):这两个常量只定义「**莫奈关闭时的出厂预选色卡**」(配色来源第三级),
  不再代表唯一配色来源;背景图/壁纸取色与内置色卡库都不在本 crate、尚未实现
2026-11-01 18:09:14 +08:00
AskaEth 9519a89472 feat(design): M70 r1 —— 补第 4 档「天蓝」候选 #3C8DBC(四档对比)
③ 的 primary `#0C6780` 偏青(teal),不是「天蓝」。新增 ④ 天蓝:种子 `#3C8DBC`
(CAM16 hue 239.5°,夹 ③ 与 ①② 之间)+ 中性彩度 12 → primary `#236488`。

- `tests/skyseed_candidates.rs`:候选表 3 → 4 档,④ 的 9 角色 × light/dark 全部进 pin;
  `the_three_tiers_are_actually_distinguishable` 改为 `the_candidates_are_actually_distinguishable`,
  增加「③ 与 ④ 中性彩度相同、主色/容器必须靠色相区分」的断言
- `tests/contrast_aa.rs`:候选 WCAG 门禁 3 → 4 档(26 × 4 × 2 = 208 项)
- 默认值**不动**:仍为 ③ `#87CEEB`(临时默认,待用户看完四档再拍);
  `product_default_is_the_recommended_candidate` 保持原样,专门在默认与推荐不一致时报警
- 文档:`color/mod.rs` 与 `palettes.rs` 的候选清单补上 ④ 与「改一行即可切 ④」的说明
2026-11-01 18:09:13 +08:00
AskaEth 79404fa9d1 feat(design): M70 默认色候选 —— 浅冷天蓝落成两行常量 + 三档候选色卡
默认色从官方紫 `#6750A4` 换成「浅冷天蓝」,并把「换默认只改哪一行」做成常量:
- `palettes::DEFAULT_SEED` = `#87CEEB`(CSS skyblue)+ `palettes::DEFAULT_NEUTRAL_CHROMA` = 12
- `Palettes::baseline()` / `Scheme::baseline()` = 产品默认(消费方拿到的就是它)
- 官方口径锚点独立保留:`Palettes::official_baseline()` / `Scheme::official_baseline()`
  (种子 `#6750A4` + 中性 6),官方对齐测试改指向它,98 项仍每通道误差 0
- 新增 `Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma)`:中性彩度从写死
  常数变成入参(neutral-variant 按官方 +2 跟随),算法、tone 表、gamut 映射一行未动

有意偏离登记(已写进 color 模块文档):中性彩度 6 → 12 是对官方 Tonal Spot 的偏离,
理由=产品要冷调表面;primary 的 tone 被官方钉在 40,种子再浅也变不成浅蓝,
「浅」只能落在中性色上。

三档候选(同一条 HCT 管道,只差种子与中性彩度)逐个 pin 在 tests/skyseed_candidates.rs:
① 保守蓝 `#2196F3`/6、② 冷调蓝 `#2196F3`/12、③ 浅冷蓝 `#87CEEB`/12(临时默认,待用户确认)。
WCAG AA 扩到候选档:26 组 × 3 档 × 2 模式 = 156 项全绿,最紧 4.99:1。
2026-11-01 18:09:12 +08:00
AskaEth c20c742b60 Merge branch 'feat/p4-iced-app-skeleton-renderer-fonts-shel' 2026-11-01 17:58:46 +08:00
AskaEth a1adf024ba fix(app-iced): M68 r1 —— M69 迁移 + 守门补洞 + 药丸 56×32 + caption 图标 24 + 东侧热区回 1180
代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。

M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX

P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
  视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill

视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
  (只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)

视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
  只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中

视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
  现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)

P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)

顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)

门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
2026-11-01 17:46:55 +08:00
AskaEth 14da7b3dec refactor(app-iced): M68 —— 把 nav 轨宽收敛成一个 M69 迁移点(三处调用 → 一行改动)
M69(141e9b1)把 LayoutMetrics::nav_expanded_width 改名 nav_rail_width、删掉
nav_collapsed_width、新增 nav_drawer_width=240。本分支基线 0aac307 还没有 M69 的
crates/design(那个目录也不在本 mission 的改动范围),现在直接改名会编不过。

所以把 shell/nav.rs 的三处 metrics.nav_expanded_width 收敛到 nav_rail_width(metrics),
并在函数文档里写清 M69 前后对照与「合入后只改函数体一行」;theme::STROKE_PX 同样
注明可换成 unlucky_design::stroke::HAIRLINE。行为/视觉零变化(值都是 88 / 1.0)。
2026-11-01 17:11:25 +08:00
AskaEth df4ff373b7 feat(app-iced): M68 —— iced 0.14 骨架(后端探测/字体基座/自绘标题栏/导航轨/深浅主题/截图通道)
新增 crates/app-iced(package `unlucky-app-iced`,bin `unlucky-launcher-iced`),与 Slint 版并存,
为 D26「UI 从头写」打地基。本 mission 只做骨架:能开窗、颜色/字体对、层级在,不做业务页面。

- 后端(D27):启动探测 wgpu 适配器(只看 device_type,避开 WARP 误判)→ 写 ICED_BACKEND;
  auto/gpu/cpu 三条路径都保留 tiny-skia 兜底,绝不硬失败;`--renderer=cpu` 跳过探测(省 ~0.85s)
- 字体(D28 前半):令牌三平台字体栈 → 用 iced 全局 FontSystem 查装机情况 → 族名与 400/500/700
  字重落点(缺档按 Nearest/Synthetic 记录)打一行启动日志
- 令牌适配层 theme::Ui:唯一把 unlucky-design 的值翻成 iced Color/Font/Border/Radius/Theme 的地方;
  49 个 M3 角色留在自己的 Scheme 里,iced 的 6 色 Palette 只给 devtools
- 骨架:1180×760 无边框窗口、48 高自绘标题栏(drag + 东/南 drag_resize + 最小化/最大化/关闭)、
  88 宽导航轨(56×32 SecondaryContainer 药丸,由令牌推导)、页头 56 + 一张 OutlinedCard
- 主题:系统主题探测 + --theme,切主题只重建 Ui(Theme::name 变化触发重算),不动 UI 状态
- 截图通道:`--screenshot <png>` + `--screenshot-theme`(真开窗第 3 帧出图),以及
  `--diff <a> <b>` 自带逐像素对比(CI 不依赖 Python/ImageMagick)
- 图标:令牌 SVG path → iced svg 控件(颜色走 alpha 蒙版,深浅共用一份位图缓存)
- 门禁:tests/no_hardcoded_tokens.rs 扫源码挡色值/尺寸字面量 + 挡 slint 依赖(自检也算过)

实测(Windows Server 2025 / 96 DPI / RX 6600 XT):浅/深 × GPU/CPU 四张真开窗截图;GPU vs CPU
差异 0.72% 像素、全部落在字形/图标抗锯齿与 1px 圆角描边、最佳对齐 (0,0)、平坦区颜色逐字节相同;
release 体积双后端 6.44 MiB / 仅 CPU 3.75 MiB;首帧 GPU 1.18~1.31s、CPU 33~37ms。

未做/未验证:macOS 与 Wayland 全链路、西/北边缩放热区、letter spacing(iced 0.14 无此属性)、
中文 CDN 字体、玻璃;翻牌步骤写在 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md(不入库)。
2026-11-01 17:11:24 +08:00
AskaEth 3fcdc3d3e1 Merge branch 'feat/p4-design-token-extensions-component-met' 2026-11-01 17:10:04 +08:00
AskaEth 16bdad1b98 fix(design): M69 r1 —— SEARCH_BAR 内边距语义收口 + ComponentRadius 取值口上闸
P2-1(语义缝隙):`SEARCH_BAR` 的 pad_start / pad_end 原为 16,与
icon_offset = 4 按字面相加得 20,比官方图标可视边 16 多 4px(越过 rubric
±2px 容差);而被加数的 12 不在 CM 里,消费方只能自己写 12.0 —— 正是 G10
要消灭的字面量。改为 12(文本框内容内边距,Space::X3)+ 4,两侧同式:
图标可视边 = pad + icon_offset = 16。字段文档写明求和公式;测试钉死
pad + offset == 16 且 pad != 16(防有人把 16 写回 pad 再叠加)。
顺带给 SEARCH_BAR.gap 补 [估] 出处(官方无图标↔文字 token)。

P2-2(绕过路径):ComponentRadius::dp() 收成 pub(crate)。它不看尺寸、
无条件返回 2.0,公开等于把 D32 的尺寸闸交给自觉。对外唯一取值口是
radius_px(size) -> Option<f32>;ALL 只暴露变体名、不暴露数值。
tokext_pins 原用 dp(),改为紧邻的 radius_px(20.0) == Some(2.0),覆盖不丢。

顺手:修掉本轮新引入的 rustdoc warning(component_map 文档链到私有模块
metrics,改指 ComponentMetrics);Divider 行标注 motion/duration 为占位,
并在 ComponentSpec 的 text / motion 字段文档写明「无文字 / 无动效的组件
这里是占位值,消费方不得据此排版 / 排动效」。

门禁:test 165 全绿、fmt --check、clippy -D warnings、check --workspace
--all-targets、doc(warning 2 → 1,只剩既有的 typography→emphasized 一条)。
2026-11-01 17:05:19 +08:00
AskaEth 141e9b12c7 feat(design): M69 令牌补齐 —— 圆角分层 / 组件微度量 / 骨架值与一像素线
G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、
MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走
radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。
ShapeScale 与 rank() 一个字未动,2 不进刻度。

G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 /
nav_drawer_width=240 / divider_thickness=1;删除语义相反的
nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 /
折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。

G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 +
indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的
骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。

G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer /
Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增
micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。

G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为
unlucky_design::stroke),divider_thickness 直接引用它。

其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021);
component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
2026-11-01 16:53:30 +08:00
AskaEth b89cc91b78 Merge branch 'feat/p4-design-exact-hct-color-scheme-hctcolo' 2026-11-01 16:40:38 +08:00
AskaEth a4d86e9b19 docs(design): M65 r1 收口 —— 修正覆盖数量口径(290 项 / 8 个种子)
复审 P2-1:两处「验证覆盖数量」还停留在扩种子之前的旧值,只改文档文本。

- NOTICE:「242 项」→「合计 290 项」,并写出分项口径
  (baseline 49 角色 × 2 模式 = 98;强调色 8 种子 × 12 角色 × 2 模式 = 192)。
  这是 Apache-2.0 归属声明,覆盖范围写错比普通注释更该修。
- crates/design/src/color/mod.rs:51:「6 个种子」→「8 个种子 × 12 个强调族角色 × 2 模式 = 192 项」。

顺带核对了 242 的来历:242 = 98 + 6×12×2 = 98 + 144,是扩到 8 个种子之前的旧值,过期而非算错。
配色相关的 HCT 彩度措辞(#E8DEF8 是 16.096 而非 11.9)此前只出现在我的完成报告里,代码/文档/测试
均未写入 11.9(已 grep 确认),故无第三处改动。

不动代码、不动测试、不动阈值。
2026-11-01 16:40:36 +08:00
AskaEth 29f65b8e6f feat(design): M65 真 HCT 色彩 —— CAM16 + ColorSpec2021 + 官方逐角色对齐
把 M61 的「OKLCh 近似 CAM16 + gamut 硬裁」换成官方的 HCT 算法,与
material-color-utilities(Apache-2.0)逐角色对上:baseline 49 角色 ×
light/dark、8 个种子 × 12 个强调族角色 × 2 模式,**实测每通道误差 0**
(判据写入测试:≤1)。

实现(逐函数对照官方 Java 重写为 Rust,f64 内部精度):
- color/cam16.rs + viewing_conditions.rs:CAM16 色相/彩度(D65、200 lux、
  背景 L* 50),默认观察条件的中间量与 Java 实测逐位对齐
- color/hct_solver.rs + critical_planes.rs:官方 gamut 映射(Newton 迭代 +
  色调平面上的色相二分,255 项关键平面表原样搬运),只压彩度不硬裁
- color/hct.rs:HCT 坐标(hue/chroma/tone),公开给工具与测试
- color/space.rs:官方 ColorUtils 的 sRGB↔线性↔XYZ↔L* 换算(矩阵、阈值、
  取整口径一致)
- color/palettes.rs:Tonal Spot 官方彩度常数 36/16/24/6/8,error 25°/84
- color/scheme.rs:tone 表换 ColorSpec2021(on-*container 10→30、
  background 99/10→98/6)

两个旧缺陷关门:
- primary-container 与 secondary-container 不再撞色(彩度 36 vs 16,
  tone 90 装得下,无需「共享基准压彩度」补丁);reference_chroma /
  tone_scaled 随之删除
- 强调色色相不再被 gamut 硬裁(只压彩度,实测偏移 <1°)

对齐证据:tests/official_baseline.rs(98 项 + 12 个 HCT 探针)、
tests/official_accent.rs(192 项)。期望值是把官方 Java 实现本机编译运行
的输出抄本,并用 Flutter 的 ColorScheme.fromSeed 用例交叉验证
(#00FF00 → #406836、#000000 → #8C4A60、#FFFFFF → #006874 一致)。

诚实口径:只做 Tonal Spot 变体 + contrastLevel 0;对比度三档、其他变体、
壁纸多色取色、非默认观察条件都在模块文档的 gap 表里登记。
注:token_pins 的 tone 值变化来自官方算法(ColorSpec2021),不是为过测试而改。

NOTICE 增「Google Material Color Utilities」归属(Apache-2.0 要求,
含逐文件映射与对齐方法)。
2026-11-01 16:40:36 +08:00
AskaEth 958ced2d7e Merge branch 'feat/p3-guards-file-size-cover-py-sh-guardsiz' 2026-11-01 16:22:54 +08:00
AskaEth 95e8a5ca42 fix(ci): M67 文件行数守门补上 *.py / *.sh —— 脚本不再漏网
file-size job 原先只扫 *.rs/*.md/*.slint/*.yml/*.yaml/*.toml/*.json,
packaging/ 下的构建辅助脚本(make_artifacts.py、check_screenshot.py、
linux-smoke-docker.sh)完全没有任何行数保护。

- 在 git ls-files 的 pathspec 末尾加 '*.py' '*.sh',语义不变(>400 行即硬失败)
- 保留 LICENSE*/CHANGELOG* 排除;Cargo.lock 等机器生成文件刻意不入列
- 阈值、失败方式(::error + exit 1)均未改动
2026-11-01 16:22:51 +08:00
AskaEth 0aac307d92 Merge branch 'feat/p3-ci-headless-render-smoke-xvfb-xplatci' 2026-11-01 16:15:49 +08:00
AskaEth 7e08e0ccc9 fix(docs): M62 r1 —— 修正「缺字体→空图+exit 0」的错误论据,拆出运行时冒烟细节文档
review r1(p2-1items/fix-then-merge)只要求改文档与注释,门禁逻辑一行未动。

- P2-1(唯一阻塞项):原稿称「字体库缺失 → 合法 PNG + 退出码 0」,这是错的。review 实测两条失效都是
  **硬失败**:完全没有字体 → fontique panic(退出码 101,不产生 PNG);非软渲染(默认 femtovg)→
  `take_snapshot is not supported by this FemtoVG backend`(退出码 1)。改正口径:`--screenshot` 的 PNG
  是进程自己编码写出的,只要走到 `take_snapshot` 就是「合法 PNG + 退出码 0」,像素断言是**纵深防御**,
  守的是「进程活着、PNG 合法、但场景几乎没画」这一档;其余情形本机暂无样本,属防御性设计、不宣称已复现。
  落点:packaging/ci-runtime-smoke.md §1 与 packaging/check_screenshot.py 模块 docstring。
- P3-1:ci.yml 的 runtime-smoke job 头补一行 —— 本 job 绑死 Slint `Window::take_snapshot`,迁 iced 后必须
  提供等价离屏截图通道,否则静默退化成「能起进程」。
- P3-2:阈值余量写明量级(342/64=5.3×、19.45/3.0=6.5×、0.00761/0.001=7.6×,改「5~8 倍」);并写明
  闸门下限 ≈「至少画出一条标题栏」(review 实测切片 174/35.10/0.02623 与右下 400×300 切片仍 pass),
  即只证明「场景被真实渲染过」,不证明元素齐全、更不证明视觉正确。
- P3-3:README 398/400 已贴红线 —— 借 review 给的逃生口**提前拆**:细节搬进 packaging/ci-runtime-smoke.md
  (78 行),README §11 收缩为 12 行索引(374 行),并写明「再增长继续往细节文档写,两文件都 ≤400」。
- P3-4:本地复跑示例改 Windows 安全路径 `D:/tmp/ull-smoke`;脚本对 repo_root/out_dir 统一 `cygpath -m`
  归一并回显真实路径(Git Bash 下 `/tmp` 会静默落进 Docker VM,宿主什么都收不到 —— review 实测踩到)。
- P3-5:两个 `run:` 里的 `set -o pipefail` 保留但加注释说明「当前无管道,为将来加管道先有失败传播」;
  `cargo clean -p unlucky-app || true` 加内联注释(N7 兜底,CI 里没有对应行,别误抄进 ci.yml)。

验证:`--selftest` 三组仍全部 fail(判红能力未被改坏);真实截图仍 pass(1180x760 / 342 / 19.45 / 0.00761);
0 字节与非 PNG 路径 exit 1;`yaml.safe_load` 通过(6 job,Linux 槽位无 continue-on-error,artifact 步
`if: always()`);`cargo fmt --all --check` 退出 0;cygpath 修复后重跑 Docker 冒烟,宿主机 D:/tmp/ull-m62-rev2
拿到 PNG,md5 `156c382a…` 与前两次 run 及 review 的 artifact 完全一致。
2026-11-01 16:15:47 +08:00
AskaEth ea5f66cf92 feat(ci): M62 —— Linux runtime-smoke(xvfb 真渲染一帧)+ 像素非空白门禁
「编得过」不等于「跑得起来」:CI 里 Linux/macOS 此前只 build + test,从未创建过真实图形窗口。
本 commit 补第一份 Linux 真实图形环境运行证据,并把它做成不可伪造的门禁。

- ci.yml 新增 runtime-smoke(ubuntu-latest):装 xvfb + 软渲染/字体依赖,用 xvfb-run 起 GUI 进程,
  走既有 `--software-renderer --screenshot` 通道渲出一帧 PNG,再断言 ①退出码 0 ②PNG 宽高 > 0
  ③非空白(唯一色数 / 亮度σ / 强边缘占比三项过阈值),并把截图作为 artifact `runtime-smoke-screenshot` 上传。
  既有 fmt/clippy/test/build 四个 job 与触发器语义零改动(纯追加)。
- ci.yml 新增 runtime-smoke-macos 探针:只在 workflow_dispatch 触发且 continue-on-error。官方 macOS
  runner 处于 LaunchDaemon 上下文、无 WindowServer 会话,大概率建不了窗口;本机无 macOS 且 worker 禁
  push,这条路径尚无实测证据,故不拿它卡 PR 门禁(首跑结论决定升常驻或按 D26 归入 iced 重写)。
- packaging/check_screenshot.py:零第三方依赖的 PNG 解码(zlib + 反过滤 0~4)与三项指标判定,
  `--selftest` 打印阈值依据(人为纯色帧 1/0.0/0.0 必红,真实帧 342/19.45/0.00761 全过线)。
- packaging/linux-smoke-docker.sh:在 Docker 里复刻同一 job(TUNA apt/rustup 源),便于无 Linux 桌面的本地复跑。
- packaging/README.md §11:覆盖平台、阈值依据数值、本地复跑、已知跳过项、迁 iced 后需重新验证。

本机实测(Debian 12 容器 + xvfb + 软渲染):退出码 0,PNG 1180x760,唯一色 342 / σ 19.45 / 强边缘 0.00761。
CI(Ubuntu 24.04 runner)首跑仍未验证——本 job 证明「能出图」,不证明视觉正确。
2026-11-01 16:15:47 +08:00
AskaEth a681ccb526 Merge branch 'feat/p4-design-system-tokens-typography-icons' 2026-11-01 16:06:40 +08:00
AskaEth 83e19e58f0 fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口:

P2-1 冻结测试补全(变异实测证明原来可静默漂移):
- tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family)
  与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写
- tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、
  16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯
- 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED

P2-2 emphasized 口径事实错误(用户点名):
- 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组):
  label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、
  title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0
- TypeRole::emphasized() 改为官方表(typography/emphasized.rs);
  原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方
- 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、
  title-medium 0.2)与 TitleLarge 的 Brand 族归属
- crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap

弹簧改为两套 token 结构(用户追加令):
- motion::spring(scheme × category × speed) 二维表 12 格:
  standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、
  effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值
- Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量

中文字重覆盖(用户追加令):
- FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给
  Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记

component_map 高程按官方回调(P2 并入 finding):
- FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时
  按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言

P3 四条:
- 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma),
  primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变
- femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门
- 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档
- 图标折算口径订正为「居中描边,视觉外扩 <1px」
2026-11-01 15:58:19 +08:00
AskaEth 65ca0ed29a feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)
D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基:
令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖
(cargo tree -p unlucky-design --edges normal 只有它自己一行)。

- 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板;
  tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在
  模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色)
- 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记
  (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色)
- 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体)
- 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与
  surface container 映射、时长/缓动/弹簧(纯函数可采样)
- 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加;
  写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异,
  换算封装成 Icon::stroke_width
- 组件档位表:20 个组件的形状/高程/字体/动效一次取全

TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值,
接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
2026-11-01 15:34:04 +08:00
AskaEth fef0922073 Merge branch 'feat/p3-release-linux-arm64-slot-linuxarm' 2026-11-01 14:21:12 +08:00
AskaEth 4c1a6c54e5 fix(release): M51 r1 —— linux-arm64 改用 ubuntu-22.04-arm(与 x64 同 glibc 2.35 地板)
P2-1:24.04-arm 会给出 2.39 地板,把 Debian 12 / Raspberry Pi OS Bookworm
(2.36)挡在门外,且「arm64 只有 24.04 档」这句前提是错的(同段注释自己就承认
ubuntu-22.04-arm 有效)。改 os 为 ubuntu-22.04-arm,注释改成「与 x64 同地板
(2.35),覆盖 Debian 12 / Ubuntu 22.04 arm64 / RPi OS Bookworm」;
README §3/§4/§7.5/§9 与 release-notes 的 Downloads/Support matrix 全量订正
2.39 → 2.35+,删掉「arm64 地板高于 x64」的对比措辞。槽位名、产物名、六槽位
齐全性检查与打包脚本均未变。

P2-2:rustup target add 步骤改 env TARGET + `rustup target add "$TARGET"`,
并补 shell: bash(此前是全文件唯一没显式 shell 的 run,在 Windows 槽位会落进
pwsh);Linux 图形栈依赖那步也补了 shell: bash,现在所有 run 都显式 bash、
run 块内零 `${{ }}` 直插。

顺带 nit:host_slot() 报错改回原始 arch token(`riscv64` 不再显示成 None),
README §7.5 宣传的那个用例现在诊断可读。

验证:yaml 结构断言(六槽位 / linux-arm64=ubuntu-22.04-arm / 齐全性循环 6 /
run 块 ${{ }} 零泄漏 / run 步骤全带 shell)、全部 run 块 bash -n、grep 2.39
零残留、host_slot 7 组合注入 + freebsd/riscv64 报错含原名、image_arch 真 exe、
windows-arm64 stub 全流程、架构守卫拦截、linux-arm64 归档两次 sha256 一致
(f949d0f5… 与上一轮相同)、windows-x64 原生回归(冒烟 + sha256sum -c)。
2026-11-01 14:16:29 +08:00
AskaEth 15caa1b61f feat(release): M51 —— 发布矩阵补 Linux arm64 与 Windows arm64 两槽位(六槽位)
- release.yml:加 linux-arm64(原生 ubuntu-24.04-arm runner)与 windows-arm64
  (windows-latest 上交叉编译到 aarch64-pc-windows-msvc);产物根改由
  matrix.build_dir 参数化,dumpbin 自检与打包都吃矩阵路径而非硬编码 target/release;
  交叉槽位多两步(rustup target add + ARM64 库体检),release job 的槽位齐全性
  检查从四扩到六。
- make_artifacts.py:--slot / --build-dir 参数化 + 六槽位映射;打包前回读可执行文件头
  (PE 机器码 / ELF e_machine / Mach-O cputype)校验架构,跨槽位构建自动跳过
  ull --version 冒烟并打印原因(不在 x64 上假装跑得动 arm64 二进制)。
- packaging/README.md + release-notes.md:两张支持矩阵同步加 Windows 11 ARM64 与
  Linux arm64 行;arm64 glibc 地板如实写 2.39(高于 x64 的 2.35),并说明与 Termux
  实验级的区别(这两个是正式发布槽位)。

本地验证:宿主判定注入(七个组合 + 非法宿主 exit 1)、文件头架构识别(真 x64 exe +
合成 PE/ELF/Mach-O 头)、windows-arm64 stub 全流程、架构守卫真拦、linux-arm64 归档
两次 sha256 一致、windows-x64 原生回归(冒烟 + sha256sum -c)。arm64 真机构建由 CI 补。
2026-11-01 13:48:37 +08:00
AskaEth 0bfe22aa6f Merge branch 'feat/p3-release-windows-dumpbin-locator-fix-d'
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 13:06:12 +08:00
AskaEth a6013c7d22 fix(ci/release): M50 —— Windows 槽位 dumpbin 定位链改三级(vswhere → find → PATH)
v0.1.0 首跑(run 41273841717)windows-x64 在「Windows 单文件自检」挂
`找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)`。两个独立根因:

- `command -v dumpbin` 在 windows-latest 上不成立:VS 环境未预置进 PATH。
- 兜底 glob 深度错了一级:写的是 `<VS 根>/<年>/VC/Tools/...`,真实布局是
  `<VS 根>/<年>/<版次>/VC/Tools/...`(2022/Enterprise、2022/BuildTools),
  所以即便 PATH 失守、glob 也不可能命中。
- 且只扫了 64 位 `Program Files` 根,Build Tools 落在 `Program Files (x86)` 根。

改成三级定位链,全不命中才 fail(门禁语义一字未动:工具缺失仍 exit 1、
导入表出现 VCRUNTIME/MSVCP/MSVCR/ucrtbase 仍 exit 1,文案不变):

1. vswhere —— VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/布局无关:
   `.../Installer/vswhere.exe -latest -products '*' -find <pattern>`。
   模式先试反斜杠(官方文档形式)、再试正斜杠,输出统一折成正斜杠后再执行。
2. find 兜底 —— 两个 VS 根(64 位 + x86)按路径模式扫,`-maxdepth 11`
   留一级余量,不再依赖固定层数的 glob。
3. PATH —— 最后才试,PATH 最不可控。

另加一行 `dumpbin: <路径>(定位自 <层级>)`,下次真机挂掉能直接看出命中哪一级。
同文件其余步骤不依赖 VS 环境(已逐条核对),无需收敛。

验证(本机无 VS):
- 抽出 run 块 `bash -n` 通过;13 条 stub 用例全绿:无工具→fail 文案不变且不写
  dependents.txt、vswhere 反斜杠/正斜杠两个模式、两处 VS 根 find 兜底、PATH 兜底、
  动态 CRT 仍 fail-closed、vswhere 优先于 PATH、vswhere 输出的反斜杠路径归一化后可执行。
- 未改写的原始 run 块在本机实跑:干净 exit 1 + 原文案,不误命中、不崩。
- yaml 解析 + 23 条结构断言全过(矩阵四槽位、触发条件、门禁 grep/文案/exit 原样);
  文件 276 行 ≤ 400。
- git-bash 行为实证:`.exe` 的 `-x` 为真、非可执行文件为假、`find` 接受含空格的
  `C:/Program Files` 根。
真机验证由 merge 后 retag 重跑流水线完成。
2026-11-01 12:58:40 +08:00
AskaEth 37ccaaf23f Merge branch 'feat/p3-fix-game-java-discover-cfg-gating-jav'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 11:00:32 +08:00
AskaEth dc4fac8391 fix(core/game): M49 —— Java 去重键平台无关归一化(Linux CI 三红)
`game::java::discover::path_key` 只在 `cfg!(windows)` 分支折分隔符,POSIX 上
把 `C:\a\b\java.exe` 原样留成带反斜杠的键,与 `C:/a/b/java.exe` 不同键 →
`dedupe` 不去重。三条用例(Windows 语义无 cfg 门)因此在
`test (ubuntu-latest)` 挂:`path_key_normalises_separators`、
`dedupe_keeps_the_higher_priority_source`、
`java_discover::dedupe_collapses_one_install_found_twice`。

- `path_key`:`\?\` 前缀剥离与分隔符折成 `/` 改为**与平台无关**(同一个
  `bin/java[.exe]` 用哪种拼法都是同一个文件,键必须相同);大小写折叠仍只有
  Windows 做——POSIX 上 `Java` 与 `java` 是两个目录,折了会把两份真实安装误并。
  已知取舍写进文档:POSIX 上文件名里的字面反斜杠会一并被当分隔符。
- 三条用例:不靠 `#[cfg(windows)]` 跳过。`path_key` 单测改成平台无关断言 +
  大小写折叠的双侧断言(Windows 折 / POSIX 不折,两侧都钉住);集成用例的
  「同一路径第二种写法」按平台给(Windows 用大小写+分隔符,POSIX 用分隔符)。
- 回归钉子:新增 `one_install_normalises_to_one_key_whatever_the_spelling`
  (普通写法 / 反斜杠写法 / `\?\` 前缀写法 / 混合写法 四种拼法同键)与
  `path_key_folds_case_only_where_the_filesystem_does`。

验证:Linux(rust:1-bookworm 容器,ull-linux-target 热缓存)
`cargo test -p unlucky-core --lib --test game` → 224 + 120 passed / 0 failed
(基线是 lib 220/2 failed、game 119/1 failed);
`cargo test --workspace --all-targets --no-fail-fast` → 34 个测试块
1137 passed / 0 failed(该次跑在 M48 launch 修复叠加的临时副本上,因为 M48
尚未合入 main,见报告)。Windows 本机 `cargo fmt --check`、
`cargo clippy -p unlucky-core --all-targets -D warnings`、
`cargo test -p unlucky-core` 全绿(17 个测试块 0 failed)。
2026-11-01 11:00:25 +08:00
AskaEth 38832df095 Merge branch 'feat/p3-fix-posix-tree-kill-ci-red-posixkill' 2026-11-01 10:48:15 +08:00
AskaEth 6ce825db8d fix(launch): M48 —— POSIX 组杀假成功导致 stop() 空等超时
CI test (ubuntu-latest) 自 M43 起红的根因不是「组杀打到自身进程组」,
而是 procps-ng 的 kill 把裸 `-<pgid>` 当成又一个信号选项吞掉:退出码 0、
什么都没发。旧实现据此提前 return,单 pid 兜底永远不执行,于是
process_smoke::stop_kills_process_tree 与
pipeline_cases::pipeline_stop_records_killed_exit 只能干等
graceful+5s 预算后报「强制结束后进程仍在运行」。

- process.rs:Unix 分支用 CommandExt::process_group(0)(std 1.64+ 安全
  API,无需 pre_exec/unsafe)让子进程自成一个进程组,与 Windows 的
  CREATE_NEW_PROCESS_GROUP 对称;这也让「树」回收真正覆盖后代。
- tree_kill.rs:组杀改用 `kill -TERM -- -<pgid>`(`--` 不可省),并且
  先过 group_kill_target 三重验证——目标必须是自己的进程组组长、必须
  读得到我们自己的组、目标组不能就是我们所在的组;单 pid 兜底改为
  「永远执行」,kill 缺失(NotFound)返回明确错误而不是静默失败。
  pid==0 守卫从 kill_tree 提到 terminate/force_kill 共用入口。
- 测试钉子(Linux 门住):spawn 后子进程 pgid == 自己的 pid 且 !=
  测试进程 pgid;组杀只接受验证过的独立组;组杀覆盖孙进程且不碰我们
  自己的组;同组子进程靠单 pid 兜底仍能被干掉;pid 0 一律拒绝。

验证:rust:1-bookworm 容器(ull-linux-target 热缓存)cargo fmt --check、
cargo clippy --workspace --all-targets -D warnings、cargo test -p
unlucky-core --test launch(82 passed,两条原红灯转绿)、cargo test
--workspace --all-targets --no-fail-fast 不再被 SIGTERM 打断;Windows
本机 fmt/clippy/cargo test -p unlucky-core 全绿。
2026-11-01 10:32:18 +08:00
AskaEth eaa1414847 Merge branch 'feat/p3-packaging-chore-six-p3-findings-packc' 2026-11-01 09:40:59 +08:00
AskaEth 62019a29cd fix(packaging): M47 r1 P2 —— 重跑不再断言 pre-release 标记
reviewer 指出:edit 分支此前每次默认重跑都重传 --prerelease,会把用户按
README 建议手动提升为 stable 的 Release 静默翻回 pre-release,文档承诺"重跑
不翻转标记"不成立。按首选修法让代码匹配文档:

- release.yml:create/edit 两套标记分开算。create 保持老契约(空输入 → 首建
  pre-release);edit 只在 inputs.prerelease 显式给出时断言
  (true → --prerelease,false → --prerelease=false),空值(push 或其 Re-run)
  不传任何标记。非法取值直接 ::error + exit 1。
- README §3:重跑语义与实现逐字一致,并把"改标记"的两条等价路径
  (gh workflow run -f prerelease=false / 手动 gh release edit)与 dispatch
  语义写顺;yml 头注释同步一句。
2026-11-01 09:37:55 +08:00
AskaEth ea6016716e chore(packaging): 收 M46 r1 六条 P3(dumpbin 门禁/ref 注入面/重跑幂等/文档口径)
- O1 release.yml 找不到 dumpbin 从 ::warning+exit 0 改为 ::error+exit 1(门禁不降级),
  README §7.4 同步为"两侧都 fail、没有降级分支"
- O2 打包 step 改用 env REF_TYPE/TAG 传参,bash 里不再出现 ${{ }} 直插;
  release job 的 TAG/PRERELEASE 已是 env 写法,全文件复核无同类风险点
- O3 gh release create 改为 view 命中则 upload --clobber + edit,同 tag 重跑幂等;
  README §3 补重跑语义与 pre-release 标记以首次创建为准
- O4 yml 头注释与 README §3 点明 inputs.prerelease 只在 tag ref 上 dispatch 可达
  (UI 只能选分支,需 gh workflow run --ref v<tag>),不与"dispatch 只构建不发布"冲突
- O5 release-notes 模板补 Termux 试验级行,README §9 同口径,标题改为"逐行一致"
- O6 README §6 补 ci.yml 每次 push/PR 的 release build 吃 fat LTO 链接时间;
  订正"fat 是全局单线程 LTO"为 codegen-units=1 导致单线程(§6/§7.1 两处);
  §7.3 写入 reviewer 补测的 tar 可复现证据 a99d6e9e…92f8
2026-11-01 09:30:27 +08:00
AskaEth 92d8a705e8 Merge branch 'feat/p3-forge-neoforge-gui-install-via-proces' 2026-11-01 09:14:07 +08:00
AskaEth d95c9664c6 fix(app): M45 r1 两条 P2(向导拒装文案 + 处理器段进度满格)
P2-1:wizard.slint 的 Forge/NeoForge 提示还写着「需要 installer processors:本轮请用 ull install」,
改成「需要 installer processors(首装会外调 java,约 1–3 分钟)」;全仓再扫一遍
「ull install / 显式拒装 / 终端跑」等措辞,user-facing 文案已无残留(余下命中全是
OptiFine/LiteLoader 的正确说明与测试注释)。

P2-2:进度条在处理器依赖库下完那一刻就满格(36/36),随后加载器库的 29 个文件才被计入
→ 100% 回落再涨回,且 bytes_total 用 .max() 与文件数对不上。
- Meter 新增 expect(files, bytes):文件数与字节都 saturating_add;
- install_loader 在处理器段之前就把加载器库的文件/字节一次预记账(两段之和),
  加载器库阶段只剩切阶段文案,不再记账;
- Reporter::expect 改为委托 Meter::expect,处理器依赖库沿用同一条曲线;
- 回归测试钉住:预记账后 files_total=95、bytes_total=17MB、ratio 不再满格,
  处理器库下完仍是中间态,加载器库下完才到 100%。

真机重截 + 识图:D:/tmp/ull-m45-shots/forge-mid-fixed-25s.png —— 同一时刻面板
36/65 文件、3.4 MB/15.3 MB、进度条只填了约一半(旧图 36/36 + 满格)。
2026-11-01 09:14:02 +08:00
AskaEth 3d9372029e refactor(app): 处理器测试夹具用 StepView 别名,过 clippy::type_complexity
clippy --all-targets -D warnings 报 very complex type(RefCell<Vec<(usize, String, Vec<String>, usize)>>),
抽 pub(super) type StepView 别名,语义不变。
2026-11-01 09:14:01 +08:00
AskaEth b0f747074f feat(app): GUI 接上 Forge/NeoForge installer processors(M45)
- host/install_processors:GUI 与 CLI 同序跑 processors(计划 → 依赖库 → 选 Java → 串行 java -cp),
  进度经 HostTask::Install 回流(阶段文案 + 每步处理器名 + 文件/字节/速度/ETA),取消在阶段边界生效;
- host/install_journal:安装过程追加到 .ULL/logs/install.log,失败文案带日志路径,不再静默失败;
- host/install.rs:supported() 扩到原版 + Fabric/Quilt/Forge/NeoForge,删除 Forge/NeoForge 拒装路径
  (仅剩 OptiFine/LiteLoader 两家 core 侧无管线者),spawn_install 统一持 Job 上下文;
- 失败诊断:缺 Java / processor 非零退出(保留 processor_* 大类码与脱敏 stderr 尾部)/ 依赖库下漏
  / 元数据缺失,均为结构化文案 + Toast + 装日志;
- dto::InstallStage::Installing 文案改「生成版本」(同时覆盖写版本 JSON 与跑 installer 两段);
- screenshot:新增 --screenshot-loader,Forge/NeoForge 中间态可离屏取图;
- 测试 +14(138 全绿):supported() 语义、处理器阶段注入 fake 后端覆盖跳过/缺 Java/非零退出/
  依赖库失败/命令行构造/进度文案,另修旧用例(Forge 不再被门禁拒装)。
2026-11-01 09:14:01 +08:00
AskaEth 858c227900 Merge branch 'feat/p3-packaging-release-artifacts-matrix-pa'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-11-01 08:33:18 +08:00
AskaEth 76d83148ff feat(packaging): 单文件产物矩阵 + tag 触发的 Release 流水线(M46)
- packaging/make_artifacts.py:本地与 CI 共用的打包脚本 —— 版本取自 cargo metadata
  (tag 交叉校验,不一致即失败)、按宿主判定槽位、Windows 裸 exe / UNIX tar.gz
  (内含 LICENSE/NOTICE/README.md)、SHA256SUMS.txt;打包前跑 `ull --version` 断言版本;
  tar 内 mtime/uid 归零 + gzip header mtime=0 → 同源构建 sha256 可复现
- packaging/README.md:产物矩阵与命名约定、版本唯一真相源、各平台运行时依赖、
  软渲染兜底、release profile 取舍、**本机实测**体积与冒烟证据、合规与支持矩阵
- packaging/release-notes.md:GitHub Release 说明模板({{VERSION}}/{{SUMS}},
  写清 Win10+ / macOS 12+ / Linux x64 支持矩阵)
- .github/workflows/release.yml:tag v* 触发四槽位矩阵 → 产物 + 重算 SHA256SUMS →
  gh release create(pre-release 默认开,workflow_dispatch 只构建);Windows 用
  +crt-static 并以 dumpbin 导入表把"单文件"做成硬门禁;归档内容自检 LICENSE/NOTICE/README
- Cargo.toml:release profile lto thin → fat(实测 GUI 16,366,080 → 14,595,584 B
  = −10.8%,ull 7,572,992 → 6,157,824 B = −18.7%)

验证:cargo build --release 通过;GUI `--software-renderer --screenshot` 出 1180×760 PNG、
`ull --version` = 0.1.0;PE 导入表 33 个 DLL 全为系统库(无 VCRUNTIME/libwinpthread);
cargo deny licenses/bans/sources ok;workflow 用 python yaml 解析自检。
2026-11-01 08:33:17 +08:00
AskaEth f9079f8ab0 Merge branch 'feat/p3-msa-authorization-code-flow-browser-l' 2026-11-01 08:33:13 +08:00
AskaEth 07fd6113a7 fix(core): 回调先校验 state 再处理 error,堵掉伪造取消失效登录
reviewer P2(crates/core/src/auth/microsoft/callback.rs):handle() 先处理 error 参数、
之后才校验 state,于是任意一次 `GET http://localhost:{port}/auth?error=access_denied`
(state 随便给甚至不给)都会让 wait_for_code 直接返回 Canceled,登录被静默中止、账户不入库
—— 不需要知道随机 state、不需要 code、不需要任何凭据。违反 RFC 6749 §4.1.2.1 / RFC 9207
(错误响应同样要校验 state),也与本模块「与本次登录无关的请求一律继续等待」的声明矛盾。

- state 校验提到 error 之前:state 不匹配的请求(无论带不带 error)一律 400 后继续等;
  只有带**本机 state** 的 access_denied 才判 Canceled。模块文档第 4 条同步改写。
- 顺带采纳 reviewer 的两条非计数观察:
  * 读头预算改为 accept 之后按 deadline 重算(原来复用 accept 前的 remaining,
    总时长可超发 ≤5s);预算归零直接超时。
  * state 由 64 位升到 128 位(两个独立随机种子的 SipHash 输出拼接),对齐
    RFC 6749 §10.10 的熵建议;新增单测断言 32 位 hex 且每次不同。
- 新增回归用例(crates/core/tests/auth/microsoft_authcode.rs):
  `foreign_state_cannot_cancel_the_login` —— 先发 `?error=access_denied`(无 state)
  再发 `?error=access_denied&state=not-our-state`,两次都必须 400 且服务器继续等待,
  随后真回调拿到 code;`access_denied_with_our_state_is_still_canceled` 钉住另一半语义。
  既有的「陈旧 state 回 400 后继续等」用例保持通过。

门禁:cargo fmt --all --check OK;cargo clippy --workspace --all-targets -- -D warnings
exit 0 无告警;cargo test -p unlucky-core -p unlucky-app 18 个 suite 全绿(auth 59 例)。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00
AskaEth 8194e38bcf feat(core): 加 MSA 授权码流(本地回调服务器)并接到账户页
research/08 方案 A:GUI 的微软登录改走授权码流(HMCL 主流做法),设备码流保留给
CLI/无浏览器场景。两条流只差「怎么拿到 MS access_token」,拿到之后共用
chain::complete_chain 与 MicrosoftSession,落盘/刷新语义没有第二套。

core(crates/core/src/auth/microsoft/)
- endpoints:新增 authorize(…/consumers/oauth2/v2.0/authorize),与 token 同租户。
- authcode:RedirectUri 是 http://localhost:{port}/auth 的唯一来源(授权 URL、
  token 请求体、实际监听三处共用,坑 5);AuthCodePolicy(回调超时 300s / 绑定重试 3 次 /
  固定端口 / 固定 state);authorization_url(response_type=code + prompt=select_account +
  scope=XboxLive.signin%20offline_access);exchange_code 复用 flow 的 oauth_error/tokens_from。
- callback:本地 HTTP/1.1 回调服务器。内核分配端口(占用按次数退避重试);非 /auth 路径
  (favicon、预连)404 后继续等;state 不匹配 400 后继续等;error=access_denied 归 Canceled;
  单条连接读头超时 5s,半开连接拖不垮时间预算。
- MicrosoftAuth::authcode_login(present):绑端口 → present(宿主开浏览器)→ 等回调 →
  换 token → 整链;失败与设备码流一样落 M38 失败日志。

app(crates/app)
- AppCommand::StartBrowserLogin + host::accounts::start_browser_login;设备码/授权码共用
  LoginJob 取消槽位、收尾入库(store_session)与面板 DTO。
- host::opener 用系统默认浏览器打开授权 URL(Windows rundll32 url.dll,FileProtocolHandler)。
- 账户页:主按钮「微软登录」走授权码流,「设备码登录」作备用;DeviceLoginView::browser_waiting
  在面板里给出可复制的授权链接(user_code 留空)。

测试
- crates/core/tests/auth/microsoft_authcode.rs:授权 URL 向量(租户/编码/prompt)、
  URI 一致性(授权 URL 与 token 表单同字面量)、mock 全链、favicon+陈旧 state 后仍拿到 code、
  端口占用重试文案、超时、access_denied→Canceled、login_with_xbox 403 的 M38 诊断
  (detail_code=login_with_xbox_403,真机白名单未批时验收止步于此)。
- 单测:authcode 编码/向量、callback query 解码与错误分流、DTO browser_waiting、conv 面板。

未做/未验证:真机完整登录需 Azure 公共客户端 id(ULL_MSA_CLIENT_ID)与 Minecraft API 许可,
本机只能到「授权页 302 到 login.live.com 且 redirect_uri/scope/prompt 原样保留」这一步。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00
AskaEth f62bb00494 docs(readme): add official GitHub project home line
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-10-31 22:19:47 +08:00
AskaEth daadc9170f Merge branch 'feat/p3-gui-full-wiring-all-pages-consume-cor'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
2026-10-31 22:16:35 +08:00
AskaEth 50b321ec64 fix(app): LaunchEnd 也清 pending 并记失败原因,补早失败回归测试(M43 r3 两条 P2)
P2-1(本轮新引入的回归):`pending` 只在 LaunchStarted 清除,但 `launch::start`
的两条早失败出口(build_request 失败 / spawn 失败——没装 Java、版本 JSON 坏就是
这两条)只发 LaunchEnd → pending 永久残留 → `in_flight` 恒真 → 该实例本会话再也
启动不了,磁贴锁在「启动中」。修:LaunchEnd 分支先 `pending.remove`,早失败与
正常收尾都释放。

P2-2(failed 零 insert):上一轮只在 note 里声称「失败原因进 failed 表」,代码没
落地(唯一 insert 缺失,refresh 的 Failed 分支是死代码,test 6 还靠手动塞表绕过)。
修:LaunchEnd 非 Ok 时 `failed.insert(id, summary)`、Ok 时 remove;refresh_instances
的 Failed 分支从此由真实路径喂数据。

测试(123 → 124,全部走真实入口):
- 新增 `launch_end_before_launch_started_still_releases_the_instance`:mark_pending
  → handle(LaunchEnd Crashed) → pending 空、in_flight 假、refresh 后磁贴是
  Failed{reason}(同时钉住 P2-1 与 P2-2)。
- test 6 重写:Busy 断言改走 `mark_pending`(start_launch 用的同一入口),Failed
  断言改走 `handle(LaunchEnd Crashed)`,不再手动塞 failed 表。
- 所有测试的数据根加 `TempRoot` Drop 守卫,跑完不留临时目录。
2026-10-31 22:14:24 +08:00
AskaEth 7b2ff62ddc fix(app): 运行时状态回归真实来源 + 启动状态机补回归测试(M43 r2 两条 P2)
P2-1(修复零测试):新增 host/launch_state_tests.rs,6 条单测钉住启动状态机:
- running_pid 按实例 id 取 pid(并发 A/B 后停 A 不会拿到 B 的 pid,A 收尾不动 B);
- 重复启动守卫:launches 已落盘时 start_launch不发 Busy、只给 warn(不起第二个进程);
- pending 窗口守卫:后台线程还没回报 LaunchStarted 时同样挡住重复启动;
- LaunchStarted 清 pending 并登记 pid;LaunchEnd 清启动表与失败记录;
- refresh 后在跑实例仍是 Running{pid};pending 是 Busy;失败记录是 Failed(保住
  磁贴的停止键/运行条/重试外观)。

P2-2(运行时状态未派生):refresh_instances 重建快照后按 host 的启动表覆写运行时
(Running{pid} / Busy / Failed),不再被 game::instances 的硬编码 Idle 打回原形——
装版本成功、加删账户、别的实例收尾等任意刷新都不再让「在跑的实例」丢掉停止入口。

顺带加固(reviewer 建议的 pending 窗口):单槽守卫改为 pending ∪ launches 并集,
launch::start 到 LaunchStarted 之间的重复 dispatch 不会起第二个进程;失败原因也
记进 failed 表,避免收尾后的刷新把「失败/重试」覆盖成 Idle。
2026-10-31 22:09:47 +08:00
AskaEth ecded9bef0 fix(app): 停止实例按实例 id 找 pid,并发启动不再杀错进程树
reviewer-guiwire r1 的 P2-1:`running_pid` 只记「最近一次启动」的单槽 pid,
并发启动 A、B 后点 A 的停止键会 tree_kill 掉 B;且实时尾随在 B 启动时被抢占,
A 的日志再也没人跟。兜底分支也永远不命中(`game::instances` 每次重建都把
runtime 置 Idle)。

改法:
- 单槽 `launch_pid` → `launches: Vec<LiveLaunch{instance, session, pid}>`,
  停止/收尾/尾随判定都按实例 id 或会话 id 精确匹配;
- 同一实例重复点启动直接拒绝(「已经在启动/运行中」),不再起第二个进程;
- 收尾只拆自己那场的尾随,并发时另一实例的实时日志不被掐断;
- 实时尾随仍是单窗口(一次只跟一场,另一场收尾后可从 JSONL 回放)——这是
  有意的 UI 取舍,不是漏判。
2026-10-31 21:55:59 +08:00
AskaEth 359a5a4325 chore: gitignore 截图验收产物目录(.screens/.tmp-screens) 2026-10-31 21:55:59 +08:00
AskaEth ddc0577f83 feat(app): GUI 全量接线,五页改吃 core 真实数据(M43)
app 从「mock 骨架」切到「真实 core host」:新增 src/host 装配层(env/job/
game/launch/identity/versions/install/meter/java/accounts/logs/settings/engine/
dispatch),是 app 里除 settings_bridge 外唯一 use unlucky_core 的地方,出口只有
crate::dto 的 DTO/事件(三通道纪律,AGENTS §6)。

页面接线:
- 实例页:profiles+版本 JSON 继承链+会话索引 → 磁贴(真实版本/加载器徽标/最后启动/
  隔离档位/载荷缺失);启动按钮走 LaunchRequest 装配 → Launcher::spawn → JSONL
  尾随 → 运行/失败回写;M30 ModeChangeWarning 进横幅通知条。
- 新建实例向导:版本 → 加载器 → 名字 → 确认 → 真实安装进度;Fabric/Quilt 可装,
  Forge/NeoForge 因需要 installer processors(仅 CLI 有)显式拒装并给出 CLI 命令。
- 下载页:远端 manifest(12h 缓存)+ 内嵌 unlisted → 分类(正式/预览/远古四子类);
  安装进度(文件数/字节/速度/ETA,速度由相邻采样差分)+ 阶段边界可取消;
  Java 分组:四家发行版 + 回退链可视化(计划链/真实 ChainLog)+ 本地运行时。
- 账户页:三轨账户列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录
  (用户码/链接/轮询状态;取消后丢弃结果)。凭据只在 host/identity 与 host/accounts
  出现,DTO 无 token 字段(单测钉住)。
- 设置页:新增 download/java/data/ai/mcp 五组,行由 core::setting schema 键生成,
  写回走 SettingStore::set(与 ull config set / AI set_config 同源 launcher.json)。
- 日志页:会话索引 → 列表;事件 JSONL → 日志行(等级高亮)+ D8 规则引擎诊断卡片;
  启动中实时尾随(600ms/拍,只读新增字节);崩溃日志导入分析(hs_err/crash/latest)。

架构与守线:
- 新页面状态走 ui/page_state.slint 的 PageState global(main.slint 再导出),
  Rust 侧 page_sync.rs 灌入、callbacks_pages.rs 接线;避免属性逐层转发顶破 400 行。
- DTO 全部 Serialize+Deserialize(IPC 契约),新增 accounts/java/settings/install/
  notices 五个子模块;事件扩展 LogAppended/LaunchFinished/AccountsChanged/
  DeviceLoginChanged/JavaChanged/InstallProgress/SettingsChanged/NoticesChanged。
- 截图通道扩展:--data-dir(钉数据根且 settings 同源)/--mock/--screenshot-page/
  --screenshot-wizard/--screenshot-session/--screenshot-install/--screenshot-launch
  (真实中间态,取图后自动 stop 防孤儿进程)。

已知边界:GUI 账户「当前启动账户」仅内存态(core schema 无 auth.* 键,app 不自建键);
Forge/NeoForge GUI 安装待 processors 执行器上移。
2026-10-31 21:55:58 +08:00
AskaEth bc41dfcf7d Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00
924 changed files with 8321 additions and 115739 deletions

No files matched your search

+27 -134
View File
@@ -3,10 +3,6 @@ name: CI
# 主干门禁:fmt / clippy / test / build 双平台矩阵。
# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。
# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。
# (M270)每个 `Swatinem/rust-cache@v2` 都显式写 `key:` = **该 job 的 runner 镜像标签**(不是「更保险」):v2 的 key
# 只有 `os.type()-os.arch()`(`Linux-x64`),**没有镜像标签/glibc** ⇒ **改前**本文件 `build`(ubuntu-latest = 24.04)与
# `release-build.yml` 的 `build`(ubuntu-22.04 = glibc 2.35)共用同一份 `target/`(脏产物就是这么串过去的)。因果链见
# `release-build.yml` 那处注释;`cache-targets` 取舍、剩余风险与 key 演算见 packaging/docs/build-and-ci.md §3「构建缓存」。
#
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树,源码原样保留)。
# 所以本文件里所有 `--workspace` 都**不再**覆盖 `unlucky-app`:CI 不再编 slint 1.18 + usvg 0.48
@@ -14,66 +10,18 @@ name: CI
# 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README:
# 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。
# ---- 夜间全量矩阵(M266 / T30):为什么是 schedule,而不是 push dev ----
# 上面那套门禁的前提是「有人往 main 推东西」,而本仓的实际节奏是**日常只推 dev**、main 一个月只在
# 发版那一刻动一次 ⇒ 全量矩阵一个月跑一次,一次爆 5 个红,全部是早已存在的平台依赖问题。
# `schedule` 就是补这个缺口的入口:每晚在 main 的末梢上跑一轮,把「平台回归的发现延迟」从一个月
# 压到一天。
#
# **不要顺手把它改成 `push: branches: [dev]`。** 本文件的矩阵 = 两个 OS 的 build release + 两个 OS 的
# test + 两个 OS 的 clippy + fmt + runtime-smoke + update-e2e。挂到 dev 上等于每次提交烧一轮四平台
# 矩阵,成本与排队都不可接受(dev 白天是开发时间);而 24 小时一轮就足以把平台回归压到 T+1。
# 这是**故意的取舍**:抓得住「平台回归 T+1 就能看见」,抓不住「当天的错当天见」。dev 线的快守门
# (fmt / clippy / guards / gpu 自测)在 dev-guards.yml,那边刻意不上全量 test / build,理由见其头部。
#
# schedule 的五条语义边界(省得下次重新查;每条都能在 GitHub 文档里对上原文):
# ① 定时只在**默认分支**上生效:cron 跑的是 **main 的末梢**,dev 上尚未合入的内容**不在这一轮里**。
# 所以「dev 已经有人天天看着了」这句话是**错的**——dev 线只有 dev-guards.yml 那几道快守门
# (fmt / clippy / guards / gpu 自测),全量矩阵在 dev 上一次都没跑过。另外这份文件必须先合进
# main,cron 才会第一次响:合并之前一次都不会跑。
# ② 失败要向人喊话:`::error::` 注解由 test 作业的 shell 步骤自己产出,不读 event 上下文,这半条
# 与 push 一样。但**收件人不是「默认分支的维护者」**——文档原话是 "Notifications for scheduled
# workflows are sent to the user who last modified the cron syntax in the workflow file":
# 谁最后一次改这行 `cron`,就一直收它的告警,所以这行 cron 的改动者要当成值班人。
# ③ 逐个 job 核过 `github.event` 依赖:除 concurrency 组那一处(`github.event_name`)与
# runtime-smoke-macos 的 `if: github.event_name == 'workflow_dispatch'`(schedule 下为假 ⇒ 跳过,
# 它本来就只是手动探针)之外,其余 job 与矩阵里**没有任何** event 表达式,夜间不会空转。
# ④ 定时运行必须与 push main **分列到不同的 concurrency 组**:两者的 `github.ref` 都是
# `refs/heads/main`,同组 + `cancel-in-progress` 会让它们互相取消 —— 而**取消不是红**。
# 一个经常被静默取消的夜间入口比没有更坏:「昨晚没报错」会变成一句假话。所以 group 里带了
# event_name(证据强度见 `concurrency:` 上方那几条注释,别当成已在 runner 上实证过)。
# ⑤ **它自己会悄悄停**:public 仓库的 scheduled workflow 在仓库 60 天无活动后会被**自动 disable**
# (文档:"In a public repository, scheduled workflows are automatically disabled when no
# repository activity has occurred in 60 days")。这是「夜里那轮某天不再响、而且没人发现」的
# 真实失效模式,比红更隐蔽;本仓若是 public 就适用,发现入口不跑了先看那条 disable 提示。
#
# 时间:`17 19 * * *`。GitHub 按 UTC 解释 cron(文档:"By default, scheduled workflows run in UTC"),
# 所以它是 **19:17 UTC = 本地(UTC+8)次日 03:17**,本文件不再出现第二种换算口径。
# 为什么是 17 分:文档只说 "the start of every hour" 是高负载期,且 "If the load is sufficiently high
# enough, some queued jobs may be dropped"(负载过高时排队任务会被**丢弃**,不只是延迟),并建议错开
# "a different time of the hour"。文档没有「:30 也挤」这条依据,所以这里只避开整点。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# 夜间全量矩阵(M266 / T30,理由与取舍见文件头):19:17 UTC = 本地次日 03:17。
schedule:
- cron: '17 19 * * *'
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
concurrency:
# 加了 schedule 之后这一行必须带上 event_name:定时运行落在默认分支上,`github.ref` 同样是
# `refs/heads/main`,与「push main」落进同一个组 ⇒ 配上 cancel-in-progress 就是两者互相掐死
# (夜里那轮被白天的推送取消 = 那一夜没有结果,而取消不是红,正是本次要填的洞)。带事件名之后:
# push↔push 仍按 ref 互斥(原有行为一字未变),定时自成一列,既不会被推送取消,也不去取消推送。
# 证据强度:以上是 **GitHub 文档语义**(schedule 事件的 ref 是默认分支、取消按 group 判定),
# **没有在真实 runner 上构造并发验证过**——本地造不出两个并发 run。别把它当实测结论引用。
group: ci-${{ github.event_name }}-${{ github.ref }}
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -107,8 +55,6 @@ jobs:
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
# 所以这条命令不会拉起 slint / usvg / i-slint-*。
- run: cargo clippy --workspace --all-targets -- -D warnings
@@ -131,17 +77,17 @@ jobs:
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}
# ---- 下载页选项区的四条像素守门(M127):先出图,再把路径喂给 `--lib` ----
# 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些变量零命中 ⇒
# **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处常驻调用点。(另有一道保险:
# 变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过 ⇒ 将来谁重构掉下面出图那步,构建会红。)
# 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些
# 变量零命中 ⇒ **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处
# 常驻调用点。(另有一道保险:变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过。
# 于是将来谁把下面出图那步重构掉,构建会红。)
#
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸 40×40),这些数字
# 按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上这套字体不存在,回退字体的字高与行距
# 一变就是假红。同一条限制也写在 `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的
# 模块文档里(非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸
# 40×40),这些数字按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上
# 这套字体不存在,回退字体的字高与行距一变就是假红。同一条限制也写在
# `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的模块文档里
# (非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
if: runner.os == 'Windows'
shell: bash
@@ -183,20 +129,22 @@ jobs:
name: dl-shots
path: ${{ runner.temp }}/dl-shots/
if-no-files-found: warn
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118)。**别再写 `--exclude unlucky-app`**:它已不是成员,
# 只会打一条 `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
#
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的第三条红 `test (windows)` 只留下 `Process completed with exit code 1`,而 cargo 自己的失败码
# 是 **101** ⇒ 那个 1 的来源不明(包装层洗码 / 进程被杀,两说都未排除);job log 要登录才能读
# (匿名 403),但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事
# 各对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑 7 个 test
# binary 就退出),后面还有没有第二条失败完全看不到;
# 2) `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,注解是匿名侧
# 唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、零诊断」
# 分开——这正是 M144 没定的那一半。
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
# 事各自对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
# 注解是匿名侧唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
@@ -218,10 +166,6 @@ jobs:
- name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows'
shell: pwsh
env:
# M171 跨卷守卫:runner 有没有第二块可写卷没核实手段,所以不赌 ⇒ 显式豁免(惰性:真探到就不参与
# 判断,见 install_xdev_cases 的真值表用例)。
ULL_XDEV_TEST_WAIVE: "1"
run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。
@@ -256,25 +200,9 @@ jobs:
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# M270:本 job 就是与发布侧**同名同 key** 的那一个(都叫 build,镜像 24.04 vs 22.04)。
# 镜像标签进 key 前缀之后,主 key 与兜底 restoreKey 两条恢复路径都不再跨镜像命中。
key: ${{ matrix.os }}
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
- run: cargo build --workspace --release
# M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本——
# 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫
# 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。
- name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py \
--gui target/release/unlucky-launcher-iced.exe \
--cli target/release/ull.exe
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
@@ -302,10 +230,9 @@ jobs:
fonts-dejavu-core fonts-liberation2
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: ubuntu-latest
# 用 debug 而不是 release:「运行时渲染通路能通」与优化无关,release 的 lto="fat" + codegen-units=1
# 只会把这一步拖到十几分钟。M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
# M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
- run: cargo build --bin unlucky-launcher-iced --locked
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
run: |
@@ -344,8 +271,6 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: macos-latest
- run: cargo build --bin unlucky-launcher-iced --locked
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
run: |
@@ -363,35 +288,3 @@ jobs:
name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
#
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
# (r1 复审 P2-4)。
#
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
#
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
update-e2e:
name: update-e2e (windows x64)
runs-on: windows-latest
timeout-minutes: 45
env:
# 同 test 作业:跨卷 e2e 守卫在没有第二块卷的 runner 上需要显式豁免,否则会判红。
ULL_XDEV_TEST_WAIVE: "1"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: windows-latest
- name: update-e2e
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
-8
View File
@@ -49,14 +49,6 @@ jobs:
name: 架构与合规守门(同 main 口径)
uses: ./.github/workflows/guards.yml
# GPU 证据门禁(M253 r1 P2-1):与 main **同一份** workflow,理由同上 —— 判据只能有一份。
# dev 上 `evidence` 照样显式跳过(无 GPU、无产物,见该文件的 probe job),所以实际拿到的是
# `selftest` 那一半:19 条向量 + 三个退出码 0/1/2 + 接线自检,成本就是一次 python 自测。
# 不另加「只跑自测」的开关:那会给这道门禁多开一个「可以合法关掉真判据」的旋钮。
gpu-evidence:
name: GPU 证据门禁(同 main 口径;无 GPU 只跑自测)
uses: ./.github/workflows/gpu-evidence.yml
fmt:
name: fmt (rustfmt --check)
runs-on: ubuntu-latest
-233
View File
@@ -1,233 +0,0 @@
name: GPU evidence
# GPU 截图证据门禁(M253:把 M249 的 `packaging/check_gpu_shot.py` 接进 CI)。
#
# **为什么新开一个文件,不并进 guards.yml**:guards.yml 已是 399 行、红线 400(`file-size` 那道门
# 就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。这与
# `release-guard.yml` 当初单独成文的理由逐字相同(那份文件头:「那份文件已经 398 行(400 行红线),
# 再挂一个 job 必越线」)。
#
# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。
# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验
# (33 条「改绿」变异逐条重放、10 类封闭清单),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、
# 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」):
# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物;
# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。
# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。
# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示
# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么,
# 并打 `::warning::` 说明「本次没有判过任何真实产物」。
#
# 载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。
# 所以改这道门禁时请连自检一起想:自检只认「上面那张封闭清单」,不宣称「不可能被改成总是绿」。
#
# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。
# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
shot_log:
description: 'stderr 日志路径(需带 ULL_GPU_LOG=info,例 DevDocs/artifacts/gpu-probe/a1-gpu.stderr.log);留空则按下面的约定路径判'
required: false
default: ''
shot_png:
description: '同一运行的 PNG 路径;负例(expect_refuse=true)可留空'
required: false
default: ''
expect_refuse:
description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)'
type: boolean
default: false
# 日常线 `dev` 复用**同一份**判据与接线(r1 P2-1):`dev-guards.yml` 里
# `uses: ./.github/workflows/gpu-evidence.yml`。理由与 guards.yml 的 `workflow_call:` 逐字相同:
# 判据只能有一份,两份各自演进迟早出现「dev 绿、main 红」的双真相;而 `dev-guards.yml` 文件头
# 自己写着「空跑的门禁等于没有」。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的
# 是 `selftest` 那一半:19 条向量 + 三个退出码 + 接线自检,成本就是一次 python 自测。
workflow_call:
jobs:
selftest:
name: GPU 判据自测(不需 GPU;跳过 ≠ 已验证)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)
id: vectors
run: python3 packaging/check_gpu_shot.py --self-test
# 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍:
# 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。
- name: 退出码语义(0 过 / 1 判据不过 / 2 用法错)
id: exitcodes
run: |
set -uo pipefail
work="$RUNNER_TEMP/gpu-shot-exitcodes"
mkdir -p "$work"
# 用自测脚手架现造一份能过的一对(同一份 synth_log/synth_png,避免第二套手工拼装)
python3 - "$work" <<'PY'
import sys
from pathlib import Path
sys.path[:0] = ["packaging", "packaging/tests"]
from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png
root = Path(sys.argv[1])
(root / "pass.stderr.log").write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8")
(root / "pass.png").write_bytes(synth_png("content", SELFTEST_SIZE))
(root / "red.stderr.log").write_text(synth_log(renderer="tiny-skia"), encoding="utf-8")
PY
expect() { # expect <期望退出码> <说明> <命令...>
want="$1"; note="$2"; shift 2
set +e
"$@" > "$work/out.txt" 2>&1
got=$?
set -e
cat "$work/out.txt"
if [ "$got" -ne "$want" ]; then
echo "::error::退出码语义不对:$note 期望 $want,实际 $got。判据脚本的 0/1/2 是整条门禁的接口,变了就是门禁变了。"
exit 1
fi
echo "OK: $note → exit=$got"
}
expect 0 "正例(合成日志 + 合成 PNG,六条判据全过)" \
python3 packaging/check_gpu_shot.py "$work/pass.stderr.log" "$work/pass.png"
expect 1 "CPU 档日志(renderer=tiny-skia,判据点名判红)" \
python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png"
expect 2 "用法错(一个位置参数都没给)" \
python3 packaging/check_gpu_shot.py
- name: 接线自检(33 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring)
id: wiring
run: python3 packaging/tests/gpu_shot_ci_selftest.py
probe:
name: GPU 证据 — 探测可用产物与硬件适配器
runs-on: ubuntu-latest
outputs:
available: ${{ steps.detect.outputs.available }}
log: ${{ steps.detect.outputs.log }}
png: ${{ steps.detect.outputs.png }}
reason: ${{ steps.detect.outputs.reason }}
steps:
- uses: actions/checkout@v4
- name: 探测样例产物与硬件适配器(只探测、不判定)
id: detect
env:
SHOT_LOG: ${{ github.event.inputs.shot_log }}
SHOT_PNG: ${{ github.event.inputs.shot_png }}
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
EVENT: ${{ github.event_name }}
run: |
set -uo pipefail
default_dir=DevDocs/artifacts/gpu-probe
default_stem=gpu-shot
log_path=""
png_path=""
available=false
reason=""
if [ -n "${SHOT_LOG:-}" ]; then
# ① dispatch 显式点了名:**给了却找不到 = 红**。若「明确说要判、东西不在」能退化成跳过,
# 那又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip。
if [ ! -f "${SHOT_LOG}" ]; then
echo "::error::dispatch 指定了 shot_log=${SHOT_LOG},但工作区里没这个文件 —— 显式要求判定的输入缺失按红处理(不跳过)"
exit 1
fi
if [ -n "${SHOT_PNG:-}" ] && [ ! -f "${SHOT_PNG}" ]; then
echo "::error::dispatch 指定了 shot_png=${SHOT_PNG},但工作区里没这个文件 —— 同上,按红处理"
exit 1
fi
if [ "${EXPECT_REFUSE:-false}" != "true" ] && [ -z "${SHOT_PNG:-}" ]; then
echo "::error::正例(expect_refuse=false)必须给 shot_png;只有负例可以省略 PNG"
exit 1
fi
log_path="${SHOT_LOG}"
png_path="${SHOT_PNG:-}"
available=true
reason="dispatch 显式指定产物(${log_path})"
elif [ -f "${default_dir}/${default_stem}.stderr.log" ] && [ -f "${default_dir}/${default_stem}.png" ]; then
# ② 约定路径:一次取证 = 一份干净的 stderr + 同一次运行的那张图
# (`--diff`/拼接会骗过判据,见 gpu-evidence.md §5 第 1/7 条)。
log_path="${default_dir}/${default_stem}.stderr.log"
png_path="${default_dir}/${default_stem}.png"
available=true
reason="工作区里有约定产物(${default_dir}/${default_stem}.*)"
else
# ③ 什么都没有 —— 这是 GitHub 托管 runner 的常态:托管 runner 没有 GPU,
# 而样例产物也不在仓库里(`DevDocs/` 被 .gitignore 忽略,图不进仓库)。
# 必须显式跳过 + 写明原因,不许静默 pass。
if compgen -G '/dev/dri/renderD*' >/dev/null; then
adapter='本机有硬件适配器(/dev/dri/renderD*)'
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
adapter='本机有硬件适配器(nvidia-smi -L 有输出)'
else
adapter='无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi)'
fi
reason="${adapter},且工作区里没有可用的样例产物(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=${EVENT})"
fi
{
echo "available=${available}"
echo "log=${log_path}"
echo "png=${png_path}"
echo "reason=${reason}"
} >> "$GITHUB_OUTPUT"
if [ "${available}" = "true" ]; then
echo "GPU 证据:${reason} —— 交给 evidence job 跑真实产物判据(0 过 / 1 判据不过)"
else
echo "GPU 证据:${reason},跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)"
# warning 而不是 notice:这一趟运行**看起来是绿的**,必须在 UI 上留一条黄条说清它没判过。
echo "::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」"
fi
{
echo "## GPU 证据(M253)"
echo
if [ "${available}" = "true" ]; then
echo "- 结论:**跑真实产物判据**(${reason})"
echo "- 产物:${log_path}"
if [ -n "${png_path}" ]; then echo "- PNG:${png_path}"; else echo "- PNG:无(负例口径 ${EXPECT_REFUSE:-false})"; fi
echo "- 判定看 \`evidence\` job 的退出码(0 过 / 1 判据不过 / 2 用法错);这里是「有东西可判」,不是「判过了」。"
else
echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}"
echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。"
echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。"
echo "- 想让它变成真判定,只有两条**在这个 CI 运行里真能执行**的路:"
echo " ① \`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`,且两个文件必须**已被 checkout 下来**"
echo " —— checkout 只拿已跟踪文件,所以产物得先提交进被 dispatch 的那个 ref;"
echo " ② 把一对产物提交到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`"
echo " 再对那个 ref 跑一次(\`${default_dir}\` 在 .gitignore 里,要用 \`git add -f\`;"
echo " 或改放到任一已跟踪目录,用 ① 的输入指过去)。"
echo " 就地往 workspace 里拷文件(不提交)在 CI 上做不到:那次运行 checkout 的树里没有 DevDocs/。"
fi
} >> "$GITHUB_STEP_SUMMARY"
evidence:
name: GPU 证据 — 真实产物判据
needs: [selftest, probe]
# 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行,UI 上是灰色 skipped,
# 不是一个绿色 ✓。这正是「跳过」与「通过」分开写的地方(r1 复审实核过这条 if 的语义)。
if: needs.probe.outputs.available == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)
id: judge
env:
LOG: ${{ needs.probe.outputs.log }}
PNG: ${{ needs.probe.outputs.png }}
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
run: |
set -uo pipefail
echo "GPU 证据:判 ${LOG}"
if [ "${EXPECT_REFUSE:-false}" = "true" ]; then
if [ -n "${PNG}" ]; then
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse "$PNG"
else
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse
fi
else
python3 packaging/check_gpu_shot.py "$LOG" "$PNG"
fi
+24 -34
View File
@@ -1,16 +1,12 @@
name: Guards
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处)
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91)
#
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
@@ -58,11 +54,7 @@ jobs:
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
exit 1
fi
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -117,9 +109,7 @@ jobs:
exit 1
fi
echo "$tree"
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -179,32 +169,32 @@ jobs:
fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
# M196 加第二处(控制台附着 `AttachConsole`)、M256 加第三处(系统「减少动效」探针
# `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)`;Linux/macOS 两支走子进程、零 unsafe)。
# 三处各自成文件,判据是**集合相等**而不是「黑名单命中」:
# - 任何第四个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
# 判据必须是**集合相等**而不是「黑名单命中」:
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope:
name: unsafe 落点守门(集合必须恰好等于期望)
name: unsafe 落点守门(集合必须恰好一个文件)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
run: |
set -uo pipefail
EXPECTED=$'crates/app-iced/src/platform/motion.rs\ncrates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。
#
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(在它尾部加
# 作用域 allow,r2 门禁 exit 0 假绿)。
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw / 字符字面量),
# 只在「普通态」认 `#`/`#!` 紧跟 `[` 的属性起点,再累计到那一个属性的 `]` 配平。
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
#
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
@@ -217,6 +207,7 @@ jobs:
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
#
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
# 红 #[doc = r#"a "b"#] 后跟真 allow
@@ -233,6 +224,7 @@ jobs:
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
#
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
@@ -326,27 +318,25 @@ jobs:
}
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort || true)
echo "期望(恰好这两处,集合相等):$EXPECTED"
done | LC_ALL=C sort)
echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(不在期望集合里):"
echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
printf '%s\n' "$extra" | sed 's/^/ - /'
fi
if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
fi
echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
exit 1
fi
echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
file-size:
name: 文件行数 ≤400 守门
+61 -213
View File
@@ -1,13 +1,9 @@
name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参
# (`matrix.{slot,os,build_dir,rustflags}` → `inputs.{slot,os,build_dir,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 没有运行时证据,不造没验证过的门禁)。
# **M270 给 `Swatinem/rust-cache` 加了 `key`(= 镜像标签)与 `save-if`(tag run 不写)**:机制与取舍见 `packaging/docs/build-and-ci.md` §3「构建缓存」。
# 本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,唯一的改动是四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。其余 `name` / `if` / `env` / `run` / `uses` 与拆分前逐行相同。
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
@@ -69,71 +65,66 @@ jobs:
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
# (M270)`key:` 必须带**镜像维度**,不是「加个 key 更保险」:v2 的 key = `v0-rust-` + 本行 key + job +
# `-Linux-x64`(os.type/os.arch)+ env 哈希 + 锁哈希 —— 里面**没有镜像标签/glibc**;本 job 与 `ci.yml` 的
# `build` 同 job id、rustc 同版、同一份 `Cargo.lock` ⇒ 曾逐字节相同,`ubuntu-latest`(24.04) 编的 `target/`
# 被恢复到 `ubuntu-22.04`(2.35)。致命处:cargo 的 fingerprint(`.rlib` 的 `-C metadata`)**不含 glibc** ⇒
# 判 fresh 不重编,24.04 编的 `aws-lc-sys` `bcm.o`(引 `__isoc23_sscanf`)在 2.35 上链接 ⇒ undefined。
# 故 key 只取 `inputs.os`:linux-x64(22.04) 与 main 的 `ubuntu-latest` 从此不同 key(要断的就是这条),而
# windows-x64(windows-latest) 与 main 的 `ci/build` **故意同 key** —— 同镜像,借热缓存安全(M270 前 Windows
# 一直走这条且产物是绿的);slot 是冗余维度,每个槽位的 `inputs.os` 本来就互不相同。
# `save-if`:tag run 只能读 main / 同名 tag 的缓存 ⇒ 本侧写出的 target 缓存永远轮不到复用,只贡献冷编 +
# 死缓存 + 挤掉 main 的热缓存。故 tag 不写;留 `ref_type != 'tag'` 这个门是给 main 上 dispatch 预热用的。
- uses: Swatinem/rust-cache@v2
with:
key: ${{ inputs.os }}
save-if: ${{ github.ref_type != 'tag' }}
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
# 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
# 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
# `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
# 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
# M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
# 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
# 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static`
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e——
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出),
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。
- name: Windows ARM64 工具链体检(红不挡发布,只取证)
if: inputs.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
[ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
# ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
Microsoft.VisualStudio.Component.Windows11SDK.26100 \
Microsoft.VisualStudio.Component.Windows11SDK.22621; do
hit=""
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
scan
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
v=""
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done
# ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
done
for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
[ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
for ver in "$root"/*/; do
libs=( "$ver"um/arm64/*.lib )
say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
done
done
# ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
hit=""
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
say notice "link.exe 候选 $p → ${hit:-未命中}"
done
echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
v=""
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
scan
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
exit 1
fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
@@ -141,97 +132,14 @@ jobs:
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
# (M164)arm64 槽位在编译前把工具链**显式**备齐(不再靠「runner 反正自带」):① `rustup target add
# aarch64-pc-windows-msvc`(原生 runner 上幂等 no-op,把「缺 std」挡在编译前);② `LIB`/`INCLUDE` 用微软
# 自己的 `vcvarsall.bat arm64` 取(**不手搓路径**,少一个目录就是 LNK1181)并写进 `$GITHUB_ENV`;
# ③ `link.exe` 由 vswhere 定位后导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 优先)。
# **②必须先于③**:只给链接器不给 LIB 撞的就是 `LNK1181: cannot open input file 'kernel32.lib'`。
# 三道都是「定位不到就打印原因继续」——真缺工具链时让链接器自己报错。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
if [ "$SLOT" = "windows-arm64" ]; then
rustup target add aarch64-pc-windows-msvc
rustup target list --installed
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ -x "$vswhere" ]; then
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 路径直接塞给 `cmd //c "..."`**:MSYS 会把 `"` 改写成
# `\"`,cmd 的 /c 剥离接不住 ⇒ LIB/INCLUDE 全空、只剩 `set` 的空输出(M164 复审实测)。故往
# `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat(引号留在 .bat 的 `call "..."` 里),`cmd //c`
# 只收不含引号的文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
mkdir -p "$tmp"
vcvars=""
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
# 只赌一种就等于再写一个「静默退化成 warning」。
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
[ -n "$vcvars" ] && break
done
dump=""
if [ -n "$vcvars" ]; then
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
{ printf '@echo off\r\n'
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
fi
for key in LIB INCLUDE; do
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
if [ -n "$value" ]; then
export "$key=$value"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
echo "env $key=${value:0:90}…(${#value} 字符)"
else
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
fi
done
found=""
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
[ -n "$found" ] && break
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
[ -n "$hit" ] && found=${hit//\\//}
done
if [ -n "$found" ]; then
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
echo "linker: $found"
else
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
fi
else
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
fi
fi
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
# 产物清单只给 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
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于它进了 PE——
# 资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式版本号、FIXEDFILEINFO
# 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。只在 Windows 槽位跑:Linux/macOS 产物
# 没有 PE 头,那边 `build.rs` 整段不执行(见 `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**。
# 步骤体本身在 `packaging/ci-pe-version-gate.sh`:读回 + 同源取版本号 + 失败逐条转 `::error::` 注解
# (M264 从本文件拆出,给 400 行硬门禁腾地方;判据没动,仍由检查器 exit 1)。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
bash packaging/ci-pe-version-gate.sh "$BUILD_DIR"
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
@@ -284,54 +192,6 @@ jobs:
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: PE 子系统门禁(GUI=WINDOWS_GUI/2,CLI=CONSOLE/3;判据与理由见 packaging/README.md §15)
if: runner.os == 'Windows'
shell: bash
run: |
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py --gui "${{ inputs.build_dir }}/unlucky-launcher-iced.exe" --cli "${{ inputs.build_dir }}/ull.exe"
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,「发出去的包真能开窗渲出一帧」得每轮由 CI 证明
# (证据见 `packaging/docs/linux-evidence.md`)。**只在 linux-x64 跑**:判据按 x64 软渲染实测标定,`linux-arm64`
# 上一次都没验过,写成 `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:
@@ -379,21 +239,9 @@ jobs:
if-no-files-found: error
retention-days: 14
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。M164 起本槽位的红
# **只可能来自真实构建链路**(体检永不 exit 1):红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
- name: arm64 失败收尾注解(红不挡发布)
if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
if: failure() && inputs.slot == 'windows-arm64'
shell: bash
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"
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
-101
View File
@@ -1,101 +0,0 @@
name: Release guard
# (M268)显式 `run-name`:本文件**不是**可复用工作流(`on:` 是 push main / pull_request / workflow_dispatch),
# 每次都在 Actions 列表里单独占一行,所以同样吃「运行名 = 提交信息」这条默认行为——v0.2.4 的发布提交推到 main 时,
# 那段「孤儿提交 / 无父 / 树 = dev …」在这里也会再泄漏一次。PR 事件的 `github.ref_name` 是 `<PR 号>/merge`,
# 贴出来不人读,故按事件分两支:PR 用 `github.event.pull_request.number`(`github` 上下文,run-name 可用且必有值),
# 其余(push main / dispatch)用 `ref_name`(`refs/heads/main` ⇒ `main`,非空)。**`name:` 一字未动。**
run-name: "${{ github.event_name == 'pull_request' && format('Release guard · PR #{0}', github.event.pull_request.number) || format('Release guard · {0}', github.ref_name) }}"
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
#
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
#
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
#
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
# `--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/
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
# 该红的红、该绿的绿。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
release-semantics:
name: 发布标记语义守门(push tag → 稳定版)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 标记表自检(push tag ⇒ 稳定版)
run: python3 packaging/release_marker.py --self-test
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
run: python3 packaging/check_release_semantics.py
- name: 变异自测(守门抓不抓得住回归)
run: python3 packaging/check_release_semantics.py --self-mutation
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
run: python3 packaging/check_publish_slots.py --self-test
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
run: python3 packaging/check_publish_slots.py
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
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
# 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
# M190 r3:`update-test-key` 是**信任锚的开关**(把公开的测试公钥并进信任表)。它只许由
# `crates/presenter` 的 `[dev-dependencies]` 打开;进了发布构建就等于「谁都能签出官方更新」。
# 判据:core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内、
# 其余(workflow / packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红。
- name: 测试公钥守门自检(注入 workflow / dependencies / 根 Cargo.toml 三处变异)
run: python3 packaging/check_test_key_off.py --self-test
- name: 测试公钥不进发布构建(只许 presenter 的 dev-dependencies)
run: python3 packaging/check_test_key_off.py
+44 -119
View File
@@ -1,25 +1,15 @@
name: Release / publish(可复用)
# (M268)**故意不加 `run-name`**:本文件只有 `on.workflow_call`,由 `release.yml` 的 `release` job `uses:` 调用。
# 可复用工作流**不产生自己的 run**——它的 job 挂在调用方那一次 run 里(Actions 列表里只有 `release.yml` 那一行,
# 运行名由 `release.yml` 的 `run-name` 决定),所以这里写 run-name 是死代码,写了也不会显示。同理没有 `name:`
# 改动。真正需要 run-name 的另一处是 `release-guard.yml`(那是独立触发的,会单独占一行)。
#
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
# `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与
# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
@@ -32,14 +22,6 @@ 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:
@@ -53,21 +35,11 @@ jobs:
contents: write
env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# **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::` 明说未发布。
# macOS 两档仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64 linux-x64"
# **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着
# arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据),
# **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步
# README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64"
steps:
- uses: actions/checkout@v4
@@ -76,17 +48,6 @@ jobs:
path: dist
merge-multiple: true
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
shell: bash
run: |
set -euo pipefail
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
@@ -103,76 +64,36 @@ jobs:
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 重算全量 SHA256SUMS
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
# 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
# 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。
for slot in $PUBLISH_SLOTS; do
stem=${slot//-/_}
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
echo "::error::发布白名单缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
shell: bash
env:
TAG: ${{ github.ref_name }}
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
run: |
set -euo pipefail
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
python3 packaging/make_manifest.py --self-test
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
# 后者在 Release 正文里已有,两处各写一份迟早漂。
python3 packaging/make_manifest.py \
--dist dist \
--version "$TAG" \
--channel stable \
--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
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
ls -l dist/manifest.json dist/manifest.json.sig
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
@@ -198,25 +119,29 @@ jobs:
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
# 三种触发各自创建什么(那张表是唯一真相源):
# push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
# dispatch prerelease=true → create / edit 都带预发布标记
# dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
# 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
# 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
# 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
# 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
# 进程替换里的失败会被 `set -e` 吞掉。
marks=/tmp/release-marks.sh
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
source "$marks"
echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 github.event.inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# `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)
assets=(dist/unlucky-launcher_* dist/ull_* 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
+21 -61
View File
@@ -1,43 +1,20 @@
name: Release
# (M268)显式 `run-name`:**不写这一行,GitHub 就把提交信息当运行名**贴到 Actions 列表上——公开界面曾经出现
# 「release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)」,把 D41 快照模型的实现术语泄漏给所有人。
# 取值面已核(文档:`run-name` 可用 `github` / `inputs` / `vars`,无特殊函数限制):
# push tag `v0.2.4` ⇒ `github.ref = refs/tags/v0.2.4` / `ref_type = tag` ⇒ `Release v0.2.4`
# dispatch 在 tag ref 上 ⇒ 同上(`gh workflow run release.yml --ref v0.2.4`)⇒ `Release v0.2.4`
# dispatch 在分支 ref 上 ⇒ `github.ref_name = main` / `ref_type = branch` ⇒ `Release 试跑 main(分支 ref,不发布)`
# 两种 ref 面上 `ref_name` 都非空(`ref` 一定存在),故不需要兜底分支。分支 ref 单独成一种写法是因为它被
# `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外——一律写 `Release main` 会让人以为 main 上发了版。
# **只新增 run-name,`name:`(工作流标识符)一字未动**:改名会断掉历史 run 与分支保护/规则的匹配。
run-name: "${{ github.ref_type == 'tag' && format('Release {0}', github.ref_name) || format('Release 试跑 {0}(分支 ref,不发布)', github.ref_name) }}"
# 发布流水线: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::` 明说未发布。
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 +
# SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试
# 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都
# 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
#
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
# cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build /
# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on:
@@ -47,17 +24,13 @@ on:
workflow_dispatch:
inputs:
all_slots:
description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows"
type: boolean
default: false
prerelease:
# 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上——
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: false
default: true
env:
CARGO_TERM_COLOR: always
@@ -74,7 +47,7 @@ jobs:
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
plan:
name: 选槽位(默认四个:Windows 两档 + Linux 两档)
name: 选槽位(默认两个 Windows)
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
@@ -94,25 +67,16 @@ jobs:
SLOTS = [
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
# **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
# 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费;GitHub 文档的 Standard runners 也列
# `windows-11-vs2026-arm`)。M140 前是在 windows-latest 上交叉,**CI 从未成功过一次**(v0.2.1 首跑红在
# 工具链体检,M141/M142 在此修)。原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
rustflags="-C target-feature=+crt-static", mode="ci"),
# ↓ 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。
# ↓ 四档**未验证,不纳入 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"),
# 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"),
@@ -149,9 +113,8 @@ jobs:
release:
name: GitHub Release
needs: build
# `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
# 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
# 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
@@ -161,6 +124,3 @@ 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 }}
-121
View File
@@ -1,121 +0,0 @@
name: Signing status(播报,不是门禁)
# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。**
# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的),
# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。
#
# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION`
# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
#
# 口径四条(由 `packaging/tests/signing_ready_wiring_selftest.py` 的接线判据机器校验,不是靠注释自律):
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。
# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 ——
# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow,
# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。
# ④ **生产签名种子只在 `refs/heads/main` 上注入**,且**没有 `pull_request` 触发**(M255 r1 P2-1)。
# 这条是本文件唯一的**安全**口径,别为了「PR 上也能看读数」把它放开:
# - 仓库内分支的 PR / 任意分支的 dispatch **拿得到**仓库 Secret(只有 fork / Dependabot 拿不到);
# - 而这把种子能给**全量已安装客户端**签出被信任的清单(自更新链 = 用户端 RCE),
# 既有设计把它锁在 tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`)。
# - 播报**不需要**在 PR 上跑:PR 改了 `check_signing_ready.py` 时,CI 会在那个 PR 自己的代码上执行它,
# 若同时注入种子,等于把种子交给任何能推分支的人。
# 门由两行 env 表达(同一个条件、同一个地方可审):钥给 main,非 main 则给 `ULL_SIGNING_KEY_WITHHELD`
# 一个原因串 —— 于是 ② 读成「未判定」而不是「没配」(把策略说成缺配是一次假读数)。
#
# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头;
# 人类要做的那几步:`packaging/docs/signing-runbook.md`。
on:
push:
branches: [main]
# ⚠ 刻意**没有** `pull_request`(见口径④)。要读 PR 的签名就绪度,就跑 push 到分支或对着 main dispatch。
workflow_dispatch:
permissions:
contents: read
jobs:
# 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。
# 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、
# 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。
selftest:
name: 判据自测(三行/退出码/变异重放/接线)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据本体(三行 / 0/1/2 / 两向退出码变异 / --key 用法错 / 指针章节)
run: python3 packaging/tests/signing_ready_selftest.py
# 接线单独一份(400 行红线):播报不是门禁、**生产种子只在 main 注入**、文档指针真的存在,
# 以及「去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红」三处变异重放。
- name: 接线(ref 门 / 不被别的 workflow 复用 / 无 error 级注解 / 文档路径存在性 + 三处变异)
run: python3 packaging/tests/signing_ready_wiring_selftest.py
- name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错)
run: |
set -uo pipefail
work="$RUNNER_TEMP/signing-ready"
mkdir -p "$work"
# 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」,
# 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。
run_and_show() { # run_and_show <说明> <命令...>
note="$1"; shift
echo "── $note"
python3 packaging/check_signing_ready.py "$@" 2>&1 || true
echo
}
run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)"
# 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。
python3 - "$work" <<'PY'
import sys
from pathlib import Path
trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8")
begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>")
end = trust.index("<<< PINNED-KEYS <<<")
fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n'
Path(sys.argv[1], "trust-with-fake-key.rs").write_text(
trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8"
)
PY
run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \
--trust "$work/trust-with-fake-key.rs"
run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true
# 播报:只把三行搬进 job summary,不看颜色。
status:
name: 签名就绪度(播报)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ② 要读「环境里有没有钥」,所以把 Secret 传进来 —— **只在 main ref 上**(口径④)。
# 门是下面这两行 env(同一个条件),不是 `if:`:这样非 main 的运行照样跑完三行读数,
# 只是 ② 读成「未判定」,而不是整个 job 被跳过后一片空白。
# ⚠ 这两行是安全边界,接线自检钉着它们(去掉 ref 门 / 加回 pull_request 都会当场红)。
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
env:
ULL_MANIFEST_SIGNING_KEY: ${{ github.ref == 'refs/heads/main' && secrets.ULL_MANIFEST_SIGNING_KEY || '' }}
ULL_SIGNING_KEY_WITHHELD: ${{ github.ref == 'refs/heads/main' && '' || '非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)' }}
run: |
set -uo pipefail
set +e
python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1
rc=$?
set -e
cat "$RUNNER_TEMP/signing-ready.txt"
case "$rc" in
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
1) echo "::warning::签名未就绪(或 ② 未判定):该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
esac
{
echo
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
echo "- **Secret 只在 \`refs/heads/main\` 上注入**(生产签名种子锁在 main / tag 后面);非 main ref 的 ② 显示「未判定」而不是「没配」。"
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
-5
View File
@@ -30,8 +30,3 @@ __pycache__/
# OS
Thumbs.db
.DS_Store
# 签名私钥:绝不入库(M255 手册产出的本地副本)
.signing/
**/signing-secret*.json
**/ull-signing/
-82
View File
@@ -1,82 +0,0 @@
# 更新日志
> 自上而下由新到旧。版本号与 `Cargo.toml` 同源;二进制 `unlucky-launcher --version` 打同一个号。
> 只记**对外可感知的变化**(用户能看到、能用到、能感觉到稳的),不记内部重构流水账。
---
## 0.2.5 — 当前版本
> 0.2.4 打过 tag 但**发布流水线全红、没有生成发布包**,其内容整体并入本版。所以本版是 0.2.4 + 下面这些。
**动效(本版主线)**
- **切页有方向了**:同级页按导航栏的上下顺序左右移动,进详情页/返回按层级纵深移动;旧页同时反向退场,
不是「只挪新页」那种一闪而过
- **对话框改成绕中心缩放**(0.96→1.0)+ 背景渐暗,不再是硬切;关闭时反向
(此前实现把缩放的平移项写反了,实际是「从左上斜滑进来」,本版修正)
- **跟随系统「减少动效」设置**:系统关掉动画时全站动效归零(首次真正接上系统偏好,此前只有环境变量入口)
- 过渡中途再次切页会干净接管,不会叠三层或卡住;过渡结束后不再保留上一页
**发布与构建**
- 修复「英文系统上构建校验误报失败」:检查器把中文打到非 UTF-8 输出流会抛异常,判据其实全过
- 修复「旧版系统上链接失败」:构建缓存跨系统串用,导致用新系统编译的中间产物在旧系统上链接
- 修复一处只在新系统上才暴露的编译告警(Windows 侧永远看不到)
- 发布工作流的运行名改为固定格式(不再显示提交信息原文)
**稳定性**
- 三条「只在特定平台/特定时序才红」的测试改成确定性判据:路径写法、全局状态竞争、文件时间戳精度
- 修复一处会让 Windows 桌面闪黑框的子进程启动(动效探针未走静默启动)
---
## 0.2.4 — 已并入 0.2.5(原 tag 发布失败)
这一批的主线是「把存档区做成真正能用」,以及把验证方式从「看起来对」改成「能被证伪」。
**存档**
- 存档列表可重命名(行内输入,回车即提交)、删除(二次确认)、备份导出为 zip
- **游戏运行时写操作被拦下并给出明确原因**——此前在 Linux 上删一个正在跑的世界会成功
- 截图区从「一行文件名」变成**缩略图墙**:真解码、最大边 160px、单次最多 60 张、单张超 8 MiB 跳过;「未生成预览」与「文件过大」是两种不同的格子,失败逐条可见
**外观**
- **强调色自定义真的生效**:填了色,按钮 / 进度条 / 图标跟随;中性底与描边不动(既定语义)
- 非法色值给红框 + 结构化原因,且**不写盘**;手打过程中不闪红,离开输入格或按回车才判
- 三处组件配色改走主题角色(描边、进度轨道、Elevated 底色),不再对着设计稿比色值
**交互**
- 单行输入框支持**回车提交**:存档重命名、强调色两处接上;未接的既有调用点行为不变
**发布与验证**
- GPU 截图证据门禁接进 CI:无 GPU 时**显式跳过**,「跳过」与「通过」分开呈现,不会伪装成已验证
- 新增**签名就绪度**播报与操作手册:一条命令看清「签名还差什么」;播报只播报,不是门禁
- 修掉一处会让 CI 把**生产签名种子**交给 PR 代码的接线(仓库内分支的 PR 拿得到仓库 Secret)
- 合并态全量测试在每次合并后的 tip 上重跑,不再只看定向验证
**已知问题**
- **自动更新链路是断的**:正式签名信任表为空,任何发布二进制都验不过(fail-closed)。通道本身已接到底,缺的是「一把被钉住的公钥 + 一份签名 Release」
- Ink 主题下 Elevated 卡片层级几乎看不出(对比约 1.03:1)
- Windows 路径在界面上仍显示正斜杠
- macOS 两档构建仍是 `probe`,从未真构建、也没在真机跑过
---
## 0.2.3 — 2026-11-08
- 首次进入**稳定更新通道**(`latest` 指向本版;此前的版本只在预览通道)
- 断点与续传、镜像选路、崩溃弹窗与现场日志导出等前序能力在本版收敛完成
> 注:`main` 采用孤儿快照发布,`v0.2.3..HEAD` 之类的区间日志不可用;本条目按发布记录与 README 口径编写。
---
## 0.2.1 — 2026-11-06
- 早期公开版本:安装、启动、日志与设置四项主流程可用
- 具体条目从简(发布记录以 tag 为准)
---
## 0.2.0 — 2026-11-05
- 首个公开预览版本
- 具体条目从简(发布记录以 tag 为准)
Generated
+8 -170
View File
@@ -368,12 +368,6 @@ 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"
@@ -666,12 +660,6 @@ 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"
@@ -858,33 +846,6 @@ 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"
@@ -931,16 +892,6 @@ 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"
@@ -1026,30 +977,6 @@ 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"
@@ -1167,12 +1094,6 @@ 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"
@@ -3166,16 +3087,6 @@ 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"
@@ -3421,15 +3332,6 @@ 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"
@@ -4223,15 +4125,6 @@ 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"
@@ -4295,15 +4188,6 @@ 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"
@@ -4504,16 +4388,6 @@ 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"
@@ -4864,21 +4738,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
@@ -4909,12 +4768,6 @@ 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"
@@ -5129,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "unlucky-agent"
version = "0.2.5"
version = "0.2.1"
dependencies = [
"axum",
"futures-util",
@@ -5144,7 +4997,7 @@ dependencies = [
[[package]]
name = "unlucky-app-iced"
version = "0.2.5"
version = "0.2.1"
dependencies = [
"iced",
"image",
@@ -5154,26 +5007,23 @@ dependencies = [
"unlucky-design",
"unlucky-i18n",
"unlucky-presenter",
"winresource",
]
[[package]]
name = "unlucky-cli"
version = "0.2.5"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",
"tokio",
"unlucky-agent",
"unlucky-core",
"winresource",
]
[[package]]
name = "unlucky-core"
version = "0.2.5"
version = "0.2.1"
dependencies = [
"ed25519-dalek",
"flate2",
"futures-util",
"md-5",
@@ -5191,15 +5041,15 @@ dependencies = [
[[package]]
name = "unlucky-design"
version = "0.2.5"
version = "0.2.1"
[[package]]
name = "unlucky-i18n"
version = "0.2.5"
version = "0.2.1"
[[package]]
name = "unlucky-palette-extract"
version = "0.2.5"
version = "0.2.1"
dependencies = [
"image",
"unlucky-design",
@@ -5207,13 +5057,11 @@ dependencies = [
[[package]]
name = "unlucky-presenter"
version = "0.2.5"
version = "0.2.1"
dependencies = [
"ed25519-dalek",
"serde",
"serde_json",
"unlucky-core",
"unlucky-palette-extract",
]
[[package]]
@@ -6120,16 +5968,6 @@ 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"
+1 -8
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"]
[workspace.package]
version = "0.2.5"
version = "0.2.1"
edition = "2021"
license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"]
@@ -71,13 +71,6 @@ 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"
+30 -129
View File
@@ -4,8 +4,6 @@
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.4`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
@@ -45,14 +43,6 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID.
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
and every field falls back on its own: instance → global setting → factory default, so an instance
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
the backend row only appears for game versions that have it. The CLI and the GUI read the same
resolved values (`crates/core/src/game/launch_overrides.rs`).
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
@@ -66,17 +56,8 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
- **Import conflicts are decided per file** — before anything is written, the scan lists every
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
that do not conflict are never rewritten, and the report still names every conflict afterwards.
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*.
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
the target instance (project type, game version, loader); project details and version lists follow,
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
time. Being offline is its own state — "not connected" — never "no matching content".
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
library (packs go through the import pipeline above); CurseForge's *install* is disabled because
its file endpoint is not wired; and an item whose game version or loader does not match the target
@@ -179,101 +160,42 @@ The dispatch rule is deliberately narrow, so nothing that used to work changed m
that its typo took effect. The subcommand table lives in exactly one place
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `update`, `agent`, `version`, `help`.
### Updates, and the stable channel
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
`ull update check` / `ull update apply` both build the same `core::update::Job`
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
*renamed* but not overwritten, which is why the swap is a rename.
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
no artifact for this platform / the check itself failed (offline, broken manifest, channel
mismatch). A failed check is never reported as "up to date".
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
- **Nothing installs itself behind your back.** The check at startup can be turned off
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
### Where the logs are
Everything the launcher writes on its own side lives under `<data root>/logs/`. The settings page has
a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has
*Open folder* as well. The data root is `<exe folder>/.ULL` in portable mode, `<per-user app data>/ULL`
in standard mode, and `<custom root>/.ULL` in custom mode — the same value the settings page displays
as the launcher data directory.
| Path | What it is |
|---|---|
| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, **the named reason when a render backend fails to start**, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. |
| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. |
| `logs/game/<session-id>.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. |
| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. |
| `logs/install.log` | Install pipeline journal, one line per stage. |
| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. |
| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. |
The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`,
`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long
enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft
initialises, the game never gets that far, and the honest evidence is the session JSONL plus
`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is
hiding.
`diag`, `config`, `java`, `agent`, `version`, `help`.
### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer.** You download a file and run it — there is no setup step, and updates replace that
file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)).
- **The update path has only been exercised against a local directory source.** The whole chain
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
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.
- **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a
newer version for you, and there is no update source decided yet.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. In the settings frame: *Save*, *Reset
*Open folder*, *Delete*. On the logs page: *Open folder*. 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.
- **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.
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
been downloaded and run end to end. 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.
- **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
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
translate — which is exactly the `静默失败` you can see in the English screenshot above.
- **The in-app assistant has no model behind it** (see above). The MCP server does.
- **Pack import has two global policies**, keep-user or overwrite, not a per-file choice; the import
report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install path.
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
[`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only.
## Download and run
@@ -285,28 +207,20 @@ Grab the file for your platform from
| Slot | Asset | Shape |
|------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **built, not published**, see below |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **built, not published** (no runtime evidence yet) |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not built** by the release workflow |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow |
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
**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.
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
The row stays because the architecture is still supported and the slot still builds (unpublished) in
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
only downloadable Windows asset.
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
@@ -314,18 +228,6 @@ 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_<version>_linux_x64.tar.gz
cd unlucky-launcher_<version>_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_<version>_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
@@ -392,8 +294,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 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 |
| `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
## Architecture and module map
+25 -112
View File
@@ -4,8 +4,6 @@
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
@@ -35,11 +33,6 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
@@ -51,15 +44,8 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。
@@ -143,88 +129,35 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
`agent`、`version`、`help`。
### 自更新与稳定通道
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
渠道不符)。失败绝不会被说成「已是最新」。
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 日志在哪
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
![设置页「通用」分组:日志文件夹一行,显示 D:/ULL-Demo/.ULL/logs,旁边是「打开目录」与「复制路径」两个按钮,下面是解释文案](docs/images/logs-folder-zh.png)
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
数据根按三种模式取值:便携是 `<exe 目录>/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是
`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。
| 路径 | 是什么 |
|---|---|
| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择、**渲染后端起不来时的具名原因**,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 |
| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 |
| `logs/game/<session-id>.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 |
| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 |
| `logs/install.log` | 安装流水:每个阶段一行。 |
| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 |
| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 |
游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、
`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏
弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与
`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、
`version`、`help`。
### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
(见[自更新与稳定通道](#自更新与稳定通道))。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → 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`),而且同一条判据已经常驻成发布流水线里
**这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。
「整窗透明度」与「圆角」这两条在 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`
能编这两档取证,但它们从不发布。
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
「矩阵里有就算支持」。
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 `workflow_dispatch`
的探针、带 `continue-on-error`,不是门禁。
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
真正拿到的高度。
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),目前只在 CPU 渲染下验证过一次。
## 下载与运行
@@ -235,37 +168,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
| 槽位 | 产物 | 形态 |
|------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `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 文件,独立资产 |
| `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` | 同上 |
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
**本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
**不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`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` 连显示都不需要。
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -326,8 +240,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-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`,守门会对自己做变异自测 |
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
## 架构与模块地图
+3 -15
View File
@@ -1,6 +1,6 @@
# Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 分支:`termux-glibc`(基于 `origin/dev`,14 个提交,HEAD `38d3024`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR
@@ -97,11 +97,8 @@
## 合并指南
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
1. `git fetch origin && git rebase origin/dev`(分支基于 `7d8b3b9` 之后的 dev;
dev 若前进,先 rebase 再推)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败:
@@ -110,15 +107,6 @@
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
所以在 Termux 上跑门禁看不出来):
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
行为不变、用例原样留在原处。
## 验证矩阵(本机)
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies]
unlucky-core = { path = "../core", version = "0.2.5" }
unlucky-core = { path = "../core", version = "0.2.1" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。
+1 -1
View File
@@ -45,7 +45,7 @@ pub use chat::{
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
};
pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
pub use provider::{ProviderConfig, ProviderError, ProviderKind};
/// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8;
+86 -70
View File
@@ -14,11 +14,6 @@ use std::fmt;
use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。
@@ -59,35 +54,6 @@ impl ProviderKind {
}
}
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
///
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
/// 未知值给 `None`(由调用方回落默认,不猜)。
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim() {
"openai_compatible" => Some(Self::OpenAiCompatible),
"anthropic" => Some(Self::Anthropic),
"ollama" => Some(Self::Ollama),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
#[must_use]
pub fn setting_value(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 设置页展示名。
#[must_use]
pub fn display_name(self) -> &'static str {
@@ -208,22 +174,18 @@ impl ProviderConfig {
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
return Err(ProviderError::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
));
}
if self.model.trim().is_empty() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
return Err(ProviderError::new("没有配置模型名(model)"));
}
if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
format!("{} 需要填写端点(base_url)", self.kind.display_name()),
));
return Err(ProviderError::new(format!(
"{} 需要填写端点(base_url)",
self.kind.display_name()
)));
}
if self.kind.requires_api_key()
&& self
@@ -231,10 +193,10 @@ impl ProviderConfig {
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
format!("{} 需要 API key", self.kind.display_name()),
));
return Err(ProviderError::new(format!(
"{} 需要 API key",
self.kind.display_name()
)));
}
Ok(())
}
@@ -277,38 +239,20 @@ impl fmt::Debug for ProviderConfig {
}
}
/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
/// `ErrorReport` 翻译,见 [`failure`])。
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError {
kind: ProviderFailure,
message: String,
}
impl ProviderError {
/// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
/// 从可读文本构造。
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
kind: ProviderFailure::Other,
message: message.into(),
}
}
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 失败分类(「测试连接」按它分档)。
#[must_use]
pub fn kind(&self) -> ProviderFailure {
self.kind
}
}
impl fmt::Display for ProviderError {
@@ -320,5 +264,77 @@ impl fmt::Display for ProviderError {
impl std::error::Error for ProviderError {}
#[cfg(test)]
#[path = "provider/tests.rs"]
mod tests;
mod tests {
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
@@ -1,62 +0,0 @@
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
//!
//! ## 为什么不是字符串匹配
//!
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
//! 一度偶发红)。
//!
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
//!
//! ## 六档口径
//!
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
//! |---|---|---|
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderFailure {
/// 配置不完整:**没有发出任何请求**。
NotConfigured,
/// 鉴权失败(401 / 403 / 凭据被拒)。
Auth,
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound,
/// 端点不可达(传输层失败,没拿到任何回应)。
Unreachable,
/// 超时(调用方时限或传输层超时)。
Timeout,
/// 其它(协议 / 解析 / 5xx / 未知)。
Other,
}
impl ProviderFailure {
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfigured => "not_configured",
Self::Auth => "auth",
Self::ModelNotFound => "model_not_found",
Self::Unreachable => "unreachable",
Self::Timeout => "timeout",
Self::Other => "other",
}
}
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
#[must_use]
pub const fn sent_a_request(self) -> bool {
!matches!(self, Self::NotConfigured)
}
}
-107
View File
@@ -1,107 +0,0 @@
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
//!
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
+83 -60
View File
@@ -47,7 +47,7 @@ use std::sync::Arc;
use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::{ErrorKind, ProviderError as RigError};
use rig_core::error::ProviderError as RigError;
use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
@@ -64,7 +64,7 @@ use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture,
};
use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0";
@@ -275,63 +275,9 @@ fn to_reply(
})
}
/// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` /
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
///
/// | rig 侧信号 | 本方分类 |
/// |---|---|
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
/// rig 错误 → 本方错误(文案面向用户)。
fn map_error(error: RigError) -> ProviderError {
let report = error.report();
let kind = match report.kind {
ErrorKind::Timeout => ProviderFailure::Timeout,
ErrorKind::Http => {
if transport_timed_out(&error) {
ProviderFailure::Timeout
} else {
ProviderFailure::Unreachable
}
}
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
Some(401 | 403) => ProviderFailure::Auth,
Some(404) => ProviderFailure::ModelNotFound,
_ if matches!(
report.code.as_deref(),
Some("model_not_found" | "model_not_found_error")
) =>
{
ProviderFailure::ModelNotFound
}
_ => ProviderFailure::Other,
},
_ => ProviderFailure::Other,
};
ProviderError::classified(kind, report.message)
}
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
///
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
/// TLS)都归 `Unreachable`。
fn transport_timed_out(error: &RigError) -> bool {
let RigError::Http(http_error) = error else {
return false;
};
let mut source = std::error::Error::source(http_error);
for _ in 0..8 {
let Some(cause) = source else { return false };
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
ProviderError::new(error.to_string())
}
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -341,5 +287,82 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
}
#[cfg(test)]
#[path = "rig/tests.rs"]
mod tests;
mod tests {
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}
-82
View File
@@ -1,82 +0,0 @@
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
//!
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
.is_ok()
);
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
+5 -18
View File
@@ -22,15 +22,15 @@ path = "src/lib.rs"
[dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.5" }
unlucky-design = { path = "../design", version = "0.2.1" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.5" }
unlucky-core = { path = "../core", version = "0.2.1" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.5" }
unlucky-presenter = { path = "../presenter", version = "0.2.1" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -60,11 +60,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.5" }
unlucky-cli = { path = "../cli", version = "0.2.1" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.5" }
unlucky-i18n = { path = "../i18n", version = "0.2.1" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
@@ -90,19 +90,6 @@ iced = { version = "=0.14.0", default-features = false, features = [
default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"]
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
# ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 \
# cargo run -p unlucky-app-iced --features update-test-source
# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里
# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。
# 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。
update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"]
[lints]
workspace = true
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
-14
View File
@@ -1,14 +0,0 @@
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
//!
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
//!
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("unlucky-launcher-iced");
}
-261
View File
@@ -1,261 +0,0 @@
//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。
//!
//! ## 四段(与 presenter 的 `AdoptState` 一一对应)
//!
//! | 段 | 画什么 | 从哪来 | 文件 |
//! |---|---|---|---|
//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] |
//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] |
//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] |
//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] |
//!
//! 「现在画哪一段」的判据只有一处:presenter 的 `AdoptState::stage`,外面套一条收窄
//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由:
//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换;
//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。
//!
//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`],
//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。
//!
//! ## 三条纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀,
//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。
//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes`
//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录),
//! 真正的后果原文在确认按钮之前一定会出现。
//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。
//!
//! ## 为什么是「盖一层」而不是实例页里的一段
//!
//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、
//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是**
//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。
//!
//! ## 表单字段归 `App`,事件真相归 presenter
//!
//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程);
//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半,
//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。
mod panes;
mod pick;
mod shell;
/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。
pub(crate) mod shot;
mod text;
#[cfg(test)]
mod tests;
use iced::widget::{container, mouse_area, opaque, Space};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView,
};
use crate::app::components::feedback::dialog::scrim;
use crate::theme::Ui;
use super::message::Message;
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
const CARD_WIDTH: f32 = 640.0;
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
const CARD_MAX_HEIGHT: f32 = 620.0;
/// 正在等哪一条命令的回程。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Busy {
/// 空闲(可以点)
#[default]
Idle,
/// 正在扫描 `versions/`
Scanning,
/// 正在预检(零写盘)
Checking,
/// 正在真正登记
Importing,
}
impl Busy {
/// 有没有在等回程。
#[must_use]
pub const fn waiting(self) -> bool {
!matches!(self, Self::Idle)
}
}
/// 向导的表单态(**归 `App`**,页面借一帧)。
#[derive(Debug, Default)]
pub struct Form {
/// 面板开着吗
pub open: bool,
/// 选中的版本 id(`None` = 还没选)
pub selected: Option<String>,
/// 展示名(空 = 用版本 id)
pub name: String,
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
pub target: AdoptTargetView,
/// 在等在飞的那一条命令
pub busy: Busy,
/// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。
///
/// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的
/// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次
/// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的
/// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。
pub checked: bool,
}
impl Form {
/// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。
pub fn open(&mut self) {
*self = Self {
open: true,
busy: Busy::Scanning,
..Self::default()
};
}
/// 关掉面板(全清)。
pub fn close(&mut self) {
*self = Self::default();
}
/// 回程到了:不在等任何东西了。
pub fn settled(&mut self) {
self.busy = Busy::Idle;
}
/// 当前该画哪一段:`Preview` 只在用户**点过预检**之后才算数,其余按 `AdoptState::stage()`。
///
/// 这条收窄是为了「点选共享档 ⇒ 立刻看到后果原文」而不把用户直接甩到确认页:
/// 自动预检的结果落在 Pick 段里当**提示**读,用户点「预检」之后才进确认段。
#[must_use]
pub fn stage(&self, adopt_state: &AdoptState) -> AdoptStage {
match adopt_state.stage() {
AdoptStage::Preview if !self.checked => AdoptStage::Pick,
other => other,
}
}
/// 后台算出来、且**对得上当前选择**的预检(对不上 = 过期的结果,一律不画)。
///
/// 两个判据缺一不可:
/// - **目标档一致**:改了档之后旧 plan 的 `notes` 说的是别的目录;
/// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录
/// (版本目录档的后果原文里带 `versions/<id>`,拿它当新版本的后果就是撒谎)。
///
/// 没有选中版本时也返回 `None`:那一刻没有任何版本可言,画谁的预检都不成立。
/// 这两条都不是「选中即重扫」带来的——`Select` 只改本地字段、不发命令(落点见
/// `app/update/adopt.rs`),所以过期判定只能在这里做。
#[must_use]
pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> {
let selected = self.selected.as_deref()?;
adopt_state
.plan
.as_ref()
.filter(|plan| plan.target == self.target && plan.version_id == selected)
}
/// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。
#[must_use]
pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> {
let id = self.selected.as_deref()?;
adopt_state
.scan
.as_ref()?
.candidates
.iter()
.find(|candidate| candidate.version_id == id)
}
}
/// 向导消息(`crate::app::Message::Adopt` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdoptMessage {
/// 打开向导(实例页页头「导入」)
Open,
/// 关掉(点遮罩 / 关闭按钮)
Close,
/// 选中一个候选版本
Select(String),
/// 改展示名
Name(String),
/// 改运行目录档
Target(AdoptTargetView),
/// 预检(零写盘)
Check,
/// 确认登记(唯一写盘入口)
Confirm,
/// 从 Preview / Failed 退回候选列表(等价于重新扫一次)
Back,
}
/// 向导消息 → app 消息。
fn adopt(message: AdoptMessage) -> Message {
Message::Adopt(message)
}
// --------------------------------------------------------------- 视图
/// 把向导叠在页面之上(没打开时原样返回页面树)。
pub(super) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
form: &'a Form,
adopt_state: &'a AdoptState,
lang: Lang,
) -> Element<'a, Message> {
if !form.open {
return base;
}
let catalog = lang.catalog();
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(scrim(ui))),
..container::Style::default()
}),
)
.on_press(adopt(AdoptMessage::Close));
// 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。
let centered = container(opaque(shell::card(ui, form, adopt_state, catalog)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 中间区(按段交给 `pick` / `panes`)。
pub(super) fn middle<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
match stage {
AdoptStage::Pick => pick::pick(
ui,
form,
adopt_state.scan.as_ref(),
form.fresh_plan(adopt_state),
catalog,
),
AdoptStage::Preview => panes::preview(ui, form.fresh_plan(adopt_state), catalog),
AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog),
AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog),
}
}
-127
View File
@@ -1,127 +0,0 @@
//! Preview / Failed / Done 三段。
//!
//! | 段 | 画什么 | 判据 |
//! |---|---|---|
//! | Preview | 生效运行目录(**回程给的**)+ 隔离档 + `notes` 原文逐条 | `AdoptEvent::Planned` |
//! | Failed | 稳定码 + `problems[]` 逐条 | `AdoptEvent::Failed` |
//! | Done | 实例名 + 运行目录 + `notes` 原文逐条 | `AdoptEvent::Done` |
//!
//! 三段都不自己算路径、不自己写后果句:`run_dir` 与 `notes` 是 core 的返回值原样。
//! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道**
//! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView};
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{field_line, isolation_label, mono, problem_line, wrapped};
/// 预检结果:落点 + 隔离档 + 后果原文。
pub(super) fn preview<'a>(
ui: &'a Ui,
plan: Option<&'a AdoptPlanView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(plan) = plan else {
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![
field_line(ui, catalog.text(Key::AdoptInstanceName), &plan.name),
field_line(
ui,
catalog.text(Key::AdoptPlanRunDir),
&plan.run_dir.display().to_string()
),
field_line(
ui,
catalog.text(Key::AdoptPlanIsolation),
&isolation_label(plan.isolation, catalog),
),
]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
// 后果话术:core `target_notes()` 的原文,一条一行、一个字不改
for note in &plan.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
body.into()
}
/// 失败:稳定码 + **problems 逐条**。
pub(super) fn failed<'a>(
ui: &'a Ui,
failure: Option<&'a AdoptFailureView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(failure) = failure else {
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![mono(ui, failure.code.clone())]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
if failure.problems.is_empty() {
body = body.push(wrapped(
ui,
failure.message.clone(),
ui.color(Role::OnSurfaceVariant),
));
return body.into();
}
body = body.push(wrapped(
ui,
catalog.text(Key::AdoptProblemCount).to_string(),
ui.color(Role::OnSurfaceVariant),
));
for problem in &failure.problems {
body = body.push(problem_line(ui, problem));
}
body.into()
}
/// 完成:实例名 + 运行目录 + 提示原文。
pub(super) fn done<'a>(
ui: &'a Ui,
done: Option<&'a AdoptDoneView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(done) = done else {
return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant));
};
let mut body = column![
field_line(ui, catalog.text(Key::AdoptInstanceName), &done.name),
field_line(
ui,
catalog.text(Key::AdoptPlanRunDir),
&done.run_dir.display().to_string()
),
field_line(
ui,
catalog.text(Key::AdoptPlanIsolation),
&isolation_label(done.isolation, catalog),
),
]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
for note in &done.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
body.into()
}
-228
View File
@@ -1,228 +0,0 @@
//! Pick 段:候选列表 + 目标档 + 实例名。
//!
//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来
//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。
//!
//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色);
//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。
use iced::widget::{button, column, container, row};
use iced::{Alignment, Background, Border, Color, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{label, problem_line, target_label, wrapped};
use super::{adopt, AdoptMessage, Form};
/// Pick 段整体。
pub(super) fn pick<'a>(
ui: &'a Ui,
form: &'a Form,
scan: Option<&'a AdoptScanView>,
plan: Option<&'a AdoptPlanView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(scan) = scan else {
// 扫描还没回来:一行进度文案(不做假进度条)
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![].spacing(ui.space(Sp::X3)).width(Length::Fill);
if scan.is_empty() {
let dir = scan.versions_dir.display().to_string();
body = body.push(wrapped(
ui,
catalog.render(Key::AdoptEmpty, &[("dir", &dir)]),
ui.color(Role::OnSurfaceVariant),
));
} else {
body = body.push(label(ui, catalog.text(Key::AdoptSelectLabel)));
body = body.push(candidate_list(ui, form, scan, catalog));
}
body = body.push(frame::divider(ui).map(Message::Page));
body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel)));
body = body.push(target_row(ui, form, catalog));
// 后果原文(契约 §5.2):共享档 / 版本目录档一被点选就显示 core `target_notes()` 的原文——
// 这一条来自**零写盘**的自动预检(见 `AdoptMessage::Target` 的落点),不是界面另写的一句。
if let Some(plan) = plan {
for note in &plan.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
}
body = body.push(text_field(
ui,
Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)),
|value| adopt(AdoptMessage::Name(value)),
));
body.into()
}
/// 候选列表(可滚,定高:列表再长页脚按钮也留在卡里)。
fn candidate_list<'a>(
ui: &'a Ui,
form: &'a Form,
scan: &'a AdoptScanView,
catalog: Catalog,
) -> Element<'a, Message> {
let rows = column(
scan.candidates
.iter()
.map(|candidate| candidate_row(ui, form, candidate, catalog))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
rows.into()
}
/// 一条候选。
fn candidate_row<'a>(
ui: &'a Ui,
form: &'a Form,
candidate: &'a AdoptCandidateView,
catalog: Catalog,
) -> Element<'a, Message> {
let selected = form.selected.as_deref() == Some(candidate.version_id.as_str());
let mut items = column![row![
frame::text_role(
ui,
TypeRole::BodyLarge,
selected,
candidate.version_id.as_str(),
ui.color(Role::OnSurface),
)
.map(Message::Page),
container(status(ui, candidate, selected, catalog))
.width(Length::Fill)
.align_x(Alignment::End),
]
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
// 问题逐条(不折叠):候选阶段就把「为什么不能导入」说清楚
for problem in &candidate.problems {
items = items.push(problem_line(ui, problem));
}
let body = container(items)
.width(Length::Fill)
.padding(ui.space(Sp::X2))
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Medium),
..Border::default()
},
..container::Style::default()
});
if !candidate.adoptable() {
// 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路)
return body.into();
}
button(body)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: true,
})
.on_press(adopt(AdoptMessage::Select(candidate.version_id.clone())))
.into()
}
/// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。
fn status<'a>(
ui: &'a Ui,
candidate: &AdoptCandidateView,
selected: bool,
catalog: Catalog,
) -> Element<'a, Message> {
if !candidate.adoptable() {
let count = candidate.problems.len().to_string();
return wrapped(
ui,
catalog.render(Key::AdoptBlocked, &[("count", &count)]),
ui.color(Role::Error),
);
}
if let Some(name) = candidate.registered.first() {
return wrapped(
ui,
catalog.render(Key::AdoptRegistered, &[("name", name)]),
ui.color(Role::OnSurfaceVariant),
);
}
if selected {
return chip(ui, Chip::current(catalog.text(Key::AdoptReady)));
}
wrapped(
ui,
catalog.text(Key::AdoptReady).to_string(),
ui.color(Role::OnSurfaceVariant),
)
}
/// 三枚目标 chip(缺省档在最前;共享 / 版本目录档必须**明确点选**)。
fn target_row<'a>(ui: &'a Ui, form: &'a Form, catalog: Catalog) -> Element<'a, Message> {
let chips = AdoptTargetView::ALL
.into_iter()
.map(|target| {
chip(
ui,
Chip::filter(
target_label(target, catalog),
form.target == target,
adopt(AdoptMessage::Target(target)),
),
)
})
.collect::<Vec<_>>();
column![
row(chips).spacing(ui.space(Sp::X2)).width(Length::Fill),
target_hint(ui, form.target, catalog),
]
.spacing(ui.space(Sp::X1))
.width(Length::Fill)
.into()
}
/// 目标档的一句话说明(点选后立刻换)。**不是后果原文**:原文在 Preview 段逐条画
/// `plan.notes`(core `target_notes()`)。
fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Element<'a, Message> {
let key = match target {
AdoptTargetView::InstanceDir => Key::AdoptTargetInstanceDirHint,
AdoptTargetView::Shared => Key::AdoptTargetSharedHint,
AdoptTargetView::VersionDir => Key::AdoptTargetVersionDirHint,
};
let color = if target.touches_foreign_dir() {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
};
wrapped(ui, catalog.text(key).to_string(), color)
}
-198
View File
@@ -1,198 +0,0 @@
//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。
//!
//! | 函数 | 职责 |
//! |---|---|
//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 |
//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) |
//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 |
//!
//! ## 这个文件不是「纯搬移」
//!
//! 它**起于**一次搬移:`app/adopt.rs` 落笔 412 行越过 `components::gate` 的 400 行硬线,
//! 于是把卡壳 / 标题 / 页脚搬到这里。但**同一个提交里**这三块还改过行为与几何,逐条如下
//! (复审对 diff 时按这几条看,别按「一行未改」看):
//!
//! | 改了什么 | 为什么 |
//! |---|---|
//! | `card` 拆成「定高 + 中间区可滚」(Pick / Failed)与「按内容收高」(Preview / Done)两种几何 | 原来只给 `max_height`,内容一高就被裁:页脚按钮与实例名整排不可见(出图时抓到) |
//! | `footer` 的「关闭」只在 Pick / Preview 出现 | Failed / Done 的主按钮就是关闭,再给一枚文字关闭就是两枚一模一样的按钮 |
//! | `subtitle` 的 Preview 文案改成「版本名 · 目标档」 | 原来打「隔离档 · 目标档」,同屏两个「共用目录」 |
//!
//! 调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。
use iced::widget::{column, container, row, scrollable, Space};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptStage, AdoptState};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{target_label, wrapped};
use super::{adopt, middle, AdoptMessage, Form, CARD_MAX_HEIGHT, CARD_WIDTH};
/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。
pub(super) fn card<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
catalog: Catalog,
) -> Element<'a, Message> {
let stage = form.stage(adopt_state);
let middle = middle(ui, form, adopt_state, stage, catalog);
let head = column![
frame::text_role(
ui,
TypeRole::HeadlineSmall,
true,
title_text(stage, catalog),
ui.color(Role::OnSurface),
)
.map(Message::Page),
wrapped(
ui,
subtitle(stage, adopt_state, catalog),
ui.color(Role::OnSurfaceVariant)
),
frame::divider(ui).map(Message::Page),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
let tail = column![
frame::divider(ui).map(Message::Page),
footer(ui, form, adopt_state, stage, catalog),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
// 列表段(Pick / Failed)**定高 + 中间区可滚**:内容再长,页脚按钮也不会被裁掉
// (第一版就是被裁掉的)。短内容段(Preview / Done)按内容高收,不留一大片空白。
let list_stage = matches!(stage, AdoptStage::Pick | AdoptStage::Failed);
let card = if list_stage {
let body = column![
head,
scrollable(middle)
.height(Length::Fill)
.style(frame::scroll_style(ui)),
tail,
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.height(Length::Fixed(CARD_MAX_HEIGHT))
} else {
let body = column![head, middle, tail]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.max_height(CARD_MAX_HEIGHT)
};
card.padding(ui.space(Sp::X6))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(ShapeScale::ExtraLarge),
..container::Style::default()
})
.into()
}
/// 段标题(四段各一句)。
pub(super) fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str {
match stage {
AdoptStage::Pick => catalog.text(Key::AdoptTitle),
AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle),
AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle),
AdoptStage::Done => catalog.text(Key::AdoptDoneTitle),
}
}
/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。
pub(super) fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String {
match stage {
AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(),
// 预检:版本名 · 目标档(隔离档在正文里单独一行,重复一遍只会更吵)
AdoptStage::Preview => adopt_state
.plan
.as_ref()
.map(|plan| format!("{} · {}", plan.name, target_label(plan.target, catalog)))
.unwrap_or_default(),
AdoptStage::Failed => adopt_state
.failure
.as_ref()
.map(|failure| failure.message.clone())
.unwrap_or_default(),
AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(),
}
}
/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。
pub(super) fn footer<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
let busy = form.busy.waiting();
let primary: Element<'a, Message> = match stage {
AdoptStage::Pick => {
let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy;
if ready {
buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)),
)
} else {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview)))
}
}
AdoptStage::Preview if !busy => buttons::filled(
ui,
Button::new(
catalog.text(Key::AdoptConfirm),
adopt(AdoptMessage::Confirm),
),
),
AdoptStage::Preview => {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm)))
}
// 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了)
AdoptStage::Failed | AdoptStage::Done => buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
),
};
let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill);
// Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录)
if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)),
));
}
actions = actions.push(container(Space::new()).width(Length::Fill));
// 失败/完成段的主按钮就是「关闭」,再给一枚文字「关闭」就是两枚一模一样的按钮
if matches!(stage, AdoptStage::Pick | AdoptStage::Preview) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
));
}
actions = actions.push(primary);
column![actions].width(Length::Fill).into()
}
-205
View File
@@ -1,205 +0,0 @@
//! 出图用的向导状态夹具(D30 视觉验收的输入)。
//!
//! ## 为什么要有它
//!
//! 向导是**四段有状态**的界面,而截图通道只跑一次 `view()`(第 3 帧落盘),没有真人点按钮。
//! 组件库的做法是 `--screen=gallery` + `ForcedState`,页面层的做法是 `pages/instances/demo.rs`
//! 的 `DEMO`——同一类要求,同一类做法:**给渲染器一份确定的状态**,而不是让截图去赌异步时序。
//!
//! ## 哪些是真、哪些是夹具(复审请照这条核对)
//!
//! | 内容 | 来源 |
//! |---|---|
//! | 候选 / 问题 / 失败原因 | **夹具**(合成的 id 与问题文本,只为把版面撑到真实形态) |
//! | 运行目录 / 隔离档 | 夹具(固定路径,便于两张图逐像素对照) |
//! | **后果原文** | **真**:`unlucky_core::game::adopt::target_notes()` 现算,一个字不抄 |
//! | 文案(标题/标签/按钮) | **真**:`unlucky_i18n` 目录 |
//! | 渲染 | **真**:`--software-renderer`(tiny-skia)1180×760,与产品路径同一棵控件树 |
//!
//! 也就是说:这些图证明**版面与文案**,不证明 core 的判据(判据的证据在 `host/adopt/tests.rs`
//! 与 `core/tests/adopt/`)。
//!
//! ## 怎么用
//!
//! ```text
//! ULL_ADOPT_STATE=pick|pick-selected|empty|preview|shared|failed|done \
//! unlucky-launcher-iced.exe --software-renderer --screenshot <path> [--screenshot-theme dark]
//! ```
//!
//! 只在 `--screenshot` 已经给了的情况下生效(交互模式读这个环境变量没有意义,直接忽略)。
use std::path::PathBuf;
use unlucky_core::game::adopt::{target_notes, AdoptTarget};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptState, AdoptTargetView,
};
use super::{Busy, Form};
/// 夹具里的版本目录(只是**画**出来的路径,不碰盘)。
const VERSIONS_DIR: &str = "D:/minecraft/.minecraft/versions";
/// 夹具里的数据区运行目录。
const INSTANCE_RUN_DIR: &str = "D:/ULL/.ULL/data/instances/Better-MC";
/// 夹具里的游戏根。
const GAME_ROOT: &str = "D:/minecraft/.minecraft";
/// 环境变量名(见模块文档)。
pub(crate) const ENV: &str = "ULL_ADOPT_STATE";
/// 按名字造一份「打开着的向导」;不认识的名字返回 `None`(不影响正常启动)。
pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> {
let name = name.trim().to_ascii_lowercase();
let mut form = Form {
open: true,
..Form::default()
};
let mut state = AdoptState::default();
match name.as_str() {
// 扫描完成、还没选版本(列表里三态齐全:可导入 / 已登记 / 有问题)
"pick" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
form.busy = Busy::Idle;
}
// 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态)
"pick-selected" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
form.selected = Some("1.20.1".to_string());
form.busy = Busy::Idle;
}
// 游戏根下真没有版本(空态)
"empty" => {
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: PathBuf::from(VERSIONS_DIR),
candidates: Vec::new(),
})));
form.busy = Busy::Idle;
}
// 选中缺省档(InstanceDir)并点过预检
"preview" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Planned(Box::new(plan_view(
AdoptTargetView::InstanceDir,
AdoptIsolationView::Custom,
INSTANCE_RUN_DIR,
))));
form.selected = Some("1.20.1".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
// 选中共享档(Shared):后果原文由 core `target_notes()` 现算
"shared" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Planned(Box::new(plan_view(
AdoptTargetView::Shared,
AdoptIsolationView::Shared,
GAME_ROOT,
))));
form.selected = Some("1.20.1".to_string());
form.target = AdoptTargetView::Shared;
form.checked = true;
form.busy = Busy::Idle;
}
// 预检失败:两条问题逐条列出(不许折叠成一句)
"failed" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记:清单不是合法 JSON".to_string(),
problems: vec![
AdoptProblemView {
code: "manifest_invalid_json".to_string(),
message: format!(
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
),
},
AdoptProblemView {
code: "missing_parent".to_string(),
message: "版本 1.19.4 继承的父版本 1.19.3 不在盘上".to_string(),
},
],
})));
form.selected = Some("1.19.4".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
// 登记完成
"done" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "2f1c0a5e-7f4b-4c1d-9a2e-6b8d5c3a1f70".to_string(),
name: "Better MC".to_string(),
version_id: "1.20.1-fabric-0.15.0".to_string(),
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from(INSTANCE_RUN_DIR),
dir_created: true,
notes: target_notes(&AdoptTarget::InstanceDir, &PathBuf::from(INSTANCE_RUN_DIR)),
})));
form.selected = Some("1.20.1-fabric-0.15.0".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
_ => return None,
}
Some((form, state))
}
/// 一份扫描结果(三态齐全)。
fn scan_view() -> AdoptScanView {
AdoptScanView {
versions_dir: PathBuf::from(VERSIONS_DIR),
candidates: vec![
AdoptCandidateView {
version_id: "1.20.1".to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems: Vec::new(),
},
AdoptCandidateView {
version_id: "1.20.1-fabric-0.15.0".to_string(),
jar_present: false,
inherits: vec!["1.20.1-fabric-0.15.0".to_string(), "1.20.1".to_string()],
registered: vec!["Better MC".to_string()],
problems: Vec::new(),
},
AdoptCandidateView {
version_id: "1.19.4".to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems: vec![AdoptProblemView {
code: "manifest_invalid_json".to_string(),
message: format!(
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
),
}],
},
],
}
}
/// 一份预检结果。**`notes` 由 core 现算**(共享档/版本目录档的后果原文只有一处)。
fn plan_view(
target: AdoptTargetView,
isolation: AdoptIsolationView,
run_dir: &str,
) -> AdoptPlanView {
let core_target = match target {
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
AdoptTargetView::Shared => AdoptTarget::Shared,
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
};
AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target,
isolation,
run_dir: PathBuf::from(run_dir),
notes: target_notes(&core_target, &PathBuf::from(run_dir)),
}
}
-287
View File
@@ -1,287 +0,0 @@
//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。
//!
//! 抓得住:
//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待);
//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」);
//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。
//!
//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView,
};
use super::*;
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
AdoptCandidateView {
version_id: version_id.to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems,
}
}
fn state_with_scan() -> AdoptState {
let mut state = AdoptState::default();
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: std::path::PathBuf::from("/game/versions"),
candidates: vec![candidate("1.20.1", Vec::new())],
})));
state
}
/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。
#[test]
fn open_and_close_reset_the_form() {
let mut form = Form::default();
form.open();
assert!(form.open);
assert_eq!(form.busy, Busy::Scanning);
assert_eq!(form.target, AdoptTargetView::InstanceDir);
form.selected = Some("1.20.1".to_string());
form.name = "生存".to_string();
form.close();
assert!(!form.open);
assert!(form.selected.is_none());
assert!(form.name.is_empty());
// 再开一次也不该带着上一轮的选中项
form.open();
assert!(form.selected.is_none());
}
/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。
#[test]
fn settling_keeps_what_the_user_typed() {
let mut form = Form::default();
form.open();
form.selected = Some("1.20.1".to_string());
form.name = "生存".to_string();
form.settled();
assert_eq!(form.busy, Busy::Idle);
assert_eq!(form.selected.as_deref(), Some("1.20.1"));
assert_eq!(form.name, "生存");
}
/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。
#[test]
fn candidate_is_none_when_the_selection_vanished() {
let state = state_with_scan();
let mut form = Form {
selected: Some("1.20.1".to_string()),
..Form::default()
};
assert!(form.candidate(&state).is_some());
form.selected = Some("9.9.9".to_string());
assert!(form.candidate(&state).is_none());
}
/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。
#[test]
fn the_four_stages_project_from_the_event_state() {
let catalog = Lang::ZhCn.catalog();
let mut state = state_with_scan();
// Pick
assert_eq!(state.stage(), AdoptStage::Pick);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptTitle),
"标题走目录"
);
assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制"));
// Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改
let note =
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::Shared,
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
notes: vec![note.clone()],
})));
assert_eq!(state.stage(), AdoptStage::Preview);
assert_eq!(
state.plan.as_ref().map(|plan| plan.notes.first().cloned()),
Some(Some(note.clone())),
"后果原文必须原样留着"
);
// Failed:problems 逐条(不折叠)
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记".to_string(),
problems: vec![
AdoptProblemView {
code: "manifest_missing".to_string(),
message: "没有清单".to_string(),
},
AdoptProblemView {
code: "missing_parent".to_string(),
message: "缺父版本 1.20.1".to_string(),
},
],
})));
assert_eq!(state.stage(), AdoptStage::Failed);
assert_eq!(
state.failure.as_ref().map(|f| f.problems.len()),
Some(2),
"两条问题一条都不许吞"
);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptFailureTitle)
);
// Done
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "id-1".to_string(),
name: "1.20.1".to_string(),
version_id: "1.20.1".to_string(),
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
dir_created: false,
notes: vec![note],
})));
assert_eq!(state.stage(), AdoptStage::Done);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptDoneTitle)
);
}
/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。
#[test]
fn both_catalogs_have_the_wizard_wording() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = lang.catalog();
for key in [
Key::AdoptTitle,
Key::AdoptSubtitle,
Key::AdoptScanning,
Key::AdoptEmpty,
Key::AdoptSelectLabel,
Key::AdoptReady,
Key::AdoptTargetLabel,
Key::AdoptTargetInstanceDir,
Key::AdoptTargetShared,
Key::AdoptTargetVersionDir,
Key::AdoptNameLabel,
Key::AdoptPreview,
Key::AdoptConfirm,
Key::AdoptBack,
Key::AdoptClose,
Key::AdoptPlanTitle,
Key::AdoptPlanRunDir,
Key::AdoptPlanIsolation,
Key::AdoptFailureTitle,
Key::AdoptProblemCount,
Key::AdoptDoneTitle,
Key::AdoptDoneHint,
] {
let value = catalog.text(key);
assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的");
assert_ne!(
value,
format!("{key:?}"),
"{key:?} 在 {lang:?} 没进目录(回落成了 key 名)"
);
}
}
// 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道)
let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint);
assert!(hint.contains(".minecraft"), "{hint}");
}
/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页:
/// 预检结果到了也只是 Pick 段里的提示,直到用户点过「预检」(`checked`) 才进 Preview。
///
/// 抓得住:把 `Form::stage` 写回直接取 `AdoptState::stage()`(点一次共享档就跳到确认页,
/// 实例名还没来得及改);或忘了 `fresh_plan` 的目标一致性判据(改档之后画上一档的后果原文)。
#[test]
fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() {
let mut state = state_with_scan();
let note =
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::Shared,
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
notes: vec![note.clone()],
})));
let mut form = Form {
open: true,
selected: Some("1.20.1".to_string()),
target: AdoptTargetView::Shared,
..Form::default()
};
// 自动预检回来但用户没点「预检」:仍是 Pick 段,notes 当作提示可读
assert_eq!(form.stage(&state), AdoptStage::Pick);
assert_eq!(
form.fresh_plan(&state)
.map(|plan| plan.notes.first().cloned()),
Some(Some(note.clone())),
"后果原文必须能取到"
);
// 点过「预检」之后才进确认段
form.checked = true;
assert_eq!(form.stage(&state), AdoptStage::Preview);
// 换了目标档:上一档的预检作废(不再当后果原文画)
form.target = AdoptTargetView::VersionDir;
assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画");
}
/// **P2-2**:`fresh_plan` 必须按**版本 id** 过期,不只是目标档。
///
/// 场景:先选 1.20.1 + 版本目录档(notes 说 `versions/1.20.1`),再改选 1.19.4——目标档没变,
/// 旧的预检结果若不按版本过滤就会继续当「这个版本的后果原文」画出来,那是一句假话。
///
/// 抓得住:`fresh_plan` 只看 `plan.target == self.target`(第一次修法就是这样漏的)。
/// 抓不住:core 真的会不会为 1.19.4 算出同一段 notes(那是 `target_notes()` 的活)。
#[test]
fn fresh_plan_expires_when_the_selected_version_changes() {
let mut state = state_with_scan();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::VersionDir,
isolation: AdoptIsolationView::Isolated,
run_dir: std::path::PathBuf::from("/game/versions/1.20.1"),
notes: vec!["运行目录 = 版本目录 /game/versions/1.20.1".to_string()],
})));
let mut form = Form {
open: true,
selected: Some("1.20.1".to_string()),
target: AdoptTargetView::VersionDir,
checked: true,
..Form::default()
};
assert!(
form.fresh_plan(&state).is_some(),
"对得上版本与目标档时应当可读"
);
// 改选另一个版本:目标档没变,但旧预检说的是别的版本的目录
form.selected = Some("1.19.4".to_string());
assert!(
form.fresh_plan(&state).is_none(),
"换了版本之后旧预检必须过期(否则画的是 1.20.1 的后果原文)"
);
// 没选版本时同样不该画(`None` = 没有任何版本的预检可言)
form.selected = None;
assert!(form.fresh_plan(&state).is_none());
}
-88
View File
@@ -1,88 +0,0 @@
//! 向导里的小工具:等宽行、可换行正文、标签、问题行、档位人话。
//!
//! 单独一个文件只为守 400 行硬线(AGENTS §3.1):三段视图(`pick` / `panes`)与卡壳
//! (`adopt.rs`)都要用它们,放哪儿都会让那一处越线。
use iced::widget::text::LineHeight;
use iced::widget::{column, text};
use iced::{Color, Element, Font, Length, Pixels};
use unlucky_design::color::Role;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptIsolationView, AdoptProblemView, AdoptTargetView};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
/// 段内的次级标签(`LabelMedium` + `OnSurfaceVariant`)。
pub(super) fn label<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
value,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽一行(路径 / 机读码要能逐字读)。
pub(super) fn mono<'a>(ui: &'a Ui, value: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.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()
}
/// 一段可换行的正文(长路径只按词折行会把卡顶破,所以按词或字形折)。
pub(super) fn wrapped<'a>(ui: &'a Ui, value: String, color: Color) -> Element<'a, Message> {
frame::text_role_owned(ui, TypeRole::BodySmall, false, value, color).map(Message::Page)
}
/// 「标签 + 值」一行(值用等宽)。
pub(super) fn field_line<'a>(ui: &'a Ui, label_text: &'a str, value: &str) -> Element<'a, Message> {
column![label(ui, label_text), mono(ui, value.to_string())]
.spacing(0.0)
.width(Length::Fill)
.into()
}
/// 一条问题:稳定码(等宽) + 人读原因(`Error` 色)。**一条都不许折叠**。
pub(super) fn problem_line<'a>(ui: &'a Ui, problem: &'a AdoptProblemView) -> Element<'a, Message> {
column![
mono(ui, problem.code.clone()),
wrapped(ui, problem.message.clone(), ui.color(Role::Error)),
]
.spacing(0.0)
.width(Length::Fill)
.into()
}
/// 隔离档的人话(沿用实例页已有的两条;`custom` 就是缺省档,直接说「在 ULL 数据区新建」)。
pub(super) fn isolation_label(isolation: AdoptIsolationView, catalog: Catalog) -> String {
match isolation {
AdoptIsolationView::Isolated => catalog.text(Key::InstancesIsolationIsolated).to_string(),
AdoptIsolationView::Shared => catalog.text(Key::InstancesIsolationShared).to_string(),
AdoptIsolationView::Custom => catalog.text(Key::AdoptTargetInstanceDir).to_string(),
}
}
/// 目标档的短标签。
pub(super) fn target_label(target: AdoptTargetView, catalog: Catalog) -> &'static str {
match target {
AdoptTargetView::InstanceDir => catalog.text(Key::AdoptTargetInstanceDir),
AdoptTargetView::Shared => catalog.text(Key::AdoptTargetShared),
AdoptTargetView::VersionDir => catalog.text(Key::AdoptTargetVersionDir),
}
}
+5 -8
View File
@@ -18,7 +18,6 @@
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
use crate::app::diag::diag;
use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change;
@@ -55,7 +54,7 @@ impl App {
} else {
line.push_str(" layered=skipped outcome=no-window");
}
diag!("{line}");
eprintln!("{line}");
}
/// 设置页改了一项外观。
@@ -83,7 +82,7 @@ impl App {
});
}
}
// 其余键:走 bridge(M250 起强调色也在这条路上,取的是上一次合法值)。
// 其余键:走 bridge(它知道怎么保留强调色这类本 mission 不管的字段)。
Some(_) => self.persist_appearance(),
None => {}
}
@@ -110,7 +109,7 @@ impl App {
self.apply_window_corner("reload");
}
/// 把外观写回 `launcher.json`(本页控件管的那批字段 + 强调色;其余键原样保留)。
/// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。
fn persist_appearance(&mut self) {
let mut prefs = self.appearance_prefs.clone();
prefs.bg_image_enabled = self.appearance.bg_enabled;
@@ -119,15 +118,13 @@ impl App {
prefs.bg_dim = self.appearance.bg_dim;
prefs.panel_alpha = self.appearance.panel_alpha;
prefs.glass = self.appearance.glass;
// M250:强调色也是「UI 写入字段」了——取**上一次合法值**(非法手输不写盘,见 `accent::Accent`)。
prefs.accent_color = self.appearance.accent.persist_value().to_owned();
self.bridge.persist(&prefs, self.theme_mode);
self.appearance_prefs = prefs;
match self.bridge.path() {
Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
}
}
}
+10 -11
View File
@@ -12,7 +12,6 @@
//! 为什么重开必须是**换进程**: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;
@@ -52,10 +51,10 @@ pub(crate) fn run(options: Options) -> iced::Result {
let font_started = Instant::now();
let resolved = fonts::resolve();
let font_ms = font_started.elapsed();
diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
diag!("{}", resolved.describe());
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
diag!(
eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
@@ -65,7 +64,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() {
diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
diagnostics::report_theme(&options, flavor);
@@ -87,14 +86,14 @@ pub(crate) fn run(options: Options) -> iced::Result {
}
Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
diag!(
eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
.screenshot
.as_ref()
.map_or_else(String::new, |path| path.display().to_string())
);
diag!(
eprintln!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
);
Err(iced::Error::GraphicsCreationFailed(error))
@@ -193,13 +192,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());
diag!(
eprintln!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV,
kind.env_value(),
kind.env_value()
);
diag!("[probe] {}", decision.describe());
eprintln!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() {
@@ -233,11 +232,11 @@ fn boot_probe(options: &Options) -> Boot {
if cfg!(feature = "gpu") {
match probe_ms {
Some(elapsed) => diag!(
Some(elapsed) => eprintln!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => diag!(
None => eprintln!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name()
),
@@ -35,11 +35,6 @@ 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` 没有扩展样式
+4 -8
View File
@@ -9,7 +9,6 @@
//! (`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};
@@ -59,10 +58,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| {
diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
});
diag!(
eprintln!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(),
@@ -79,7 +78,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
diag!("[gpu] 降级重开失败:{err}");
eprintln!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
@@ -108,10 +107,7 @@ pub(super) fn fallback_command(
args: impl IntoIterator<Item = std::ffi::OsString>,
fallback: Kind,
) -> std::process::Command {
// 子进程就是本 GUI exe 自己(PE Subsystem=2,本来就不会分到控制台),所以这一处今天闪不了;
// 走 `quiet` 是为了让「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住——将来
// 某天把降级重开换成控制台辅助程序时,不会从这里漏出一块黑框(M217)。
let mut command = unlucky_core::platform::child::quiet(program);
let mut command = std::process::Command::new(program);
command.args(args).envs(fallback_env(fallback));
command
.stdin(std::process::Stdio::inherit())
@@ -6,6 +6,8 @@
//!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use std::process::Command;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
@@ -52,11 +54,7 @@ pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> {
// 非 Windows 上 `quiet` 是原样返回(POSIX 没有「spawn 就新开一个终端」这回事);走它是为了
// 不在整棵 `crates/` 的子进程守卫里开一个「Termux 专用」例外(M217)。
let output = unlucky_core::platform::child::quiet("xdpyinfo")
.output()
.ok()?;
let output = Command::new("xdpyinfo").output().ok()?;
if !output.status.success() {
return None;
}
-169
View File
@@ -1,169 +0,0 @@
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
//!
//! ## 为什么要有这一层
//!
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
//!
//! 现在这些行同时进 `launch::launcher_log`:
//!
//! ```text
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
//! ```
//!
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
//!
//! ## 哪些行进日志、哪些只进 stderr
//!
//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。**
//!
//! | 进日志 | 只进 stderr |
//! |---|---|
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) |
//! | **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` / `[gpu] 失败详情(具名)`(`renderer::report_failure`)、`[gpu] iced_wgpu: …Selected: AdapterInfo…`(`log_bridge`)、`[probe] request=… -> ICED_BACKEND=…`(`probe::apply_with_preflight`)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]` / gallery 页面里的 `[gallery]`) |
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
//! | 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒 ≈ 4.8 小时才滚一次)、`[shot]` 的通道提示 | — |
//!
//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体——
//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。
/// 行首没有 `[tag]` 时的默认 scope。
pub const APP_SCOPE: &str = "app";
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
pub(crate) fn emit(line: &str) {
eprintln!("{line}");
let (scope, message) = split_scope(line);
unlucky_core::launch::launcher_log::record(scope, message);
}
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
///
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
let Some(rest) = line.strip_prefix('[') else {
return (APP_SCOPE, line);
};
let Some((scope, tail)) = rest.split_once(']') else {
return (APP_SCOPE, line);
};
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
return (APP_SCOPE, line);
}
(scope, tail.trim_start())
}
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
macro_rules! diag {
($($arg:tt)*) => {{
crate::app::diag::emit(&format!($($arg)*));
}};
}
pub(crate) use diag;
/// 串行化「会改 `launcher_log` 全局目录」的用例。
///
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
#[cfg(test)]
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::split_scope;
#[test]
fn the_leading_bracket_becomes_the_scope() {
assert_eq!(
split_scope("[font] 求值耗时 12 ms"),
("font", "求值耗时 12 ms")
);
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
// 正文里的方括号不是标签
assert_eq!(
split_scope("[page] 占位动作 [x] pressed"),
("page", "占位动作 [x] pressed")
);
}
#[test]
fn lines_without_a_tag_fall_back_to_the_default_scope() {
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
assert_eq!(
split_scope("[a b] 空白不是标签"),
("app", "[a b] 空白不是标签")
);
}
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
///
/// ## 为什么要「重试」
///
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
///
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
#[test]
fn the_launcher_log_rolls_over_and_stays_bounded() {
use unlucky_core::launch::launcher_log;
let _guard = super::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建夹具目录");
let path = dir.join(launcher_log::FILE_NAME);
let rolled = dir.join("launcher.1.log");
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
for _ in 0..50 {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&rolled);
launcher_log::set_dir(&dir);
if launcher_log::path() != Some(path.clone()) {
continue; // 这一轮被别人的 set_dir 抢先了
}
launcher_log::record("boot", "第一行");
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
if launcher_log::path() != Some(path.clone()) {
continue;
}
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
if launcher_log::path() != Some(path.clone()) {
continue; // 追加落到了别人的目录,这一轮不算
}
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
let size = std::fs::metadata(&path).expect("新文件").len();
assert!(
size < launcher_log::MAX_BYTES,
"滚完之后新文件必须远小于上限:{size}"
);
let text = std::fs::read_to_string(&path).expect("读新文件");
assert!(text.contains("滚过之后这一行"), "{text}");
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
launcher_log::record("boot", "第二次滚动");
assert!(rolled.is_file());
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
std::fs::remove_dir_all(&dir).ok();
return;
}
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
}
}
-390
View File
@@ -1,390 +0,0 @@
//! 模态对话框(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<String>,
}
/// 弹窗消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 主按钮。
Primary,
/// 次按钮。
Secondary,
/// 点遮罩 / Esc。
Dismiss,
/// 展开 / 收起详细说明。
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<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[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<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
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<String> {
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<String>, Vec<String>) {
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<Modal> {
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;
}
}
}
-217
View File
@@ -1,217 +0,0 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`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;
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
#[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) -> Exported {
let catalog = Catalog::of(lang);
let Some(logs) = &report.launcher_logs else {
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())
.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) => {
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<BundleEntry> = Vec::new();
let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
if path.exists() {
entries.push(BundleEntry::new(group, path));
}
};
if let Some(logs) = &report.launcher_logs {
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 {
// 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id,
// 也不会出现「清单写 <redacted:token> 而包里是原始 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(&mut entries, "game", &artifact.path);
}
}
if let Some(latest) = report.latest_log_path() {
push(&mut entries, "game", &latest);
}
if let Some(json) = &report.version_json {
push(&mut entries, "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 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()))
}
/// 平台各自的「打开这个目录」实现。
///
/// ## 为什么这三处**故意**不走 `unlucky_core::platform::child::quiet`(M217)
///
/// 这一处 spawn 的**目的就是让桌面上出现一个可见的东西**(文件管理器窗口 / Finder 窗口 /
/// 桌面环境的文件管理器)。`quiet` 的语义是「把子进程的窗口抑制掉」,用在这里是把目的本身
/// 抑制掉——改错了用户点「打开文件夹」就只看到「打不开」的报错。
///
/// 而且从窗口角度也不需要它:`explorer.exe` / `open` / `xdg-open` 自己都是 GUI 侧程序,由
/// 无控制台的 GUI 父进程 spawn 时 Windows 不会给它们新建控制台。所以这三处在
/// `crates/core/tests/child_quiet_guard.rs` 的 `ALLOWED_ROLES` 里登记成「目录打开器」角色,
/// **不是**漏网。别把它们改成 `quiet`。
#[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::<Vec<_>>()
.join("\n")
}
-344
View File
@@ -1,344 +0,0 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`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<String>) -> 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());
}
/// 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() {
// 与 `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("建目录");
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}");
}
#[path = "tests/export.rs"]
mod export;
@@ -1,85 +0,0 @@
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
//!
//! 从 `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
);
}
-399
View File
@@ -1,399 +0,0 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
//!
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
//!
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
//! 立刻停。所以三层的顺序是:
//!
//! ```text
//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
//! ```
//!
//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
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::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry;
use crate::app::components::motion;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::{Message as DialogMessage, Modal};
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0;
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
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> {
// M256:模态进出只由**一个**进度驱动——遮罩做画布色→scrim 色的**预合成**插值(不透明),
// 卡片做 0.96→1.0 的缩放。`progress = 1` 时两者都退化成改造前的原值(出图基线不变)。
let progress = motion::modal().progress;
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color_at(ui, progress))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(motion::scaled(
card_plane(ui, modal),
motion::modal_scale(progress),
))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 遮罩色(静息 = 完全展开):**组件层那一份不透明预合成**(黑 @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`] 断言**视图真正用的那一份**;产品路径一律走
/// [`backdrop_color_at`](`progress = 1` 那一端逐位相同)。
#[cfg(test)]
pub(super) fn backdrop_color(ui: &Ui) -> Color {
backdrop_color_at(ui, 1.0)
}
/// 遮罩色在**动画进度** `progress` 上的取值(M256):画布色 → scrim 色的预合成插值。
///
/// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级**
/// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经
/// 合成好的不透明色从画布色插值到 scrim 色,全程 `a = 1.0`,两个后端出图一致。
/// `1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。
///
/// ⚠️ **口径(复审 P2-3 订正)**:`0` 时这一层是**整块画布色的不透明矩形**,它把页面内容
/// **整块遮住**(不是"透明所以看不见",也不是"看得到页面")——D27 禁面板 α,所以只有这一种
/// 可行实现。观感是「画面先被画布色盖住、随进度变暗到 scrim」,不是「页面透过淡遮罩隐隐可见」。
pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color {
let t = progress.clamp(0.0, 1.0);
if t >= 1.0 {
return scrim(ui);
}
let from = ui.layer(MaterialLayer::Canvas);
let to = scrim(ui);
Color {
r: from.r + (to.r - from.r) * t,
g: from.g + (to.g - from.g) * t,
b: from.b + (to.b - from.b) * t,
// 预合成色两端都是不透明的:这里显式钉死 1.0,不给"淡入=半透明"留口子。
a: 1.0,
}
}
/// 卡片层:`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> {
// 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
let geometry = DialogGeometry::of(ui);
let shape = Component::Dialog.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(if modal.notice.is_some() {
SCROLL_HEIGHT_WITH_NOTICE
} else {
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);
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
container(body)
.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),
..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()
}
#[cfg(test)]
mod motion_tests {
//! 模态进场的**可判读数值取证**(M256 / tower 门槛 E):缩放值与遮罩色逐档钉住。
//!
//! 抓得住:把淡入做回「调 α」(`a < 1` 即红,D27)、把 `progress = 1` 做成近似值(与
//! `backdrop_color` 不等即红)、把缩放方向做反。抓不住:**观感**(截图见 artifacts/motion1)。
use super::*;
use crate::app::components::motion;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
const FAMILIES: crate::theme::Families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn luma(color: Color) -> f32 {
0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
}
/// 遮罩:`0` 是整块画布色(**把页面内容盖住**,不是透明)、`1` 是原遮罩(逐位相同)、
/// 全程不透明且单调变暗。
#[test]
fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
let canvas = ui.layer(MaterialLayer::Canvas);
let rest = backdrop_color(&ui);
assert_eq!(
backdrop_color_at(&ui, 0.0),
canvas,
"{flavor:?}: 起手同画布"
);
assert_eq!(
backdrop_color_at(&ui, 1.0),
rest,
"{flavor:?}: 到位逐位相同"
);
let mut previous = luma(canvas);
for step in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
let color = backdrop_color_at(&ui, step);
assert_eq!(color.a, 1.0, "{flavor:?}: 遮罩必须全程不透明(D27)");
let current = luma(color);
assert!(
current <= previous + 1.0e-6,
"{flavor:?}: 遮罩必须单调变暗({previous} → {current})"
);
previous = current;
println!(
"[motion1] {flavor:?} t={step:.2} scale={:.3} rgb({:.3},{:.3},{:.3})",
motion::modal_scale(step),
color.r,
color.g,
color.b
);
}
}
}
}
-121
View File
@@ -1,121 +0,0 @@
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
//!
//! ## 为什么不在页面里现拼路径
//!
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
//!
//! 本模块只做两件事:
//!
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
//!
//! ## 谁来登记
//!
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
//! `platform::data::layout()`——同一份解析链的缓存版本。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_presenter::settings_bridge::SettingsBridge;
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
let home = config.parent()?.parent()?;
Some(LauncherLayout::new(home).logs())
}
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
pub(crate) fn register(bridge: &SettingsBridge) {
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
}
/// 本进程的日志目录。
///
/// - 已登记且解析出来 ⇒ 那条;
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
pub(crate) fn logs_dir() -> Option<PathBuf> {
match LOGS_DIR.get() {
Some(known) => known.clone(),
None => crate::platform::data::layout().map(|layout| layout.logs()),
}
}
/// 确保日志目录存在(不在就建),返回给用户看的路径。
///
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir)
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
if !dir.is_dir() {
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
}
Ok(dir.to_path_buf())
}
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
///
/// # Errors
///
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
pub(crate) fn open() -> Result<PathBuf, String> {
let dir = logs_dir().ok_or_else(|| {
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
.to_string()
})?;
let dir = prepare(&dir)?;
crate::app::dialog::export::open_dir(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
///
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
#[test]
fn the_log_folder_is_the_logs_partition_next_to_config() {
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
assert_eq!(
logs_dir_of(&config),
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
);
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
}
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
///
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
#[test]
fn prepare_creates_the_folder_and_reports_the_same_path() {
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".ULL/logs");
assert!(!dir.is_dir(), "夹具目录不该已经存在");
let prepared = prepare(&dir).expect("应当建得出来");
assert_eq!(prepared, dir);
assert!(dir.is_dir(), "打开之前必须真的存在");
std::fs::remove_dir_all(&root).ok();
}
/// 失败文本带路径(「不许只在成功时可用」)。
#[test]
fn a_failure_names_the_path_it_could_not_use() {
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
std::fs::write(&file, b"x").expect("写夹具");
let error = prepare(&file).expect_err("一个文件不是目录");
assert!(error.contains(&file.display().to_string()), "{error}");
std::fs::remove_file(&file).ok();
}
}
-7
View File
@@ -67,13 +67,6 @@ pub enum Message {
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message),
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
///
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
Adopt(super::adopt::AdoptMessage),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
-4
View File
@@ -27,13 +27,9 @@
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。
mod adopt;
mod appearance;
mod backend;
mod boot;
pub(crate) mod diag;
mod dialog;
pub(crate) mod logs_folder;
mod message;
mod state;
mod subscription;
+1 -68
View File
@@ -56,9 +56,6 @@ 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`])。
@@ -67,12 +64,8 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
mod boot_probe;
// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响)
mod construct;
mod mods_shot;
mod refresh;
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
mod saves_shot;
mod theme;
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
@@ -118,15 +111,6 @@ pub struct App {
pub(super) width: f32,
/// 展示转换缓存(presenter 快照 → 页面视图模型)。
pub(super) view: pages::Model,
/// **换页过渡期的旧页快照**(M256):页面切换时把上一帧的 `view` 移进来,退场腿走完就丢掉。
///
/// 为什么必须存在 `App` 里而不是线程局部:`pages::page_with_settings_transition` 返回的
/// `Element<'a>` **借**模型里的字符串,`'a` 由调用方决定 ⇒ 线程局部的借用守卫活不过函数体,
/// 只有 `App` 的字段能给出 `&'a Model`。
///
/// **用完即焚**:`App::sync_motion` 在 `motion::needs_frames()` 为假的那一拍把它置 `None`——
/// 这是长时间常驻的启动器,留着就是「每换一次页多一份模型」的线性增长。
pub(super) previous_view: Option<pages::Model>,
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
pub(super) view_dirty: bool,
@@ -146,18 +130,12 @@ pub struct App {
pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool,
/// 上一次看到的**中文字体下载状态**(M168)。
///
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
/// 而空闲态不会为了它每拍白重建一次。
pub(super) font_seen: crate::fonts::install::CjkInstall,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 没动过的字段原样保留;M250 起强调色也在「真改了」的那一列里)。
/// 强调色这类本 mission 不管的字段原样保留)。
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>,
@@ -190,14 +168,6 @@ pub struct App {
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64,
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64,
/// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。
///
/// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、
/// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。
pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -208,27 +178,6 @@ pub struct App {
pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page,
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
///
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
///
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
pub(super) adopt_form: super::adopt::Form,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
pub(super) dialog_card: Option<super::dialog::CrashCard>,
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
///
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -237,19 +186,3 @@ pub struct App {
/// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
}
impl App {
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
///
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
///
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
#[cfg(test)]
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
&self.state.settings_groups
}
}
+2 -23
View File
@@ -4,7 +4,6 @@
//! 「进程启动那一刻记了什么」,与 `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;
@@ -23,37 +22,17 @@ pub(super) fn app_options(options: &Options) -> AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some() && !real_host_in_screenshot(),
mock: options.screenshot.is_some(),
}
}
/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。
///
/// ## 为什么需要它(M201)
///
/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发**
/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」
/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。
/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。
///
/// ## 边界(别把它当产品行为)
///
/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**;
/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧);
/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。
fn real_host_in_screenshot() -> bool {
std::env::var("ULL_SCREENSHOT_REAL_HOST")
.ok()
.is_some_and(|raw| raw.trim() == "1")
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
if let Some(started) = STARTED.get() {
diag!(
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
+2 -41
View File
@@ -13,7 +13,6 @@ use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
@@ -42,9 +41,6 @@ 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)
}
@@ -58,17 +54,7 @@ impl App {
) -> Self {
let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height;
// 主题档:截图通道**不读机器配置**(D30:同一份代码在两台机器上要出同一张图)——
// 由 `--screenshot-theme` 钉死(浅 → Silver / `light`,深 → Ink / `dark`)。
// 产品路径才读 `appearance.theme_mode`。复审 P2:此前这里两种通道都读文件,
// 设置页「主题」那一行的选中态会随出图机器的配置漂移。
let theme_mode = match options.screenshot.is_some() {
true => match options.shot_flavor().unwrap_or(SchemeFlavor::Light) {
SchemeFlavor::Light => ThemeMode::Light,
SchemeFlavor::Dark => ThemeMode::Dark,
},
false => bridge.load().1,
};
let theme_mode = bridge.load().1;
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
//
@@ -100,14 +86,9 @@ impl App {
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some());
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
crate::fonts::install::set_offline(options.screenshot.is_some());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
height,
Some(pages::Screen::Instances.nav()),
);
view.lang = lang.lang();
@@ -126,17 +107,12 @@ impl App {
last_tick: std::time::Instant::now(),
width,
view,
// M256:换页过渡期的「旧页」快照。启动时没有旧页。
previous_view: None,
view_dirty: false,
watch_until: None,
window: None::<window::Id>,
frames: 0,
refreshes: 0,
auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height,
appearance,
appearance_prefs,
@@ -148,13 +124,7 @@ impl App {
agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
adopt_form: super::super::adopt::Form::default(),
install_cancelled_seen: 0,
launch_ends_seen: 0,
mods_failed_seen: None,
dialog_card: None,
dialog_model: None,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
@@ -167,17 +137,8 @@ impl App {
avatars: pages::account::avatar::Cache::default(),
corner_applied: None,
};
// M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`),
// 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。
app.settings.language_appearance.theme = theme_mode;
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
// M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`).
app.hydrate_adopt_state();
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
@@ -1,97 +0,0 @@
//! 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 MODS 区截图需要一屏**有内容的列表**。产品路径的列表来自后台线程(列 `mods/` → 快照),
//! 而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及完成,截出来会是
//! 「正在读取 mods/…」。M157 的启动参数区、M222 的下载页、M223 的导入都是同一个问题,
//! 处理方式一致:**状态注入**,且注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`ModListView`]),不是页面模型——`Mods::from_state` 的转换、
//! 计数、`.old` 徽标、在飞置灰走的都是产品代码;
//! - 真实链路由两条端到端用例钉住(`host::mods::tests` 在真盘上列目录 + 启停)。
/// 夹具开关是否打开(`ULL_MODS_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_MODS_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// `dir` 故意留空:夹具不碰路径(这一版页面也不画它),写一个假路径反而会被当成"真的派生结果"。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::ModListView {
use unlucky_presenter::dto::{ModEntryView, ModListView};
let entry = |file: &str, name: &str, enabled: bool, bytes: u64, old: bool| ModEntryView {
file: file.to_string(),
name: name.to_string(),
enabled,
bytes,
old,
};
// 夹具不碰路径:`dir` 留空(页面这一版不画它,它只是 DTO 的一部分)。
let dir = String::new();
ModListView {
instance: instance.to_string(),
dir,
exists: true,
entries: vec![
entry(
"sodium-fabric-0.5.11.jar",
"sodium-fabric-0.5.11.jar",
true,
1_048_576,
false,
),
entry(
"iris-fabric-1.8.5.jar",
"iris-fabric-1.8.5.jar",
true,
2_621_440,
false,
),
entry(
"jei-1.20.4-forge-17.3.0.49.jar.disabled",
"jei-1.20.4-forge-17.3.0.49.jar",
false,
1_363_148,
false,
),
entry(
"aether-1.20.1-1.0.0.jar.old",
"aether-1.20.1-1.0.0.jar",
true,
7_340_032,
true,
),
entry(
"create-1.20.1-0.5.1.f.jar.disabled",
"create-1.20.1-0.5.1.f.jar",
false,
4_194_304,
false,
),
entry(
"fabric-api-0.92.2+1.20.4.jar",
"fabric-api-0.92.2+1.20.4.jar",
true,
2_097_152,
false,
),
],
skipped: Vec::new(),
running: false,
known: true,
}
}
+1 -201
View File
@@ -4,17 +4,10 @@
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
use super::{mods_shot, saves_shot};
use unlucky_presenter::toast::ToastKind;
use crate::app::components::motion;
use crate::app::view;
use crate::pages;
use unlucky_presenter::router::Route;
use super::App;
impl App {
@@ -41,38 +34,6 @@ impl App {
self.state.toasts.push(ToastKind::Info, text);
}
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
if self.state.launch_ends != self.launch_ends_seen {
self.launch_ends_seen = self.state.launch_ends;
if let Some(finished) = self.state.last_launch_end.as_deref() {
let text = view::instances::launch_toast(finished, self.lang());
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
&& finished.failure.is_none()
{
ToastKind::Success
} else {
ToastKind::Error
};
self.state.toasts.push(kind, text);
// M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。
let ended = finished.clone();
self.open_failure_dialog(&ended);
}
}
// M224:详情页 ⇄ MODS 区的绑定与取数(换实例就重新列一次 mods/)。
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
self.sync_mods();
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure();
// M247(复审 r1 P2-1):被确认的世界已经从快照里消失 ⇒ 收起那个确认框。
// 不这样的话主按钮就是一次静默空操作(点了没删、也不说一句)。
self.settle_saves_confirm();
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
@@ -82,180 +43,19 @@ impl App {
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
let next = super::super::view::model(
self.view = super::super::view::model(
&self.state,
width,
self.height,
self.options.force_state,
category,
&draft,
self.lang(),
&self.content,
);
// M256:换页 / 换层(屏 或 详情 id 变了)时才把**上一帧的模型**移进 `previous_view`。
// 用 `replace`(移动)而不是克隆:旧页要的是**转换开始那一刻的快照**,之后 presenter 再怎么
// 刷新都不该动它(否则会看到"两个页面同时在动内容")。
// 同一屏的普通刷新(下载进度 / 主题 / 语言)不换——否则每来一条事件就多留一份模型。
if (self.view.screen, detail_key(&self.view)) != (next.screen, detail_key(&next)) {
self.previous_view = Some(std::mem::replace(&mut self.view, next));
} else {
self.view = next;
}
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
//
// M256:**退场期间要留住旧框**。逻辑状态(`state.dialogs`)在用户点下去的那一拍就关掉了
// (所以 `dialog_modal()` 变 `None`),但画面还要缩着退出去 150ms——那一份模型必须留到
// 退场跑完(`App::sync_motion` 收到 `take_modal_closed()` 时置 `None`)。
// 判据用 `motion::modal_closing()` 而不是"有没有动效":关着动效时它恒为假 ⇒ 当场清。
let next_modal = self.dialog_modal();
if next_modal.is_some() || !motion::modal_closing() {
self.dialog_model = next_modal;
}
self.view_dirty = false;
self.refreshes += 1;
}
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
///
/// 判据由 presenter 给(`SavesState::take_closed_confirm`:那个世界从快照里消失了),
/// **框的 id 也由它带着**——所以只收我们自己那个框,崩溃卡片/进度框一概不碰。
///
/// 为什么必须收:目标已经作废,主按钮就成了一次**静默空操作**(用户点了「删除世界」,
/// 框关了、什么也没发生、也没一句解释)。少删是安全的,但"看起来坏了"不是可接受的结果。
fn settle_saves_confirm(&mut self) {
let Some(dialog) = self.state.saves.take_closed_confirm() else {
return;
};
if self
.state
.dialogs
.current()
.is_some_and(|current| current.id == dialog)
{
self.close_dialog();
}
}
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
/// 详情页 ⇄ MODS 区的绑定与取数。
///
/// 判据只有一条:**绑定变了**(进详情页 / 换实例 / 退出详情页)。
/// - 绑定函数自己会清掉旧快照(A 的列表不许画在 B 的详情页上,见 presenter 的 `ModsState::bind`);
/// - 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Mods(List)` 去后台列目录。
///
/// 为什么放在 `refresh_view` 里:它是**唯一**的「路由变了」漏斗(点行、返回、首屏自动选中都走它)。
/// 派发会再进一次 `refresh_view`(`dispatch` → `pump` → 重建),但那时 `bound` 已经相等,
/// 这一支直接返回,不会重复发请求、也不会无限递归。
fn sync_mods(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.mods.bound == wanted {
return;
}
self.state.bind_mods(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_MODS_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `mods_shot` 的模块文档)。
if mods_shot::enabled() {
self.state.mods.changed(mods_shot::view(&id));
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒(`watch_until`),
// 而这里只是"进详情页顺手后台列一次目录"——结果在下一拍心跳(1 Hz)落地就够了。
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红(`subscription` 的三条用例立刻红:实测过)。
self.backend
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
}
/// 详情页 ⇄ SAVES 区的绑定与取数(M235)。
///
/// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动;
/// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。
///
/// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。
fn sync_saves(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.saves.bound == wanted {
return;
}
self.state.bind_saves(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
if saves_shot::enabled() {
saves_shot::seed(self, &id);
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
// 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由:
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。
self.backend
.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id))));
}
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
///
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
/// **只弹一次**——第二次是同一件事的重复,再弹就是刷屏(用户已经看见了那句红字)。
/// 换了文件、换了实例、或失败大类变了(例如"不覆盖冲突"变成"被占用"),三元组就变了,
/// 于是照样会再弹一次:提示语跟的是"新事实",不是"点击次数"。
fn note_mods_failure(&mut self) {
let Some(failure) = self.state.mods.failed.as_ref() else {
self.mods_failed_seen = None;
return;
};
let key = (failure.instance.clone(), failure.file.clone(), failure.kind);
if self.mods_failed_seen.as_ref() == Some(&key) {
return;
}
let text = pages::mods_failure_line(failure, self.lang().catalog());
self.state.toasts.push(ToastKind::Error, text);
self.mods_failed_seen = Some(key);
}
}
/// 详情的身份键(`None` = 不在详情页):与 `app/update/motion_sync.rs` 的 `detail` 同一判据。
fn detail_key(model: &pages::Model) -> Option<&str> {
model
.instances
.detail
.as_ref()
.map(|detail| detail.id.as_str())
}
-266
View File
@@ -1,266 +0,0 @@
//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程
//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及
//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致:
//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、
//! 计数、「有图标」标记走的都是产品代码;
//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与
//! `presenter::host::saves::tests`(派生运行目录 → 快照)。
//!
//! ```text
//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \
//! unlucky-launcher-iced --screenshot=saves.png --software-renderer
//! ```
/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_SAVES_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。
///
/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」
/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。
const NEWEST: u64 = 1_714_557_600_000;
/// 比 [`NEWEST`] 早 32 天。
const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000;
/// 比 [`NEWEST`] 早 42 天。
const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
use unlucky_presenter::dto::{
SavesListView, ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind,
ScreenshotView, WorldView,
};
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
dir: dir.to_string(),
icon,
modified_ms,
};
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>, preview: ScreenshotPreview| {
ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
preview,
}
};
SavesListView {
instance: instance.to_string(),
dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(),
exists: true,
worlds: vec![
world("Better World", true, Some(NEWEST)),
world("Redstone Lab", true, Some(MIDDLE)),
world("Skyblock", false, Some(OLDEST)),
world("Test Flat", false, None),
],
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
screenshots_exists: true,
// 四张截图,把缩略图墙的**四种格子**一次摆出来(M251):
// ① 白天(真像素)② 黄昏(真像素)③ 「未生成预览」占位 ④ 具名的失败原因。
// 像素是**合成的**(下面 `scene`):夹具不连真实解码器,出图只证明墙的排版与状态,
// 解码由 presenter 的 `host::saves::thumbs` 用例用真 PNG 钉住。
screenshots: vec![
shot(
"2024-05-01_10.00.00.png",
1_887_436,
Some(NEWEST),
ScreenshotPreview::Ready(scene(1)),
),
shot(
"2024-04-30_21.14.05.png",
2_097_152,
Some(MIDDLE),
ScreenshotPreview::Ready(scene(2)),
),
shot(
"2024-04-20_08.30.00.png",
964_608,
None,
ScreenshotPreview::NotGenerated,
),
shot(
"2024-04-11_19.02.41.png",
9_437_184,
Some(OLDEST),
ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9437184 > 8388608".to_string(),
}),
),
],
skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
last_op: None,
}
}
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
///
/// | 取值 | 出图抓什么 |
/// |---|---|
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
/// | `confirm` | 删除的二次确认模态框 |
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// 默认:列表 + 行操作区。
Plain,
/// 行内重命名进行中。
Rename,
/// 删除确认模态。
Confirm,
/// 备份成功的回执。
BackedUp,
/// 写操作被「游戏在运行」挡下。
Running,
}
fn mode() -> Mode {
match std::env::var("ULL_SAVES_FIXTURE")
.unwrap_or_default()
.trim()
{
"rename" => Mode::Rename,
"confirm" => Mode::Confirm,
"backed-up" => Mode::BackedUp,
"running" => Mode::Running,
_ => Mode::Plain,
}
}
/// 注入这一屏需要的状态(**只走产品路径的入口**:`changed` / `start_rename` / `ask_delete` /
/// `dialogs.push`,一处新渲染分支都没有)。
pub fn seed(app: &mut crate::app::state::App, instance: &str) {
let mode = mode();
let mut view = view(instance);
if mode == Mode::BackedUp {
view.last_op = Some(unlucky_presenter::dto::SavesOped::BackedUp {
dir: "Better World".to_string(),
file: "Better World-20240501-100000.zip".to_string(),
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
bytes: 84_213_760,
skipped: 0,
});
}
let first = view.worlds.first().map(|world| world.dir.clone());
app.state.saves.changed(view);
let Some(dir) = first else {
return;
};
match mode {
Mode::Rename => app.state.saves.start_rename(&dir),
// 在飞闸门那条失败(`host::saves::dispatch` 在 `running` 时直接回它)。
Mode::Running => app
.state
.saves
.failed(unlucky_presenter::dto::SavesFailureView {
instance: instance.to_string(),
file: dir,
op: Some(unlucky_presenter::dto::SavesFailureOp::Rename),
kind: unlucky_presenter::dto::SavesFailureKind::GameRunning,
detail: String::new(),
}),
Mode::Confirm => {
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
let dialog = app.state.dialogs.push(
unlucky_presenter::dialogs::DialogSource::UserAction,
title,
body,
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
);
app.state.saves.ask_delete(dialog, &dir);
}
Mode::Plain | Mode::BackedUp => {}
}
}
/// 一张**合成的**缩略图(160×90 RGBA):天空渐变 + 地面 + 太阳/月亮。
///
/// 为什么合成而不是读真图:夹具的职责是「让页面有东西可画」,而它跑在截图通道的第 3 帧——
/// 真解码要真的有一张 PNG 在盘上、还要等后台线程回来。合成像素让出图**确定**(两张主题图
/// 逐像素可比),并且明确地不假装自己是解码证据(那由 presenter 的用例负责)。
///
/// `tone`:`1` = 白天(亮天空 + 绿地面),其余 = 黄昏(橙天空 + 暗地面)。同一套几何,
/// 两种配色——墙上两格一眼能看出"每格各画各的"。
fn scene(tone: u8) -> unlucky_presenter::dto::ThumbnailView {
const W: u32 = 160;
const H: u32 = 90;
// 地平线在 2/3 高度(截图里天空总比地面多)。
const HORIZON: u32 = H * 2 / 3;
let (sky_top, sky_bottom, ground, sun): ([u8; 3], [u8; 3], [u8; 3], [u8; 3]) = if tone == 1 {
(
[128, 176, 240],
[200, 224, 248],
[86, 138, 66],
[255, 246, 186],
)
} else {
([96, 66, 96], [232, 148, 96], [58, 74, 48], [255, 214, 150])
};
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for y in 0..H {
for x in 0..W {
let (r, g, b) = if y < HORIZON {
// 天空:从顶到底线性插值(`t` = 0..=255 的定点比例)。
let t = y * 255 / HORIZON.max(1);
let mix = |from: u8, to: u8| -> u8 {
let span = i32::from(to) - i32::from(from);
(i32::from(from) + span * t as i32 / 255) as u8
};
(
mix(sky_top[0], sky_bottom[0]),
mix(sky_top[1], sky_bottom[1]),
mix(sky_top[2], sky_bottom[2]),
)
} else {
// 地面:按 3px 一条的条纹给出「方块地形」的观感。
let stripe = (y / 3) % 2 == 0;
let shade: u8 = if stripe { 0 } else { 14 };
(
ground[0].saturating_sub(shade),
ground[1].saturating_sub(shade),
ground[2].saturating_sub(shade),
)
};
// 太阳/月亮:右上角一个 18px 的圆(够小,不会盖住画面主体)。
let (dx, dy) = (x as i32 - 126, y as i32 - 26);
let (r, g, b) = if dx * dx + dy * dy <= 18 * 18 {
(sun[0], sun[1], sun[2])
} else {
(r, g, b)
};
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
unlucky_presenter::dto::ThumbnailView {
width: W,
height: H,
pixels,
}
}
+8 -154
View File
@@ -5,9 +5,6 @@
//!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use crate::app::message::Message;
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode;
@@ -33,18 +30,14 @@ impl App {
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
diag!(
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
);
}
/// 按「强调色 / 配色来源是否接管」重建 [`Ui`]。
///
/// **优先级(M250):显式强调色 > D38 取色方案 > 出厂品牌板**。手填的色是直接意图,取色是从
/// 图片派生的,所以强调色只在用户真的填了合法值时压过取色——两条路都只决定「颜色来自哪里」,
/// `flavor`(明暗 / 跟随系统)仍由 `self.appearance.flavor()` 决定。
/// 按「配色来源是否取色成功」重建 [`Ui`]。
///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
@@ -53,37 +46,17 @@ impl App {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha();
// 三选一规则只在 `theme::tokens` 里写一遍:出图 harness 也调它(两头各抄一份迟早
// 出现「图上有强调色、产品里没有」,而那种偏差在图上看不出来)。
let (ui, colors) = crate::theme::tokens(
self.appearance.flavor(),
self.appearance.accent.color(),
self.appearance.derived_scheme().copied(),
self.families,
);
match colors {
crate::theme::Colors::Accent => {
// M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。
diag!(
"[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;取色方案 palette={} 被压住)",
self.appearance.accent.log_token(),
self.appearance.palette_level()
);
}
crate::theme::Colors::Scheme => {
diag!(
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
Ui::from_scheme(*scheme, self.families)
}
crate::theme::Colors::Board => {
// 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。
if let Some(error) = self.appearance.accent.error() {
diag!("[appearance] accent 非法({error:?})→ 出厂板,错行见设置页");
None => Ui::new(self.appearance.flavor(), self.families),
}
}
}
self.ui = ui.with_panel_alpha(panel_alpha);
.with_panel_alpha(panel_alpha);
}
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
@@ -99,8 +72,6 @@ impl App {
}
let mode = self.bridge.load().1;
self.theme_mode = mode;
// M229:设置页那一行画的是这个字段(唯一写者见 [`App::set_theme_mode`])。
self.settings.language_appearance.theme = mode;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
@@ -108,125 +79,8 @@ impl App {
}
}
/// 换主题档(M229:设置页「通用 → 主题」→ `pages::Message::Theme`)。
///
/// 四件事:①改 `theme_mode`(唯一真相)②同步设置页那一行的档位 ③重建 `Ui`(色板换了,
/// `Theme::name` 与 49 个角色一起变,iced 才会重算缓存)④落盘 `appearance.theme_mode`。
/// `Auto` 自己没有颜色,要等系统回答——所以返回 `iced::system::theme()` 那个 Task,
/// 由既有的 `Message::SystemTheme` 分支落地(它只在 `theme_mode == Auto` 时吃这条消息)。
pub(in crate::app) fn set_theme_mode(&mut self, mode: ThemeMode) -> Task<Message> {
self.settings.language_appearance.theme = mode;
if mode == self.theme_mode {
return Task::none();
}
self.theme_mode = mode;
let prefs = self.appearance_prefs.clone();
self.bridge.persist(&prefs, mode);
match self.bridge.path() {
Some(path) => diag!("[theme] mode={} 已写回 {}", mode.as_str(), path.display()),
None => diag!(
"[theme] mode={} 未落盘(数据根解析不出来,内存态运行):重启会回到旧值",
mode.as_str()
),
}
match mode {
ThemeMode::Light => {
self.set_flavor(SchemeFlavor::Light);
self.after_theme_switch();
Task::none()
}
ThemeMode::Dark => {
self.set_flavor(SchemeFlavor::Dark);
self.after_theme_switch();
Task::none()
}
// 跟随系统:当前先按已有深浅画,紧接着由系统主题回答覆盖
// (`set_flavor` 有「同档不重建」的保护,所以这里不必先猜一次)。
ThemeMode::Auto => iced::system::theme().map(Message::SystemTheme),
}
}
/// 换档之后重建视图模型 + 记一行取证日志(色板名就是证据:`ink` / `silver`)。
fn after_theme_switch(&mut self) {
self.view_dirty = true;
self.refresh_view();
diag!(
"[theme] mode={} board={} Theme::name={}",
self.theme_mode().as_str(),
self.board_name(),
self.ui.name()
);
}
/// 当前主题档(设置页那一行的选中态来自 `settings.groups`,这里是取证与测试的读数口)。
pub(in crate::app) fn theme_mode(&self) -> ThemeMode {
self.theme_mode
}
/// 当前色板名(日志与测试用:`ink` / `silver`;取色方案接管时仍报它所属深浅的那块)。
pub(in crate::app) fn board_name(&self) -> &'static str {
self.ui.board().name()
}
/// 当前令牌求值器(测试与截图日志用)。
pub(in crate::app) fn ui(&self) -> &Ui {
&self.ui
}
}
#[cfg(test)]
mod tests {
use super::super::App;
use crate::app::components::feedback::progress;
use crate::cli::Options;
use crate::pages::appearance::Change;
use crate::theme::Families;
use unlucky_design::color::{Color, SchemeFlavor};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 强调色接线(M250 ACCENTWIRE)的 App 级判据:`rebuild_ui` 真的换了令牌。
///
/// **这条是判据一的变异探针**:把 [`App::rebuild_ui`] 改回「恒走 `Ui::new` 出厂板」
/// (或在它前面短路掉 `accent.color()` 分支),下面 `assert_ne!` 当场红——`pages/appearance`
/// 那条用例只证明「`Ui::with_accent` 能给出不同的色」,抓不到「谁去调它」。
///
/// 抓得住:接线不存在 / 被短路 / 强调色解析结果没进 `Ui`。
/// 抓不住:像素(样式闭包被别处覆盖)——那是出图的事。
#[test]
fn rebuild_ui_follows_the_configured_accent() {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
);
// 出厂态:同一组件(进度指示条 = `Role::Primary`)取到的值
let (_, factory_primary) = progress::linear_colors(app.ui());
assert_eq!(app.ui().name(), "ull-silver");
// 设置页填色 → `on_appearance` 会 rebuild 一次(与真机路径同一个函数)
app.on_appearance(Change::Accent(Color::rgb(0x00, 0x6A, 0x5C).to_hex()));
let (_, accent_primary) = progress::linear_colors(app.ui());
assert_ne!(
factory_primary, accent_primary,
"填了自定义强调色,`rebuild_ui` 却还给出出厂板的 Primary ⇒ 接线没落地"
);
assert_eq!(
app.ui().flavor(),
SchemeFlavor::Light,
"强调色只换颜色来源,不改明暗"
);
}
}
-301
View File
@@ -11,14 +11,10 @@
//! | 条件 | 为什么需要 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)
@@ -47,7 +43,6 @@ use std::time::Instant;
use iced::{window, Subscription};
use super::components::motion;
use super::message::Message;
use super::state::App;
@@ -90,306 +85,10 @@ 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 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}
#[cfg(test)]
mod tests {
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
//!
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」的假绿)。
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟);动效全局态是 thread-local,天然隔离。
use super::*;
use crate::app::components::motion::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` 同构)。
///
/// 定档走 `motion::configure_for_test`(P2-5):产品入口读宿主机「减少动效」开关,用例在关了
/// 系统动画的机器上会红;真探针的接线另有专门用例守。
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_for_test(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_for_test(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_page` 的 `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_for_test(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/**`**,只看代码行;唯一允许的第二个文件是 `pages/harness.rs`
/// (`pages_demo` 这条独立 example 自己的 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::<Vec<_>>()
.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<std::path::PathBuf>) {
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);
}
}
}
/// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。
/// 口径:`pages/**` 的产品代码不许出现时长的任何入口(`DurationToken` / `page_spec` /
/// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档由
/// `components/motion/transition.rs` 那张表决定(D31)。
///
/// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴(变异:
/// 在 `pages/download.rs` 顶上加 `use unlucky_design::motion::DurationToken;` ⇒ 红)。
/// **抓不住**:①某档数字写错(那是 `components/motion/tests` 的登记用例);②绕过符号名的
/// 路径(转一手、通配导入)——只按符号名扫,不做类型分析。
#[test]
fn pages_cannot_reach_for_a_duration() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("pages");
let mut files = Vec::new();
collect_rust_files(&dir, &mut files);
assert!(
files.len() >= 20,
"只扫到 {} 个页面文件:扫描逻辑坏了",
files.len()
);
const FORBIDDEN: [&str; 5] = [
"DurationToken",
"page_spec",
"modal_spec",
"FRAME_MS",
"advance(",
];
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
// 只看产品代码(测试与文档注释不算:`#[cfg(test)]` 之后整段跳过)。
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"));
for symbol in FORBIDDEN {
if code.clone().any(|line| line.contains(symbol)) {
let name = path
.strip_prefix(&dir)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
hits.push((name, symbol));
}
}
}
assert!(
hits.is_empty(),
"页面层不许直接碰时长/时间轴(用哪一档是 `transition.rs` 的表决定的):{hits:?}"
);
}
}
+7 -11
View File
@@ -12,8 +12,6 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)]
mod appearance;
#[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter;
use super::message::Message;
@@ -54,11 +52,11 @@ fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state.navigate(Route::Logs);
app.refresh_view();
assert_eq!(app.ui().name(), "ull-silver");
assert_eq!(app.ui().name(), "ull-m3-light");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui().name(), "ull-ink", "切主题要换主题名");
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
@@ -78,7 +76,7 @@ fn screenshot_mode_never_waits_for_the_system_theme() {
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
let app = headless(options, SchemeFlavor::Light);
assert!(app.has_screenshot() && app.ui().name() == "ull-silver");
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
}
#[test]
@@ -336,7 +334,7 @@ fn the_page_navigation_entry_also_detaches() {
fn screenshot_mode_ignores_the_settings_file_theme() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
app.apply_config_theme();
assert_eq!(app.ui().name(), "ull-silver", "截图模式的主题只认命令行");
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
}
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
@@ -355,7 +353,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-ink", "auto 模式要跟随系统");
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
@@ -363,7 +361,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
pinned.ui().name(),
"ull-ink",
"ull-m3-dark",
"配置文件说 dark,系统说 light:不许改"
);
@@ -378,9 +376,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
explicit.ui().name(),
"ull-ink",
"ull-m3-dark",
"--theme 优先级最高,系统主题不许盖"
);
}
mod theme;
+3 -3
View File
@@ -181,7 +181,7 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() {
"取色成功后 Ui 要换成解析出的方案"
);
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
assert_eq!(app.ui().name(), "ull-silver");
assert_eq!(app.ui().name(), "ull-m3-light");
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
assert_ne!(
app.ui().color(unlucky_design::color::Role::Primary),
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
}
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
@@ -1,145 +0,0 @@
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
//!
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
//!
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
LoaderInfo, LoaderKind,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::message::Message;
use crate::app::state::{App, TICK};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
#[derive(Debug, Default)]
struct FailingSaveBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
}
impl Backend for FailingSaveBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command.clone());
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
self.queued.push(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Error,
text: "内存超出范围".to_string(),
remaining_ms: 0,
}));
}
}
}
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
fn snapshot() -> Vec<InstanceSummary> {
vec![InstanceSummary {
id: "survival".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: LaunchArgsView {
instance_id: "survival".to_string(),
memory_mb: Some(4096),
memory_effective_mb: 4096,
..LaunchArgsView::default()
},
}]
}
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let backend = FailingSaveBackend {
dispatched: Rc::clone(&dispatched),
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
};
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
///
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
/// 下面的前置断言会红)。
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
#[test]
fn failed_save_keeps_the_draft_through_the_whole_loop() {
let (mut app, dispatched) = app_with_snapshot();
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
app.advance(TICK);
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
assert!(!app.launch_form.dirty);
// 用户把内存改成非法值(host 会拒),然后点保存。
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Memory("4g".to_string()),
)));
assert_eq!(app.launch_form.memory, "4g");
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Save,
)));
assert!(
dispatched
.borrow()
.iter()
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
"保存要把命令派发出去"
);
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
app.advance(TICK);
assert_eq!(
app.launch_form.memory, "4g",
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
);
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
}
-187
View File
@@ -1,187 +0,0 @@
//! 主题三档(Ink / Silver / 跟随系统)的落点测试(M229;从 `app/tests.rs` 拆出守 400 行)。
//!
//! 这一份只管**用户点设置页那三枚 chip 之后**发生的事:换色板、同步设置组状态、落盘。
//! `Auto`(跟随系统)那条路在 `app::tests::the_system_theme_never_overrides_an_explicit_one`。
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一个把配置写在临时文件里的 App:测「落盘」必须真的过文件(内存桥读回来永远是出厂值)。
fn with_config() -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("ull-theme-m229-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, "{}").expect("写配置文件");
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
/// M229:设置页「通用 → 主题」三档(Ink / Silver / 跟随系统)真的换色板、真的落盘。
///
/// 抓得住:①`pages::Message::Theme` 没接进 `on_page`(点了没反应)②接了但不换 `Ui`
/// (只有选中态变了,界面颜色还是旧的)③同步设置组状态漏了(设置页首帧/刷新后画错档)
/// ④落盘漏了(重启回旧值——这里**真的过文件**:`with_config` 用 `SettingsBridge::with_path` 写临时
/// 配置,再用同一路径重新 `load()` 读回;内存桥读回来永远是出厂值,验不了落盘)。
/// 抓不住:色板好不好看、像素级差异(那要截图,见 `DevDocs/artifacts/m229/`)。
#[test]
fn the_theme_message_switches_the_board_and_persists_it() {
let (mut app, dir) = with_config();
assert_eq!(app.board_name(), "silver", "首帧是浅色主方案(Silver)");
// 选 Ink(= 深色主方案)
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Dark)));
assert_eq!(app.theme_mode(), ThemeMode::Dark);
assert_eq!(app.board_name(), "ink", "Ink 深板要真切过去");
assert_eq!(app.ui().flavor(), SchemeFlavor::Dark);
assert_eq!(
app.settings.language_appearance.theme,
ThemeMode::Dark,
"设置页那一行的档位要跟着改"
);
// 落盘:真的过了文件(重新打开同一个配置读回来就是 dark)
assert_eq!(
SettingsBridge::with_path(dir.join("config/launcher.json"))
.load()
.1,
ThemeMode::Dark,
"主题档要落盘"
);
// 选 Silver(= 浅色主方案)
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
assert_eq!(app.board_name(), "silver");
assert_eq!(app.ui().flavor(), SchemeFlavor::Light);
assert_eq!(
SettingsBridge::with_path(dir.join("config/launcher.json"))
.load()
.1,
ThemeMode::Light
);
// 同一档再点一次:不许白重建
let refreshes = app.refreshes;
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型");
std::fs::remove_dir_all(&dir).ok();
}
/// **截图通道把主题档钉死在 `--screenshot-theme`**,不读机器上的 `launcher.json`(D30 可复现)。
///
/// 复审 r2 P2-a 指出:`construct.rs` 里新加的这条分支没有回归测试——于是「哪天有人把它改回
/// `bridge.load().1`」不会有人发现,两次不同配置的机器又会出不同颜色的图。
///
/// 判据:**同一份截图参数 + 两份不同的机器配置 ⇒ 同一个档位**,且等于参数(浅→`Light`、深→`Dark`);
/// 反证:非截图通道下同样两份配置**确实**分出两个档(否则这条测试等于没测到分支)。
#[test]
fn the_screenshot_channel_pins_the_theme_mode_instead_of_reading_the_file() {
use std::path::{Path, PathBuf};
/// 写一份只含 `appearance.theme_mode` 的临时配置,并用它构造 App。
fn with_mode(
dir_name: &str,
json: &str,
options: Options,
flavor: SchemeFlavor,
) -> (App, PathBuf) {
let dir =
std::env::temp_dir().join(format!("ull-shot-theme-{dir_name}-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, json).expect("写配置文件");
let app = App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
fn cleanup(dir: &Path) {
std::fs::remove_dir_all(dir).ok();
}
let dark_file = r#"{"appearance":{"theme_mode":"dark"}}"#;
let light_file = r#"{"appearance":{"theme_mode":"light"}}"#;
// ① 截图参数给浅色:机器配置是 dark 也一样出 Light(钉死值赢)
let shot_light = Options {
screenshot: Some(PathBuf::from("unused.png")),
screenshot_theme: Some(SchemeFlavor::Light),
..Options::default()
};
let (from_dark_file, d1) = with_mode(
"light-a",
dark_file,
shot_light.clone(),
SchemeFlavor::Light,
);
let (from_light_file, d2) = with_mode("light-b", light_file, shot_light, SchemeFlavor::Light);
assert_eq!(
from_dark_file.theme_mode(),
ThemeMode::Light,
"截图模式下档位必须由 --screenshot-theme 决定,不许读文件里的 dark"
);
assert_eq!(
from_dark_file.theme_mode(),
from_light_file.theme_mode(),
"两份机器配置必须出同一个档位"
);
assert_eq!(from_dark_file.board_name(), "silver");
assert_eq!(from_light_file.board_name(), "silver");
// ② 反方向:截图参数给深色 ⇒ 即使文件写 light 也是 Dark/Ink
let shot_dark = Options {
screenshot: Some(PathBuf::from("unused.png")),
screenshot_theme: Some(SchemeFlavor::Dark),
..Options::default()
};
let (from_light_file_dark, d3) = with_mode("dark-a", light_file, shot_dark, SchemeFlavor::Dark);
assert_eq!(from_light_file_dark.theme_mode(), ThemeMode::Dark);
assert_eq!(from_light_file_dark.board_name(), "ink");
// ③ 反证:非截图通道下,同样的两份配置**确实**分出两个档(证明这条分支真的存在)
let (interactive_dark, d4) =
with_mode("plain-a", dark_file, Options::default(), SchemeFlavor::Dark);
let (interactive_light, d5) = with_mode(
"plain-b",
light_file,
Options::default(),
SchemeFlavor::Light,
);
assert_eq!(
interactive_dark.theme_mode(),
ThemeMode::Dark,
"产品路径要读文件"
);
assert_eq!(
interactive_light.theme_mode(),
ThemeMode::Light,
"产品路径要读文件"
);
for dir in [d1, d2, d3, d4, d5] {
cleanup(&dir);
}
}
+12 -106
View File
@@ -20,15 +20,11 @@
//!
//! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---|
//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 截图 / 有 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;
use iced::{window, Task};
@@ -46,48 +42,18 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod account;
// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回)
mod adopt;
mod agent;
mod appearance;
pub(super) mod boot_env;
mod boot_env;
mod clock;
mod content;
mod content_pack;
mod dialog;
mod dialog_fixture;
mod download;
mod instances;
mod motion_sync;
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 {
/// 一条消息进来,改状态并返回后续任务。
///
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
let task = self.route_message(message);
self.sync_motion();
task
}
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
fn route_message(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => self.on_boot(),
Message::WindowReady(id) => {
@@ -106,7 +72,7 @@ impl App {
match hwnd {
Some(_) => self.apply_window_alpha("boot"),
None => {
diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None);
}
}
@@ -120,7 +86,7 @@ impl App {
iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
};
diag!("[theme] 系统主题 = {mode:?}");
eprintln!("[theme] 系统主题 = {mode:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
@@ -150,18 +116,7 @@ impl App {
Task::none()
}
Message::Page(message) => self.on_page(message),
Message::Dialog(message) => dialog::on_dialog(self, message),
// M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭)
Message::Adopt(message) => self.on_adopt(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)
}
Message::Agent(message) => self.on_agent(message),
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => {
self.state.navigate(route_of(nav));
@@ -189,7 +144,7 @@ impl App {
Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
diag!("[gallery] {} pressed", message.id());
eprintln!("[gallery] {} pressed", message.id());
Task::none()
}
}
@@ -202,16 +157,12 @@ impl App {
fn on_boot(&mut self) -> Task<Message> {
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 的依赖)出面,
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
// 「没配置」与「配了但登录失败」。
diag!("{}", unlucky_cli::commands::msa_client_id_log_line());
eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line());
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
@@ -222,8 +173,6 @@ 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() {
@@ -242,7 +191,7 @@ impl App {
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = boot_env::env_start_route() {
diag!("[boot] ULL_START_ROUTE → {route:?}");
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
@@ -250,10 +199,6 @@ 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) {
diag!("[boot] ULL_DIALOG_FIXTURE → {mode}");
}
self.auto_select();
self.view_dirty = true;
@@ -265,66 +210,27 @@ 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 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> {
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
// 「有轨道」又让下面的 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()
}
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
/// [`CAPTURE_AT_FRAME`] 帧出图。
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
motion::advance();
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
self.motion_fixture();
}
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK {
@@ -350,9 +256,9 @@ impl App {
};
match crate::shot::save(screenshot, &path) {
Ok(summary) => {
diag!("{}", summary.describe());
eprintln!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
diag!(
eprintln!(
"[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(),
crate::shot::flavor_slug(&self.ui),
@@ -360,7 +266,7 @@ impl App {
self.state.route()
);
}
Err(error) => diag!("[shot] 失败:{error}"),
Err(error) => eprintln!("[shot] 失败:{error}"),
}
iced::exit()
+2 -25
View File
@@ -95,7 +95,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request));
}
}
Panel::Microsoft => return self.on_account(Account::StartDevice),
Panel::Microsoft => return self.on_account(Account::StartBrowser),
Panel::None => {}
},
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -136,8 +136,7 @@ impl App {
match account.kind {
AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft;
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
self.dispatch(AppCommand::StartDeviceLogin);
self.dispatch(AppCommand::StartBrowserLogin);
}
AccountKind::Authlib => {
self.account_form.open(Track::Authlib);
@@ -174,23 +173,6 @@ 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;
@@ -216,11 +198,6 @@ 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;
@@ -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, countdown, ..
code, remaining, ..
} => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
}
other => panic!("进行中状态必须可见,实际 {other:?}"),
}
-358
View File
@@ -1,358 +0,0 @@
//! 「导入已有版本」向导的落点(M223 GUIADOPT)。
//!
//! ## 四段各自发什么、等什么
//!
//! | 用户动作 | 发什么 | 等什么回程 |
//! |---|---|---|
//! | 页头「导入」/ [`AdoptMessage::Open`] | `AdoptCommand::Scan` | `AdoptEvent::Scan` |
//! | 选目标 + 点「预检」/ [`AdoptMessage::Check`] | `AdoptCommand::Preview` | `Planned` / `Failed` |
//! | 点「确认导入」/ [`AdoptMessage::Confirm`] | `AdoptCommand::Confirm` | `Done` / `Failed` |
//! | 「返回」/ [`AdoptMessage::Back`] | `AdoptCommand::Scan`(重扫一次,顺手清掉预检/失败) | `Scan` |
//! | 「关闭」/ [`AdoptMessage::Close`] | `AdoptCommand::Dismiss` | `Cleared` |
//!
//! ## 三段纪律
//!
//! 1. **不在本地判「能不能导入」**:候选可不可用、落点在哪、有哪些问题全部来自 presenter 的回程
//! (`AdoptState`)。这里只把「用户点了什么」翻成命令;
//! 2. **确认只发用户填过的那份请求**(版本 + 名字 + 目标档),预检与确认**同一份**——
//! 于是「预检 A、写 B」这种错位不可能发生;
//! 3. **在等回程时把主按钮并掉**(`Form::busy`):连点两次「确认」不会写两条实例记录。
use iced::Task;
use unlucky_presenter::dto::{AdoptCommand, AdoptRequestView, AppCommand};
use crate::app::adopt::{AdoptMessage, Busy};
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 向导的一条消息。
pub(super) fn on_adopt(&mut self, message: AdoptMessage) -> Task<Message> {
match message {
AdoptMessage::Open => {
self.adopt_form.open();
self.dispatch_adopt(AdoptCommand::Scan);
}
AdoptMessage::Close => {
// 登记在飞时不吃「关闭」:core 那条线程已经开跑了,写了盘却不回报 Done
// 就会出现「实例已存在但界面什么都没说」——等它交卷(几十毫秒级)。
if self.adopt_form.busy == Busy::Importing {
return Task::none();
}
self.adopt_form.close();
self.dispatch_adopt(AdoptCommand::Dismiss);
}
AdoptMessage::Select(version_id) => {
self.adopt_form.selected = Some(version_id);
}
AdoptMessage::Name(name) => self.adopt_form.name = name,
AdoptMessage::Target(target) => {
// 登记在飞时不吃「换档」:它会派一条零写盘的预览把 `adopt_epoch` +1,
// 于是在飞的 Confirm 交卷时 Done 被代次丢掉——实例写进去了,界面什么都没说。
// 连本地档位也不改:界面上显示 A 档、盘上写的是 B 档,是最糟的一种不一致。
if self.adopt_form.busy == Busy::Importing {
return Task::none();
}
self.adopt_form.target = target;
// 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着)
self.adopt_form.checked = false;
// 共享档 / 版本目录档会写进**别人的目录**:一被点选就把后果原文拿出来
// (零写盘的预检,见 `Form::stage` 的收窄:这一次结果落在 Pick 段当提示读)。
if target.touches_foreign_dir() {
if let Some(request) = self.adopt_request() {
self.adopt_form.busy = Busy::Checking;
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
}
}
}
AdoptMessage::Check => {
if let Some(request) = self.adopt_request() {
self.adopt_form.checked = true;
self.adopt_form.busy = Busy::Checking;
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
}
}
AdoptMessage::Confirm => {
if let Some(request) = self.adopt_request() {
self.adopt_form.busy = Busy::Importing;
self.dispatch_adopt(AdoptCommand::Confirm(Box::new(request)));
}
}
AdoptMessage::Back => {
// 同 Close / Target:登记在飞时「返回」会把 `adopt_epoch` +1(它派 Scan),
// 在飞 Confirm 的 Done 就再也到不了界面。
if self.adopt_form.busy == Busy::Importing {
return Task::none();
}
// 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废,
// 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。
self.adopt_form.checked = false;
self.adopt_form.busy = Busy::Scanning;
self.dispatch_adopt(AdoptCommand::Scan);
}
}
Task::none()
}
/// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。
/// 出图通道的种子(`ULL_ADOPT_STATE`,与 `ULL_JAVA_STATE` / `ULL_AI_STATE` 同一族)。
///
/// **只在 `--screenshot` 下生效**:交互模式读这个环境变量没有意义(也没有人会设它)。
/// 不认识的名字一律忽略,退回正常启动路径(向导保持关闭)。
pub(in crate::app) fn hydrate_adopt_state(&mut self) {
if self.options.screenshot.is_none() {
return;
}
let Ok(raw) = std::env::var(crate::app::adopt::shot::ENV) else {
return;
};
if let Some((form, state)) = crate::app::adopt::shot::seed(&raw) {
self.adopt_form = form;
self.state.adopt = state;
}
}
pub(super) fn settle_adopt(&mut self) {
self.adopt_form.settled();
}
/// 当前表单能拼出的请求(没选版本就没有请求——按钮在那边也是禁用的)。
fn adopt_request(&self) -> Option<AdoptRequestView> {
let version_id = self.adopt_form.selected.clone()?;
let name = self.adopt_form.name.trim();
Some(AdoptRequestView {
version_id,
name: (!name.is_empty()).then(|| name.to_string()),
target: self.adopt_form.target,
})
}
fn dispatch_adopt(&mut self, command: AdoptCommand) {
self.dispatch(AppCommand::Adopt(Box::new(command)));
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AdoptCommand, AdoptEvent, AdoptTargetView, AppCommand, AppEvent, Backend,
};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::adopt::{AdoptMessage, Busy};
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 {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 记账后端:把 `dispatch` 收到的命令留在台账里(`MockBackend` 只认自己那几条,
/// 「有没有派出去」根本看不见——而 P2-1 的判据正是「不许派」)。
#[derive(Debug, Default)]
struct RecordingBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
}
impl Backend for RecordingBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 带台账的 App(返回台账句柄,测试直接查「派了什么」)。
fn recording() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(RecordingBackend {
dispatched: Rc::clone(&dispatched),
}),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// 台账里有没有派过某条 adopt 命令(只认命令种类,不看载荷内容)。
///
/// 写法是 `let ... else` + `matches!`:内层用 `match` 会被 clippy 判成
/// `match expression looks like matches! macro`(deny 级,CI 的 `-D warnings` 直接红)。
fn dispatched_adopt(ledger: &Rc<RefCell<Vec<AppCommand>>>, want: &str) -> bool {
ledger.borrow().iter().any(|command| {
let AppCommand::Adopt(command) = command else {
return false;
};
matches!(
(&**command, want),
(AdoptCommand::Scan, "scan")
| (AdoptCommand::Preview(_), "preview")
| (AdoptCommand::Confirm(_), "confirm")
| (AdoptCommand::Dismiss, "dismiss")
)
})
}
/// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。
///
/// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。
/// 抓不住:扫描真的返回了什么(那在 presenter 的 `host/adopt` 用例里)。
#[test]
fn opening_the_wizard_asks_for_a_scan() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
assert!(app.adopt_form.open);
assert_eq!(app.adopt_form.busy, Busy::Scanning);
assert_eq!(
app.adopt_form.target,
AdoptTargetView::InstanceDir,
"缺省不是 isolated"
);
}
/// 没选版本时「预检 / 确认」不派命令(否则会发出一条 `version_id` 为空的请求)。
///
/// 抓得住:`adopt_request()` 在没有选中项时仍拼出请求(空版本 id 打到 core 换一句
/// `adopt_version_not_found`,用户看到莫名其妙的失败)。
#[test]
fn no_request_without_a_selected_version() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Check);
assert_eq!(app.adopt_form.busy, Busy::Scanning, "Check 不该改等待态");
let _ = app.on_adopt(AdoptMessage::Confirm);
assert_eq!(app.adopt_form.busy, Busy::Scanning);
}
/// 选中 + 预检 = 一条 Preview 命令,且请求里的名字是**去过空白**的用户输入。
#[test]
fn check_sends_the_form_as_a_preview_request() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Name(" 生存 ".to_string()));
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
let _ = app.on_adopt(AdoptMessage::Check);
assert_eq!(app.adopt_form.busy, Busy::Checking);
// 请求本体不在 App 上留副本:能证明意图的只有等待态与目标档
assert_eq!(app.adopt_form.target, AdoptTargetView::Shared);
}
/// 回程到了就解除等待(`settle_adopt` 由 `pump` 调)。
#[test]
fn an_incoming_event_settles_the_wait() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
assert!(app.adopt_form.busy.waiting());
app.settle_adopt();
assert_eq!(app.adopt_form.busy, Busy::Idle);
// 事件真相进了 presenter 的状态(界面从它取词)
app.state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)),
));
assert!(app.state.adopt.scan.is_none());
}
/// 共享档一被点选就发一条**预检**(零写盘),但**不**置 `checked`——确认页要用户点「预检」。
///
/// 抓得住:`Target` 分支忘了发预检(后果原文永远不出现,契约 §5.2 失守)、
/// 或者顺手把 `checked` 也置上(点一下档位就跳到确认页)。
#[test]
fn choosing_a_foreign_target_previews_but_waits_for_the_user() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
assert_eq!(app.adopt_form.busy, Busy::Checking);
assert!(!app.adopt_form.checked, "确认页要等用户点「预检」");
// 缺省档不触发自动预检(它不会写进别人的目录)
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir));
assert!(!app.adopt_form.checked);
}
/// 登记在飞时「关闭」被忽略:core 已经在写了,关掉面板只会让 Done 无处可去。
///
/// 抓得住:Close 无条件清表单并 `Dismiss`(实例写成功了,界面却什么都没说)。
#[test]
fn closing_is_ignored_while_the_registration_is_in_flight() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Check);
app.adopt_form.busy = Busy::Importing;
let _ = app.on_adopt(AdoptMessage::Close);
assert!(app.adopt_form.open, "登记在飞时不许把面板关掉");
}
/// **P2-1**:登记在飞时,除了 Close,「返回」与「换目标档」也必须早退。
///
/// 三条都是同一条病:它们都会派一条新命令把 `adopt_epoch` +1,于是在飞 Confirm 的
/// `Done` 回来时被代次丢掉 —— 实例**写进去了**,界面却什么都没说(Close 那条早就守住了,
/// 唯独这两支漏了)。
///
/// 抓得住:`Back` 派了 `Scan`(旧 `Done` 被丢)、`Target` 派了 `Preview`(同上),
/// 以及「守卫写了但写在改本地状态之后」(表单被改成新档位,而盘上写的是旧档位)。
/// 抓不住:host 侧代次比较的真实时序(那在 presenter 的 `host/adopt` 那层)。
#[test]
fn back_and_target_are_ignored_while_the_registration_is_in_flight() {
let (mut app, ledger) = recording();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir));
// 走到「确认」那一步:真的派一条 Confirm 出去
let _ = app.on_adopt(AdoptMessage::Check);
let _ = app.on_adopt(AdoptMessage::Confirm);
assert!(
dispatched_adopt(&ledger, "confirm"),
"前提:确认要真的派出去"
);
assert_eq!(app.adopt_form.busy, Busy::Importing);
ledger.borrow_mut().clear();
// 在飞期间点「返回」:不许派 Scan
let _ = app.on_adopt(AdoptMessage::Back);
assert!(
!dispatched_adopt(&ledger, "scan"),
"登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)"
);
assert_eq!(app.adopt_form.busy, Busy::Importing, "等待态不许被改掉");
// 在飞期间换目标档:不许派 Preview,也不许改本地档位
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
assert!(
!dispatched_adopt(&ledger, "preview"),
"登记在飞时换档不许派 Preview(会把 Done 的代次顶掉)"
);
assert_eq!(
app.adopt_form.target,
AdoptTargetView::InstanceDir,
"挡下来的点击不该改出界面与盘上不一致的档位"
);
assert_eq!(app.adopt_form.busy, Busy::Importing);
}
}
+5 -6
View File
@@ -29,7 +29,6 @@
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
use crate::app::diag::diag;
use iced::keyboard::{key, Key};
use iced::Task;
@@ -142,10 +141,10 @@ impl App {
self.drain_agent();
}
SubmitOutcome::Failed(failure) => {
diag!("[agent] 提交失败({})", failure.code());
eprintln!("[agent] 提交失败({})", failure.code());
}
SubmitOutcome::Rejected => {
diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
}
}
}
@@ -205,7 +204,7 @@ impl App {
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow;
diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
@@ -280,7 +279,7 @@ impl App {
}
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
diag!("[agent] rail=detached → Route::Agent");
eprintln!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
@@ -372,7 +371,7 @@ impl App {
let _ = self.on_agent(message);
} else {
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
diag!(
eprintln!(
"[agent] Enter on focus={} handled by the composer",
self.agent.focus.name()
);
+1 -2
View File
@@ -28,7 +28,6 @@
//! `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};
@@ -74,7 +73,7 @@ impl App {
"frame=? layered=?".to_owned(),
),
};
diag!(
eprintln!(
"{}",
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
);
+2 -149
View File
@@ -1,11 +1,8 @@
//! 启动期的取证环境变量解析(`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`)。
@@ -27,147 +24,3 @@ pub(super) fn env_start_route() -> Option<Route> {
_ => None,
}
}
/// 动效档位与相位钉住(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=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
///
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
pub(super) fn env_motion_pin() -> Option<f64> {
std::env::var("ULL_MOTION_AT")
.ok()?
.trim()
.parse::<f64>()
.ok()
.filter(|ms| ms.is_finite() && *ms >= 0.0)
}
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
///
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
#[derive(Debug, Clone)]
pub(super) enum MotionFixture {
/// 跳到一个一级页(走「换页」那一档)。
Route(Route),
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
Detail,
}
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
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,
})
}
/// `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<u32> {
std::env::var("ULL_MOTION_FIXTURE_AT")
.ok()?
.trim()
.parse::<u32>()
.ok()
.filter(|beats| *beats > 0)
}
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
///
/// ## 为什么这一拍只能「先记、后落」
///
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
///
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
///
/// 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;
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());
// 后端这一拍也要在日志里:`[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()),
),
);
}
+4 -45
View File
@@ -17,7 +17,6 @@
//! [`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;
@@ -43,16 +42,6 @@ impl App {
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true;
}
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
// 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
let font_status = crate::fonts::install::status();
if font_status != self.font_seen {
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));
@@ -75,14 +64,10 @@ impl App {
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
diag!("[settings] 配置文件变了,重读主题 + 外观");
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
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 {
@@ -93,8 +78,8 @@ impl App {
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
diag!(
"[tick] n={} frames={} refreshes={} clock={} motion={}",
eprintln!(
"[tick] n={} frames={} refreshes={} clock={}",
self.ticks,
self.frames,
self.refreshes,
@@ -102,10 +87,7 @@ impl App {
"frames"
} else {
"idle-1hz"
},
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
// 也是「动画期间重绘成本」的记账口径,见报告)
crate::app::components::motion::samples()
}
);
}
}
@@ -129,35 +111,12 @@ impl App {
if events.is_empty() {
return;
}
let mut clean_scan = false;
for event in events {
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
if matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
pack
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
) {
clean_scan = true;
}
// M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除),
// 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。
let settled_adopt = matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_))
);
self.state.apply(event);
if settled_adopt {
self.settle_adopt();
}
}
self.view_dirty = true;
self.auto_select();
self.refresh_view();
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
if clean_scan {
self.continue_import_after_clean_scan();
}
}
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
+144 -43
View File
@@ -16,10 +16,9 @@
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use crate::app::diag::diag;
use unlucky_presenter::dto::{
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
ExportRequestView,
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
ImportRequestView, PackCommand,
};
use crate::pages::content::{Blocker, Content};
@@ -78,12 +77,7 @@ impl App {
self.with_query(|query| query.loader = loader)
}
pages::content::Message::Select(id) => {
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板)
self.content.selected = Some(id.clone());
self.state.content.request_detail(&id);
if let Some(request) = self.content_detail_request() {
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
}
self.content.selected = Some(id);
self.view_dirty = true;
}
pages::content::Message::SetTarget(id) => {
@@ -104,26 +98,155 @@ 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),
}
Task::none()
}
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
overwrite,
})
}
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
///
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export(
&self.content.pack,
@@ -176,26 +299,6 @@ impl App {
.is_some_and(|results| results.curseforge)
}
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
///
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref());
Some(ContentDetailRequest {
project: hit.id.clone(),
source: hit.source,
game_version: target.map(|target| target.mc_version.clone()),
loader: target.map(|target| target.loader),
})
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
@@ -209,10 +312,10 @@ impl App {
if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) {
diag!("[content] 装入被挡下:{reason}");
eprintln!("[content] 装入被挡下:{reason}");
}
if model.blockers.contains(&Blocker::NeedsPipeline) {
diag!("[content] 整合包装入未实现(M111)");
eprintln!("[content] 整合包装入未实现(M111)");
}
return None;
}
@@ -231,8 +334,6 @@ impl App {
instance: target.id.clone(),
game_version: target.mc_version.clone(),
loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
})
}
}
@@ -1,240 +0,0 @@
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
//!
//! ## 为什么单独一个文件
//!
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
//!
//! ## 两条口径(与原来同一句)
//!
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
//! 状态,不进 presenter;
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
//!
//! ## M160 加的那条纪律
//!
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
use unlucky_presenter::dto::{
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
};
use crate::pages;
use super::*;
impl App {
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
if let Some(request) = self.pack_import_request() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
request,
)))));
}
}
PackMessage::PickPage(page) => {
self.content.pack.pick_page = page;
self.view_dirty = true;
}
PackMessage::TogglePick(path) => {
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
// (所以"一次都没点过"仍然等于 `KeepAll`)。
let choice = self.content.pack.pick_choice(&path);
let flipped = match choice {
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
};
self.content.pack.pick_choices.insert(path, flipped);
self.view_dirty = true;
}
PackMessage::PickKeepAll => {
self.content.pack.pick_overwrite_all = false;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::PickOverwriteAll => {
self.content.pack.pick_overwrite_all = true;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::ConfirmImport => {
let decisions = self
.content
.pack
.decisions(self.state.pack.import.scan.as_ref());
if let Some(request) = self.pack_import_request_with(decisions) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::CancelImport => {
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
if let Some(request) =
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
{
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 预检干净(没有冲突)时直接往下导。
///
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
/// 结论也只会是"保留用户的"。
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
fn pack_import_request(&self) -> Option<ImportRequestView> {
self.pack_import_request_with(ConflictDecisionsView::default())
}
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
fn pack_import_request_with(
&self,
decisions: ConflictDecisionsView,
) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
decisions,
})
}
}
-168
View File
@@ -1,168 +0,0 @@
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
use iced::Task;
use unlucky_presenter::toast::ToastKind;
use crate::app::components::motion;
use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App;
/// 关掉当前这个框(**唯一出口**,M256)。
///
/// 逻辑状态是**当场**关的(`close_dialog()` 立刻清 `state.dialogs`)——延迟关闭会让队列里那个
/// 框在退场期间仍然"活着"(`current()` 还是它),所有按"有没有框"判断的代码都会看到一个已经
/// 点过「确定」的框。所以退场只发生在**画面上**:`refresh_view` 在 `motion::modal_closing()`
/// 期间保留旧框的视图模型,`App::sync_motion` 在退场走完那一拍把它置 `None`。
///
/// 关着动效(`MotionScale::NONE` / 截图通道)时 `begin_modal_close()` 返回 `true`,
/// `modal_closing()` 恒假 ⇒ 当场清,退回改造前的行为。
fn close(app: &mut App) {
let _ = motion::begin_modal_close();
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。退场的第一帧必须在这一拍画出来。
app.refresh_view();
}
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
// M256:退场动画期间**吞掉**弹窗消息。框正在缩小,这时再点一次「删除世界」不该再生效
// (主按钮的 `take_confirm` 已经在上一次点击里核销过目标,但用户看到的仍是"框还在")。
if motion::modal_closing() {
return Task::none();
}
match message {
DialogMessage::Primary => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
//
// **认框不认人**(复审 r1 P1-1):只核销"当前这个框"登记的目标。哪怕某条关闭路径漏清,
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
let current = app.state.dialogs.current().map(|request| request.id);
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
// M256:走统一出口(逻辑当拍关,画面多留 150ms 退场)。
// 目标已经在上一行核销 ⇒ 退场期间再点一次主按钮不会删第二个世界
// (那一拍的消息还会被 `modal_closing` 那道闸门吞掉)。
close(app);
if let Some(instance) = app.selected_instance_id() {
app.state
.saves
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
dir: dir.clone(),
});
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
)));
}
return Task::none();
}
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else {
close(app);
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.view_dirty = true;
app.refresh_view();
Task::none()
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
app.state.saves.close_confirm(current);
}
// M256:统一出口(有动效时先退场)。「立即重画」这条要求由 `close` 里的
// `refresh_view` 满足:退场的第一帧必须在这一拍画出来。
close(app);
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 {
// M256:退场窗口里 Esc **也算被吃掉**。`on_dialog` 那道闸门管不到这里(Esc 走的是
// `Message::Agent(Key)` 这条独立路径),而逻辑状态已经关掉 ⇒ 不加这一行,Esc 会落到
// AI 栏去收起面板,用户看到的是「框还在缩、AI 栏先没了」。
if motion::modal_closing() {
return true;
}
let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
return false;
};
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
app.state.saves.close_confirm(current);
// M256:统一出口(Esc 也走退场动画,而不是硬跳)。
close(app);
true
}
#[cfg(test)]
mod tests;
@@ -1,262 +0,0 @@
//! 主按钮 / Esc 的落点判据(M247,复审 r1 P1-1 / P2-1):**接线**层,不是 presenter 的状态机。
//! 一个 App + 一个只记账的后端,看"点了主按钮之后下发了什么"——「盘上的世界还在」在这里等价于
//! 「没有下发 `DeleteWorld`」(app 不写盘,唯一写入口是 `host::saves::dispatch`)。
//! 真实 `export()` 写没写 zip 由 `app/dialog/tests.rs` 的夹具覆盖,这里不造崩溃卡片。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SavesCommand, SavesListView, WorldView,
};
use unlucky_presenter::mock_data::mock_instances;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::{escape, on_dialog};
use crate::app::dialog::Message as DialogMessage;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
const DIR: &str = "Better World";
/// 只记账、按需喂事件的后端(与 `app/tests/presenter.rs` 的 recorder 同款)。
#[derive(Debug, Default)]
struct SpyBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
events: Rc<RefCell<Vec<AppEvent>>>,
}
impl Backend for SpyBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut *self.events.borrow_mut())
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 一个进到详情页、可喂事件、可记账的 App。
struct Harness {
app: App,
dispatched: Rc<RefCell<Vec<AppCommand>>>,
events: Rc<RefCell<Vec<AppEvent>>>,
}
impl Harness {
fn new() -> Self {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let events = Rc::new(RefCell::new(vec![AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
)]));
let backend = SpyBackend {
dispatched: Rc::clone(&dispatched),
events: Rc::clone(&events),
};
let mut harness = Self {
app: App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
),
dispatched,
events,
};
let _ = harness.app.update(Message::Boot);
assert_eq!(
harness.app.state.route(),
&Route::Detail("survival".to_string()),
"首屏该落在详情页(后面每一步都以它为前提交付目标)"
);
harness
}
/// 喂一条事件再抽干(`pump` 是唯一的 apply 入口,它顺带 `refresh_view`)。
fn feed(&mut self, event: AppEvent) {
self.events.borrow_mut().push(event);
self.app.pump();
self.app.refresh_view();
}
/// 点一次「删除 X」(页面消息的真实入口)。
fn request_delete(&mut self) {
let _ = self.app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::RequestDeleteWorld {
dir: DIR.to_string(),
},
)));
}
/// 有没有下发过 `SavesCommand::DeleteWorld`(= core 会不会去删盘上的世界)。
fn deleted(&self) -> bool {
self.dispatched.borrow().iter().any(|command| {
matches!(
command,
AppCommand::Saves(command)
if matches!(**command, SavesCommand::DeleteWorld { .. })
)
})
}
}
/// 一份 `saves/` 快照(`worlds` 决定"那个世界还在不在")。
fn snapshot(worlds: &[&str]) -> AppEvent {
AppEvent::Core(CoreEvent::Saves(Box::new(Ok(SavesListView {
instance: "survival".to_string(),
dir: "C:/mc/saves".to_string(),
exists: true,
worlds: worlds
.iter()
.map(|dir| WorldView {
dir: (*dir).to_string(),
icon: false,
modified_ms: None,
})
.collect(),
screenshots_dir: "C:/mc/screenshots".to_string(),
screenshots_exists: false,
screenshots: Vec::new(),
skipped: Vec::new(),
last_op: None,
}))))
}
/// **P1-1 事故链**:删存档 → `Esc` → 一个不相干的 `Confirm`(崩溃卡片的主按钮是「导出诊断包」)
/// → 点主按钮 ⇒ **没有** `DeleteWorld` 下发,框走它自己那条路(这里没有崩溃卡片 ⇒ 关框)。
#[test]
fn escaping_the_delete_dialog_disarms_it_for_the_next_one() {
let mut harness = Harness::new();
harness.request_delete();
assert!(
harness.app.state.dialogs.current().is_some(),
"确认框没弹出来"
);
assert!(harness.app.state.saves.confirm.is_some(), "目标没登记");
assert!(escape(&mut harness.app), "有框时 Esc 该被吃掉");
assert!(
harness.app.state.saves.confirm.is_none(),
"Esc 之后目标还留着 —— 下一个框的主按钮会把它兑现"
);
assert!(harness.app.state.dialogs.current().is_none());
// 不相干的框:崩溃卡片就是这个形态(`Confirm`,主按钮「导出诊断包」)。
let _ = harness.app.state.dialogs.push(
DialogSource::UserAction,
"启动失败",
"退出码 1",
DialogKind::Confirm {
primary: "导出诊断包".to_string(),
secondary: "关闭".to_string(),
},
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(
!harness.deleted(),
"Esc 之后那个目标被下一个框兑现了:世界被静默删除"
);
assert!(
harness.app.state.dialogs.current().is_none(),
"主按钮该走它自己那条路(这里没有崩溃卡片 ⇒ 关框)"
);
}
/// **纵深防御(P1-1 的第二层)**:万一某条路径只关了框、**漏清**目标,下一个不相干的框
/// 点主按钮也**拿不走**它 —— 因为 `take_confirm` 只有在"登记时那个框 == 当前框"时才交付。
///
/// 这条直接构造"漏清"状态(登记一个不存在的框 id),所以它**不依赖** `Esc` 那条路有没有清:
/// 去掉 id 校验,它立刻红。
#[test]
fn a_leaked_target_cannot_be_redeemed_by_another_dialog() {
let mut harness = Harness::new();
// 模拟事故形态:目标还挂着,但那个框早就不在了(登记 999 = 一个不存在的框)。
harness.app.state.saves.ask_delete(999, DIR);
let _ = harness.app.state.dialogs.push(
DialogSource::UserAction,
"启动失败",
"退出码 1",
DialogKind::Confirm {
primary: "导出诊断包".to_string(),
secondary: "关闭".to_string(),
},
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(
!harness.deleted(),
"漏清的目标被别的框兑现了:世界被静默删除(P1-1 的事故形态)"
);
assert!(
harness.app.state.saves.confirm.is_some(),
"别人的框不该顺手清掉我们的目标(清它的是关闭路径,不是主按钮)"
);
}
/// 反向:目标属于**当前**这个框时,主按钮仍然真的下发删除(没把功能关死)。
#[test]
fn the_owning_dialog_still_deletes() {
let mut harness = Harness::new();
harness.request_delete();
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(harness.deleted(), "自己的框点主按钮居然没删");
assert!(harness.app.state.saves.confirm.is_none(), "取走即清");
assert!(
harness.app.state.saves.pending.is_some(),
"该把那一行标成在飞(否则能连点)"
);
assert!(harness.app.state.dialogs.current().is_none(), "框该关掉");
}
/// **P2-1**:世界还在 ⇒ 一拍无关快照(备份回程 / 打开文件夹回程)之后,主按钮照样删。
#[test]
fn an_unrelated_snapshot_keeps_the_confirmation_redeemable() {
let mut harness = Harness::new();
harness.request_delete();
harness.feed(snapshot(&[DIR, "Other"]));
assert!(
harness.app.state.saves.confirm.is_some(),
"无关快照把目标吃掉了:主按钮会变成静默空操作"
);
assert!(
harness.app.state.dialogs.current().is_some(),
"框不该被收掉"
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(harness.deleted(), "快照之后主按钮点了没反应");
}
/// **P2-1 的另一半**:世界从快照里消失 ⇒ 框被**收起来**(不是留一个点了没反应的按钮),
/// 而且不下发删除(那个世界已经不存在了,下发只会换来一条 `WriteMissing` 噪音)。
#[test]
fn a_vanished_world_closes_its_confirmation() {
let mut harness = Harness::new();
harness.request_delete();
harness.feed(snapshot(&["Other"]));
assert!(
harness.app.state.saves.confirm.is_none(),
"作废的目标还留着"
);
assert!(
harness.app.state.dialogs.current().is_none(),
"框没被收起:主按钮会是一次静默空操作"
);
assert!(!harness.deleted(), "世界都不在了还下发删除");
}
@@ -1,152 +0,0 @@
//! 启动失败弹窗的**取证夹具**(`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-<pid>`)。
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<String> {
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<String> = 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<ArtifactFact> = 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=<home>… ".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()
}
-304
View File
@@ -1,304 +0,0 @@
//! 实例页「启动参数」区的消息落点(M157)。
//!
//! 三件事:
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
use unlucky_presenter::state::SavesOp;
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else {
return Task::none();
};
match message {
// M224:启停一个 mod。**不做乐观翻面**——先把那个开关标成「请求在飞」(置灰),
// 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。
//
// 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」,
// 与启动参数表单播没播种无关(守卫是给表单草稿用的,见下一条)。
InstanceMessage::SetModEnabled { file, enabled } => {
self.state.mods.mark_pending(&file);
self.dispatch(AppCommand::Mods(Box::new(ModsCommand::SetEnabled {
id: instance,
file,
enabled,
})));
return Task::none();
}
// M235:重新列一次存档(失败后的「重试」也走这条)。
//
// 与 `SetModEnabled` 一样放在 `bound_to` 守卫**之前**:存档区只认"当前选中实例",
// 与启动参数表单播没播种无关(那条守卫是给表单草稿用的)。
InstanceMessage::ReloadSaves => {
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(instance))));
return Task::none();
}
// M235:打开存档文件夹——拉起哪个目录由 host 定(`saves/` → `screenshots/` → 运行目录),
// 真正的 opener 在 `core::platform::opener`(平台差异不散到 app 这一层)。
InstanceMessage::OpenSavesFolder => {
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::OpenFolder {
id: instance,
})));
return Task::none();
}
// M247:行内重命名的三个动作。**输入过程不 dispatch**(只改 presenter 的草稿),
// 提交才发命令——与启动参数表单「编辑只改状态,保存才派命令」同一条纪律。
InstanceMessage::StartRenameWorld { dir } => {
self.state.saves.start_rename(&dir);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::RenameWorldChanged(text) => {
self.state.saves.edit_rename(text);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::CommitRenameWorld => {
let Some(draft) = self.state.saves.take_rename() else {
return Task::none();
};
// 空名字 / 没改 → 直接收起,不打后台:这两个不是"操作失败",是"用户改主意了"。
if draft.text.is_empty() || draft.text == draft.dir {
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
self.state.saves.mark_pending(SavesOp::Rename {
dir: draft.dir.clone(),
});
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::RenameWorld {
id: instance,
dir: draft.dir,
to: draft.text,
})));
return Task::none();
}
InstanceMessage::CancelRenameWorld => {
self.state.saves.cancel_rename();
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
InstanceMessage::RequestDeleteWorld { dir } => {
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
// **先压框、拿到它的 id,再登记目标**:目标绑在那个框上(复审 r1 P1-1)——
// 反过来(先登记再压框)会让"这一拍还没有框"的中间态挂在状态里,别的框的主按钮
// 就有机会把它兑掉。
let dialog = self.state.dialogs.push(
DialogSource::UserAction,
title,
body,
DialogKind::Confirm { primary, secondary },
);
self.state.saves.ask_delete(dialog, &dir);
self.view_dirty = true;
return Task::none();
}
// M247:备份——落点由 host 定(`<data>/exports/saves/`),回执里带完整路径。
InstanceMessage::BackupWorld { dir } => {
self.state
.saves
.mark_pending(SavesOp::Backup { dir: dir.clone() });
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::BackupWorld {
id: instance,
dir,
dest: None,
})));
return Task::none();
}
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text;
self.launch_form.dirty = true;
}
InstanceMessage::Game(text) => {
self.launch_form.game = text;
self.launch_form.dirty = true;
}
InstanceMessage::Memory(text) => {
self.launch_form.memory = text;
self.launch_form.dirty = true;
}
InstanceMessage::Java(text) => {
self.launch_form.java = text;
self.launch_form.dirty = true;
}
InstanceMessage::Backend(id) => {
self.launch_form.backend = id;
self.launch_form.dirty = true;
}
InstanceMessage::Reset => {
self.launch_form.reset();
}
InstanceMessage::Save => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch();
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
///
/// `pub(in crate::app)`(M247):删除确认框的主按钮在 `app/update/dialog.rs`,也要问同一个问题。
pub(in crate::app) fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::{InstanceMessage, LaunchForm};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn app_with_form() -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::mock::MockBackend::new()),
SettingsBridge::memory_only(),
);
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
app.state.navigate(unlucky_presenter::router::Route::Detail(
INSTANCE.to_string(),
));
app.launch_form = LaunchForm::from_view(
Some(INSTANCE),
&LaunchArgsView {
instance_id: INSTANCE.to_string(),
..LaunchArgsView::default()
},
);
app
}
/// 测试用的实例 id(mock 后端的实例快照里有它)。
const INSTANCE: &str = "survival";
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
#[test]
fn editing_marks_dirty_and_reset_waits_for_save() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
assert_eq!(app.launch_form.memory, "6144");
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
assert_eq!(app.launch_form.backend, "vulkan");
let _ = app.on_instance_message(InstanceMessage::Reset);
assert_eq!(app.launch_form.memory, "");
assert_eq!(app.launch_form.backend, "default");
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
}
/// **MODS 区的启停在表单守卫之前**(M224):表单还没播种也照样能启停。
///
/// 抓得住:把 `SetModEnabled` 放回 `bound_to` 守卫之后——那时首帧的详情页点了开关
/// 什么都不会发生(用户看到开关纹丝不动,也没有任何提示)。
#[test]
fn mod_toggles_work_before_the_launch_form_is_bound() {
let mut app = app_with_form();
// 页面已经打开(真路径上 `refresh_view` 的 `sync_mods` 会做这一步),但表单还没播种。
app.state.bind_mods(Some(INSTANCE));
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::SetModEnabled {
file: "sodium.jar".to_string(),
enabled: false,
});
assert!(
app.state.mods.is_pending("sodium.jar"),
"启停被表单守卫挡住了(开关点了没反应)"
);
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form();
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
assert_eq!(app.launch_form.memory, "");
assert!(!app.launch_form.dirty);
}
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
///
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
#[test]
fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save);
assert!(
app.launch_form.dirty,
"派发后仍要保留脏标记,等回程快照确认"
);
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
}
}
@@ -1,398 +0,0 @@
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
//!
//! ## 为什么这条不能在 `view` 里
//!
//! iced 0.14 的顺序:`update()`(**订阅表在这里求值**,iced_winit `src/lib.rs:1338`)→
//! `build_user_interfaces()`(这时才调 `view`,`src/lib.rs:1233`)⇒ **订阅表的求值早于视图**。
//! 第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,`frames()` 要等下一条消息才打开,而那一帧
//! 的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,用户看到的是一次 12/8px 的偏移闪跳(截图通道
//! 因为 `has_screenshot()` 恒真,**结构上**不可能暴露这条)。所以起跑搬到这里。
//!
//! ## 标识从哪来
//!
//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」,
//! `nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,不看屏键大小——见 [`nav_rank`])。
use unlucky_presenter::router::Route;
use crate::app::components::motion;
use crate::pages;
use super::boot_env::MotionFixture;
use crate::app::message::nav_item_of;
use crate::shell::NavItem;
use super::*;
impl App {
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
pub(in crate::app) fn sync_motion(&mut self) {
// M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。
// 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的
// 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。
// M256 r2(P2-2):退场走完只在**队列里确实没有框**时才丢掉画面模型——退场途中开了新框时
// `observe_modal(true)` 已把旧退场当场收尾(并留下一次「已关」),照样置空会闪一下。
if motion::take_modal_closed() && self.state.dialogs.current().is_none() {
self.dialog_model = None;
self.view_dirty = true;
}
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
// `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, nav_rank(route), detail);
// M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`:
// 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。
motion::observe_modal(self.state.dialogs.current().is_some());
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
if !motion::needs_frames() {
self.previous_view = None;
}
}
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`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<Screen>`,这里
/// 再折成 `Option<u64>` 给帧泵用。
fn screen_key(route: &Route) -> Option<u64> {
let screen = pages::Screen::of(route)?;
pages::Screen::ALL
.iter()
.position(|candidate| *candidate == screen)
.map(|index| index as u64 + 1)
}
/// 这一屏在**导航轨**上的次序(`NavItem::ALL` 的下标)——左右方向的唯一真源。
///
/// 为什么不能用屏键(`Screen::ALL` 的下标):两处顺序不同。`Screen::ALL` 是
/// `… Content / Settings / Logs`,而导航轨是 `… Logs / Settings / Ai` ⇒ 用屏键会把
/// **Logs↔Settings 的左右镜像**(复审 P2-1)。`Content` 与 `Download` 共用导航项,`rank` 相等,
/// 方向退回按屏键比大小(`Download ↔ Content` 判成同级水平位移)。
///
/// 账户头像(`NavItem::Account`)与 AI(`NavItem::Ai`)不在这个集合里 / 不归 pages 画,
/// 返回 `None`——它们的切换本来就不起跑页面轨道。
fn nav_rank(route: &Route) -> Option<u8> {
let item = nav_item_of(route);
NavItem::ALL
.iter()
.position(|candidate| *candidate == item)
.map(|index| index as u8)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
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);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
}
@@ -21,7 +21,6 @@
use unlucky_i18n::{Lang, LangChoice};
use crate::app::diag::diag;
use crate::pages;
use crate::ui::lang::LangState;
@@ -55,8 +54,6 @@ impl App {
}
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -66,9 +63,6 @@ impl App {
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
pages::Message::Language(choice) => self.set_language(choice),
// M229:换主题档(Ink / Silver / 跟随系统)——换 `Ui` 的色板 + 落盘
// `appearance.theme_mode`。与语言行同一条「页面消息直接改本地 + 落盘」的路。
pages::Message::Theme(mode) => return self.set_theme_mode(mode),
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
pages::Message::Content(message) => return self.on_content(message),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
@@ -94,32 +88,11 @@ impl App {
self.view_dirty = true;
self.refresh_view();
}
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
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)
}
};
}
// M223:实例页页头「导入」——以前是占位动作(点了只打一行日志),现在是
// 「导入已有版本」向导的入口(M221 的 core 能力 + M223 的 presenter/GUI 接线)。
pages::Message::Action(pages::Action::Import) => {
return self.on_adopt(crate::app::adopt::AdoptMessage::Open);
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
}
}
@@ -150,7 +123,9 @@ impl App {
self.lang = LangState::of(choice);
self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) {
diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
eprintln!(
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
}
self.lang.log_cjk();
self.view_dirty = true;
@@ -1,170 +1,17 @@
//! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
//!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
//!
//! | 消息 | 做什么 |
//! |---|---|
//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) |
//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) |
//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` |
//!
//! ## 为什么「保存」不派 `AppCommand::SetSetting`
//!
//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会
//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路
//! (`SettingStore::set` + `save`),只是不经过那条通道。
//!
//! ## 为什么「测试」读文件而不是读草稿
//!
//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
use crate::pages::settings::ai::{Probe, ProbeResult};
impl App {
/// 助手分组的一条消息。
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_ai_about(
&mut self,
message: pages::settings::ai_about::Message,
) -> Task<Message> {
use pages::settings::ai_about::Message;
match message {
Message::ToggleEnabled(enabled) => {
self.settings.ai_about.enabled = enabled;
self.touch();
}
Message::PickProvider(provider) => {
self.settings.ai_about.provider = provider;
self.touch();
}
Message::EditBaseUrl(value) => {
self.settings.ai_about.base_url = value;
self.touch();
}
Message::EditModel(value) => {
self.settings.ai_about.model = value;
self.touch();
}
Message::EditKey(secret) => {
self.settings.ai_about.key = secret;
self.touch();
}
Message::Reveal(reveal) => {
self.settings.ai_about.reveal = reveal;
self.view_dirty = true;
}
Message::Save => return self.save_ai_settings(),
Message::Test => return self.test_ai_connection(),
Message::ProbeDone(result) => {
self.settings.ai_about.probe = Probe::Done(*result);
self.view_dirty = true;
}
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch(&mut self) {
self.settings.ai_about.dirty = true;
self.settings.ai_about.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。
///
/// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。
/// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判),
/// 否则外部一改文件就把用户正在输入的内容冲掉。
pub(in crate::app) fn hydrate_ai_settings(&mut self) {
if self.options.screenshot.is_some() {
let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string());
if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) {
self.settings.ai_about = state;
}
return;
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
return;
};
let snapshot = pages::settings::ai::store::load(&path);
if self.settings.ai_about.hydrate(snapshot) {
self.view_dirty = true;
}
}
/// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。
fn save_ai_settings(&mut self) -> Task<Message> {
use pages::settings::ai::{store, SaveNotice};
if let Err(refusal) = self.settings.ai_about.validate() {
self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal));
self.view_dirty = true;
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.notice = Some(SaveNotice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let saved = store::save(&path, &self.settings.ai_about);
match saved {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.ai_about.dirty = false;
self.settings.ai_about.hydrate(snapshot);
self.settings.ai_about.key = pages::settings::ai::Secret::default();
self.settings.ai_about.notice = Some(SaveNotice::Saved);
}
Err(reason) => {
self.settings.ai_about.notice =
Some(pages::settings::ai::SaveNotice::Failed(reason));
}
}
self.view_dirty = true;
Task::none()
}
/// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。
fn test_ai_connection(&mut self) -> Task<Message> {
if !self.settings.ai_about.can_test() {
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome(
unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl,
},
0,
));
self.view_dirty = true;
return Task::none();
};
self.settings.ai_about.probe = Probe::Running;
self.view_dirty = true;
Task::perform(
async move {
let started = std::time::Instant::now();
let outcome = unlucky_cli::commands::agent::probe::probe_file(
&path,
unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT,
);
ProbeResult::from_outcome(outcome, started.elapsed().as_millis())
},
|result| {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(
pages::settings::ai_about::Message::ProbeDone(Box::new(result)),
),
))
},
)
match message {}
}
}
@@ -1,75 +1,18 @@
//! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事:
//! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环
//! (`host/dispatch.rs` 的 `SetSetting` 分支:`settings::set` 落盘 → `apply_tuning` → 重新发
//! `CoreEvent::SettingsChanged`)。
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//!
//! ## 不做乐观回显
//!
//! 这里**不**改任何「当前值」:界面上的值是 presenter 快照(`pages::Model.settings`)的投影,
//! 只有 host 把 `SettingsChanged` 发回来才会变。理由与下载页的安装/启动同一条纪律——本地改一份、
//! host 回来再改一次,两边打架时界面就会各说一套(尤其是写盘失败:乐观回显会让用户以为改成了)。
//!
//! ## 文本行为什么要「应用」才提交
//!
//! 见 `pages/settings/download.rs` 的文件头:每敲一个键写一次盘会把半截值落进去。所以
//! `Edit` 只改本地草稿(`view_dirty` 都不用翻:草稿由 `States` 直接读),`Commit` 才派发。
//! 提交之后草稿当场丢掉——快照随即成为唯一真值,写失败时行上会回到文件里的值 + presenter 的
//! 错误 Toast 说清原因(比留着一个没生效的草稿更诚实)。
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
use unlucky_presenter::dto::{find_row, AppCommand};
impl App {
/// Downloads 分组的一条消息。
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_download(
&mut self,
message: pages::settings::download::Message,
) -> Task<Message> {
use pages::settings::download::Message;
match message {
Message::Set { key, value } => {
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
Message::Edit { key, value } => {
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
self.settings.download.edit(&key, value);
Task::none()
}
Message::Commit { key } => {
let value = self.pending_text(&key);
self.settings.download.clear(&key);
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
// `settings::set` 忽略(那条分支先认键、再解析值)。
Message::Action { key } => {
self.dispatch(AppCommand::SetSetting {
key,
value: String::new(),
});
Task::none()
match message {}
}
}
}
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
///
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
fn pending_text(&self, key: &str) -> String {
self.settings
.download
.pending(key)
.or_else(|| find_row(&self.view.settings, key).map(|row| row.value_text.clone()))
.unwrap_or_default()
}
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
@@ -1,332 +0,0 @@
//! Downloads 组的**写回闭环**(M201):`控件 → Message → AppCommand::SetSetting → 真 host 写盘
//! → CoreEvent::SettingsChanged → App 的快照与页面模型`。
//!
//! 为什么这条断言必须在 app 层:链子的两头都是「有状态的东西」——中间断在任何一环,页面层测试
//! 都看不见(它只拿到一份已经画好的模型)。要问的是「用户点了以后,**盘上和 App 认的值**变了吗」,
//! 而不是「按钮的 `on_press` 里有没有构造消息」。
//!
//! 两个后端都测,因为它们证明的事不同:
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 前四条 | 假后端(按 host 形状回快照) | 搬运:消息 → 命令、命令 → 快照、快照 → 模型 |
//! | 最后一条 | **真 `CoreHost` + 临时数据根** | 整条链:命令 → `settings::set` → `launcher.json` 真的变了 |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SettingControl, SettingGroupView, SettingRowView,
};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::pages::settings::download;
use crate::theme::Families;
use super::*;
/// 这一组要断言的那个键(与 presenter 快照里的键同名)。
const CONCURRENCY: &str = "download.concurrency";
/// 一个文本键(代理地址)。
const PROXY_URL: &str = "download.proxy_url";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一份只有下载组的快照:并发 = `concurrency`,代理 = `proxy`。
///
/// 行形状照 presenter 的 `host::settings::download::group` 抄(choice + 档位选项,text + 原文),
/// 只保留本文件断言要用的那两行——这里守的是 app 侧的搬运,不是 presenter 的行文案。
fn snapshot(concurrency: &str, proxy: &str) -> Vec<SettingGroupView> {
let options = ["4", "8", "16"];
let selected = i32::try_from(
options
.iter()
.position(|item| *item == concurrency)
.unwrap_or(1),
)
.expect("装得下");
vec![SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
CONCURRENCY,
"并发数",
"同时下载的文件数",
&options,
selected,
))
.with(SettingRowView::text(
PROXY_URL,
"HTTP 代理",
"manual 模式生效",
proxy,
))]
}
/// 记下令、并按 host 的形状回一条 `SettingsChanged` 的后端。
struct Hostish {
commands: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
/// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。
state: RefCell<(String, String)>,
}
impl Backend for Hostish {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.commands.borrow_mut().push(command.clone());
if let AppCommand::SetSetting { key, value } = command {
let mut state = self.state.borrow_mut();
if key == CONCURRENCY {
state.0 = value;
} else if key == PROXY_URL {
state.1 = value;
}
// host 写盘之后重新发一份快照(`dispatch.rs` 的 `SetSetting` 分支就这一行)。
self.queued
.push(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
&state.0, &state.1,
))));
}
}
}
/// 一个装着 [`Hostish`] 的 App + 那本命令账。
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let commands = Rc::new(RefCell::new(Vec::new()));
let backend = Hostish {
commands: Rc::clone(&commands),
queued: Vec::new(),
state: RefCell::new(("8".to_string(), "http://127.0.0.1:7890".to_string())),
};
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
// 首屏:收一份 host 的快照(产品路径在 `on_boot` 的 `RefreshAll` 之后就是这样)。
app.state
.apply(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
"8",
"http://127.0.0.1:7890",
))));
app.refresh_view();
(app, commands)
}
/// 一条设置页消息走完漏斗。
fn send(app: &mut App, message: pages::settings::download::Message) {
let message = pages::Message::Settings(pages::settings::GroupMessage::Download(message));
let _ = app.on_page(message);
}
/// **主用例**:点一个档位 ⇒ 命令发出去了 ⇒ host 回的快照进了 App ⇒ 页面模型跟着换。
///
/// 抓得住:
/// 1. handler 不派发 `AppCommand::SetSetting`(写回那一行被删)——这时假后端收不到命令、
/// 也回不出事件,快照停在旧值,红(变异验证见报告);
/// 2. `pages::Model` 不从快照取设置(`view::model` 那行 `settings:` 被删)——`app.view.settings`
/// 是空的,`current_value` 返回 `None`,红;
/// 3. App 侧**乐观回显**(点完就地改本地值、不等事件)——把假后端的回事件删掉后第 2 段仍会绿,
/// 所以本用例的「红」只证明闭合回路;它抓不住「有没有多存一份本地值」这一半。
///
/// 抓不住:真实点击(`Chip` 的 `on_press` 是否被真的按下)、host 是否真的落盘(那是最后一条的活)。
#[test]
fn changing_a_choice_value_lands_in_the_app_snapshot_and_the_page_model() {
let (mut app, commands) = app();
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"开局认的是 host 给的那份"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "4".to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == CONCURRENCY && value == "4"
)),
"写回必须走已有的 `AppCommand::SetSetting`(实际下发:{:?})",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("4"),
"App 的设置快照必须跟着 host 回来那份变(不是本地 State 变)"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("4"),
"页面模型(这一帧要画的值)也必须是 4:控件画的是 `Model.settings`"
);
}
/// 文本行:`Edit` 只改草稿(**不发指令**),`Commit` 才带着草稿去写盘。
///
/// 抓得住:`Edit` 也派发(每敲一个键写一次盘)、`Commit` 用快照旧值而不是草稿(用户输入丢失)。
/// 抓不住:输入框本身的按键路径(`text_field` 的 `on_input` 是组件既有实现)。
#[test]
fn typing_a_text_value_only_commits_on_demand() {
let (mut app, commands) = app();
send(
&mut app,
pages::settings::download::Message::Edit {
key: PROXY_URL.to_string(),
value: "http://127.0.0.1:1080".to_string(),
},
);
assert!(
commands.borrow().is_empty(),
"输入过程中不许写盘(半截值会落进 launcher.json)"
);
send(
&mut app,
pages::settings::download::Message::Commit {
key: PROXY_URL.to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == PROXY_URL && value == "http://127.0.0.1:1080"
)),
"提交时带的必须是**草稿**里的那一串(不是快照里的旧值):{:?}",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), PROXY_URL).as_deref(),
Some("http://127.0.0.1:1080"),
"提交后快照换成 host 回来的新值"
);
}
/// 动作行(「重新检测地区」):派发的是那一行的键,值留空(presenter 那条分支先认键、不看值)。
///
/// 抓得住:动作按钮被写成「什么都不发」、或者把键发成按钮文案。
#[test]
fn an_action_row_dispatches_its_key() {
let (mut app, commands) = app();
let key = "download.action.detect_region".to_string();
send(
&mut app,
pages::settings::download::Message::Action { key: key.clone() },
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key: sent, .. } if sent == &key
)),
"动作行必须走「按 schema 键写设置」那一条既有通路:{:?}",
commands.borrow()
);
}
/// 快照里没有本组时,页面**不许**自己编一行(`controls_of` 空 = 退回演示行)。
///
/// 抓得住:把演示行当成真值画(M193 用户的投诉形状)。
/// 抓不住:`settings/mod.rs` 那条退回路径本身(那是 `demo_card` 的事)。
#[test]
fn an_empty_snapshot_yields_no_controls() {
let (mut app, _commands) = app();
app.state.settings_groups.clear();
app.refresh_view();
assert!(download::controls_of(app.settings_snapshot()).is_empty());
// 有快照时,本组的控件种类就是 presenter 给的那几行(这里两份:choice + text)。
let kinds = download::controls_of(&snapshot("8", ""));
assert_eq!(
kinds,
vec![SettingControl::Choice, SettingControl::Text],
"控件种类照快照的 `control` 字段画,不许自己另定"
);
}
/// **真 host 端到端**:点「16」⇒ 「设置页里改了就生效」这句话的正面证据。
///
/// 链子全长:`Message::Set` → `AppCommand::SetSetting` → 真 `CoreHost` 的 `dispatch.rs` 分支
/// (`settings::set` 校验 + 落盘 + `apply_tuning` + 重新发 `SettingsChanged`)→ `AppState` →
/// `pages::Model::settings`。断言落在**磁盘上那串字节**上,不是任何中间变量。
///
/// 抓得住:
/// 1. 只改本地、不派发(`Message::Set` 的分支被删)⇒ `launcher.json` 里没有 16,红;
/// 2. 派发了但 handler 用了错误的键/值 ⇒ 文件里那个键还是出厂 8,红;
/// 3. 快照没跟着 host 的回流走(`view::model` 那行 `settings:` 被删)⇒ 页面模型空,红;
/// 4. 写盘校验被绕过(`settings::set` 不再 `save()`)⇒ 文件不存在/没变,红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑,`Chip::filter` 的 `on_press` 不在其中)、
/// 引擎是否立刻吃到新值(`apply_tuning` 与 `concurrency_probe` 由 presenter 的
/// `the_concurrency_setting_reaches_the_real_download_engine` 守)、以及界面像素(截图的事)。
#[test]
fn a_choice_change_reaches_the_real_settings_file() {
let dir = std::env::temp_dir().join(format!("ull-m201-writeback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"临时根里没有这个键 ⇒ 快照给的是出厂 8(也是引擎的生效值)"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "16".to_string(),
},
);
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"concurrency\": 16"),
"磁盘上必须真的有 16(这才是「设置页里改了就生效」):{written}"
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("16"),
"host 把新快照发回来了,App 认的也是 16"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("16"),
"页面模型也换成了 16(下一帧控件画的就是它)"
);
std::fs::remove_dir_all(&dir).ok();
}
+14 -333
View File
@@ -1,34 +1,18 @@
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//!
//! | 消息 | 做什么 |
//! |---|---|
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//! 消息进来的通道有两条,都不经过共享文件:
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! (`app::Message::Page` 是既有通道,不用加新变体)。
//!
//! ## 为什么「设为默认」不攒到「保存」里
//!
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
use super::super::*;
use crate::app::diag::diag;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
/// M197 的两个落点(各自一个 `impl App` 块):真下载一个 JDK / 三个设置键。
mod downloads;
mod prefs;
impl App {
/// Java 分组的一条消息。
@@ -36,317 +20,14 @@ impl App {
&mut self,
message: pages::settings::java::Message,
) -> Task<Message> {
use pages::settings::java::Message;
match message {
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(),
// M197:真下载一个 JDK + 三个设置键。落点各自一个 `impl App` 块(见 `mod` 声明)。
Message::Download(major) => return downloads::on_download(self, major),
Message::EditInstallRoot(value) => return prefs::on_edit_install_root(self, value),
Message::ToggleAutoInstall(on) => return prefs::on_toggle_auto_install(self, on),
Message::MoveVendor { index, up } => return prefs::on_move_vendor(self, index, up),
Message::ResetPriority => return prefs::on_reset_priority(self),
Message::SavePrefs => return prefs::on_save_prefs(self),
}
Task::none()
}
/// 三键写盘成功之后,请 presenter 重发一次设置快照(M204)。
///
/// ## 为什么需要这一拍
///
/// 本组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
/// 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`),
/// **不经过** `AppCommand::SetSetting` 的写盘分支。而 `CoreEvent::SettingsChanged` 全仓只有两处
/// 发出(`dispatch.rs` 的 `SetSetting` 分支、`host/mod.rs` 的 `refresh_all`)——不请一次,
/// `AppState::settings_groups` 里 java 组那几行会停在旧值,直到下一次任何别的写入。
///
/// ## 为什么不是 `AppCommand::RefreshAll`
///
/// `refresh_all()` 除重发快照之外还会 `versions::spawn_list`(拉版本清单)与
/// `start_update_check`(自更新检查)——每点一次「保存内存」打两个网络请求,代价与动作不相称。
/// 动作键 `JAVA_REFRESH_ACTION` 走的是同一条 `SetSetting` 分支的四个副作用臂,不写盘、不发网络。
///
/// ## 抓得住 / 抓不住
///
/// 抓得住:`tests/java_refresh.rs` 的 `saving_the_java_form_refreshes_the_host_settings_snapshot`
/// 用**真 `CoreHost` + 临时数据根**跑完整链,摘掉这一行的 dispatch 就红(实测)。
/// 抓不住:presenter 是否会因为**别的原因**(下一次外部写盘)自己刷新——所以那条用例的判据是
/// 「写盘后**当场**快照里有新值」,而不是「最终会有」。
fn request_settings_refresh(&mut self) {
self.dispatch(AppCommand::SetSetting {
key: unlucky_presenter::dto::JAVA_REFRESH_ACTION.to_string(),
value: String::new(),
});
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
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=<empty|two|editing>` 用种子(D30 可复现);
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
pub(in crate::app) fn hydrate_java_settings(&mut self) {
let seeded = self.options.screenshot.is_some();
if seeded {
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
if raw.trim() != "live" {
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
self.settings.java = state;
}
return;
}
// `ULL_JAVA_STATE=live`:不灌种子,继续往下走真路径(读本机配置 + 真扫盘)。
}
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);
// M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)+ 它们的生效值与来源层。
let prefs_changed = self
.settings
.java
.prefs
.hydrate(pages::settings::java::prefs_store::load(&config));
self.settings.java.effective =
pages::settings::java::prefs_store::effective(&config, &self.settings.java.prefs);
let scan_changed = self.hydrate_java_scan();
self.settings.java.context = self.java_context();
if changed || prefs_changed || scan_changed {
self.view_dirty = true;
}
// 自动扫一次(首屏就该有真数据)。**没有 `cfg!(test)` 分叉**:单测走的也是这条生产入口,
// 删掉这一行 / 把实参翻成 `true` ⇒ `tests/java.rs` 的
// `the_first_hydrate_starts_a_real_scan_by_itself` 立刻红(r3 复审 P3-3 的缺口)。
//
// 实参为什么恒为 `false`:截图档的 `empty|two|editing` 在上面就 `return` 了,能走到这里的
// 只有「不是截图」与「`live` 档」两种,而 `live` 按产品路径算(见本函数文档 D30 的例外)。
self.auto_scan_java_if_due(false);
}
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
///
/// 抽出来是为了让「取消过就不再自动扫」(r2 复审 P2-2)有个窄的落点;而**首屏真的调了它**
/// 这件事由生产入口自己守(`tests/java.rs` 的那条首屏用例),不再靠 `cfg!(test)` 把调用关掉。
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
}
/// 把后台扫描的快照灌进状态。返回是否变了。
///
/// M197 起这一拍**也**灌一次下载作业的快照:`app/update/clock.rs` 每拍只调这一个入口,
/// 而复用它比改心跳的调用点便宜(`clock.rs` 不在本 mission 的 scope 里)。
/// 代价写在 `settings/java/downloads.rs`:下载期间进度按 1 Hz 刷新。
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
let report = self.settings.java.scan.report();
let mut changed = self.settings.java.apply_scan(&report);
if self.hydrate_java_downloads() {
changed = true;
}
if changed {
self.view_dirty = true;
}
changed
}
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
fn start_java_scan(&mut self) -> Task<Message> {
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::<Vec<_>>();
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();
}
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
#[cfg(test)]
pages::settings::java::Message::Ping => {
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()));
// M204:这一笔也写进 launcher.json 了(`java.selected_path`),请 host 重发快照。
self.request_settings_refresh();
}
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);
// M204:内存 + JVM 参数已经落盘(一次 `store.save()`),请 host 重发快照。
self.request_settings_refresh();
}
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 日志,
/// 界面上给一句页面自己的「写不进去」。
///
/// `pub(super)`:`tests/java.rs` 直接钉这一层的映射(M199 起页面侧已经撞不到 schema 那一档)。
pub(super) 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) => {
diag!("[java] 设置写盘失败:{detail}");
Notice::WriteFailed
}
}
}
@@ -1,118 +0,0 @@
//! 「下载一个 JDK」的 App 层落点(M197)。
//!
//! 三件事:
//!
//! | 函数 | 谁调 | 干什么 |
//! |---|---|---|
//! | [`App::start_java_download`] | `Download(major)` 消息 | 本地判据(目录/承接方/落点/是否已在跑)→ 起后台作业 |
//! | [`App::hydrate_java_downloads`] | 心跳那一拍(经 `hydrate_java_scan`) | 把作业快照灌成视图差量;**刚成功**时重扫 + 按 `java.auto_install` 决定要不要设为默认 |
//!
//! ## 为什么轮询挂在 `hydrate_java_scan` 上
//!
//! M175 的扫描已经是「后台线程 + 快照 + 心跳比对」的形状,`app/update/clock.rs` 每拍只调
//! `hydrate_java_scan()` 这一个入口。下载作业是同一族东西(后台线程 + 快照),所以复用那一拍,
//! `clock.rs` 一个字节都不用改——代价是**下载期间进度按 1 Hz 刷新**(帧时钟的判据在
//! `app/subscription.rs` 的 `needs_frames`,那是本 mission scope 之外的文件)。百分比一秒动一次
//! 对一次 100 MB 级的下载是够的;要紧的是它**会动**,而不是每秒动 60 次。
//!
//! ## 装完之后为什么要重扫
//!
//! 列表里那一行「已安装:…」来自**真扫盘的结论**(M175 的 `ScanSlot`),不是另存的安装记录。
//! 装完不重扫,界面上就会出现「下载成功」与「列表里没有它」并存——正是本仓最忌讳的那种自相矛盾。
use iced::Task;
use crate::app::message::Message;
use crate::app::state::App;
use crate::pages;
use unlucky_core::game::java::catalog;
use crate::pages::settings::java::{prefs_store, store};
impl App {
/// 下载并安装某个大版本(`Download(major)` 的落点)。
///
/// 四道本地判据,任何一道不过都在下载区顶上写清原因(点了没反应是禁止的):
/// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点;
/// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。
pub(super) fn start_java_download(&mut self, major: u32) {
use pages::settings::java::downloads::{gh_proxy_of_config, Refusal, Request};
self.settings.java.download_notice = None;
self.settings.java.notice = None;
self.view_dirty = true;
if catalog::find(major).is_none() {
self.settings.java.download_notice = Some(Refusal::UnknownMajor);
return;
}
if self.settings.java.downloads.busy() {
self.settings.java.download_notice = Some(Refusal::Busy);
return;
}
let Some(config) = store::config_of(&self.bridge) else {
self.settings.java.download_notice = Some(Refusal::NoStorage);
return;
};
let priority = self.settings.java.priority_vendors();
let domestic = self.settings.java.effective.domestic;
if catalog::vendors_for(major, &priority, domestic).is_empty() {
self.settings.java.download_notice = Some(Refusal::NoVendor);
return;
}
let saved_root = self.settings.java.prefs.saved_install_root.clone();
let Some(cache_root) = prefs_store::cache_root(&config) else {
self.settings.java.download_notice = Some(Refusal::NoStorage);
return;
};
let Some(install_root) = prefs_store::install_root(&config, &saved_root) else {
// 有设置文件但解析不出落点(standard 模式读不到系统目录):说这一句,
// 而不是含糊的「没有可用的设置文件」。
self.settings.java.download_notice = Some(Refusal::RootUnresolved);
return;
};
let started = self.settings.java.downloads.start(Request {
major,
cache_root,
install_root,
priority,
// `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认,
// `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。
gh_proxy: gh_proxy_of_config(&config),
});
if !started {
self.settings.java.download_notice = Some(Refusal::Busy);
}
}
/// 把作业快照灌成视图差量(心跳那一拍调)。返回是否变了。
pub(in crate::app) fn hydrate_java_downloads(&mut self) -> bool {
let report = self.settings.java.downloads.report();
if report == self.settings.java.download_seen {
return false;
}
let fresh = report
.success
.clone()
.filter(|success| self.settings.java.download_seen.success.as_ref() != Some(success));
self.settings.java.download_seen = report;
if let Some(success) = fresh {
// 新装上的一份:先重扫(列表要出现它),再按 `java.auto_install` 决定默认值。
let _ = self.start_java_scan();
if self.settings.java.prefs.saved_auto_install
&& self.settings.java.selected.trim().is_empty()
{
self.select_java_default(&success.binary);
}
}
self.view_dirty = true;
true
}
}
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
pub(super) fn on_download(app: &mut App, major: u32) -> Task<Message> {
app.start_java_download(major);
Task::none()
}
@@ -1,126 +0,0 @@
//! 三个设置键的 App 层落点(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
//!
//! ## 为什么这三项单独一个「保存」
//!
//! M175 的「保存」只写内存 + JVM 参数(`java.default_memory_mb` / `java.extra_args`),那是**运行时
//! 参数**;这三项是**装到哪、按什么顺序找、缺 JDK 时怎么准备**——改了它们的副作用是「下一次下载 /
//! 下一次扫描的落点变了」,与内存那两项混在一个按钮里,用户就不清楚自己刚保存的到底是哪一半。
//! 于是两段各有自己的脏标记与保存按钮,各回读各的键。
//!
//! ## 保存之后为什么立刻重扫
//!
//! `java.installRoot` 改了 ⇒ 托管根的集合变了 ⇒ 「已检测到的 JDK」那份列表可能整份不同。
//! 保存成功就重扫一次,界面不会出现「落点写着新目录,列表还是旧目录里的 JDK」。
//!
//! ## 回读校验
//!
//! `prefs_store::save` 返回的是**落盘后从磁盘读回来**的真值,[`Prefs::adopt`] 把草稿与
//! 「落盘真值」两半都对齐到它:用户看到的就是磁盘上那份(要是 schema 把值规整过,界面显示的是
//! 规整后的结果,而不是他刚敲的那串——这一条正是「改完回读验证已落盘」)。
use iced::Task;
use crate::app::message::Message;
use crate::app::state::App;
use crate::pages;
use super::super::java::write_notice;
impl App {
/// `java.installRoot` 草稿(只改草稿,不写盘)。
pub(super) fn edit_java_install_root(&mut self, value: String) {
self.settings.java.prefs.install_root = value;
self.settings.java.notice = None;
self.settings.java.download_notice = None;
self.view_dirty = true;
}
/// `java.auto_install` 开关(只改草稿)。
pub(super) fn toggle_java_auto_install(&mut self, on: bool) {
self.settings.java.prefs.auto_install = on;
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 发行版顺序上移 / 下移一行。
pub(super) fn move_java_vendor(&mut self, index: usize, up: bool) {
let domestic = self.settings.java.effective.domestic;
if up {
self.settings.java.prefs.move_up(index, domestic);
} else {
self.settings.java.prefs.move_down(index, domestic);
}
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 恢复地区默认序(= 把这个键写回空,让地区那一层重新生效)。
pub(super) fn reset_java_priority(&mut self) {
self.settings.java.prefs.reset_priority();
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 保存三个键:本地校验 → 落盘 → **用读回的真值**刷新草稿与生效值 → 重扫一次。
pub(super) fn save_java_prefs(&mut self) {
use pages::settings::java::Notice;
self.view_dirty = true;
if let Err(refusal) = self.settings.java.prefs.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
return;
};
match pages::settings::java::prefs_store::save(&config, &self.settings.java.prefs) {
Ok(snapshot) => {
self.settings.java.prefs.adopt(snapshot);
self.settings.java.effective = pages::settings::java::prefs_store::effective(
&config,
&self.settings.java.prefs,
);
// 落点 / 顺序变了:托管根的集合可能整份不同,立刻重扫一次。
// 顺序要紧:`start_java_scan` 会清 `notice`,所以回执写在它之后。
let _ = self.start_java_scan();
self.settings.java.notice = Some(Notice::PrefsSaved);
// M204:三键也已落盘(`java.installRoot` / `java.auto_install` / `java.vendorPriority`
// 一次 `store.save()`),请 host 重发快照——否则 `AppState::settings_groups` 里
// java 组那三行停在旧值,要等下一次任何别的写入才跟上。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
}
}
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
pub(super) fn on_edit_install_root(app: &mut App, value: String) -> Task<Message> {
app.edit_java_install_root(value);
Task::none()
}
/// `ToggleAutoInstall` 的转发。
pub(super) fn on_toggle_auto_install(app: &mut App, on: bool) -> Task<Message> {
app.toggle_java_auto_install(on);
Task::none()
}
/// `MoveVendor` 的转发。
pub(super) fn on_move_vendor(app: &mut App, index: usize, up: bool) -> Task<Message> {
app.move_java_vendor(index, up);
Task::none()
}
/// `ResetPriority` 的转发。
pub(super) fn on_reset_priority(app: &mut App) -> Task<Message> {
app.reset_java_priority();
Task::none()
}
/// `SavePrefs` 的转发。
pub(super) fn on_save_prefs(app: &mut App) -> Task<Message> {
app.save_java_prefs();
Task::none()
}
@@ -256,12 +256,8 @@ 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::EditMemory(
"4096".to_string(),
)),
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
)));
assert!(
@@ -269,132 +265,3 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
);
}
// --------------------------------------------------------------- M174:助手配置的活路径
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
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(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
let app = App::with_presenter(
Options::default(),
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, path)
}
fn ai(message: pages::settings::ai_about::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(message),
))
}
/// **M174 的活路径**:设置页那四条消息(改端点/模型/密钥)+ 保存,真的写进 `launcher.json`。
///
/// 抓得住:handler 没接 `Save`、`store::save` 没写某一个键、写错键名、密钥没落盘。
/// 抓不住:用户真的点了那颗按钮(那是 iced 事件循环的事),也抓不住「保存后 agent 读到了」
/// (那一半由 `unlucky-cli` 的 `probe_file` 用例证明:它读的就是这个文件)。
#[test]
fn saving_the_ai_form_writes_the_real_settings_file() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("save");
let _ = app.update(ai(Ai::PickProvider(
pages::settings::ai::ProviderId::OpenAiCompatible,
)));
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
"sk-live-app-path",
))));
let _ = app.update(ai(Ai::Save));
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
assert!(
text.contains("\"base_url\": \"http://127.0.0.1:8080/v1\""),
"{text}"
);
assert!(text.contains("\"model\": \"qwen2.5-7b\""), "{text}");
assert!(
text.contains("\"provider\": \"openai_compatible\""),
"{text}"
);
assert!(text.contains("\"api_key\": \"sk-live-app-path\""), "{text}");
// 保存之后:草稿清干净、回执是「已保存」、密钥输入框清空(值已进文件,不再留在 UI 状态里)。
let state = &app.settings.ai_about;
assert_eq!(state.notice, Some(pages::settings::ai::SaveNotice::Saved));
assert!(!state.dirty);
assert!(state.key.is_empty());
assert_eq!(state.key_chars, "sk-live-app-path".chars().count());
let printed = format!("{state:?}");
assert!(
!printed.contains("sk-live-app-path"),
"UI 状态泄漏密钥:{printed}"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **非法值不保存**:端点没有协议头时,保存被拦下、文件不动、回执是具名拒绝原因。
#[test]
fn refusing_to_save_leaves_the_file_untouched() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("refuse");
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen".to_string())));
let _ = app.update(ai(Ai::Save));
let saved = std::fs::read_to_string(&path).expect("第一次应当写成");
let _ = app.update(ai(Ai::EditBaseUrl("api.deepseek.com/v1".to_string())));
let _ = app.update(ai(Ai::Save));
assert_eq!(
app.settings.ai_about.notice,
Some(pages::settings::ai::SaveNotice::Refused(
pages::settings::ai::state::Refusal::BaseUrl
)),
"没有协议头的端点必须被拦下,且要说清原因"
);
let after = std::fs::read_to_string(&path).expect("读文件");
assert_eq!(saved, after, "被拒绝的保存不许改动文件:{after}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **未配置时不发请求**(GUI 侧那一半):缺模型名时点「测试连接」不会起任务、不留 Running 态。
#[test]
fn testing_without_configuration_does_nothing() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("no-test");
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
// 模型名空着 ⇒ `can_test()` 为假
assert!(app.settings.ai_about.missing().is_some());
let _ = app.update(ai(Ai::Test));
assert_eq!(
app.settings.ai_about.probe,
pages::settings::ai::Probe::Idle,
"未配置时点测试不该有任何探测态(更不该发请求)"
);
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;
/// M204:Java 组写盘之后请 host 重发快照(动作键 `JAVA_REFRESH_ACTION`)。
#[path = "tests/java_refresh.rs"]
mod java_refresh;
@@ -1,374 +0,0 @@
//! 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();
}
// --------------------------------------------------------------- r3 P3-3:首屏自动扫必须有落脚点
/// **首屏自动扫一次盘**这件事必须在测试里有落脚点(r3 复审 P3-3)。
///
/// 现场:`hydrate_java_settings` 末尾那次 `if !cfg!(test) { auto_scan_java_if_due(...) }` 整行删掉、
/// 或把实参翻成 `true`(截图抑制档),全套测试仍是绿的——于是「设置页从不自动检测本机 Java」
/// 这个**用户实测的头号抱怨**回来时没人拦。
///
/// 为什么这条不是「再把判据测一遍」:判据(`auto_scan_due` 的四条)与取消语义已经有用例;这里要
/// 证明的是**首屏这条路真的调了它**。所以走的是生产入口(`construct.rs` 构造时与心跳都调
/// `hydrate_java_settings`)与生产作业(真扫盘),没有 `cfg!(test)` 分叉、也没有注入假作业。
///
/// 判据为什么同步可判、且与线程快慢无关:`ScanSlot::start_with` 在起线程**之前**就把 `running`
/// 置真(同一把锁里);万一扫描线程已经跑完,`finished` 会留着。所以「真的起过一次扫描」= 这两个
/// 位里至少有一个(空槽两者都是假)——不用等线程,也不看这台机器扫到了什么。
///
/// 变异证明:①删掉 `self.auto_scan_java_if_due(false);` 这一行 ⇒ 两位仍是假 ⇒ 红;
/// ②把它改成 `self.auto_scan_java_if_due(true);` ⇒ 被 `auto_scan_due` 的 `!screenshot` 挡掉 ⇒ 红。
#[test]
fn the_first_hydrate_starts_a_real_scan_by_itself() {
let (mut app, path) = bridged_app("java-autoscan");
// 构造那一刻已经自动扫过一次(同一个入口);这里要重新演一遍「首屏」,所以换一个空槽。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
app.hydrate_java_settings();
let report = app.settings.java.scan.report();
assert!(
report.running || report.finished,
"首屏 hydrate 没有自动发起扫描(快照是空的 {report:?}):设置页就不会自己检测本机 Java\
(r3 P3-3 的回归)"
);
// 收尾:取消这次真扫盘,别让它在后台继续探测本机 PATH 上的 java。
app.settings.java.scan.cancel();
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<pages::settings::java::RuntimeRow> {
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");
// 构造时已经自动扫过一次(首屏那条生产入口,见上一条用例);换成空槽,让本用例自己起。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
// 真起一次扫描(注入卡住的作业),再取消,等线程退出: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());
// 心跳那条路:走**生产入口**(`clock.rs` 每发现配置文件变了就调 `hydrate_java_settings`),
// 它末尾那次自动发起必须被「取消过」这一条挡住。
app.hydrate_java_settings();
assert!(
!slot.report().running,
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销):{:?}",
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 失败 @ …`。
///
/// M199 起页面侧已经撞不到这一档了(`java.extra_args` 超长先被 `State::validate` 具名拒掉,见下一条
/// 用例),所以这里直接钉 `write_notice` 这一层的映射:`Schema` 只能变成页面自己的 key。
///
/// 变异证明:把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 红;把它拼进
/// `Notice::Refused` 的 detail ⇒ 红(Debug 里会出现那句中文)。
#[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
use pages::settings::java::store::WriteError;
let notice = super::super::java::write_notice(WriteError::Schema(
"IO 失败 @ /tmp/launcher.json".to_string(),
));
assert_eq!(notice, pages::settings::java::Notice::WriteFailed);
let printed = format!("{notice:?}");
assert!(
!printed.contains("IO 失败"),
"core 的中文句子漏进了界面文案:{printed}"
);
}
/// 粘了 2000 字符的 JVM 参数:保存被**具名理由**拦下,而且文件一个字节都不动。
///
/// 现场(r3 复审 P3-4):`java.extra_args` 的 `max_len=1024` 只在 schema 那一档,页面侧不判长度
/// ⇒ 用户得到的是那句通用的「写不进去」,具体原因只在 stderr。本用例走的是 App 那条真保存路径。
///
/// 变异证明:删掉 `State::validate` 里的 `MAX_JVM_ARGS` 判据 ⇒ notice 变回
/// `Notice::WriteFailed`(schema 那一档),本条红。
#[test]
fn an_overlong_jvm_line_is_refused_with_its_own_reason() {
use pages::settings::java::Notice;
let (mut app, path) = bridged_app("java-jvm-long");
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));
let notice = app
.settings
.java
.notice
.clone()
.expect("保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("超长参数要给自己的原因(「太长」),不是那句通用的「写不进去」:{notice:?}");
};
assert_eq!(
reason.label(),
unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
"拒绝原因必须落到「太长」那条文案上(中英各一句,见 i18n 金表)"
);
assert!(!path.exists(), "被本地校验拒掉的保存不许碰磁盘");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- M197:三个设置键 + 真下载的落点
/// 三键改完点保存:**真落盘**、草稿被读回值收尾、不再脏,并且保存顺手重扫一次。
///
/// 变异证明:`save_java_prefs` 里不调 `prefs.adopt(snapshot)` ⇒ 草稿还是 `" D:/JDKs "`、
/// `dirty` 仍为真 ⇒ 本条红;不调 `start_java_scan` ⇒ `scan.report().running` 那条红。
#[test]
fn saving_the_three_keys_writes_them_and_reads_them_back() {
use pages::settings::java::prefs_store;
let (mut app, path) = bridged_app("java-prefs");
// 落点用这个临时数据根下的绝对路径:Windows / macOS / Linux 上都是绝对的,
// 断言于是与平台无关(写 "D:/JDKs" 在非 Windows 上是相对路径,会被解析到数据根下)。
let home = path.parent().unwrap().parent().unwrap().to_path_buf();
let jdks = home.join("JDKs").display().to_string();
let _ = app.update(java(pages::settings::java::Message::EditInstallRoot(
format!(" {jdks} "),
)));
let _ = app.update(java(pages::settings::java::Message::ToggleAutoInstall(
false,
)));
let _ = app.update(java(pages::settings::java::Message::MoveVendor {
index: 0,
up: false,
}));
assert!(app.settings.java.prefs.dirty(), "改过三键就该是脏的");
let _ = app.update(java(pages::settings::java::Message::SavePrefs));
assert_eq!(
app.settings.java.notice,
Some(pages::settings::java::Notice::PrefsSaved)
);
assert!(
!app.settings.java.prefs.dirty(),
"保存并把读回值当草稿之后不该还脏"
);
assert_eq!(
app.settings.java.prefs.install_root, jdks,
"草稿必须等于磁盘上那份(trim 过的),不是用户刚敲的原样"
);
assert!(
!app.settings.java.prefs.saved_auto_install,
"auto_install 要真变成关"
);
// 冷读:另起一次 load,确认三个键都在文件里。
let snapshot = prefs_store::load(&path);
assert_eq!(snapshot.install_root, jdks);
assert!(!snapshot.auto_install);
assert_eq!(snapshot.priority.len(), 4, "下移之后落盘的是整条链");
assert_ne!(snapshot.priority[0], "temurin", "顺序真的被改过");
assert!(
app.settings.java.scan.report().running,
"改了落点必须重扫一次,否则列表还停在旧目录里"
);
// 生效值也跟着换了层级(设了 installRoot ⇒ 落点来自设置)。
assert_eq!(
app.settings.java.effective.root, jdks,
"生效落点必须是刚保存的那一个"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// `java.auto_install` 开着且还没有默认 JDK:装完之后自动把新装的那份设为默认。
///
/// 变异证明:把 `hydrate_java_downloads` 里那段 `saved_auto_install && selected.is_empty()`
/// 的判据删掉 ⇒ 本条红(`selected` 还是空);把关掉那一档的断言反过来说明判据不是恒真。
#[test]
fn auto_install_adopts_the_fresh_jdk_when_no_default_is_set() {
use pages::settings::java::downloads::{Report, Success};
let (mut app, path) = bridged_app("java-adopt");
app.settings.java.prefs.saved_auto_install = true;
app.settings.java.selected = String::new();
app.settings.java.downloads.seed_report(Report {
job: None,
tries: Vec::new(),
failure: None,
success: Some(Success {
major: 21,
vendor: "Eclipse Temurin",
root: "D:/jdks/temurin/21".to_string(),
binary: "D:/jdks/temurin/21/bin/java.exe".to_string(),
source: Some("tuna".to_string()),
reused: false,
}),
});
assert!(app.hydrate_java_downloads(), "快照变了就要重建视图");
assert_eq!(
app.settings.java.selected, "D:/jdks/temurin/21/bin/java.exe",
"auto_install 开着 + 没有默认 JDK ⇒ 新装的那份自动成为默认"
);
let snapshot = pages::settings::java::store::load(&path);
assert_eq!(
snapshot.selected, "D:/jdks/temurin/21/bin/java.exe",
"而且真写进了 java.selected_path"
);
// 关掉 auto_install 时同样的成功快照**不**改默认。
let (mut app, path) = bridged_app("java-adopt-off");
app.settings.java.prefs.saved_auto_install = false;
app.settings.java.selected = String::new();
app.settings.java.downloads.seed_report(Report {
job: None,
tries: Vec::new(),
failure: None,
success: Some(Success {
major: 17,
vendor: "Eclipse Temurin",
root: "D:/jdks/temurin/17".to_string(),
binary: "D:/jdks/temurin/17/bin/java.exe".to_string(),
source: None,
reused: true,
}),
});
assert!(app.hydrate_java_downloads());
assert_eq!(app.settings.java.selected, "", "关掉就不许替用户改默认");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// 点一个目录里没有的大版本:立刻在下载区顶上写清原因,**不**起线程。
///
/// 变异证明:把 `start_java_download` 里 `catalog::find(major).is_none()` 那道判据删掉 ⇒
/// `download_notice` 为 `None`,本条红(点了却什么都不说是禁止的)。
#[test]
fn an_unknown_major_is_refused_on_the_spot() {
use pages::settings::java::downloads::Refusal;
let (mut app, path) = bridged_app("java-dl-refuse");
let _ = app.update(java(pages::settings::java::Message::Download(99)));
assert_eq!(
app.settings.java.download_notice,
Some(Refusal::UnknownMajor)
);
assert!(!app.settings.java.downloads.busy(), "不该起任何作业");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
@@ -1,307 +0,0 @@
//! M204:Java 组**写盘成功之后请 host 重发设置快照**这一拍(`JAVA_REFRESH_ACTION`)。
//!
//! ## 为什么需要这一条(缺口是什么)
//!
//! Java 组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
//! 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`)——
//! 那是**同步、原子**的一次落盘,但**不经过** `AppCommand::SetSetting` 的写盘分支。而
//! `CoreEvent::SettingsChanged` 全仓只有两处发出:`host/dispatch.rs` 的 `SetSetting` 分支与
//! `host/mod.rs` 的 `refresh_all`。于是三键写成功之后,`AppState::settings_groups` 里 java 组那几行
//! 会**停在旧值**,直到下一次任何别的写入(改并发、改主题……)顺手带一份新快照回来。
//!
//! M204 的修法:落盘成功之后派发一条动作键(`dto::JAVA_REFRESH_ACTION`,`host::settings::set`
//! 见到它只返回 `Ok(())`,不写盘),让 `SetSetting` 分支照常重发 `SettingsChanged`。**没有**改用
//! `RefreshAll`:那个还会 `versions::spawn_list` 与 `start_update_check`,每点一次保存打两个网络请求。
//!
//! ## 两条用例证明的事不同
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 第一条 | **真 `CoreHost` + 临时数据根** | 写盘 ⇒ 快照真的变了(磁盘 + 快照两头的字节级证据) |
//! | 第二条 | 假后端(记账)+ 本地校验拒绝 | **被拒的保存不许请求刷新**(那一拍只该跟在真落盘之后) |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{find_row, AppCommand, AppEvent, Backend, SettingGroupView};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use super::*;
/// 本组要断言的键(与 presenter 快照 / schema 同名)。
const JVM: &str = "java.extra_args";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一条 Java 分组消息 → 页面消息(与 `tests/java.rs` 同一个漏斗)。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 快照里某个键的**上屏文本**(`None` = 快照里没这一行)。
///
/// 用 `value_text` 而不是控件序号:Java 组这三个键里 `extra_args` / `selected_path` 是 text 行,
/// `value_text` 就是文件里的原文;内存那一行是四档 choice(见报告「没做/没验」第 ③ 条),
/// 它的**当前值**表达在 `options[selected]` 上,所以本文件不对内存行做取值断言——那会把
/// 「presenter 的四档表达不了 6144」这件事伪装成绿。
fn row_value(groups: &[SettingGroupView], key: &str) -> Option<String> {
find_row(groups, key).map(|row| row.value_text.clone())
}
/// **主用例**:改内存 + JVM 参数 ⇒ 点保存 ⇒ 磁盘变了 **且** host 发回来的快照也变了。
///
/// 链子全长(与本组的产品路径逐段对应):`Message::EditMemory/EditJvm` → `State` 草稿 →
/// `Message::Save` → `store::save`(一次 `store.save()` 写两个键)→ `request_settings_refresh()`
/// → 真 `CoreHost` 的 `SetSetting` 分支(`settings::set` 认动作键、不写盘)→
/// `CoreEvent::SettingsChanged(settings::groups(env))` → `AppState::settings_groups` →
/// `pages::Model::settings`。断言落在**磁盘字节**与**快照文本**上,不落在任何中间变量上。
///
/// 抓得住:
/// 1. 摘掉 `request_settings_refresh()` 的 dispatch(`save_java_settings` 里那一行)⇒ 快照仍是
/// 空串 ⇒ 红(**这就是本用例的变异验证**,且是本文件第二条都不红的唯一原因);
/// 2. 写盘没真发生(`store::save` 的 `save()` 被删)⇒ 磁盘断言红;
/// 3. 动作键在 presenter 侧被当成真键(`host::settings::set` 少了那条早退)⇒ `store.set` 报
/// 「未知键」⇒ 页面拿到 error Toast、快照不更新 ⇒ 红;
/// 4. 快照回流被丢掉(`view::model` 的 `settings:` 那行被删)⇒ `app.view.settings` 空 ⇒ 红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑);「页面上的值是不是旧值」这一半——写盘成功后
/// 页面本来就该显示新值,旧值那一半由 `tests/java.rs` 的拒绝用例守(本地校验不过时文件一个字节
/// 不动、回执是具名原因)。
#[test]
fn saving_the_java_form_refreshes_the_host_settings_snapshot() {
let dir = std::env::temp_dir().join(format!("ull-m204-java-refresh-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
row_value(app.settings_snapshot(), JVM).as_deref(),
Some(""),
"临时根里没有 `java.extra_args` ⇒ 快照这一行是空串(出厂值就是没有额外参数)"
);
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(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"default_memory_mb\": 6144"),
"磁盘上必须真的有 6144:{written}"
);
assert!(
written.contains("-XX:+UseG1GC"),
"磁盘上必须真的有那段 JVM 参数:{written}"
);
assert_eq!(
row_value(app.settings_snapshot(), JVM).as_deref(),
Some("-XX:+UseG1GC"),
"host 必须为刚写进去的三键重发一次快照(否则 AppState 里这一行还是旧值)"
);
assert_eq!(
row_value(&app.view.settings, JVM).as_deref(),
Some("-XX:+UseG1GC"),
"页面模型也得跟着换:下一帧画的是 `Model.settings`"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 被拒的保存不许请求刷新
/// 只记账、不回事件的后端(本用例只关心「有没有发出那条动作键」)。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
/// 本地校验把保存拦下来时,**一次刷新都不许请求**:那一拍只该跟在真落盘之后。
///
/// 抓得住:把 `request_settings_refresh()` 挪到 `validate()` 之前(或无条件调用)⇒ 这里能看见
/// 动作键 ⇒ 红。抓不住:写盘失败的回执文案(那是 `tests/java.rs` 的 `write_notice` 用例)。
#[test]
fn a_refused_save_does_not_ask_for_a_refresh() {
use pages::settings::java::Notice;
let commands = Rc::new(RefCell::new(Vec::new()));
let dir = std::env::temp_dir().join(format!("ull-m204-java-refuse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&commands))),
SettingsBridge::with_path(&path),
);
// 128 MB 低于 schema 的下限(512):本地校验就该拦下,不写盘。
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"128".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
// 越界的内存要在本地就被拒(具名原因 = schema 下限 512 那条文案,不是通用的「写不进去」)。
let notice = app
.settings
.java
.notice
.clone()
.expect("被拒的保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("越界的内存必须是具名的本地拒绝:{notice:?}");
};
assert_eq!(
reason.label(),
unlucky_i18n::Key::SettingsJavaReasonMemoryRange,
"拒绝原因要落到「超出允许范围」那条文案上"
);
assert!(!path.exists(), "被拒的保存不许碰磁盘");
assert!(
!commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, .. }
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
)),
"没落盘就没有可刷新的事实(实际下发:{:?})",
commands.borrow()
);
std::fs::remove_dir_all(&dir).ok();
}
/// 真的落盘时,动作键**必须**发出去(正面那一半;与第一条互为背书)。
///
/// 为什么还要这一条:第一条用真 `CoreHost`,它的失败可能是「快照没回来」的任意一环;这一条把
/// 「到底有没有派出那条动作键」单独钉住,红的行号不同,排查时不必再猜是哪一环。
///
/// 抓得住:`request_settings_refresh()` 的键名写错(比如漏了 `action` 段)⇒ presenter 会把它当
/// 真键报「未知键」,而这条用例在本层就能看见键名对不对。
/// 抓不住:presenter 收到动作键之后有没有真的重发快照(那是第一条的事)。
#[test]
fn a_successful_save_dispatches_the_refresh_action_key() {
let commands = Rc::new(RefCell::new(Vec::new()));
let dir = std::env::temp_dir().join(format!("ull-m204-java-dispatch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&commands))),
SettingsBridge::with_path(&path),
);
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
assert!(path.exists(), "这一条的前提是保存真的落盘了(临时根可写)");
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, .. }
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
)),
"落盘之后必须派发动作键(实际下发:{:?})",
commands.borrow()
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 「立即扫描」真的接线
/// 「立即扫描」在 App 层是**真接线**的:`Message::Scan` ⇒ 起一次真扫盘(不是只画了颗按钮)。
///
/// 为什么这一条住在本文件(而不是 `tests/java.rs`):那个文件 374 行、`tests.rs` 正好 400 行,
/// 再开一个模块文件就要往 `tests.rs` 加一行 `mod`(400 → 401,撞 `gate` 的 400 行红线)。
/// 主题上也勉强算同一族:两者都是「设置页那一组对 App 做了什么」的守卫(这条守扫描入口,
/// 上面几条守写回闭环)。
///
/// 抓得住:`on_settings_java` 的 `Message::Scan => return self.start_java_scan()` 被改成
/// `Task::none()`(只画按钮不接线 —— 用户在早期投诉里点名的那一条)⇒ `report().running`
/// 仍是 `false`、`scanning` 仍是 `None` ⇒ 红(实测见报告「变异验证」M3)。
/// 抓不住:扫描**结论**是否写对了(那是 `pages/settings/java/tests/runtime.rs` 的事),
/// 也抓不住真实鼠标点击(本用例从 `Message` 起跑)。
#[test]
fn pressing_scan_starts_a_real_scan() {
// 用截图档构造(`Options.screenshot = Some(..)`)**只为一个目的**:让首屏那次
// `auto_scan_java_if_due` 不发生(`hydrate_java_settings` 的截图分支灌种子后直接 return),
// 于是「开局没有扫描在跑」这个前提是确定的。手动点「立即扫描」走的是**同一条产品代码**
// (`start_java_scan` → `ScanSlot::start`),截图档不拦它。
let dir = std::env::temp_dir().join(format!("ull-m204-java-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options {
screenshot: Some(std::path::PathBuf::from("m204-scan.png")),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
SettingsBridge::with_path(&path),
);
assert!(
!app.settings.java.scan.report().running,
"这一条的前提:构造时没有扫描在跑"
);
let _ = app.update(java(pages::settings::java::Message::Scan));
let report = app.settings.java.scan.report();
assert!(
report.running,
"「立即扫描」必须真的起一条扫描线程:{report:?}"
);
assert!(
app.settings.java.scanning.is_some(),
"状态行要能区分「正在扫描」(`scanning` 不能还是 None)"
);
// 别把这条用例起的真扫盘留给后面的用例:置取消位,线程会在下一个候选边界退出。
app.settings.java.scan.cancel();
std::fs::remove_dir_all(&dir).ok();
}
@@ -1,234 +0,0 @@
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
use crate::app::diag::diag;
use iced::Task;
use unlucky_presenter::dto::AppCommand;
use crate::pages::UpdateAction;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 一条更新动作。
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
match action {
UpdateAction::CheckNow => {
if self.update_busy() {
return self.refuse_busy("立即检查");
}
self.dispatch(AppCommand::CheckUpdate)
}
UpdateAction::Apply => {
if self.update_busy() {
return self.refuse_busy("下载并安装");
}
self.dispatch(AppCommand::ApplyUpdate)
}
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
UpdateAction::RestartNow => return self.restart_now(),
}
Task::none()
}
/// 有没有一条检查/下载正在跑。
///
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
///
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
fn update_busy(&self) -> bool {
let view = &self.state.update;
view.applying || view.outcome.is_checking()
}
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
fn refuse_busy(&self, what: &str) -> Task<Message> {
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
Task::none()
}
/// 立即重启:按同样的命令行拉起自己,然后退出。
///
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
/// 文件、并把取证进程拖成死循环。
fn restart_now(&mut self) -> Task<Message> {
if self.options.screenshot.is_some() {
diag!("[update] 截图通道不重启(忽略「立即重启」)");
return Task::none();
}
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
// 子进程是本 GUI exe 自己(Subsystem=2,不会分到控制台);走 `quiet` 是为了让
// 「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住(M217)。
let spawned = unlucky_core::platform::child::quiet(&exe)
.args(&args)
.spawn();
match spawned {
Ok(child) => diag!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
exe.display()
),
Err(error) => {
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
return Task::none();
}
}
}
Err(error) => {
diag!("[update] 取 current_exe 失败:{error}");
return Task::none();
}
}
iced::exit()
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::UpdateAction;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
struct Recorder {
seen: Arc<Mutex<Vec<AppCommand>>>,
}
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.seen
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(command);
}
fn tick(&mut self, _elapsed: std::time::Duration) {}
}
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder {
seen: Arc::clone(&seen),
}),
SettingsBridge::memory_only(),
);
(app, seen)
}
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.last()
.cloned()
.expect("应当派发过一条命令")
}
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
#[test]
fn every_update_action_reaches_the_backend() {
let (mut app, seen) = app();
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate);
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
let _ = app.on_update_action(UpdateAction::Dismiss);
assert_eq!(last(&seen), AppCommand::DismissUpdate);
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
}
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
///
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
#[test]
fn pressing_while_busy_dispatches_nothing() {
let (mut app, seen) = app();
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
let _ = app.on_update_action(UpdateAction::CheckNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"正在检查时再按「立即检查」不许派发第二条命令"
);
// 检查回来(失败也算回来):不忙了,能再点。
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
);
let _ = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
app.state.update.applying = true;
let _ = app.on_update_action(UpdateAction::Apply);
let _ = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
before,
"applying 期间不许派发任何新的检查/安装"
);
// 应用结束:恢复可点。
app.state.update.applying = false;
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
}
/// 截图通道不重启:取证进程不许把自己重启成死循环。
#[test]
fn the_screenshot_channel_never_restarts() {
let (mut app, seen) = app();
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
let _ = app.on_update_action(UpdateAction::RestartNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+6 -95
View File
@@ -21,58 +21,12 @@ use super::state::{log_first_frame, App};
use super::APP_TITLE;
mod account_page;
pub(super) mod instances;
mod instances;
mod logs;
mod rail;
mod update;
use instances::{detail_of, runtime_status_row, supporting_line};
use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model;
use update::update_of;
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
///
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-ink` / `ull-silver`),
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
/// D30 §2.1 的「哪一行日志证明它是哪个组/主题」:出图通道专用的一行身份日志。
///
/// ## 为什么必须有
///
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-silver`。
///
/// ## 为什么只在变化时打
///
/// `App::view` 每帧都调用;每帧一行会把 `launcher.log` 刷满,反而没人能从中找到那一行。
/// 判据是(组序号,主题名)这一对——同一次截图进程里最多两行(首帧 + 主题切一次)。
///
/// ## 为什么是 `try_lock` 而不是 `lock`
///
/// 这里在 UI 线程的每帧路径上。`try_lock` 拿不到锁就**直接返回**(这一帧不打,下一帧还会再来,
/// 身份行只是晚一拍、不会丢),于是「界面帧永远不会被这把锁挡住」是**代码保证的**,不是靠
/// 「今天只有一个调用点、锁里只有一次比较」这种会随改动失效的前提。M204 r1 复审把原先那句
/// 「拿不到锁就不打」和当时用的阻塞 `lock()` 对上了:注释承诺的保证必须由代码提供。
///
/// **非截图通道一律不打**:交互模式下用户每点一次左列就多一行日志,没有价值。
fn note_screenshot_group(screenshot: bool, route: &Route, group: usize, theme: &str) {
if !screenshot {
return;
}
let Ok(mut last) = SHOT_GROUP_LOGGED.try_lock() else {
return;
};
let seen = (group, theme.to_string());
if last.as_ref() == Some(&seen) {
return;
}
*last = Some(seen);
drop(last);
crate::app::diag::diag!("[shot] settings route={route:?} group={group} theme={theme}");
}
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -89,18 +43,6 @@ impl App {
pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame();
// D30 §2.1:出图通道要有一行能**自证身份**的日志——「这张图是哪个设置组、哪个主题」。
// 上一次 M201 的事故就是拿外观页当下载组的证据;图不自证身份,结论就无处落地。
// 只对设置页打(左列那几组才有「哪个组」这件事)。
if self.state.route() == &Route::Settings {
note_screenshot_group(
self.options.screenshot.is_some(),
self.state.route(),
self.appearance.selected_group,
self.ui().name(),
);
}
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
@@ -131,8 +73,7 @@ impl App {
};
let account = self.rail_account();
// M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account(
&self.ui,
shell::NavRail {
@@ -142,21 +83,15 @@ impl App {
APP_TITLE,
background,
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
pages::page_with_settings_transition(
pages::page_with_settings(
&self.ui,
&self.view,
// M256:换页过渡期的旧页快照(`App::sync_motion` 在动画结束当拍把它丢掉)。
self.previous_view.as_ref(),
pages::settings::States {
appearance: Some(&self.appearance),
groups: &self.settings,
// M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。
settings: &self.view.settings,
},
// M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(),
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
&self.launch_form,
)
.map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width)
@@ -185,21 +120,7 @@ impl App {
self.options.force_state,
self.lang(),
),
};
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
let tree = match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree,
};
// M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。
// 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。
super::adopt::layer(
&self.ui,
tree,
&self.adopt_form,
&self.state.adopt,
self.lang(),
)
}
}
}
@@ -215,7 +136,6 @@ impl App {
pub(super) fn model(
state: &AppState,
width: f32,
height: f32,
forced: Option<crate::cli::ForcedState>,
category: pages::Category,
draft: &pages::InstallDraft,
@@ -234,7 +154,7 @@ pub(super) fn model(
id: cell.instance.id.clone(),
name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog),
status: runtime_status_row(&cell.instance.runtime, catalog),
status: runtime_status(&cell.instance.runtime, catalog),
})
.collect();
@@ -250,21 +170,12 @@ pub(super) fn model(
pages::Model {
screen,
window_width: width,
window_height: height,
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang),
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
mods: instances::mods_of(state, catalog),
saves: instances::saves_of(state, catalog),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
settings: state.settings_groups.clone(),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+7 -128
View File
@@ -18,14 +18,10 @@
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome,
};
use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
use crate::pages::{Detail, LaunchFacts, Meta, Mods, Saves};
use crate::pages::{Detail, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -63,7 +59,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
@@ -75,101 +71,11 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
}
// 失败有**两种形状**,各说各的话(复审 P1-1):
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
// 绝不说成「启动游戏进程失败」。
//
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
InstanceRuntime::Failed { reason } => {
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
}
}
/// 一条失败状态行。
///
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
match cause {
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(*stage, catalog)),
("detail", detail.as_str()),
],
),
LaunchFailureCause::Stage { stage, .. } => catalog.render(
Key::InstancesFailed,
&[("reason", failure_stage_label(*stage, catalog))],
),
LaunchFailureCause::Crashed { exit_code } => {
let reason = match exit_code {
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
};
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
LaunchFailureCause::SilentFailure { .. } => {
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
}
}
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
///
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
other => runtime_status(other, catalog),
}
}
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
///
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
})
}
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
///
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
#[must_use]
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
let catalog = lang.catalog();
if let Some(failure) = &finished.failure {
return catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(failure.stage, catalog)),
("detail", failure.detail.as_str()),
],
);
}
let outcome = catalog.text(match finished.outcome {
LogOutcome::Ok => Key::LaunchOutcomeNormal,
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
});
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
catalog.render(
Key::LaunchToastEnded,
&[("outcome", outcome), ("seconds", seconds.as_str())],
)
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
@@ -229,17 +135,6 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
}
}
@@ -265,19 +160,3 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
_ => text.to_string(),
}
}
/// 本地 Mod 的展示模型(M224):presenter 的视图态 → 页面要画的东西。
///
/// 转换本体在 [`crate::pages::instances::mods`] 的 `from_state`(文案要用 `Texts`,那是页面层的
/// 取词入口)——这里只把"当前语言"给过去,与 [`detail_of`] 同一条接法。
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
crate::pages::mods_from_state(&state.mods, catalog)
}
/// 存档 / 截图的展示模型(M235):presenter 的视图态 → 页面要画的东西。
///
/// 与 [`mods_of`] 同一条接法:转换本体在 `pages::instances::saves::from_state`(文案要用
/// `Texts`),这里只把"当前语言"给过去。
pub(super) fn saves_of(state: &AppState, catalog: Catalog) -> Saves {
crate::pages::saves_from_state(&state.saves, catalog)
}
+2 -6
View File
@@ -74,9 +74,7 @@ fn crashed_only() -> AppState {
id: "dev".to_string(),
session: CRASH.to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
summary: "崩溃(exit 1)".to_string(),
}));
state
@@ -168,9 +166,7 @@ fn an_imported_crash_file_still_shows_its_findings() {
id: "dev".to_string(),
session: CRASH.to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
summary: "崩溃(exit 1)".to_string(),
}));
state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt",
+15 -5
View File
@@ -7,9 +7,6 @@ use unlucky_presenter::dto::{
};
use unlucky_presenter::mock_data::mock_instances;
#[path = "tests/launch.rs"]
mod launch;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
@@ -25,7 +22,6 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
}
}
@@ -42,7 +38,6 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
model(
state,
1180.0,
760.0,
forced,
pages::Category::All,
&draft,
@@ -126,6 +121,21 @@ fn a_busy_instance_offers_stop_instead_of_play() {
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test]
fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog();
@@ -1,149 +0,0 @@
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
//!
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
use super::*;
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
#[test]
fn failed_instances_can_be_retried() {
// 进程**没起来**:说「哪一步 + 原文」。
let failed = instance(InstanceRuntime::Failed {
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
});
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
assert_eq!(
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process")
);
// 详情页给全文(阶段 + 原文)。
assert_eq!(
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process: os error 5")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
///
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
#[test]
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
let line = |cause: LaunchFailureCause, lang: Lang| {
let runtime = InstanceRuntime::Failed { cause };
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
(row, detail)
};
// ① 没起来:说阶段。
let (row, detail) = line(
LaunchFailureCause::Stage {
stage: LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
Lang::En,
);
assert_eq!(row, "Launch failed · Starting the game process");
assert_eq!(
detail,
"Launch failed · Starting the game process: os error 5"
);
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
assert!(
!detail.contains("Starting the game process"),
"进程起来了,不许说「启动游戏进程」:{detail}"
);
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
let (row, zh) = line(
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
Lang::ZhCn,
);
assert_eq!(row, "启动失败 · 退出码 0,没起来");
assert_eq!(zh, row);
assert!(row.contains("没起来"), "{row}");
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
assert_eq!(row, "Launch failed · The game crashed");
}
///
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
///
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
#[test]
fn the_launch_toast_speaks_the_interface_language() {
use unlucky_presenter::dto::{
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
};
let finished = |outcome, failure| LaunchFinished {
id: "survival".to_string(),
session: "20261031T120000-abcd12".to_string(),
outcome,
exit_code: Some(1),
uptime_ms: 12_500,
failure,
};
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
let ok = finished(LogOutcome::Ok, None);
let en = super::instances::launch_toast(&ok, Lang::En);
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
assert_eq!(en, "Finished normally · 12.5s");
assert_eq!(zh, "正常结束 · 12.5 秒");
assert_ne!(en, zh, "切语言时提示语要变");
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
let failed = finished(
LogOutcome::Crashed,
Some(LaunchFailureView::new(
LaunchFailureStage::InstanceConfig,
"permission denied (os error 5)",
)),
);
let en = super::instances::launch_toast(&failed, Lang::En);
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
assert!(
en.starts_with("Launch failed · Reading the instance settings"),
"{en}"
);
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
for text in [&en, &zh] {
assert!(
text.contains("permission denied (os error 5)"),
"原文(取证信息)不许丢:{text}"
);
}
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
assert!(
!zh.contains("Launch failed"),
"中文提示里混了英文前缀:{zh}"
);
}
/// 有没有中文字符(上面那条「不许混语言」的判据)。
fn is_cjk(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
-157
View File
@@ -1,157 +0,0 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
skipped: view.skipped.clone(),
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view);
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}
@@ -2,9 +2,6 @@
//!
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
//!
//! 禁用态的合成底([`Base`],M234)也从这里往下传:`Button::base(..)` → [`StyleSpec::base`] →
//! `disabled_button_colors` / `disabled_button_content`。装配侧没有第二个猜底色的地方。
use iced::widget::text::LineHeight;
use iced::widget::{button, text, Row};
@@ -13,17 +10,18 @@ use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::elevation::Elevation;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Base, Interaction};
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::{Button, Geometry, Variant};
use super::{Geometry, Variant};
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
#[derive(Debug, Clone, Copy)]
pub(crate) struct StyleSpec {
pub(super) struct StyleSpec {
/// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。
pub fill: Option<Role>,
/// 内容(文字 + 图标)角色。
@@ -36,12 +34,6 @@ pub(crate) struct StyleSpec {
pub stroke: f32,
/// 圆角折算用的边长(药丸 = 可见高)。
pub radius_side: f32,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容都叠在它上面。
///
/// 由构造点给(卡片 = `surfaces::elevated::BASE`、菜单项 = `inputs::menu::PANEL_BASE`、
/// 直接摆在画布上 = [`Base::CANVAS`])。M229 之前这里恒为 `Role::Surface`,
/// 卡片里的禁用按钮因此把 10% 叠在画布色上(M223 的「形丢失」)。
pub base: Base,
/// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
///
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
@@ -54,11 +46,15 @@ pub(crate) struct StyleSpec {
pub(super) fn assemble<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: Variant,
spec: Button<'a, M>,
spec: super::Button<'a, M>,
) -> Element<'a, M> {
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
let (fill, content, _border) = variant.roles();
let forced = spec.forced();
let geometry = Geometry::of(ui, variant, spec.leading.is_some());
let (fill, content, border) = variant.roles();
let forced = spec.state.or(if spec.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
debug_assert!(
@@ -72,22 +68,14 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
let ink = content_color(
ui,
spec.base,
forced,
content,
fill,
variant.disabled_content(),
);
let ink = content_color(ui, forced, content, fill, variant.disabled_content());
// 标签排版跟尺寸档走:`ControlSize::label()` 已把「字号跟字级、字重固定 500」定死
// (XS/S = LabelLarge 14、M = BodyLarge 16、L = HeadlineSmall 24、XL = HeadlineLarge 32)。
let label_style = geometry.size.label();
let label = text(spec.label)
.size(label_style.size_px)
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
.font(ui.font_of(label_style))
.size(ui.style(TypeRole::LabelLarge).size_px)
.line_height(LineHeight::Absolute(Pixels(
ui.style(TypeRole::LabelLarge).line_height_px,
)))
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
.color(ink);
let mut row = Row::new().align_y(Alignment::Center);
@@ -118,7 +106,18 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
.height(Length::Fill)
.align_y(iced::Alignment::Center);
let style = style_closure(ui, text_style_spec(ui, variant, &spec));
let style = style_closure(
ui,
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
forced,
},
);
let element = button(content_box)
.height(Length::Fixed(geometry.height))
@@ -131,26 +130,6 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
}
}
/// `Button` → 样式描述:**装配的唯一接线点**(`assemble` 与单测都走这里)。
///
/// 抽出来的理由与 [`super::icon_style_spec`] 同源:`button::Style` 是闭包、`Element` 是黑盒,
/// 两者都不可内省,所以「禁用态的底到底传了哪一个」只能在**结构体**上断言。
/// 写死一个 `StyleSpec` 再喂给 `style_closure` 的测试证明不了接线(M229 复审 P1-1 踩过)。
pub(super) fn text_style_spec<M>(ui: &Ui, variant: Variant, spec: &Button<'_, M>) -> StyleSpec {
let (fill, content, border) = variant.roles();
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
base: spec.base,
forced: spec.forced(),
}
}
/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
///
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
@@ -160,20 +139,19 @@ pub(super) fn text_style_spec<M>(ui: &Ui, variant: Variant, spec: &Button<'_, M>
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
pub(super) fn content_color(
ui: &Ui,
base: Base,
forced: Option<Interaction>,
content: Role,
fill: Option<Role>,
disabled_content: Role,
) -> iced::Color {
match forced {
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated),容器再叠在 `base` 上
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated)
Some(Interaction::Disabled) if fill.is_some() => {
interaction::disabled_button_content(ui, base, disabled_content, true)
interaction::disabled_button_content(ui, disabled_content, true)
}
// 无容器:38% 直接叠在 **base** 上(Text / Outlined / standard IconButton)
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, base, disabled_content, false)
interaction::disabled_button_content(ui, disabled_content, false)
}
_ => ui.color(content),
}
@@ -190,17 +168,11 @@ pub(super) fn style_closure(
.forced
.unwrap_or_else(|| Interaction::from_status(status));
// 禁用那一对颜色**只求值一次**:背景与描边用的是同一对值(早先调了两次)
let disabled = state
.is_disabled()
.then(|| interaction::disabled_button_colors(ui, spec.base));
let (background, text_color) = match disabled {
Some((container, content)) => {
let (background, text_color) = if state.is_disabled() {
let (container, content) = interaction::disabled_button_colors(ui, Role::Surface);
// 透明容器在禁用态依然透明(官方只改内容透明度)
(spec.fill.map(|_| container), content)
}
None => {
} else {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
let background = match spec.fill {
Some(role) => match state.layer() {
@@ -208,17 +180,16 @@ pub(super) fn style_closure(
.or(Some(ui.color(role))),
None => Some(ui.color(role)),
},
None => transparent_state_color(ui, spec.base, state, spec.content),
None => transparent_state_color(ui, state, spec.content),
};
(background, ui.color(spec.content))
}
};
let border_color = match disabled {
Some((container, _content)) => container,
None => spec
.border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)),
let border_color = if state.is_disabled() {
interaction::disabled_button_colors(ui, Role::Surface).0
} else {
spec.border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
};
button::Style {
@@ -246,18 +217,14 @@ pub(super) fn style_closure(
}
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
/// 叠在**内容角色**上的状态层——把它们当作「叠在 `base`(脚下那一层)上」求值,得到不透明实色。
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。
///
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
/// 三个变体共用一条口径,免得各自发挥。
///
/// M234 复审 P2-3:`base` 之前写死 `Role::Surface`,卡片里的 Text/Outlined 按钮抬起时
/// 状态层叠在画布色上(与禁用支的口径不一致)。
pub(super) fn transparent_state_color(
ui: &Ui,
base: Base,
state: Interaction,
content: Role,
) -> Option<iced::Color> {
interaction::overlay_color(ui, state, content, base.role())
interaction::overlay_color(ui, state, content, Role::Surface)
}
@@ -1,227 +0,0 @@
//! FAB 四态(规格卡 `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。
//!
//! ## 规格卡原值(`[spec]` = 图上的坐标/字号,几何全部来自 [`FabSize`] 的令牌访问器)
//!
//! | 档 | 尺寸 | 圆角 | 图标 | 标签 | 规格卡依据 |
//! |---|---|---|---|---|---|
//! | Small | 40×40 | 12(`Medium`) | 20 | — | `+` 徽章式小 FAB |
//! | Default | 56×56 | 16(`Large`) | 26 | — | M3 默认 FAB |
//! | Medium | 80×80 | 20(`LargeIncreased`) | 34 | — | 中大档 |
//! | Extended | 160×56(最小宽) | 16(`Large`) | 24 | 15 | `+ New`:起始内距 28、图标↔标签 8 |
//!
//! ## 配色:`PrimaryContainer` / `OnPrimaryContainer`
//!
//! 规格卡上四颗 FAB 都是**淡绿底 + 深绿字形**,M3 的对应角色正是 `primary-container` /
//! `on-primary-container`(`boards/ink.rs` 的 `[spec]` 行注释也这么标:`FAB 底` / `FAB 图标`)。
//! 所以这里不引入新角色,也不画阴影——规格卡上是纯色块,M3 的 FAB 高程由容器色表达。
//!
//! 色值不写在这份文档里:`tests/no_hardcoded_tokens.rs` 会扫 `src/**` 的十六进制色值,
//! 要核对数字请看 `boards/ink.rs` 的 `ANCHORS` 表(那是唯一真源)。
//!
//! ## 为什么单独一个文件
//!
//! 按钮家族(五式 × 五态 × 五档)已经在 `buttons/mod.rs` + `assemble.rs` 里贴着 400 行;FAB 的
//! 几何与按钮**不共享**(圆角不是半高、图标随档放大、扩展态有自己的内距),硬塞进去只会让两边
//! 的读取路径都变长。共享的只有 `interaction` 的状态层与图标烘焙工具。
use iced::widget::{button, container, row, text};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::inkcat::size::FabSize;
use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
/// 扩展态右侧内距(px)。规格卡只量到起始 28(`FabSize::pad_start_px`),右侧没有量;
/// 取同值 **28** 让「图标 + 标签」在 160 宽里左右对称(`[估]`,与起始同源的唯一对称解)。
pub const EXTENDED_PAD_END: f32 = 28.0;
/// 一颗 FAB:图标必有,标签只有扩展态有;`on_press = None` 即禁用。
#[derive(Debug, Clone)]
pub struct Fab<'a, M> {
icon: IconKind,
label: Option<&'a str>,
size: FabSize,
on_press: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Fab<'a, M> {
/// 标准 FAB(默认档 = 56)。
pub fn new(icon: IconKind, on_press: M) -> Self {
Self {
icon,
label: None,
size: FabSize::Default,
on_press: Some(on_press),
state: None,
}
}
/// 扩展态(药丸 + 图标 + 标签)。`label` 文案由调用方给(组件不碰 i18n)。
pub fn extended(icon: IconKind, label: &'a str, on_press: M) -> Self {
Self {
label: Some(label),
size: FabSize::Extended,
..Self::new(icon, on_press)
}
}
/// 换档(Small / Default / Medium / Extended)。
pub fn size(mut self, size: FabSize) -> Self {
self.size = size;
self
}
/// 强制视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 禁用态的内容色:`OnPrimaryContainer` @38% 叠在 **FAB 自己的容器**(`PrimaryContainer`)上。
///
/// ## 为什么底是 FAB 自己的容器,不是画布(M234 复审 P2-2)
///
/// 五式按钮的禁用容器是「`OnSurface`@10% 叠在脚下那一层」——透明的那种甚至没有容器;所以它们的
/// 内容要叠 `0.10 容器 → 父底`。FAB 不一样:`Interaction::Disabled.layer()` 是 `None`,
/// 样式闭包走 `None => Some(ui.color(Role::PrimaryContainer))`,**禁用时背景仍是满档
/// `PrimaryContainer`**(官方 `FloatingActionButtonTokens` 也没有 DisabledContainerOpacity)。
/// 所以内容只能叠 `PrimaryContainer` 本身,`on_container = false`——那一层 10% 在 FAB 身上
/// 根本不存在,给了反而多算一层灰。
///
/// 修前传 `Base::CANVAS`:38% 的 `OnPrimaryContainer` 合在画布色上,出来是一块与 FAB 底无关的灰
/// (Silver 浅色实测是一块灰 `rgb(0.549,0.627,0.667)`),而不是「深色字压在淡蓝容器上」
/// 的应有结果(同板上正确值是 `rgb(0.329,0.561,0.737)`;数字见 artifacts/m234 的 README)。
///
/// `button()` 与样式闭包**共用这一个函数**:早先两处各写一遍,两次都传了同一个错值。
fn content_color(ui: &Ui, forced: Option<Interaction>) -> iced::Color {
match forced {
Some(Interaction::Disabled) => interaction::disabled_button_content(
ui,
Base::of(Role::PrimaryContainer),
Role::OnPrimaryContainer,
false,
),
_ => ui.color(Role::OnPrimaryContainer),
}
}
/// 画一颗 FAB。
pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
let size = spec.size;
let forced = spec.state.or(if spec.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
// 内容色(含禁用档)只走 `content_color` 一处
let ink = content_color(ui, forced);
let glyph = layout::fixed(
size.icon_px(),
size.icon_px(),
icons::view_at::<M>(spec.icon, ink, size.icon_px()),
);
let content: Element<'a, M> = match spec.label {
// 扩展态:图标 + 标签(起始内距 28、间距 8,规格卡实测值)
Some(label) => {
let label_style = TypeStyle {
weight: Weight::Medium,
..TypeRole::BodyMedium.style()
};
let label_px = size.label_px();
row![
glyph,
text(label)
.size(label_px)
.font(ui.font_of(TypeStyle {
size_px: label_px,
..label_style
}))
.color(ink),
]
.spacing(size.gap_px())
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: EXTENDED_PAD_END,
bottom: 0.0,
left: size.pad_start_px(),
})
.into()
}
None => container(glyph)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into(),
};
let shape = size.shape();
let radius = size.radius_px();
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let background = match state.layer() {
// 状态层叠在容器色上(与按钮族同一条 `overlay_color` 口径)
Some(_) => interaction::overlay_color(
ui,
state,
Role::OnPrimaryContainer,
Role::PrimaryContainer,
)
.or(Some(ui.color(Role::PrimaryContainer))),
None => Some(ui.color(Role::PrimaryContainer)),
};
let text_color = content_color(ui, Some(state));
button::Style {
background: background.map(Background::Color),
text_color,
border: Border {
radius: iced::border::Radius::new(radius),
..Border::default()
},
// 规格卡不画阴影;M3 的 FAB 高程靠容器色(rubric §7 的「L0/L1 不用阴影」同源)
shadow: Shadow::default(),
snap: true,
}
};
// 四档的宽都由令牌给(方态是边长、扩展态是**最小**宽 160)。
//
// ⚠️ iced 0.14 的 `Length` 只有 `Fill/FillPortion/Shrink/Fixed`,**没有 Min**(本仓 G15 / G17-d
// 已登记同一堵墙),所以「最小宽随标签撑开」在这里退化成**固定宽**:标签比 160 内距容得下的
// 更长时会被裁,短时也不会缩。登记为偏离(见 `FabSize::width_px` 的文档);真要 Min 语义得等
// iced 给 `Length::Min`,或在组件里先量文本再算宽——那要引字体度量,不在本轮。
let width = Length::Fixed(size.width_px());
let element = button(content)
.width(width)
.height(Length::Fixed(size.height_px()))
.padding(Padding::default())
.style(style);
debug_assert!(radius > 0.0, "FAB 圆角必须来自 ShapeScale({shape:?})");
debug_assert!(
matches!(size, FabSize::Extended) || size.pad_start_px() == 0.0,
"只有扩展态有起始内距"
);
match spec.on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
#[cfg(test)]
mod tests;
@@ -1,115 +0,0 @@
//! FAB 的几何断言(四档尺寸/圆角/图标/内距,全部对着规格卡原值)。
//!
//! 抓得住:档位与规格卡错位(例如把 Default 写成 48)、圆角被写成半高、扩展态内距悄悄改成 M3 的 16。
//! 抓不住:图上好不好看(D30),以及 hover/pressed 的状态层(那走 `interaction` 的既有测试)。
//!
//! 配色只钉**一条**(禁用内容色,M234 复审 P2-2):FAB 没有禁用容器档,所以 38% 必须合在
//! 自己的 `PrimaryContainer` 上,而不是画布色。其余配色仍由 `interaction` 的测试覆盖。
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::inkcat::size::FabSize;
use unlucky_design::typography::Platform;
use super::super::super::interaction::Interaction;
use crate::theme::{to_iced, Families, Ui};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn the_four_states_are_the_spec_sizes() {
for (size, width, height, radius) in [
(FabSize::Small, 40.0, 40.0, 12.0),
(FabSize::Default, 56.0, 56.0, 16.0),
(FabSize::Medium, 80.0, 80.0, 20.0),
(FabSize::Extended, 160.0, 56.0, 16.0),
] {
assert_eq!(size.width_px(), width, "{size:?} 宽");
assert_eq!(size.height_px(), height, "{size:?} 高");
assert_eq!(size.radius_px(), radius, "{size:?} 圆角");
}
assert_eq!(FabSize::ALL.len(), 4);
}
#[test]
fn only_the_extended_state_carries_a_label_and_its_inset() {
for size in [FabSize::Small, FabSize::Default, FabSize::Medium] {
assert_eq!(size.label_px(), 0.0, "{size:?} 不该有标签");
assert_eq!(size.pad_start_px(), 0.0);
}
assert_eq!(FabSize::Extended.label_px(), 15.0);
assert_eq!(FabSize::Extended.pad_start_px(), 28.0);
assert_eq!(FabSize::Extended.gap_px(), 8.0);
}
/// 图标随档放大(20 / 26 / 34),扩展态回到 24(它与标签同行,跟着文字尺度走)。
#[test]
fn the_glyph_grows_with_the_smaller_states() {
assert!(FabSize::Small.icon_px() < FabSize::Default.icon_px());
assert!(FabSize::Default.icon_px() < FabSize::Medium.icon_px());
assert_eq!(FabSize::Extended.icon_px(), 24.0);
}
/// 扩展态右内距是常量(不是散在 `Padding` 里的字面量 28),且与起始内距对称。
#[test]
fn the_extended_pad_end_is_a_named_constant() {
use super::super::fab::EXTENDED_PAD_END;
use unlucky_design::inkcat::size::FabSize;
assert_eq!(EXTENDED_PAD_END, 28.0);
assert_eq!(
EXTENDED_PAD_END,
FabSize::Extended.pad_start_px(),
"左右对称:规格卡只量到起始 28,右侧取同值"
);
}
/// **M234 复审 P2-2 的判据**:禁用 FAB 的内容色必须合在**自己的容器**上。
///
/// 走的是真实现(`fab::content_color`,`button()` 与样式闭包共用它)。抓得住:底又改回画布
/// (那就是修前那块与 FAB 底无关的灰)、或有人把 `on_container` 打开(会多算一层
/// 10% 的 `OnSurface` 灰)。抓不住:容器本身该不该在禁用时保持满档 `PrimaryContainer`
/// (那是 M3 FAB token 的口径问题,不由本测试裁决)。
#[test]
fn disabled_fab_ink_composites_over_its_own_container() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = unlucky_design::color::BrandBoard::for_flavor(flavor).scheme();
// 期望值独立算:38% 的 OnPrimaryContainer 叠在满档 PrimaryContainer 上
let expected = to_iced(
scheme
.get(Role::OnPrimaryContainer)
.with_alpha(0.38)
.over(scheme.get(Role::PrimaryContainer)),
);
let actual = super::content_color(&ui, Some(Interaction::Disabled));
assert_eq!(
actual, expected,
"{flavor:?}:禁用 FAB 的内容色 = on-primary-container@38% 叠 PrimaryContainer"
);
// 反向:不许等于「叠画布」那一档(修前的错值)
let over_canvas = to_iced(
scheme
.get(Role::OnPrimaryContainer)
.with_alpha(0.38)
.over(scheme.get(Role::Surface)),
);
assert_ne!(actual, over_canvas, "{flavor:?}:又合到画布上了");
// 非禁用档不受影响
assert_eq!(
super::content_color(&ui, Some(Interaction::Enabled)),
ui.color(Role::OnPrimaryContainer)
);
}
}
@@ -25,7 +25,6 @@
use iced::Padding;
use unlucky_design::icons::IconSize;
use unlucky_design::inkcat::size::ControlSize;
use unlucky_design::spacing::Space;
use crate::theme::{Ui, STROKE_PX};
@@ -45,50 +44,29 @@ pub struct Geometry {
pub gap: f32,
/// 描边宽度;`0` = 该式无描边。
pub stroke: f32,
/// 尺寸档(决定高、圆角折算边、内距与标签字级)。
pub size: ControlSize,
}
impl Geometry {
/// 按变体 + 有没有前置图标求值(默认 **S 档 = 40 高**,产品页用的就是这一档)。
///
/// `#[cfg(test)]`:产品路径一律走 [`Geometry::sized`](`assemble` 里带上了尺寸档),
/// 这里只给单测一个「S 档」的便捷入口,留成产品 API 只会让 `dead_code` 响。
#[cfg(test)]
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
Self::sized(ui, variant, leading_icon, ControlSize::S)
}
/// 按尺寸档求值(规格卡 `BUTTONS / XS - XL`)。
///
/// 内距取 [`ControlSize::pad_x`](规格卡没写内距,那是 `[估]` 的「半高向下取 4 栅格、封顶 48」
/// 推导值);带前置图标时左侧再收 **4**(`Space::X1`)——药丸左半圆里图标要离弧远一点,
/// 与 M3 的「带图标左内距更小」是同一条观感规则。S 档因此是 **20 / 16 / 20**,
/// 而 M3 官方是 24 / 16 / 24:差 4,登记在 M229 报告里(规格卡没给内距,我们按它自己的
/// 半高规则推,而不是回到官方值——否则五档里只有 S 一种内距口径)。
pub fn sized(ui: &Ui, variant: Variant, leading_icon: bool, size: ControlSize) -> Self {
let (_, _, border) = variant.roles();
let pad = size.pad_x();
let start = if leading_icon {
pad - ui.space(Space::X1)
ui.space(Space::X4)
} else {
pad
ui.space(Space::X6)
};
Self {
height: size.height_px(),
height: ui.metrics().control_height,
padding: Padding {
top: 0.0,
right: pad,
right: ui.space(Space::X6),
bottom: 0.0,
left: start,
},
// 图标盒固定 18(`IconSize::Button`):规格卡五档按钮的图标都没单独写字号,
// 档位只放大容器与标签,不放大图标——那是按钮,不是 FAB。
icon: IconSize::Button.px(),
gap: ui.space(Space::X2),
stroke: if border.is_some() { STROKE_PX } else { 0.0 },
size,
}
}
}
@@ -1,68 +0,0 @@
//! IconButton 的四式(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
//!
//! M234 纯搬移:本文件原来只是 `buttons/mod.rs` 的第 166~225 行(`mod.rs` 已顶到 399 行,
//! 加 `.base(..)` 与 `icon_on` 之前必须先腾地方)。搬移**零行为改动**,`IconVariant` 的文档、
//! 角色表、A1 出处一字未改;对外路径由 `mod.rs` 的 `pub use` 保持为 `buttons::IconVariant`。
use unlucky_design::color::Role;
/// IconButton 的**四式**(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
///
/// 规格卡画了四颗:实心 / 色调 / 描边 / 纯图标。12b §2.5 只有前三式(Standard / Filled / Tonal),
/// M229 按规格卡把 Standard 拆成 **Plain**(纯图标)与 **Outlined**(1px 描边圆圈),
/// `Standard` 这个名字保留为 `Plain` 的别名语义,避免既有调用点全改。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 纯图标(规格卡 `PLAIN`):容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Plain,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
/// 描边:透明容器 + 1px `Outline`(规格卡第三颗,M229 新增)。
Outlined,
}
impl IconVariant {
/// 旧名字的别名:`Standard` = [`IconVariant::Plain`](既有调用点不必改语义)。
#[allow(non_upper_case_globals)]
pub const Standard: IconVariant = IconVariant::Plain;
/// 四式全序(gallery 顺序 = 规格卡从左到右)。
pub const ALL: [IconVariant; 4] = [
IconVariant::Filled,
IconVariant::Tonal,
IconVariant::Outlined,
IconVariant::Plain,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Plain => "plain",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
IconVariant::Outlined => "outlined",
}
}
/// **禁用态图标色**:四式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Plain => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
IconVariant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
}
}
}
+71 -113
View File
@@ -12,8 +12,7 @@
//! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] |
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated = **`Elevation::Level1.surface_role()`**(= `SurfaceContainerLow`)/`Primary`;Outlined 透明 + 1dp `Outline` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
//! | 1.9b | 与 A1 的登记差异(产品口径,M248) | Outlined 描边 A1 是 `OutlineVariant`,本项目取 **`Outline`**:1dp 边界属于**非文字元素**(目标 3:1,`design/color/mod.rs` 的对比度纪律),`OutlineVariant` 是装饰分隔线的角色;且 `IconVariant::Outlined` 一直是 `Outline`,两族由此统一 | [`Variant::roles`] |
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
@@ -48,23 +47,18 @@ use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::inkcat::size::ControlSize;
use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::interaction::{Base, Interaction};
use super::interaction::Interaction;
use super::layout;
mod assemble;
mod fab;
mod geometry;
mod icon_variant;
pub use fab::{fab, Fab};
pub use geometry::{Geometry, IconGeometry};
pub use icon_variant::IconVariant;
use assemble::{assemble, style_closure, StyleSpec};
@@ -78,9 +72,9 @@ pub enum Variant {
Filled,
/// 次强调(tonal):`SecondaryContainer`。
Tonal,
/// 带高程:L1 的容器面(`Elevation::Level1.surface_role()` = `SurfaceContainerLow`)+ `Primary` 文本。
/// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。
Elevated,
/// 描边:透明容器 + 1dp `Outline`(非文字元素角色;装饰分隔线才用 `OutlineVariant`)。
/// 描边:透明容器 + 1dp `OutlineVariant`。
Outlined,
/// 纯文本:最低权重。
Text,
@@ -159,17 +153,61 @@ impl Variant {
Role::OnSecondaryContainer,
None,
),
// 容器面由**高程令牌**推导(L1 → `SurfaceContainerLow`):不许在组件里再抄一遍角色名,
// 换层级口径只改 `elevation.rs` 一处(M248:P2 的落地方式是改来源,不是改色值)。
Variant::Elevated => (Some(self.elevation().surface_role()), Role::Primary, None),
// 1dp 边界取 `Outline`(非文字元素、目标 3:1);`OutlineVariant` 是装饰分隔线的角色,
// 拿它画边界在 Ink/Silver 上都到不了 3:1,也和 `IconVariant::Outlined` 不一致(M248:P3)。
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None),
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)),
Variant::Text => (None, Role::OnSurfaceVariant, None),
}
}
}
/// IconButton 的三种容器变体(12b §2.5)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Standard,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
}
impl IconVariant {
/// 三式全序(gallery 顺序)。
pub const ALL: [IconVariant; 3] = [
IconVariant::Standard,
IconVariant::Filled,
IconVariant::Tonal,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Standard => "standard",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
}
}
/// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
}
}
}
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
///
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
@@ -182,10 +220,6 @@ pub struct Button<'a, M> {
leading: Option<IconKind>,
trailing: Option<IconKind>,
state: Option<Interaction>,
/// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。
size: ControlSize,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容叠在它上面。
base: Base,
}
impl<'a, M> Button<'a, M> {
@@ -197,8 +231,6 @@ impl<'a, M> Button<'a, M> {
leading: None,
trailing: None,
state: None,
size: ControlSize::S,
base: Base::CANVAS,
}
}
@@ -210,20 +242,9 @@ impl<'a, M> Button<'a, M> {
leading: None,
trailing: None,
state: None,
size: ControlSize::S,
base: Base::CANVAS,
}
}
/// 换尺寸档(`XS 32 / S 40 / M 56 / L 96 / XL 136`,圆角一律药丸)。
///
/// 产品页只用 **S**(= M3 `control_height` 40);M / L / XL 是规格卡的梯子档,给
/// 空态主行动与展示型入口留着,`--screen=gallery` 的矩阵会把五档一起拍出来。
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
@@ -241,28 +262,6 @@ impl<'a, M> Button<'a, M> {
self.state = Some(state);
self
}
/// **告诉按钮它现在踩在什么底上**(M234):禁用态的 10% 容器与 38% 内容都叠在这个底上。
///
/// 放进卡片/面板/浮层里**必须**给——不给就按 [`Base::CANVAS`] 合成,禁用态会与容器底
/// 脱节(M223 的「CR 1.05、形丢失」)。容器自己的 `BASE` 常量就是标准答案:
/// 卡片 = [`super::surfaces::elevated::BASE`]、菜单项 = `super::inputs::menu::PANEL_BASE`。
///
/// iced 没有环境上下文(样式闭包只拿得到 `&Theme`),所以这条信息只能显式传:
/// 这是「组件不猜宿主」的代价,也是 M229 那条「参数在、全传死值」的修法。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 视觉态:强制的先,否则「没有消息就是禁用」。
pub(super) fn forced(&self) -> Option<Interaction> {
self.state.or(if self.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
})
}
}
/// Filled 按钮(实心 `Primary`)。
@@ -275,12 +274,12 @@ pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a
assemble(ui, Variant::Tonal, button)
}
/// Elevated 按钮(L1 的容器面 + `Primary` 文本,用色调差表达高程、不画阴影)。
/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。
pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Elevated, button)
}
/// Outlined 按钮(透明 + 1dp `Outline`)。
/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。
pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Outlined, button)
}
@@ -290,42 +289,6 @@ pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a,
assemble(ui, Variant::Text, button)
}
/// 图标按钮的描边宽度:**有描边角色就有那一圈,没有就是 0**。
pub(crate) fn icon_stroke(variant: IconVariant) -> f32 {
if variant.roles().2.is_some() {
crate::theme::STROKE_PX
} else {
0.0
}
}
/// 图标按钮的**样式描述**(从 `icon()` 抽出来,好让它能被单测直接钉住)。
///
/// 为什么抽:`Style` 闭包不可内省,`icon()` 返回 `Element` 也看不进去——复审把
/// `StyleSpec.stroke` 写死成 `0.0` 时,`icon_stroke()` 的纯函数测试照样绿(它测的是函数,
/// 不是**接线**)。现在「角色 → 填充/内容/描边」这件事在 `icon_style_spec` 里一次成型,
/// 单测断言的就是 `icon()` 真正塞进 `style_closure` 的那个结构体。
pub(crate) fn icon_style_spec(
variant: IconVariant,
side: f32,
forced: Option<Interaction>,
base: Base,
) -> StyleSpec {
let (fill, content, border) = variant.roles();
StyleSpec {
fill,
content,
border,
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
shape: Component::IconButton.spec().shape,
// M229 复审 P1-1:这里曾硬编码 `0.0`,于是 `Outlined` 与 `Plain` 逐像素相同。
stroke: icon_stroke(variant),
radius_side: side,
base,
forced,
}
}
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
///
/// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`,
@@ -334,8 +297,6 @@ pub(crate) fn icon_style_spec(
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
///
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
///
/// 这是 **画布版**(禁用底 = [`Base::CANVAS`]):放进卡片/面板里请用 [`icon_on`] 给真实父底。
pub fn icon<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: IconVariant,
@@ -343,22 +304,8 @@ pub fn icon<'a, M: Clone + 'a>(
on_press: Option<M>,
state: Option<Interaction>,
) -> Element<'a, M> {
icon_on(ui, variant, kind, on_press, state, Base::CANVAS)
}
/// 带**父底**的 IconButton(M234):禁用态的 10% 容器与 38% 内容叠在 `base` 上。
#[allow(clippy::too_many_arguments)]
pub fn icon_on<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: IconVariant,
kind: IconKind,
on_press: Option<M>,
state: Option<Interaction>,
base: impl Into<Base>,
) -> Element<'a, M> {
let base = base.into();
let geometry = IconGeometry::of(ui);
let (fill, content, _) = variant.roles();
let (fill, content, border) = variant.roles();
let forced = state.or(if on_press.is_some() {
None
} else {
@@ -370,12 +317,23 @@ pub fn icon_on<'a, M: Clone + 'a>(
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
// 所以「居中」这件事必须由容器自己写死,别指望 button。
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
let glyph =
assemble::content_color(ui, base, forced, content, fill, variant.disabled_content());
let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content());
let icon_box =
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
let style = style_closure(ui, icon_style_spec(variant, geometry.side, forced, base));
let style = style_closure(
ui,
StyleSpec {
fill,
content,
border,
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
shape: Component::IconButton.spec().shape,
stroke: 0.0,
radius_side: geometry.side,
forced,
},
);
let element = button(icon_box)
.width(Length::Fixed(geometry.side))
+10 -19
View File
@@ -11,7 +11,7 @@ use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use super::super::interaction::{self, Base, Interaction};
use super::super::interaction::{self, Interaction};
use super::assemble::StyleSpec;
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
use crate::theme::{Families, Ui, STROKE_PX};
@@ -69,20 +69,16 @@ fn geometry_matches_the_reference_values() {
20.0,
"12b 1.2 full 圆角 = 高/2"
);
// M229:内距取规格卡的 `ControlSize::pad_x`(S 档 = 20),不再是 12b 的 24。
assert_eq!(geometry.padding.left, 20.0, "S 档 pad_x 20");
assert_eq!(geometry.padding.right, 20.0, "S 档 pad_x 20");
assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24");
assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24");
assert_eq!(geometry.padding.top, 0.0);
assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8");
assert_eq!(geometry.gap, ui.space(Space::X2));
assert_eq!(geometry.stroke, 0.0, "实心按钮无描边");
let with_icon = Geometry::of(&ui, Variant::Filled, true);
assert_eq!(
with_icon.padding.left, 16.0,
"带图标左侧再收 4(20 − 4):与 12b 1.4 的 16 恰好同值"
);
assert_eq!(with_icon.padding.right, 20.0, "带图标右内边距不动");
assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16");
assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动");
let outlined = Geometry::of(&ui, Variant::Outlined, false);
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
@@ -122,10 +118,6 @@ fn icon_button_geometry_matches_the_reference() {
);
}
mod base;
mod icon;
mod roles;
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
///
/// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为
@@ -189,9 +181,8 @@ fn five_variants_have_distinct_colour_roles() {
(
None,
unlucky_design::color::Role::OnSurfaceVariant,
Some(unlucky_design::color::Role::Outline)
),
"描边取 Outline(M248:非文字元素角色;OutlineVariant 是装饰分隔线的)"
Some(unlucky_design::color::Role::OutlineVariant)
)
);
assert_eq!(
Variant::Text.roles(),
@@ -258,7 +249,6 @@ fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
content,
border,
shape: variant.shape(),
base: Base::CANVAS,
stroke: Geometry::of(&light(), variant, false).stroke,
radius_side: Geometry::of(&light(), variant, false).height,
forced,
@@ -277,7 +267,7 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角");
assert_eq!(
idle.border.color,
ui.color(unlucky_design::color::Role::Outline)
ui.color(unlucky_design::color::Role::OutlineVariant)
);
assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影");
@@ -287,7 +277,8 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
assert_ne!(hovered.background, idle.background);
let disabled = style(&ui.theme(), button::Status::Disabled);
let (container, content) = interaction::disabled_button_colors(&ui, Base::CANVAS);
let (container, content) =
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
assert_eq!(
disabled.border.color, container,
@@ -1,75 +0,0 @@
//! 禁用态**合成底**的接线判据(M234;从 `buttons/tests.rs` 拆出,守 400 行硬线)。
//!
//! 判据走真装配路径(`Button::base` → `text_style_spec` → `style_closure`),
//! 不是「自己拼一个 `StyleSpec` 再喂进去」——后者证明不了接线(M229 复审 P1-1 的教训)。
use iced::widget::button;
use iced::Background;
use unlucky_design::color::Role;
use unlucky_design::color::SchemeFlavor;
use super::super::Button;
use super::{light, style_closure, Variant};
use crate::app::components::interaction::Base;
use crate::app::components::surfaces;
use crate::theme::to_iced;
/// **M234 的接线判据**:禁用按钮的容器色必须在**它被告诉的那层底**上合成。
///
/// 走的是**真装配路径**:`Button::base(..)` → `text_style_spec`(接线)→ `style_closure`
/// (求值)。所以这条红了只有两种原因:① `assemble` 没把 `spec.base` 传下去;
/// ② `disabled_button_colors` 内部又写死了画布色。
///
/// 抓得住:卡片/面板里的禁用按钮叠错底(M223 的「CR 1.05、形丢失」就是这么来的)。
/// 抓不住:调用点**忘了**调 `.base(..)`(编译期不报错,默认 `Base::CANVAS`)——那一半由
/// `gallery` 的像素 probe(`ULL_GALLERY_SECTION=disabledbase`)与卡片场景守。
#[test]
fn a_disabled_button_composites_over_the_base_it_is_given() {
use unlucky_design::color::BrandBoard;
let ui = light();
let card = surfaces::elevated::BASE;
let spec = super::super::assemble::text_style_spec(
&ui,
Variant::Filled,
&Button::<u8>::disabled("卡片里"),
);
assert_eq!(
spec.base,
Base::CANVAS,
"不调 `.base(..)` 时是画布底(默认值,语义写在 `Base::CANVAS` 的文档里)"
);
let card_spec = super::super::assemble::text_style_spec(
&ui,
Variant::Filled,
&Button::<u8>::disabled("卡片里").base(card),
);
assert_eq!(
card_spec.base, card,
"接线:`Button::base` 必须原样进 `StyleSpec`"
);
let card_style = style_closure(&ui, card_spec)(&ui.theme(), button::Status::Disabled);
let canvas_style = style_closure(&ui, spec)(&ui.theme(), button::Status::Disabled);
// 期望值独立算(写死 0.10 + 直接取色板角色),不调被测入口。
let scheme = BrandBoard::for_flavor(SchemeFlavor::Light).scheme();
let expected_card = to_iced(
scheme
.get(Role::OnSurface)
.with_alpha(0.10)
.over(scheme.get(Role::SurfaceContainer)),
);
assert_eq!(
card_style.background,
Some(Background::Color(expected_card)),
"卡片里的禁用 Filled 按钮容器 = on-surface@10% 叠 **SurfaceContainer**"
);
assert_ne!(
card_style.background, canvas_style.background,
"卡片底与画布底的合成结果必须不同(叠在画布上就是 M234 要修的那条)"
);
}
@@ -1,53 +0,0 @@
//! 图标按钮的单测(M229 复审 P1-1 从 `buttons/tests.rs` 拆出,守 400 行硬线)。
//!
//! 拆出来的只有「四式图标按钮的描边」这一组:`icon()` 曾经把 `StyleSpec.stroke` 写死 `0.0`,
//! 于是 `Outlined` 与 `Plain` 逐像素相同——判据在这里,渲染级的那一半在 `gallery/probe/rows.rs`。
use super::*;
/// **M229 复审 P1-1 的回归门**:四种图标按钮里**只有 `Outlined` 有描边**。
///
/// 抓得住:`icon()` 里把 `stroke` 写死成 0(那样 Outlined 与 Plain 逐像素相同——正是复审量到的现场)。
/// 抓不住:描边颜色对不对(那是 `IconVariant::roles` 的事,上面已有变体表测试)。
///
/// 渲染级的第二道在 `components/gallery/probe.rs`(`buttons` 段的 sheet 上数 Outlined 行的环像素、
/// 并断言 Plain 行没有环),这一条是纯函数的、不依赖出图。
#[test]
fn only_the_outlined_icon_variant_draws_a_ring() {
use super::super::{icon_stroke, icon_style_spec};
assert_eq!(
icon_stroke(IconVariant::Outlined),
crate::theme::STROKE_PX,
"Outlined 必须画 1px 环(否则与 Plain 逐像素相同)"
);
assert_eq!(
icon_stroke(IconVariant::Outlined),
1.0,
"全站描边 1px(rubric G7)"
);
for plain in [IconVariant::Plain, IconVariant::Filled, IconVariant::Tonal] {
assert_eq!(icon_stroke(plain), 0.0, "{plain:?} 不该有描边");
}
// 与角色表同源:有描边角色 ⇔ 有环。
for variant in IconVariant::ALL {
assert_eq!(
icon_stroke(variant) > 0.0,
variant.roles().2.is_some(),
"{variant:?} 的描边与角色表不一致"
);
// **接线断言**:`icon()` 真正塞进 `style_closure` 的那个结构体(`icon_style_spec`)。
// 只测 `icon_stroke()` 抓不住「调用点写死 0」——第一版就是这样漏的(变异测试证明过)。
let spec = icon_style_spec(variant, 40.0, None, Base::CANVAS);
assert_eq!(
spec.stroke,
icon_stroke(variant),
"{variant:?}:icon() 用的描边宽度与角色表不一致(调用点被写死了?)"
);
assert_eq!(
spec.border.is_some(),
icon_stroke(variant) > 0.0,
"{variant:?}:描边角色与线宽必须同生同灭"
);
}
}
@@ -1,190 +0,0 @@
//! 角色语义 + 「可主题化」断言(M248:Elevated 底 / Outlined 描边)。
//!
//! **判据不是「等于某个 hex」**:品牌色板本来就会被换(D38 的取色方案会整表接管 49 个角色),
//! 钉死色值等于把「配色可自定义」这条路焊上。所以这里钉两件互相补位的事:
//!
//! 1. [`role_table_is_named_roles_not_colours`]:`Variant::roles()` 吐的是**角色名**;Elevated 的
//! 容器还必须由**高程令牌**推导(`Elevation::Level1.surface_role()`),不许在组件里手抄一遍;
//! 2. [`styles_follow_the_scheme_across_boards_and_custom_accents`]:同一变体在 Ink / Silver /
//! 自定义种子方案(`Scheme::from_seed`)三套方案下,`button::Style` 里解出的颜色**分别等于
//! 各自方案的该角色色值**,且三者互不相同。把「取角色」改成「取死值(`Role::Surface`)」
//! 或「写死某套方案」都会被这条打红。
//!
//! 抓不住什么:
//! - **令牌表里两个角色同值**时,换角色的变异只有第 1 条会红;
//! - **像素**:角色写对了、图上却是别的颜色(样式闭包被别处覆盖)——那是 `gallery/probe` 与出图的事。
use iced::widget::button;
use iced::Background;
use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor};
use unlucky_design::elevation::Elevation;
use unlucky_design::typography::Platform;
use super::super::{style_closure, IconVariant, Variant};
use super::spec_of;
use crate::theme::{Ui, STROKE_PX};
/// 三套配色来源:两块出厂品牌板 + 一套 `Scheme::from_seed` 自定义种子方案。
///
/// 第三套是关键:品牌板是固化数据,**换不了**;只有「同一份组件代码吃进另一套方案就换色」
/// 才叫可自定义(D38 的取色链就是这么接管 49 个角色的)。种子用 design 里的
/// [`DEFAULT_SEED`](取色链默认的天蓝)——它是令牌,不是本文件写下的色值字面量
/// (`tests/no_hardcoded_tokens.rs` 拦 `#RRGGBB`,这条门禁是对的)。
fn themes() -> Vec<(&'static str, Ui)> {
vec![
(
"ink",
Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES),
),
(
"silver",
Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES),
),
(
"custom",
Ui::from_scheme(
Scheme::from_seed(SchemeFlavor::Dark, unlucky_design::color::DEFAULT_SEED),
super::FAMILIES,
),
),
]
}
#[test]
fn role_table_is_named_roles_not_colours() {
// ---- P2:Elevated 的容器是**高程令牌的推论**,不是一个手抄的角色名 ----------
assert_eq!(Variant::Elevated.elevation(), Elevation::Level1, "L1");
assert_eq!(
Variant::Elevated.roles().0,
Some(Elevation::Level1.surface_role()),
"Elevated 的底必须由高程令牌推导(L1 → surface_role = SurfaceContainerLow),\
组件里不许再写一遍这个角色名"
);
assert_ne!(
Elevation::Level1.surface_role(),
Role::Surface,
"L1 的容器面必须比画布高一档(否则 Elevated 与画布同色)"
);
// ---- P3:1dp 边界取 `Outline`;`OutlineVariant` 是装饰分隔线的角色 ----------
assert_eq!(
Variant::Outlined.roles(),
(None, Role::OnSurfaceVariant, Some(Role::Outline)),
"Outlined 的 1dp 描边取 Outline(`design/color/mod.rs` 的对比度纪律:\
Outline 是「非文字元素」角色、目标 3:1;OutlineVariant 是纯装饰分隔线)"
);
// 同族一致性:图标按钮那一式一直是 Outline,此前文字按钮是 OutlineVariant ⇒ 全站两套描边
assert_eq!(
IconVariant::Outlined.roles().2,
Some(Role::Outline),
"图标按钮 Outlined 与文字按钮 Outlined 必须是同一个描边角色"
);
}
/// 非文字元素的对比度下限 3:1(WCAG 1.4.11;`design/color/mod.rs` 的对比度纪律同一口径)。
///
/// 这条是 P3 的**理由**而不只是判据:`OutlineVariant` 在深/浅两块板上都到不了 3:1,
/// 拿它画按钮边界等于「看得见形状、量不出边界」。别把下限写死成某个角色的实测值——
/// 换品牌板或换取色方案时,比例是**算出来的**,不是抄出来的。
#[test]
fn outlined_boundary_meets_the_non_text_contrast_bar() {
for (name, ui) in themes() {
let scheme = ui.scheme();
let boundary = scheme.contrast(Role::Outline, Role::Surface);
assert!(
boundary >= 3.0,
"{name}: Outline/Surface = {boundary:.2},非文字元素下限是 3:1"
);
// 报告用:装饰角色差多少(`--nocapture` 可见),不改判据
eprintln!(
"[{name}] Outline/Surface={boundary:.2} · OutlineVariant/Surface={:.2}",
scheme.contrast(Role::OutlineVariant, Role::Surface)
);
}
}
#[test]
fn styles_follow_the_scheme_across_boards_and_custom_accents() {
let elevated = spec_of(Variant::Elevated, Some(super::Interaction::Enabled));
let outlined = spec_of(Variant::Outlined, Some(super::Interaction::Enabled));
let mut elevated_fills = Vec::new();
let mut outlined_strokes = Vec::new();
for (name, ui) in themes() {
let style = style_closure(&ui, elevated);
let idle = style(&ui.theme(), button::Status::Active);
let expected = ui.color(Elevation::Level1.surface_role());
assert_eq!(
idle.background,
Some(Background::Color(expected)),
"{name}: Elevated 的底 = 本方案的 L1 容器面"
);
assert_ne!(
idle.background,
Some(Background::Color(ui.color(Role::Surface))),
"{name}: Elevated 退化成画布色了(取死值 Surface 的变异会打红这条)"
);
assert_eq!(
idle.text_color,
ui.color(Role::Primary),
"{name}: Elevated 标签 = Primary"
);
let style = style_closure(&ui, outlined);
let idle = style(&ui.theme(), button::Status::Active);
assert_eq!(
idle.border.color,
ui.color(Role::Outline),
"{name}: Outlined 描边 = Outline"
);
assert_eq!(idle.border.width, STROKE_PX, "{name}: 描边 1dp");
assert_eq!(
idle.text_color,
ui.color(Role::OnSurfaceVariant),
"{name}: Outlined 标签 = OnSurfaceVariant"
);
elevated_fills.push(expected);
outlined_strokes.push(idle.border.color);
}
// 三套方案两两不同 ⇒「跟着配色来源走」这件事有实据(不是三套恰好同色)
for (index, (name, _)) in themes().iter().enumerate() {
for other in index + 1..elevated_fills.len() {
assert_ne!(
elevated_fills[index], elevated_fills[other],
"{name} 与第 {other} 套方案的 Elevated 底同色,可主题化没有实据"
);
assert_ne!(
outlined_strokes[index], outlined_strokes[other],
"{name} 与第 {other} 套方案的 Outlined 描边同色"
);
}
}
}
/// 基线条:Ink(深)与 Silver(浅)两块**出厂**板必须给出不同的 Elevated 底与 Outlined 描边。
///
/// 与上一条分开写:上一条的三套方案里有一套是自定义的,这条只钉出厂外观——
/// 哪怕有人把 `themes()` 改成只跑自定义方案,出厂这块的回归也还在。
#[test]
fn ink_and_silver_resolve_the_same_roles_to_different_colours() {
let ink = Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES);
let silver = Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES);
for role in [
Elevation::Level1.surface_role(),
Role::Outline,
Role::OnSurfaceVariant,
] {
assert_ne!(
ink.color(role),
silver.color(role),
"{role:?} 在 Ink 与 Silver 上同色,说明它没跟着色板走"
);
}
// 浅色板的 L1 容器必须比画布暗、深色板必须比画布亮(层级方向不能反)
assert!(silver.color(Role::Surface).r > silver.color(Role::SurfaceContainerLow).r);
assert!(ink.color(Role::Surface).r < ink.color(Role::SurfaceContainerLow).r);
}
Loaded 100 of 924 files, more files were not shown because too many files have changed in this diff. Show more