174 Commits
Author SHA1 Message Date
AskaEth c97630d59e Merge branch 'feat/m204-cleanupdoc-debt-sweep-four-small-it'
Dev Guards / 架构与合规守门(同 main 口径) (push) Blocked by required conditions
Dev Guards / fmt (rustfmt --check) (push) Waiting to run
Dev Guards / clippy (ubuntu) (push) Waiting to run
Guards / core 零 UI 守门 (push) Blocked by required conditions
Guards / presenter 零 UI 守门 (push) Blocked by required conditions
Guards / design 零第三方依赖守门 (push) Blocked by required conditions
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Blocked by required conditions
Guards / 文件行数 ≤400 守门 (push) Blocked by required conditions
Guards / 依赖许可与来源(cargo-deny) (push) Blocked by required conditions
2026-11-09 09:58:56 +08:00
AskaEth 8e37bb9cf9 docs(packaging): §12 文件清单里本文的节号范围补到 §11–§16
复审观察 1:那张表还写「索引 + §11 / §12 / §13」,而本文现在也有 §14/§15/§16(含本次新编的
§16)。是改动前就存在的陈旧,顺手补全。
2026-11-09 09:49:44 +08:00
AskaEth 3b5f95375d docs(presenter): 「抓不住」那段别再点名 signature_cases.rs 管 manifest_sha256(复审 P2)
信封字段与钥表确实在 core/src/update/tests/signature_cases.rs;但 manifest_sha256 那条冗余校验
(signature.rs:161-171,要求「钥已钉住 + 方程验过 + 声明 sha 不同」)全仓没有任何用例覆盖,
现有篡改用例都先死在方程上。原文把这一项也推给那个文件,等于把责任推给了不存在的守卫。

改成如实写「全仓无用例,已立 finding」。只动 doc,用例逻辑与断言未改(394 → 395 行)。
2026-11-09 09:49:41 +08:00
AskaEth ba96a4fc94 docs(presenter): 更正 ATTEMPTS 注释里「拿不到宿主设置」的假话(复审 P1)
原文把「没读设置」写成了物理限制。事实:get_with_headers 手里就有 env,第一句 engine(env)
已经走过 apply_tuning(host/settings/download.rs → core/download/shared.rs),用户当前值就在
engine.config().max_attempts_per_source。这条路径之所以恒为出厂默认,只是因为它没读那个字段。

只改注释,行为不变(仍是编译期 const)。同时把不对称写清:另一条无 header 的路径走引擎,
本来就跟随 download.retry_limit(源冷却中时引擎给 1 次,那是恢复探测)。
2026-11-09 09:49:36 +08:00
AskaEth 133f14ba0b test(presenter): 坏清单用例按错误分类区分「JSON 坏」与「签名被拒」
台账 §10 第 7 条 / M199 复审 P3-2:原来只断言「结果是 Failed」,而它喂的每一份清单都签对了
——验签一旦 fail-open 或签名错误被误分类,这条用例照样绿。

现在两组各钉自己的分类:
- 内容坏(5 个变体,签名都对):kind 必须是 invalid_manifest / invalid_version;
- 字节好、只有签名对不上(放行就会报「有更新 0.9.0」):kind 必须是 signature_invalid 且不可重试。

变异证明(只改实现侧,跑完已还原):
- A core::update::signature::verify_manifest fail-open → ② 分支红,实际 Available(0.9.0);
- B core::update::manifest::from_json 把解析失败误分类成 SignatureInvalid → ① 分支红,
  「not json:坏内容分类是 signature_invalid」。
两个变异各只打红对应分支,说明两类坏输入确实各走各的分类。

文件仍在 400 行红线内(394 行)。
2026-11-09 09:32:58 +08:00
AskaEth 06fcbb8a51 docs(packaging): 修掉两个 §14 撞号(M190 让位到 §16)
台账 §10 第 5 条:packaging/README.md 里 §14 被 M169(更新链路测试通道)与 M190(产物署名与
清单签名)各用了一次。

判据(谁后插进来谁让位):§14 与 §15 都被仓库里的引用钉着——packaging/check_test_source_off.py
引 §14(M169),.github/workflows/release-build.yml 与 crates/app-iced/src/platform/win32/console.rs
引 §15(M196);这两处引用都在本 mission 的 scope 外。所以 M169 保持 §14、M196 保持 §15,
M190 那一节整段后移到文末编 §16,内容一字未改,只改编号与位置(新增一段「编号来历」说明为什么
它让位)。全文 §11/§12/§13/§14/§15/§16 各一次,无重复。
2026-11-09 09:32:54 +08:00
AskaEth 9163054d46 refactor(presenter): 带 header 的请求重试次数改引 core 的单一来源
台账 §10 第 4 条:http.rs 的 const ATTEMPTS: u32 = 3 是「数字 3 的第二处副本」。
改成引用 unlucky_core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE(download.retry_limit
的 schema 默认值同源),core 侧一个字未改。

这条带 header 的路径(只有 CurseForge 用)拿不到宿主设置,所以只吃出厂默认——这一点写进了
doc 注释,免得下一个人以为它会跟随用户的 download.retry_limit。
2026-11-09 09:32:51 +08:00
AskaEth 56402d97a1 Merge branch 'feat/m201-cfgkeyring-compile-time-strip-of-th'
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-09 08:37:18 +08:00
AskaEth 50d13d26ab fix(m201): 测试钥改由 #[cfg] 编译期剔除,无测试档产物里不再留那段字节
`keyring()` 原先用 `cfg!(...)` 判断要不要并入测试钥 —— 那是「运行期值为假的分支」,
`TEST_ONLY` 的字节照编进产物(实测:无 feature 的 dev / release 两档 core rlib 里,
测试公钥十六进制与 key_id 都能 grep 到)。

* `TEST_ONLY` 拆成两条同名 const:`#[cfg(any(test, feature = "update-test-source",
  feature = "update-test-key"))]` 的真条目 + `#[cfg(not(any(...)))]` 的空表;
  `keyring()` 删掉 `cfg!` 判断,改成无条件 `extend_from_slice(TEST_ONLY)`(空表那档等于
  什么都没并,`mut` 两档都有用武之地,不需要 `unused_mut` 口子)。
* 文档把 M199 降级掉的那句强断言改回真话,并贴出六行实测读数(dev / release 均 0 命中,
  带 `--features update-test-key` 的对照臂 2 命中)。
* 明确划界:读数只到 rlib 为止;最终 exe 经 `lto = "fat"` + `strip = "symbols"` 之后是否
  仍有那段字节**未验**。

验收:cargo fmt --all --check → 0;cargo clippy --workspace --all-targets -- -D warnings → 0;
cargo test -p unlucky-presenter → 311 passed / 0 failed(1 ignored);
cargo test --workspace --all-targets → 0。
2026-11-09 08:19:04 +08:00
AskaEth de41e4c3b1 Merge branch 'feat/m200-presenterfix-sign-manifest-fixtures'
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-09 07:56:36 +08:00
AskaEth 540b82e197 fix(m200-r1): 文档降到实测能撑的程度 + 守门收紧 core 声明内容(复审 P2-1 / P3-1)
复审 reviewer-presenterfix-r1 的 fix-then-merge 两条,逐条落地。

P2-1(a)「两档」措辞被本次提交自己作废 ⇒ 三处全改成三档:
* `crates/core/src/update/trust.rs` 模块文档(读者最先看到的那句,上次漏了)
* `crates/core/src/update/tests/support_sign.rs` 模块文档 + `TEST_SIGNING_SEED` 的文档

P2-1(b)「三者都进不了发布产物」被复证实测证伪(`cfg!()` 是运行时假分支,不是 `#[cfg]`
编译期剔除;无 feature 的 dev 与 --release 两档 core rlib 里公钥十六进制与 `test-only-key` 都在)
⇒ 降级到事实能撑的断言:「不会被 `keyring()` 并进信任表,正式构建永远不认这把测试钥」,
并明写「字节仍编在产物里」「字节级不在产物里未证、也未声称」「换 `#[cfg]` 是另一条结构性改动」。
没有换成 `#[cfg]`(那是复审建议另开的 mission)。

P3-1 守门对 core「自己的声明」只认形状 ⇒ 收紧为只许空数组:
`DECLARATION` 从 `^\s*update-test-key\s*=\s*\[` 改成 `^\s*update-test-key\s*=\s*\[\s*\]\s*$`,
并加一条自检向量(core 声明里偷偷带子 feature ⇒ 必须红)。`--self-test` 从 6 条变 7 条
(5 红 / 2 绿)+ 基线,`packaging/README.md` 的计数同步。

⚠ 一个自己踩出来的坑,已被既有守门抓住:收紧时我先在 `check_test_key_off.py` 里把那条更宽的
测试通道 feature 写成**字面量**(自检向量 + 文档表格),`check_test_source_off.py` 立刻红
(`packaging/**` 按字面扫,这次提交前它一直是绿的)。改法照该文件自己的声明办——用
`OTHER_FEATURE = "-".join(("update", "test", "source"))` 拼出来,源码里字面量为 0,
自检向量仍然构造出完整字符串并必须判红。

验证(均在最终树上):`cargo test --workspace --all-targets` 0、`cargo fmt --all --check` 0、
`check_test_key_off.py --self-test` 0(7 向量)/ 正式扫描 0、`check_test_source_off.py` 0、
`check_workflows_yaml.py` 0。
2026-11-09 07:56:33 +08:00
AskaEth 528f5706a0 docs(m200): 补 update-test-key 的两处文档一致性(cherry-pick 之外)
`80b291c` 原样落盘(与本提交前一个 commit 逐字节相同),这里只补两处**文档一致性**,
不改任何判据、不改任何 feature 行为:

1. `crates/core/src/update/trust.rs`:`TEST_ONLY` 的文档注释还写着「**只**在 `cfg(test)` 与
   `update-test-source` 构建里存在」——同一文件里 `keyring()` 新增的表已经把三档(`cfg(test)` /
   `update-test-source` / `update-test-key`)讲清了,上面这句变成了自相矛盾的旧口径。改成三档,
   指向 `keyring()` 的说明。
2. `packaging/README.md` §12「文件清单」:新守门 `packaging/check_test_key_off.py` 已接进
   `release-guard.yml`,但清单表里没有它(同类守门 `check_test_source_off.py` 在表里),补一行。

验证:`cargo test --workspace --all-targets` exit 0(311 passed)、`cargo clippy --workspace
--all-targets -- -D warnings` exit 0、`cargo fmt --all --check` exit 0、
`packaging/check_test_key_off.py`(自检 + 正式扫描)exit 0、`packaging/check_workflows_yaml.py`
exit 0。
2026-11-09 07:56:33 +08:00
AskaEth 19cc5c7950 fix(m190): presenter 更新链路用例补上清单签名(6 条既有测试回绿)+ 测试公钥守门
背景:M190 把「验签」做成了强制且唯一收口(`source::verified_manifest`,先验签再解析),而
`crates/presenter` 的更新链路用例只写 `manifest.json`、不写 `.sig` —— 它们写在验签之前,所以在
dev 上红成 6 条 `signature_missing`。真发布本来就必须带 `.sig`,所以修的是**夹具**,不是判据:
没有放宽任何断言、没有在测试里关掉验签、没有把 `available=true` 改成「接受 Failed」。

夹具(`crates/presenter/src/host/update_tests.rs`):
* `write_manifest()` 把清单**连签名**成对落盘(`dist/stable.json` + `dist/stable.json.sig`),
  `dir_source()` 改走它;三处直接写清单的用例(坏清单 / 空清单 / 缺本平台产物)也改走它。
* 签名用 `TEST_SIGNING_SEED` + `key_id = test-only-key`,信封形状与 core 的
  `tests/support_sign.rs` **同源**(文件头写明「改一处要改两处」;presenter 不依赖 core 的
  `#[cfg(test)]` 资产,也没把测试资产导出成生产 API)。

为了让这把**公开**测试公钥在 presenter 的测试构建里被认得:
* core 新增 feature `update-test-key`(**只**把 `trust::TEST_ONLY` 并进信任表,不开任何数据源开关);
* 只有 `crates/presenter` 的 `[dev-dependencies]` 打开它(库/发布构建拿不到);锁里只多
  `ed25519-dalek` 这一行(presenter 的 dev-dep,包已在锁里,无新包、无版本变动);
* 新守门 `packaging/check_test_key_off.py`(238 行,带 6 条变异自检)接进 `release-guard.yml`:
  core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内,其余(workflow /
  packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红 —— 这把钥进发布构建就等于「谁都能签出官方更新」。
* 没有用 `debug_assertions`/`cfg(test)` 旁路:那会让「测试构建的信任表和发布构建不同」变成一句注释,
  而不是一条门禁。
2026-11-09 07:56:32 +08:00
AskaEth 1f2d89c23e Merge branch 'feat/m199-javaguard-auto-scan-and-proc-kill-g'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-09 07:20:18 +08:00
AskaEth aae9a233ff fix(m198): JAVAGUARD——首屏自动扫 / 子进程回收 / 参数长度三条守卫 + 设置页正文结构断言
- 首屏自动扫:删掉 `cfg!(test)` 分叉,生产入口在测试里真跑;新增
  `the_first_hydrate_starts_a_real_scan_by_itself`(删掉那一行、或把实参翻成 `true`
  都红)。顺手删掉恒为假的 `seeded && !live`:截图种子档在上方就 return 了,编译器也
  报 "assigned but never used"。
- proc:`kill()` + `wait()` 收进 `terminate()`(三个分支共用);用例先用同一条命令
  真跑一次证明子进程起得来(`spawn().ok()?` 的空跑假绿),并新增 Linux/Android 的
  僵尸回收用例(只 kill 不 wait ⇒ `/proc/<pid>` 留成僵尸)。
- java.extra_args:`MAX_JVM_ARGS = 1024` 镜像 schema,`Refusal::JvmTooLong` + 中英
  文案 + JVM 输入框变红;App 级用例断言回执是具名的「太长」而不是通用的「写不进去」。
- 设置页正文:判据①从「数 `stack = stack.push(` 的出现次数」升级成按角色断言
  (唯一来源 section_columns、`push(` 恰一次且实参是循环变量、尾表达式、禁
  extend/chain/once/concat/append),单行折叠变异体现在会红。
2026-11-09 06:46:29 +08:00
AskaEth 7f697f5762 Merge branch 'feat/m198-javadown-jdk-version-list-and-mirro'
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-09 06:28:22 +08:00
AskaEth 6e80fd3563 merge: origin/dev(1623f68)合入 M197 分支——解 golden/tail.rs 冲突
基线:合的是 **origin/dev**(`1623f68`)。`origin/main` 是 D41 的孤儿发布快照
(`d616d65` parents 为空),不参与合并。用 merge 而不是 rebase,保住三轮复审 clean 的 tip。

冲突只有一个:`crates/i18n/src/catalog/golden/tail.rs`(热点文件,多条 mission 都往这里加 key)。

解法(不重排别人的行、不改别人的译文):
1. 以 **dev 侧 398 行**为底稿(含其它 mission 后加的 key);
2. 把 M197 的 68 行(`SettingsJavaTooNew` … `SettingsJavaReasonInstallRootTooLong`)
   整块插在 `SettingsJavaReasonWriteFailed` 之后、M174 助手域(`SettingsGroupAssistant`)之前
   —— 即 `settings_java_entries` 那段之后,装配链的正确位置;
3. 两侧合起来 463 行会越过 400 行硬线,于是按既有先例把**M174 助手域起的那半截**
   (dev 的 260 行)整段纯搬移到 `golden/tail_ai.rs`:`tail.rs` 211 行 / `tail_ai.rs` 274 行,
   链序 `golden → tail → tail_ai → tail2` 不变,逐字节不删不改;
4. 解冲突时发现 dev 的 golden 行还是 M197 之前的旧文案(`SettingsJavaEmptyBody` /
   `SettingsJavaAddHint` 的反引号、`SettingsJavaNeverScannedBody` 的旧说法),已把这三条的
   金表行同步回域文件里的现行文案(`split_keeps_every_translation` 逐位置比对当场抓出来的)。

顺带(合并导致的越线):`crates/core/src/download/engine.rs` 两侧各 400 行、合并后 401
(我 +1 行 `kind: err.kind`,dev +1 行),把模块文档里那三行散文重排成两行(一字未删)→ 400 行。
同一次还把这文件里对 `MirrorTable::set_gh_proxy` 的旧描述改成 M193 的现实(那两个函数已被删)。

验证:fmt 0 · check(app-iced+core+i18n all-targets)0 · i18n 36 · engine_mock 8 · app-iced java 45 ·
core lib java 71 · setting 116 · app-iced lib 全量 832;全仓 file-size 守门脚本复制执行 → 超限 0。
2026-11-09 06:17:54 +08:00
AskaEth 1623f68483 Merge branch 'feat/m197-shellfix-gui-subsystem-and-cli-stdo'
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-09 06:01:42 +08:00
tower-worker 1764301cdf merge: 解 origin/dev 三处冲突(M182 / M184+M190 / README §14),两边都留
冲突(都来自别人后来改同一批文件):
- crates/app-iced/src/platform/mod.rs:保留 M182 的 HostOs / host·linux·macos 分册表 /
  plan_corner_for,同时保留 M196 的 `[`win32::console`]` 落点说明;把 M182 措辞里已经不
  成立的「唯一 unsafe 落点 / 只此一处」改成「两处落点(transparency + console)」
  (unsafe-scope 门禁自 M196 起期望集合就是两处,不改这句文档就是错的)。
  注意:`pub mod console;` 属于 `platform/win32/mod.rs`,本次解冲突时一度被误加到
  `platform/mod.rs`(`cargo check` 立刻报 E0583 文件不存在),已修回。
- .github/workflows/release-build.yml:M190 的「PE 版本资源读回」步、M184 的
  linux-x64 xvfb 冒烟步与首帧上传步**全部逐字保留**;M196 的 PE 子系统门禁作为**兄弟步**
  插在导入表门禁之后(两道 PE 门禁互不替代)。按块比对:dev 的 10 个步骤逐字未变,
  合并后 11 个步骤。
- packaging/README.md:保留 M184 的 §11 补充与 §12 行、M190 的 §14 签名段;§12 里
  check_publish_slots 用 dev 的 11 条版本、另加 M196 的 check_pe_subsystem 行;§15 归位在
  §14 之后。

行数:release-build.yml 合并后 413 行会撞 400 行红线(dev 侧本来 393),故把 M196 那一步
压到 6 行 + 1 空行(说明改由步骤名指路 packaging/README.md §15 与脚本 docstring 承载),
最终正好 400 行。

验证见 mission 报告:cargo check/test/fmt 全绿,两个 PE 门禁在同一批产物上都 exit 0
(子系统 GUI=2 / CLI=3,版本资源的 subsystem 读数同为 2 / 3)。
2026-11-09 05:49:38 +08:00
AskaEth 9bb9855392 Merge branch 'feat/m194-dlmsettings-wire-download-concurren'
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-09 05:36:38 +08:00
worker-dlmsettings 54dde7f494 fix(m193): 复审 r2——台账三处失效引用、M39 注释逐字还原、schema doc 数字补守卫
- P2-1:docs 的契约表把 retry_limit 范围从 (1,10) 改成 0–10(0 与 1 都 = 只试一次),
  与 §4 和 keys.rs 对齐;§2 与 §6 的守卫名统一成 the_schema_contract_is_single_sourced
  (原两条已合并),并把变异 M9(去掉 .max(1) ⇒ 红)补进 §6 台账。
- P3-4:keys.rs 的 M39(MCP server)既有注释按 origin/dev 原文逐字还原(含「同一安全策略、
  同一审计日志」);腾出的 1 行用「去掉 alias use、两处常量改全路径」换,没有压任何既有论证。
  比对:版本史 / D20 / M39 三段与 origin/dev 逐字节相同。
- P3-2:SettingSpec.doc 是 &'static str(concat! 插不进常量),描述里的数字是第三处第二来源,
  现在有守卫 the_key_docs_quote_the_real_limits(改 1~64 → 1~99 ⇒ 红,变异 M10 已跑)。
  §6.1.1 用表登记三处「数字的第二来源」现状。
- P3-1:docs 里那个游离反引号删掉。
2026-11-09 05:24:12 +08:00
worker-dlmsettings 94f56f9d9c fix(m193): 复审 r1 三条 P2——版本史还原、retry_limit 0 有诚实语义、装配点措辞更正
- P2-2:keys.rs 的 schema 版本史按原文逐字还原(1.4.0/1.5.0 的键名、1.2.0 的迁移口径、
  旧读新的完整结论),不再用版本文档换行数;腾行改从我自己新加的注释里出。
- P2-1:download.retry_limit 范围恢复 0~10,并把 0 的语义定成「只试一次」(attempts = max(1, 0)):
  老配置合法、零迁移、ull config get 读到的 0 与引擎真做的事一致(不再回落出厂 3)。
  schema 描述与 presenter/iced 两处 UI 都写清「0 与 1 都 = 只试一次,不代表不下载」。
- P2-3:shared.rs 的「唯一一处装配」更正为「presenter 侧唯一一处」(CLI 还有三处自建),
  docs 加「哪些路径吃这套设置」表,并立 finding(CLI install/java/pack 不读设置、并发 64→8)。
- P3-3:mirrors.rs 的 gh_proxies 注释改成「无生产读者,仅解析留档」。
- P3-4(presenter 侧):并发/重试描述的出厂值与上限改用常量 format!,不再是第二处字面量。
- 出图重拍(含 0/1 = 只试一次)。
2026-11-09 05:24:12 +08:00
worker-dlmsettings e8544b19a7 feat(m193): 下载并发/重试/gh-proxy 真接进引擎(schema 与引擎同源常量)
- download.concurrency / download.retry_limit 以前引擎侧零消费者(恒用
  EngineConfig::default() 的 64,而 schema 说 8)。现在默认值与硬上限收成
  download/config.rs 的常量,schema、EngineConfig::default()、Semaphore 容量
  三处同源;宿主把三个键翻译成 DownloadTuning 后经 shared::set_tuning(按数据根)
  灌进装配点,改了设置下一次取引擎就生效。
- download.gh_proxy 三态(留空=跟随内置默认 / off=关 / 前缀=覆盖)下沉到
  shared::build,所有走共享引擎的下载路径(内容库/版本清单/字体/安装)一致。
- 删掉 MirrorTable::set_gh_proxy 与 default_gh_proxy 两个改了没用的入口。
- 下载设置页把三条假演示值换成说语义的中文行(含三态说明与 1~64 硬上限)。
- 守卫:探针读 Semaphore 真实许可数(不是读配置),每条配变异证明。
- docs/download-settings.md 记下取证表(另四个键仍零消费,已立 finding)。
2026-11-09 05:24:11 +08:00
AskaEth 98262d3835 Merge branch 'feat/m196-straycard-settings-page-renders-for'
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-09 05:23:49 +08:00
AskaEth fb0b1a5311 merge(dev): 解 M196 与 dev(M179/M193)在 settings/mod.rs 的冲突
`origin/main` 是 D41 的孤儿快照(单提交、与开发史无共同祖先),M179/M193 这些提交在
`origin/dev` 上;按「跟上主线」的意图合的是 `origin/dev`。用 merge 不用 rebase:复审 clean 的
tip `e5a4b9b` 原样保留,本提交只负责解冲突。

唯一冲突:`settings/mod.rs` 里 `form` 的文档注释(M179 与 M195 重写过同一段)。两边都留:
- **M179/M193**:`type Body` 带 `Lang`、四条按下标分发(General / Update / Assistant / Java)、
  M193 的 download / logs 接线及其文档更新——只有一处措辞随之调整:「四组在下面各有 `else if`」
  → 「在 [`section_columns`] 里各有 `else if`」,因为 M195 把那层循环抽成了独立函数;
- **M195**:循环之后不再 push 那张「没有逐实例覆盖」的空态卡、`overrides_empty` 不存在、
  `section_columns` 与其行为断言(`frame.rs` 两条用例)。
合并后的注释同时说明两件事:`GROUP.body` 从形参拿生效语言(M179)+ 尾部不许再挂东西、
逐实例覆盖的真控件在实例详情页(M195)。

为守住 400 行硬线(冲突态 424 → 399),只压自己的重复叙述:M196 段压到 4 行、
`section_columns` 的文档改为指向 `form`(它原来重复了一遍分发点,且在 M179 之后已不准确)、
模块文档里一段被 M179 实现取代的历史「r3 订正」压成一句可执行的结论。

代码零语义改动(证据见汇报):`section_columns` 的循环体与 `origin/dev` 归一化后逐字节相同(46 行),
`GROUPS` / `GROUP_LABELS` 三方一致;相对 `origin/dev` 的差异只剩本 mission 的 4 个文件。
2026-11-09 05:18:29 +08:00
AskaEth 0e6d21ba89 Merge branch 'feat/m195-importcancel-import-pipeline-cancel'
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-09 05:07:04 +08:00
AskaEth 59011b2d56 fix(m195): 复审 r3 —— 失败不再被说成「取消太晚」,并给三档补推导用例
P2-1(界面撒谎):`cancel_view` 的 `_ if cancel_requested => TooLate` 把 `Failed` 也吞进去了。
「点取消的同时摘要不符」这类失败(取消置位之后、下一个检查点之前的非容忍失败)会得到
`status=Failed cancel=TooLate rolled_back=true`,于是同一屏上红卡说「已回滚 —— 目标目录零残留」,
下面还有一句「取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖」——后半句是假的,
而且与红卡直接对立。改成**只有** `Success | Partial + cancel_requested` 才判 `TooLate`;
`Failed` 一律 `NotRequested`(失败卡自己用 `failure_stage` 分「没写过 / 回滚干净 / 回滚没做完」,
真相已经说全)。
另外 app-iced 的 `cancel_note` 加一道双保险:只有 `status` 是 `Success/Partial` 时才出那句话,
推导万一回归也不会贴到红卡上。

P2-2(推导零覆盖):此前只有「给定这一档、文案怎么画」的用例,把 `RollbackIncomplete` 的推导
删掉也全绿。现在 `convert/cancel.rs` 带三条推导用例:`Cancelled + !rolled_back ⇒ 回滚没做完`
(含对照 `rolled_back ⇒ 已回滚`)、`Failed + 取消 ⇒ NotRequested`(含对照 `Success/Partial ⇒ TooLate`)、
没点过取消 ⇒ `NotRequested`。

变异实测:
- P2-1:把 `Failed` 也判 `TooLate` ⇒ `a_failed_run_is_never_dressed_up_as_a_late_cancel` 红
  (`left: TooLate / right: NotRequested`,"失败的导入没有「跑完」这一说")。
- P2-2:删掉「回滚没做完」的推导(`Cancelled` 一律按已回滚)⇒ `an_incomplete_rollback_is_its_own_bucket` 红
  (`left: RolledBack / right: RollbackIncomplete`)。

验证:`cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2188 passed / 0 failed;
`-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。
2026-11-09 05:06:56 +08:00
AskaEth b1524635ba fix(m195): 复审 r2 —— 取消的真实结局必须到达界面(三档 + 终态穿透代次)
复审 r2 P2-1:界面无条件断言「已停手并回滚:没有留下半包,也没有覆盖已有文件」,而两条路径下
这句话是假的——①取消来得太晚(落在最后一个检查点之后)时管线已经跑完,用户的同名文件被覆盖、
实例已登记;②回滚本身没做完(`rolled_back=false`)。core 一直是诚实的,是 presenter 把报告丢了:
`cancel.load()` 早退 + `cancel_pack` bump 代次后 `handle` 按代次丢弃。

按"接通真相"做(不是改软话):

- **终态一定发出**:`import::run` 去掉 `cancel` 的无条件早退,收尾抽成 `finish`(清自建空目录 +
  无条件 `send(PackEvent::Report)`);取消标记在管线返回后**再读一次**,作为"太晚"的唯一证据。
- **终态穿透代次**:`HostPackState::terminal_epochs` 记下 `cancel_pack` 作废的那个代次,
  `handle` 对它**只放行一条 `PackEvent::Report`**(进度事件照旧按代次丢,`fresh_cancel` 起新
  一轮时清空)。这是路径 A 的成因与修法。
- **DTO 三档**(tower 批准扩 scope):`ImportStatusView` 加 `Cancelled`;新增
  `ImportCancelView { NotRequested, RolledBack, RollbackIncomplete, TooLate }` 挂在
  `ImportReportView::cancel` 上——已回滚 / 回滚没做完 / 取消太晚,三档各自可判。
  `PackEvent::Report` 到达时清掉 `ImportState.cancelled`(它现在只是"取消后还没有结果"的占位)。
- **界面按结局分支**:标题 `PackStatusCancelled`(已取消 — 已回滚)/ `PackCancelIncomplete`
  (已取消 — 回滚没做完)/ 取消太晚时保留真实状态标题并另起一句 `PackCancelTooLate`
  (实例会保留、同名文件可能已被覆盖);占位的 `PackCancelled` 改成"正在停手,真实结果随后给出"。
  图标:`Close`(描边,页面对填充字形有守门)/ 回滚不完整用 `Warn`,一档都不用 `Role::Error`。

守门与变异:
- 新增路径 A 用例 `a_late_cancel_still_delivers_the_real_outcome_to_the_ui`:真管线把取消置在
  **最后一条 Commit 进度**上(之后没有检查点)⇒ core 报 `Success`/未回滚、用户的 `mods/a.jar`
  确实被覆盖;再 `cancel_pack`(代次 +1 并记终态)→ `finish` → `handle`,断言界面收到
  `status == Success` + `rolled_back == false` + `cancel == TooLate`,且同代次的进度事件仍被丢。
- 变异①:`handle` 恢复"代次不符一律丢" ⇒ 该用例红("取消太晚时,真实结果必须到达界面")。
- 变异②:`finish` 恢复"取消就早退" ⇒ 该用例红(另带 P2-2 那条也红)。
- app-iced 新增 `model/tests/cancel.rs`:三档标题互不相同 + 「太晚」必须有那句补充说明 +
  已回滚不许显示成「部分成功」。

验证(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-194):
`cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2185 passed / 0 failed;
`-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。
2026-11-09 05:06:55 +08:00
AskaEth db5b08b9ab fix(m195): 复审 r1 三项 —— 解压检查点真守卫、presenter 接线测试、取消文案改回真话
P2-1(解压循环检查点没有真守卫):那条用例原来的断言(status/target/files/bytes/无残留暂存)
在删掉 `extract_overrides` 的检查点后**依然成立**——提交前检查点⑤会替它停一次,于是几 GB 的包
会一直解到结束而测试全绿。改成数 `ImportStage::Extract` 事件:有检查点 = 1 条、删掉 = 3 条
(两面实测)。模块文档同时如实登记"还没有真守卫"的检查点:②③④⑤、全新导入 commit 的取消
分支、phase.rs 远端下载逐条、complete.rs 补全循环的 break。

P2-2(presenter 的真令牌接线零测试):新增 `pack/tests/cancel.rs`,直接调 `import::run` 并把
传入的 `Arc<AtomicBool>` **预先置 true**,判据只能是盘上事实(目标目录不存在 + profiles.json
零实例)——"没收到 Report"那种判据两条路都成立,`run` 早退条件里的 `cancel.load()` 会把
"没接线"完全遮住。

P2-3(用户可见文案说反了话):`PackCancelled` 中英双语从「导入会在后台继续跑完 / Import keeps
running in the background…已经写下的文件会留下」改为「导入已取消 —— 后台已停手并回滚:没有留下
半包,也没有覆盖已有文件」;golden 金表同步;`model/tests.rs` 那条把旧文案钉死的用例改成钉新
语义(必须说清停手+回滚,且**禁止**出现 background);`model.rs` 的 `cancelled` 字段文档与
`dispatch.rs` 的取消注释同族修正。

验证:`cargo test -p unlucky-core -p unlucky-presenter` 1480 passed / 0 failed;
i18n 31 / 0;app-iced packui 28 / 0;三 crate clippy 零 warning。
2026-11-09 05:06:53 +08:00
AskaEth 1441a240fe feat(m195): 导入管线取消令牌与原子回滚(取消不是失败)
M190 复核 §3 A-2:导入管线没有取消令牌,「取消导入」只改界面状态,后台仍跑完并写盘
(半包 / 覆盖风险,丢数据档)。本轮把取消做成管线级:

- core 新增 `import/cancel.rs`:`ImportCancel` 令牌(可 `from_flag` 接管既有
  `Arc<AtomicBool>`)+ 内部 `Stopped::{Cancelled, Failed}` 两条停止路线;
- 结果类型单列 `ImportStatus::Cancelled`(`error` 恒为 None、退出码 130、文案 key 独立),
  与 `Failed` 是两条路——UI 不弹红、脚本不会把「改主意」记成「装坏了」;
- 七个检查点:入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 合并态逐文件 → 登记前;
  解压 / 远端下载各逐条检查;单条下载在飞不可中断(fetcher 无取消参数)已在文档里说准;
- 取消后不留半包 / 不覆盖已有实例:暂存目录整个删、合并态 journal 逆序回滚(备份改回原名)、
  落盘后取消连内容一起回滚;`files / conflicts / missing / bytes_written` 清零,不留假账;
- presenter:同一个取消标记现在真交给 core(`with_cancel`),取消后不再「后台跑完并登记」;
  CLI 增 `cancelled → 130` 一档与三档人读名。

守门三条各带变异证明(见 mission 报告):删回滚 → 红、删登记前检查 → 红、把取消并进
`fail()` → 红。

没验:超大整合包的真实 IO 下取消响应延迟(最坏 = 当前一条文件读完/下完 + 一次回滚)、
单条下载在飞不可中断、macOS / Linux 未跑。
2026-11-09 05:06:52 +08:00
AskaEth 3116f43c9f Merge branch 'feat/m192-retryui-surface-dependency-failures'
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-09 05:06:09 +08:00
AskaEth 476bf92f97 test(addon): 给「失败路径取名字」的生产接线与降级补守卫 (M191 r3)
复审 r3 p2:那两处都只靠消费层用例撑着,生产接线上删掉/改坏也全绿。补两条能真红的守卫。

**接线层**:`install.rs` 里那次 `names_for_incompatible(..)` 原来不可驱动。现在下载器与
取名字器从 `Ports` 参数进来(生产 = 引擎 + `plan::fetch_title`,用例 = 假实现),
于是用例能驱动**生产那个 `collect`**:一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的
计划跑完,断言不兼容那条拿到了名字、且**只**给不兼容档问过一次。
变异(把那一行短路成空名单)⇒ `the_failed_install_path_really_fetches_names_for_the_incompatible_items`
红:`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`。

**降级层**:把「一次查询 → 有名字/没名字」的折叠抽成 `title_from(query, id)`
(`fetch_title` 只剩「建客户端 + 调它」)。参数化之后三种「没名字」都能离线钉住:
查询失败 / 空标题 / 全空白 ⇒ `None`,不 panic。
变异 ①`ok` 换 `expect` ⇒ `a_failed_or_blank_lookup_yields_no_name` 红
(panic:`带断网`);②去掉空串过滤 ⇒ 同一条红(空白档)。

**顺手**:`fetch_title` 真发一次 HTTP 的那一跳没进用例(本轮实测它确实取到了真数据
`P7dR8mSH → "Fabric API"`,但把网络调用放进 `cargo test` 会变脆,所以只把可坏的那一步——折叠——
参数化测掉,报告里如实写「HTTP 这一跳仍无用例」)。

**行数**:`install.rs` 加到 415、`tests/retry.rs` 到 436,都越过 400 硬线 ⇒ 两步纯搬移:
`collect` + `Ports` + `Placement` → `install/collect.rs`(117 行,`install.rs` 321);
r3 用例 → `tests/retry/wire.rs`(115 行,父文件 332)。
2026-11-09 05:06:06 +08:00
AskaEth 26e7b7333e fix(content): 不兼容档在失败路径上取项目名 + 重试轮不再贴错根的标题 (M191 r2)
复审 r2 两条 p2,都修了,并按复审给的口径验过红。

**P2-1(续):不兼容档不再只有裸 id**
`FailureKind::Incompatible` 的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),
所以失败路径上值得现取一次名字——`plan::names_for_incompatible` 按失败项去重、一项一次 GET
(`Modrinth::project`),`fetch_title` 把任何失败都折成 `None`:
**拿名字失败不许影响安装结果**,取不到 / 拿到空标题就退回 id。只有这一档发请求;
「取不到」档 404 那部分项目根本不存在,再发一次只会再吃 404。

**P2-2(新):重试轮把根的标题贴到了前置头上**
根因是判「这条是不是根项目自己失败」用了 `plan.request.roots.first()`,而重试那一轮的 plan
来自 `retry_request()`,它的 `roots` 就是上一轮的失败名单——第一个是前置。
现在判据改成 **id 比对**(`Root{id,title}`,id 由 `remember` 一起记下来):
`label_of` 只在这条失败的 id 真的等于原始根 id 时才用根的标题。复审给的那个形状
(roots=["P7dR8mSH","1eAoo2KR"] + root_title="Sodium")现在画 `P7dR8mSH`,不再是
`Sodium (P7dR8mSH)`。

**守门(每条都真跑红过,且在拆完模块的最终代码上复跑)**
- 去掉不兼容档的名字来源 ⇒ `an_incompatible_item_shows_the_project_name_once_it_is_known` 红
  (`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`)。
- `label_of` 换回 `roots.first()` ⇒ `the_retry_round_does_not_paste_the_root_title_onto_a_dependency` 红
  (`left: ["Sodium (P7dR8mSH)","1eAoo2KR"]`,正是复审实测的那一行)。
- 取名字不降级(`expect`)⇒ `a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything` 红。

**行数**:`plan.rs` 加完这套到 414 行、`install.rs` 到 403 行,都越过 400 硬线 ⇒
把「名字从哪来」整块(`Root`/`Names`/`names_for_incompatible`/`label_of`/`fetch_title`)
纯搬移到 `plan/labels.rs`(111 行):`plan.rs` 336、`install.rs` 388。
2026-11-09 05:06:06 +08:00
AskaEth 961ff453a0 fix(content): 重试面板画可读名,不再只有站点 id (M191 r1 P2-1)
复审 P2-1:面板原来只画 `P7dR8mSH` 这种项目键,用户拿到的是一串哈希。
可读名其实都在手边,只是没进 DTO——现在补上,按复审给的**最小修法**做,没扩散:

- `InstallFailureView` 加 `label: String`(**语言中立**:站点标题或文件名),
  配 `line()` 统一决定「名字 (id)」还是「只有 id」(名字为空时不编造)。
  `detail`(core 中文散文)**依旧不进界面**——那会踩 i18n R2。
- `plan::label_of` 三个来源,零额外请求:
  根项目 → 请求里的 `title`;计划里有节点(下载失败)→ `node.file.filename`;
  都没有(解析 404 / 不兼容的前置)→ 空,界面退回只画 id(复审认这条兜底)。
- 计划被记住时连显示名一起记(`plan::remember` 多一个 `title`),于是重试那一轮
  面板头与失败名单沿用的是同一个名字(顺手收掉复审 N5 的「重试时面板头显示 id」)。
- N2:删掉新加的 `ContentInstallDismiss`,复用既有的 `DownloadDismiss`(同一句话不立两条 key)。
- 行数/clippy:`collect` 的参数捆成 `Placement{dir,title}`(9 个参数超限);
  `plan.rs` 加完 `label_of` 到 429 行,把「记住的计划」表纯搬移到 `plan/remembered.rs`(363 + 82)。

守门(真跑红再还原):
- 把 `label_of` 置空 ⇒ `the_retry_list_shows_a_readable_name_not_just_the_project_id` 红
  (三行全变成 `["AANobbMI","P7dR8mSH","1eAoo2KR"]`);
- 审阅指出的另一半判据也补上了:`Done` + 残留名单 ⇒ 必须不画;现在**只去掉**
  `stage != Failed` 那一半就红(之前只去掉它仍是全绿,报告的表述不准,已改)。
2026-11-09 05:06:05 +08:00
AskaEth 5a52f38f35 feat(content): 安装失败的两档分组与「只重试这些」(M191)
M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()),
但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里:

- DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开);
  InstallProgressView 加 retry: Vec<InstallFailureView>(project + 档位 + core 原话),
  档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind);
  ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。
- presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法,
  host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」——
  调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。
  install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。
- 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮;
  判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。
- i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。

守门(每条都做过变异验证,见报告):
① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]);
② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红;
③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。

数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。
2026-11-09 05:06:04 +08:00
AskaEth 5505ea0f91 Merge branch 'feat/m193-animmotion-wire-design-motion-token'
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-09 05:04:11 +08:00
AskaEth 93bd704786 fix(m192): r1 复审 P1-1——动效起跑搬进 update 侧 + 帧基准兜底 + 按运行时顺序的守卫
复审的机制三步全部复核成立(iced_winit-0.14.1 `src/lib.rs:1222` 调 update、`1338` 在
`update()` 内部求值订阅表、`1233` 之后才 `build_user_interfaces` 调 view)。第一版把
「标识变了 → play」写在 `pages::render`(= view)里,于是:同一拍的订阅表看不见轨道 ⇒
`frames()` 要等下一条消息才打开;而那一帧的 dt 是整段空闲时长 ⇒ 轨道被当场判到期删掉。
用户看到的是「一帧 12/8px 偏移 + 闪跳」,比硬切更差;截图通道因为 `has_screenshot()` 恒真,
结构上不可能发现它。

修法(三件都做)
- **起跑搬进 update**:新增 `app/update/motion_sync.rs::App::sync_motion`,在 `App::update`
  收尾(`route_message` 之后、`subscription()` 之前)按**路由**算内容标识并 `motion::observe`。
  `pages::render` 只留 `motion::sample()`(纯采样)。
  顺带:`screen == None`(账户页 / AI 独立页)只记标识不起跑,于是「从它们回到某一屏」也跑换页。
- **帧基准兜底**:`observe` 起跑成功时把 `LAST_FRAME` 重置到「现在」;`Pump::advance` 再加一道
  `MAX_STEP_MS`(4 帧 = 64 ms)单次步进上限——掉帧/挂起时宁可把动画放慢,也不许一帧抹掉。
- **没有窗口就不起跑**:`window.is_none()` 且不是截图通道时 `sync_motion` 直接返回(也**不记**
  标识)⇒「首屏入场」等窗口到了才算第一眼,启动期那棵空树吃不掉它(原先被 1 Hz 心跳 /
  既有 `an_idle_app_...` 用例照出来的就是这个边界)。
- `on_heartbeat` 也推进动效时间轴:窗口被遮挡/最小化/合成器不送绘制时,帧一个都不来,而
  「有轨道」会让 1 Hz 推进整段停摆 ⇒ 轨道靠心跳按单次上限收尾(最坏 5 拍内自然退订)。

守卫(新增 5 条,全部按运行时顺序写)
- `a_state_change_opens_the_frame_subscription_in_the_same_tick`:**先** `assert!(!needs_frames)`
  →**一条真消息**(`Message::Page(Navigate(..))`)→ `assert!(needs_frames)`,并断言
  `watch_until` 仍是 `None`(排除「观察窗顺手开帧」这条假绿)。r1 那条顺序是反的,已删。
- `opening_a_detail_also_opens_the_frames_in_the_same_tick`、`pages_that_render_elsewhere_do_not_start_a_track`、
  `without_a_window_nothing_animates`、`a_stale_baseline_cannot_kill_the_track_in_one_sample`。

r1 两条 P2
- P2-1:`pump::FRAME_MS` 与 `mod.rs` 契约表补写「16 ms 只卡采样/布局推进,**整窗重绘跟随渲染器**
  (软件渲染 ≈970 帧/秒、一次 300 ms 换页 ≈290 次整窗重绘 ≈0.30 核·秒;GPU 档 AutoVsync 40~60)」。
- P2-2:删掉 `app/subscription/tests.rs`,测试并回 `app/subscription.rs` 的 `mod tests` ⇒ 本轮
  动过的文件全部落在声明 scope 内(新文件只有 `app/update/motion_sync.rs`,在 `app/update/**` 下)。

真机证据(GPU 档,`ULL_TICK_TRACE=1`,空闲 14 s 后由心跳触发一次**真实**导航,
`DevDocs/artifacts/m192/idle-click-evidence-gpu-final.log`):
  idle: frames=96 clock=idle-1hz motion=14(连续 7 拍不动)
  fire: clock=frames motion=16 → 17(同一拍订阅表就开了)
  end : frames=102 clock=idle-1hz motion=20(自己退订)
说明:本机会话没有前台窗口(`GetForegroundWindow()==0`),合成点击(`mouse_event` 与
`PostMessage(WM_LBUTTONDOWN)`)递不进 iced 的输入层,故用「心跳第 N 秒真导航」这条同构路径;
样本次数由**绘制送达**决定(该次动效只收到 ~5 次绘制 ⇒ +6 次采样),正常可见窗口下 60 Hz 呈现
对应 ~19 次。

变异证明 7/7 红(`DevDocs/artifacts/m192/mutate.sh`,含新增 M6「删掉 sync_motion」与
M7「去掉单次步进上限」),还原后自检全绿。

编译:cargo test -p unlucky-app-iced → 0(797 passed / 2 ignored,集成 6 passed);
clippy --all-targets -D warnings → 0;fmt --check → 0;build → 0。
出图基线复验:三张 rest 基线与九张序列帧的 sha256 与 r1 **逐字节相同**(常驻的那层 container 零内距)。
2026-11-09 04:52:40 +08:00
AskaEth a1876a08c0 feat(m192): 动效帧泵——design motion 令牌接进 iced 的有界帧订阅 + 首批 3 条入场动效
令牌早就在 `crates/design/src/motion/` 里齐着(16 档时长 / 可采样缓动 / 弹簧二维表 /
MotionScale 降级),但适配层从没消费过,所以一切状态变化都是硬切。本次补的正是那条中间层:

新增 `components/motion/`(时间轴,不是控件家族)
- `pump.rs`:`Slot`(boot/screen/detail 三个互斥触发点)× `Spec`(时长档 × 缓动档,
  **唯一一张表**,页面拿不到「给我 250ms」这种口子,D31)→ 进度采样;时间轴按 16ms 结算
  (≈60 次/秒),到期轨道**当场删除**。
- `mod.rs`:进程内单时间轴(thread-local;iced 在事件循环线程上串行调 update/view,
  与 `App::update(&mut self)` 同一条纪律,测试也因此天然隔离)+ `enter`/`shifted` 两个页面入口。
- **默认 NONE**:只有产品 `on_boot` 按档位打开;`pages_demo` 这类截图 harness 从不打开。

接线(都接在既有那条链上,不另开循环)
- `App::needs_frames` 多一条「有轨道」分支 → 与截图 / Busy / toast / watch_until 共享**同一个布尔**,
  `window::frames()` 在源码里仍只有一处(守卫:双订阅就红)。
- `on_frame` 每帧推进时间轴;`on_heartbeat` 不变(有帧时不推进)。
- 定档策略集中在 `update/boot_env.rs::motion_scale`:截图档 NONE(D30 出图可复现)、
  CPU 渲染档 HALF、GPU 档 STANDARD,`ULL_MOTION` 可强制。

首批 3 条动效(`pages::render` 一个漏斗,静息恒为 (0,0) ⇒ 既有截图基线一张都不用重出)
- 首屏进入 16px / 换页 12px(纵向) / 列表↔详情 8px(横向);
  时长 400/300/350ms 全部取自 `DurationToken`,曲线 `Easing::Standard`
  (`EmphasizedDecelerate` 把 75% 位移压在 51ms 内走完,12px 的整页平移会像跳一格)。

守卫与取证
- 11 条帧泵算术守卫 + 4 条接线守卫(含「NONE 档帧采样次数必须为 0」与「帧订阅源码只出现一处」);
- 5 条变异证明全部复现(删退订 / 删 NONE 短路 / 删 needs_frames 分支 / 删 16ms 闸门 /
  加第二条帧订阅 ⇒ 对应用例红);
- 多帧序列图 12 张在 `DevDocs/artifacts/m192/`(t=0 / 半程 / 末 + 静息基线,
  `--diff --diff-scan` 实测换页 12px/6px、详情 8px);
- 空闲实证:`ULL_TICK_TRACE=1` 跑真窗口,帧订阅窗口一过 `clock=idle-1hz` 且 `frames=` 冻结。
2026-11-09 03:56:19 +08:00
AskaEth 393d6f188f Merge branch 'feat/m190-signing-publisher-metadata-and-mani'
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-09 02:42:28 +08:00
AskaEth a219a21db8 fix(m190): r2 复审——dalek strict 口径照实测写正、补小阶 R 用例、锁里 toml_datetime 退回 1.1.2
三条 P2 都是「文档/注释与事实不符 + 一条上报事实错了」,代码行为没变。

P2-1 dalek 的 strict vs 非 strict 口径改成正测试出来的样子:
* `VerifyingKey::from_bytes` **不**校验编码规范性(`y = p` 的非规范写法返回 Ok,`is_weak() == true`),
  先前写的「要求规范编码」是错的;
* 非 strict 的 `verify` **也**查 `S` 规范性 —— 先前把它写成「不做可锻检查」是半错;
* 于是这批拒绝类用例里,strict 与非 strict 唯一的分歧是**小阶点**:承重的是
  `small_order_public_key_is_rejected`,其余几条(malleable / non_canonical_r / empty)换掉 strict 后
  **仍然绿**,文件头的表与用例注释都改成这个口径,别让人误以为它们也是守卫。
* 新补 `small_order_r_is_rejected_only_by_strict`:R = 单位元 + S = k·a mod L 的伪造,**两边都断言**
  —— dalek 的非 strict `verify` 接受它、我们的 `verify` 拒绝。这是第二条「只由 strict 拒」的判据。
* `non_canonical_r_encoding_is_rejected` 里那条「非规范公钥编码」原来对两种实现都绿(等于没测),
  现在断言 dalek 的**真实**行为(`from_bytes(...).is_ok()` + `is_weak()`),dalek 升级改口径会红。

P2-2 `packaging/sign_manifest.py` 里指向已删除 `ed25519/{field,group}.rs` 的旧注释删掉(那段还写着
「same formulas」,而两套实现现在是**互相独立**才构成交叉验证)。

P2-3 `Cargo.lock`:`toml_datetime` 从 1.1.3 **退回 1.1.2**(`cargo update -p toml_datetime --precise
1.1.2` 实测可降、`cargo metadata --locked` 自洽)。先前报告里「被 `toml` 拒绝所以无法避免」的理由
**不成立**,已在报告里更正。现在相对 `44a0c87` 的锁变更是**纯新增**:16 个包、零既有版本变动。

变异证明(本轮按要求重跑):把 `verify_strict` 换成 `verify`(并补 `Verifier` trait 使其可编译)
⇒ `test result: FAILED. 110 passed; 2 failed`,红的两条正是
`small_order_public_key_is_rejected` 与 `small_order_r_is_rejected_only_by_strict`;其余拒绝类用例全绿
(与修正后的口径一致)。恢复后 `112 passed; 0 failed`。
2026-11-09 02:33:08 +08:00
AskaEth 918d826645 fix(m190): r1 复审——0x01 修成 \1 + YAML 门禁、被调方声明 secret、拆两个超限测试文件、验签换 ed25519-dalek
四条 P1 + 四条 P2 一并处置(不变的是判据,变的是「谁在承重」)。

P1-1 `.github/workflows/release-build.yml` 的 sed 替换串是裸 0x01(本意 `\1`):GitHub 解析该文件会
失败 ⇒ 整个 run 起不来;即便容忍,版本号也会被求值成单字节,PE 判据报「与 Cargo.toml 不同源?」把
排障引向错方向。修成字面 `\1`,并新增 `packaging/check_workflows_yaml.py`(真 YAML 解析器 parse
全部 workflow + 裸 C0 控制字符字节判据;带 --self-test 注入 0x01/坏结构断言必红),接进
`release-guard.yml` 两步。文本扫描型守卫看不见这类字节,这道门禁专门挡它。

P1-2 `release-publish.yml` 没在 `on.workflow_call.secrets` 里声明 `ULL_MANIFEST_SIGNING_KEY`,调用方
却传了:可复用工作流只拿得到自己声明过的 secret ⇒ 用户配好钥也会在签名步红,而错误文案反过来指控
Secret 没配。补声明(required: false,fail-closed 由步内 ::error:: + exit 1 承担,同
ULL_MSA_CLIENT_ID 的模式)。

P1-3 两个测试支撑文件越过 400 行守卫:签名夹具拆到 `tests/support_sign.rs`(support.rs 458→392),
CLI e2e 的签名部分拆到 `support/update_e2e_sign.rs`(update_e2e.rs 406→348)。纯搬家。

P2-2 「唯一收口点」改成结构事实:从 `UpdateSource` 删掉 `fetch_manifest` 默认实现,调用方只能拿
`verified_manifest(&source, channel)`(源只交原文,想绕过验签就得自己重写解析与渠道判据)。
P2-3 文档写的第 ④ 条命令补进流水线:`sign_manifest.py --verify-pinned` 用 trust.rs 里钉住的公钥
回验刚落盘的 .sig(不碰私钥),发布侧因此验证的是「客户端的判据」而不是「签名脚本的自我声明」。
P2-4 随自写实现一起删除(`Fe` 的错误 PartialEq 不再存在)。
P2-5 app-iced/build.rs 注释里那个 windows_subsystem 属性本分支不存在(M196 才有),改为说明
「本文件只写版本资源,子系统由 rustc 属性决定,check_pe_version.py 会把 subsystem 打出来」。
P2-6 坏清单不再带 error.path 是有意降级(来源 id 在 message 里),保持。

裁定项:自写 Ed25519 换成 `ed25519-dalek::verify_strict`(信任锚不许自写密码学;此刻信任表还是空表,
零兼容负担)。删除 `update/ed25519/{field,group,mod}.rs`(含只服务它们的单测),新 `update/ed25519.rs`
只剩一个薄封装;拒绝类用例(可锻 S+L / 小阶公钥 / 非规范 R / 空签名 / 未知 key)**一条没删**,
改成钉 dalek 的 strict 语义——实测 dalek 对全部五类的结论与旧实现一致(非规范 R 的拒绝理由不同:
dalek 走「小阶点」而非「编码非规范」,用例注释里写明)。签名侧留在 `packaging/ed25519_ref.py`
(纯 Python、零 pip 依赖),`--self-test` 用 RFC 8032 同一组向量钉两边。
2026-11-09 02:14:32 +08:00
AskaEth b7d9b99e12 feat(m190): 产物署名(PE VERSIONINFO)+ 清单 Ed25519 多签名与更新前验签
两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。

① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
   「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
   LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
   `crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
   判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。

② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
   字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
   任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
   里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
   管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
   是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
   (signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
   CLI 的 hint 单独说人话。

③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
   用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
   `packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。

④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
   一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
   脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。

Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
2026-11-09 01:59:05 +08:00
AskaEth 44a0c87a11 style: cargo fmt --all(合并期整合修复)
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
M187/M188 并入后全仓 fmt --check 为红(CI 的 fmt job 会红)。
本次仅 cargo fmt --all 的换行/缩进变化,无语义改动;
cargo fmt --check 与 cargo check 均已复验为绿。
2026-11-09 01:43:31 +08:00
AskaEth edd12b38ab Merge branch 'feat/m188-logplace-launcher-runtime-log-and-g'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-09 01:14:41 +08:00
worker-logplace 8c5bebf3e3 fix(m188): r1 复审 P2-1——后端启动失败的具名原因进 launcher.log
复审结论 p2-1/1 item + fix-then-merge:主线成立,但「启动器自己因 GPU / 渲染后端起不来」的
事故原因仍只进 stderr —— 双击启动没有 stderr,那条路径上等于没有日志。

- renderer/mod.rs::report_failure 的两行(`[gpu] <backend> 初始化失败(具名)` /
  `[gpu] 失败详情(具名)`)改走 diag!;
- renderer/log_bridge.rs 转出的 wgpu 日志(含 `…Selected: AdapterInfo…`,GPU 真生效的唯一证据
  行)改走 diag!;
- renderer/preflight.rs 的 `[gpu-preflight]`(ULL_PREFLIGHT=1)改走 diag!;
- probe.rs::apply_with_preflight 的三行 `[probe]` 改走 diag!。
- app/diag.rs 模块文档 + docs/logs-location.md §3 的判据改准:
  「逐帧度量不接;显式取证开关与一次性事故行可接」,并修掉 `[corner]` 两处自相矛盾
  (唯一打印点在 app/update/appearance.rs,走 diag! ⇒ 进日志;corner.rs 只 format_line)。
- 新守门 renderer::tests::a_backend_startup_failure_lands_in_the_launcher_log:
  断言那两行真的落进 launcher.log;变异(改回纯 eprintln)→ 红。
- README 两语的 launcher.log 行补上「渲染后端起不来的具名原因」。
2026-11-09 01:05:40 +08:00
worker-logplace 5fd5cd47dc feat(m188): launcher.log 早落盘 + 会话日志搬到 logs/game(旧家只读兼容)
用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。

启动器运行日志(.ULL/logs/launcher.log)
- presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme])
  当场刷盘,不再是「只有开过一局游戏才有日志」。
- app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope),
  含 boot/boot::fallback/state/update 各线。
- boot 那一拍补一行后端事实(request/effective/fallback)。
- 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。

会话日志搬家:data/agent/sessions → logs
- LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。
- host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。
- 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。

入口与文案
- 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。
- 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。
- README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有),
  docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。

守门(每条都有变异证明,见报告)
- 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。
- launcher.log 滚动 ⇒ 干掉 rename 变红。
- 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。
- 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。
2026-11-09 00:43:45 +08:00
AskaEth efc663a3cb Merge branch 'feat/m187-crashui-launch-failure-dialog-with'
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-09 00:13:29 +08:00
AskaEth 2b6dcbed40 fix(m187): r2 复审 p2——截断过的文件也必须脱敏(截断 改到文本流里做)
r2 复审挖到的隐私旁路:上一版在**字节**上切两刀,切在多字节字符中间时
String::from_utf8 失败 ⇒ 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文
按字节进包,而清单什么也不说。命中的正是最容易超过 4 MiB 的 latest.log 与会话 JSONL;
中文 Windows 用户名(C:\Users\张三)让家目录串本身就是 CJK,切点落在附近必然命中。

修法(比复审给的三条更严一点:先脱敏再截断,避免切点留下半截 secret):
解码(lossy,任何字节都能进脱敏器)→ 脱敏整份 → 按**字符/行边界**截断 → 插截断标记。
新增 crash_bundle/clip.rs(切点逻辑 + 4 条用例),write_bundle 里只剩一条通路,
「二进制原样」这条分支彻底删除。

守卫:truncated_entries_are_still_redacted —— 造一个 '中' 正好跨在 HEAD_BYTES 上的
4 MiB+ 文件,内含注册过的会话 id 与家目录;断言 ①包里找不到原文 ②<home> 在
③尾部 exit 事件仍在。变异(改回字节级截断 + 仅解码成功才脱敏)⇒ 红在「仍带着家目录原文」。

顺带:
- P3-1 启动日志写两遍(WindowHandle(None) 分支重复调 record_boot_log)→ 删掉重复调用。
- P3-2 清单里的截断字节数改成**实际保留量**(原来报的是常量 HEAD_BYTES)。
- P3-4 truncated>0 时界面那句话补用例(拆出 dialog/tests/export.rs,两个文件都 < 400 行)。
- P3-6 BundleSpec::manifest() 自己也过脱敏,去掉「谁调谁泄漏」的口子。
- P3-5 卡片宽度取官方上限 560 的语义在代码里写清(不改行为,登记为台账)。
2026-11-08 23:57:49 +08:00
AskaEth 3cd371b537 fix(m187): r1 复审——深色遮罩提亮、点遮罩关闭、包内条目名、截断保尾、启动日志接线
P1(阻断):遮罩从 Role::OnSurface@32% 换成组件层的不透明预合成
(feedback::dialog::scrim,黑@0.32 叠画布)。深色档此前是「近白@32%」把整窗提亮,
实测 (9,21,28)→(129,138,145);现在 (6,14,19)(= 组件文档预测值),且两后端逐字节相同。
顺带按纪律改用 Component::Dialog + DialogGeometry(半径 28 / 宽 560 / 内距 24),
两后端弹窗图差异从 66% 降到 2.65%(浅)/ 2.76%(深),余量全是文字抗锯齿。

P2-1:卡片那层的命中区改回卡片本身(opaque 只包卡片)。此前 mouse_area 包的是整窗居中
容器 ⇒ 点卡片外先被 Swallow 捕获,遮罩永远收不到,「点遮罩关闭」只在注释里成立。
守卫:断言卡片层的 size_hint 任一维都不是 Fill。

P2-2:条目名与清单**同源**(BundleSpec::entry_names),会话 JSONL 钉成 session/session.jsonl。
此前条目名用原始文件名(会话 id 原样进包)而 manifest 过了 redactor ⇒ 清单指向的条目不存在。
(上一轮报告把这条写反了,本轮以 unzip -l 实输出为准。)

P2-3:截断改「头 + 尾」(MAX_ENTRY_BYTES 一半一半,中间插 <truncated N bytes>),
并在弹窗补一句「有 N 个文件太大,已截断」。此前只留头部 ⇒ 长会话 JSONL 的 exit 事件丢失。

P2-4:app 启动那一拍(主题/语言/路由/窗口/实例数/msa 行)真的写进 launcher_log;
目录仍由 presenter 在首次启动时登记,所以这些行先进 core 缓冲、登记时一次刷出。

P3-1/P3-2:关闭与 Esc 立即重画(否则空闲 1Hz 下弹窗再留 1 秒);导出成败按 write_bundle
真实结果判(不再看 launcher_logs 有没有值)。

用例:新增 5 条(深色遮罩方向、卡片层命中区、条目名与清单一致、截断保尾、启动日志落点),
每条都给了变异证明(只改实现侧,均已还原)。
2026-11-08 23:21:26 +08:00
AskaEth 414f6ff886 feat(m187): 启动失败弹窗 + 诊断包导出,并接通 DialogQueue
用户实测「MC 启动失败找不到崩溃日志」的直接修法:失败时弹一个框,把退出码、
一句人话原因、证据形态、四条真实路径与三个按钮摆在同一个地方。

- app-iced:最小一层模态(遮罩 + 卡片 + 主/次按钮 + Esc/点遮罩关闭),
  消费 presenter 的 DialogQueue(此前全仓零引用,所有弹窗都画不出来);
  队列管弹窗,CrashCard 管这次失败的事实,按 DialogId 绑定。
- 弹窗区分两种失败:游戏自己写了 crash-reports(贴 Description + 栈片段)/
  游戏没来得及写(明说「这次没有生成崩溃报告」并贴出捕获的最后 30 行)/
  什么都没抓到(给最可能原因分类)。绝不对第二种说「找不到日志」。
- 导出诊断包:core 侧新 crash_bundle 写 zip(清单 + 启动器日志 + 会话 JSONL +
  crash-reports/* + hs_err + latest.log + 版本 JSON),落盘即脱敏(家目录 / token /
  会话 id),单文件 4 MiB、总量 48 MiB 上限并在清单记截断。
- 启动器自身日志落盘(core::launch::launcher_log → <.ULL>/logs/launcher.log,
  登记目录前先缓冲),补上「双击启动时诊断行直接蒸发」的缺口。
- i18n:25 条中英文案进 catalog/logs.rs + 金表。
- 取证:ULL_DIALOG_FIXTURE=crash-report|no-logs|export 在临时目录造真文件出图,
  wgpu 与 tiny-skia 各一套,见 DevDocs/artifacts/m187/。

未复现 M190 的「owned 文字在 wgpu 下丢字」:同一串中文两条文字路径、两种后端逐字相同
(ab-wgpu.png / ab-tinyskia.png),因此本轮未改任何渲染路径。
2026-11-08 22:42:00 +08:00
AskaEth 7b79d5c397 chore(m197): 三处拆文件守 400 行硬线(复审 r1 后新增内容把三个文件顶过线)
- `core/src/setting/keys.rs` 396(原 400 → 我的 +15 到 415):`download.proxy_mode` /
  `proxy_url` / `gh_proxy` 三键搬进新 `keys/proxy.rs`(`specs()` + 一句「空串 ≠ 关」的守卫),
  主表留一行 `.with_all(proxy::specs())`——与 `font` / `update` 同一种形状。
- `core/tests/download/engine_mock_test.rs` 407 → 364:404 那条用例整条搬进
  `tests/download/engine_mock/not_found.rs`(`#[path]` 子模块,仍是 `engine_mock` 这个 target)。
- `pages/settings/java/downloads/tests.rs` 423 → 313:gh-proxy 两条守卫搬进
  `downloads/tests/gh_proxy.rs`(`#[path = "tests/gh_proxy.rs"]`)。

纯搬移 + 一条新单测(`keys/proxy.rs` 的「空串 = 跟随内置默认,不是关」);行为不变。
2026-11-08 21:42:15 +08:00
AskaEth 29da7e2e47 Merge branch 'feat/m180-i18nappear-localize-appearance-page'
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-08 21:42:14 +08:00
AskaEth 1d240ff1f2 fix(i18n): 复审 r1 —— 两句近义软件渲染说明合一,生效层级码翻成译文(M180)
P2-1(一屏两句重复):
- 留面板那一句(就在被禁用的滑块旁边,正好回答「为什么拖不动」),措辞同时点名面板与玻璃
- 删 `SettingsAppearanceSoftwareRendererNote`(catalog 一条 + 金表一行,两边同时删)
- 删 `bg_image.rs` 的 `backend_note` 渲染块与 `State::backend_note()`;state.rs 391 → 382 行

P2-2(配色:preset 的机器码):
- `palette::level_key(code)` 把 `preset`/`wallpaper`/其余 映射到已有的三条档位 key,不新增平行词表
- `bg_image.rs` 用 `tr(PaletteLevel, {level})` 传译文,中文界面显示「配色:内置色卡」
- 补 `every_level_code_maps_to_its_own_sentence`:三个码值一一对应 + 三条译文互不相同

顺手(复审 L-1/L-2/L-4,全在本 scope 内):
- `appearance_texts` 加「渲染后不许残留 `{`」断言(参数名写错当场红)
- `section/mod.rs` 文档:取词口 t/tr、填充措辞、清掉孤立 `//!`、把「没有一句英文」改成
  写明两处例外(`{reason}` 原文串、专名)
2026-11-08 21:42:12 +08:00
AskaEth c4c3c15285 feat(i18n): 外观页 40 条文案进词表(M180 I18NAPPEAR)
- 卡片内 22 处硬编码英文(整窗/面板透明度、背景图开关+路径、填充、暗化、配色来源)全部改走 Key
- 运行时诊断改成 key + 参数:`background::Note`(读盘/解码/过大/小图平铺)、`state::backend_note`、
  `palette::note` 全部返回 Key;`state.rs` 只留 key、不放文案表(390 → 391 行)
- 上限/下限常量走占位符 `{floor}` / `{max}` / `{limit}` / `{requested}`;`{:.2}` 在 Rust 侧格式化好
  再套模板(`Catalog::render` 只认 `{name}`)
- 新增 40 条 key(中英各一条),按装配链锚点插在 `SettingsAppearanceCornerSuspended` 之后,
  金表 `golden/tail.rs` 同位置插入 40 行(`rows() == len() == Key::ALL.len()`)
- 守卫三条:中文渲染全非 ASCII、卡片 key 全覆盖(改回写死英文即红)、外观 key 逐条真翻过
- probe 加第二条取证路(`record_rendered`),`take()` 签名不动(M179 的守卫在 scope 外)
2026-11-08 21:42:11 +08:00
AskaEth 65e7ccc3bf Merge branch 'feat/m184-linuxci-promote-linux-slots-into-re'
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-08 21:35:30 +08:00
AskaEth 7e8b8740c4 fix(m184): r1 —— 证据账不再把「机制会跑」写成「已有运行时证据」,并收 5 条文档漂移
P2-1(唯一阻塞):`packaging/docs/linux-evidence.md` 里 `ull --version` 冒烟那条被写成
「两个槽位都有一份 CLI 运行时证据」——`make_artifacts.py::smoke_version` 只保证**机制**
(`slot == host_slot()` 时真跑),而仓库里找不到任何一次 linux-arm64 成功出包的记录
(`git grep -rn all_slots DevDocs/` 零命中),所以 arm64 侧运行时证据为零。§1 表格与
§5 改成「机制上会真跑 / 截至本分支没有那一次记录」,§6 解锁条件同步补一句。

P3 漂移:
1. `release-publish.yml` 注释 8 条 → 11 条自测回归(与脚本/其它三处一致)。
2. `packaging/docs/profiles-and-measurements.md` 同上 8 → 11。
3. `check_publish_slots.py` 注释示例 `PUBLISH_SLOTS: "windows-x64"` → 两档现值。
4. `release-build.yml` 注释「x64(当前唯一在发的槽位)」→ 两档已发布,并点名
   linux-x64 的证据在自己的 xvfb 冒烟步。
5. `linux-evidence.md` 「20 倍以上余量」→ **8~37 倍**,并点明最紧的是亮度 σ
   (24.06/3.0≈8×,与 check_screenshot.py 自述的 1/5~1/8 一致)。

自查(复审点名的五处之外):`只发 windows-x64` / `Windows only` / `暂未发布` /
`once published` / `8 条` 全仓再扫一遍,release-notes / README ×2 /
artifacts-and-version 剩下的都是准确表述(如「the only Windows asset」指 windows_x64.exe)。

验证:check_publish_slots --self-test EXIT=0、check_test_source_off EXIT=0,另
check_publish_slots(source) / check_release_semantics(+--self-mutation) /
make_manifest --self-test 均 EXIT=0。本轮无 cargo 构建,未创建 CARGO_TARGET_DIR。
2026-11-08 21:35:26 +08:00
AskaEth 2813e2984c ci(m184): linux-x64 进发布名单,两档 Linux 转 mode=ci,arm64 只构建不发布
发布流水线默认从 2 个槽位变 4 个:`linux-x64`(ubuntu-22.04)与 `linux-arm64`
(ubuntu-22.04-arm)的 mode 由 probe 改 ci,tag 推送默认就编。

构建 ≠ 发布仍由 `PUBLISH_SLOTS` 决定,本轮只加 `linux-x64`:
- `linux-x64` 有运行时证据 —— release 产物在 Linux 真机(Ubuntu 22.04 + WSLg 真窗口)
  渲出一帧、[theme]/[backend] final 日志齐全、像素判定通过,真窗口 1180x760 从 Windows
  侧截屏可见;`release-build.yml` 另加一条**同槽位**的 xvfb 运行时门禁(只在
  `inputs.slot == 'linux-x64'` 上跑,不给没验过的 arm64 造红)。
- `linux-arm64` 只有构建证据、运行时零证据,产物照出 artifact 取证但不发布;解锁条件
  (真机渲出一帧 + 主题日志)写进 release-notes 与 §9。

守卫跟着扩(`packaging/check_publish_slots.py`):
- 自测从 8 条扩到 11 条,新增「声明的 linux-x64 构建红了 ⇒ 必须红(挡住发布)」、
  「linux-arm64 有产物但没声明 ⇒ 只警告」、「提前把 linux-arm64 写进名单 ⇒ 缺产物必红」;
- probe 档样例从 linux-x64 换成仍是 probe 的 macos-arm64(否则该用例会从「必须红」
  变成「本来该绿」);
- 新增「mode=ci 却本次没产物 ⇒ ::warning::」:红一档不能只表现为表里少一行。

文档矩阵同步(release-notes §Downloads/§Support matrix、compliance §9/§10、
artifacts §1、build-and-ci §3、README ×2),两份文档都把 Linux 写成
「已构建并发布(x64)/ 已构建未发布(arm64)」,并把新发现写实:Linux 上整窗透明
与圆角没有代码路径(hwnd=none / corner=policy=system),不是「未验」;Linux 上
`ull update apply` 会拒绝 portable-zip(清单里现在有 linux-x64 产物)。

证据账(含实测输出、CI 增量估算、未验清单):packaging/docs/linux-evidence.md。
2026-11-08 21:35:26 +08:00
AskaEth a14b00aced fix(m197): 复审 r1 的 P1 + 三条 P2(描述对齐行为 / 内部编号下屏 / kind 守卫 / gh-proxy 可覆盖)
P1(阻断)`java.auto_install` 的描述与行为不一致——那句会进 AI 工具的 JSON Schema
`description`,等于让助手对用户说一件实现刻意不做的事:
- `crates/core/src/setting/keys.rs`:改成「缺 Java 时在 Java 页标出推荐版本并可一键安装;
  不会自动下载」,去掉 `(D13 基准 JDK17)`。
- `SettingsJavaAutoInstallHint` 中英两侧各补一句显式否定(「不会自己开始下载 —— 点「安装」
  才会下」/ "Nothing is downloaded on its own — you press Install."),标签从「自动准备」改成
  「缺 JDK 时推荐一档」;金表同步。
- 新增守卫 `the_auto_install_description_says_it_never_downloads_by_itself`(描述必须含
  「不会自动下载」、不含旧说法与内部编号)。

P2-1 内部决策编号印在界面上:`SettingsJavaDownloadBaseline` 的 `Baseline (D13)` /
`基准版本(D13)` → `Recommended baseline` / `推荐基准版本`(金表同步)。另加两条目录级守卫
(`no_user_visible_string_mentions_an_internal_decision_id` /
`no_user_visible_string_carries_markdown_backticks`);后者顺带清掉 M168 遗留的
`FontCjkStatusHint` 里的 `ui.font_cdn` 反引号——全仓最后一条。

P2-2 `ProgressEvent::SourceFailed.kind` 没有守卫:`engine_mock_test`
- `reports_progress_events_and_errors_when_all_sources_fail` 的解构补 `kind`,断言
  `ServerError`;
- 新增 `a_404_is_classified_as_not_found_and_never_cools_the_source_down`(404 → `NotFound`,
  与「源坏了」分开)。

P2-3 交付任务书那句「(默认值可改)」:新增 `download.gh_proxy`(空 = 跟随内置默认 /
`off` = 关 / 其它 = 覆盖前缀),`engine_with` 是它的真消费者(改 `RegionProfile`,因为
`gh_proxy_candidates` 读的就是档案里的前缀);Java 页不画 UI(归 M193)。**内置默认值没动**。
schema minor 1.7.0 → 1.8.0(新键必须推 minor),三处硬编码 minor 的用例同步。

另:真下载用例改成 panic 也清理(复审顺带项 7④);`downloads/refusal.rs` 的「四档」改「五档」。
2026-11-08 21:35:13 +08:00
AskaEth dc3fa2be87 Merge branch 'feat/m182-maccorners-follow-compositor-rounde'
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-08 21:34:42 +08:00
AskaEth c198a3c995 Merge branch 'main' into feat/m182-maccorners-follow-compositor-rounde
冲突只有一个 hunk:`crates/app-iced/src/platform/mod.rs` 的模块声明表。
- 我方(M182):`pub mod host;` / `pub mod linux;` / `pub mod macos;` + `pub use host::HostOs;`
  + `plan_corner_for` 重导出;
- 主线(M168 `449ded2`):`pub mod data;`(字体缓存数据根派生)。

两边都要,全部保留并按字母序排:`data, host, linux, locale, macos, win32`。
另把模块分册表补一行 `data`(M168 的产品在表里原本缺位),首段「四件事」随之改成「五件事」。
`pub use win32::corner::{…}` 取我方超集(main 的四个名字 + `plan_corner_for`),逐个名字比对无丢失。

非冲突文件零改动:我方 11 个文件(host.rs / linux/ / macos/ / win32/ / boot/diagnostics.rs /
design/shape.rs)合并后与 tip 28762ec 逐字相同;`decorations: false` 仍在
`app/boot/diagnostics.rs:43`(裁决 B 未被越过)。
2026-11-08 21:13:20 +08:00
AskaEth 124e474656 Merge branch 'feat/m179-i18nseam-thread-lang-into-page-bodi'
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-08 21:02:18 +08:00
AskaEth ca92014b81 fix(m179): 复审 r2 —— 订正「Body 带 Lang 之后」残留的六处文档漂移
P2-1:`settings/mod.rs:273`(`AI_GROUP`)还写着「`Group.body` 的签名里没有 `Lang`」,
而同一次提交在同一个文件把槽位改成了 `fn(&Ui, States, Lang)` 并在上方 `GENERAL_GROUP`
按新事实重写 —— 一个文件里两段互相矛盾的说明。本条按新事实重写(行数不变,仍是 389 行)。

一并订正 finding 里剩下的外域漂移(tower 已扩 scope):
- `settings/ai_about.rs:8`(同一句假话的表格行,finding 没列到)与 `:14-16`
- `settings/general.rs:7-11`(槽位现在带 `Lang`,装不下的只剩**档位** `LangChoice`)
- `settings/general.rs:287`、`settings/update.rs:5`、`pages/mod.rs:271`、
  `settings/language_appearance.rs:20` 的 `pages/appearance/section.rs` 路径

措辞与复审建议的一处偏离(已在提交与报告里说明):复审建议写「槽位给不了『测试连接』的
结论」,与实现不符——该结论在 `States.groups.ai_about` 里,槽位拿得到;AI 组仍按下标分发
的真实原因是**尚未迁移**(槽位要 `Option<Element>`,`ai::view::body` 返回 `Element`)。

零代码改动:本提交所有变更行都是以 `//!` / `///` 开头的文档行
(`git diff -U0 | grep '^[+-]' | grep -vE '^[+-]+\s*//[/!]'` 为空)。
按 tower 要求**不动** `crates/i18n/src/catalog/settings/appearance.rs:8`(M180 正在改)。

验证:cargo fmt --all --check → 0;cargo check -p unlucky-app-iced --all-targets → 0;
cargo clippy -p unlucky-app-iced --all-targets -D warnings → 0;
cargo test -p unlucky-app-iced --lib appearance → 0(37 passed)。
2026-11-08 21:01:25 +08:00
AskaEth 9a7b138d08 chore(m197): 清掉 clippy 的三处拒绝 + 一处 2018 idioms 警告
CI 的门禁是 `cargo clippy --workspace --all-targets -- -D warnings`:
冗余闭包 / 用例表的类型复杂度 / Default 之后逐字段赋值 / elided lifetime 四处都会红。
纯实现侧改动,行为不变(757 + 31 全绿)。
2026-11-08 20:57:23 +08:00
AskaEth ce463cc9b5 feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)
用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」

- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
  数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
  要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
  下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
  `java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
  各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
  `SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。

顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
2026-11-08 20:49:56 +08:00
AskaEth b457f0acc8 feat(m179): 外观分组接真语言并拆成五段文件(只接管子,不补文案)
M172 审计:`pages/appearance/**` 中文界面下真正本地化 0/27,两个根因各断一刀。

① 拆文件(纯搬移):`section.rs` 395 行 → `section/` 五个文件,行数守卫只剩 5 行余量,
   不拆就加不了任何东西。
   - `section/mod.rs`(壳:section() + card() + 五个行控件 + 取词口 t)
   - `section/window.rs`(① 整窗 α + ② 圆角,含 notice/说明段)
   - `section/panel.rs`(③ 面板 α)、`section/bg_image.rs`(④⑤⑥⑦⑧)、
     `section/colour_source.rs`(⑨)
   取证:每段与原文件做「去空白/去尾逗号」diff,4/4 相同;只有五处机械改写
   (`rows = rows.push(…)`→`rows.push(…)`、`t(Key::X)`→`t(Key::X, lang)`、
   `super::notice`→`super::super::notice`、一处 `.into()`、rustfmt 换行)。

② 语言接到渲染层:`settings::Body` 从 `fn(&Ui, States)` 变成 `fn(&Ui, States, Lang)`,
   `form` 传 `language.effective`,`settings/appearance.rs` 收 `lang` 转给
   `pages::appearance::section(ui, state, lang)`,`section::t(key, lang)` 取词。
   选「接进唯一那个槽位」而不是再给 `form` 加一条按下标分发的 `else if`:`settings/mod.rs`
   贴着 400 行硬顶(M195 还要在此落地),这条路净增 0 行且不可能与分发表漂开。
   验收点:`pages/appearance/**` 里已经搜不到 `Lang::En`。

③ 能变红的守卫:`the_appearance_group_speaks_chinese_when_the_interface_does`
   —— 走 `settings::view` 真分发渲染一帧,断言取词记录里含汉字、英文那帧不含汉字且同一 key
   中英译文不同。取证口是 `appearance::probe`(`#[cfg(test)]` thread_local):iced 0.14
   没有可遍历控件树的公开接口(`Widget::children()` 返回状态 Tree;`operate` 要 Renderer),
   所以取证点退到「字进树之前」的唯一取词口。变异证明:把 `t()` 改回 `Lang::En.catalog()`
   ⇒ 该用例红(失败信息逐条列出四条英文);还原 ⇒ 绿。

只接语言管子,不补文案:本组其余英文仍是字面量(行号见 M179 报告)。真实窗口截图、
macOS/Linux 字体差异未验。
2026-11-08 20:42:28 +08:00
AskaEth e386cfbeee Merge branch 'feat/m186-addondep-modrinth-dependency-resolu'
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-08 20:24:00 +08:00
AskaEth ff2f229b80 fix(addon): 复审 r2 —— 下载阶段抽出可测边界并补守门
- 阻塞项:下载循环从 `install::run` 抽到新文件 `addon/downloads.rs`
  (`download_failure()` + `download_stage()`),假下载器就能驱动它;三条用例分别钉
  ①失败→Unreachable+叶子成因、②第 2 个失败不中断且已装不回滚、③解析完就取消一份都不下。
  实测删掉 `record_failure` 那行会红(failures 0 vs 1),把 `cause` 丢掉也会红;
- nit① 补 core 用例:夹具里 `{"version_id":"vid-only","dependency_type":"required"}` ⇒
  `plan.unresolved_required == [UnresolvedRequired{parent:"MIXED001",…}]`,同版本的
  `project_id` required 仍被跟;删掉收集那段即红(left: [] vs 一条);
- nit② 取消检查加回解析之后(`download_stage` 入口),并用例钉「解析后取消 ⇒ 不下任何文件」;
- nit③ `fail()` 文档写明去重按**请求键**而非规范 id 的边界(先 slug 后 project_id 仍会两条),
  不消掉但不再让人误以为全覆盖;
- `install.rs` 只留解析 + 收口(275 行),模块表登记 `downloads`。
2026-11-08 20:23:57 +08:00
AskaEth aa33210bb6 fix(addon): 复审 r1 —— 失败去重、null 载荷、完成态进度、顺序守门、下载失败归并
- P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按
  `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents`
  折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复;
- P2-2 `RawDependency.dependency_type` 改 `Option<String>`:`#[serde(default)]` 兜不住
  `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上;
  现在 null/未知一律 `Unknown`(不递归);
- P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3);
  解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」;
- P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红;
  另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`);
- P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件:
  已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request`
  自动覆盖它们;
- nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档);
  nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略);
  nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。
2026-11-08 20:23:56 +08:00
AskaEth 011d525e8d feat(addon): Modrinth 前置依赖 BFS 解析与两档失败名单
- model 纳入 `dependencies`(project_id / version_id / file_name + required /
  optional / incompatible / embedded / unknown 五值),只跟 required 递归;
- 新增 `online::dependency`(请求/计划/跳过的形状)与 `online::resolve`
  (BFS:规范项目 id 去重防环、每项目只解一次版本、同层按项目键字典序、
  默认深度 6 且截断写进计划、父子关系进 `required_by`);
- 失败从类型上分两档:`Incompatible`(所选版本/加载器没有匹配项)与
  `Unreachable`(并保留叶子成因 NotFound/NotConnected/…),`retry_request()`
  只含失败项,解析永不返回 Err(失败是数据,不连坐);
- 盘上第二道去重:`present_key` 归一化文件名(小写、砍路径、去 `.disabled`),
  命中即跳过下载,但依赖图仍然展开;
- presenter 装入路径改成「解析计划 → 逐个下载」,文案把两档分开写并列出
  「可只重试这 N 个」;`KNOWN_LOADERS` 补 iris/optifine(光影的加载器);
- 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。
2026-11-08 20:23:55 +08:00
AskaEth 94f792e978 Merge branch 'feat/m178-msalogin-fix-microsoft-login-redire' 2026-11-08 20:23:49 +08:00
AskaEth a40b3a5940 fix(msa): 浏览器登录按钮在生产路径真的置位 browser_first(复审 r1 收口)
复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是
共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login`
里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路)
实际未交付。

- P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走
  `.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的
  模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测:
  把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对
  `/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。
- P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在
  `TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去;
  删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条
  msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的
  `callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。
- P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并
  回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。
  (下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。)
- P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是
  「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块
  文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。
- P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成
  视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。
- P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」,
  下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。

门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、
`cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到
`client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。
2026-11-08 20:23:44 +08:00
AskaEth f82bd395f8 fix(msa): 微软登录默认走设备码,浏览器流降级为可选加速路
官方产物上点「微软登录」必失败:GUI 只接了授权码流(随机端口回环回调),而 Azure 应用
注册当初是为 device-code 建的,重定向 URI 一项都没登记,于是授权端点当场回
`invalid_request: ... 'redirect_uri' is not valid`,浏览器里只剩一张 AAD 原始错误页,
启动器干等 5 分钟超时。设备码流本身是好的(CLI `accounts add msa` 一直能用)。

- core:新增 `preflight`(回环重定向 URI 注册预检)与 `login`(浏览器优先 + 自动回退设备码)。
  预检拿「即将使用的 redirect_uri」(端口不参与匹配,微软官方文档 *Localhost exceptions*
  原文已引进模块文档)先问一次授权端点;没登记 / 问不出来就地跳过,**一个监听口都不开**。
  预检通过才真绑端口走授权码;授权码失败(超时/被拒/绑不上)自动回退设备码并带上结构化
  原因码;用户取消(`Canceled`)原样上抛,不偷偷换流。设备码是默认,零监听口。
- presenter:`start_login` = 设备码(默认);`start_browser_login` = 浏览器优先 + 回退,
  回退原因码写进 `DeviceLoginView.browser_note`(原文只进日志)。
- i18n:三条回退文案(含「怎么修:把 http://localhost/auth 加进重定向 URI,端口不参与匹配」)。
- app-iced:登录面板主按钮改成设备码、浏览器降级为次按钮;重登也走设备码;设备码面板与失败
  面板都能画回退说明。倒计时文案改由视图模型持有(借用渲染),顺带修掉中文倒计时整行不显示。
- 守卫(都能变红):①授权端点回 invalid_request 时确实回退设备码且原因是结构化码;
  ②未登记回环 / 默认设备码路 `listeners_opened == 0`;③回环登记了仍能走授权码(不焊死加速路);
  ④用户取消不被偷偷换流;⑤client id 仍只能构建期注入(扫 `auth/microsoft/` 源码无 UUID 字面量)。
2026-11-08 20:23:42 +08:00
tower-worker 26ba1579ab docs(m196): r2 复审 —— 给「无控制台宿主」那条下边界声明
复审指出 README §15 把两个**近似**形态(父进程句柄不继承 / 父子都无句柄)直接叫「服务形状」,
顶层又写「服务 / 计划任务」,读者会把它当成「真服务也测过」。补一段边界:① ② 都是近似,
真 SCM 服务与计划任务(session 0、no window station)、start /b、ConPTY 全部未测,结论靠
「句柄无效时 std 写返回 Ok」这条机制外推;第三方交叉读数(复审 r2 那组)单独标注来源,
不混进我自己的读数。

同一处过度概括也出现在 console.rs 的 NoConsole 文档(原文「④ / 服务 / 计划任务」),
按同一口径改成「④ 双击,以及其他没有控制台的宿主」并指向 README §15 的边界段。

只改文档,不动代码。
2026-11-08 20:22:36 +08:00
tower-worker 3fd33a672f fix(m196): r1 复审 P1(非 Windows dead_code)+ P2 四条
P1(阻断):console.rs 的 `INVALID_HANDLE_VALUE` 只被 `#[cfg(target_os = "windows")]`
的 usable() 用到,而这个模块在 Linux/macOS 上也编译(platform/mod.rs 的 pub mod win32;
没有 cfg)⇒ ubuntu 的 `clippy -- -D warnings` 会把它升级成 dead_code error。
按「精确 cfg」修(不用 dwm.rs 那种文件级 allow(dead_code):那会永久掩掉本文件在非
Windows 上的所有死项)。自证方式是真实文件翻转 cfg 的 rustc probe:修前 exit 1
(`constant INVALID_HANDLE_VALUE is never used`)、修后 exit 0 零告警;同一条命令写进
了注释,下一个人可以照着跑。

P2-1:自测条数按实现改成 8 条(红 6 / 绿 2),三处(脚本 docstring、release-build.yml
注释、README §12)一致。
P2-2:文件头那组句柄读数换成 release 产物的实测值(out 0x0→0x1F0 / err 0x0→0x1F4),
并说明数字随实例变化、单测里的 0xE4/0xE8 是示例。
P2-3:README §15 补这段边角语义——但**按实测改写了复审的结论**:两种「既无控制台也不
重定向」的宿主形态(父有句柄不继承 / 父子都无句柄)× 两条命令,child_exit_code 全是 0,
不是 EXIT_INTERNAL;原因是 Rust std 在 Windows 上对取不到的句柄直接返回 Ok(独立探针
实测 SetStdHandle(out)=0x0 / 0x1234 / (HANDLE)-1 三种取值 write/flush 都是 Ok),
所以 runner.rs 那条「写失败 ⇒ EXIT_INTERNAL」的分支不会被触发。
P2-4:NoParentConsole → NoConsole(这个名字同时覆盖「attach 成功但句柄仍不可用」),
诊断文案与注释同步。
2026-11-08 20:12:36 +08:00
AskaEth 329c529bea Merge branch 'feat/m175-javapage-wire-java-settings-to-real'
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-08 20:09:04 +08:00
AskaEth e5a4b9bbd1 fix(settings): 改口逐实例覆盖的事实陈述,并把第三条守卫换成行为断言
复审 r1 的两条 P2:

P2-1(`settings/mod.rs` 的 `form` 文档):原先写「『逐实例覆盖』既没有数据源也没有
消息落点」——这是错的。逐条核过:真控件在实例详情页的「启动参数」区(M157
`pages/instances/launch_args.rs`,由 `pages/instances/detail.rs` 渲染),落盘走
`app/update/instances.rs` 的 `AppCommand::set_launch_args` → presenter
`host/launch_args.rs` 的 `resolved`/`apply`(写回 `config/instances/<id>.json`),
回落与落位在 core 的 `game/launch_overrides.rs` / `launch/overrides.rs`。真正无人产出
的只有 UI 变体 `Action::AddOverride`。改口后的事实:它是**放错了地方**,不是没接。

P2-2(守卫):删掉按名字扫源码的两条(`empty_state`/`overrides` 子串、`instance` 子串)——
前者的假警报会挡住「某组确实需要自己的空态」,后者抓的根本不是它声明的不变量(真正让实例
选择进不来的是 `settings::view` 的签名),且日后一句正当英文注释就能把它弄红。换成:
- 判据 ①(保留)守**收尾**:`form()` 里 `stack = stack.push(` 恰好一处、且以
  `frame::scroll(ui, stack)` 收尾(整段重写让计数变 0 也红);
- 判据 ②(新增,行为断言)守**生产**:把每组一列的循环抽成 `section_columns()`,
  测试真调它数返回值——窄窗 == `GROUPS.len()`,单选各组 == 1,换任何名字都躲不掉。
文档里的「抓不住」段也不再指向静态图(静态图只能证明「现在没有」)。

`form` 的拼装形状没变(循环 + `frame::scroll` 收尾),分组表、分发顺序、正文内容一字未动。
2026-11-08 19:54:38 +08:00
AskaEth df331f6a60 feat(m175): Java 设置页接真运行时(扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数)
用户实测四条抱怨逐条对应:
- 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 +
  注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消
- 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘
- 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回
- 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version),
  设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否)

core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与
取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。
i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。

复审 r1(p2-5items)全修:
- P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动
- P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key),
  不再把 Rust 枚举名当人话
- P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在)
- P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串
- P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间
  不许起第二个(界面显示「正在取消…」)

复审 r2(p2-2items)全修:
- P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口
- P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫,
  配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响
- 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、
  core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、
  默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息)
2026-11-08 19:52:04 +08:00
tower-worker 14f9318e09 fix(m196): GUI 产物 PE Subsystem → WINDOWS_GUI(2),并用 AttachConsole 保住 CLI 输出
群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头
Subsystem 仍是 CUI(3)。同一个修复 2026-11-01 做过一次(37d3039),但只改了已被
workspace exclude 的 Slint 树(crates/app),从未到达 iced 产物。

- crates/app-iced/src/main.rs:加 #![cfg_attr(windows, windows_subsystem = "windows")],
  并写清为什么放 bin 层而不是 build.rs(PE 子系统是每个 bin 目标的属性)。
- crates/app-iced/src/platform/win32/console.rs(新):AttachConsole(ATTACH_PARENT_PROCESS)
  的落点 + 判据 should_attach(纯函数,可断言)。GUI 子系统进程不继承父控制台,实测
  从 cmd 启动时三个 std 句柄全是 0;附着后 GetStdHandle 立刻给出控制台句柄(0x0→0x208),
  所以不改 std 句柄、不碰用户的重定向(ATTACH_PARENT_PROCESS 只会附到已存在的控制台,
  永不新建窗口)。
- packaging/check_pe_subsystem.py(新):读产物 PE 头 Subsystem 字段(GUI 必须 2、
  CLI ull 必须 3),非 2 即红;--self-test 注入 5 条合成 PE 回归。
- 接进 release-build.yml(发布侧两槽位)与 ci.yml build job(PR 阶段)。
- guards.yml:unsafe 落点期望集合变成两处(console.rs + transparency.rs),集合相等判据其余不变;
  平台层所有「全仓唯一 unsafe 落点」措辞同步改正。
2026-11-08 19:33:56 +08:00
AskaEth 7fdebba961 fix(settings): 设置页尾部不再渲染不属于本页的空态卡
用户 2026-11-08 在 M174 的助手页与 M175 的 Java 页截图里看到同一张卡(四格图标 +
「没有逐实例覆盖」+ 说明被窗口下沿截断):两张图 1180×1100、卡片位置完全一致、
独立居中在内容下方。

根因是 `pages/settings/mod.rs` 的 `form()` 在分组循环之后**无条件** push 了
`pages/settings/frame.rs` 的 `overrides_empty()`(M84 骨架期的占位,M99 纯搬移时原样
留下,固定 320dp 高):于是六个分组下面都长出同一张卡,与实例选中态无关。

修法:删掉该空态位及其唯一的三个 i18n key(`SettingsOverrides*`,金表同步删三行)。
设置页永远有内容(总有一组被选中),12b §15 的空态件是给实例/下载/日志页的;
「逐实例覆盖」全仓既无数据源也无消息落点,所以不挪给任何一组,直接不渲染。

守门(`settings/frame.rs` 的 `no_stray_empty_state` 三条,变异实测会红):
尾部不许再出现额外 push、`form()` 不认任何空态件名、设置页不读实例选择。
2026-11-08 19:13:37 +08:00
AskaEth 30b61e2684 fix(m174): 恢复被截断的 ai/outcome.rs —— dev 不编译
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
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
fd4a5f8(截图种子按档给各自自洽的 detail)把 outcome.rs 从 197 行截成 1 行,
而 ai/mod.rs 仍然 `pub mod outcome;` + `pub use outcome::ProbeResult;`,
dev 上 unlucky-app-iced 直接编不过。第三轮复审判 clean 时漏了这一点(它没真编译)。

先恢复 93d02ab 的完整实现让树能编(cargo check -p unlucky-app-iced --all-targets
退出码 0 已验)。fd4a5f8 想做的「种子按档给自洽 detail」没有生效,作为 follow-up 重做
——出图自洽是次要,树不许红是底线。
2026-11-08 17:46:47 +08:00
AskaEth c72bf10690 Merge branch 'feat/m174-agentcfg-ai-provider-base-url-model'
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-08 17:26:14 +08:00
AskaEth fd4a5f87d9 fix(m174): 截图种子的每档详情各写一句(证据自相矛盾)
r3 出图时实测:`ULL_AI_STATE=failed-model` 那一张的分类是「端点通了,但没有这个模型」,
而详情行写着 `status 401 Unauthorized / Incorrect API key`——种子把 Auth 的详情复用给了
ModelNotFound/Failed。证据自相矛盾比没有证据更糟。

改:`ProbeResult::screenshot_seed` 按档给各自自洽的 detail(401 invalid_api_key / 404
model_not_found / Http client error / 8 s / 503)。种子仍然只伪造「结论是哪一档」,字段形状
照旧由 `from_outcome` 决定(r1 修的那条不动)。
2026-11-08 17:16:51 +08:00
AskaEth 6f78e0cf7e fix(m174): 复审 r2 —— 写入口收敛成一个(语言入口同缺陷)+ 只读文件上保存不再谎报成功
P2-1(同一类缺陷的第三个入口):`persist_ui_language` 与 `persist` 共用同一份长生命
store、`save()` 同样整份落盘,但语言入口没先 `reload()` ⇒ 保存 AI 配置后 1 秒内切界面语言,
`ai.*` 连密钥一起被 1 秒前的旧快照盖掉,而且函数返回 true。

修法选「收敛成唯一写入口」而不是「再补一次 reload()」:
- 新文件 `presenter/src/settings_bridge/write.rs` 提供 `SettingsBridge::write_with(|store| Edit)`
  ——现读 → 改 → 落盘 → 刷 mtime,`Edit::{Changed,Unchanged,Rejected}` 表达闭包的结果,
  `WriteOutcome::{NoStorage,Unchanged,Saved,Skipped(reason),Failed}` 表达写的结果(只有 `Saved`
  算成功)。两个入口都改成传闭包,**入口自己再也拿不到「不重读的 save」**——下一个写入口不会
  再靠「记得」。r1 就是「补一次 reload」的做法,隔一个文件又漏了一次。

第二条(用户可见谎话):`ai::store::save` 注释写着「落盘后再读一遍才算数」,实际返回的是内存
doc;`SettingStore::save` 还有 `Skipped(ReadOnly/Unreadable/NotDirty)` 一档(`Ok`,不是 `Err`),
旧代码 `.is_ok()` 会把它当成功 ⇒ 在「配置文件被更新版本启动器写过」的机器上什么都没写,界面报
「已保存」,重启回旧值。现在按 `SaveOutcome` 分支(只有 `Saved` 算成功,`Skipped` 给具名原因),
并且真的 `SettingStore::load` 一次从磁盘回读。

回归用例(都做了变异验证):
- `settings_bridge_lang::tests::persisting_the_language_keeps_keys_another_writer_just_saved`
  (去掉 `write_with` 的 `reload()` ⇒ 红,输出即「语言写回把刚保存的密钥盖掉了」+ 被覆盖的文件)
- `settings_bridge_lang::tests::persisting_the_language_reports_false_when_the_file_is_read_only`
  (`persist_ui_language` 恒返回 true ⇒ 红)
- `ai::store::tests::read_only_config_is_reported_as_a_failure`(`save()` 恒返回 Ok ⇒ 红)
- `ai::store::tests::skip_reasons_are_named`
2026-11-08 17:03:13 +08:00
AskaEth 93d02ab12b fix(m174): 复审 r1 五项 —— 失败带 target、密钥消息掩码、请求体断言、桥写前重读、结构化分类
P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体
改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条
「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增
`ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版
手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。

P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。
用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。

P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例
`the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出
一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自
`ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。

P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份
文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例:
`presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的
`ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。

P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化
报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure`
(`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable`
不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例
`factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。

顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` /
`rig/tests.rs`(纯搬移)。
2026-11-08 16:20:34 +08:00
AskaEth 9cb605f316 feat(m174): 助手(AI)提供商配置真接线 + 「测试连接」
设置页新增第 6 组「助手」:提供商四态 / 端点 base_url / 模型名 / API 密钥(默认掩码,
点「显示」才明文)+ 保存 + 测试连接,四类失败(鉴权 / 模型不存在 / 不可达 / 超时)各自
一句文案 + 目标与耗时。

取舍三条:

1. 读写走 `core::setting` 的 schema API(`SettingStore::load` 现读 → `set` → `save`),
   不经 `AppCommand::SetSetting`——那条指令是可序列化 DTO,`ai.api_key` 走它等于让明文进
   事件流/日志。与 `SettingsBridge::persist` 写外观是同一条做法。
2. 「测试连接」不另写一套 HTTP:`unlucky_cli::commands::agent::probe` 现读设置文件、
   交给 `RigProvider`(真 provider)发一次最小补全,带 8 秒时限;分类只看错误文本
   (rig 只给 Display),四类各有一条真 TCP 假服务器用例钉住。
3. `ProviderKind` 补 `from_setting` / `setting_value`:serde 的 `rename_all = snake_case`
   产出 `open_ai_compatible`,与 schema 的 `openai_compatible` 不是同一个词——这正是
   M174 之前「agent 侧一次都没读过设置」的根因。

守卫(都做变异验证,见 mission 报告):密钥不出 `Debug`/`detail`/DTO;未配置时工厂构造
计数为 0;改完 base_url 后探测打到新地址;空/非法值被 schema 拦下且文件不动。

i18n:49 条中英各一(金表按 `Key::ALL` 位置插入);presenter 的 ai_group 补上 schema 允许
的 `disabled` 选项与一行「已配置 N 字符」的只读状态(仍然不显示值)。
2026-11-08 15:57:25 +08:00
AskaEth 82f3bbbff6 Merge branch 'feat/m173-scrollfit-settings-scrollbar-beside'
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-08 15:28:14 +08:00
AskaEth 28762ec268 fix(m182): 判据断言一律显式传 HostOs,ubuntu 门禁在容器里前后对照为绿(复审 P1-1 / P2-1)
复审 P1-1 实测:`corner/tests.rs` 里三条用例走宿主绑定的 `plan_corner()` 却断言 Windows
的结果——本机(Windows)绿,合进 main 后 `ubuntu-latest` 的 test job 会当场红。已修:

- 三条改为 `on_windows(...)` → `plan_corner_for(policy, HostOs::Windows, …)`:
  `the_win11_boundary_is_inclusive`、`the_log_line_carries_every_field`、
  `the_log_line_shape_is_pinned_with_the_measured_readings`;
  去重那条(`the_same_policy_is_dispatched_only_once`)也一并显式传 Windows。
- 宿主绑定的 `plan_corner()` 现在只剩**产品路径**(`app/update/appearance.rs:51`)与
  **一条**用例 `the_production_entry_forwards_to_the_pure_judgement`——后者两边都用同一个
  `HostOs::current()`,所以任何宿主都成立,它证明的是「入口确实转发」,不是某个平台的结果。
- m116 探针(`cfg(windows)` + `#[ignore]`)也改成显式 `HostOs::Windows`:那是真窗口实验,
  不该让「这台机器是 Windows」成为隐式前提。产物那一行逐字不变。

复审 P2-1:`unknown-os` 此前没有任何断言。新增表测试
`every_exclusion_code_is_a_stable_non_empty_string`,把七个原因码逐个钉住(含 `unknown-os`),
并断言非空、无空白、两两不重复、总数 7。另在 `the_log_line_carries_every_field` 里补
`host=windows` + `os=unknown` + `excluded_reason=unknown-os` 同现。

证据(`packaging/linux-check-docker.sh`,容器内 ubuntu 真实复跑,非推理):
- 修前(把 tests.rs 回退到 0451b3f):`test -p unlucky-app-iced --lib` → **3 failed**
  (正好是复审点名的三条),exit=1;
- 修后同一命令:**685 passed / 0 failed**,exit=0;
- 最终树跑完整两条 CI 门禁:`cargo clippy --workspace --all-targets -- -D warnings` exit=0、
  `cargo test --workspace --all-targets --no-fail-fast` exit=0(53 个测试二进制,合计
  passed=2672 / failed=0)。
本机(Windows)同树:clippy exit=0、`test -p unlucky-app-iced --lib` 686 passed、fmt exit=0。

`decorations` 取值未动(分支 vs 基点仅新增注释行);非 Windows 的 `os=` 仍为 `n/a`,
Windows 上「真的读不到版本」仍叫 `os=unknown`,但同一行有 `host=windows` +
`excluded_reason=unknown-os` 自解释。
2026-11-08 15:18:23 +08:00
AskaEth 037c68d0e6 fix(m173): 设置页滚动条并排在内容右侧(右侧恒留 10dp 槽)
根因(读 iced 0.14 源码,不是猜):`scrollable` 的竖直滚动条是浮层——
`iced_widget-0.14.2/src/scrollable.rs:1970-1980` 把轨道定位在
`bounds.right - (width.max(scroller_width) + 2*margin)`,而同文件 `:437` 给内容的横向
约束是整个视口宽(`Scrollbar::spacing` 默认 None ⇒ 右内边距恒 0)。本仓 6 轨 + 2×2 内缩
= 10dp,于是这 10dp 整条压在表单列内容上(1180 与 880 宽窗口都一样)。

改法:`components::scroll::vertical()` 在内容右侧恒留一条 `轨宽 + 2×margin` 的槽,轨道画在
槽里;`pages/settings/frame.rs::scroll` 改成一行转发。不采用 iced 的 `Scrollbar::spacing`,
因为它只在滑块可见时才预留(`scrollable.rs:478-500`),长/短页面切换内容会横跳 10dp。

数值(1180×760、100% DPI、tiny-skia、实测像素):
- 卡片右缘 799 → 789(内容可用宽 424 → 414 = 424 − 10);
- 滑块仍是 792..797(没有移动),与内容之间隔 2px 页面底色(= SCROLLBAR_MARGIN);
- 长页面(外观组)与短页面(Java 组,无滑块)左边界 376、宽 414 **逐项相同**;
- 150% 缩放下 621px = 414×1.5、滑块 9px = 6×1.5、槽 15px = 10×1.5,同比例不重叠。

守卫(两条都是数值,不看图):`components/scroll.rs` 与 `pages/settings/tests/scroll_fit.rs`
用 `Renderer = ()` 真跑 iced 的 `layout()`,断言「内容宽 == 容器宽 − 槽宽」且长/短两态一致;
把 `beside` 的 padding 改回 `Padding::ZERO`,4 条断言立刻红(424 vs 414)。
2026-11-08 15:11:54 +08:00
AskaEth 0451b3f972 fix(m182): 非 Windows 圆角判据回答「由谁负责」,并给 macOS 直角三条源码级证据
用户第三轮 Mac 验收看到四角直角 + `source=square os=unknown`。原来非 Windows 一律
落到 `UnknownOs`,把「Windows 版本读不出来」与「这台根本不是 Windows」糊成同一格。

- `platform/host.rs`:新增 `HostOs`——平台族是一个**值**而不是散落的 `cfg`。
  这样三行真值表能在本机(只有 Windows)一次验完,`cfg` 只留在真要 FFI 的地方。
- `plan_corner` 拆成 `plan_corner_for(policy, host, os, backend)` + 保持原签名的薄包装
  (`app/update/appearance.rs` 在 scope 外,调用点一字未改)。
- `CornerSource` 加第三档 `Compositor`(跟随合成器,我们既不下发也不自绘);
  `CornerExclusion` 加 `borderless`(macOS)、`client-side`(Linux)、`unsupported`。
- 日志加 `host=`,非 Windows 的 `os=` 由 `unknown` 改 `n/a`(那栏是 Windows 版本,
  在 macOS/Linux 上不是「没读到」而是「跟本机无关」)。
- `platform/macos/mod.rs`:macOS 为什么是直角的**三环证据**(我们的 `decorations: false`
  → winit `Borderless`(无 `Titled`)→ 窗口服务器只裁 titled 窗;winit macOS 后端
  `cornerRadius` 命中 0),以及「迁移原生标题栏」的未实现配方与阻塞点(iced 0.14 未转发
  winit 的 `titlebar_buttons_hidden`,缩放键按不掉)。
- `platform/linux/mod.rs`:xdg-shell 无圆角概念、wl_surface 区域只能是矩形、XShape 只能
  切硬边 ⇒ CSD 窗没人替我们裁,D46 排除自绘 ⇒ 直角。没把 DWM 判据搬过去(单测钉着
  「非 Windows 不许出现 dwm」)。
- `design/shape.rs`:删掉「macOS 走 `layer.cornerRadius`」这句过期描述(D46 不许自绘裁切)。
- `dwm.rs` 的内联测试挪到 `dwm/tests.rs`(原文件 398 行,加第三档映射会顶破 400 行红线)。

验证:cargo fmt --check / clippy -D warnings / `cargo test -p unlucky-app-iced --lib`
684 passed;三次定向变异各自变红(macOS 行改回 Square/UnknownOs → 4 红;非 Windows
`os=` 改回 unknown → 1 红;Linux 原因码复制成 borderless → 4 红)。
Windows 侧无语义改动;`cargo test --lib m116 -- --ignored --nocapture` 复跑读回仍是
`pref=2 readback=? read_hr=0x80070057`(写入被接受、回读不可用),**不作为「圆角已生效」的证据**。
2026-11-08 14:50:15 +08:00
AskaEth da5b837c7c Merge branch 'feat/m168-fontfetch-cjk-font-cdn-download-wir'
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-08 14:38:33 +08:00
AskaEth 460da9330d fix(m168): 复审 r3 四项 —— 说准 panic 档、给两条修复补守卫、时限算术补洞
复审 r3(p2-4items / fix-then-merge)四项 + 顺手五条:

1. **P2-1 `catch_unwind` 在发布档是死代码,三处文档说了不成立的话**:
   `Cargo.toml` 的 `[profile.release]` 是 `panic = "abort"`(只有 `fast` 是 `unwind`),发布走
   `--release`。分两半写清楚:①**闸前移仍安全**——release 档 panic 是进程退出,不存在永久停在
   `Downloading`;`dev`/`test`/`fast` 档 `catch_unwind` 真会写 `Failed`;②但「下载失败不崩」在
   panic 这一支上对发布版**是反的**:注册/`load_font` panic ⇒ 启动器整体 abort,不是降级。
   `install.rs::plan_with_offline` 的文档(新增两档对照表)、`worker.rs` 模块文档与 `run` 注释全部
   按这个说法重写,不再用 `catch_unwind` 给「不崩」背书。

2. **P2-2 「启动不发起下载」这条修复没有守卫**:拆出纯函数
   `fonts::resolve_with_faces(setting, faces)`(`resolve_with` 只是 `system_faces()` 的包装),
   新增 `fonts::tests::resolving_fonts_never_starts_a_download`:给一张只有拉丁族的脸表
   (一定判 `NeedsDownload`),断言调用后 `install::status()` **不是** `Downloading`。
   变异(实跑):把 `install::after_probe(cjk_report.strategy)` 加回 `resolve_with_faces` ⇒
   `求值字体**不许**发起下载(状态从 Unknown 变成了 Downloading { bytes: 17772300 })`,红。

3. **P2-3 时限算术有洞 + `is_last` 没守卫**:改 `ATTEMPT_DEADLINE=60 s` / `TOTAL_DEADLINE=420 s`
   (原来 120/360 时,第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 72.3 KiB/s,r1 实测
   67 KiB/s 需要 259 s,仍然必失败)。文档按新数值重算成一张表:第一个候选瞬间失败 ⇒ 420 s ⇒
   42.3 KiB/s;爬满 60 s 被切 ⇒ 360 s ⇒ 49.4 KiB/s,两者都低于实测最慢的 67 KiB/s。
   守卫:把同解的旧用例换成 **单候选 + attempt 300 ms / total 5 s**——有 `is_last` 时跑到总时限
   (≈5 s),没有时 300 ms 就切,两种实现可分辨。变异(实跑):删掉 `is_last` ⇒
   `the_last_candidate_gets_the_whole_remaining_budget` 红在
   `最后一个候选该拿整个剩余预算(≈5 s)…452.6035ms`。

4. **P2-4(b)(c)**:①`install::tests` 里三处判据用例改用 `plan_with_offline(..., false)`,把进程级
   `OFFLINE` 从判据里摘出去(否则同进程里 `headless(screenshot_options())` 会把它置 true 且从不
   还原 ⇒ 结果依赖执行顺序);②`fonts.rs` 注释改成真实机制——出图 harness 不经
   `App::with_presenter`、压根没有 `set_offline`,它零流量靠的是「`resolve_with` 不触发」。

顺手:①`sha256_mismatch…` 的 doc 改成「两层校验互备,本用例抓的是两层同时拆」(原来说
「拆任意一层就红」是假的);②`font.rs` 文档「把 90 s 压成 300 ms」→ 60 s/420 s;③`install.rs` 与
`catalog/font_install.rs` 的「≈17 MB」→ 16.9;④`worker::start` 的裸 `thread::spawn` 换成
`Builder::spawn`,起不来就落 `Failed`(发布档 panic = abort,不能让它 panic);线程体抽成 `run()`。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,取退出码、不截断):
- `cargo test -p unlucky-app-iced --lib` → **688 passed / 0 failed / 2 ignored / exit 0**
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓无文件 >400;本轮改动文件最大 378(`app-iced/src/fonts.rs`)
2026-11-08 14:38:25 +08:00
AskaEth b9009c0e61 fix(m168): 复审 r2 三项 —— 夹具不再空转、启动不再替英文用户下载、时限不再判慢线下不完
复审 r2(p2-3 / fix-then-merge)三项:

1. **P2-1 爬行服务端是空转的**(`font/tests/support.rs`,从 `tests.rs` 拆出守 400 行):
   `seen.windows(4).any(|w| w == b"\n\n")` 拿 4 字节窗口比 2 字节切片 ⇒ `PartialEq` 先比长度 ⇒
   恒 false ⇒ 请求头永远读不完、一个字节不发,用例证明的其实只是「连接挂着被掐断」。上一版
   这里是 Python 生成时把 `\r\n` 落成了真实换行字节。现在两处都是转义写法
   (`b"\r\n\r\n"` + 完整 CRLF 响应头),并且**把「夹具真的在说话」变成断言**:
   `serve_trickle()` 返回 `TrickleProof { header, dripped }`,用例断言「真的发过头」「真的挤过
   字节」——否则夹具再坏一次也会绿。拆成两条用例:`a_trickling_candidate_is_cut_by_the_attempt_deadline`
   ([爬行, 正常],300 ms 切断第一个后第二个成功,0.38 s)与
   `the_total_deadline_cuts_a_single_crawling_candidate`(单候选 + 400 ms 总预算)。
   变异证明:把服务端改成「一个字节都不发」⇒ 两条用例红在「爬行服务端必须真的发出响应头」。

2. **P2-2 启动替英文用户下 17 MiB(真行为 bug)**:
   - `fonts::resolve_with` **不再触发下载**(启动那一拍只报告判据)。唯一的触发点收在
     `fonts::cjk_for`(`ui/lang.rs::log_cjk`),那一拍的 `setting` 与 `LangChoice` 同源
     (ULL_LANG > 配置 > 系统 locale)= 「界面实际用哪种语言」。
   - `plan` 把 `is_downloading()` / `Ready` 的闸提到**所有策略之前**:上一版只在 `NeedsDownload`
     分支里,"下载中 + Unprobeable" 会把状态改回「无法探测」而线程还在飞,下完又翻回「已就绪」。
     当时那条理由(「否则界面停在正在下载」)只对僵死成立 ⇒ worker 现在用 `catch_unwind`
     兜底「任何 panic 都写终态」,闸才敢前移。
   - 出图通道:新增 `install::set_offline(true)`(`App::with_presenter` 在
     `options.screenshot.is_some()` 时调),`plan_with_offline` 在 offline 下永不 `Start`。
     于是 `pages/harness/app.rs` 与 `--screenshot` 都不会在 zh-locale 无字体机器上真下 17 MiB。
   - 被钉住的旧判据(`Unprobeable + Downloading ⇒ Apply`)改成 `Skip` 并写明旧行为为什么是错的;
     新增 `every_strategy_skips_while_a_download_is_in_flight`(四策略打表)与
     `the_screenshot_channel_never_starts_a_download`。

3. **P2-3 90 s 单次上限把「慢但能下完」变永久失败**:改 `ATTEMPT_DEADLINE=120 s` /
   `TOTAL_DEADLINE=360 s`,并且**最后一个候选拿整个剩余总预算**(前 n-1 个仍 `min(单次, 剩余)`)。
   算账写在常量文档里:17 772 300 B / 360 s ≈ 49 KB/s,比 r1 实测最慢那条 IPv6(67~77 KB/s)
   还低一档 ⇒ 慢而能下完的线路仍有一次成功机会;健康线路(1.1~16.6 s)在第一/第二个候选就结束。
   代价(最坏 6 分钟)也是刻意取舍,写在同一处。

顺手:`i18n::CjkFont::source` 的「尚未钉死」改成指向 `Source::Pinned`;`clock.rs` 与
`general_section.rs` 注释里的 17.0 MB → 16.9 MB。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,全量重编;取退出码,不截断):
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
  (lib 625 + setting 115 + 其余 14 个)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo test -p unlucky-app-iced`(含 `no_hardcoded_tokens` integration)→ **693 passed / 0 failed / exit 0**
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓受检文件 ≤400(本分支最大 361)
2026-11-08 14:38:25 +08:00
AskaEth 875b792206 fix(m168): 复审 r1 五项 —— 修红的 integration target、独立失败域、整体时限
复审 r1(p1-1/fix-then-merge)五项全改:

1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
   scope 漏掉了覆盖 schema 版本号的目录式 target):
   - `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
     `ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
   - `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
     1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
     这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
     守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
   换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
   (Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
   并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
   `TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
   `min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
   `a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
   顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
   现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
   那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
   「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
   `Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
   `Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。

次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。

验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
  `setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
2026-11-08 14:38:23 +08:00
AskaEth 449ded2cb7 feat(m168): 中文字体 CDN 下载真接线(D28 后半)
M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。

判据 → 下载 → 注册(一条链,四个 crate 各管一段):

* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
  钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
  17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
  `FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
  sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
  缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
  可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
  (英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
  族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
  `install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
  原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
  视图模型,所以那一行会自己刷新。

配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。

守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。

测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
2026-11-08 14:38:23 +08:00
AskaEth 2a004a5754 ci(m171): 两个 windows 作业显式豁免跨卷守卫
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
M171 合并后,跨卷守卫在只有一块系统卷的 runner 上会判红(这是设计:
跳过必须是响的)。runner 是否有第二块可写卷没有核实手段,因此不赌,
显式给 ULL_XDEV_TEST_WAIVE=1;该豁免是惰性的,探到第二块卷时不参与
判断。补丁位置属 M171 scope 之外,由 tower 在合并窗口内一并落地。
2026-11-08 13:52:35 +08:00
AskaEth 9700de1bd4 Merge branch 'feat/m171-xdevreplace-fix-cross-volume-update' 2026-11-08 13:51:03 +08:00
AskaEth ef77c67d3c fix(m171): r2 —— 跨卷守卫不许静默跳过,崩溃现场不删唯一旧副本
按首轮复审 p2-1items 的三条就地修。

P2-1(唯一阻塞项)跨卷守卫在只有一块卷的机器上静默变 ok:探不到第二块卷时
是 `eprintln! + return`,libtest 默认捕获 stdout/stderr,于是默认输出只有
`... ok`,跳过的记录只有 `--nocapture` 才看得见(复审 M3 变异实测)。
按复审给的方案 (b) 把裁决收成一条常跑用例(本仓口径同
`options_shot/feed.rs::not_fed`:跳过必须是响的):

  探到第二块卷            → 绿(真跨卷用例真跑)
  探不到 + ULL_XDEV_TEST_WAIVE=1 → 绿(显式、记录在案的降级,打印一行)
  探不到 + 没豁免         → 红(默认判红,失败正文给出三条出路)
  另有 ULL_XDEV_TEST_REQUIRE=1:声明「必须有」,与豁免同给时以它为准(不许自相矛盾)

裁决是纯函数 `xdev_guard(found, waiver, require)`:逐条用例走同一个入口
(`cross_volume_scene`),没人能靠 `return` 静静跳过;core 与 CLI e2e 两处政策一致
(源头在 `install_xdev_cases`,CLI 侧 `support::cross_volume_waived` 照抄并注明)。

CI 选择:**windows 作业显式声明豁免**(不挂 VHD)。理由:VHD 要多一步 diskpart、
要管盘符、易碎;豁免是一行 env、写在 ci.yml 里可见,且只在探测失败时被咨询——
将来 runner 真有第二块盘,真跨卷用例照样真跑。ubuntu 侧不豁免:`/tmp` 与
`/dev/shm` 是不同设备(本机 docker debian:bookworm-slim 实测设备号 119 / 158),
真跨卷守卫在 Linux 上应当是真跑的。ci.yml 不在本 mission 的改动范围,已用
TowerFinding 登记那一行补丁。

P3-1 `update_e2e_test.rs` 里 `install::prune_stale_backups` → `prune_stale_artifacts`。

P3-2 `prune_stale_artifacts(None, dest)` 会删掉崩溃现场唯一幸存的旧 exe:上一次装到
「dest → .ullold-*」之后被杀时,dest 空着、旧字节只剩备份那一份;下次成功安装时
`symlink_metadata(dest)` 失败 ⇒ keep=None ⇒ 连它一起删,用户失去手工恢复的可能
(修前不会删)。改成 keep=None 时**只清 `.ullnew-*`、一个 `.ullold-*` 都不动**,
模块文档补上这条例外与其来由。

验证(本机,CARGO_TARGET_DIR=C:/Temp/ull-target/wt-171):
* `cargo test -p unlucky-core --lib update::tests` → 90 passed / 0 failed
* `cargo test -p unlucky-core` → 623 passed(+ 各集成目标全绿),exit 0
* `cargo clippy -p unlucky-core --all-targets` / `-p unlucky-cli --all-targets
  --features update-test-source` → exit 0
* `cargo test -p unlucky-cli --features update-test-source --test update_e2e_test
  --test update_e2e_tamper_test` → 3 + 4 passed,含 `installs_across_volumes_from_the_cli`
* fmt clean
变异实测(都验完再还原):
  M3 让 probe 恒 None ⇒ 默认 `cargo test` 输出 2 failed(无须 --nocapture),
     正文「没有第二块可写卷,也没有显式豁免:跨卷守卫在这台机器上是空跑。」
  M3 + WAIVE=1 ⇒ 3 passed;M3 + REQUIRE=1 ⇒ 2 failed
  去掉 keep.is_some() 那一半 ⇒ `a_crashed_install_keeps_the_only_surviving_old_copy` 红
2026-11-08 13:34:29 +08:00
AskaEth 36ff193609 fix(m171): 跨卷替换改成「目标同目录中转名 + 同卷原子改名」
已发布版本上的真 bug:暂存包在 <数据根>/cache/update/staging,目标 exe 在安装目录,
两者不同卷(便携版放 U 盘 / 数据根在 D 盘而程序在 C 盘 / 部分 macOS 配置)时
`rename(staged → dest)` 直接报 EXDEV / ERROR_NOT_SAME_DEVICE(io::ErrorKind::CrossesDevices),
更新必然失败,而 is_transient 不含 CrossesDevices,也没有任何降级路径。

复现(本机真跨卷:C:/Temp NVMe ↔ D:/Temp 机械盘,原实现):
  InstallFailure { error: UpdateError { kind: Io, message: "系统无法将文件移到不同的磁盘驱动器。
  (os error 17)", path: Some("D:/Temp\ull-xdev-…\app\ull.exe") }, rolled_back: true }
失败点就是 `rename(暂存包 → dest)`;唯一调用方是 core::update::job::Job::apply(CLI/GUI 共用)。

修法(不把原子替换退化成可能留半截的复制):
  0. 新包 → <目标同目录>/.<文件名>.ullnew-<pid>-<seq>  同卷 rename;跨卷才 copy 到该中转名,
     并核对写出字节数(少了直接判失败)
  1. dest → <同目录 .ullold-…>                         旧版本先安全落地
  2. rename(中转名 → dest)                             同卷,原子
  3a. 成功 → InstallReceipt;3b. 失败 → 回滚 + 清中转名
目标路径上的新字节只来自一次原子命名,任何时刻都不是半截文件;同卷路径一个字节都不多搬
(注入用例断言 copies == 0,rename 恰好 3 次)。

备份清理策略一并定死:成功后只留最近一份 .ullold-*(更早的与残留的 .ullnew-* 删掉,
别的程序名的备份不碰)。旧行为是每次 apply 留一份、全仓无人调 remove_backup ⇒ 无限堆积。

顺手把文件动作抽成 replace_io::ReplaceFs(真实现 = std::fs),让「跨卷被拒」「复制到一半
失败」「改名失败」成为确定性输入;判据与顺序仍在 install 里,注入改不了。

守卫(都做过变异实测,均能变红):
  * install_xdev_cases:真跨卷安装成功且目标字节完整(去掉降级 → 红,报 os error 17)
  * install_io_cases:跨卷降级只复制一次 / 半截复制后目标仍是旧字节且无 .ullnew- 残留 /
    短复制被字节数判据拒掉 / 最后一步改名失败回滚并清中转名 / 同卷 copies == 0
  * cli update_e2e_test::installs_across_volumes_from_the_cli:数据根一卷、安装目录另一卷,
    整环从 CLI 入口跑通(去掉降级 → 红,输出 update_apply_failed + os error 17)
2026-11-08 13:06:57 +08:00
AskaEth ab79a28b28 Merge branch 'feat/m169-updatee2e-update-channel-end-to-end'
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-08 11:31:48 +08:00
worker-updatee2e d7adbd1172 fix(m169): r3 四条就地修完 —— 只认同级 name:、纳入 .cargo/config.toml、登记 env 交接边界、校准两处数字
合并门禁只认 clean,r3 的 p2-4items 就地修掉(无行为改动,判据更严,不放松任何一条)。

P2-1(一行 + 三向量)`_name_of` 现在只认**同级** `name:`(`item.end() == key_indent`):更深缩进的
`- name:`(`env:`/`with:` 子列表、`run: |` 块标量里的行)不再被当成项名;同一项里出现两个**不同**
的 `name:` 返回 `None`(无法判定 ⇒ 默认拒绝,不去猜 YAML 重复键语义)。新向量:name 藏在 env 子项 /
藏在 `run: |` 里 / 两个不同 name ⇒ 必须红。

P2-2 `.cargo/config*.toml` 进扫描面(`CONFIG_GLOBS`:根与 `crates/*` 下的 `config.toml`/`config`),
新增 `scan_cargo_config`:feature 字面量(覆盖 `--cfg feature="…"`、`[env] RUSTFLAGS`)走原有判据,
`rustc-wrapper` / `rustc-workspace-wrapper` **一律判红**(wrapper 注入什么由程序决定,文本扫描看不见;
合法用途如 sccache 需要 review 明确同意,不能顺手加)。新向量:config.toml 用 `--cfg` 开该 feature
⇒ 红;设 rustc-wrapper ⇒ 红;普通 config.toml ⇒ 绿(防误杀)。

P2-3 运行期 env 交接写进守门自己的「抓不住」表(脚本头 + README §14):第一步把 feature 写进
`$GITHUB_ENV`、第二步只用 `--features "$FEAT"`——第二步没有字面量,**任何静态扫描都看不见**,这是本
守门的固有边界(引 YAML 解析器也修不了,因为这是数据流不是结构),不是漏判。

P2-4 两处过期数字:`release-guard.yml` 的「注入 14 条」→ 26 条注入 + 6 条防误杀/放行(另加基线,
条数以 `--self-test` 输出为准);README 的「26 条:21 红 + 6 绿」→「32 条向量:26 红 + 6 绿 + 基线」。

守门脚本 399 行(≤400,仍零第三方依赖);自测数据 32 条向量在 `packaging/tests/guard_vectors.json`。

验证(本机,未跑全量 cargo —— tower 已在合并态跑过 workspace 全量 EXIT=0/2648 passed):
- `--self-test`:26 条注入全红 + 7 条基线/防误杀绿,exit 0。
- **真仓注入 8 组**(改真 ci.yml / 新建真 .cargo/config.toml,改完即还原):env 子项里的 name ⇒ 红;
  `run: |` 里的 name ⇒ 红;两个不同 name ⇒ 红;`--cfg feature=` ⇒ 红;`rustc-wrapper` ⇒ 红;
  普通 config.toml ⇒ 绿;r2 的无名步骤 / flow 步骤回归 ⇒ 仍红;还原后基线绿。
- 正式扫描(真仓)exit 0;全仓 400 行扫描无超限;改动文件全 LF。
2026-11-08 11:27:21 +08:00
worker-updatee2e 68a1f90828 fix(m169): 复审 r2 —— 守门归属改成「列表项结构判定 + 默认拒绝」,堵掉无名步骤/无名 job/flow 步骤的继承放行
r2 复审(reviewer-updatee2e-r2,p2-4items;被抓出一个真洞,mission 翻回 active)逐条落地。

P2-1(本轮核心,真洞)allowlist 的归属原来按「最近一行 `- name:`」切块,于是任何不写成
`- name: <x>` 的步骤都继承上一块的名字;被放行的 `update-e2e` 恰好是 ci.yml 里最后一个
`- name:`,所以追加到文件末尾的一切都落在它的块里。复审三条真实注入(无名 `- run:`、无名步骤的
整个 job、flow 风格步骤)当时全绿。现在:
- `split_items()` 按 YAML **列表项**切块(每个 `- ` 起一项;比当前项深的行归当前项;同级或更浅的
  非列表行**结束**当前项)。项名 = 该项内**同级**的 `name:` 键(键序无关;`run: |` 块里的
  `name: dummy` 不算;`run: echo "name: x"` 也不算)。
- **默认拒绝**:落不到列表项上的行(`jobs:` 之前的孤儿行、job 级 `run:` 这类项外键)带 feature 即红;
  没有 `name:` 的项带 feature 即红。不再有「继承上一块」这回事。
- 三条复审注入 + 本轮新增三条(`jobs:` 之前的孤儿行、`run: |` 里夹带并伪造 name、shell 变量拼接)
  都进了自测向量;在**真仓库文件**上重跑也是全红。

P2-2(同族误杀):
- 键序无关:允许的那一步把 `run:` 写在 `name:` 之前仍绿(旧实现会报到上一块的名字上)。
- `cargo hack check --all-targets` 不再被误判成 `--all`(正则收紧到 `--all(?![-\w])`)。
- Cargo 的 `update-test-source = [ … ]` **跨行声明**不再被误判成「写进了别的 feature 列表」
  (`scan_manifest` 增加 `in_declaration` 状态)。

P2-3 `Fixture::run` 的第二条断言比错对象:原来比夹具字段 `self.dest`,手写
`fx.run(&["apply","--dest","<测试二进制>","--json"])` 两条都过。现在从 `args` 里解析真实 `--dest`
值(含 `--dest=` 形式)再 canonicalize 比较;夹具字段那份作为额外一道保留。

P2-4 `release-guard.yml` 的步骤级注释还停在 r1 的扫描面:改成「含复合 action / packaging 递归 /
Makefile 等 + 归属按列表项判 + 唯一允许 ci.yml 的 update-e2e 一步」。

自测数据搬到 `packaging/tests/guard_vectors.json`(基线 8 个文件 + 26 条向量:21 红 / 6 绿),
脚本本身从 379 行降到 373 行(守门仍 ≤400);README §14 补上归属规则与「抓不住」清单。

验证(本机,全部 exit 0):
- 守门正式扫描绿;`--self-test` 21 条注入全红 + 6 条基线/放行绿;向量文件缺失时 exit 1(不静默)。
- **真仓注入**(在真 ci.yml / release-build.yml / cli/Cargo.toml 上改完即还原):复审三条(无名步骤 /
  无名 job / flow 步骤)全红;本轮新增三条(孤儿行 / `run: |` 夹带 / shell 拼接)全红;
  三条合法写法(键序、`--all-targets`、跨行声明)全绿;还原后基线绿。
- E2E:debug 6 绿、release 6 绿(`running 3 tests` ×2,0.26s / 0.23s,非 0 measured)。
- `cargo clippy -p unlucky-cli --all-targets --features update-test-source -- -D warnings` 干净;
  `cargo fmt --all -- --check` 干净;全仓 400 行扫描无超限(守门 373 / 向量 194 / ci.yml 317)。

约束遵守:未 push;`PUBLISH_SLOTS` 只列 `windows-x64`(未动);release 侧仍无该 feature;跨卷 bug 未碰。
⚠ 仍未验:CI 里 E2E 真跑起来本机验不了(只证明 workflow 写对 + 守门放行,首跑要等推送后看 Actions)。
2026-11-08 11:12:11 +08:00
worker-updatee2e 28bf104041 fix(m169): 复审 r1 五条 p2 —— 守门开一个极窄 allowlist 并把 E2E 真接进 CI
r1 复审(reviewer-updatee2e-r1,p2-5items,均不挡合并)逐条落地:

P2-3(本轮主产出)守门把「E2E 进 CI」这条路堵死了:`scan_enabling_text` 对 workflow 里任何该
feature 字面量判红,于是 `cargo test --release … --features update-test-source --test
update_e2e_test` 这种**合法调用**也红,自己登记的 follow-up 无法实现。现在:
- `ALLOWED_STEP = {workflow: .github/workflows/ci.yml, name: update-e2e,
  must_contain: --test update_e2e_test}`;按 `- name:` 把 workflow 切成步骤块再判,文件名 / 步骤名
  / 命令行**三者任一不符即红**(job 名不参与判据:改 job 名不该解锁新能力)。
- `ci.yml` **新增一个独立 job** `update-e2e`(windows-latest、timeout 45m、release 档;现有 job
  一行未动,纯追加 27 行)。只在 Windows:夹具的「真形状」是裸 `.exe` + `portable-exe`,Linux/macOS
  的真实产物是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` 直接拒),在 ubuntu 上跑绿
  是假绿。不降 debug:`debug_assertions`/优化/panic 行为都不同。
- self-test 覆盖 allowlist 六向量(允许 / 换文件 / 换步骤名 / 缺 `--test` / 发布工作流 / ci 内另一步)。

P2-2 守门 8 条绕过:注释剥离改**引号感知**(`echo "#"; cargo …` 不再吃掉真命令);扫描面扩到
`.github/actions/**`、`packaging/**` 递归子目录、`Makefile|justfile|Taskfile` 第二构建入口;
`--all-features` 之外补 `cargo hack --feature-powerset|--each-feature|--all`。大小写/下划线变体按
复审结论写进「抓不住」表(cargo feature 名敏感,写错编不过,不算漏检)。

P2-1 GUI 人工验收命令根本跑不起来:`-p unlucky-launcher-iced` 是 bin 名,改成 package 名
`-p unlucky-app-iced`(app-iced / presenter 两处,并写明 `-p` 只认 package)。

P2-5 夹具缺「不给 --dest 就 panic」的结构性保险:`Fixture::run` 加两条断言——`apply` 必带
`--dest`、`--dest` 不许等于本进程 exe 的 canonical 路径。抓得住「以后有人手写 fx.run(&["apply"])」,
抓不住「绕开 Fixture 直接调 unlucky_cli::run」。

P2-4 夹具「真形状」只在 Windows 成立:写进 `tests/support/update_e2e.rs` 文件头(说明 Linux 上跑绿
是假绿、CI 因此只在 Windows),**不改断言**。另按复审 ⑤ 补一行:失败路径三例抓不住回滚/备份
(摘要失败发生在 install_staged 之前),回滚靠正路备份断言 + install_cases。

跨卷替换 bug 按 tower 指令**未动**(另开 mission)。

验证:守门正式扫描 + self-test 绿(14 红 / 3 绿);真仓注入 6 组(基线绿、步骤改名红、缺 --test 红、
搬进 release-build 红、改 --all-features 红、还原绿);E2E debug 6 绿、**release 6 绿**;
`cargo clippy -p unlucky-cli --all-targets --features update-test-source -- -D warnings` 干净;
`cargo fmt --all -- --check` 干净;全仓 400 行扫描无超限(守门脚本 379 行、ci.yml 317 行)。

⚠ 未验:CI 里 E2E 真的跑起来这件事本机验不了——只证明 workflow 写对 + 守门放行,首次真实运行要等
推送后看 Actions。
2026-11-08 11:12:11 +08:00
worker-updatee2e 24826c1a7c feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source`
(core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量
(`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段
分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。

- core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成
  纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`);
  `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源;
  不带 ⇒ 环境变量设满也走 GitHub)。
- cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读
  环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。
- 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具
  `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同
  字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现
  新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节
  进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须
  拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。
- 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、
  `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`)
  就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。

验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿;
`cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收);
`cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
2026-11-08 11:12:10 +08:00
AskaEth a14b9e17c1 Merge branch 'feat/m170-zip64gate-make-gate-order-test-real'
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-08 10:55:57 +08:00
AskaEth add7f2e672 test(m170): 改对复审点出的两处证据陈述 + 收紧用例前提(零行为改动)
r1 复审 p2-2items(守卫本身判 clean,四条变异自跑确认有牙)。这一轮**只动注释与测试
前提,产品行为零变化**:`format/write.rs` 一字未改,`probe.rs` 保持上一提交的收窄结果。

p2-1 复审核正的事实:`one_file_over_the_threshold_...` 的 `kind()` **不区分闸的位置**。
它的源是**可读的** 8 KiB 真文件,闸被挪到 `File::open` 之后时写循环第二道闸
`Written::spend`(每块之后 `guard_with`)照样返回 `Limits`——所以它在「闸挪位置」的
变异下仍然绿(我这轮重跑变异 A 也复现:其余 4 条全绿)。原来那句「源文件能读时这里
拿到的是 pack_io」是错的,改成「可读源命中第二道闸」并把顺序的举证责任明确交给
`the_gate_runs_before_any_content_is_opened`。

p2-2 目录长度的实测数字之前钉错了:我早先那条探针用的是**短名**(`f0`..`f39`,7 条
才到 4096、40 条仍 4096),而用例里填的是 `filler-NN`。这轮按用例真实形状重测三组
(`std::fs::metadata`,同一台机器同一 `%TEMP%`):
  短名 `f0`…`f39`:空 0 | 3 条 0 | 7 条 4096 | 20/32/39/40 条 4096
  `filler-NN`:    空 0 | 3 条 4096 | 7 条 4096 | 20 条 8192 | 32 条 8192 | 39 条 12288 | 40 条 16384
两次独立跑同形状结果一致。所以「第几个条目起非零」没有平台无关答案(NTFS 目录索引按
4 KiB 一档分配,跟条目名长度与条数都有关),三处注释改成区间 + 一张实测表,并注明
「只钉 32 个 `filler-NN` 这个形状(本机 8192)」,不再硬凑一个好看的数字。

顺带做掉复审那条「不计入 p2 但建议收紧」:前提从 `declared > 0` 提到
**`limit > 清单 JSON 长度`**(`limit = declared.saturating_sub(1)`,`json_len` 由
`CURSEFORGE.render(&manifest)` 现算,本用例实测 220 字节)。否则某个对 32 个条目报得很
小的文件系统会让用例因「清单自己越线」而假绿。同时把 `declared - 1` 换成
`saturating_sub`,让长度为 0 时走到清楚的 assert 文案而不是整数下溢 panic。
把 `DIR_LENGTH_FILLERS` 临时改成 0 验证过这条 assert 是活的:红在这句上
(`声明长度 0 − 1 = 0 … 必须大于清单 JSON 自身长度(220)`)。

变异复跑(本机 Windows/NTFS,跑完已还原 write.rs):
- A:`preflight` 挪到 `File::open(&source)` 之后 → 新用例**红**(left Io / right Limits),
  其余 4 条仍绿(含上面那条可读源用例,正好印证 p2-1 的更正);
- C:A 之上只留 `expect_err` / `!target.exists()` / `leftovers` → **5 条全绿**,
  结构断言确属同义反复。

门禁:`cargo test -p unlucky-core --lib` 613 passed / 0 failed / 0 ignored;
`cargo fmt --all --check` 干净;`cargo clippy -p unlucky-core --all-targets -- -D warnings`
干净;zip64.rs 345 行(<400)、probe.rs 45 行。
2026-11-08 10:39:28 +08:00
AskaEth 72767067f3 test(m170): 闸顺序用例在 Windows 上也真跑(声明长度自己造 + 反证)
M166 r2 复审的两条「合并后收口」观察:

① `zip64::the_gate_runs_before_any_content_is_opened` 原来量夹具目录的
   `metadata().len()`,量到 0 就 `return`——而本机 NTFS 上空目录就是 0(实测:空 0、
   第 7 个条目起 4096),于是这条「行为证明」在 Windows 上一条断言都没执行。
   现在:先把目录填到长度非零(32 个 filler),拿「声明长度减一字节」当总量上限,
   前提不满足就 `assert!`;单条目上限给 `u64::MAX`,不让 `guard_entry` 当替身。
   另加一条**反证**:同一份夹具把限额放到 `u64::MAX`,必须死在 `File::open`/`read`
   上拿到 `pack_io`——于是上面拿到 `pack_limits` 只能解释为「闸在碰内容之前」。
   同时更正 `one_file_over_the_threshold_...` 里那句过头的注释:`leftovers` /
   `!target.exists()` 在任何失败路径上都成立,属结构侧检,不是「没读一个字节」的证明。

② `probe.rs` 的模块级 `#![allow(clippy::missing_const_for_thread_local)]` 收窄到
   `thread_local!` 内那个 `static` 上(A/B 实测:没带的那组只报一条 lint,带了就干净,
   且不判 unused),模块级 allow 不再替未来的真漏 const 打掩护。

变异验证(本机 Windows/NTFS,跑完已还原):
- 预检挪到 `File::open` 之后 → 本用例红(`left: Io` / `right: Limits`),改前那版全绿;
- 预检改成用 `std::fs::read` 量尺寸 → 同样红;
- 只留结构断言(拆掉 kind/文案/反证)→ 同一变异下**全绿**,证明它们确实区分不了顺序。
2026-11-08 09:54:31 +08:00
AskaEth 5965575210 Merge branch 'feat/m166-zip64fix-shrink-zip64-test-fixture'
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-08 09:30:30 +08:00
AskaEth 9b23221a62 test(m166): 钉住生产 write() 的限额绑定(复审 r1 p2-1)
复审 r1 的变异:把 `format.rs` 里 `write()` 传给 `write_with_limits` 的
`WriteLimits::production()` 换成 `WriteLimits::new(u64::MAX, u64::MAX, entries)`,
**606 条全绿**。原因是夹具缩到 KiB 之后,"生产路径 + 真超限内容"那种顺带守住生产
限额的证法没了,而 `the_production_limits_are_the_shared_constants` 只钉了
`production()` 的**返回值**、没钉 `write()` 的**调用点**。

补法(不动生产签名、不进发布构建):
- 新增 `format/probe.rs`(`#[cfg(test)]`):线程局部的限额记录钩子;
  `write_with_limits` 进门时 `#[cfg(test)] probe::record(limits);` 记一笔。
- `tests/limits.rs::the_production_write_path_hands_the_shared_constants_to_the_preflight`
  走**生产**的 `CURSEFORGE.write`(KiB 级 payload)后断言记到的就是
  `WriteLimits::production()` 的逐字段值,并再钉一遍统一常量。
- 反向验:上述变异现在恰好 1 条红(`总量限额:生产路径传错了`,2026-11-08 实测
  18446744073709551615 vs 536870912);只把 entry 换成 u64::MAX 也红(`单条目限额`)。

附带(复审非阻断观察):
- 观察 2 采纳:新增 `zip64::the_gate_runs_before_any_content_is_opened`,把"闸在读之前"
  从结构证明升级成行为证明——源做成**目录**(`metadata()` 有长度、`File::open` 拒绝访问,
  本机实测 C:\Windows 20480 字节 / crates/core/src 4096 字节),闸若挪到读之后会拿到
  `pack_io` 而不是 `pack_limits`。
- 观察 1 采纳:`tests/limits.rs` 里"改坏常量会在 cargo check 就红"的旧注释改成准确说法
  (E0080 只在测试构建出现,裸 `cargo check` 是绿的)。
- `probe.rs` 里 `#![allow(clippy::missing_const_for_thread_local)]`:clippy 1.99 对已经
  写成 `const { Cell::new(None) }` 的 `thread_local!` 仍报错(最小复现同样红),是误报;
  外挂在宏上的 `#[allow]` 会被判 unused attribute,故用模块级 inner attribute。

cargo test -p unlucky-core --lib:608 passed / 0 failed / 0 ignored(+2 条);
clippy --all-targets -D warnings、fmt --check 干净;全仓 0 个 >400 行文件。
2026-11-08 09:30:26 +08:00
AskaEth 49a2fafe51 test(m166): zip64 夹具改用小 limits,去掉 8.5 GiB 真落盘
`set_len` 造的所谓稀疏文件在 NTFS 上并不稀疏:实测 `SetLength(1 GiB)` 之后
C 盘可用空间正好掉 1024 MiB。于是 `zip64.rs` 那两条用例的声明尺寸
(`u32::MAX + 1` ≈ 4 GiB 加 `3 × 1.5 GiB` = 4.5 GiB)就是 8.5 GiB **真占用**,
再加上 `write/tests.rs` 的 3 GiB 与 `limits.rs` 的三条 64 MiB 级用例,
单次跑的落盘峰值到过 ~12 GiB。

判据本身跟夹具大小无关(吃的是 `metadata().len()`),所以把 limits 做成参数:
`format::write_with_limits` 是 `PackFormat::write` 的实体,生产路径仍只传
`WriteLimits::production()`,测试传 KiB 级小值走**同一条** `preflight` 分支。
`WriteLimits` 的可见性顺势开到 `pub(in crate::addon::pack)`。

GiB 级的**数值判定**搬进 `format/write/tests.rs` 的纯算术用例:>4 GiB(撞 ZIP64
阈值即拒、差一字节放行)与 >2 GiB(M152 那个 2.5 GB 样本现在按统一上限拒),
以及第二道闸 `Written` 在同一批数值上的行为——两道闸吃的都是 u64,不需要落盘。

断言一条没删、用例一条没新加 `#[ignore]`(反而把旧的那条 `#[ignore]` 的
2.5 GB 用例换成会跑的 KiB 级版本,补上 `guard_entry` 分支的端到端覆盖)。

扫描:`grep -rn 稀疏 crates/ packaging/ docs/` 里凡写着"稀疏文件不占磁盘"的
注释一并改成实测口径(含 `addon/temp.rs`),避免下一个人照着再踩一次。
2026-11-08 09:30:26 +08:00
AskaEth 5e1f0868f1 Merge branch 'feat/m165-dlengine-reuse-download-engine-acro'
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-08 09:30:10 +08:00
AskaEth afe169751f fix(m165): forget 之后让自更新也换引擎(复审 P2-1)+ 卡口计数与端到端守卫
复审 P2-1(采「修法 1」,不是只改措辞):`shared::forget` 只让登记处不再持有
那一份,换不掉自更新 `Factory` 里常驻的 Arc —— 于是「重新检测地区」对更新路径
不生效,直到重启。现在设置动作会把 Factory 一起重建。

- presenter/update_state: `Runtime::rebuild_factory` + `CoreHost::refresh_update_factory`;
  只换 factory,不动 view/inflight(在飞的 Job 派发时已克隆走装配,重建不打断它)
- presenter/dispatch: `SetSetting` 成功且键是「重新检测地区」时调 refresh
- presenter/engine: `DETECT_REGION_ACTION` 常量(设置页行 id / set 分派 / dispatch
  判断共用);放这里而不是 settings.rs 是因为后者已到 400 行红线
- core/shared: 文档改准 —— forget 的范围是「登记处不再持有」,长命持有者必须自己重建;
  记账从 `engine_for` 移到 `DownloadEngine::new` 这个卡口(分两把锁,避免自锁),
  于是 `constructions` 连绕过登记处另造引擎的调用点也统得住

守卫(都做了变异实证):
- update_tests::detect_region_rebuilds_the_update_factory_engine:走真实命令路径
  `AppCommand::SetSetting` → 断言 Factory 换了引擎且指向登记处当前实例;变异(删掉
  dispatch 里那句 refresh)→ 红在 Arc::ptr_eq
- update_tests::rebuilding_the_factory_does_not_touch_a_running_job:重建不清
  inflight、不改快照,在飞 Job 仍能跑完
- addon/http::tests::three_host_request_paths_share_one_engine:连走三条真实宿主路径
  (Modrinth 元数据 GET / CF 带 header GET / Java 页渲染)后 constructions == 1;
  变异(让 get_via_engine 自己造引擎)→ 红,left:2 right:1

顺带修:settings.rs 在上一提交被推到 403 行、engine.rs 401 行,两者都超
guards.yml 的 file-size 红线(>400 直接失败)。现在 settings.rs 400 / engine.rs 399,
全仓 `*.rs` 复跑「>400」扫描无输出。

验证:core lib 608 过、5 个 download 集成 target 42 过、presenter lib 270 过、
clippy --all-targets 干净、fmt 干净。真站点仍未打(见报告「没验的部分」)。
2026-11-08 09:30:05 +08:00
AskaEth 46389d61fd feat(m165): 下载引擎按数据根跨请求复用(运行时常驻 + 构造次数守卫)
宿主侧以前每次请求都现建一个 DownloadEngine:连接池、信号量限流、
健康度记忆、内容寻址缓存索引全部清零。现在改成按数据根共享一个实例。

- core: 新增 download::shared(进程级登记处,键 = 缓存根)——
  engine_for / forget / constructions;region/health/cache/mirrors 的装配
  收在这一个 build() 里(持锁构造只做本地 I/O,不联网)
- core: download::blocking 改成进程内唯一常驻运行时。reqwest 的连接池绑在
  驱动连接的运行时上,引擎复用必须配运行时常驻,否则复用的是个装满死连接的池
- presenter: host::engine::engine 返回 Arc<DownloadEngine>(共享实例),
  addon/update/versions/java/authlib 等全部调用点随之改;新增 forget 供
  「重新检测地区」失效(否则进程级引擎下那句话不成立)
- presenter: addon 详情页把「项目 + 版本列表」两次 block_on 收成一次

失败分类 / 镜像回退 / Retry-After 语义未改动(error.rs、http.rs、engine.rs 的
候选循环一字未动)。

守卫(都带变异实证):
- shared::tests::one_engine_per_cache_root + host::engine::tests::
  host_requests_share_one_engine:断言 constructions(root) == 1(不是只看结果)
- shared::tests::pooled_connection_survives_across_block_on_calls:127.0.0.1
  回环服务统计 accept 次数,两次 block_on 必须只拨号一次

验证:cargo test -p unlucky-core --lib(608 过)、5 个 download 集成 target(42 过)、
cargo test -p unlucky-presenter --lib(267 过)、cargo clippy --all-targets、
cargo fmt --check 全绿。真站点(Modrinth/CurseForge/BMCLAPI)未打,见报告。
2026-11-08 09:30:05 +08:00
AskaEth b4e6fbf8c1 Merge branch 'feat/m164-windowsarm64-fix-arm64-build-and-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-08 08:50:21 +08:00
AskaEth 5ff6cdb38b fix(m164): 复审三条 P2 —— vcvarsall 用 wrapper .bat 真抓到 LIB/INCLUDE、ls 进 arm64 分支、两处数字与一句顺序承诺
P2-1(核心:上一轮的 ② 在 git-bash 下是死代码):`cmd //c "\"${vcvars//\//\}\" arm64 …"`
不行——MSYS 把参数里的 `"` 改写成 `\"` 交给 cmd.exe,cmd 的 `/c` 引号剥离接不住,LIB/INCLUDE
全空,只剩 `set` 的空输出;于是 ③「显式 link.exe」在**没有 LIB** 的状态下生效,正是本步注释里
写的那种会造出 `LNK1181: cannot open input file 'kernel32.lib'` 的组合。改法(复审验过的路线):
往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper `.bat`(`call "<vcvarsall>" arm64 >/dev/null 2>&1` + `set`),
`cmd //c` 只收**不含引号**的相对文件名(先 cd 进去)——引号留在 .bat 内部由 cmd 解析,路径带空格
也不受影响。同源的 ③ 也补齐:`vswhere -find` 的 `vcvarsall.bat` 与两个 `link.exe` 候选都改成
「反斜杠形态 + 正斜杠形态各试一次」(和同文件 dumpbin 步同规矩),不再赌一种形态。

P2-2:cargo build 步末尾那条 `ls -l` 移到 `if [ "$SLOT" = "windows-arm64" ]` 门内(并加 `|| true`):
x64 是当前唯一在发的槽位,不给它加一个失败面。

P2-3:① `release-publish.yml` 步数改对——主线 7 步、本分支 8 步(`release.yml` 与
`packaging/docs/build-and-ci.md` 两处都改,并列出 8 步是哪几步);② `check_publish_slots.py`
的 report() 全部改走 **stdout**:注解(`::error::`/`::warning::`)在 stdout 上同样被 Actions 识别,
而分两个流写会让「表格 → 注解」的顺序不可控(原注释承诺的顺序当时并不成立)。顺带在
`build-and-ci.md` 的 arm64 工具链段补一句「`cmd //c` 别收带引号的路径、要 wrapper .bat」,
免得下一个 agent 再把 ② 写回死代码。

本地证据(同一 worktree 复跑):
- P2-1 新证据:把本步 `run:` 块原样抽出,用 `bash --noprofile --norc -eo pipefail`(与 runner 同
  语义)、假 vswhere + **路径带空格**的假 `vcvarsall.bat`(真 cmd)跑:新写法 LIB/INCLUDE/linker
  三行全部写进 `$GITHUB_ENV`、rc=0;同一套假件跑 e858664 的老写法 ⇒ LIB/INCLUDE 全空、只有 linker
  (与复审结果一致)。降级路径也复测:vswhere 不存在 ⇒ 只 1 条 warning、rc=0;vswhere 不给
  vcvarsall ⇒ ② warning、③ 仍拿到 Hostx64 兜底、rc=0。
- 体检步 5 场景(无 VS / vswhere 全成功 / 报错退出 / 垃圾+非零 / 路径不存在)全部 rc=0、0 条
  `::error::`。
- `check_publish_slots.py --self-test` 8 条 + 警告断言全绿;`--dist` 六场景复测(x64-only 绿 /
  多一份 arm64 绿+warning / 缺 CLI rc=1 / 空 dist rc=1 / `--slots` 漂移 rc=1 / `--dist` 非目录
  rc=1),并且缺产物时输出顺序是「表格 → warning → error → 汇总」,stderr 为空。
- 7 个 workflow `yaml.safe_load` OK;全部 `run:` 块 `bash -n` OK;全仓 400 行扫描 0 个超限
  (release-build.yml 316、check_publish_slots.py 345)。
- `check_release_semantics.py`(+`--self-mutation`)、`release_marker.py --self-test`、
  `make_manifest.py --self-test`、`cargo metadata --no-deps` 全绿。

边界不变:arm64 产物仍**从未被构建过、也从未在真机跑过**;`PUBLISH_SLOTS` 仍是 `"windows-x64"`,
发布语义与 x64 静态 CRT 门禁未动。下一次 tag 的 arm64 仍可能红,红点大概率换到 C 依赖编译
(aws-lc-sys 的 CMake + aarch64 cl.exe)或链接器(LNK1181)——**不是「arm64 修好了」**。
2026-11-08 08:45:02 +08:00
AskaEth e858664fca ci(release): M164 arm64 判定权交回编译器 + 发布名单守卫(不翻牌)
根因(拿到的真实证据,不是猜的):v0.2.2(run 42322062879 / job 128248713926)与 v0.2.3
(run 42429017002 / job 128573547829)两份 check-run 注解逐字一致 —— 失败步骤是第 6 步
`Windows ARM64 工具链体检`,第 7 步 `cargo build` 状态 skipped。也就是说 v0.2.1~v0.2.3 五轮
全红都红在一个**编译前的启发式探针**上,真正的编译器/链接器一次都没说话。该探针的结论还与它
自己打印的快照矛盾:`SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,]` 明明在,却
同时报 `kernel32=缺失`(同一个 `find -path` 模式在本地合成目录树上命中)。runner 实况是
Windows 11 Arm64 + Visual Studio 2026 镜像,`VC.Tools.ARM64` 与 `Windows11SDK.22621/26100`
都装好了,M142 的 `setup.exe modify` 反倒返回非 0。

改:
- release-build.yml:探针降级为「只取证」——vswhere 组件查询 + 各目录 `.lib` 计数 +
  arm64 `link.exe` 候选,一条 `::error::` 都不写、永不 exit 1;不再自动补装 VS。红绿只由
  cargo/链接/静态 CRT 导入表门禁决定。cargo build 步给 arm64 显式备齐工具链:
  `rustup target add aarch64-pc-windows-msvc`、`vcvarsall.bat arm64` 取 `LIB`/`INCLUDE`、
  vswhere 定位 arm64 `link.exe` 后导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
  (顺序不能反:只给链接器不给 LIB ⇒ LNK1181 kernel32.lib)。
- 新增 packaging/check_publish_slots.py(344 行,零依赖):名单 ⇄ 产物**两个方向**。
  声明了缺 GUI/CLI ⇒ `::error::` + exit 1;有产物没声明 ⇒ `::warning::` 明说未发布(M142 之后
  这个方向是静默的,裁剪步只 echo 一行 drop:)。另有源码级断言(名单每项必须存在且 mode=ci、
  运行期名单必须等于文件里那行)与 8 条 `--self-test` 注入回归。
- release-publish.yml:裁剪前插一步跑那个守卫,原来内联的齐全性循环搬进脚本(一套判据一处实现);
  重算 SHA256SUMS 的步骤只负责重算。
- release-guard.yml:PR 阶段跑守卫自测 + 源码级断言。
- 文档同步:release.yml / release-publish.yml / release-build.yml 头注释、packaging/README §12、
  §1 矩阵、§3 构建与发布、§9 支持矩阵、§7.5 arm64 取证、release-notes.md、README/README.zh-CN
  的产物矩阵与说明。口径统一为「arm64 构建取证中、未发布」,不写成已支持。

没做的(按 tower 裁决「方案乙」):`PUBLISH_SLOTS` 本轮**不加回** arm64。arm64 从未产出过 PE、
本机也证不了,写进名单就等于拿 x64 的发布去赌它(M142 移出名单正是为避免这个事故)。下一 tag 的
CI 真出包后翻一行即可,守卫与 SHA256SUMS 自动跟着扩。

验证:workflow YAML 全部可被 PyYAML 解析;所有 `run:` 块过 `bash -n`;探针步在本机(无 VS/SDK)
实跑 exit 0 并打出实况注解;守卫 `--self-test` 8 条全绿、正反两个方向都在合成 dist 上实测过;
check_release_semantics.py(含 --self-mutation)/ release_marker / make_manifest 自检全绿;
cargo metadata 正常。**arm64 产物从未被构建过、也从未在真机跑过**——本机 `rustc --target
aarch64-pc-windows-msvc` 直接撞上没有 MSVC 链接器(git-bash 的 GNU `link` 被当成 link.exe),
`cargo check --workspace --target aarch64-pc-windows-msvc` 也死在 aws-lc-sys 的 cc-rs 阶段
(GNU host 上没有该 target 的 C 编译器)。真实结论只能等下一 tag 的 CI。
2026-11-08 08:33:44 +08:00
AskaEth 15a19bb375 Merge branch 'feat/m163-releasesem-stable-by-default-publis'
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-07 19:11:10 +08:00
AskaEth 1d2e84ed61 fix(m163): push tag 默认建稳定版(发布标记语义 + 0.2.3 bump)
发布标记:`release.yml` 的 dispatch `prerelease` 默认值 true → false;`release-publish.yml`
的 create 路径不再塞 `--prerelease`(空串 = push tag ⇒ 稳定版),edit 路径行为不变(空串不碰
标记)。三种触发各创建什么改由 `packaging/release_marker.py` 一张表定义,流水线
`--emit-bash` 出来两个数组直接用,自己不再写 case——v0.2.2 至今 `releases/latest` 404 的根因
就是「push 默认 prerelease」这层叠上「dispatch 默认 true」。

守门:新增 `packaging/check_release_semantics.py`(CI 在 `.github/workflows/release-guard.yml`,
guards.yml 已 398 行不能再挂 job):①标记表逐条对(push tag 的 create 不含 --prerelease);
②把 `--emit-bash` 真灌进 bash 读回两个数组;③publish 的可执行内容里没有 --prerelease 字面量、
必须调用 emit-bash、两个数组各喂 create/edit;④release.yml 的 dispatch 默认值 == false(读
YAML 结构)。`--self-mutation` 注入三项真实回归(默认值改回 true / 塞回字面量 / 表里 push
改成预发布),断言每项都红、基线绿。

版本:0.2.2 → 0.2.3(workspace `[workspace.package]` + 五处 path 依赖 pin + Cargo.lock)。

文档:README 中英两版版本行改 0.2.3,并把本阶段能力补进现有章节(Modrinth 匿名在线内容源、
自更新与稳定通道、整合包导入逐文件决策、实例级启动参数),删掉「没有自动更新通道」「导入只有
两份全局策略」两条过期说法;`release-notes.md` 写 0.2.3 的真实更新摘要(保留 {{VERSION}} /
{{SUMS}});`packaging/README.md` 与 `docs/build-and-ci.md` 里「push 默认仍 prerelease」的旧口径
全部改掉。
2026-11-07 18:56:01 +08:00
AskaEth 5d5fe8aa73 Merge branch 'feat/m162-onlinecontent-modrinth-online-conte'
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-07 17:54:42 +08:00
AskaEth c319f9dbfd fix(m162-rebase): M161 合入后三处越线文件回到 400 行内
rebase 到 0241f79(M161)之后,两侧各加了几行,这三个文件同时越过 400 行硬线
(guards.yml::file-size 会红)。处理原则:**不删语义、只搬位置**。

- `app-iced/src/pages/content/parts.rs` 410 → **240**:把状态层(loading / empty / failed /
  offline / centered / status_body)搬到新文件 `pages/content/parts/status.rs`(200 行)。
  状态层本来就是「一屏只有一个」的独立东西,拆开后 parts.rs 只剩筛选条、列表、图标与排序文案。
- `presenter/src/dto/events.rs` 402 → **399**:`CoreEvent::ContentDetail` 的文档由 4 行压成 1 行
  (字段说明一个不少,`project` 是请求时选中的 id 这句保留)。
- `presenter/src/state.rs` 404 → **400**:把 `ContentFailed` / `ContentDetail` 两个事件的落地
  搬进 `state/content.rs`(`apply_content_failure` / `apply_content_detail`),state.rs 只剩
  一行投递——与既有的 `apply_content` 同一个形状。

`state/content.rs` 58 → 64(两个新方法),仍在 400 内。语义零改动:只是投递点换文件。
2026-11-07 17:37:13 +08:00
AskaEth 47ed270293 fix(m162-r3): 金表条数与内容同源(堵住「少挂一段仍绿」的假绿)+ 补两条覆盖缺口
P2-1(r3 抓到的假绿):`golden::len()` 从「各段 len() 相加」改成 **`rows().count()`**。
r3 实测的变异是:删掉 `rows()` 里的 `chain(tail2::ROWS)` 后 `split_keeps_every_translation`
仍绿——因为条数来自一处手工聚合(仍含 tail2 ⇒ 459 == `Key::ALL.len()`)而内容只遍历
`rows()`(少了 213 行)。现在条数与内容同源,漏挂一段当场对不上。
- 变异复现(删 chain):`rows().count()=246 len()=246 Key::ALL.len()=459` ⇒ 用例
  **FAILED**(`left: 459 / right: 246`,「key 条数与拆分前不一致」);
- 恢复后:`rows().count()=459 len()=459 Key::ALL.len()=459` ⇒ **ok**;
- 断言一字未改(没有放宽成「只比条数」或「只比前缀」)。

顺带三条:
1. `golden.rs` 的 `rows()` 文档补上 `tail2`(并写明「加段必须也 chain 上,条数与内容只能从
   这一条序列导出」),与上一条同族。
2. `online/mod.rs` 模块表里 `[complete]` 的职责改成「按摘要认文件(`identity_of`)」,
   与「补全循环已删、生产在 `import::complete`」一致;批量 POST 那段也点明循环位置。
3. 补两条 pack 用例走不到的覆盖(`import/complete.rs` 的 `#[cfg(test)] mod tests`):
   - `an_entry_without_sha512_is_counted_not_guessed`:有 path、有 downloads、无 sha512 ⇒
     `unfilled += 1` **且一次请求都不发**(mrpack 读者强制 sha512,这条是防御分支);
   - `an_existing_download_list_is_never_extended`:已有直链时 `downloads` **逐字相等**
     (长度 + 内容),不再用「包含」这种测不出重复追加的断言。
   两条都验过能红:把「缺 sha512 不计数」与「总是追加直链」同时变异 ⇒ 两条 **FAILED**;
   恢复后 **ok**。

登记项(r3 指出的 scan 注记)也顺手收了:`Completion::note` 在 `filled == 0` 时改说
「按 sha512 从 Modrinth 补全 sha1:一条也没补到(补不出 N 条)」,并且不再带
「其余条目照旧按『缺省保留』处置」(那是导入场景的尾缀,预检里没有这回事)。用例
`offline_with_nothing_filled_does_not_say_zero_items_were_filled` 与
`import_online::only_a_real_offline_stops_the_completion_round` 都钉住「真断网 + 一条没补到」
既不说「补全了 0 条」也不说「缺省保留」。

全仓行数(guards.yml::file-size 同口径):**0 个超限**。
2026-11-07 17:37:13 +08:00
AskaEth ecd24f0608 fix(m162-r2): 金表沿用主线的拆分边界 + phase/pipeline 回到 400 行内
金表(rebase 到 M161 后重写这一段):**采用 main 的拆分边界**——`golden/tail.rs`
(`InstancesLaunchReset` 之后、`PackTargetNew` 之前)+ `golden/tail2.rs`
(`PackInstanceName` 之后),不再保留本分支原来那套 205/221 的切法。
本分支新增的 6 条 key(`ContentOffline{Title,Body}`、`ContentDetail{Fetching,FetchFailed,
VersionCount,VersionLine}`)按 `Key::ALL` 的顺序并入 main 的 `tail.rs`;
`golden.rs` 的 `rows()` 保持 `GOLDEN + tail + tail2` 三段串联。(`len()` 与内容同源的修正
在下一个提交里。)

其余两处(本来就是我推过线的,现在回到红线内):
- `import/phase.rs` 397 → **400**:只动注释(`Resolved` 的两个摘要字段共用一句、
  `resolve`/`settle` 的文档各并一行),代码语义零改动。
- `import/pipeline.rs` 399 → **400**:把补全注记(14 行)搬到 `import/complete.rs` 的
  `Completion::note(lead)` 旁边,管线只剩一行 `complete_and_note(...)`;
  `scan_pack_online` 也改用同一个 `note("预检前")`——注记文案由两处变一处。

同时删除与 `import/complete.rs` 重复的 `online::complete::{complete_files, CompletionSummary}`
(生产零调用方),只留生产在用的 `identity_of`。
2026-11-07 17:37:13 +08:00
AskaEth b394316df4 fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门
P1-1(p1-1items 的根因):
- download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与
  `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有
  Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」
  取最可诊断的那个(**不从 suppressed 文本猜状态码**)。
- download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。
- online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是
  NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。
- import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开;
  `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。
- import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN,
  Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此
  只会在真断网时出现。

P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用
`core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`;
`HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`,
`InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。

P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回
`content_install_request`,详情方法只留自己那一行。

P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的
项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到
结果清掉。

顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过;
两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的
ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。

用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条
(AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、
单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」
(共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
2026-11-07 17:28:36 +08:00
AskaEth 81278f55af feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态
core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。

core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。

core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。

presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。

app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。

测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
2026-11-07 17:28:36 +08:00
AskaEth 0241f793b2 Merge branch 'feat/m161-uipolish-debts-sweep-update-keys-bu'
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-07 17:27:39 +08:00
AskaEth 79b796fd58 fix(m161-r1): 崩溃/静默失败不再冒充「启动进程失败」;补三处假绿的守卫
复审 P1-1(用户可见假文案):`LaunchEnd` 在 `failure == None`(跑起来之后崩的)时
临时造了 `Stage::Spawn + format!("exit {:?}")`,于是磁贴写「启动失败 · 启动游戏进程」、
详情页漏出 `exit Some(1)`、静默失败丢掉「exit 0 却没起来」,而同一时刻 Toast 说「崩溃」。
- 新增 `LaunchFailureCause{Stage|Crashed|SilentFailure}`(dto/launch_failure.rs),
  `LaunchFailureCause::of(outcome, failure, exit_code)` 是唯一组装点;`InstanceRuntime::Failed{cause}`。
  `LaunchFailureStage` 保持「只装装配阶段」(文档那句「崩溃是结局不是阶段」现在成立)。
- 界面:`InstancesFailed`(框架)+ `InstancesCrashed` + `InstancesSilentFailed`;
  删掉 `InstancesFailedStage`。退出码按数字出词,不再有 Debug 文本。
- 用例:presenter 断言**枚举变体**(三种形状逐条)、app 断言**具体句子**
  (「不许出现 Some(」「进程起来了不许说启动游戏进程」「静默失败要点出没起来」)。
  mock 夹具与 launch_state_tests 的期望值同步;launch-error 三张图重拍。

复审 P2-1(`convert::failure_stage` 零覆盖):新增 `convert/stage_tests.rs` 5 条(三档 +
「既没回滚也没宣称干净」+ 非致命无阶段 + `failed_report_with` ⇒ NotStarted)。
变异实测:改成恒 `RolledBack` ⇒ 3 条红。

复审 P2-2(矮窗口用例的算式三个数全错、`body_height` 零测试):用例改成直接调
`frame::body_height`,chrome 从 `Ui::metrics` + `WidthClass::gutter` 现取,并把门槛与真实
chrome 绑死(chrome+MIN ⇒ Split、-1px ⇒ ScrollWhole);新增 `frame` 自己的 `body_height` 用例。
变异实测:`2.0 * gutter` → `gutter` ⇒ 2 条红。

复审 P2-3(源指纹闸门 / `Refused` 零覆盖):新增两条 core 用例——闸门(原地留着 prefs.json
+ 预先写好的同源收据 ⇒ Nothing 且收据未被重写,反证:源变了允许重放)、`Refused`
(`$schema` 1.7.0 ⇒ ReadOnly ⇒ 不动旧文件、不写收据、理由含「不可写」)。
变异实测:删闸门 ⇒ 1 条红;`can_save` 守卫改 `if false` ⇒ 1 条红。

顺手收:
- CLI `pack import` 那句「已整包回滚」也按三档出词(`RollbackStage` + `rollback_line`),
  加 2 条单测。CLI 不依赖 presenter,判定暂与 presenter 各一份 → 已 file finding 建议下沉 core。
- 设置页 `status_text()` 抽出 applying 档,用例断言「正在安装…」且非错误色。

金表:新 key 加在 `golden.rs`(81 行),未动 `golden/tail.rs`(211 行),避免与 M162 的拆分冲突。
2026-11-07 17:01:13 +08:00
AskaEth 6804b0ad1d docs(m161): 内容库筛选条「会自动换行」的注释改成如实描述(只改注释,不动行为) 2026-11-07 15:05:31 +08:00
AskaEth 03741ecd2d fix(m161): 收更新键/按钮/矮窗口/启动错误/导入结果五笔债
① update.auto_check / update.channel 登记进 core setting schema(1.6.0)
   - 新增 setting/keys/update.rs(两键 + UPDATE_CHANNELS,只允许 stable);
     SettingSchema 加 with_all 供域键表批量登记。
   - 新增 setting/update_prefs.rs:cache/update/prefs.json → launcher.json 的一次性迁移。
     四条硬约束:源指纹闸门(幂等)、不丢 false、新值胜出、旧文件改名 .migrated 后不再被读;
     主配置不可写时不动旧文件。收据 config/update-prefs-migration.json。
   - presenter Runtime 建会话时迁一次并把结局留在 migration();prefs 字段换成
     auto_check + 每次派发从主配置读 channel(不再硬编码 Channel::Stable)。
     保留旧字段 auto_check 的真实来源(原有测试 cases 会断言)。
   - CLI config get/set/dump 先跑同一次迁移(幂等),输出里给 migration 段与收据路径。

② presenter/dto/events.rs 的「本次会话不再弹提示条」注释改成真实语义
   (记的是版本号:同版本压住、更高版本照弹、手动检查清掉)。

③ 检查/下载进行中按钮禁用:pages/update/busy.rs 的 busy/can_check/can_apply;
   设置页两颗按钮改画禁用(不是消失),app 层 on_update_action 再挡一道
   (忙碌时 CheckNow/Apply 不派发);新增两条用例(含「忙完恢回可点」)。

④ 内容库低高度:content_layout::MIN_BODY_HEIGHT=460 + BodyMode(按固定件+三整行算出来,
   用例守着算式);frame::body_height 从 Ui::metrics 算正文可用高,pages::Model 带 window_height。
   ≥460 列表自己滚;<460 主列整列一个滚动条、列表按自然高度铺开。

⑤ 启动错误文案本地化:presenter 删掉 launch_summary / 「启动失败:」前缀 / 五条中文阶段前缀,
   改发 LaunchFailureView{stage,detail};LaunchFinished 改带 exit_code/uptime_ms/failure,
   Toast 由 app 层按 i18n key 现求(与 install_cancelled 同构)。InstancesFailed 拆成
   InstancesFailedStage(列表单行)+ LaunchStageFailed(详情/Toast 全文)。

⑥ 导入结果块:ImportReportView 加 failure_stage(NotStarted/RolledBack/RollbackIncomplete,
   由 core 的 rollback 机器位推出);标题、图标、底色三处分档,预检失败不再说「已回滚」。

顺带:六处注释/文档漂移(update.rs 的分工表与偏好落点、pack 的「已整包回滚」、
build_request 的 Errors、settings/update 的按钮禁用说明等),
以及为守 400 行把 update/cli-config/core-keys/presenter-dto/presenter-launch/i18n-golden
里贴线或越线的文件按模块拆开(纯搬移,行为不变)。
2026-11-07 15:02:13 +08:00
AskaEth b2d9f3927f Merge branch 'feat/m160-modpackpick-per-file-overwrite-deci'
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-07 13:57:08 +08:00
worker-modpackpick d568cc6f37 fix(m160-r1): 预检失败不再静默空转 + 写侧条目数闸 + 判据归一 + 冲突页对齐
复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。

P2-1 预检失败被吞掉(错误呈现回归)
- host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report**
  (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到
  既有的「致命 —— 已回滚」结果块,不再落回入口态。
- convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。
- 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列**
  `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC
  `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。
  变异验证:删掉那条 Report 发送 ⇒ 该用例红。

P3-1 写侧补条目数闸
- `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前**
  按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。
- 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个
  真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、
  `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。
  变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。

P3-2 「内容相同」判据归一
- 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留
  `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。
- 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。

P3-3/P3-4 口径与注释
- `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。
- `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算,
  注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。

视觉(tower 看图给的三条)
- 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒
  右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。
- 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端,
  与内容列对齐(不再是右侧一大片留白)。
- 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。

顺手
- `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个
  自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。
- `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。

证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4`
= 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的
`ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0),
df 106G→106G 余量不变。
2026-11-07 13:52:44 +08:00
worker-modpackpick 4defc41e8d feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
  逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
  缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
  全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
  spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
  预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。

限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
  (20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
  不等式在 pack/tests/limits.rs 里用 const 断言钉死。

测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
  under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
  multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。

测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
  以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。

证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
2026-11-07 13:52:43 +08:00
AskaEth a4f096eea3 Merge branch 'feat/m159-updategui-auto-update-client-wiring'
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-07 13:35:22 +08:00
AskaEth 04ac90911e fix(m159): r1 复审返工 —— parse.rs 拆出子模块 + 五条 P3 真问题
按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。

P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
  `crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
  全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
  上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。

P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
  **挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
  比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
  没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。

P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
  发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
  `job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。

P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
  同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
  文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。

P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
  `to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
  `{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
  r2 报告换成了实机 dump。

P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
  + `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
  `help_text_covers_every_command` 的 topic 列表补 `"update"`。

P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
  并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。

顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
  渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
  `UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
  `UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
2026-11-07 13:03:18 +08:00
AskaEth 1c8f6aa1b3 feat(m159): 自更新 GUI 客户端接线 + CLI update check/apply 同一内核
D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。

内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
  取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
  可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
  (壳层要运行期选源,`UpdateChecker` 是泛型的)。

presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
  MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
  + 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
  set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
  `CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。

app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
  结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
  分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
  已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
  拉同参数的新进程后退出(截图通道不重启)。

CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
  报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。

i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。

M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
  (它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
  两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。

顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。

验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
2026-11-07 12:09:42 +08:00
AskaEth a80362915a Merge branch 'feat/m157-instargs-per-instance-launch-args-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-07 11:28:53 +08:00
AskaEth ee67965b9b fix(m157): r4 修复未收集的周快照用例 + 两处 clippy 红,并补整环回归(复审 p1-2)
P1-1 crates/core/src/launch/overrides/tests.rs:modded_weekly_snapshot_needs_the_root_version_for_the_gate 原先写在 backend_allowed_combines_capability_and_threshold 函数体内(unnameable_test_items,永不执行)。移到模块层;移动后首次真正执行暴露出用例自身的 EmptyJavaPath(AdapterOptions 默认 java 为空),补 JavaSelection 后绿。--list 现在 10 条 launch::overrides::tests::(含该名字)。

P1-2 clippy -D warnings 两处:cli/commands/instance_launch.rs needless_borrows_for_generic_args(去 &);presenter/mock_data.rs items_after_test_module(mock_launch 移到 mod tests 之前)。

P3-a 新增整环回归 app/tests/instances_save_loop.rs:真 Message → dispatch → 后端回错误 Toast → pump → refresh_view → sync_launch_form,断言 4g 草稿与脏标记都还在。
P3-b LaunchForm::matches 内存改按数值比(解析失败退文本),补 memory_is_compared_by_value_not_by_text(04096 / +4096 / 清空三档)。
P3-c 门槛文案不再在 catalog 硬编码 26.2:InstancesLaunchBackendGated 改用 {min} 占位符,页面从 LaunchFacts::min_version(← core GRAPHICS_BACKEND_MIN_VERSION)代入;金表同步;灰化态截图重拍。
2026-11-07 11:06:18 +08:00
AskaEth 211e07b470 fix(launch): M157 r3 周快照门限按继承根版本 + 保存失败保留草稿(复审 P2-1/P2-2)
P2-1 周快照 + 加载器的 --graphicsBackend 静默丢失:presenter host::launch::build_request 与 cli snapshot::prepare 给 SnapshotInput.game_version 传 None,装配按带加载器后缀的 version_id(26w14a-fabric-0.16.5)判门限,GameVersion::is_weekly_snapshot 的 6 字符形态认不出来。两个调用点改为传继承链的 MC 根版本(25 行注释说明「门槛与拼装必须同一口径」)。回归:core launch::overrides 的周快照实际装配用例(正/负两档)、presenter host::launch::tests、cli snapshot::tests 各一条根版本断言。

P2-2 保存失败丢草稿:app/update/instances.rs 的 Save 分支派发前就把 launch_form.dirty 清掉,失败事件后 pump 用旧快照重建视图并覆盖表单。改为派发不清脏;LaunchForm::sync 只在「回程快照已等于表单」(= 落盘确认)时清脏并重播种,否则保留草稿。回归:failed_save_keeps_the_draft / confirmed_save_clears_dirty_and_reseeds / save_dispatch_keeps_the_dirty_flag。

顺带:launch::overrides 测试拆到 overrides/tests.rs(守 400 行)。
2026-11-07 10:36:37 +08:00
AskaEth c76a1437e6 fix(app-iced): M157 r2 详情列加滚动区 + 启动参数卡对齐信息卡(D30 复审)
- 详情列(含卡片与动作行)套 scrollable:1180×1000 下被裁的内容可达、滚动条与左列同一套几何;右侧 X2 内缩,卡片描边不再被滚动条压住
- 启动参数卡改用 frame::card(card_padding 21 + OutlinedCard 描边),标题 LabelMedium 不加粗——与相邻信息卡同款
- 段间距 X3→X2,卡片不再比内容长
- 演示夹具补 Game arguments(--fullscreen)
- presenter host 单测临时目录加序号(同一进程并行用例互删导致 flake)
2026-11-07 10:36:35 +08:00
AskaEth e2021645d7 feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)
core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。

presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。

cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。

app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
2026-11-07 10:36:35 +08:00
AskaEth d68523b02a Merge branch 'feat/m156-termxmerge-merge-termux-glibc-into'
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-07 09:38:20 +08:00
AskaEth 3b3430a93e docs(termux): 对外口径升级——CPU 软渲染 + 自编 Mesa GPU 两条路径各真机验证一次(用户拍板 A,四处同步) 2026-11-07 09:33:24 +08:00
AskaEth cb3ae8556b docs(core): install.rs 模块文档补上 Termux 分支与平台参数不早退
拆分后模块文档只描述归档链,读起来像「Termux 也走这条」;补一行说明 Termux 的系统包
分支在链之前,以及 `AdoptiumTarget::host()` 失败已改成每家一条降级原因(不再整体早退)。
2026-11-07 09:33:24 +08:00
AskaEth 88ac9d5aca docs(termux): 适配文档头部与合并指南对齐实际合并(15 提交 / --no-ff / 两处 PC 侧修复)
- 头部:`14 个提交,HEAD 38d3024` 是写文档那一刻的自我指涉,永远不可能对——改成
  `15 个提交,分叉点 87ab878,合并前 HEAD e987e02`。
- 合并指南第 1 步原来写 `git rebase origin/dev`,实际按用户令走 `--no-ff` 合并
  (原 15 个提交一字未改地留在历史里,便于按组 revert),改为记录既成事实与
  零文本冲突的交集(Cargo.lock / 两个 README / app-iced 的 Cargo.toml)。
- 新增第 6 条:PC 门禁发现、已就地修掉的两处——cfg(unix) 测试导入未 gate 导致
  Windows clippy 判错;两个文件越 400 行红线后的拆分。这两条在 Termux 上跑门禁看不出来,
  写进合并指南,免得下一个人以为「本机绿了就等于 PC 绿」。
2026-11-07 09:33:24 +08:00
AskaEth 4dbeb07220 refactor(core): 拆分越 400 行红线的 install.rs 与 install/libraries.rs
合并 termux-glibc 后两个文件顶破了「单文件 ≤400 行」(guards.yml 的 file-size):
`game/java/install.rs` 379 → 456、`game/install/libraries.rs` 258 → 409。

按步骤拆,纯搬运、零行为变化:

- `install/termux_install.rs`(新):Termux 系统包那条路。它与归档链不共用任何一步
  (不查版本元数据、不下载、不解压、不校验 sha256),原来只能是一段嵌在 install() 中间的
  if。返回 `Outcome::Done`/`Skipped` 并用 Box 装 `InstalledJdk`(否则 large_enum_variant:
  392 vs 24 字节)。
- `install/one.rs`(新):回退链里「一家」的机械步骤——下载 → 解压 → 探测 + 幂等复用,
  连同 `Prepared` 与 `note()`。
- `install/libraries/arm64_natives.rs`(新):Linux arm64 的 natives 改写与 Maven 兜底
  (两个常量 + 改写/拼址两个函数)。用例仍留在 libraries.rs 的 tests 里,它们从 `plan()`
  的出口断言,不碰私有步骤。

拆分后:install.rs 319、one.rs 100、termux_install.rs 104、libraries.rs 348、
arm64_natives.rs 72;全仓受检文件(*.rs/*.md/*.toml/*.yml/*.json/*.py/*.sh)复算
最大 400 行,红线内。
2026-11-07 09:33:24 +08:00
AskaEth c82e39d0eb fix(core): Windows 上 clippy 报 discover.rs 两个 cfg(unix) 测试导入未使用
这两个 Termux 扫描用例挂着 `#[cfg(unix)]`,但它们的 `use`(`standard_roots_in`、
`platform::paths::MapEnv`)写在 mod tests 顶部没跟着 gate:

- Termux/Linux 上 cfg(unix) 成立 → 有用例 → 导入有使用者 → clippy 干净(作者本机 475 测试
  通过是真的);
- Windows 上那两个用例不编译 → 导入无使用者 → `-D warnings` 直接判错
  (`unused import` ×2,`could not compile unlucky-core (lib test)`)。

把两个 `use` 同样 gate 成 `#[cfg(unix)]`。代价是这两个用例在 Windows 上不编译不执行,
覆盖由 Linux CI 承担(它们断言的本来就是 Unix 路径)。
2026-11-07 09:33:23 +08:00
AskaEth 161aec52f4 fix(app-iced): 新增的 unlucky-core 依赖版本对齐 0.2.2(合并后语义冲突)
termux-glibc 分支写这条依赖时 workspace 版本还是 0.2.1;M154 之后其余内部依赖
都已被抬到 0.2.2。caret 约束下 0.2.1 仍能解析到 0.2.2,所以不会构建失败,
但版本对齐是 M154 的既定契约,留一条 0.2.1 会让下一个读到该文件的人以为有例外。
2026-11-07 09:33:23 +08:00
AskaEth 41019d9cae docs(termux): 根目录新增适配方案与合并指南
TERMUX-ADAPTATION.md:方案(平台识别/JDK/字体/横屏/GPU/MC)、逐文件改动表、
兼容性说明(Termux gate、x86_64 不变)、合并步骤与 CI 预期、验证矩阵、
后续路线与按组回滚策略。README.termux 顶部链过去。
2026-11-07 09:33:23 +08:00
AskaEth fe472e36a5 docs(termux): README 校正——GPU 默认、横屏截图尺寸、数据根说明
- 构建描述从 CPU-only 改为 GPU 构建(wgpu + CPU 兜底)
- 窗口证据改为横屏 1657x1067、首帧 ~60 ms
- 快速开始不再强制 --software-renderer:默认 auto,驱动缺失自动降级
- 补数据根说明(Portable 按 exe 目录,--data-dir 暂忽略)
2026-11-07 09:33:23 +08:00
AskaEth 394faad611 docs(termux): MC 1.20.4 真机跑通结论与剩余项
- README:MC 标 ✅(glibc JRE + arm64 natives + softpipe);后续收敛为
  zink/DRI3、剪贴板/IME、narrator/音频
- 构建文档 §4.2 补真机启动命令、窗口尺寸与日志位置、非致命项
2026-11-07 09:33:23 +08:00
AskaEth 23c2abebc2 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 09:33:23 +08:00
AskaEth 0e7ae44923 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 09:33:22 +08:00
AskaEth 7eed6c6261 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 09:33:22 +08:00
AskaEth 02ed09ae49 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 09:33:22 +08:00
AskaEth 173a18b842 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 09:33:21 +08:00
AskaEth bbb316ca08 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 09:33:21 +08:00
AskaEth c434dbe335 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 09:33:21 +08:00
AskaEth 7b971dc75d fix(java): Termux 幂等安装报「未走网络」,vendors 在 Termux 上展示 termux 行
- termux::install 返回 (home, reused),复用时 downloaded=None,
  CLI 不再显示「0 字节 / 0 个文件」
- java vendors 在 Termux 上把 termux 插到顺序最前(仅展示,默认回退序不变)
2026-11-07 09:33:20 +08:00
AskaEth 48d1ee6616 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-07 09:33:19 +08:00
AskaEth 1e0fc44fed 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-07 09:33:19 +08:00
AskaEth 4582a025db 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-07 09:33:18 +08:00
AskaEth e090c67297 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-07 09:33:18 +08:00
AskaEth ac2e163eb8 Merge branch 'feat/m156guard-fix-bash-e-silent-kill-in-guar'
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-07 09:20:21 +08:00
AskaEth aa42496561 fix(ci): M156 修 guards.yml 三处 bash -e 静默自杀(presenter-no-ui 从未绿过)
M155 钉死:guards.yml 的 `hits=$(… | grep -iE …)` 在 grep 零命中(exit 1)时被
runner 默认的 `bash -e {0}` 拖红整步,且脚本里 `set -uo pipefail` 关不掉 -e,
所以一条 `::error::` 都打不出来 —— presenter-no-ui 自 M89 引入起从未绿过一次。

- presenter 依赖树 step:`hits=` 补 `|| true`(零命中回到绿路,真命中仍红)
- core 依赖树 step:裸 `tree=$(cargo tree …)` 改 `if ! tree=$(…)` 守卫,
  与 presenter / design 两处对称,cargo tree 失败时打注解而不是静默退出
- unsafe-scope:`actual=$(git grep …)` 末尾补 `|| true`,零匹配不再中止整步
- 文件头加一条警示:「本文件所有 run: 都在 bash -e 下,裸管道/裸赋值会被静默杀死」

验证(M155 方法:step 脚本从 yml 原样切出、`bash -e` 执行):
presenter 零命中 改前 exit1/0 注解 → 改后 exit0 打 OK;真命中仍 exit1/2 注解;
core cargo tree 失败 改前 exit101/0 注解 → 改后 exit1/1 注解;unsafe 零匹配
改前 exit1/0 注解 → 改后 exit1/2 注解;两处正常路径均仍绿。pyyaml 可解析,398 行。
2026-11-07 09:15:10 +08:00
AskaEth 4bb1591aca Merge branch 'feat/m152-packlast-finish-modpack-import-expo'
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-07 08:34:11 +08:00
AskaEth be280c16a3 fix(core): ZIP64 第二道闸不再重复累计预检总量(复审 P2-1)
复审在 tip 54c2d75 上实证的缺陷:`container()` 把 `preflight()` 返回的**全量**声明尺寸
当成写循环累加基数,循环里又对每个文件 `+= bytes.len()`,于是真实总量只要过半阈值
(~2.15 GiB)就被误拒,而且拒在内容读出来之后——"闸在内容进内存之前"的性质同时失效。

两处修:
1. 记账拆成独立类型 `Written`,**从清单 JSON 长度起算**,与预检返回值无关;预检的返回值
   现在只给测试与诊断看(`let _ =`,文档写明不许当基数)。
2. 内容改成 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`),不再 `std::fs::read` 整份摊在内存里。
   第二道闸看的是"已写字节数",与内存占用解耦;顺带把"拿预检 total 当基数"这类写法
   在内存侧也变成无害的小错而不是几个 GiB 的读。

测试(按塔台资源令,默认套件不再真写 2.5 GB):
- `write.rs` 新增 `the_container_does_not_double_count_the_preflight_total`:把阈值注入成
  几十字节(`container`/`preflight`/`Written` 都收 `limit`,生产路径恒为
  `zip::ZIP64_BYTES_THR`),构造"正确实现 70 < 90 通过 / 重复累计 110 ≥ 90 被拒"的窗口,
  **0.01 秒**钉住 P2-1;实测把 bug 写回去即红。
- `write.rs` 新增 `the_second_gate_counts_from_the_manifest_length_not_the_preflight_total`:
  直接对 `Written` 断言 2.5 GB 不拒、撞阈值必拒。
- `zip64.rs` 的 2.5 GB 稀疏文件端到端用例保留但 `#[ignore]`(真跑要 ~70 秒 deflate 与一大片
  内存/页面文件,C 盘页面文件已被上一轮撑到 34.8G),实测过一次成功:条目声明尺寸原样
  2 500 000 000、产物只有几 MB。跑法写在该用例的文档里。

门禁:fmt 0 / clippy -p unlucky-core --all-targets 0 / cargo test -p unlucky-core 1033 passed
0 failed(lib 480 passed + 1 ignored)。
2026-11-07 08:22:47 +08:00
AskaEth d728ed836f feat(core): M152 整合包收尾——同路径遮蔽逐文件上报 + ZIP64 拒写预检
导入侧把「同名文件冲突」从整包一个策略推进到逐文件事实:同一实例路径被本包多份声明
命中时(跨侧 overrides/client-overrides,或同侧重复),按叠层顺序(Both → Client)定
胜者,其余标 PlanOverride::shadowed(winner / winner_dir / same_bytes),逐条留
pack.import.note.override_shadowed 注记;phase 只遍历未被遮蔽的条目,不再白读字节,
报告里也不再出现"两条 Installed、前一条的哈希其实没落地"。

不新增 FileAction/ConflictReason 变体:这三个枚举被 crates/presenter 的 convert.rs
穷尽 match,而那个 crate 不在本 purpose 的作用域内,所以走既有的 ImportIssue 通道
(与 server-overrides/ 被跳过同一条)。

导出侧那条 ZIP64 闸原来够不着:先 std::fs::read 整个覆盖文件进内存再累加判断,真要
导出 4 GiB 文件的实例,内存先炸、用户永远看不到那句 pack_limits。改成写前按
metadata().len() 做声明尺寸预检,超阈值在任何字节进内存之前就报;写循环里的增量检查
留作第二道。写 zip 的实现搬到 format/write.rs 以守 400 行。

测试:pack/tests/zip64.rs(稀疏文件真跑到 ZIP64 分支,含累计口径与对照)、
tests/import_layering.rs(同路径不同哈希 / 同哈希不同路径 / 叠层顺序 / 基线记胜者)、
unknown_fields.rs 补第 4 条假边(mrpack files[].extra + 未知哈希算法)。
五条变异各自变红(遮蔽 no-op 6 红、按哈希判重 6 红、翻转叠层 2 红、错误码改 io 3 红、
预检不累计则预检单测红且端到端用例从 0.01s 变成 95s/约 4.5 GiB 内存)。
2026-11-07 08:22:47 +08:00
AskaEth 37de00b07e Merge branch 'feat/m154-verb022-bump-workspace-version-to-0'
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-07 08:16:39 +08:00
AskaEth 06fe12d811 chore(release): M154 版本升到 0.2.2(第一个稳定通道版本,D51)
- 根 Cargo.toml `[workspace.package] version` 0.2.1 → 0.2.2;`crates/{agent,app-iced,cli,palette-extract,
  presenter}` 里 9 处 intra-workspace path 依赖的 version 约束一并对齐(M137 既定做法)。`crates/app` 是
  workspace `exclude` 的 Slint 退役树,manifest 不参与解析,原样保留。
- Cargo.lock 由 cargo 再生成:只有 8 个 `unlucky-*` 成员条目从 0.2.1 变 0.2.2,第三方依赖(含版本号恰好
  也是 0.2.1 的 hexf-parse / windows-link / windows-threading / xkeysym / yazi)一字未动。这一步不是可选:
  CI 与发布流水线全走 `--locked`(ci.yml:236,274 / release-build.yml:135,142 / make_artifacts.py:50),
  lock 不跟就是「tag 一打就红」。
- packaging 文档里的**示例 tag** 跟到 v0.2.2:docs/artifacts-and-version.md 两处(命名规则示例 + 「tag 说
  X、产物里写 Y」的事故示例)、docs/build-and-ci.md 三处(`--version v…`、两次 `gh workflow run --ref v…`)、
  docs/profiles-and-measurements.md 的「同一槽位现在叫 `unlucky-launcher_0.2.2_windows_x64.exe`」、
  make_manifest.py 四处(Usage 示例 / `normalize_version` docstring / `--version` 的 argparse help)。
- **历史注记一律不动**:docs/runtime-deps.md 的「v0.2.1 首次真跑即红在工具链体检」(M140 事故记录)、
  `.github/workflows/release.yml:71` 同一事故的另一处记录、`crates/core` 更新模块测试里的样本版本串
  (`checker_cases.rs` / `version_cases.rs`,是解析器测试夹具,不是发布事实)、`microsoft.rs:224` 的
  `21.0.13.0.2.1` 负例。
- README.md / README.zh-CN.md、packaging/release-notes.md 核实后**无需改动**:三份文件里没有任何写死的
  版本号——下载表是 `<version>` / `<版本>` 占位符(M153 刚统一的口径,含非发布档位标「暂未发布」的行,
  原样保留未回退),release-notes 正文是 `{{VERSION}}` / `{{SUMS}}` 模板占位符。

验证:`cargo check -p unlucky-cli --all-targets --locked` 过(编译日志里 unlucky-core/agent/cli 均报 v0.2.2)、
`cargo test -p unlucky-cli --lib --locked` 42 passed 0 failed、`cargo metadata --locked --offline` 过(全
workspace manifest + lock 一致)、`python3 packaging/make_manifest.py --self-test` ok、
`ull --version` 实跑输出 `ull 0.2.2(unlucky-cli)`。
2026-11-07 08:13:29 +08:00
AskaEth c38fbe4068 Merge branch 'feat/m150-ghrsrc-implement-githubreleasesourc'
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-07 08:06:31 +08:00
AskaEth f001b93942 feat(core): M150 新增 GitHub Releases 更新源(D50)
D50 定案 GitHub Releases 为唯一更新源,清单地址固定为
`releases/latest/download/manifest.json`。此前 update 模块只有对拍用的
DirSource,这次把真实源补上。

- `update::github::GitHubReleaseSource` 实现 `UpdateSource`:清单从
  `<base>/<owner>/<repo>/releases/latest/download/manifest.json` 取,
  产物按 D49 命名规则(文件名)拼到同一条 latest/download 路径下;清单里
  已写完整 http(s) 地址的原样使用。
- 网络全走 `core::download`,不新造栈:包体交给引擎 `download()`(镜像展开
  → 地区判定 → 健康度排序 → 退避 → 续传 → 原子落盘),清单走引擎的
  `plan()` + 共享 client 逐候选取文本(与 `game::java` 的一锤子 GET 同一手法)。
  两条路径都吃到 mirrors.json 与地区档案里生效的 gh-proxy 前缀。
- 可配置面只有 owner/repo(默认 AskaEth/Unlucky-Launcher)与 base(默认
  https://github.com,测试指向本地假服务);渠道只有 stable 有清单,
  beta/alpha/nightly 返回 Ok(None)(D50「先只发 stable」),清单 404 也算
  「还没发布」而不是错误。
- `UpdateErrorKind` 新增 `Network`:远端取字节失败(连不上/超时/5xx/候选全挂)
  与本地 IO 分开——前者可重试,后者重试无意义。引擎把逐源失败压成
  AllSourcesFailed,本源从失败摘要里还原「所有源都 404」为 SourceMissing
  (产物没上传不该被报成网络抖动)。
- 校验仍只有一处:包体请求只带 expected_size,长度/摘要双检留在
  `update::verify`(与 DirSource 同口径)。
- 单测 13 条,全程离线(假 HTTP 服务在 127.0.0.1):固定清单地址、产物 URL
  解析、自定义 base/owner/repo、清单 404/非 JSON/字段非法/渠道不符/5xx 各自
  归到白名单分类、产物下载、产物 404 → SourceMissing、镜像优先下 gh-proxy
  前缀(清单与产物都验)、与 DirSource 同清单同产物选择结果一致。
2026-11-07 08:06:22 +08:00
AskaEth afdbc9fd51 Merge branch 'feat/m153-docsweep-close-forgotten-doc-findin'
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-07 08:00:04 +08:00
AskaEth 7946e5b804 docs(readme,cli,packaging): M153 清账四条被排忘的文档类 finding
① README 两版下载表:linux-x64 / linux-arm64 / macos-arm64 / macos-x64 四行补「not yet published /
暂未发布」标记(与 M143 的 windows-arm64 行同款措辞),表后改成「本版只发布 windows-x64」的口径,
并说明发布 job 只留 PUBLISH_SLOTS 里的槽位(现住 release-publish.yml);顺带把「produced by the CI
matrix」那句改成「矩阵里有、默认发布跑不编、all_slots=true 才编来取证」。

② packaging/make_artifacts.py 两处 docstring 还写 windows-arm64 是 cross-built:按 M140 后的原生
windows-11-arm 事实改写。(release-notes.md 的同一说法在 M139/M142 已改掉,本次核实无残留,未再动。)

③ cli 缺 id 的 hint:删掉「(id 不进仓库)」并改成与 cli/help.rs 一致的「官方构建已内置、环境变量优先、
自建时构建期注入」;Secret 透传的事实留在同处代码注释。

④ README 两版文件清单表:release.yml 从「six-slot build / 六槽位构建」改成「两个 Windows 槽位原生
构建 → 只发布 windows-x64;all_slots=true 才整表六槽位取证」。

纯文案,不动任何构建或运行时逻辑。
2026-11-07 07:57:32 +08:00
AskaEth 5c96227881 Merge branch 'feat/m151-relmanif-generate-update-manifest-i'
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-07 07:55:17 +08:00
AskaEth f9607c1246 docs(packaging): M151 复审 P2-1 两处未决注释按 D51 定案改写(方案甲/六件套/v0.2.2 起适用) 2026-11-07 07:52:50 +08:00
AskaEth e3ee20bdcd feat(packaging): M151 发布流水线生成更新清单 manifest.json(D50)
清单挂 GitHub Release 资产,固定文件名 manifest.json(D50)。字段形状的权威是 M146 的
crates/core/src/update/manifest.rs(ReleaseManifest,deny_unknown_fields + 逐字段校验),
生成器只负责把那套形状填对,不另立 schema。

同源,不双写:packaging/make_manifest.py 的每个 sha256 直接读 Release 自己的
SHA256SUMS.txt,size 取自同一批文件的 stat();产物名里的版本段必须等于 --version,
tag 与 Cargo.toml 漂了就没有产物可对,当场失败。CI 在「裁剪发布白名单后重算
SHA256SUMS」之后调它,所以清单描述的是真正要发的资产,不是构建出来的全部。
make_artifacts.py 只加 --manifest 开关,用子进程调同一份脚本(本地跑也产出
manifest.json),本文件继续是产物命名的唯一真相源。

只放启动器产物:DTO 的 Artifact 没有角色字段,Release::artifact_for() 取第一个
target 命中的产物,把 ull 并列写进去会让「覆盖哪个文件」取决于数组顺序;ull 照旧进
SHA256SUMS.txt 与 Release。

url 两种形态:默认裸文件名(DirSource 口径,本地/内网用);CI 传 --asset-base-url
拼绝对地址并钉 tag(不是 latest),清单里每条都指向与它自报版本相符的字节。

自验:--self-test 用 packaging/tests/manifest.golden.json 逐字节对拍(覆盖裸 exe 与
tar.gz 两种形态,并钉住「ull 不进清单」),再跑 14 条负例证明严格解析不是摆设;
发布步会先跑它再产文件。真 DTO 对拍已在本机用 M146 的 ReleaseManifest 实测:
golden 解析通过(2 个产物、min_supported、force 均正确落位),14 条篡改全部被拒。

未决(报告带利弊,未自行改发布默认值):releases/latest/download/* 只指向非 prerelease
的最新 Release,而发布默认 prerelease=true。

Refs: D50, M146
2026-11-06 22:15:58 +08:00
549 changed files with 66231 additions and 4813 deletions

No files matched your search

+46
View File
@@ -166,6 +166,10 @@ jobs:
- name: cargo test --workspace --all-targets(windows,失败升注解) - name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
env:
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
ULL_XDEV_TEST_WAIVE: "1"
run: | run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet, # pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。 # 不会把它改掉,所以它是 cargo 自己的码。
@@ -203,6 +207,18 @@ jobs:
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档 # 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。 # (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
- run: cargo build --workspace --release - 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 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。 # 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里 # 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
@@ -288,3 +304,33 @@ jobs:
name: runtime-smoke-screenshot-macos name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/ path: ${{ runner.temp }}/smoke/
if-no-files-found: warn 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
- name: update-e2e
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
+29 -17
View File
@@ -1,12 +1,16 @@
name: Guards 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): # 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 # 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层 # 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身 # 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) # 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 # 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91) # 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处)
# #
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件, # M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转: # **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
@@ -54,7 +58,11 @@ jobs:
run: | run: |
set -uo pipefail set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
tree=$(cargo tree -p unlucky-core --edges normal --prefix none) # 与下方 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
echo "$tree" echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -109,7 +117,9 @@ jobs:
exit 1 exit 1
fi fi
echo "$tree" echo "$tree"
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden") # 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
if [ -n "$hits" ]; then if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:" echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /' printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -169,22 +179,22 @@ jobs:
fi fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的 # D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。 # M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
# 判据必须是**集合相等**而不是「黑名单命中」: # 判据是**集合相等**而不是「黑名单命中」:
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); # - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope: unsafe-scope:
name: unsafe 落点守门(集合必须恰好一个文件) name: unsafe 落点守门(集合必须恰好等于期望)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点 - name: 全仓显式放行 unsafe 的文件集合 == 期望集合
run: | run: |
set -uo pipefail set -uo pipefail
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。 # 扫描口径(M91 r3):一个小词法器,而不是正则。
# #
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
@@ -318,25 +328,27 @@ jobs:
} }
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开, # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。 # 所以这个预筛不会漏)。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 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 if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort) done | LC_ALL=C sort || true)
echo "期望(恰好这一个):$EXPECTED" echo "期望(恰好这两处,集合相等):$EXPECTED"
echo "实际扫到的集合:" echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true) extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(D36 只允许一个):" echo "::error::多出来的 unsafe 落点(不在期望集合里):"
printf '%s\n' "$extra" | sed 's/^/ - /' printf '%s\n' "$extra" | sed 's/^/ - /'
fi fi
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。" echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
fi fi
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。" echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
exit 1 exit 1
fi fi
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED" echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
file-size: file-size:
name: 文件行数 ≤400 守门 name: 文件行数 ≤400 守门
+213 -60
View File
@@ -1,9 +1,14 @@
name: Release / build(可复用) name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。 # Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# 本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,唯一的改动是四个矩阵键换成同名入参: # M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、 # `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。其余 `name` / `if` / `env` / `run` / `uses` 与拆分前逐行相同。 # `matrix.rustflags` → `inputs.rustflags`。
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`), # 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。 # 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE # `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
@@ -67,64 +72,56 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static` # (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要 # M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。 # 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e—— # 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出), # `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测 # 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就 # M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。 # 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
- name: Windows ARM64 工具链体检(红不挡发布,只取证) # 也不再自动补装: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 工具链取证(只打印实况,红绿交给编译器)
if: inputs.slot == 'windows-arm64' if: inputs.slot == 'windows-arm64'
shell: bash shell: bash
run: | run: |
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关 set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; } say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio") shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs; vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。 [ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib" # ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib") for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath="" Microsoft.VisualStudio.Component.Windows11SDK.26100 \
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true) Microsoft.VisualStudio.Component.Windows11SDK.22621; do
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib") hit=""
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处 [ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。 if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true) else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
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 done
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})" # ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
v="" for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \ hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4) say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}" done
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}" for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi [ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的 for ver in "$root"/*/; do
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。 libs=( "$ver"um/arm64/*.lib )
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100) say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348 done
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id" done
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then # ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})" for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \ hit=""
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)" [ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
scan say notice "link.exe 候选 $p → ${hit:-未命中}"
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi done
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
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)。 # M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。 # 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
@@ -132,14 +129,107 @@ jobs:
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。 # 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」, # 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。 # 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
- name: cargo build --release --locked - name: cargo build --release --locked
shell: bash shell: bash
env: env:
RUSTFLAGS: ${{ inputs.rustflags }} RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }} ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: | run: |
set -euo pipefail 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.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`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 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**:找不到 python 就等于门禁失效。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
echo "expect-version=$version"
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -192,6 +282,56 @@ jobs:
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1 echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi 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` 上这条链一次都没验过(没有 arm64 的
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
if: inputs.slot == 'linux-x64'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb xauth \
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
out="$RUNNER_TEMP/linux-smoke"
mkdir -p "$out/data"
log="$out/launcher.log"
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
set +e
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "launcher exit=$code"
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
ls -l "$out"
exit "$code"
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
if: always() && inputs.slot == 'linux-x64'
uses: actions/upload-artifact@v4
with:
name: linux-x64-first-frame
path: ${{ runner.temp }}/linux-smoke/
if-no-files-found: warn
- name: 打包(产物 + SHA256SUMS) - name: 打包(产物 + SHA256SUMS)
shell: bash shell: bash
env: env:
@@ -239,9 +379,22 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」—— # (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。 # 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
- name: arm64 失败收尾注解(红不挡发布) - name: arm64 失败收尾注解(红不挡发布)
if: failure() && inputs.slot == 'windows-arm64' if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
shell: bash shell: bash
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release" env:
SLOT: ${{ inputs.slot }}
run: |
set -euo pipefail
if [ "$SLOT" = "linux-arm64" ]; then
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
else
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
fi
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
+94
View File
@@ -0,0 +1,94 @@
name: Release guard
# 发布标记语义守门(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
+114 -44
View File
@@ -4,12 +4,17 @@ name: Release / publish(可复用)
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、 # job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、 # `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。 # 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`)。 # ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。 # 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个 # ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与 # 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是 # `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是 # **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。 # 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。 # 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
@@ -22,6 +27,14 @@ on:
PUBLISH_TOKEN: PUBLISH_TOKEN:
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)" description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
required: true 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)。 # 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
permissions: permissions:
@@ -35,11 +48,21 @@ jobs:
contents: write contents: write
env: env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)── # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着 # **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
# arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据), # 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
# **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步 # `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
# README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。 # 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
PUBLISH_SLOTS: "windows-x64" # **`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"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -48,6 +71,17 @@ jobs:
path: dist path: dist
merge-multiple: true 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: 裁掉白名单外的槽位产物(构建取证 ≠ 发布) - name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash shell: bash
run: | run: |
@@ -64,36 +98,76 @@ jobs:
echo "publish: $base" echo "publish: $base"
;; ;;
*) *)
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)" echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f" rm -f "$f"
;; ;;
esac esac
done done
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS - name: 重算全量 SHA256SUMS
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
ls -l dist ls -l dist
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个 # 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。 # 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。 # 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
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 cd dist
rm -f SHA256SUMS.txt rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt sha256sum -c SHA256SUMS.txt
cat 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 说明(支持矩阵模板) - name: 生成 Release 说明(支持矩阵模板)
shell: bash shell: bash
env: env:
@@ -119,29 +193,25 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md) base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push # ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。 # 三种触发各自创建什么(那张表是唯一真相源):
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。 # push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
create_mark=() # dispatch prerelease=true → create / edit 都带预发布标记
edit_mark=() # dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
case "${PRERELEASE:-}" in # 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
true) # 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
create_mark=(--prerelease) # 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
edit_mark=(--prerelease) # 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
;; # 进程替换里的失败会被 `set -e` 吞掉。
false) marks=/tmp/release-marks.sh
edit_mark=(--prerelease=false) python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
;; source "$marks"
"") echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 github.event.inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md) # 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)
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber gh release upload "$TAG" "${assets[@]}" --clobber
+50 -21
View File
@@ -1,20 +1,32 @@
name: Release name: Release
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 + # 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
# SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。 # `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试 # `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
# 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都 # 不碰标记)。
# 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。 # **构建 ≠ 发布**:只有 `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::` 明说未发布。
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 契约唯一真相源 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 与 # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
# #
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**, # M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改: # 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build / # - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。 # cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算 # - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。 # SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。 # `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on: on:
@@ -24,13 +36,17 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
all_slots: all_slots:
description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows" description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
type: boolean type: boolean
default: false default: false
prerelease: prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)" # 默认 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 时才生效)"
type: boolean type: boolean
default: true default: false
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -47,7 +63,7 @@ jobs:
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy` # M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。 # 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
plan: plan:
name: 选槽位(默认两个 Windows) name: 选槽位(默认四个:Windows 两档 + Linux 两档)
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.pick.outputs.matrix }} matrix: ${{ steps.pick.outputs.matrix }}
@@ -67,16 +83,25 @@ jobs:
SLOTS = [ SLOTS = [
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。 # 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"), 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,公开仓库免费;GitHub 文档的 Standard runners 也列 # Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
# `windows-11-vs2026-arm`)。M140 前是在 windows-latest 上交叉,**CI 从未成功过一次**(v0.2.1 首跑红在 # **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
# 工具链体检,M141/M142 在此修)。原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。 # 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release", dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
rustflags="-C target-feature=+crt-static", mode="ci"), rustflags="-C target-feature=+crt-static", mode="ci"),
# ↓ 四档**未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编 # ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。
# (将来验证 Linux/macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS + # Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。
# 同步 README §1/§9 与 release-notes.md。Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移。 # 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里:
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="probe"), # `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="probe"), # `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机
# 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。
# **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"),
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"),
# ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
# (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
# 同步 README §1/§9 与 release-notes.md。
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。 # 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-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"), dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
@@ -113,8 +138,9 @@ jobs:
release: release:
name: GitHub Release name: GitHub Release
needs: build needs: build
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) # `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
# 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 # 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。 # tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag' if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 # `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
@@ -124,3 +150,6 @@ jobs:
secrets: secrets:
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串,
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
Generated
+169 -8
View File
@@ -368,6 +368,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -660,6 +666,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.11.0" version = "0.11.0"
@@ -846,6 +858,33 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" 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]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.1"
@@ -892,6 +931,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" 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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -977,6 +1026,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 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]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.42" version = "0.8.42"
@@ -1094,6 +1167,12 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@@ -3087,6 +3166,16 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -3332,6 +3421,15 @@ dependencies = [
"rand_core 0.9.5", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -4125,6 +4223,15 @@ dependencies = [
"syn 3.0.9", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4188,6 +4295,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -4388,6 +4504,16 @@ dependencies = [
"bitflags 2.13.2", "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]] [[package]]
name = "sse-stream" name = "sse-stream"
version = "0.2.6" version = "0.2.6"
@@ -4738,6 +4864,21 @@ dependencies = [
"tokio", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@@ -4768,6 +4909,12 @@ dependencies = [
"winnow", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -4982,7 +5129,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -4997,7 +5144,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
@@ -5007,23 +5154,26 @@ dependencies = [
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"unlucky-agent", "unlucky-agent",
"unlucky-core", "unlucky-core",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"ed25519-dalek",
"flate2", "flate2",
"futures-util", "futures-util",
"md-5", "md-5",
@@ -5041,15 +5191,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.1" version = "0.2.3"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.1" version = "0.2.3"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5057,8 +5207,9 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.1" version = "0.2.3"
dependencies = [ dependencies = [
"ed25519-dalek",
"serde", "serde",
"serde_json", "serde_json",
"unlucky-core", "unlucky-core",
@@ -5968,6 +6119,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+8 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.1" version = "0.2.3"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
@@ -71,6 +71,13 @@ md-5 = "0.10"
# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 # 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。
zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } 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`, # M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`,
# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 # 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。
flate2 = "1" flate2 = "1"
+129 -30
View File
@@ -4,6 +4,8 @@
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and > **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases> > release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.3`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust 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 (Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
@@ -43,6 +45,14 @@ 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 - **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 game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID. 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 - **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 (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 mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
@@ -56,8 +66,17 @@ 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 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 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 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, - **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*. 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 - **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 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 its file endpoint is not wired; and an item whose game version or loader does not match the target
@@ -160,42 +179,101 @@ 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 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` (`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`, lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `agent`, `version`, `help`. `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.
### Not done yet ### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy. This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a - **No installer.** You download a file and run it — there is no setup step, and updates replace that
newer version for you, and there is no update source decided yet. 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.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*, - **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset *Open folder*, *Delete*. In the settings frame: *Save*, *Reset
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save. edit them rather than on a Save.
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`, - **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself
been downloaded and run end to end. Treat them as untested output, not as "supported because the was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its
matrix lists them". theme/backend lines — and that same check is now a per-slot CI gate on every release
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why (`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified.
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window
builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app.
probe with `continue-on-error`, not a gate. `linux-arm64` is built (native `ubuntu-22.04-arm`) but **not** published — no ARM64 machine has ever run it, so
the artifact stays as build evidence only.
- **macOS is not built by the release workflow, and it is not finalised.** It has two rounds of real-device
evidence (Apple Silicon), which is why the build exists at all — but the menu-bar name, the placement of the
three window buttons (macOS puts them top-left; ours are drawn top-right), and 300s+ steady state still need a
real machine. macOS builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` probe with
`continue-on-error`, not a gate; `gh workflow run release.yml -f all_slots=true` builds the two slots for
evidence, but they are never published.
- **The content library is cramped at low window heights**, and the window height is not wired into - **The 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. 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-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 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. 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. - **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. - **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. - **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 - **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: possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only. [`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).
## Download and run ## Download and run
@@ -207,20 +285,28 @@ Grab the file for your platform from
| Slot | Asset | Shape | | Slot | Asset | Shape |
|------|-------|-------| |------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run | | `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `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; glibc 2.35+ and the X11/Wayland + fontconfig stack |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory | | `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 | | `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 | | `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 | | `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow |
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset | | every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is **This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress. named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it
The row stays because the architecture is still supported and the slot still builds (unpublished) in writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in (`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that
only downloadable Windows asset. exist for an unlisted slot get an explicit "not published" warning instead of being deleted quietly, and a
`mode: ci` slot that produced nothing at all warns too — a red slot must never be visible only as a missing row.
Of the unlisted rows, CI builds two. `windows-arm64` is built natively on GitHub's `windows-11-arm` runner
(Windows 11 Arm64 + Visual Studio 2026); every ARM64 failure so far came from a pre-build toolchain probe that
aborted before `cargo build` ran, that probe is now evidence-only, and the next CI run is the first real answer to
whether the slot can produce a binary. `linux-arm64` is built natively on `ubuntu-22.04-arm` (same glibc 2.35 floor
as x64) and is unpublished for a different reason: **nothing has ever run it on ARM64 hardware**. One frame plus the
theme/backend log lines on a real ARM64 machine is the unlock condition; until then the artifact is build evidence.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level 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 directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -228,6 +314,18 @@ documents are attached to the release next to it — downloading the Windows bin
licence. Download links never carry implementation detail in the name, and the binary inside an licence. Download links never carry implementation detail in the name, and the binary inside an
archive is always called `unlucky-launcher`. 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 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 `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 it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35
@@ -294,7 +392,8 @@ 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/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/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*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) |
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
## Architecture and module map ## Architecture and module map
+112 -25
View File
@@ -4,6 +4,8 @@
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里: > **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases> > <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是 Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器 **一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
@@ -33,6 +35,11 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。 与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位, - **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。 每次运行留一份 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`、 - **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在 `crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。 日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
@@ -44,8 +51,15 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或 - **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的 CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。 导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果, - **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。 以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线); - **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是 CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。 禁用态,并把不匹配写清楚。没有一条是静默失败的。
@@ -129,35 +143,88 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 **未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 `unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、 (`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、 `install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
`version`、`help`。 `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`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
### 还没做 ### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。 这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包,也没有自动更新通道。** 下个文件直接跑,没有任何东西替你检查新版本,更新源也还没定。 - **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 (见[自更新与稳定通道](#自更新与稳定通道))。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
观察要等 `0.2.3` 发出去之后。
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、 (日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。)
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是 - **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编
「矩阵里有就算支持」。 `linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置 后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉 **这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。
Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 `workflow_dispatch` 「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到
的探针、带 `continue-on-error`,不是门禁。 (`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`
能编这两档取证,但它们从不发布。
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它 - **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
真正拿到的高度。 真正拿到的高度。
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子 - **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的 (`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。 `静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。 - **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。 - **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),目前只在 CPU 渲染下验证过一次。 - **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
## 下载与运行 ## 下载与运行
@@ -168,18 +235,37 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
| 槽位 | 产物 | 形态 | | 槽位 | 产物 | 形态 |
|------|------|------| |------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 | | `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 | | `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 | | `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 | | `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 | | `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** |
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的 **本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进 (`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`, **不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`):
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。 名单里点名了却没产物 ⇒ Release 变红(`linux-x64` 构建红了就挡住整次发布,靠的就是这条);产物在、名单没点名
⇒ 显式打「未发布」警告,不再静默删掉;`mode: ci` 的槽位本次连产物都没有(那一档红了)⇒ 也打警告。
不发布的行里 CI 真编两档。`windows-arm64` 在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + Visual Studio
2026)上编来取证——**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**,`cargo build` 一次都没跑过;
M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。`linux-arm64` 在原生
`ubuntu-22.04-arm`(与 x64 同 glibc 地板)上编,不发布的理由完全不同:**没有任何 ARM64 机器跑过它**。
解锁条件 = 真机渲出一帧 + 主题/后端日志行,之后才把键加进 `PUBLISH_SLOTS`。
Linux x64 的安装就是解开归档直接跑:
```bash
tar -xzf unlucky-launcher_<版本>_linux_x64.tar.gz
cd unlucky-launcher_<版本>_linux_x64
./unlucky-launcher # 没有可用 GPU 适配器就加 --software-renderer
```
载入期要 glibc 2.35+、`libxkbcommon`、`fontconfig`、`libGL`;Debian/Ubuntu 上
`sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` 就够。
与归档并列的裸 `ull_<版本>_linux_x64` 连显示都不需要。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -240,7 +326,8 @@ 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/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/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release | | `.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`,守门会对自己做变异自测 |
## 架构与模块地图 ## 架构与模块地图
+15 -3
View File
@@ -1,6 +1,6 @@
# Termux 适配:方案、改动清单与合并指南 # Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,14 个提交,HEAD `38d3024`) > 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。 > 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR ## TL;DR
@@ -97,8 +97,11 @@
## 合并指南 ## 合并指南
1. `git fetch origin && git rebase origin/dev`(分支基于 `7d8b3b9` 之后的 dev; 1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
dev 若前进,先 rebase 再推)。 (M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` / 2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。 `test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败: 3. 本机已知、**不影响 PC CI** 的三条失败:
@@ -107,6 +110,15 @@
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂) - `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。 4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。 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 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.3" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+1 -1
View File
@@ -45,7 +45,7 @@ pub use chat::{
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
}; };
pub use exec::{ToolExecOutcome, ToolExecutor}; pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderKind}; pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 一轮对话最多几轮工具调用。 /// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8; pub const DEFAULT_MAX_STEPS: usize = 8;
+70 -86
View File
@@ -14,6 +14,11 @@ use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。 /// Ollama 默认端点(research/02 §5.2)。
@@ -54,6 +59,35 @@ 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] #[must_use]
pub fn display_name(self) -> &'static str { pub fn display_name(self) -> &'static str {
@@ -174,18 +208,22 @@ impl ProviderConfig {
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> { pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() { if !self.is_enabled() {
return Err(ProviderError::new( return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
)); ));
} }
if self.model.trim().is_empty() { if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)")); return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
} }
if self.resolved_base_url().trim().is_empty() { if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要填写端点(base_url)", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要填写端点(base_url)", self.kind.display_name()),
))); ));
} }
if self.kind.requires_api_key() if self.kind.requires_api_key()
&& self && self
@@ -193,10 +231,10 @@ impl ProviderConfig {
.as_deref() .as_deref()
.is_none_or(|key| key.trim().is_empty()) .is_none_or(|key| key.trim().is_empty())
{ {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要 API key", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要 API key", self.kind.display_name()),
))); ));
} }
Ok(()) Ok(())
} }
@@ -239,20 +277,38 @@ impl fmt::Debug for ProviderConfig {
} }
} }
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 /// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
/// `ErrorReport` 翻译,见 [`failure`])。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError { pub struct ProviderError {
kind: ProviderFailure,
message: String, message: String,
} }
impl ProviderError { impl ProviderError {
/// 从可读文本构造。 /// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
#[must_use] #[must_use]
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
Self { Self {
kind: ProviderFailure::Other,
message: message.into(), 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 { impl fmt::Display for ProviderError {
@@ -264,77 +320,5 @@ impl fmt::Display for ProviderError {
impl std::error::Error for ProviderError {} impl std::error::Error for ProviderError {}
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "provider/tests.rs"]
use super::*; mod tests;
#[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}"
);
}
}
@@ -0,0 +1,62 @@
//! 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
@@ -0,0 +1,107 @@
//! [`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}"
);
}
+60 -83
View File
@@ -47,7 +47,7 @@ use std::sync::Arc;
use futures_util::StreamExt; use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::ProviderError as RigError; use rig_core::error::{ErrorKind, ProviderError as RigError};
use rig_core::message::{ use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
@@ -64,7 +64,7 @@ use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture, ProviderFuture,
}; };
use super::provider::{ProviderConfig, ProviderError, ProviderKind}; use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0"; pub const RIG_VERSION_PIN: &str = "=0.45.0";
@@ -275,9 +275,63 @@ fn to_reply(
}) })
} }
/// rig 错误 → 本方错误(文案面向用户)。 /// 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`] |
fn map_error(error: RigError) -> ProviderError { fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string()) 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
} }
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "rig/tests.rs"]
use super::*; mod tests;
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
@@ -0,0 +1,82 @@
//! [`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("对话历史为空"));
}
+18 -5
View File
@@ -22,15 +22,15 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.3" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 # 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.3" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.1" } unlucky-presenter = { path = "../presenter", version = "0.2.3" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 关掉默认 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 → # 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.1" } unlucky-cli = { path = "../cli", version = "0.2.3" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.1" } unlucky-i18n = { path = "../i18n", version = "0.2.3" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
@@ -90,6 +90,19 @@ iced = { version = "=0.14.0", default-features = false, features = [
default = ["gpu"] default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 # 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"] 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] [lints]
workspace = true 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
@@ -0,0 +1,14 @@
//! `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");
}
+4 -3
View File
@@ -18,6 +18,7 @@
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。 //! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。 //! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
use crate::app::diag::diag;
use unlucky_presenter::dto::AppCommand; use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change; use crate::pages::appearance::Change;
@@ -54,7 +55,7 @@ impl App {
} else { } else {
line.push_str(" layered=skipped outcome=no-window"); line.push_str(" layered=skipped outcome=no-window");
} }
eprintln!("{line}"); diag!("{line}");
} }
/// 设置页改了一项外观。 /// 设置页改了一项外观。
@@ -123,8 +124,8 @@ impl App {
self.appearance_prefs = prefs; self.appearance_prefs = prefs;
match self.bridge.path() { match self.bridge.path() {
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()), Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"), None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
} }
} }
} }
+11 -10
View File
@@ -12,6 +12,7 @@
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic //! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use crate::app::diag::diag;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use iced::Task; use iced::Task;
@@ -51,10 +52,10 @@ pub(crate) fn run(options: Options) -> iced::Result {
let font_started = Instant::now(); let font_started = Instant::now();
let resolved = fonts::resolve(); let resolved = fonts::resolve();
let font_ms = font_started.elapsed(); let font_ms = font_started.elapsed();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0); diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe()); diag!("{}", resolved.describe());
if resolved.has_synthetic() { if resolved.has_synthetic() {
eprintln!( diag!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理" "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
); );
} }
@@ -64,7 +65,7 @@ pub(crate) fn run(options: Options) -> iced::Result {
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。 // 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。 // **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() { if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
} }
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
diagnostics::report_theme(&options, flavor); diagnostics::report_theme(&options, flavor);
@@ -86,14 +87,14 @@ pub(crate) fn run(options: Options) -> iced::Result {
} }
Some(OnFailure::Refuse) => { Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。 // 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!( diag!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options options
.screenshot .screenshot
.as_ref() .as_ref()
.map_or_else(String::new, |path| path.display().to_string()) .map_or_else(String::new, |path| path.display().to_string())
); );
eprintln!( diag!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。" "[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
); );
Err(iced::Error::GraphicsCreationFailed(error)) Err(iced::Error::GraphicsCreationFailed(error))
@@ -192,13 +193,13 @@ fn boot_probe(options: &Options) -> Boot {
if let Some(kind) = renderer::forced_fallback() { if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind); let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value()); std::env::set_var("ICED_BACKEND", kind.env_value());
eprintln!( diag!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV, renderer::FALLBACK_ENV,
kind.env_value(), kind.env_value(),
kind.env_value() kind.env_value()
); );
eprintln!("[probe] {}", decision.describe()); diag!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None` // 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。 // ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() { let (probe, probe_ms) = if acceptance::enabled() {
@@ -232,11 +233,11 @@ fn boot_probe(options: &Options) -> Boot {
if cfg!(feature = "gpu") { if cfg!(feature = "gpu") {
match probe_ms { match probe_ms {
Some(elapsed) => eprintln!( Some(elapsed) => diag!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)", "[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0 elapsed.as_secs_f64() * 1000.0
), ),
None => eprintln!( None => diag!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)", "[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name() options.backend.name()
), ),
@@ -35,6 +35,11 @@ pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
size: iced::Size::new(size.0, size.1), size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE), min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。 // 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
//
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
decorations: false, decorations: false,
resizable: true, resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式 // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
+4 -3
View File
@@ -9,6 +9,7 @@
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程 //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。 //! 只能建一次事件循环。
use crate::app::diag::diag;
use crate::renderer::Decision; use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind}; use crate::renderer::{self, acceptance, Kind};
@@ -58,10 +59,10 @@ pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2]
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。 /// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| { let exe = std::env::current_exe().unwrap_or_else(|err| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None)); std::process::exit(exit_code(None));
}); });
eprintln!( diag!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。", 下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(), fallback.name(),
@@ -78,7 +79,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let code = match status { let code = match status {
Ok(status) => exit_code(status.code()), Ok(status) => exit_code(status.code()),
Err(err) => { Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}"); diag!("[gpu] 降级重开失败:{err}");
exit_code(None) exit_code(None)
} }
}; };
+169
View File
@@ -0,0 +1,169 @@
//! 启动期诊断行的**双写**: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
@@ -0,0 +1,390 @@
//! 模态对话框(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;
}
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`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()))
}
#[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
@@ -0,0 +1,344 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`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;
@@ -0,0 +1,85 @@
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
//!
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
use unlucky_i18n::Lang;
use super::report;
use crate::app::dialog::export;
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
#[test]
fn export_writes_a_bundle_even_without_a_crash_report() {
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
spec_report.launcher_logs = Some(root.join("logs"));
spec_report.session_log = None;
spec_report.run_dir = None;
spec_report.version_json = None;
let done = export::export(&spec_report, Lang::ZhCn);
let text = done.text.clone();
assert!(done.ok, "{text}");
assert_eq!(done.truncated, 0);
assert!(text.contains("已导出"), "{text}");
assert!(text.contains("字节"), "{text}");
let exports = root.join("logs").join("exports");
let files: Vec<_> = std::fs::read_dir(&exports)
.expect("导出目录")
.filter_map(Result::ok)
.collect();
assert_eq!(files.len(), 1, "一次导出只该落一个包");
let size = files[0].metadata().expect("元数据").len();
assert!(size > 0, "包不该是空文件");
assert!(
text.contains(&size.to_string()),
"报出来的字节数要与磁盘一致:{text} / {size}"
);
}
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
///
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
/// 抓不住:那句话排版好不好看(视觉)。
#[test]
fn a_truncated_export_says_so_in_the_notice() {
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
let mut big = String::new();
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
big.push_str(line);
}
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
let mut report = report(false, Vec::new());
report.launcher_logs = Some(root.join("logs"));
report.run_dir = Some(root.join("game"));
report.session_log = None;
report.version_json = None;
let done = export::export(&report, Lang::ZhCn);
assert!(done.ok, "{}", done.text);
assert_eq!(
done.truncated, 1,
"这份 latest.log 必须真的被截断(>4 MiB)"
);
assert!(
done.text.contains("已截断"),
"界面上没有说「有文件被截断」:{}",
done.text
);
}
+299
View File
@@ -0,0 +1,299 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`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::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> {
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color(ui))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(card_plane(ui, modal))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, 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`] 断言**视图真正用的那一份**(P1 的守卫)。
pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui)
}
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
///
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
iced::widget::opaque(card(ui, modal))
}
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
// 几何全部走组件层(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()
}
+121
View File
@@ -0,0 +1,121 @@
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
//!
//! ## 为什么不在页面里现拼路径
//!
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
//!
//! 本模块只做两件事:
//!
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
//!
//! ## 谁来登记
//!
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
//! `platform::data::layout()`——同一份解析链的缓存版本。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_presenter::settings_bridge::SettingsBridge;
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
let home = config.parent()?.parent()?;
Some(LauncherLayout::new(home).logs())
}
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
pub(crate) fn register(bridge: &SettingsBridge) {
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
}
/// 本进程的日志目录。
///
/// - 已登记且解析出来 ⇒ 那条;
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
pub(crate) fn logs_dir() -> Option<PathBuf> {
match LOGS_DIR.get() {
Some(known) => known.clone(),
None => crate::platform::data::layout().map(|layout| layout.logs()),
}
}
/// 确保日志目录存在(不在就建),返回给用户看的路径。
///
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir)
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
if !dir.is_dir() {
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
}
Ok(dir.to_path_buf())
}
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
///
/// # Errors
///
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
pub(crate) fn open() -> Result<PathBuf, String> {
let dir = logs_dir().ok_or_else(|| {
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
.to_string()
})?;
let dir = prepare(&dir)?;
crate::app::dialog::export::open_dir(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
///
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
#[test]
fn the_log_folder_is_the_logs_partition_next_to_config() {
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
assert_eq!(
logs_dir_of(&config),
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
);
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
}
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
///
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
#[test]
fn prepare_creates_the_folder_and_reports_the_same_path() {
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".ULL/logs");
assert!(!dir.is_dir(), "夹具目录不该已经存在");
let prepared = prepare(&dir).expect("应当建得出来");
assert_eq!(prepared, dir);
assert!(dir.is_dir(), "打开之前必须真的存在");
std::fs::remove_dir_all(&root).ok();
}
/// 失败文本带路径(「不许只在成功时可用」)。
#[test]
fn a_failure_names_the_path_it_could_not_use() {
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
std::fs::write(&file, b"x").expect("写夹具");
let error = prepare(&file).expect_err("一个文件不是目录");
assert!(error.contains(&file.display().to_string()), "{error}");
std::fs::remove_file(&file).ok();
}
}
+2
View File
@@ -67,6 +67,8 @@ pub enum Message {
Captured(window::Screenshot), Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。 /// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage), Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message),
} }
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
+3
View File
@@ -30,6 +30,9 @@
mod appearance; mod appearance;
mod backend; mod backend;
mod boot; mod boot;
pub(crate) mod diag;
mod dialog;
pub(crate) mod logs_folder;
mod message; mod message;
mod state; mod state;
mod subscription; mod subscription;
+28
View File
@@ -56,6 +56,9 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 /// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: /// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 /// 它挂在 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); pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 /// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
@@ -130,6 +133,12 @@ pub struct App {
pub(super) refreshes: u64, pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool, pub(super) auto_selected: bool,
/// 上一次看到的**中文字体下载状态**(M168)。
///
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
/// 而空闲态不会为了它每拍白重建一次。
pub(super) font_seen: crate::fonts::install::CjkInstall,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32, pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
@@ -168,6 +177,9 @@ pub struct App {
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」, /// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。 /// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64, pub(super) install_cancelled_seen: u64,
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
/// ///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -178,6 +190,22 @@ pub struct App {
pub(super) account_form: pages::account::Form, pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。 /// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page, 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,
/// 启动失败弹窗的**事实**(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:同一张皮肤只解一次)。 /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache, pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
+2 -1
View File
@@ -4,6 +4,7 @@
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出, //! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。 //! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
use crate::app::diag::diag;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Instant; use std::time::Instant;
@@ -32,7 +33,7 @@ pub(crate) fn log_first_frame() {
return; return;
} }
if let Some(started) = STARTED.get() { if let Some(started) = STARTED.get() {
eprintln!( diag!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0 started.elapsed().as_secs_f64() * 1000.0
); );
@@ -41,6 +41,9 @@ impl App {
Some(root) => SettingsBridge::with_path(config_path(root)), Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(), None => SettingsBridge::open(),
}; };
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
crate::app::logs_folder::register(&bridge);
Self::with_presenter(options, families, flavor, backend, bridge) Self::with_presenter(options, families, flavor, backend, bridge)
} }
@@ -86,9 +89,14 @@ impl App {
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some()); 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( let mut view = pages::Model::new(
pages::Screen::Instances, pages::Screen::Instances,
width, width,
height,
Some(pages::Screen::Instances.nav()), Some(pages::Screen::Instances.nav()),
); );
view.lang = lang.lang(); view.lang = lang.lang();
@@ -113,6 +121,9 @@ impl App {
frames: 0, frames: 0,
refreshes: 0, refreshes: 0,
auto_selected: false, auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height, height,
appearance, appearance,
appearance_prefs, appearance_prefs,
@@ -124,7 +135,11 @@ impl App {
agent_host: super::presenter::AgentHost::from_env(), agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(), download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0,
dialog_card: None,
dialog_model: None,
content: pages::content::PageState::default(), content: pages::content::PageState::default(),
account_form: pages::account::Form::new(), account_form: pages::account::Form::new(),
account: pages::account::Page::build( account: pages::account::Page::build(
@@ -139,6 +154,10 @@ impl App {
}; };
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui(); app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log(); app.lang.log();
+52
View File
@@ -6,8 +6,11 @@
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::view;
use crate::pages; use crate::pages;
use unlucky_presenter::router::Route;
use super::App; use super::App;
impl App { impl App {
@@ -34,6 +37,26 @@ impl App {
self.state.toasts.push(ToastKind::Info, text); 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);
}
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category; let category = self.view.download.category;
@@ -46,16 +69,45 @@ impl App {
self.view = super::super::view::model( self.view = super::super::view::model(
&self.state, &self.state,
width, width,
self.height,
self.options.force_state, self.options.force_state,
category, category,
&draft, &draft,
self.lang(), self.lang(),
&self.content, &self.content,
); );
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。 // 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width); self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
self.dialog_model = self.dialog_modal();
self.view_dirty = false; self.view_dirty = false;
self.refreshes += 1; self.refreshes += 1;
} }
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `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,
}
}
} }
+3 -2
View File
@@ -5,6 +5,7 @@
//! //!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。 //! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::prefs::ThemeMode;
@@ -30,7 +31,7 @@ impl App {
self.appearance.set_flavor(flavor); self.appearance.set_flavor(flavor);
let previous = self.ui.name(); let previous = self.ui.name();
self.rebuild_ui(); self.rebuild_ui();
eprintln!( diag!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)", "[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous, previous,
self.ui.name() self.ui.name()
@@ -48,7 +49,7 @@ impl App {
let panel_alpha = self.appearance.effective_panel_alpha(); let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() { self.ui = match self.appearance.derived_scheme() {
Some(scheme) => { Some(scheme) => {
eprintln!( diag!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level() self.appearance.palette_level()
); );
+256
View File
@@ -11,10 +11,14 @@
//! | 条件 | 为什么需要 33 ms 节拍 | //! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---| //! |---|---|
//! | 截图通道 | 第 3 帧要落盘 | //! | 截图通道 | 第 3 帧要落盘 |
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | //! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 | //! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | //! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//! //!
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 //! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//! //!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2) //! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
@@ -43,6 +47,7 @@ use std::time::Instant;
use iced::{window, Subscription}; use iced::{window, Subscription};
use super::components::motion;
use super::message::Message; use super::message::Message;
use super::state::App; use super::state::App;
@@ -85,10 +90,261 @@ impl App {
/// 现在要不要**时钟**(见模块文档的表)。 /// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool { pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot() self.has_screenshot()
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|| motion::needs_frames()
|| !self.state.toasts.is_empty() || !self.state.toasts.is_empty()
|| !self.state.running().is_empty() || !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self || self
.watch_until .watch_until
.is_some_and(|deadline| Instant::now() < deadline) .is_some_and(|deadline| Instant::now() < deadline)
} }
} }
#[cfg(test)]
mod tests {
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
//!
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
//!
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
use super::*;
use crate::app::components::motion::{Slot, FRAME_MS};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
///
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
fn idle_app() -> App {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
///
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
#[test]
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
let mut app = idle_app();
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(
app.watch_until.is_none(),
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
);
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
assert!(
app.needs_frames(),
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
);
run_until_idle();
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
assert!(
!app.needs_frames(),
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
);
motion::reset();
}
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
#[test]
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
let mut app = idle_app();
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
run_until_idle();
assert!(!app.needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
run_until_idle();
assert!(!app.needs_frames());
motion::reset();
}
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
#[test]
fn pages_that_render_elsewhere_do_not_start_a_track() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
assert!(
!motion::needs_frames(),
"账户页没有 pages 树可动,不许白开帧"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
run_until_idle();
motion::reset();
}
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
#[test]
fn without_a_window_nothing_animates() {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
let _ = app.update(Message::Boot);
app.watch_until = None;
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
run_until_idle();
motion::reset();
}
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
///
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
#[test]
fn no_scale_requests_no_frames_at_all() {
motion::reset();
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
motion::configure(MotionScale::NONE, None);
// 三种状态变化:换页 / 换详情 / 再换回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(!app.needs_frames());
run_until_idle();
assert!(!motion::needs_frames());
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
motion::reset();
}
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
///
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
#[test]
fn there_is_exactly_one_frame_subscription() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rust_files(&src, &mut files);
assert!(
files.len() >= 40,
"只扫到 {} 个文件:扫描逻辑坏了",
files.len()
);
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<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);
}
}
}
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
#[test]
fn slots_are_the_three_documented_ones() {
assert_eq!(Slot::ALL.len(), 3);
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
}
}
+2
View File
@@ -12,6 +12,8 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)] #[cfg(test)]
mod appearance; mod appearance;
#[cfg(test)] #[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter; mod presenter;
use super::message::Message; use super::message::Message;
+2 -2
View File
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings); assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP); assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
drop(_tree); drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。 // 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0)); app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0); assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
} }
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。 /// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
@@ -0,0 +1,145 @@
//! 「启动参数保存失败」的**整环**回归(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, "草稿还在,脏标记也不能掉");
}
+102 -12
View File
@@ -20,11 +20,15 @@
//! //!
//! | 场景 | 时钟 | 周期 | 谁触发 | //! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---| //! |---|---|---|---|
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | //! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] | //! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
//! //!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
//! 下一拍就退回心跳(见 `components::motion`)。
use crate::app::components::motion;
use crate::app::diag::diag;
use std::time::Instant; use std::time::Instant;
use iced::{window, Task}; use iced::{window, Task};
@@ -44,16 +48,44 @@ use super::CAPTURE_AT_FRAME;
mod account; mod account;
mod agent; mod agent;
mod appearance; mod appearance;
mod boot_env; pub(super) mod boot_env;
mod clock; mod clock;
mod content; mod content;
mod content_pack;
mod dialog;
mod dialog_fixture;
mod download; mod download;
mod instances;
mod motion_sync;
mod page_messages; mod page_messages;
mod settings; 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 { impl App {
/// 一条消息进来,改状态并返回后续任务。 /// 一条消息进来,改状态并返回后续任务。
///
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
pub(super) fn update(&mut self, message: Message) -> Task<Message> { 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 { match message {
Message::Boot => self.on_boot(), Message::Boot => self.on_boot(),
Message::WindowReady(id) => { Message::WindowReady(id) => {
@@ -72,7 +104,7 @@ impl App {
match hwnd { match hwnd {
Some(_) => self.apply_window_alpha("boot"), Some(_) => self.apply_window_alpha("boot"),
None => { None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None); self.log_appearance("boot", None);
} }
} }
@@ -86,7 +118,7 @@ impl App {
iced::theme::Mode::Dark => SchemeFlavor::Dark, iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light, iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
}; };
eprintln!("[theme] 系统主题 = {mode:?}"); diag!("[theme] 系统主题 = {mode:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。 // 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现 // 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。 // `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
@@ -116,7 +148,16 @@ impl App {
Task::none() Task::none()
} }
Message::Page(message) => self.on_page(message), Message::Page(message) => self.on_page(message),
Message::Agent(message) => self.on_agent(message), Message::Dialog(message) => dialog::on_dialog(self, message),
Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message {
if is_escape(event) && dialog::escape(self) {
return Task::none();
}
}
self.on_agent(message)
}
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => { Message::Select(nav) => {
self.state.navigate(route_of(nav)); self.state.navigate(route_of(nav));
@@ -144,7 +185,7 @@ impl App {
Message::Captured(screenshot) => self.on_captured(&screenshot), Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => { Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收) // gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
eprintln!("[gallery] {} pressed", message.id()); diag!("[gallery] {} pressed", message.id());
Task::none() Task::none()
} }
} }
@@ -157,12 +198,16 @@ impl App {
fn on_boot(&mut self) -> Task<Message> { fn on_boot(&mut self) -> Task<Message> {
let mut tasks = vec![window::latest().map(Message::WindowReady)]; 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`。 // M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络; // 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨 // 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
// 「没配置」与「配了但登录失败」。 // 「没配置」与「配了但登录失败」。
eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line()); diag!("{}", unlucky_cli::commands::msa_client_id_log_line());
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
@@ -173,6 +218,8 @@ impl App {
self.appearance.apply_env(); self.appearance.apply_env();
self.rebuild_ui(); self.rebuild_ui();
self.log_appearance("boot", None); self.log_appearance("boot", None);
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
boot_env::record_boot_log(self);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() { if self.options.wants_system_theme() {
@@ -191,7 +238,7 @@ impl App {
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope, // 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。 // 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = boot_env::env_start_route() { if let Some(route) = boot_env::env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}"); diag!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route); self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。 // 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true; self.auto_selected = true;
@@ -199,6 +246,10 @@ impl App {
self.ensure_content_loaded(); self.ensure_content_loaded();
} }
account::force_account_state(self); 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.auto_select();
self.view_dirty = true; self.view_dirty = true;
@@ -210,27 +261,66 @@ impl App {
Task::batch(tasks) 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 过期、观察窗关闭)之后没有别的东西会叫醒 /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> { 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() { if !self.needs_frames() {
let now = Instant::now(); let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK); let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
self.last_tick = now; self.last_tick = now;
self.advance(elapsed); self.advance(elapsed);
} }
// M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒
// 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。
self.motion_fixture_after_idle();
heartbeat() heartbeat()
} }
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。 /// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
/// [`CAPTURE_AT_FRAME`] 帧出图。
/// ///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。 /// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> { fn on_frame(&mut self) -> Task<Message> {
self.frames += 1; 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 now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick); let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK { if elapsed >= TICK {
@@ -256,9 +346,9 @@ impl App {
}; };
match crate::shot::save(screenshot, &path) { match crate::shot::save(screenshot, &path) {
Ok(summary) => { Ok(summary) => {
eprintln!("{}", summary.describe()); diag!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。 // M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
eprintln!( diag!(
"[shot] screen={} theme={} renderer={} route={:?}", "[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(), self.options.screen.name(),
crate::shot::flavor_slug(&self.ui), crate::shot::flavor_slug(&self.ui),
@@ -266,7 +356,7 @@ impl App {
self.state.route() self.state.route()
); );
} }
Err(error) => eprintln!("[shot] 失败:{error}"), Err(error) => diag!("[shot] 失败:{error}"),
} }
iced::exit() iced::exit()
+25 -2
View File
@@ -95,7 +95,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request)); self.dispatch(AppCommand::AddAuthlibAccount(request));
} }
} }
Panel::Microsoft => return self.on_account(Account::StartBrowser), Panel::Microsoft => return self.on_account(Account::StartDevice),
Panel::None => {} Panel::None => {}
}, },
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -136,7 +136,8 @@ impl App {
match account.kind { match account.kind {
AccountKind::Microsoft => { AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft; self.account_form.panel = Panel::Microsoft;
self.dispatch(AppCommand::StartBrowserLogin); // M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
self.dispatch(AppCommand::StartDeviceLogin);
} }
AccountKind::Authlib => { AccountKind::Authlib => {
self.account_form.open(Track::Authlib); self.account_form.open(Track::Authlib);
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
app.state.device_login_started = app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600)); 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)` // 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => { "authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib; app.account_form.panel = crate::pages::account::Panel::Authlib;
@@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
"closed" => { "closed" => {
let _ = app.on_account(crate::pages::account::Message::Close); 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 an account」展开态(P2-4 的收敛后形态)
"add" => { "add" => {
app.account_form.section_open = true; 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); let _ = app.on_account(Account::StartDevice);
match &app.account.device { match &app.account.device {
DevicePanel::Code { DevicePanel::Code {
code, remaining, .. code, countdown, ..
} => { } => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
} }
other => panic!("进行中状态必须可见,实际 {other:?}"), other => panic!("进行中状态必须可见,实际 {other:?}"),
} }
+6 -5
View File
@@ -29,6 +29,7 @@
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻 //! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。 //! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
use crate::app::diag::diag;
use iced::keyboard::{key, Key}; use iced::keyboard::{key, Key};
use iced::Task; use iced::Task;
@@ -141,10 +142,10 @@ impl App {
self.drain_agent(); self.drain_agent();
} }
SubmitOutcome::Failed(failure) => { SubmitOutcome::Failed(failure) => {
eprintln!("[agent] 提交失败({})", failure.code()); diag!("[agent] 提交失败({})", failure.code());
} }
SubmitOutcome::Rejected => { SubmitOutcome::Rejected => {
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住"); diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
} }
} }
} }
@@ -204,7 +205,7 @@ impl App {
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm { if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow; self.agent.focus = Focus::ConfirmAllow;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)"); diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm { } else if !pending && on_confirm {
self.agent.focus = Focus::Composer; self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false; self.agent.keyboard_mode = false;
@@ -279,7 +280,7 @@ impl App {
} }
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent { if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
eprintln!("[agent] rail=detached → Route::Agent"); diag!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent); self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。 // 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true; self.auto_selected = true;
@@ -371,7 +372,7 @@ impl App {
let _ = self.on_agent(message); let _ = self.on_agent(message);
} else { } else {
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。 // 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
eprintln!( diag!(
"[agent] Enter on focus={} handled by the composer", "[agent] Enter on focus={} handled by the composer",
self.agent.focus.name() self.agent.focus.name()
); );
+2 -1
View File
@@ -28,6 +28,7 @@
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 //! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
//! (M116 取消了 8/12/16 固定档)。 //! (M116 取消了 8/12/16 固定档)。
use crate::app::diag::diag;
use crate::platform; use crate::platform;
use crate::platform::win32::corner::{self, CornerApplied}; use crate::platform::win32::corner::{self, CornerApplied};
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome}; use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
@@ -73,7 +74,7 @@ impl App {
"frame=? layered=?".to_owned(), "frame=? layered=?".to_owned(),
), ),
}; };
eprintln!( diag!(
"{}", "{}",
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by) corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
); );
+149 -2
View File
@@ -1,8 +1,11 @@
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。 //! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。
//! //!
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线): //! 从 `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; use unlucky_presenter::router::Route;
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。 /// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
@@ -24,3 +27,147 @@ pub(super) fn env_start_route() -> Option<Route> {
_ => None, _ => 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()),
),
);
}
+36 -4
View File
@@ -17,6 +17,7 @@
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干, //! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。 //! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
use crate::app::diag::diag;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use unlucky_presenter::dto::AppCommand; use unlucky_presenter::dto::AppCommand;
@@ -42,6 +43,16 @@ impl App {
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true; 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, // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -64,10 +75,14 @@ impl App {
let reloaded = let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some(); !self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded { if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观"); diag!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme(); self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance(); self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
} }
if self.view_dirty { if self.view_dirty {
@@ -78,8 +93,8 @@ impl App {
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)—— // 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!( diag!(
"[tick] n={} frames={} refreshes={} clock={}", "[tick] n={} frames={} refreshes={} clock={} motion={}",
self.ticks, self.ticks,
self.frames, self.frames,
self.refreshes, self.refreshes,
@@ -87,7 +102,10 @@ impl App {
"frames" "frames"
} else { } else {
"idle-1hz" "idle-1hz"
} },
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
// 也是「动画期间重绘成本」的记账口径,见报告)
crate::app::components::motion::samples()
); );
} }
} }
@@ -111,12 +129,26 @@ impl App {
if events.is_empty() { if events.is_empty() {
return; return;
} }
let mut clean_scan = false;
for event in events { 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;
}
self.state.apply(event); self.state.apply(event);
} }
self.view_dirty = true; self.view_dirty = true;
self.auto_select(); self.auto_select();
self.refresh_view(); self.refresh_view();
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
if clean_scan {
self.continue_import_after_clean_scan();
}
} }
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。 /// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
+43 -144
View File
@@ -16,9 +16,10 @@
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时 //! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use crate::app::diag::diag;
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView, AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
ImportRequestView, PackCommand, ExportRequestView,
}; };
use crate::pages::content::{Blocker, Content}; use crate::pages::content::{Blocker, Content};
@@ -77,7 +78,12 @@ impl App {
self.with_query(|query| query.loader = loader) self.with_query(|query| query.loader = loader)
} }
pages::content::Message::Select(id) => { pages::content::Message::Select(id) => {
self.content.selected = Some(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.view_dirty = true; self.view_dirty = true;
} }
pages::content::Message::SetTarget(id) => { pages::content::Message::SetTarget(id) => {
@@ -98,155 +104,26 @@ impl App {
self.dispatch(AppCommand::InstallContent(Box::new(request))); 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`) // M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message), pages::content::Message::Pack(message) => self.on_pack(message),
} }
Task::none() 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」必须真的进请求** /// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。 /// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
fn pack_export_request(&self) -> Option<ExportRequestView> { pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?; let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export( Some(pages::content::request_export(
&self.content.pack, &self.content.pack,
@@ -299,6 +176,26 @@ impl App {
.is_some_and(|results| results.curseforge) .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::can_install`)。
/// ///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`), /// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
@@ -312,10 +209,10 @@ impl App {
if !model.can_install() { if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做 // 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) { for reason in model.blocker_reasons(catalog) {
eprintln!("[content] 装入被挡下:{reason}"); diag!("[content] 装入被挡下:{reason}");
} }
if model.blockers.contains(&Blocker::NeedsPipeline) { if model.blockers.contains(&Blocker::NeedsPipeline) {
eprintln!("[content] 整合包装入未实现(M111)"); diag!("[content] 整合包装入未实现(M111)");
} }
return None; return None;
} }
@@ -334,6 +231,8 @@ impl App {
instance: target.id.clone(), instance: target.id.clone(),
game_version: target.mc_version.clone(), game_version: target.mc_version.clone(),
loader: target.loader, loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
}) })
} }
} }
@@ -0,0 +1,240 @@
//! 整合包导入/导出区的消息落点(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,
})
}
}
+104
View File
@@ -0,0 +1,104 @@
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
use iced::Task;
use unlucky_presenter::toast::ToastKind;
use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message {
DialogMessage::Primary => {
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else {
app.close_dialog();
return Task::none();
};
let done = export::export(&card.report, app.lang());
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
// (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
app.state.toasts.push(
if done.ok {
ToastKind::Success
} else {
ToastKind::Error
},
done.text.clone(),
);
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(done.text);
}
app.view_dirty = true;
app.refresh_view();
Task::none()
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
app.refresh_view();
Task::none()
}
DialogMessage::Toggle => {
app.toggle_dialog_detail();
Task::none()
}
DialogMessage::OpenFolder => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let problem = export::open_folder(&card.report, app.lang());
if !problem.is_empty() {
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(problem.clone());
}
app.state.toasts.push(ToastKind::Error, problem);
app.view_dirty = true;
app.refresh_view();
}
Task::none()
}
DialogMessage::CopyPaths => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(export::paths_text(&card.report))
}
DialogMessage::CopyPath(index) => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(path.display().to_string())
}
}
}
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool {
if app.state.dialogs.current().is_none() {
return false;
}
app.close_dialog();
app.refresh_view();
true
}
@@ -0,0 +1,152 @@
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
//!
//! ## 为什么要有它
//!
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
//!
//! 取值:
//!
//! | 值 | 现场 |
//! |---|---|
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
//!
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
use std::path::{Path, PathBuf};
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_scan::{self, CrashSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use crate::app::state::App;
/// 现场根(`%TEMP%/ull-m187-fixture-<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()
}
+174
View File
@@ -0,0 +1,174 @@
//! 实例页「启动参数」区的消息落点(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::dto::{AppCommand, LaunchArgsPatch};
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 启动参数区的一条消息。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else {
return Task::none();
};
if !self.launch_form.bound_to(&instance) {
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
return Task::none();
}
match message {
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`)。
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, "恢复之后还得点保存才落盘");
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[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", "派发不改表单内容");
}
}
@@ -0,0 +1,166 @@
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
//!
//! ## 为什么这条不能在 `view` 里
//!
//! iced 0.14 的处理顺序是:
//!
//! ```text
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
//! ```
//!
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
//! **结构上**不可能暴露这条。
//!
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
//!
//! ## 标识从哪来
//!
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
use unlucky_presenter::router::Route;
use crate::app::components::motion;
use crate::pages;
use super::boot_env::MotionFixture;
use super::*;
impl App {
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
pub(in crate::app) fn sync_motion(&mut self) {
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
if self.window.is_none() && !self.has_screenshot() {
return;
}
let route = self.state.route();
let screen = screen_key(route);
let detail = match route {
Route::Detail(id) => Some(id.as_str()),
_ => None,
};
motion::observe(screen, detail);
}
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
pub(in crate::app) fn motion_fixture(&mut self) {
let Some(fixture) = super::boot_env::env_motion_fixture() else {
return;
};
let route = match (fixture, self.frames) {
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
(MotionFixture::Detail, 1) => Route::Instances,
(MotionFixture::Detail, 2) => {
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
Route::Detail(first)
}
(MotionFixture::Route(route), 1) => route,
_ => return,
};
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
///
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
/// 返回 `true` = 这一拍真的跳了页。
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
use std::sync::atomic::{AtomicU32, Ordering};
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
let Some(target) = boot_env::env_motion_fixture_at() else {
return false;
};
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
if beat != target {
return false;
}
let route = match boot_env::env_motion_fixture() {
Some(MotionFixture::Route(route)) => route,
Some(MotionFixture::Detail) => {
let Some(id) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return false;
};
Route::Detail(id)
}
None => Route::Download,
};
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
true
}
}
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
///
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<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)
}
#[cfg(test)]
mod tests {
use super::*;
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
#[test]
fn only_the_five_screens_get_a_key() {
let keys: Vec<Option<u64>> = [
Route::Instances,
Route::Download,
Route::Content,
Route::Settings,
Route::Logs,
Route::Detail("survival".to_string()),
Route::Accounts,
Route::Agent,
]
.iter()
.map(screen_key)
.collect();
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
assert_eq!(
keys[5],
Some(1),
"详情页归「实例」那一屏(与 Screen::of 同源)"
);
assert_eq!(keys[6], None, "账户页不归 pages 画");
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
}
}
@@ -21,6 +21,7 @@
use unlucky_i18n::{Lang, LangChoice}; use unlucky_i18n::{Lang, LangChoice};
use crate::app::diag::diag;
use crate::pages; use crate::pages;
use crate::ui::lang::LangState; use crate::ui::lang::LangState;
@@ -54,6 +55,8 @@ impl App {
} }
} }
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), 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::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change), pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发 // M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -88,11 +91,27 @@ impl App {
self.view_dirty = true; self.view_dirty = true;
self.refresh_view(); self.refresh_view();
} }
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
pages::Message::Instances(message) => return self.on_instance_message(message),
// M114:账户页(三轨登录 / 列表动作 / 表单输入) // M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message), pages::Message::Account(message) => return self.on_account(message),
// M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推
// (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。
pages::Message::Action(pages::Action::OpenLogs) => {
return match crate::app::logs_folder::open() {
Ok(dir) => {
diag!("[logs] 已打开日志文件夹 {}", dir.display());
Task::none()
}
Err(error) => {
diag!("[logs] 打开日志文件夹失败:{error}");
iced::clipboard::write(error)
}
};
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => { pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
} }
} }
@@ -123,9 +142,7 @@ impl App {
self.lang = LangState::of(choice); self.lang = LangState::of(choice);
self.lang.log(); self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) { if !self.bridge.persist_ui_language(choice.setting_value()) {
eprintln!( diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
} }
self.lang.log_cjk(); self.lang.log_cjk();
self.view_dirty = true; self.view_dirty = true;
@@ -1,17 +1,170 @@
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。 //! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
//! //!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。 //! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
//! //!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。 //! | 消息 | 做什么 |
//! |---|---|
//! | `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`)。用户在输入框里改了但没
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
use super::super::*; use super::super::*;
use crate::pages::settings::ai::{Probe, ProbeResult};
impl App { impl App {
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。 /// 助手分组的一条消息。
pub(super) fn on_settings_ai_about( pub(super) fn on_settings_ai_about(
&mut self, &mut self,
message: pages::settings::ai_about::Message, message: pages::settings::ai_about::Message,
) -> Task<Message> { ) -> Task<Message> {
match 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)),
),
))
},
)
} }
} }
+302 -16
View File
@@ -1,18 +1,34 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。 //! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个 //! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。 //! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//! //!
//! 消息进来的通道有两条,都不经过共享文件: //! | 消息 | 做什么 |
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`; //! |---|---|
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))` //! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! (`app::Message::Page` 是既有通道,不用加新变体)。 //! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//! //!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线 //! ## 为什么「设为默认」不攒到「保存」里
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的 //!
//! `the_group_dispatch_has_no_catch_all_arm` 会红。 //! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
use super::super::*; use 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 { impl App {
/// Java 分组的一条消息。 /// Java 分组的一条消息。
@@ -20,14 +36,284 @@ impl App {
&mut self, &mut self,
message: pages::settings::java::Message, message: pages::settings::java::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::java::Message;
match message { match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 Message::Scan => return self.start_java_scan(),
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 Message::CancelScan => {
#[cfg(test)] self.settings.java.scan.cancel();
pages::settings::java::Message::Ping => { self.hydrate_java_scan();
self.view_dirty = true;
Task::none()
} }
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()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
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();
}
self.view_dirty = true;
Task::none()
}
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
fn add_java_manual(&mut self) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
let draft = self.settings.java.add_draft.clone();
match pages::settings::java::store::add_manual(&config, &draft) {
Ok((binary, row)) => {
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
if !self
.settings
.java
.runtimes
.iter()
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
{
self.settings.java.runtimes.push(row);
self.settings
.java
.runtimes
.sort_by_key(|runtime| runtime.major);
}
self.settings.java.add_draft = String::new();
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
self.settings.java.scanned = true;
self.settings.java.notice = Some(Notice::Added(binary));
}
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
}
self.view_dirty = true;
}
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
fn select_java_default(&mut self, binary: &str) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::set_selected(&config, binary) {
Ok(snapshot) => {
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
fn save_java_settings(&mut self) {
let state = &self.settings.java;
if let Err(refusal) = state.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
self.view_dirty = true;
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::save(&config, &self.settings.java) {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.java.dirty = false;
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::Saved);
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
///
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
/// (页面就不画门槛那一行,也不假装有要求)。
fn java_context(&self) -> Context {
let tiles = self.state.tiles();
let routed = match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => {
tiles.iter().find(|cell| &cell.instance.id == id)
}
_ => None,
};
let Some(cell) = routed.or_else(|| tiles.first()) else {
return Context::default();
};
let instance = cell.instance;
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
Context {
required: Some(Requirement {
instance: instance.name.clone(),
version: instance.mc_version.clone(),
minimum: requirement.minimum,
maximum: requirement.maximum,
}),
layer: Some(match instance.launch.memory_mb {
Some(mb) => Layer::Instance {
instance: instance.name.clone(),
mb,
},
None => Layer::Global,
}),
effective_memory_mb: Some(instance.launch.memory_effective_mb),
}
}
}
/// 写盘失败 → 回执:三档都走 i18n key。
///
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
/// 界面上给一句页面自己的「写不进去」。
///
/// `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
} }
} }
} }
@@ -0,0 +1,118 @@
//! 「下载一个 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()
}
@@ -0,0 +1,122 @@
//! 三个设置键的 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);
}
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,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless(); let mut app = headless();
app.view_dirty = false; app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings( let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping), pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
"4096".to_string(),
)),
))); )));
assert!( assert!(
@@ -265,3 +269,128 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了" "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;
@@ -0,0 +1,374 @@
//! 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();
}
@@ -0,0 +1,229 @@
//! 自更新动作的落点(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();
match std::process::Command::new(&exe).args(&args).spawn() {
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(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+18 -4
View File
@@ -21,12 +21,14 @@ use super::state::{log_first_frame, App};
use super::APP_TITLE; use super::APP_TITLE;
mod account_page; mod account_page;
mod instances; pub(super) mod instances;
mod logs; mod logs;
mod rail; mod rail;
mod update;
use instances::{detail_of, runtime_status, supporting_line}; use instances::{detail_of, runtime_status_row, supporting_line};
use logs::logs_model; use logs::logs_model;
use update::update_of;
impl App { impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。 /// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -73,7 +75,8 @@ impl App {
}; };
let account = self.rail_account(); let account = self.rail_account();
match pages::Screen::of(self.state.route()) { // M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account( Some(_) => shell::view_with_account(
&self.ui, &self.ui,
shell::NavRail { shell::NavRail {
@@ -92,6 +95,8 @@ impl App {
}, },
// M106:设置页 General 组要画「当前档位 + 生效语言」。 // M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(), self.language(),
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
&self.launch_form,
) )
.map(Message::Page), .map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width) agent::docked(&self.ui, session, agent_catalog, self.width)
@@ -120,6 +125,11 @@ impl App {
self.options.force_state, self.options.force_state,
self.lang(), self.lang(),
), ),
};
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree,
} }
} }
} }
@@ -136,6 +146,7 @@ impl App {
pub(super) fn model( pub(super) fn model(
state: &AppState, state: &AppState,
width: f32, width: f32,
height: f32,
forced: Option<crate::cli::ForcedState>, forced: Option<crate::cli::ForcedState>,
category: pages::Category, category: pages::Category,
draft: &pages::InstallDraft, draft: &pages::InstallDraft,
@@ -154,7 +165,7 @@ pub(super) fn model(
id: cell.instance.id.clone(), id: cell.instance.id.clone(),
name: cell.instance.name.clone(), name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog), supporting: supporting_line(cell.instance, catalog),
status: runtime_status(&cell.instance.runtime, catalog), status: runtime_status_row(&cell.instance.runtime, catalog),
}) })
.collect(); .collect();
@@ -170,12 +181,15 @@ pub(super) fn model(
pages::Model { pages::Model {
screen, screen,
window_width: width, window_width: width,
window_height: height,
active: route.nav(), active: route.nav(),
instances: pages::Instances { rows, detail }, instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()), content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang), logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
lang, lang,
forced: forced.map(|state| match state { forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。 // `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+111 -7
View File
@@ -18,10 +18,13 @@
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、 //! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。 //! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind}; use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome,
};
use crate::pages::{Detail, Meta}; use crate::pages::{Detail, LaunchFacts, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。 /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -59,7 +62,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
} }
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 /// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> { pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime { match runtime {
InstanceRuntime::Idle => None, InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言 // 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
@@ -71,13 +74,103 @@ pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
InstanceRuntime::Running { pid } => { InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())])) Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
} }
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案 // 失败有**两种形状**,各说各的话(复审 P1-1):
InstanceRuntime::Failed { reason } => { // - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)])) // - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
// 绝不说成「启动游戏进程失败」。
//
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
}
}
/// 一条失败状态行。
///
/// `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()`(那是核心层的中文诊断串,英文界面会漏中文)。 /// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str { fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage { catalog.text(match stage {
@@ -135,6 +228,17 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime, instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } 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(),
},
} }
} }
+6 -2
View File
@@ -74,7 +74,9 @@ fn crashed_only() -> AppState {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})); }));
state state
@@ -166,7 +168,9 @@ fn an_imported_crash_file_still_shows_its_findings() {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})); }));
state.apply_core(CoreEvent::DiagnosisReady(report( state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt", "file:crash-2026-11-02_19.40.15-client.txt",
+5 -15
View File
@@ -7,6 +7,9 @@ use unlucky_presenter::dto::{
}; };
use unlucky_presenter::mock_data::mock_instances; use unlucky_presenter::mock_data::mock_instances;
#[path = "tests/launch.rs"]
mod launch;
fn instance(runtime: InstanceRuntime) -> InstanceSummary { fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary { InstanceSummary {
id: "survival".to_string(), id: "survival".to_string(),
@@ -22,6 +25,7 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(), isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(), version_id: "1.20.4".to_string(),
version_present: true, version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
} }
} }
@@ -38,6 +42,7 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
model( model(
state, state,
1180.0, 1180.0,
760.0,
forced, forced,
pages::Category::All, pages::Category::All,
&draft, &draft,
@@ -121,21 +126,6 @@ fn a_busy_instance_offers_stop_instead_of_play() {
assert!(detail.can_stop, "启动中要能 Stop"); 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] #[test]
fn timestamps_are_sliced_not_guessed() { fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog(); let en = Lang::En.catalog();
@@ -0,0 +1,149 @@
//! 启动失败/收尾的**文案**用例(从 `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
@@ -0,0 +1,157 @@
//! 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());
}
}
+5
View File
@@ -10,6 +10,8 @@
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法 //! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton) //! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
//! divider.rs ← COMPBASE(15 §22) //! divider.rs ← COMPBASE(15 §22)
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它)
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13) //! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
//! inputs/ ← 同上(§9/§20/§11/§8) //! inputs/ ← 同上(§9/§20/§11/§8)
//! feedback/ ← 同上(§12/§14/§7/§15) //! feedback/ ← 同上(§12/§14/§7/§15)
@@ -51,7 +53,10 @@ pub mod gallery;
pub mod inputs; pub mod inputs;
pub mod interaction; pub mod interaction;
pub mod layout; pub mod layout;
/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。
pub mod motion;
pub mod navigation; pub mod navigation;
pub mod scroll;
pub mod selection; pub mod selection;
#[cfg(test)] #[cfg(test)]
@@ -0,0 +1,182 @@
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。
//!
//! ## 这个模块解决什么
//!
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧**
//! ([`needs_frames`])。
//!
//! ## 有界泵的契约(四条,都有守卫)
//!
//! | 契约 | 为什么 | 守在哪 |
//! |---|---|---|
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` |
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) |
//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` |
//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` |
//!
//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**:
//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**),
//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。
//!
//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支,
//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳
//! 天然不会双订阅——它们共享同一个布尔。
//!
//! ## `MotionScale::NONE` 是**真归零**
//!
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见
//! `pump::tests::none_scale_never_starts_a_track`。
//!
//! ## 为什么是 thread-local 而不是 `App` 的字段
//!
//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程,
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
//!
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了
//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。
//!
//! ## 谁在什么时候调
//!
//! | 时机 | 调用 | 在哪 |
//! |---|---|---|
//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) |
//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` |
//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` |
//!
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
//!
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
mod pump;
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动),
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
#[allow(unused_imports)]
pub use pump::{offset, spec, Slot, Spec, FRAME_MS};
use std::cell::{Cell, RefCell};
use std::time::Instant;
use iced::widget::container;
use iced::{Element, Length, Padding};
use unlucky_design::motion::MotionScale;
// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等
// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对
// 展开后的 item 报的)。
thread_local! {
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
#[allow(clippy::missing_const_for_thread_local)]
static PUMP: RefCell<pump::Pump> = const { RefCell::new(pump::Pump::new()) };
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
#[allow(clippy::missing_const_for_thread_local)]
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
}
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
pub fn configure(scale: MotionScale, pin: Option<f64>) {
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
}
/// 当前档位。
pub fn scale() -> MotionScale {
PUMP.with(|pump| pump.borrow().scale())
}
/// 已推进的帧数。
pub fn samples() -> u64 {
PUMP.with(|pump| pump.borrow().samples())
}
/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。
pub fn needs_frames() -> bool {
PUMP.with(|pump| pump.borrow().is_animating())
}
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
///
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
/// `detail` 是详情页的实例 id。
///
/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1)
///
/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces`
/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插,
/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开,
/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。
///
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
pub fn observe(screen: Option<u64>, detail: Option<&str>) {
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail));
if started {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
}
/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。
pub fn sample() -> (f32, f32) {
PUMP.with(|pump| pump.borrow().offsets())
}
/// 推进时间轴(`App::on_frame` 每帧调一次)。
pub fn advance() {
let now = Instant::now();
let elapsed_ms = LAST_FRAME.with(|last| {
let previous = last.replace(Some(now));
previous.map_or(0.0, |previous| {
now.saturating_duration_since(previous).as_secs_f64() * 1000.0
})
});
PUMP.with(|pump| {
pump.borrow_mut().advance(elapsed_ms);
});
}
/// 把入场位移套到树上。
///
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。
/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。
pub fn shifted<'a, M: 'a>(
element: impl Into<Element<'a, M>>,
(dx, dy): (f32, f32),
) -> Element<'a, M> {
container(element.into())
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding {
top: dy,
right: 0.0,
bottom: 0.0,
left: dx,
})
.into()
}
#[cfg(test)]
pub fn reset() {
PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new());
LAST_FRAME.with(|last| last.set(None));
}
/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。
#[cfg(test)]
pub fn advance_by(ms: f64) {
PUMP.with(|pump| {
pump.borrow_mut().advance(ms);
});
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,348 @@
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
//!
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
//!
//! ## 三个概念
//!
//! | 名字 | 是什么 | 谁定 |
//! |---|---|---|
//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 |
//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) |
//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 |
//!
//! ## 为什么规格表在代码里而不是页面里
//!
//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种
//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。
use std::collections::BTreeMap;
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
use unlucky_design::spacing::Space;
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
///
/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看:
///
/// | 什么 | 频率 | 由谁决定 |
/// |---|---|---|
/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 |
/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) |
///
/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。
/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排**
/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。
pub const FRAME_MS: f64 = 16.0;
/// 单次推进的**最大步进**(4 帧 = 64 ms)。
///
/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果
/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完
/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道:
/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。
pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0;
/// 一个动效槽。
///
/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加
/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对:
/// 新的一次内容变化取代上一次。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Slot {
/// 首屏进入:进程内只在第一帧跑一次。
Boot,
/// 换页:路由换了(导航轨点选、返回、启动直达)。
Screen,
/// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。
Detail,
}
impl Slot {
/// 全部槽(遍历顺序稳定,写日志/断言用)。
pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail];
/// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。
#[cfg(test)]
pub const fn name(self) -> &'static str {
match self {
Slot::Boot => "boot",
Slot::Screen => "screen",
Slot::Detail => "detail",
}
}
}
/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spec {
/// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。
pub duration: DurationToken,
/// 缓动曲线。
pub easing: Easing,
}
/// 槽 → 规格(**唯一**一张表)。
///
/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/
/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**:
///
/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为
/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的;
/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完
/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。
pub const fn spec(slot: Slot) -> Spec {
match slot {
Slot::Boot => Spec {
duration: DurationToken::Medium4,
easing: Easing::Standard,
},
Slot::Screen => Spec {
duration: DurationToken::Medium2,
easing: Easing::Standard,
},
Slot::Detail => Spec {
duration: DurationToken::Medium3,
easing: Easing::Standard,
},
}
}
/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。
///
/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子,
/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。
pub fn offset(slot: Slot) -> (f32, f32) {
match slot {
Slot::Boot => (0.0, Space::X4.dp()),
Slot::Screen => (0.0, Space::X3.dp()),
Slot::Detail => (Space::X2.dp(), 0.0),
}
}
/// 一条正在跑的轨道。
#[derive(Debug, Clone, Copy)]
struct Track {
/// 起跑时刻(动画时间轴的坐标,毫秒)。
start_ms: f64,
spec: Spec,
}
/// 帧泵:一条时间轴 + 每槽一条轨道。
///
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
#[derive(Debug)]
pub struct Pump {
scale: MotionScale,
/// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。
pin: Option<f64>,
now_ms: f64,
pending_ms: f64,
tracks: BTreeMap<Slot, Track>,
screen: Option<u64>,
detail: Option<String>,
painted: bool,
samples: u64,
}
impl Pump {
/// 出厂态:关着、没跑过、没有轨道。
pub const fn new() -> Self {
Self {
scale: MotionScale::NONE,
pin: None,
now_ms: 0.0,
pending_ms: 0.0,
tracks: BTreeMap::new(),
screen: None,
detail: None,
painted: false,
samples: 0,
}
}
/// 定档(启动一拍调一次)。
///
/// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于
/// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次
/// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。
pub fn configure(&mut self, scale: MotionScale, pin: Option<f64>) {
self.scale = scale;
self.pin = pin;
self.painted = false;
self.screen = None;
self.detail = None;
self.pending_ms = 0.0;
if scale.is_off() {
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
self.tracks.clear();
}
}
/// 当前档位。
pub fn scale(&self) -> MotionScale {
self.scale
}
/// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。
pub fn samples(&self) -> u64 {
self.samples
}
/// 时间轴(毫秒)。
#[cfg(test)]
pub fn now_ms(&self) -> f64 {
self.now_ms
}
/// 现在有没有动画在跑(= 要不要订阅帧)。
pub fn is_animating(&self) -> bool {
!self.tracks.is_empty()
}
/// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。
///
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
pub fn play(&mut self, slot: Slot) -> bool {
self.tracks.clear();
if self.scale.is_off() {
return false;
}
// 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」,
// 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。
let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms };
self.tracks.insert(
slot,
Track {
start_ms,
spec: spec(slot),
},
);
true
}
/// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。
pub fn progress(&self, slot: Slot) -> f32 {
let Some(track) = self.tracks.get(&slot) else {
return 1.0;
};
let duration = f64::from(self.scale.ms(track.spec.duration));
if duration <= 0.0 {
return 1.0;
}
let elapsed = self.elapsed_ms() - track.start_ms;
let t = (elapsed / duration).clamp(0.0, 1.0) as f32;
track.spec.easing.sample(t)
}
/// 当前动画时刻(钉住相位时恒为钉住值)。
fn elapsed_ms(&self) -> f64 {
self.pin.unwrap_or(self.now_ms)
}
/// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。
///
/// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释)
///
/// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→
/// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`,
/// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表
/// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里
/// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。
///
/// 三条判据(顺序即优先级):
/// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`];
/// 2. 屏变了 → [`Slot::Screen`];
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
///
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**——
/// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回
/// `Some`,仍然会跑换页那一档。
///
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
pub fn observe(&mut self, screen: Option<u64>, detail: Option<&str>) -> bool {
let mut started = false;
if !self.painted {
self.painted = true;
if screen.is_some() {
started = self.play(Slot::Boot);
}
} else if self.screen != screen {
if screen.is_some() {
started = self.play(Slot::Screen);
}
} else if self.detail.as_deref() != detail && screen.is_some() {
started = self.play(Slot::Detail);
}
self.screen = screen;
if self.detail.as_deref() != detail {
self.detail = detail.map(str::to_owned);
}
started
}
/// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。
///
/// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0`
/// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。
pub fn offsets(&self) -> (f32, f32) {
let mut dx = 0.0_f32;
let mut dy = 0.0_f32;
for slot in Slot::ALL {
let remaining = 1.0 - self.progress(slot);
let (ox, oy) = offset(slot);
dx += ox * remaining;
dy += oy * remaining;
}
(dx.round(), dy.round())
}
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
///
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
/// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表
/// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。
pub fn advance(&mut self, dt_ms: f64) -> bool {
if self.tracks.is_empty() {
self.pending_ms = 0.0;
return false;
}
if dt_ms.is_finite() && dt_ms > 0.0 {
// ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。
self.pending_ms += dt_ms.min(MAX_STEP_MS);
}
if self.pending_ms < FRAME_MS {
return false;
}
let step = std::mem::take(&mut self.pending_ms);
if self.pin.is_none() {
self.now_ms += step;
}
self.samples += 1;
let now = self.elapsed_ms();
let finished: Vec<Slot> = self
.tracks
.iter()
.filter(|(_, track)| {
let duration = f64::from(self.scale.ms(track.spec.duration));
duration <= 0.0 || now - track.start_ms >= duration
})
.map(|(slot, _)| *slot)
.collect();
for slot in finished {
self.tracks.remove(&slot);
}
true
}
}
impl Default for Pump {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,345 @@
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
//!
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。
use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS};
use super::*;
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
fn standard() -> Pump {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, None);
pump
}
/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。
fn settle(pump: &mut Pump) -> u64 {
let mut frames = 0;
for _ in 0..1250 {
if !pump.is_animating() {
break;
}
if pump.advance(FRAME_MS) {
frames += 1;
}
}
frames
}
// ------------------------------------------------------------------ 令牌表
/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。
#[test]
fn every_slot_spec_is_a_registered_token_pair() {
for slot in Slot::ALL {
let spec = spec(slot);
let ms = spec.duration.ms();
assert!(
(250..=500).contains(&ms),
"{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)",
slot.name(),
ms
);
// 缓动必须是真的曲线(端点 = 0/1)
assert!(spec.easing.sample(0.0).abs() < 1.0e-6);
assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
// 位移必须来自间距栅格(4 的倍数),且不为零
let (dx, dy) = offset(slot);
assert!(
dx > 0.0 || dy > 0.0,
"{}: 位移为 0 就没有入场感",
slot.name()
);
assert_eq!(dx % 4.0, 0.0);
assert_eq!(dy % 4.0, 0.0);
assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name());
}
// 表本身钉住:谁改了这三档,报告与出图都要跟着改
assert_eq!(spec(Slot::Screen).duration.ms(), 300);
assert_eq!(spec(Slot::Detail).duration.ms(), 350);
assert_eq!(spec(Slot::Boot).duration.ms(), 400);
}
// ------------------------------------------------------------------ 有界泵
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
///
/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把
/// `is_animating` 改成恒真。两条都让本用例红。
#[test]
fn a_finished_animation_stops_asking_for_frames() {
let mut pump = standard();
assert!(pump.play(Slot::Screen), "起跑必须成功");
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
settle(&mut pump);
assert!(
!pump.is_animating(),
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
);
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位");
// 再推进也不该有任何采样(订阅表里已经没有帧了)
let before = pump.samples();
for _ in 0..60 {
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
}
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
}
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
///
/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
#[test]
fn frames_are_capped_at_sixty_per_second() {
let mut pump = standard();
pump.play(Slot::Screen);
// 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器)
let mut advanced = 0;
for _ in 0..200 {
if pump.advance(1.0) {
advanced += 1;
}
}
assert!(
advanced <= 13,
"200 ms 内最多 13 次推进(200/16),实测 {advanced} 次"
);
assert!(
advanced >= 11,
"也别少得离谱(掉帧会看起来卡),实测 {advanced}"
);
assert_eq!(pump.samples(), advanced);
// 时间轴按真实时间走,不受「帧被合并」影响
assert!(
(pump.now_ms() - 192.0).abs() <= 16.0,
"时间轴应约 192 ms(12 × 16),实测 {}",
pump.now_ms()
);
}
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
///
/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒
/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与
/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。
#[test]
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
let mut pump = standard();
pump.play(Slot::Screen);
// 60 秒的「空闲间隔」当 dt
assert!(pump.advance(60_000.0));
assert!(
pump.is_animating(),
"一次超长 dt 不许把轨道当场删掉(那就等于没有动画)"
);
assert!(
pump.now_ms() <= MAX_STEP_MS + 1.0e-6,
"单次推进最多 {:?} ms,实测 {}",
MAX_STEP_MS,
pump.now_ms()
);
assert!(
pump.progress(Slot::Screen) < 1.0,
"进度不许一步到位(那就是闪跳)"
);
// 正常帧间隔照样把动画跑完(没被抖动卡死)
settle(&mut pump);
assert!(!pump.is_animating());
}
/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。
#[test]
fn progress_is_monotone_and_lands_on_one() {
let mut pump = standard();
pump.play(Slot::Detail);
let mut previous = 0.0;
for _ in 0..40 {
let value = pump.progress(Slot::Detail);
assert!(value >= previous, "进度倒退了:{previous} → {value}");
previous = value;
if !pump.advance(FRAME_MS) {
break;
}
}
assert_eq!(previous, 1.0, "末帧必须到位");
}
// ------------------------------------------------------------------ NONE 降级
/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。
///
/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里
/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。
#[test]
fn none_scale_never_starts_a_track() {
let mut pump = Pump::new(); // 出厂即 NONE
assert!(pump.scale().is_off());
// 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道
assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑");
assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态");
assert!(!pump.is_animating());
assert!(!pump.observe(Some(2), None));
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(!pump.observe(Some(2), Some("instance-7")));
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(!pump.is_animating());
for _ in 0..120 {
assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的");
}
assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0");
}
/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。
#[test]
fn switching_to_none_mid_flight_settles_immediately() {
let mut pump = standard();
pump.play(Slot::Screen);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
pump.configure(MotionScale::NONE, None);
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
assert_eq!(pump.progress(Slot::Screen), 1.0);
assert_eq!(pump.offsets(), (0.0, 0.0));
}
/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。
#[test]
fn half_scale_finishes_in_half_the_frames() {
let mut full = standard();
full.play(Slot::Screen);
let full_frames = settle(&mut full);
let mut half = Pump::new();
half.configure(MotionScale::HALF, None);
half.play(Slot::Screen);
let half_frames = settle(&mut half);
assert!(half_frames > 0 && full_frames > 0);
assert!(
half_frames * 2 <= full_frames + 2,
"半速档应约用一半帧数:full={full_frames} half={half_frames}"
);
}
// ------------------------------------------------------------------ 触发判据
/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。
#[test]
fn identity_changes_pick_the_right_slot() {
let mut pump = standard();
// 首帧:Boot(位移是纵向 16)
assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽");
let (dx, dy) = pump.offsets();
assert_eq!(dx, 0.0);
assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移");
assert_eq!(
pump.progress(Slot::Screen),
1.0,
"首帧不该顺带起跑 Screen 槽"
);
settle(&mut pump);
assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑");
assert_eq!(pump.offsets(), (0.0, 0.0));
// 换屏:Screen(纵向)
assert!(pump.observe(Some(2), None));
let (dx, dy) = pump.offsets();
assert_eq!(dx, 0.0);
assert_eq!(dy, offset(Slot::Screen).1);
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
settle(&mut pump);
// 同屏换详情:Detail(横向)
assert!(pump.observe(Some(2), Some("instance-7")));
let (dx, dy) = pump.offsets();
assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场");
assert_eq!(dy, 0.0);
assert!(pump.progress(Slot::Detail) < 1.0);
settle(&mut pump);
assert!(!pump.observe(Some(2), Some("instance-7")));
assert_eq!(pump.offsets(), (0.0, 0.0));
}
/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。
#[test]
fn screens_rendered_elsewhere_never_start_a_track() {
let mut pump = standard();
assert!(pump.observe(Some(1), None), "首屏开机跑 Boot");
settle(&mut pump);
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
assert!(!pump.is_animating());
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(
pump.observe(Some(1), None),
"从账户页回到某一屏仍然是换页,要跑"
);
assert_eq!(pump.offsets().1, offset(Slot::Screen).1);
}
/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。
#[test]
fn a_new_transition_supersedes_the_previous_one() {
let mut pump = standard();
assert_eq!(
pump.offsets().1,
0.0,
"还没 observe 时没有位移(采样是纯函数)"
);
pump.observe(Some(1), None);
assert_eq!(pump.offsets().1, offset(Slot::Boot).1);
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
pump.observe(Some(2), None);
assert_eq!(
pump.offsets().1,
offset(Slot::Screen).1,
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
);
}
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
#[test]
fn repeated_paints_never_restart_the_animation() {
let mut pump = standard();
pump.observe(Some(3), Some("instance-9"));
let frames = settle(&mut pump);
assert!(frames > 0);
for _ in 0..30 {
assert!(!pump.observe(Some(3), Some("instance-9")));
assert_eq!(pump.offsets(), (0.0, 0.0));
}
assert!(!pump.is_animating(), "重复绘制不该起新轨道");
}
// ------------------------------------------------------------------ 取证相位
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
#[test]
fn pinned_phase_freezes_the_clock() {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, Some(150.0));
pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半
for _ in 0..10 {
pump.advance(FRAME_MS);
}
let progress = pump.progress(Slot::Screen);
assert!(
(progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6,
"钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}"
);
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
}
+224
View File
@@ -0,0 +1,224 @@
//! 滚动条**并排**在内容右侧,不压在内容上(M173)。
//!
//! ## 根因(读 iced 0.14 的源码,不是猜测)
//!
//! `scrollable` 的竖直滚动条是**浮层**,而且内容区不扣任何横向内边距:
//!
//! - 内容拿到的约束仍是整个视口宽——`iced_widget-0.14.2/src/scrollable.rs:437` 那条
//! `layout::padded(limits, self.width, self.height, Padding::ZERO, …)`:`Scrollbar::spacing`
//! 默认 `None`,右内边距**恒 0**;
//! - 轨道按视口右边缘定位——同文件 `scrollable.rs:1970-1980`
//! `x = bounds.x + bounds.width - total_scrollbar_width`,`total_scrollbar_width =
//! width.max(scroller_width) + 2 * margin`。
//!
//! 于是轨道(本仓 6 + 2×2 = **10dp**)整条压在内容最右边那 10dp 上。屏幕上的观感就是
//! 「滚动条骑在卡片右边缘」。
//!
//! ## 为什么不用 iced 自己的 `Scrollbar::spacing(Some(..))`
//!
//! 它**只在滑块可见时**才预留(`scrollable.rs:478-500`:`if state.is_scrollbar_visible
//! { padding } else { 0.0 }`)。长页面 ⇄ 短页面切换时内容宽度会跳 10dp,卡片会横移——
//! 那是另一个缺陷(`M173` 的判据要求「有无滚动条时内容宽度一致」)。
//!
//! ## 本模块的做法:恒定槽位
//!
//! 内容外面包一层「右侧恒留 `slot`」的容器,再交给 `scrollable`:
//!
//! ```text
//! 视口 0 ───────────────────────── 视口宽 − slot │ slot │
//! │ 内容(可滚) │ 轨道 │
//! ```
//!
//! `内容可用宽度 = 视口宽 − slot` 与滑块在不在**无关**(因为内边距来自本模块,不来自 iced 的
//! 可见性判断)。`slot = rail_width + 2 * margin` 与 iced 的 `total_scrollbar_width` 同口径,
//! 轨道整个落在槽里,滑块再内缩 `margin`,所以内容与滑块之间永远隔着 `margin`。
//!
//! 逻辑像素(dp):高 DPI 下 iced 按缩放因子整体放大,槽与轨道同比例,二者不会重新重叠。
//!
//! ## 谁该用
//!
//! 所有「内容宽度吃满视口」的竖直滚动区。设置页(`pages/settings/frame.rs`)是第一个用户;
//! 实例页列表列 / 详情列、日志面板、内容库正文、版本管理侧栏目前仍是 `scrollable(...)` 裸调,
//! 同一处重叠在那些页面上照样存在(M173 的 finding 里有清单)。
use iced::widget::{container, scrollable};
use iced::{Element, Length, Padding, Theme};
/// 滚动条轨占的横向槽宽(dp)= 轨宽 + 两侧留白。
///
/// 与 iced 的 `total_scrollbar_width`(`width.max(scroller_width) + 2 * margin`)同一个式子:
/// 参数里的 `rail_width` 传的是 `Scrollbar::width` 与 `scroller_width` 的同一个值(本仓两者相等)。
pub fn rail_slot(rail_width: f32, margin: f32) -> f32 {
rail_width + 2.0 * margin
}
/// 内容可用宽度(dp)= 视口宽 − 槽宽。
///
/// 这条式子是本模块的**判据**:`components/scroll.rs` 的测试用 iced 的布局引擎真量一遍,
/// `pages/settings/tests/scroll_fit.rs` 在产品那条 `frame::scroll` 上再量一遍。
/// 负数按 0 处理——视口比槽还窄时(产品窗口不会发生)宁可内容零宽,也不给 iced 送负宽。
#[cfg(test)]
pub fn content_width(viewport: f32, slot: f32) -> f32 {
(viewport - slot).max(0.0)
}
/// 竖直滚动区:内容右侧恒留 [`rail_slot`] 的槽,滚动条画在槽里。
///
/// `style` 就是 `scrollable::style` 回调(配色由调用方按令牌给),本模块只管几何。
pub fn vertical<'a, Message, Renderer>(
rail_width: f32,
margin: f32,
style: impl Fn(&Theme, scrollable::Status) -> scrollable::Style + 'a,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::text::Renderer + 'a,
{
scrollable(beside(rail_slot(rail_width, margin), content))
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(rail_width)
.scroller_width(rail_width)
.margin(margin),
))
.height(Length::Fill)
.style(style)
.into()
}
/// 内容 + 右侧槽位。容器宽度仍是 `Fill`(= 视口宽),扣掉的是**内容的**可用宽度。
fn beside<'a, Message, Renderer>(
slot: f32,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
container(content)
.width(Length::Fill)
.padding(Padding {
right: slot,
..Padding::ZERO
})
.into()
}
#[cfg(test)]
mod tests {
//! ## 为什么这里的守卫是**真的布局**而不是源码扫描
//!
//! iced 的控件树支持在测试里跑一遍布局:`iced_core` 在 debug 构建下给 `()` 实现了全套
//! `Renderer` trait(`iced_core-0.14.0/src/renderer/null.rs`),所以 `Element<_, _, Theme, ()>`
//! 能被 `Widget::layout` 真正量出尺寸——**不需要开窗、不需要 GPU**。
//!
//! 于是「内容可用宽度 = 视口宽 − 槽宽」是一条数值断言,而不是「看图觉得对」;把
//! [`beside`] 的 `padding` 去掉,下面的 `content_width` 立刻变成 434 而断言红。
//!
//! ⚠️ 诚实边界:`()` 的 Renderer 实现带 `#[cfg(debug_assertions)]`,所以这些测试在
//! `cargo test --release` 下编不过(本仓 CI/本地一律 dev profile 跑测试)。
use iced::advanced::layout::Limits;
use iced::advanced::widget::Tree;
use iced::widget::{container, scrollable, space};
use iced::{Rectangle, Size};
use super::*;
/// 本仓的轨道参数(真源是 `pages/frame/pieces.rs` 的两个常量,这里按「用户取值」复核)。
const RAIL: f32 = 6.0;
const MARGIN: f32 = 2.0;
/// 设置页表单列在 1180 宽窗口下的列宽(`measure_cap_px` = 424)。
const VIEWPORT: f32 = 424.0;
/// 视口高(1180×760 减去页头/页边距后的量级;只要求「长页 > 视口」「短页 < 视口」)。
const VIEWPORT_HEIGHT: f32 = 600.0;
/// 长页面(一定出滑块)与短页面(一定不出滑块)的内容高。
const LONG: f32 = 4000.0;
const SHORT: f32 = 120.0;
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
///
/// 节点树:`scrollable → [槽容器(Fill 宽,右内边距 = slot) → [内容]]`,见
/// `iced_core/src/layout.rs:201`(`padded` 的节点恒为「自己 + 一个子节点」)。
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
let filler: Element<'_, (), Theme, ()> = container(space())
.width(Length::Fill)
.height(Length::Fixed(content_height))
.into();
let mut element: Element<'_, (), Theme, ()> =
vertical::<(), ()>(RAIL, MARGIN, scrollable::default, filler);
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&Limits::new(Size::ZERO, Size::new(VIEWPORT, VIEWPORT_HEIGHT)),
);
let gutter = node.children()[0].bounds();
let content = node.children()[0].children()[0].bounds();
(gutter, content)
}
/// **本 mission 的核心判据**:内容可用宽度 = 视口宽 − 滚动条槽,且与有没有滑块无关。
#[test]
fn the_content_width_leaves_the_rail_its_own_slot() {
let slot = rail_slot(RAIL, MARGIN);
assert_eq!(slot, RAIL + 2.0 * MARGIN, "槽宽口径 = 轨宽 + 两侧留白");
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(
long.width,
content_width(VIEWPORT, slot),
"长页面(出滑块)的内容宽度必须是「视口宽 − 槽宽」"
);
assert_eq!(
short.width,
content_width(VIEWPORT, slot),
"短页面(不出滑块)的内容宽度必须**一样**:否则滑块一出现内容就横跳"
);
assert_eq!(VIEWPORT - long.width, slot, "右侧扣掉的正好是槽宽");
// 滑块落在槽的中间:内容右缘 → margin → 滑块 → margin → 视口右缘。
// 轨道按 iced 的算法定位(`scrollable.rs:1970-2005`:槽宽 = 轨宽 + 2×margin,滑块再内缩 margin),
// 所以「内容右缘 + margin」就是滑块的左缘。
let content_right = long.x + long.width;
let pill_left = content_right + MARGIN;
let pill_right = pill_left + RAIL;
assert_eq!(
pill_right + MARGIN,
VIEWPORT,
"滑块必须整个落在槽内(两侧各留 margin),否则「并排」变成「压着」"
);
}
/// 槽容器自己仍是满宽(`Fill`),否则页面为它预留的 424 会被再吃掉一截。
#[test]
fn the_slot_wrapper_keeps_the_full_viewport_width() {
let (gutter, _) = laid_out(LONG);
assert_eq!(gutter.width, VIEWPORT);
}
/// 左边界与宽度在长/短两态下**逐项相同**(M173 明写的「两张对照图 + 数值」里的数值部分)。
#[test]
fn long_and_short_pages_share_the_same_left_edge_and_width() {
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(long.x, short.x, "左边界必须一致");
assert_eq!(long.width, short.width, "宽度必须一致");
assert_eq!(short.x, 0.0, "槽只占右侧,左侧内边距为 0");
}
#[test]
fn content_width_never_goes_negative() {
assert_eq!(content_width(RAIL, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(0.0, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(VIEWPORT, 0.0), VIEWPORT);
}
}
@@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> {
Self::new(label, ChipVariant::Input, false, on_press) Self::new(label, ChipVariant::Input, false, on_press)
} }
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
///
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**:
/// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。
pub fn badge(label: &'a str) -> Self {
Self {
label,
variant: ChipVariant::Filter,
selected: true,
leading: None,
trailing: None,
on_press: None,
state: None,
}
}
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。 /// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
/// ///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的 /// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
+56 -15
View File
@@ -18,18 +18,31 @@
//! ## 不做什么 //! ## 不做什么
//! //!
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半, //! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。 //! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。
//! //!
//! ## 中文那一半(M92 / D28 后半) //! ## 中文那一半(M92 / D28 后半 / M168 实装下载)
//! //!
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`), //! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI), //! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、 //! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是 //! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
//! cosmic-text 的全局状态,不是本模块能管的事)。 //! cosmic-text 的全局状态,不是本模块能管的事)。
//!
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
//!
//! ```text
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
//! ```
//!
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
pub mod cdn;
mod cjk; mod cjk;
pub mod install;
use cjk::CjkReport; use cjk::CjkReport;
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch}; use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
@@ -173,16 +186,30 @@ fn register_termux_fonts() {
} }
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
///
/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是
/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入,
/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。
pub fn resolve_with(setting: Option<&str>) -> Resolved { pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts(); register_termux_fonts();
resolve_with_faces(setting, system_faces().as_deref())
}
/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。
///
/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。
///
/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`)
/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发,
/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。
/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
///
/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。
pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved {
let platform = Platform::current(); let platform = Platform::current();
let faces = system_faces();
let is_installed = |family: &str| -> bool { let is_installed = |family: &str| -> bool {
faces faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family))
.as_ref()
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
}; };
let families = [ let families = [
@@ -206,11 +233,11 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」, // 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。 // 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
let weights = [Weight::Regular, Weight::Medium, Weight::Bold] let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref())); .map(|weight| weight_report(platform, families[0].family, weight, faces));
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」, // D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
let cjk_report = cjk::probe_with(setting, faces.as_deref()); let cjk_report = cjk::probe_with(setting, faces);
Resolved { Resolved {
platform, platform,
@@ -220,20 +247,34 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
} }
} }
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值** /// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 /// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
/// ///
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上 /// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形, /// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`, /// 那就是白块。
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。 ///
/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源
/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。
/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器
/// 会白下一次 17 MiB。
///
/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经
/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`**
/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳
/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。
pub fn cjk_for(setting: Option<&str>) -> CjkReport { pub fn cjk_for(setting: Option<&str>) -> CjkReport {
cjk::probe_with(setting, system_faces().as_deref()) let report = cjk::probe_with(setting, system_faces().as_deref());
install::after_probe(report.strategy);
report
} }
/// 一条系统字面(族名 + 字重)。 /// 一条系统字面(族名 + 字重)。
///
/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一
/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct Face { pub struct Face {
family: String, family: String,
weight: u16, weight: u16,
} }
+173
View File
@@ -0,0 +1,173 @@
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
//!
//! ## 一句话
//!
//! ```text
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
//! core 的 FontSpec(值)
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
//! ```
//!
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
//! 单测)。
//!
//! ## 「不联网」的三条路
//!
//! | 情形 | 谁拦住 |
//! |---|---|
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
//!
//! ## 设置改写只换 URL,不换哈希
//!
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
use std::cell::Cell;
use std::path::PathBuf;
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
use unlucky_i18n::font::{CjkFont, FetchError, Source};
use crate::platform::data;
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
#[derive(Debug, Clone)]
pub struct CdnFetcher {
cache_root: PathBuf,
spec: FontSpec,
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
///
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
last_from_cache: Cell<Option<bool>>,
}
impl CdnFetcher {
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
///
/// # Errors
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
Ok(Self {
cache_root,
spec: spec_of(font)?,
last_from_cache: Cell::new(None),
})
}
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
///
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
#[must_use]
pub fn has_cached(&self) -> bool {
core_font::cache_path(&self.cache_root, &self.spec).is_file()
}
/// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。
///
/// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。
#[must_use]
pub fn was_cached(&self) -> Option<bool> {
self.last_from_cache.get()
}
/// 缓存文件落点(诊断日志用)。
#[must_use]
pub fn cache_file(&self) -> PathBuf {
core_font::cache_path(&self.cache_root, &self.spec)
}
}
impl unlucky_i18n::font::Fetcher for CdnFetcher {
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
self.last_from_cache
.set(outcome.as_ref().ok().map(|fetch| fetch.from_cache));
outcome.map(|fetch| fetch.bytes)
}
}
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
///
/// # Errors
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
let Source::Pinned {
urls,
sha256,
bytes,
} = font.source;
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
if let Some(override_url) = data::font_cdn_override() {
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
candidates.insert(0, override_url);
}
FontSpec::new(candidates, sha256, bytes)
.map_err(|error| FetchError::Integrity(error.to_string()))
}
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
fn translate(error: FontFetchError) -> FetchError {
match error {
FontFetchError::Network(message) => FetchError::Network(message),
FontFetchError::Integrity(message) => FetchError::Integrity(message),
FontFetchError::Disk(message) => FetchError::Disk(message),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::font::NOTO_SANS_SC;
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
///
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
#[test]
fn the_pin_survives_the_trip_to_core() {
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
let Source::Pinned {
urls,
sha256,
bytes,
} = NOTO_SANS_SC.source;
assert!(
spec.urls.len() >= urls.len(),
"候选不该变少:{:?}",
spec.urls
);
assert_eq!(spec.sha256, sha256);
assert_eq!(spec.bytes, bytes);
assert_eq!(
spec.urls[spec.urls.len() - urls.len()],
urls[0],
"内置首选不许被挤掉"
);
}
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
#[test]
fn an_empty_cache_root_is_not_a_hit() {
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
assert!(!fetcher.has_cached());
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
assert!(fetcher
.cache_file()
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
}
}
+3 -2
View File
@@ -19,8 +19,9 @@
//! //!
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); //! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` //! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); //! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。 //! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
//! //!
//! ## `ui.language` 的接线(M106) //! ## `ui.language` 的接线(M106)
//! //!
+361
View File
@@ -0,0 +1,361 @@
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
//!
//! ## 一句话
//!
//! ```text
//! i18n::font::decide(判据) → Strategy
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
//! fonts::install::register(注册) → iced 的字体系统里多一个族
//! ```
//!
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
//!
//! | 入口 | 时机 | 谁来 |
//! |---|---|---|
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
//!
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
//!
//! ## 为什么下载在后台线程而不是启动那一拍
//!
//! 一份 16.9 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
//!
//! ## 失败**不是**致命错误
//!
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
//! (「下载失败不崩」)。
//!
//! ## 状态是一份进程级快照
//!
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
//!
//! ## 截图通道
//!
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
use std::sync::{Mutex, OnceLock};
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchClass {
/// 网络层。
Network,
/// 校验层(哈希 / 魔数 / 大小上限)。
Integrity,
/// 磁盘层。
Disk,
}
impl FetchClass {
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Integrity => "integrity",
Self::Disk => "disk",
}
}
}
impl From<&FetchError> for FetchClass {
fn from(error: &FetchError) -> Self {
match error {
FetchError::Network(_) => Self::Network,
FetchError::Integrity(_) => Self::Integrity,
FetchError::Disk(_) => Self::Disk,
}
}
}
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CjkInstall {
/// 还没判定(启动前 / 判据没跑过)。
Unknown,
/// 英文环境:不需要中文字体(D28 的第一条)。
NotNeeded,
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
System {
/// 命中的族名。
family: String,
},
/// 探测不出(拿不到系统字体库)——**不下载**。
Unprobeable,
/// 正在下载。
Downloading {
/// 字节数(pin 里的期望值;用来写「16.9 MB」这种可核对的量)。
bytes: u64,
},
/// 已就绪(下载完成并注册进字体系统)。
Ready {
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
family: String,
/// 是否零网络命中缓存。
from_cache: bool,
},
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
Failed {
/// 类别。
class: FetchClass,
/// 人话原因(日志/诊断用;UI 只按类别选词)。
message: String,
},
}
impl CjkInstall {
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
#[must_use]
pub const fn is_downloading(&self) -> bool {
matches!(self, Self::Downloading { .. })
}
/// 一行日志片段(`cjk-install=…`)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Unknown => "cjk-install=unknown".to_owned(),
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
Self::Ready { family, from_cache } => {
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
}
Self::Failed { class, message } => {
format!("cjk-install=failed({},{message})", class.as_str())
}
}
}
}
/// 进程级状态(一份)。
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
}
/// 当前状态(设置页那一行、心跳、日志都读它)。
#[must_use]
pub fn status() -> CjkInstall {
if let Some(forced) = capture_override() {
return forced;
}
slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
fn capture_override() -> Option<CjkInstall> {
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
FORCED
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
.clone()
}
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
#[must_use]
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
let value = raw.trim();
Some(match value {
"not-needed" => CjkInstall::NotNeeded,
"unprobeable" => CjkInstall::Unprobeable,
"system" => CjkInstall::System {
family: "Noto Sans SC".to_owned(),
},
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
"ready" => CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
"failed" | "failed:network" => CjkInstall::Failed {
class: FetchClass::Network,
message: "capture fixture".to_owned(),
},
"failed:integrity" => CjkInstall::Failed {
class: FetchClass::Integrity,
message: "capture fixture".to_owned(),
},
"failed:disk" => CjkInstall::Failed {
class: FetchClass::Disk,
message: "capture fixture".to_owned(),
},
_ => return None,
})
}
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
///
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
pub fn ensure_for(
strategy: Strategy,
fetcher: &dyn Fetcher,
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
) -> CjkInstall {
match font::ensure(strategy, fetcher) {
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
family: family.to_owned(),
},
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
Ok(family) => CjkInstall::Ready {
family,
from_cache: false,
},
Err(message) => CjkInstall::Failed {
class: FetchClass::Disk,
message,
},
},
Err(error) => CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
},
}
}
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
///
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
/// 什么都不做(已经在下载 / 已就绪)。
Skip,
/// 只把状态改成这个(零网络)。
Apply(CjkInstall),
/// 真的发起一次下载。
Start(&'static font::CjkFont),
}
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
///
/// | 当前状态 | 策略 | 这一步 |
/// |---|---|---|
/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) |
/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) |
/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) |
/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) |
///
/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②)
///
/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」
/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**:
/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在
/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是
/// 常见情形。
///
/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底)
///
/// | 档 | panic 语义 | 线程 panic 之后 |
/// |---|---|---|
/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) |
/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 |
///
/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`),
/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的
/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。
///
/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外
/// 产生网络流量并可能在出图过程中注册新字体。
#[must_use]
pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step {
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
return Step::Skip;
}
match strategy {
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
Strategy::System { family } => Step::Apply(CjkInstall::System {
family: family.to_owned(),
}),
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
// 出图通道需要下载 ⇒ **什么都不做**(也不改状态):写「不需要下载」是谎话,
// 写「正在下载」也是谎话(并没有在下载)。
Strategy::NeedsDownload(_) if offline => Step::Skip,
Strategy::NeedsDownload(font) => Step::Start(font),
}
}
/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。
#[must_use]
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
plan_with_offline(strategy, current, offline())
}
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
///
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB);
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网;
/// * 出图通道([`set_offline`])→ 永不发起。
pub fn after_probe(strategy: Strategy) -> CjkInstall {
match plan(strategy, &status()) {
Step::Skip => status(),
Step::Apply(state) => {
update(state.clone());
state
}
Step::Start(font) => {
worker::start(font);
status()
}
}
}
/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。
///
/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体——
/// 这既产生流量,也让「同一份代码出同一张图」不再成立。
pub fn set_offline(offline: bool) {
OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed);
}
/// 当前是否处于 [`set_offline`] 的状态。
#[must_use]
pub fn offline() -> bool {
OFFLINE.load(std::sync::atomic::Ordering::Relaxed)
}
/// 进程级联网开关(默认允许)。
static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
pub(super) fn update(state: CjkInstall) {
let previous = {
let mut guard = slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::replace(&mut *guard, state.clone())
};
if previous != state {
eprintln!("[font] {}", state.describe());
}
}
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
mod worker;
#[cfg(test)]
#[path = "install/tests.rs"]
mod tests;
+369
View File
@@ -0,0 +1,369 @@
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
//!
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
use std::cell::RefCell;
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
use super::*;
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
struct FakeFetcher {
calls: RefCell<usize>,
seen: RefCell<Option<&'static str>>,
result: Result<Vec<u8>, FetchError>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
Self {
calls: RefCell::new(0),
seen: RefCell::new(None),
result,
}
}
fn calls(&self) -> usize {
*self.calls.borrow()
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
*self.calls.borrow_mut() += 1;
*self.seen.borrow_mut() = Some(font.family);
self.result.clone()
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
let recorder = std::rc::Rc::clone(&seen);
let register = move |bytes: Vec<u8>| {
recorder.borrow_mut().push(bytes.len());
Ok("Noto Sans SC".to_owned())
};
(seen, register)
}
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
///
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
/// `CdnFetcher` 的缓存判定承担)。
#[test]
fn the_three_idle_strategies_never_touch_the_fetcher() {
let (registered, register) = recorder();
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(
ensure_for(Strategy::NotNeeded, &idle, &register),
CjkInstall::NotNeeded
);
assert_eq!(
ensure_for(Strategy::System { family: "SimSun" }, &idle, &register),
CjkInstall::System {
family: "SimSun".to_owned()
}
);
assert_eq!(
ensure_for(Strategy::Unprobeable, &idle, &register),
CjkInstall::Unprobeable
);
assert_eq!(
idle.calls(),
0,
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
);
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
let working = FakeFetcher::new(Ok(vec![9; 4096]));
assert_eq!(
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, &register),
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false
}
);
assert_eq!(working.calls(), 1);
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
assert_eq!(*registered.borrow(), vec![4096]);
}
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
///
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
#[test]
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
let (registered, register) = recorder();
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, &register);
assert_eq!(
state,
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned()
}
);
assert!(registered.borrow().is_empty(), "校验失败不许注册");
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
}
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
#[test]
fn every_failure_class_survives_the_trip() {
let (_, register) = recorder();
for (error, class) in [
(FetchError::Network("超时".to_owned()), FetchClass::Network),
(
FetchError::Integrity("哈希不符".to_owned()),
FetchClass::Integrity,
),
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
] {
let fetcher = FakeFetcher::new(Err(error));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, &register);
match state {
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
other => panic!("应当是 Failed:{other:?}"),
}
}
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
let state = ensure_for(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&fetcher,
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
);
assert!(matches!(
state,
CjkInstall::Failed {
class: FetchClass::Disk,
..
}
));
}
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
///
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
///
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
///
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
#[test]
fn a_started_or_finished_install_is_not_restarted() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
let ready = CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
};
for current in [downloading, ready] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
}
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
for current in [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::Failed {
class: FetchClass::Network,
message: "超时".to_owned(),
},
] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan_with_offline(
Strategy::System { family: "SimSun" },
&CjkInstall::Unknown,
false
),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
);
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
// 不存在停在 Downloading 的状态。
assert_eq!(
plan_with_offline(
Strategy::Unprobeable,
&CjkInstall::Downloading { bytes: 1 },
false
),
Step::Skip,
"在飞的下载不许被别的策略改写状态"
);
}
/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。
///
/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)——
/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。
/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。
#[test]
fn every_strategy_skips_while_a_download_is_in_flight() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
for strategy in [
Strategy::NeedsDownload(&NOTO_SANS_SC),
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &downloading, false),
Step::Skip,
"{strategy:?}:下载中不许改状态、不许起第二条线程"
);
}
}
/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。
///
/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示
/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。
/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。
#[test]
fn the_screenshot_channel_never_starts_a_download() {
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
true
),
Step::Skip,
"出图通道不许联网"
);
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
false
),
Step::Start(&NOTO_SANS_SC),
"正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)"
);
// 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。
for strategy in [
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &CjkInstall::Unknown, false),
plan_with_offline(strategy, &CjkInstall::Unknown, true),
"{strategy:?}:不联网的策略与通道无关"
);
}
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
///
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
#[test]
fn the_capture_switch_parses_every_state() {
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
assert!(matches!(
parse_capture("system"),
Some(CjkInstall::System { .. })
));
assert!(matches!(
parse_capture("downloading"),
Some(CjkInstall::Downloading { .. })
));
assert!(matches!(
parse_capture("ready"),
Some(CjkInstall::Ready {
from_cache: true,
..
})
));
for (raw, class) in [
("failed", FetchClass::Network),
("failed:network", FetchClass::Network),
("failed:integrity", FetchClass::Integrity),
("failed:disk", FetchClass::Disk),
] {
match parse_capture(raw) {
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
other => panic!("{raw} 应解析成 Failed:{other:?}"),
}
}
assert_eq!(parse_capture("bogus"), None);
assert_eq!(parse_capture(""), None);
}
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
#[test]
fn every_state_describes_itself_on_one_line() {
let states = [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::System {
family: "SimSun".to_owned(),
},
CjkInstall::Unprobeable,
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false,
},
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned(),
},
];
for state in states {
let line = state.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk-install="), "{line}");
}
}
+164
View File
@@ -0,0 +1,164 @@
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
//!
//! ## 为什么拆出来
//!
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
//!
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
//!
//! ## 为什么下载在后台线程
//!
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
//!
//! ## 线程里做了什么(顺序即判据)
//!
//! ```text
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限)
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
//! ```
//!
//! ## panic 这件事分两档说(复审 r3 P2-1)
//!
//! | 档 | panic 语义 | 线程 panic 之后 |
//! |---|---|---|
//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 |
//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` |
//!
//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径
//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`,
//! 那属于发布 profile 的决定,不在本 mission)。
//!
//! ## 诚实边界
//!
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为
//! (那要真跑一个 `--release` 的 panic 用例)。
use unlucky_i18n::font::{self, Strategy};
use super::{ensure_for, update, CjkInstall, FetchClass};
use crate::fonts::cdn::CdnFetcher;
use crate::platform::data;
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
///
/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是
/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。
pub(super) fn start(font: &'static font::CjkFont) {
let font::Source::Pinned { bytes, .. } = font.source;
let Some(cache_root) = data::cache_root() else {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: "数据根解析不出来,字体无处可落".to_owned(),
});
return;
};
let fetcher = match CdnFetcher::new(font, cache_root) {
Ok(fetcher) => fetcher,
Err(error) => {
update(CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
});
return;
}
};
let cache_file = fetcher.cache_file();
update(CjkInstall::Downloading { bytes });
eprintln!(
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
font.family,
cache_file.display(),
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
fetcher.has_cached()
);
let spawned = std::thread::Builder::new()
.name("ull-cjk-font".to_owned())
.spawn(move || run(font, fetcher));
if let Err(error) = spawned {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: format!("起不了字体下载线程({error})"),
});
}
}
/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。
fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) {
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ensure_for(
Strategy::NeedsDownload(font),
&fetcher,
&register_into_font_system,
)
})) {
Ok(state) => state,
Err(_) => CjkInstall::Failed {
class: FetchClass::Disk,
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(),
},
};
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
let from_cache = fetcher.was_cached().unwrap_or(false);
let state = match state {
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
other => other,
};
match &state {
CjkInstall::Ready { family, from_cache } => eprintln!(
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} \
(中文字形现在由它兜底,界面不需要重启)"
),
CjkInstall::Failed { class, message } => eprintln!(
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
class.as_str()
),
other => eprintln!("[font] cjk-download {}", other.describe()),
}
update(state);
}
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
///
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
///
/// # Errors
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
let lock = iced::advanced::graphics::text::font_system();
let mut font_system = lock
.write()
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
font_system.load_font(std::borrow::Cow::Owned(bytes));
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
// 那个借用活过守卫(E0597)。
let families: Vec<String> = font_system
.raw()
.db()
.faces()
.flat_map(|face| face.families.iter())
.map(|(family, _)| family.clone())
.collect();
families
.iter()
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
.cloned()
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
}
+39
View File
@@ -173,3 +173,42 @@ fn type_roles_pick_the_display_stack_for_big_text() {
); );
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text); assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
} }
/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。
///
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
///
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
#[test]
fn resolving_fonts_never_starts_a_download() {
use unlucky_i18n::font::Strategy;
use crate::fonts::install;
// 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。
let latin: Vec<Face> = ["Segoe UI", "Consolas"]
.iter()
.map(|family| Face {
family: (*family).to_owned(),
weight: 400,
})
.collect();
let before = install::status();
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
let after = install::status();
assert_eq!(
resolved.cjk.strategy,
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
);
assert!(
!after.is_downloading(),
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
);
}
+1 -1
View File
@@ -14,7 +14,7 @@
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
//! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) | //! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) |
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 | //! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— `unsafe` 落点之一;另一半是 M196 的控制台附着 | 窗口创建 / 业务开关 |
//! //!
//! ## 消费纪律 //! ## 消费纪律
//! //!
+38 -2
View File
@@ -1,4 +1,4 @@
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里: //! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里。
//! //!
//! 一个二进制两种用法(M82),分发在进 UI 之前完成: //! 一个二进制两种用法(M82),分发在进 UI 之前完成:
//! //!
@@ -15,13 +15,49 @@
//! 给服务器 / CI / Termux 这类没有图形库的机器用。 //! 给服务器 / CI / Termux 这类没有图形库的机器用。
//! //!
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。 //! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。
//!
//! ## 为什么这里是 WINDOWS GUI 子系统(M196)
//!
//! 下面那条 `#![cfg_attr(windows, windows_subsystem = "windows")]` 直接改的是**产物 PE 头**的
//! `Subsystem` 字段(CUI=3 → WINDOWS_GUI=2),这是「双击不再多一个常驻控制台宿主窗口」的
//! 唯一机制:PE 头写 3 时由系统在启动时分配控制台(那个黑框),写 2 时系统不分配。
//!
//! **为什么放这一层而不是 `build.rs`**:PE 子系统是**每个 bin 目标**的属性,而 `cfg_attr` 就是
//! 它的原生长相——rustc 读到它就把标志写进链接出来的 PE 里,没有任何别的路径能改到那一行。
//! `build.rs` 做不到这件事:它只能在编译**之前**跑(`cargo:rustc-link-arg` 之类确实能加链接参数,
//! 但那是「顺手改了链接命令行」的间接法,且在交叉/多 bin 时按 crate 而非按 target 生效),
//! 更麻烦的是它把「这条 bin 是 GUI」这条事实挪到一个**看不见 bin 声明**的地方——本 crate 的
//! `[[bin]]` 名是 `unlucky-launcher-iced`(不是 crate 名),读 `build.rs` 的人得先在 Cargo.toml
//! 里找回来。放 `main.rs` 顶上,事实与产物一一对应:改这个文件 = 改这个 bin。
//!
//! 代价与补救见 [`unlucky_app_iced::platform::win32::console`]:GUI 子系统进程**不继承**父进程
//! 的控制台,所以 `main()` 第一步就把输出接回父控制台(`AttachConsole`),CLI 子命令、
//! `--diff`、`--screenshot` 的日志在 cmd / 终端里照样看得见;双击时没有父控制台,附着失败、
//! 也绝不新建窗口。判据是**产物 PE 头的读数**,不是「代码看起来对」:
//! `packaging/check_pe_subsystem.py` 读 `Subsystem` 字段,非 2 即红(`release-build.yml` 里跑)。
//!
//! 历史:同样的修复曾做过一次,但打在已退役的 Slint 树(`37d3039`,只改了 `crates/app/src/main.rs`),
//! 而 `crates/app` 自 M118 起不在 workspace、不进发布矩阵——所以那一笔从未到这里。
// M196:PE 头的子系统 = WINDOWS_GUI(2)。没有它,双击产物会多一个控制台宿主窗口。
// 这一行只对 bin 目标有意义(lib 没有 PE 头),所以放在 main.rs 而不是 lib.rs。
#![cfg_attr(windows, windows_subsystem = "windows")]
use std::process::ExitCode; use std::process::ExitCode;
use unlucky_app_iced::platform::win32::console::attach_parent_console;
use unlucky_app_iced::shot; use unlucky_app_iced::shot;
use unlucky_app_iced::Options; use unlucky_app_iced::Options;
fn main() -> ExitCode { fn main() -> ExitCode {
// M196 第一步:GUI 子系统进程默认没有控制台(stdout/stderr 是 0 号无效句柄),
// 不接回来 CLI 输出与所有 eprintln 都会静默丢失。四种启动情形的实测表在
// `platform/win32/console.rs` 顶部:重定向/管道两个句柄本来就可用(一个字节都不动),
// cmd 里直接跑要 attach,双击时 attach 必失败且不会新建窗口。
let console = attach_parent_console();
if std::env::var_os("ULL_ACCEPTANCE").is_some() {
eprintln!("[console] {}", console.describe());
}
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(code) = run_cli_if_requested(&args) { if let Some(code) = run_cli_if_requested(&args) {
return code; return code;
@@ -43,7 +79,7 @@ fn main() -> ExitCode {
); );
// M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。 // M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。
eprintln!( eprintln!(
"验收诊断:ULL_ACCEPTANCE=1([env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\ "验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸" ,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
); );
ExitCode::from(2) ExitCode::from(2)
+3
View File
@@ -38,6 +38,9 @@ pub mod widgets;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
#[path = "account/tests_msa.rs"]
mod tests_msa;
#[cfg(test)]
#[path = "account/tests_text.rs"] #[path = "account/tests_text.rs"]
mod tests_text; mod tests_text;
+88 -55
View File
@@ -2,9 +2,15 @@
//! //!
//! ## 为什么不是「一个转圈」 //! ## 为什么不是「一个转圈」
//! //!
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, //! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、 //! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 //!
//! ## 文案一律**借用模型里的串**(M178)
//!
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
use iced::widget::{column, row}; use iced::widget::{column, row};
use iced::{Alignment, Element}; use iced::{Alignment, Element};
@@ -13,7 +19,7 @@ use unlucky_design::color::Role;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::LoginFailure; use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
use crate::app::components::buttons::{self, Button}; use crate::app::components::buttons::{self, Button};
use crate::pages::frame; use crate::pages::frame;
@@ -36,34 +42,40 @@ pub fn panel<'a>(
DevicePanel::Code { DevicePanel::Code {
code, code,
link, link,
remaining, countdown,
expired, expired,
} => device_code(ui, code, link, *remaining, *expired, catalog), note,
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
DevicePanel::Browser { DevicePanel::Browser {
link, link,
remaining, countdown,
expired, expired,
} => browser(ui, link, *remaining, *expired, catalog), } => browser(ui, link, countdown, *expired, catalog),
DevicePanel::Failed { failure } => failed(ui, *failure, catalog), DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog), DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
} }
} }
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。 /// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
/// 没登记时在预检处直接回退设备码)。
///
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![ let body = column![row![
buttons::filled( buttons::filled(
ui, ui,
Button::new( Button::new(
catalog.text(Key::AccountDeviceBrowser), catalog.text(Key::AccountDeviceSignIn),
Account::StartBrowser.into_page(), Account::StartDevice.into_page(),
) )
), ),
buttons::outlined( buttons::outlined(
ui, ui,
Button::new( Button::new(
catalog.text(Key::AccountDeviceUseCode), catalog.text(Key::AccountDeviceBrowser),
Account::StartDevice.into_page(), Account::StartBrowser.into_page(),
) )
), ),
buttons::text( buttons::text(
@@ -76,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
] ]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.align_y(Alignment::Center),] .align_y(Alignment::Center),]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3))
.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceFallbackHint),
ui.color(Role::OnSurfaceVariant),
));
frame::card(ui, body) frame::card(ui, body)
} }
@@ -105,13 +124,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body) frame::card(ui, body)
} }
/// 设备码等待态:**码 + 链接 + 倒计时**。 /// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
fn device_code<'a>( fn device_code<'a>(
ui: &'a Ui, ui: &'a Ui,
code: &'a str, code: &'a str,
link: &'a str, link: &'a str,
remaining: Option<u64>, countdown: &'a str,
expired: bool, expired: bool,
note: Option<BrowserLoginNote>,
catalog: Catalog, catalog: Catalog,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let mut actions = row![ let mut actions = row![
@@ -150,9 +170,7 @@ fn device_code<'a>(
), ),
)); ));
let countdown = countdown_line(ui, remaining, expired, catalog); let mut body = column![
let body = column![
frame::text_role( frame::text_role(
ui, ui,
TypeRole::LabelMedium, TypeRole::LabelMedium,
@@ -174,7 +192,7 @@ fn device_code<'a>(
link, link,
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
countdown, countdown_line(ui, countdown, expired),
frame::text_role( frame::text_role(
ui, ui,
TypeRole::BodySmall, TypeRole::BodySmall,
@@ -182,17 +200,21 @@ fn device_code<'a>(
catalog.text(Key::AccountDeviceHint), catalog.text(Key::AccountDeviceHint),
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
actions,
] ]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3));
frame::card(ui, body) // 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
if let Some(text) = note_text(ui, note, catalog) {
body = body.push(text);
}
frame::card(ui, body.push(actions))
} }
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。 /// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
fn browser<'a>( fn browser<'a>(
ui: &'a Ui, ui: &'a Ui,
link: &'a str, link: &'a str,
remaining: Option<u64>, countdown: &'a str,
expired: bool, expired: bool,
catalog: Catalog, catalog: Catalog,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
@@ -237,37 +259,19 @@ fn browser<'a>(
link, link,
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
countdown_line(ui, remaining, expired, catalog), countdown_line(ui, countdown, expired),
actions, actions,
] ]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3));
frame::card(ui, body) frame::card(ui, body)
} }
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。 /// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
fn countdown_line<'a>( fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
ui: &'a Ui,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
if expired { if expired {
return frame::text_role( return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountDeviceExpired),
ui.color(Role::Error),
);
} }
let text = remaining.map_or_else(String::new, |secs| { frame::text_role(
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
});
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
frame::text_role_owned(
ui, ui,
TypeRole::LabelMedium, TypeRole::LabelMedium,
false, false,
@@ -276,16 +280,45 @@ fn countdown_line<'a>(
) )
} }
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
catalog.text(super::model::note_key(note))
}
/// 回退说明那一行(`None` = 没有这回事)。
fn note_text<'a>(
ui: &'a Ui,
note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Option<Element<'a, PageMessage>> {
let note = note?;
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
Some(frame::text_role(
ui,
TypeRole::BodySmall,
false,
browser_note_text(note, catalog),
ui.color(Role::OnSurfaceVariant),
))
}
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
/// ///
/// 三点修订叠在这里: /// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; /// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, /// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); /// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 fn failed<'a>(
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。 ui: &'a Ui,
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> { failure: LoginFailure,
let body = catalog.text(failure_body_key(failure)).to_string(); note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut body = catalog.text(failure_body_key(failure)).to_string();
if let Some(note) = note {
body.push_str("\n\n");
body.push_str(browser_note_text(note, catalog));
}
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new(); let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
if !matches!(failure, LoginFailure::MsaNotConfigured) { if !matches!(failure, LoginFailure::MsaNotConfigured) {
+17 -2
View File
@@ -7,7 +7,9 @@
use iced::widget::image; use iced::widget::image;
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView}; use unlucky_presenter::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
};
use unlucky_presenter::state::AppState; use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache}; use super::avatar::{Avatar, Cache};
@@ -164,6 +166,18 @@ impl Form {
} }
} }
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
///
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
#[must_use]
pub const fn note_key(note: BrowserLoginNote) -> Key {
match note {
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
}
}
/// 列表行 / 当前账户卡片用的数据。 /// 列表行 / 当前账户卡片用的数据。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Row { pub struct Row {
@@ -253,7 +267,8 @@ impl Page {
.map(|account| row_of(account, avatars, lang)) .map(|account| row_of(account, avatars, lang))
.collect(); .collect();
let active = rows.iter().position(|row| row.active); let active = rows.iter().position(|row| row.active);
let device = device_panel(&state.device_login, state.device_code_remaining()); let remaining = state.device_code_remaining();
let device = device_panel(&state.device_login, remaining, lang.catalog());
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、 // 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。 // 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
let panel = if matches!(device, DevicePanel::Hidden) { let panel = if matches!(device, DevicePanel::Hidden) {
+40 -9
View File
@@ -10,7 +10,10 @@
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 //! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。 //! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。 /// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,17 +28,23 @@ pub enum DevicePanel {
code: String, code: String,
/// 验证链接 /// 验证链接
link: String, link: String,
/// 剩余秒数 /// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
remaining: Option<u64>, ///
/// 是否已过期 /// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
countdown: String,
/// 是否已过期(决定颜色与「重试」按钮)
expired: bool, expired: bool,
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
note: Option<BrowserLoginNote>,
}, },
/// 授权码流:等浏览器回调 /// 授权码流:等浏览器回调
Browser { Browser {
/// 授权链接(可复制) /// 授权链接(可复制)
link: String, link: String,
/// 剩余秒数 /// 倒计时那行已渲染好的文案(同上)
remaining: Option<u64>, countdown: String,
/// 是否已过期 /// 是否已过期
expired: bool, expired: bool,
}, },
@@ -43,6 +52,8 @@ pub enum DevicePanel {
Failed { Failed {
/// 原因码(页面据此选 i18n) /// 原因码(页面据此选 i18n)
failure: LoginFailure, failure: LoginFailure,
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
note: Option<BrowserLoginNote>,
}, },
/// 成功 /// 成功
Succeeded { Succeeded {
@@ -76,8 +87,13 @@ pub enum AuthlibPanel {
} }
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。 /// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel { pub(super) fn device_panel(
view: &DeviceLoginView,
remaining: Option<u64>,
catalog: Catalog,
) -> DevicePanel {
let expired = remaining == Some(0); let expired = remaining == Some(0);
let countdown = countdown_text(remaining, expired, catalog);
match view.phase { match view.phase {
DeviceLoginPhase::Idle => DevicePanel::Hidden, DeviceLoginPhase::Idle => DevicePanel::Hidden,
DeviceLoginPhase::Requesting => DevicePanel::Requesting, DeviceLoginPhase::Requesting => DevicePanel::Requesting,
@@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
if view.user_code.trim().is_empty() { if view.user_code.trim().is_empty() {
DevicePanel::Browser { DevicePanel::Browser {
link: view.verification_uri.clone(), link: view.verification_uri.clone(),
remaining, countdown,
expired, expired,
} }
} else { } else {
DevicePanel::Code { DevicePanel::Code {
code: view.user_code.clone(), code: view.user_code.clone(),
link: view.qr_link.clone(), link: view.qr_link.clone(),
remaining, countdown,
expired, expired,
note: view.browser_note,
} }
} }
} }
DeviceLoginPhase::Failed => DevicePanel::Failed { DeviceLoginPhase::Failed => DevicePanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other), failure: view.failure.unwrap_or(LoginFailure::Other),
note: view.browser_note,
}, },
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(), name: view.account_name.clone(),
@@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
} }
} }
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
///
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
if expired {
return catalog.text(Key::AccountDeviceExpired).to_string();
}
remaining.map_or_else(String::new, |secs| {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
})
}
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。 /// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) { pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
match view.phase { match view.phase {
+8 -5
View File
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
} }
} }
fn build(state: &AppState, form: &Form) -> Page { pub(super) fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。 // 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。 // 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0) Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
@@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
match &page.device { match &page.device {
DevicePanel::Code { DevicePanel::Code {
code, code,
remaining, countdown,
expired, expired,
.. ..
} => { } => {
assert_eq!(code, "ABCD-EFGH"); assert_eq!(code, "ABCD-EFGH");
assert_eq!(*remaining, Some(900)); assert!(
countdown.contains("15:00"),
"倒计时要画成 mm:ss:{countdown}"
);
assert!(!expired); assert!(!expired);
} }
other => panic!("等待态应该是设备码面板,实际 {other:?}"), other => panic!("等待态应该是设备码面板,实际 {other:?}"),
@@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
let page = build(&state, &Form::new()); let page = build(&state, &Form::new());
match &page.device { match &page.device {
DevicePanel::Code { DevicePanel::Code {
remaining, expired, .. countdown, expired, ..
} => { } => {
assert_eq!(*remaining, Some(0));
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)"); assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
} }
other => panic!("过期态仍是设备码面板,实际 {other:?}"), other => panic!("过期态仍是设备码面板,实际 {other:?}"),
} }
@@ -0,0 +1,126 @@
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
//!
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::login::browser_note_text;
use super::model::{DevicePanel, Form, Page};
use super::tests::build;
use super::tests_text::{has_cjk, on_screen};
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
fn build_zh(state: &AppState, form: &Form) -> Page {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
}
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
///
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
#[test]
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
application."
.to_string(),
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
}
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
)));
let page = build(&state, &Form::new());
// 1. 面板态带着说明码(不是只剩一个用户码)
match &page.device {
DevicePanel::Code { note, code, .. } => {
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
assert_eq!(code, "ABCD-EFGH");
}
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
}
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
assert!(
text.contains("http://localhost/auth"),
"提示里要给出可照抄的重定向 URI:{text}"
);
assert!(
text.to_lowercase().contains("redirect uri"),
"提示里要点名「重定向 URI」这一项:{text}"
);
assert!(
!text.contains("invalid_request"),
"原始报文不许上屏:{text}"
);
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
let zh = build_zh(&state, &Form::new());
let zh_text = on_screen(&zh);
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
let note_text = browser_note_text(
BrowserLoginNote::RedirectNotRegistered,
Lang::ZhCn.catalog(),
);
assert!(
note_text.contains("http://localhost/auth"),
"中文文案也要给出可照抄的重定向 URI:{note_text}"
);
assert!(note_text.contains("重定向 URI"), "{note_text}");
assert!(note_text.contains("设备码"), "{note_text}");
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
assert!(
!zh_text.contains(forbidden),
"中文界面混进了原始报文:{zh_text}"
);
assert!(
!note_text.contains(forbidden),
"词条混进了原始报文:{note_text}"
);
}
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
assert_eq!(
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
"Could not confirm that browser sign-in is usable, so it switched to a device code."
);
}
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
#[test]
fn a_failed_device_login_keeps_the_fallback_note() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed_as(
LoginFailure::Unreachable,
"连不上 login.microsoftonline.com",
)
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Failed { failure, note } => {
assert_eq!(*failure, LoginFailure::Unreachable);
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
}
other => panic!("应是失败面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
}
+20 -10
View File
@@ -16,7 +16,7 @@
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::AccountNoticeCode; use unlucky_presenter::dto::AccountNoticeCode;
use super::login::{failure_body_key, failure_title_key}; use super::login::{browser_note_text, failure_body_key, failure_title_key};
use super::model::{AuthlibPanel, DevicePanel, Hint, Page}; use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。 /// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
@@ -60,23 +60,33 @@ pub(super) fn on_screen(page: &Page) -> String {
DevicePanel::Code { DevicePanel::Code {
code, code,
link, link,
remaining, countdown,
expired, note,
..
} => { } => {
out.push_str(code); out.push_str(code);
out.push_str(link); out.push_str(link);
if *expired { // 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
out.push_str(catalog.text(Key::AccountDeviceExpired)); out.push_str(countdown);
} else if let Some(secs) = remaining { // M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
let clock = format!("{:02}:{:02}", secs / 60, secs % 60); // 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])); if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
} }
} }
DevicePanel::Browser { link, .. } => out.push_str(link), DevicePanel::Browser {
DevicePanel::Failed { failure } => { link, countdown, ..
} => {
out.push_str(link);
out.push_str(countdown);
}
DevicePanel::Failed { failure, note } => {
out.push_str(catalog.text(failure_title_key(*failure))); out.push_str(catalog.text(failure_title_key(*failure)));
out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
}
} }
DevicePanel::Succeeded { name } => { DevicePanel::Succeeded { name } => {
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)])); out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
@@ -26,6 +26,8 @@
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清 //! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
//! (偏离登记在 [`super`] 的偏离表 #7)。 //! (偏离登记在 [`super`] 的偏离表 #7)。
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill; use unlucky_presenter::prefs::BgFill;
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。 /// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
@@ -63,7 +65,7 @@ pub const TILE_SCALE_STEP: f32 = 1.15;
/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。 /// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。
pub const MAX_TILE_SCALE: f32 = 1024.0; pub const MAX_TILE_SCALE: f32 = 1024.0;
/// 解码失败的原因(人话在 [`LoadError::describe`])。 /// 解码失败的原因(人话在 [`LoadError::note`])。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError { pub enum LoadError {
/// 读文件失败:不存在 / 没权限 / 被占用。 /// 读文件失败:不存在 / 没权限 / 被占用。
@@ -79,17 +81,76 @@ pub enum LoadError {
}, },
} }
impl LoadError { /// 一条**可翻译**诊断:`key` + 已经格式化好的参数(模板里只有 `{name}`,见 [`Key`])。
/// 可读诊断(设置页提示行与日志共用同一句)。 ///
/// 为什么不是 `String`:文案表在金表里(M180 `I18NAPPEAR`),本模块只给「哪条 key + 哪些值」。
/// 数字在这一层就格式化好(`{:.2}` / `{:?}` 这些格式说明词表不支持)。
///
/// 为什么 `types` 是 `String` 而不是 `&'static str`:`Io(reason)` / `Undecodable(reason)` 的原因来自
/// 磁盘与解码库(运行时字符串),只能持有。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
key: Key,
args: Vec<(&'static str, String)>,
}
impl Note {
/// 组装一条(参数名必须与译文里的 `{name}` 逐字相同)。
fn new(key: Key, args: Vec<(&'static str, String)>) -> Self {
Self { key, args }
}
/// 这条诊断是哪条文案。
#[must_use] #[must_use]
pub fn describe(&self) -> String { pub fn key(&self) -> Key {
self.key
}
/// 参数(名 → 已成形的值)。
#[must_use]
pub fn args(&self) -> Vec<(&str, &str)> {
self.args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect()
}
/// 按语言取整句(`{name}` 全替换;缺参时占位符原样留着,见 [`unlucky_i18n::catalog::Catalog::render`])。
#[must_use]
pub fn render(&self, lang: Lang) -> String {
lang.catalog().render(self.key, &self.args())
}
/// 英文基线里有没有这个子串。
///
/// 给**跨 mission 的既有断言**用(`app/tests/appearance.rs` 查「诊断里有没有说出原因」),
/// 那条断言不在本 mission 的 scope 里、不能改成按 key 断言。UI 一律走 [`Self::render`]。
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.render(Lang::En).contains(needle)
}
}
impl LoadError {
/// 可翻译诊断(设置页提示行与日志共用同一条 key)。
#[must_use]
pub fn note(&self) -> Note {
match self { match self {
Self::Io(reason) => format!("Background image could not be read: {reason}"), Self::Io(reason) => Note::new(
Self::Undecodable(reason) => format!( Key::SettingsAppearanceBgReadFailed,
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)" vec![("reason", reason.clone())],
), ),
Self::TooLarge { width, height } => format!( Self::Undecodable(reason) => Note::new(
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)" Key::SettingsAppearanceBgDecodeFailed,
vec![("reason", reason.clone())],
),
Self::TooLarge { width, height } => Note::new(
Key::SettingsAppearanceBgTooLarge,
vec![
("width", width.to_string()),
("height", height.to_string()),
("limit", MAX_SOURCE_EDGE.to_string()),
],
), ),
} }
} }
@@ -193,7 +254,7 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
/// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是 /// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是
/// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。 /// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。
#[must_use] #[must_use]
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> { pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<Note> {
if !matches!(fill, BgFill::Tile) { if !matches!(fill, BgFill::Tile) {
return None; return None;
} }
@@ -201,9 +262,13 @@ pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
if width.min(height) >= SMALL_TILE_EDGE { if width.min(height) >= SMALL_TILE_EDGE {
return None; return None;
} }
Some(format!( Some(Note::new(
"Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).", Key::SettingsAppearanceBgSmallTile,
image.source.0, image.source.1 vec![
("width", image.source.0.to_string()),
("height", image.source.1.to_string()),
("max", MAX_TILES.to_string()),
],
)) ))
} }
@@ -4,6 +4,8 @@
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES}; use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill; use unlucky_presenter::prefs::BgFill;
fn temp_file(label: &str, name: &str) -> PathBuf { fn temp_file(label: &str, name: &str) -> PathBuf {
@@ -66,19 +68,28 @@ fn a_real_jpeg_decodes_too() {
#[test] #[test]
fn a_missing_or_broken_file_reports_a_readable_reason() { fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。 // 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。 // 都必须是 Err(不许静默回默认色),且 note() 给出**哪条 key + 哪些参数**(句子在词表里)。
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。 // 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
let missing = temp_file("missing", "bg.png"); let missing = temp_file("missing", "bg.png");
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错"); let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}"); assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
assert!(error let note = error.note();
.describe() assert_eq!(note.key(), Key::SettingsAppearanceBgReadFailed);
.starts_with("Background image could not be read")); assert!(
note.contains("could not be read"),
"英文基线要说出「读不出来」:{}",
note.render(Lang::En)
);
let broken = temp_file("broken", "bg.png"); let broken = temp_file("broken", "bg.png");
std::fs::write(&broken, b"this is not an image").expect("写坏文件"); std::fs::write(&broken, b"this is not an image").expect("写坏文件");
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错"); let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}"); assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
assert_eq!(
error.note().key(),
Key::SettingsAppearanceBgDecodeFailed,
"解不开与读不出来必须是两条 key(措辞与修法都不同)"
);
cleanup(&broken); cleanup(&broken);
let empty = load(" ").expect_err("空路径必须报错"); let empty = load(" ").expect_err("空路径必须报错");
@@ -168,10 +179,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
), ),
"实际 {error:?}" "实际 {error:?}"
); );
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgTooLarge);
assert!( assert!(
error.describe().contains("too large"), note.contains("too large"),
"提示要人话:{}", "英文基线要说出「太大」:{}",
error.describe() note.render(Lang::En)
);
let args = note.args();
assert!(
args.contains(&("width", "9000")) && args.contains(&("height", "4")),
"宽高要作为参数传进去(不是写进译文):{args:?}"
);
assert!(
args.contains(&("limit", "8192")),
"上限走占位符而不是写进译文:{args:?}"
); );
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。 // 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192); const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
@@ -187,8 +209,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() { fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]); let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示"); let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
assert!(note.contains("32×32"), "{note}"); assert_eq!(note.key(), Key::SettingsAppearanceBgSmallTile);
assert!(note.contains("scaled up"), "{note}"); let args = note.args();
assert!(
args.contains(&("width", "32")) && args.contains(&("height", "32")),
"尺寸作为参数:{args:?}"
);
assert!(
args.contains(&("max", "64")),
"块数上限走占位符(`MAX_TILES` 改了不该动译文):{args:?}"
);
assert!(
note.render(Lang::En).contains("scaled up"),
"英文基线要说出「会被放大」:{}",
note.render(Lang::En)
);
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。 // 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
assert!(small_tile_note(&small, BgFill::Cover).is_none()); assert!(small_tile_note(&small, BgFill::Cover).is_none());
+6 -55
View File
@@ -20,7 +20,7 @@
//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
//! | `section` | 设置页「外观」分组(真控件) | 无 | //! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 |
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 | //! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
//! //!
//! ## 偏离登记(D30 要求逐条写清) //! ## 偏离登记(D30 要求逐条写清)
@@ -65,10 +65,14 @@ pub mod background;
mod palette; mod palette;
mod picture; mod picture;
#[cfg(test)]
pub(crate) mod probe;
mod section; mod section;
mod state; mod state;
#[cfg(test)]
mod tests;
/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。 /// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。
/// ///
/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。 /// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。
/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开 /// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开
@@ -164,56 +168,3 @@ impl Change {
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::{Change, APPEARANCE_GROUP};
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
(
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
keys::WINDOW_CORNER,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
}
+83 -32
View File
@@ -9,7 +9,8 @@
//! //!
//! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])—— //! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])——
//! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」); //! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」);
//! 2. 把 `Provenance::hint_key()` 翻成一句可读提示(键 → 文案是 UI 层的活,i18n 之后再统一)。 //! 2. 把 `Provenance::hint_key()` 翻成一条词表 key(M180:句子在 `catalog/settings/appearance.rs`,
//! 这里只给 key——中文界面下这句提示也是中文)。
//! //!
//! ## 为什么不在这里做对比度 //! ## 为什么不在这里做对比度
//! //!
@@ -19,6 +20,7 @@
use unlucky_design::color::{Scheme, SchemeFlavor}; use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance}; use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::PaletteSource; use unlucky_presenter::prefs::PaletteSource;
use super::background::Image; use super::background::Image;
@@ -62,9 +64,9 @@ impl Resolved {
self.provenance.level self.provenance.level
} }
/// 可读提示(`None` = 一级成功、没什么可解释的)。 /// 提示 key(`None` = 一级成功、没什么可解释的)。
#[must_use] #[must_use]
pub fn note(&self) -> Option<&'static str> { pub fn note(&self) -> Option<Key> {
note(&self.provenance) note(&self.provenance)
} }
} }
@@ -97,47 +99,55 @@ pub fn resolve(
Resolved { scheme, provenance } Resolved { scheme, provenance }
} }
/// `Provenance::hint_key()` → 一句可读提示(英文基线;i18n mission 之后走词表)。 /// `Provenance::hint_key()` → 一条词表 key(M180:句子在 `catalog/settings/appearance.rs`)。
/// ///
/// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里; /// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里;
/// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。 /// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。
#[must_use] #[must_use]
pub fn note(provenance: &Provenance) -> Option<&'static str> { pub fn note(provenance: &Provenance) -> Option<Key> {
Some(match provenance.hint_key()? { Some(match provenance.hint_key()? {
"palette.hint.background.undecodable" => { "palette.hint.background.undecodable" => Key::SettingsAppearancePaletteBgUndecodable,
"Background image could not be read; colours fall back." "palette.hint.background.too-small" => Key::SettingsAppearancePaletteBgTooSmall,
} "palette.hint.background.bad-dimensions" => Key::SettingsAppearancePaletteBgBadDimensions,
"palette.hint.background.too-small" => {
"Background image is too small to sample; colours fall back."
}
"palette.hint.background.bad-dimensions" => {
"Background image data looks inconsistent; colours fall back."
}
"palette.hint.background.no-opaque-pixels" => { "palette.hint.background.no-opaque-pixels" => {
"Background image is almost fully transparent; colours fall back." Key::SettingsAppearancePaletteBgNoOpaquePixels
} }
"palette.hint.background.low-chroma" => { "palette.hint.background.low-chroma" => Key::SettingsAppearancePaletteBgLowChroma,
"Background image is too monochrome to tint from; colours fall back." "palette.hint.background.contrast" => Key::SettingsAppearancePaletteBgContrast,
"palette.hint.wallpaper.undecodable" => Key::SettingsAppearancePaletteWallpaperUndecodable,
"palette.hint.wallpaper.low-chroma" => Key::SettingsAppearancePaletteWallpaperLowChroma,
"palette.hint.wallpaper.too-small" => Key::SettingsAppearancePaletteWallpaperTooSmall,
"palette.hint.wallpaper.bad-dimensions" => {
Key::SettingsAppearancePaletteWallpaperBadDimensions
} }
"palette.hint.background.contrast" => {
"Background image colours would be unreadable; colours fall back."
}
"palette.hint.wallpaper.undecodable" => "Desktop wallpaper could not be read.",
"palette.hint.wallpaper.low-chroma" => "Desktop wallpaper is too monochrome to tint from.",
"palette.hint.wallpaper.too-small" => "Desktop wallpaper is too small to sample.",
"palette.hint.wallpaper.bad-dimensions" => "Desktop wallpaper data looks inconsistent.",
"palette.hint.wallpaper.no-opaque-pixels" => { "palette.hint.wallpaper.no-opaque-pixels" => {
"Desktop wallpaper is almost fully transparent." Key::SettingsAppearancePaletteWallpaperNoOpaquePixels
} }
"palette.hint.wallpaper.contrast" => { "palette.hint.wallpaper.contrast" => Key::SettingsAppearancePaletteWallpaperContrast,
"Desktop wallpaper colours would be unreadable; colours fall back." "palette.hint.preset.contrast" => Key::SettingsAppearancePalettePresetContrast,
} "palette.hint.adjusted" => Key::SettingsAppearancePaletteAdjusted,
"palette.hint.preset.contrast" => "The built-in card needed a small contrast correction.",
"palette.hint.adjusted" => "A few colours were nudged for readability.",
_ => return None, _ => return None,
}) })
} }
/// 生效层级**码值** → 已有的那三条档位 key(M180 复审 P2-2:中文界面里不许留机器码)。
///
/// 码值的全部来源只有两处,取值集合相同:`Level::code()`(`background-image` / `wallpaper` /
/// `preset`)与 `PaletteSource::as_str()`(同三个词)。所以三条 key 就够,**不新增平行词表**——
/// 卡片底部「配色来源」用它们表示**用户选的**档,这一行用它们表示**回落之后真正生效**的档。
///
/// 未识别的码值落到 `background-image` 那条(宁可显示一个偏保守的档名,也不许把机器码漏进
/// 中文界面——这条兜底本身由 `tests` 里的 `every_level_code_maps_to_its_own_sentence` 钉住)。
/// `state.rs` 已贴 400 行线,所以这个映射住在本文件。
#[must_use]
pub fn level_key(code: &str) -> Key {
match code {
"preset" => Key::SettingsAppearancePalettePreset,
"wallpaper" => Key::SettingsAppearancePaletteWallpaper,
_ => Key::SettingsAppearancePaletteBackground,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{note, resolve, Source}; use super::{note, resolve, Source};
@@ -145,6 +155,7 @@ mod tests {
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_design::palette::wcag::check; use unlucky_design::palette::wcag::check;
use unlucky_design::palette::{Level, Outcome, Skip}; use unlucky_design::palette::{Level, Outcome, Skip};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::PaletteSource; use unlucky_presenter::prefs::PaletteSource;
/// 高彩度的图(纯红):取色一定成功。 /// 高彩度的图(纯红):取色一定成功。
@@ -193,7 +204,7 @@ mod tests {
assert!(resolved.provenance.did_fall_back()); assert!(resolved.provenance.did_fall_back());
assert_eq!( assert_eq!(
resolved.note(), resolved.note(),
Some("Background image could not be read; colours fall back.") Some(Key::SettingsAppearancePaletteBgUndecodable)
); );
assert!(check(&resolved.scheme).is_clean()); assert!(check(&resolved.scheme).is_clean());
} }
@@ -215,7 +226,11 @@ mod tests {
} }
other => panic!("灰图必须被判不合格,实际 {other:?}"), other => panic!("灰图必须被判不合格,实际 {other:?}"),
} }
assert!(resolved.note().unwrap().contains("monochrome")); assert_eq!(
resolved.note(),
Some(Key::SettingsAppearancePaletteBgLowChroma),
"灰图的回落原因要指名「彩度不足」"
);
} }
#[test] #[test]
@@ -276,4 +291,40 @@ mod tests {
None None
); );
} }
/// **M180 复审 P2-2 的守门**:生效层级的**每一个码值**都必须映射到它自己那条中文句子。
///
/// 抓得住:① 三个码值被映射到同一条 key(界面上分不出到底生效的是哪一级);
/// ② 某条映射被删掉 / 写错(`preset` 落到 background 那条 ⇒ 中文界面说「取自背景图」);
/// ③ 有人再加一级却忘了扩映射。
/// 抓不住:译文本身的措辞(那是 `catalog/settings/appearance.rs` 的译文守卫的事)。
///
/// 变异证明:把 `"preset"` 那行删掉(落进兜底)⇒ 本条红,且 `assert_ne!` 直接点名码值。
///
/// 为什么这条必须有:`the_appearance_card_speaks_chinese` 断的是**整句**非 ASCII,
/// 而「配色:preset」里有汉字 ⇒ 整句非 ASCII ⇒ 那个守卫放过它。这个口子只能在这里堵。
#[test]
fn every_level_code_maps_to_its_own_sentence() {
use super::level_key;
let codes = ["background-image", "wallpaper", "preset"];
let keys: Vec<Key> = codes.iter().copied().map(level_key).collect();
assert_eq!(
keys,
vec![
Key::SettingsAppearancePaletteBackground,
Key::SettingsAppearancePaletteWallpaper,
Key::SettingsAppearancePalettePreset,
],
"码值与档位 key 必须一一对应"
);
// 三条中文句子互不相同(否则「映射对了」但界面上仍分不出层级)。
let zh = unlucky_i18n::Lang::ZhCn.catalog();
let mut texts: Vec<&str> = keys.iter().map(|key| zh.text(*key)).collect();
let total = texts.len();
texts.sort_unstable();
texts.dedup();
assert_eq!(texts.len(), total, "三条档位译文有重复:{texts:?}");
}
} }
@@ -0,0 +1,68 @@
//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。
//!
//! ## 为什么需要这一层
//!
//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的
//! 公开接口:
//!
//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree`
//! (不是子控件),没有 `as_any` 之类的向下转型口;
//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器**
//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。
//!
//! 所以取证点退到「字进树之前」那一层:`section::t` / `section::tr` 是本分组**唯一**两个取词口,
//! 它们把每条译文顺手记一份(M180 起分成两份:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的
//! 整句),测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。
//!
//! ## 为什么是 thread-local
//!
//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台,
//! 也不需要加锁或串行化。
//!
//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。
use std::cell::RefCell;
use unlucky_i18n::catalog::Key;
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序(`t()` 取的 `&'static str`)。
thread_local! {
// clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }`
// 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号):
// `thread_local!{ static X: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) }; }`
// —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。
#[allow(clippy::missing_const_for_thread_local)]
static TEXTS: RefCell<Vec<(Key, &'static str)>> = const { RefCell::new(Vec::new()) };
}
// 自上次 `take_rendered` 以来记录下的 `(key, 整句)`:`tr()` 那条带占位符的路径(M180 加)。
//
// 为什么单独一份而不是把上面那份改成 `String`:M179 的守卫
// (`pages/settings/tests/appearance_section.rs::the_appearance_group_speaks_chinese_when_the_interface_does`)
// 断言的是 `&'static str`,那个文件不在 M180 的 scope 里、不能跟着改签名。两份记录各自的
// `take*` 也各自清空,互不干扰。
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static RENDERED: RefCell<Vec<(Key, String)>> = const { RefCell::new(Vec::new()) };
}
/// 记一条(由 [`super::section::t`] 调用;不对外)。
pub(crate) fn record(key: Key, text: &'static str) {
TEXTS.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 记一条**替换完占位符**的整句(由 [`super::section::tr`] 调用;不对外)。
pub(crate) fn record_rendered(key: Key, text: String) {
RENDERED.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 取走并清空本线程记录下的取词(测试用)。
pub(crate) fn take() -> Vec<(Key, &'static str)> {
TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
/// 取走并清空本线程记录下的整句(测试用)。
pub(crate) fn take_rendered() -> Vec<(Key, String)> {
RENDERED.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
@@ -1,395 +0,0 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row, space};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner};
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::alpha::{
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
WINDOW_ALPHA_FLOOR,
};
use super::background::{self, BG_DIM_MAX};
use super::state::State;
use super::Change;
/// 「外观」分组整块(一张卡)。
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows = rows.push(slider_row(
ui,
"Window transparency",
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
let corner = state.window_corner();
rows = rows.push(choice_row(
ui,
t(Key::SettingsAppearanceCorner),
vec![
choice(
ui,
t(Key::SettingsAppearanceCornerSystem),
corner == WindowCorner::System,
Change::Corner(WindowCorner::System),
),
choice(
ui,
t(Key::SettingsAppearanceCornerSquare),
corner == WindowCorner::Square,
Change::Corner(WindowCorner::Square),
),
],
));
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
if corner == WindowCorner::System && state.window_opacity < 1.0 {
rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended)));
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
rows = rows.push(gap);
}
let tone = ui.color(Role::OnSurfaceVariant);
rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone));
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows = rows.push(slider_row(
ui,
"Panel transparency",
panel_percent,
panel_hint.to_owned(),
!forced,
panel_control,
));
// ④ 背景图开关
rows = rows.push(choice_row(
ui,
"Background image",
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
],
));
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows = rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
for note in state.diagnostics() {
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
rows = rows.push(note_line(ui, &note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
let resolved = format!("Colours: {}", state.palette_level());
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows = rows.push(choice_row(
ui,
"Fill",
vec![
choice(
ui,
"Cover",
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑧ 暗化
rows = rows.push(slider_row(
ui,
"Dim",
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
// ⑨ 配色来源
rows = rows.push(choice_row(
ui,
"Colour source",
vec![
choice(
ui,
"Background",
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
card(ui, rows)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
///
/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission,
/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**,
/// 接线时只换这一行,不用回来重写控件。
fn t(key: Key) -> &'static str {
unlucky_i18n::Lang::En.catalog().text(key)
}
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
@@ -0,0 +1,144 @@
//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。
//!
//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3:
//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::background::{self, BG_DIM_MAX};
use super::super::palette;
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, fixed2, label, note_line, slider_row, t, tr, tr_note};
/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ④ 背景图开关
rows.push(choice_row(
ui,
t(Key::SettingsAppearanceBackgroundImage, lang),
vec![
choice(
ui,
t(Key::SettingsAppearanceOff, lang),
!state.bg_enabled,
Change::BgEnabled(false),
),
choice(
ui,
t(Key::SettingsAppearanceOn, lang),
state.bg_enabled,
Change::BgEnabled(true),
),
],
));
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label(t(Key::SettingsAppearanceImagePath, lang))
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows.push(
column![
label(ui, t(Key::SettingsAppearanceImagePath, lang)),
path_field
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into(),
);
// ⑥ 诊断(读盘失败 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
//
// CPU 档的「面板/玻璃被强制」那句**不在这里**:它是张卡级状态,挂在被禁用的面板滑块旁边
// (`panel.rs` 的 `PanelOpacityForcedHint`),一屏只出现一次(M180 复审 P2-1)。
for note in state.diagnostics() {
let text = tr_note(lang, note);
rows.push(note_line(ui, &text, ui.color(Role::Error)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
let text = tr_note(lang, &note);
rows.push(note_line(ui, &text, ui.color(Role::OnSurfaceVariant)));
}
if let Some(key) = state.palette_note() {
rows.push(note_line(
ui,
t(key, lang),
ui.color(Role::OnSurfaceVariant),
));
}
// 生效的配色层级:显示的是**回落之后真正生效**的那一级(与底部「配色来源」里用户选的那一档
// 不一定相同)。码值 → 已有的三条档位 key(不新增平行词表),所以中文界面也读得懂。
let level = tr(
lang,
Key::SettingsAppearancePaletteLevel,
&[("level", t(palette::level_key(state.palette_level()), lang))],
);
rows.push(note_line(ui, &level, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows.push(choice_row(
ui,
t(Key::SettingsAppearanceBgFill, lang),
vec![
choice(
ui,
t(Key::SettingsAppearanceBgFillCover, lang),
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
t(Key::SettingsAppearanceBgFillContain, lang),
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
t(Key::SettingsAppearanceBgFillTile, lang),
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑧ 暗化
rows.push(slider_row(
ui,
t(Key::SettingsAppearanceBgDim, lang),
format!("{:.0}%", state.bg_dim * 100.0),
tr(
lang,
Key::SettingsAppearanceBgDimHint,
&[("max", &fixed2(BG_DIM_MAX))],
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
}
@@ -0,0 +1,47 @@
//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。
use iced::Element;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::PaletteSource;
use crate::pages::Message;
use crate::theme::Ui;
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, t};
/// 把 ⑨ 配色来源推进行 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ⑨ 配色来源
rows.push(choice_row(
ui,
t(Key::SettingsAppearancePaletteSource, lang),
vec![
choice(
ui,
t(Key::SettingsAppearancePaletteBackground, lang),
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
t(Key::SettingsAppearancePaletteWallpaper, lang),
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
t(Key::SettingsAppearancePalettePreset, lang),
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
}
@@ -0,0 +1,286 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(铺满 / 完整显示 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线)
//!
//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`),
//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它:
//!
//! | 文件 | 是什么 |
//! |---|---|
//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 两个取词口 [`t`] / [`tr`] |
//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) |
//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) |
//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 |
//! | [`colour_source`] | ⑨ 配色来源(三档) |
//!
//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/
//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动):
//!
//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec<Element>`);
//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线);
//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块);
//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`);
//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。
//!
//! ## 语言(M179 接过管子,M180 补齐文案)
//!
//! [`t`] / [`tr`] 是**唯一**两个取词口(一个拿 `&'static str`、一个拿带占位符的 owned 句子),
//! `lang` 从 `settings::view` 的 `Language.effective` → `Group.body` → [`section`] 一路递进来。
//! M179 只把管子接通(当时本组 27 条文案里真正本地化的只有圆角那 5 条);M180 `I18NAPPEAR` 把
//! 剩下 22 条字面量与 `state.rs` / `background.rs` / `palette.rs` 的运行时诊断全部搬进
//! `catalog/settings/appearance.rs`,于是中文界面上这一页的**卡片文案**没有一句是英文。
//!
//! 两处例外(M180 复审 L-1 记下的口径):① 写进 `{reason}` 的**原因串**里,来自本仓库自写的
//! 两条(`"no path configured"`、`"unknown image format …"`)与 OS / 解码库给出的文本仍是英文——
//! 它们是不可翻译的第三方或诊断原文;② `Windows 11` / `macOS` / `Linux` / `PNG` / `JPEG` / `BMP`
//! 这些专名。除此之外这一页不出现英文。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::state::State;
use super::Change;
mod bg_image;
mod colour_source;
mod panel;
mod window;
/// 「外观」分组整块(一张卡)。
///
/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按
/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。
pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let mut rows: Vec<Element<'a, Message>> = Vec::new();
window::push(ui, state, lang, &mut rows);
panel::push(ui, state, lang, &mut rows);
bg_image::push(ui, state, lang, &mut rows);
colour_source::push(ui, state, lang, &mut rows);
card(
ui,
column(rows)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
//
// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。
pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
///
/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录
/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M180 `I18NAPPEAR` 已把这一页剩余的 22 处
/// 字面量与全部运行时诊断搬进词表,见 `catalog/settings/appearance.rs` 的模块文档。
pub(super) fn t(key: Key, lang: Lang) -> &'static str {
let text = lang.catalog().text(key);
// 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的
// 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。
#[cfg(test)]
super::probe::record(key, text);
text
}
/// 带占位符的句子(`{name}` 替换后的 owned 版本)。
///
/// 为什么不直接 `lang.catalog().render(...)`:这条路径也要进 [`t`] 那份取证记录
/// (`probe::record_rendered`),否则「中文界面下这一页每一句都不是纯 ASCII」的守卫会漏掉所有
/// 带数字的说明句。数值参数由调用方先格式化好(词表只认 `{name}`,不认 `{:.2}`)。
pub(super) fn tr(lang: Lang, key: Key, args: &[(&str, &str)]) -> String {
let text = lang.catalog().render(key, args);
#[cfg(test)]
super::probe::record_rendered(key, text.clone());
text
}
/// 两位小数(`{:.2}` 这种格式说明由 Rust 侧做,词表里只留 `{name}`)。
pub(super) fn fixed2(value: f32) -> String {
format!("{value:.2}")
}
/// 一条 [`Note`](key + 已格式化的参数)按语言取整句;与 [`tr`] 共用取证口。
pub(super) fn tr_note(lang: Lang, note: &super::background::Note) -> String {
tr(lang, note.key(), &note.args())
}
pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
pub(super) fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
pub(super) fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
pub(super) fn choice<'a>(
ui: &'a Ui,
text: &'a str,
selected: bool,
change: Change,
) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
Loaded 100 of 549 files, more files were not shown because too many files have changed in this diff. Show more