1 Commits
Author SHA1 Message Date
AskaEth 0522a41a52 release: v0.2.5
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
2026-11-12 12:06:59 +08:00
50 changed files with 3770 additions and 1034 deletions

No files matched your search

+90 -29
View File
@@ -3,6 +3,10 @@ name: CI
# 主干门禁:fmt / clippy / test / build 双平台矩阵。 # 主干门禁:fmt / clippy / test / build 双平台矩阵。
# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。 # 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。
# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。 # 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。
# (M270)每个 `Swatinem/rust-cache@v2` 都显式写 `key:` = **该 job 的 runner 镜像标签**(不是「更保险」):v2 的 key
# 只有 `os.type()-os.arch()`(`Linux-x64`),**没有镜像标签/glibc** ⇒ **改前**本文件 `build`(ubuntu-latest = 24.04)与
# `release-build.yml` 的 `build`(ubuntu-22.04 = glibc 2.35)共用同一份 `target/`(脏产物就是这么串过去的)。因果链见
# `release-build.yml` 那处注释;`cache-targets` 取舍、剩余风险与 key 演算见 packaging/docs/build-and-ci.md §3「构建缓存」。
# #
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树,源码原样保留)。 # M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树,源码原样保留)。
# 所以本文件里所有 `--workspace` 都**不再**覆盖 `unlucky-app`:CI 不再编 slint 1.18 + usvg 0.48 # 所以本文件里所有 `--workspace` 都**不再**覆盖 `unlucky-app`:CI 不再编 slint 1.18 + usvg 0.48
@@ -10,18 +14,66 @@ name: CI
# 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README: # 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README:
# 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。 # 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。
# ---- 夜间全量矩阵(M266 / T30):为什么是 schedule,而不是 push dev ----
# 上面那套门禁的前提是「有人往 main 推东西」,而本仓的实际节奏是**日常只推 dev**、main 一个月只在
# 发版那一刻动一次 ⇒ 全量矩阵一个月跑一次,一次爆 5 个红,全部是早已存在的平台依赖问题。
# `schedule` 就是补这个缺口的入口:每晚在 main 的末梢上跑一轮,把「平台回归的发现延迟」从一个月
# 压到一天。
#
# **不要顺手把它改成 `push: branches: [dev]`。** 本文件的矩阵 = 两个 OS 的 build release + 两个 OS 的
# test + 两个 OS 的 clippy + fmt + runtime-smoke + update-e2e。挂到 dev 上等于每次提交烧一轮四平台
# 矩阵,成本与排队都不可接受(dev 白天是开发时间);而 24 小时一轮就足以把平台回归压到 T+1。
# 这是**故意的取舍**:抓得住「平台回归 T+1 就能看见」,抓不住「当天的错当天见」。dev 线的快守门
# (fmt / clippy / guards / gpu 自测)在 dev-guards.yml,那边刻意不上全量 test / build,理由见其头部。
#
# schedule 的五条语义边界(省得下次重新查;每条都能在 GitHub 文档里对上原文):
# ① 定时只在**默认分支**上生效:cron 跑的是 **main 的末梢**,dev 上尚未合入的内容**不在这一轮里**。
# 所以「dev 已经有人天天看着了」这句话是**错的**——dev 线只有 dev-guards.yml 那几道快守门
# (fmt / clippy / guards / gpu 自测),全量矩阵在 dev 上一次都没跑过。另外这份文件必须先合进
# main,cron 才会第一次响:合并之前一次都不会跑。
# ② 失败要向人喊话:`::error::` 注解由 test 作业的 shell 步骤自己产出,不读 event 上下文,这半条
# 与 push 一样。但**收件人不是「默认分支的维护者」**——文档原话是 "Notifications for scheduled
# workflows are sent to the user who last modified the cron syntax in the workflow file":
# 谁最后一次改这行 `cron`,就一直收它的告警,所以这行 cron 的改动者要当成值班人。
# ③ 逐个 job 核过 `github.event` 依赖:除 concurrency 组那一处(`github.event_name`)与
# runtime-smoke-macos 的 `if: github.event_name == 'workflow_dispatch'`(schedule 下为假 ⇒ 跳过,
# 它本来就只是手动探针)之外,其余 job 与矩阵里**没有任何** event 表达式,夜间不会空转。
# ④ 定时运行必须与 push main **分列到不同的 concurrency 组**:两者的 `github.ref` 都是
# `refs/heads/main`,同组 + `cancel-in-progress` 会让它们互相取消 —— 而**取消不是红**。
# 一个经常被静默取消的夜间入口比没有更坏:「昨晚没报错」会变成一句假话。所以 group 里带了
# event_name(证据强度见 `concurrency:` 上方那几条注释,别当成已在 runner 上实证过)。
# ⑤ **它自己会悄悄停**:public 仓库的 scheduled workflow 在仓库 60 天无活动后会被**自动 disable**
# (文档:"In a public repository, scheduled workflows are automatically disabled when no
# repository activity has occurred in 60 days")。这是「夜里那轮某天不再响、而且没人发现」的
# 真实失效模式,比红更隐蔽;本仓若是 public 就适用,发现入口不跑了先看那条 disable 提示。
#
# 时间:`17 19 * * *`。GitHub 按 UTC 解释 cron(文档:"By default, scheduled workflows run in UTC"),
# 所以它是 **19:17 UTC = 本地(UTC+8)次日 03:17**,本文件不再出现第二种换算口径。
# 为什么是 17 分:文档只说 "the start of every hour" 是高负载期,且 "If the load is sufficiently high
# enough, some queued jobs may be dropped"(负载过高时排队任务会被**丢弃**,不只是延迟),并建议错开
# "a different time of the hour"。文档没有「:30 也挤」这条依据,所以这里只避开整点。
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
# 夜间全量矩阵(M266 / T30,理由与取舍见文件头):19:17 UTC = 本地次日 03:17。
schedule:
- cron: '17 19 * * *'
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
concurrency: concurrency:
group: ci-${{ github.ref }} # 加了 schedule 之后这一行必须带上 event_name:定时运行落在默认分支上,`github.ref` 同样是
# `refs/heads/main`,与「push main」落进同一个组 ⇒ 配上 cancel-in-progress 就是两者互相掐死
# (夜里那轮被白天的推送取消 = 那一夜没有结果,而取消不是红,正是本次要填的洞)。带事件名之后:
# push↔push 仍按 ref 互斥(原有行为一字未变),定时自成一列,既不会被推送取消,也不去取消推送。
# 证据强度:以上是 **GitHub 文档语义**(schedule 事件的 ref 是默认分支、取消按 group 判定),
# **没有在真实 runner 上构造并发验证过**——本地造不出两个并发 run。别把它当实测结论引用。
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@@ -55,6 +107,8 @@ jobs:
with: with:
components: clippy components: clippy
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude), # `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
# 所以这条命令不会拉起 slint / usvg / i-slint-*。 # 所以这条命令不会拉起 slint / usvg / i-slint-*。
- run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo clippy --workspace --all-targets -- -D warnings
@@ -77,17 +131,17 @@ jobs:
libwayland-dev libxcb1-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}
# ---- 下载页选项区的四条像素守门(M127):先出图,再把路径喂给 `--lib` ---- # ---- 下载页选项区的四条像素守门(M127):先出图,再把路径喂给 `--lib` ----
# 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些 # 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些变量零命中 ⇒
# 变量零命中 ⇒ **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处 # **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处常驻调用点。(另有一道保险:
# 常驻调用点。(另有一道保险:变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过。 # 变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过 ⇒ 将来谁重构掉下面出图那步,构建会红。)
# 于是将来谁把下面出图那步重构掉,构建会红。)
# #
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸 # **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸 40×40),这些数字
# 40×40),这些数字按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上 # 按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上这套字体不存在,回退字体的字高与行距
# 这套字体不存在,回退字体的字高与行距一变就是假红。同一条限制也写在 # 一变就是假红。同一条限制也写在 `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的
# `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的模块文档里 # 模块文档里(非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
# (非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows) - name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
@@ -129,22 +183,20 @@ jobs:
name: dl-shots name: dl-shots
path: ${{ runner.temp }}/dl-shots/ path: ${{ runner.temp }}/dl-shots/
if-no-files-found: warn if-no-files-found: warn
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。 # 同 clippy:`--workspace` 不含 `unlucky-app`(M118)。**别再写 `--exclude unlucky-app`**:它已不是成员,
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条 # 只会打一条 `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
# #
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ---- # ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下 # M144 的第三条红 `test (windows)` 只留下 `Process completed with exit code 1`,而 cargo 自己的失败码
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的 # 是 **101** ⇒ 那个 1 的来源不明(包装层洗码 / 进程被杀,两说都未排除);job log 要登录才能读
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403), # (匿名 403),但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件 # 各对准一种「看不见」:
# 事各自对准一种「看不见」: # 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑 7 个 test
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了 # binary 就退出),后面还有没有第二条失败完全看不到;
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到; # 2) `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,注解是匿名侧
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时, # 唯一能取到的失败正文;
# 注解是匿名侧唯一能取到的失败正文; # 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、零诊断」
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀 # 分开——这正是 M144 没定的那一半。
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解) - name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
@@ -167,8 +219,8 @@ jobs:
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
env: env:
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。 # M171 跨卷守卫:runner 有没有第二块可写卷没核实手段,所以不赌 ⇒ 显式豁免(惰性:真探到就不参与
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。 # 判断,见 install_xdev_cases 的真值表用例)。
ULL_XDEV_TEST_WAIVE: "1" ULL_XDEV_TEST_WAIVE: "1"
run: | run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet, # pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
@@ -204,6 +256,10 @@ jobs:
libwayland-dev libxcb1-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
# M270:本 job 就是与发布侧**同名同 key** 的那一个(都叫 build,镜像 24.04 vs 22.04)。
# 镜像标签进 key 前缀之后,主 key 与兜底 restoreKey 两条恢复路径都不再跨镜像命中。
key: ${{ matrix.os }}
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档 # 同上:`--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
@@ -246,9 +302,10 @@ jobs:
fonts-dejavu-core fonts-liberation2 fonts-dejavu-core fonts-liberation2
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为; with:
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。 key: ubuntu-latest
# M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。 # 用 debug 而不是 release:「运行时渲染通路能通」与优化无关,release 的 lto="fat" + codegen-units=1
# 只会把这一步拖到十几分钟。M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
- run: cargo build --bin unlucky-launcher-iced --locked - run: cargo build --bin unlucky-launcher-iced --locked
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败) - name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
run: | run: |
@@ -287,6 +344,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
key: macos-latest
- run: cargo build --bin unlucky-launcher-iced --locked - run: cargo build --bin unlucky-launcher-iced --locked
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论) - name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
run: | run: |
@@ -330,6 +389,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
key: windows-latest
- name: update-e2e - name: update-e2e
run: > run: >
cargo test --release -p unlucky-cli --features update-test-source cargo test --release -p unlucky-cli --features update-test-source
+9 -11
View File
@@ -180,9 +180,10 @@ jobs:
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。 # D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件, # M196 加第二处(控制台附着 `AttachConsole`)、M256 加第三处(系统「减少动效」探针
# 判据是**集合相等**而不是「黑名单命中」: # `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)`;Linux/macOS 两支走子进程、零 unsafe)。
# - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); # 三处各自成文件,判据是**集合相等**而不是「黑名单命中」:
# - 任何第四个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
@@ -194,17 +195,16 @@ jobs:
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合 - name: 全仓显式放行 unsafe 的文件集合 == 期望集合
run: | run: |
set -uo pipefail set -uo pipefail
EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs' EXPECTED=$'crates/app-iced/src/platform/motion.rs\ncrates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。 # 扫描口径(M91 r3):一个小词法器,而不是正则。
# #
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏, # `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到—— # 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证: # 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(在它尾部加
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。 # 作用域 allow,r2 门禁 exit 0 假绿)。
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 / # 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw / 字符字面量),
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个 # 只在「普通态」认 `#`/`#!` 紧跟 `[` 的属性起点,再累计到那一个属性的 `]` 配平。
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
# #
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会 # 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在 # 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
@@ -217,7 +217,6 @@ jobs:
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行; # - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码); # 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml; # - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
#
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(), # r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们): # 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
# 红 #[doc = r#"a "b"#] 后跟真 allow # 红 #[doc = r#"a "b"#] 后跟真 allow
@@ -234,7 +233,6 @@ jobs:
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿), # 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。 # `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。 # 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
#
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。 # 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符); # 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0, # g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
+39 -40
View File
@@ -1,14 +1,13 @@
name: Release / build(可复用) name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。 # Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# M145 时本文件 = 拆分前 `.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,os,build_dir,rustflags}` → `inputs.{slot,os,build_dir,rustflags}`)。
# `matrix.rustflags` → `inputs.rustflags`。
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器; # **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。 # 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)—— # **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有 # 进了 `PUBLISH_SLOTS` 就得由 CI 每轮证明「发出去的包真能跑」;只在 `linux-x64` 跑(arm64 没有运行时证据,不造没验证过的门禁)。
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。 # **M270 给 `Swatinem/rust-cache` 加了 `key`(= 镜像标签)与 `save-if`(tag run 不写)**:机制与取舍见 `packaging/docs/build-and-ci.md` §3「构建缓存」。
# 矩阵**仍由调用方** `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
@@ -70,7 +69,20 @@ jobs:
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
# (M270)`key:` 必须带**镜像维度**,不是「加个 key 更保险」:v2 的 key = `v0-rust-` + 本行 key + job +
# `-Linux-x64`(os.type/os.arch)+ env 哈希 + 锁哈希 —— 里面**没有镜像标签/glibc**;本 job 与 `ci.yml` 的
# `build` 同 job id、rustc 同版、同一份 `Cargo.lock` ⇒ 曾逐字节相同,`ubuntu-latest`(24.04) 编的 `target/`
# 被恢复到 `ubuntu-22.04`(2.35)。致命处:cargo 的 fingerprint(`.rlib` 的 `-C metadata`)**不含 glibc** ⇒
# 判 fresh 不重编,24.04 编的 `aws-lc-sys` `bcm.o`(引 `__isoc23_sscanf`)在 2.35 上链接 ⇒ undefined。
# 故 key 只取 `inputs.os`:linux-x64(22.04) 与 main 的 `ubuntu-latest` 从此不同 key(要断的就是这条),而
# windows-x64(windows-latest) 与 main 的 `ci/build` **故意同 key** —— 同镜像,借热缓存安全(M270 前 Windows
# 一直走这条且产物是绿的);slot 是冗余维度,每个槽位的 `inputs.os` 本来就互不相同。
# `save-if`:tag run 只能读 main / 同名 tag 的缓存 ⇒ 本侧写出的 target 缓存永远轮不到复用,只贡献冷编 +
# 死缓存 + 挤掉 main 的热缓存。故 tag 不写;留 `ref_type != 'tag'` 这个门是给 main 上 dispatch 预热用的。
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with:
key: ${{ inputs.os }}
save-if: ${{ github.ref_type != 'tag' }}
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**: # (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3 # M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
@@ -129,15 +141,12 @@ 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 反正自带」这种默契: # (M164)arm64 槽位在编译前把工具链**显式**备齐(不再靠「runner 反正自带」):① `rustup target add
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生 # aarch64-pc-windows-msvc`(原生 runner 上幂等 no-op,把「缺 std」挡在编译前);② `LIB`/`INCLUDE` 用微软
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std); # 自己的 `vcvarsall.bat arm64` 取(**不手搓路径**,少一个目录就是 LNK1181)并写进 `$GITHUB_ENV`;
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是 # ③ `link.exe` 由 vswhere 定位后导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 优先)。
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步; # **②必须先于③**:只给链接器不给 LIB 撞的就是 `LNK1181: cannot open input file 'kernel32.lib'`。
# ③ 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:
@@ -153,12 +162,10 @@ jobs:
rustup target list --installed rustup target list --installed
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ -x "$vswhere" ]; then if [ -x "$vswhere" ]; then
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的 # ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 路径直接塞给 `cmd //c "..."`**:MSYS 会把 `"` 改写成
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报 # `\"`,cmd 的 /c 剥离接不住 ⇒ LIB/INCLUDE 全空、只剩 `set` 的空输出(M164 复审实测)。故往
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出 # `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat(引号留在 .bat 的 `call "..."` 里),`cmd //c`
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在 # 只收不含引号的文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}} tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
mkdir -p "$tmp" mkdir -p "$tmp"
vcvars="" vcvars=""
@@ -212,24 +219,19 @@ jobs:
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
fi fi
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于 # (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于它进了 PE——
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式 # 资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式版本号、FIXEDFILEINFO
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。 # 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。只在 Windows 槽位跑:Linux/macOS 产物
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见 # 没有 PE 头,那边 `build.rs` 整段不执行(见 `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**。
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。 # 步骤体本身在 `packaging/ci-pe-version-gate.sh`:读回 + 同源取版本号 + 失败逐条转 `::error::` 注解
# (M264 从本文件拆出,给 400 行硬门禁腾地方;判据没动,仍由检查器 exit 1)。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里) - name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
env: env:
BUILD_DIR: ${{ inputs.build_dir }} BUILD_DIR: ${{ inputs.build_dir }}
run: | run: |
set -euo pipefail bash packaging/ci-pe-version-gate.sh "$BUILD_DIR"
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'
@@ -289,11 +291,9 @@ jobs:
PY=$(command -v python3 || command -v python) 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" "$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 每轮证明, # (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,「发出去的包真能开窗渲出一帧」得每轮由 CI 证明
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。 # (证据见 `packaging/docs/linux-evidence.md`)。**只在 linux-x64 跑**:判据按 x64 软渲染实测标定,`linux-arm64`
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的 # 上一次都没验过,写成 `runner.os == 'Linux'` 就是给没验证过的槽位加会红的判据(M171 同类事故)。
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言) - name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
if: inputs.slot == 'linux-x64' if: inputs.slot == 'linux-x64'
shell: bash shell: bash
@@ -380,9 +380,8 @@ jobs:
retention-days: 14 retention-days: 14
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和 # (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。 # 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。M164 起本槽位的红
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是 # **只可能来自真实构建链路**(体检永不 exit 1):红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里, # M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。 # 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
- name: arm64 失败收尾注解(红不挡发布) - name: arm64 失败收尾注解(红不挡发布)
+7
View File
@@ -1,5 +1,12 @@
name: Release guard name: Release guard
# (M268)显式 `run-name`:本文件**不是**可复用工作流(`on:` 是 push main / pull_request / workflow_dispatch),
# 每次都在 Actions 列表里单独占一行,所以同样吃「运行名 = 提交信息」这条默认行为——v0.2.4 的发布提交推到 main 时,
# 那段「孤儿提交 / 无父 / 树 = dev …」在这里也会再泄漏一次。PR 事件的 `github.ref_name` 是 `<PR 号>/merge`,
# 贴出来不人读,故按事件分两支:PR 用 `github.event.pull_request.number`(`github` 上下文,run-name 可用且必有值),
# 其余(push main / dispatch)用 `ref_name`(`refs/heads/main` ⇒ `main`,非空)。**`name:` 一字未动。**
run-name: "${{ github.event_name == 'pull_request' && format('Release guard · PR #{0}', github.event.pull_request.number) || format('Release guard · {0}', github.ref_name) }}"
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。 # 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
# #
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事: # 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
+5
View File
@@ -1,5 +1,10 @@
name: Release / publish(可复用) name: Release / publish(可复用)
# (M268)**故意不加 `run-name`**:本文件只有 `on.workflow_call`,由 `release.yml` 的 `release` job `uses:` 调用。
# 可复用工作流**不产生自己的 run**——它的 job 挂在调用方那一次 run 里(Actions 列表里只有 `release.yml` 那一行,
# 运行名由 `release.yml` 的 `run-name` 决定),所以这里写 run-name 是死代码,写了也不会显示。同理没有 `name:`
# 改动。真正需要 run-name 的另一处是 `release-guard.yml`(那是独立触发的,会单独占一行)。
#
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release` # Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
# 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`、
+11
View File
@@ -1,5 +1,16 @@
name: Release name: Release
# (M268)显式 `run-name`:**不写这一行,GitHub 就把提交信息当运行名**贴到 Actions 列表上——公开界面曾经出现
# 「release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)」,把 D41 快照模型的实现术语泄漏给所有人。
# 取值面已核(文档:`run-name` 可用 `github` / `inputs` / `vars`,无特殊函数限制):
# push tag `v0.2.4` ⇒ `github.ref = refs/tags/v0.2.4` / `ref_type = tag` ⇒ `Release v0.2.4`
# dispatch 在 tag ref 上 ⇒ 同上(`gh workflow run release.yml --ref v0.2.4`)⇒ `Release v0.2.4`
# dispatch 在分支 ref 上 ⇒ `github.ref_name = main` / `ref_type = branch` ⇒ `Release 试跑 main(分支 ref,不发布)`
# 两种 ref 面上 `ref_name` 都非空(`ref` 一定存在),故不需要兜底分支。分支 ref 单独成一种写法是因为它被
# `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外——一律写 `Release main` 会让人以为 main 上发了版。
# **只新增 run-name,`name:`(工作流标识符)一字未动**:改名会断掉历史 run 与分支保护/规则的匹配。
run-name: "${{ github.ref_type == 'tag' && format('Release {0}', github.ref_name) || format('Release 试跑 {0}(分支 ref,不发布)', github.ref_name) }}"
# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` / # 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带 # `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、 # `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
+25 -1
View File
@@ -5,7 +5,31 @@
--- ---
## 0.2.4 — 未发布(当前 `dev`) ## 0.2.5 — 当前版本
> 0.2.4 打过 tag 但**发布流水线全红、没有生成发布包**,其内容整体并入本版。所以本版是 0.2.4 + 下面这些。
**动效(本版主线)**
- **切页有方向了**:同级页按导航栏的上下顺序左右移动,进详情页/返回按层级纵深移动;旧页同时反向退场,
不是「只挪新页」那种一闪而过
- **对话框改成绕中心缩放**(0.96→1.0)+ 背景渐暗,不再是硬切;关闭时反向
(此前实现把缩放的平移项写反了,实际是「从左上斜滑进来」,本版修正)
- **跟随系统「减少动效」设置**:系统关掉动画时全站动效归零(首次真正接上系统偏好,此前只有环境变量入口)
- 过渡中途再次切页会干净接管,不会叠三层或卡住;过渡结束后不再保留上一页
**发布与构建**
- 修复「英文系统上构建校验误报失败」:检查器把中文打到非 UTF-8 输出流会抛异常,判据其实全过
- 修复「旧版系统上链接失败」:构建缓存跨系统串用,导致用新系统编译的中间产物在旧系统上链接
- 修复一处只在新系统上才暴露的编译告警(Windows 侧永远看不到)
- 发布工作流的运行名改为固定格式(不再显示提交信息原文)
**稳定性**
- 三条「只在特定平台/特定时序才红」的测试改成确定性判据:路径写法、全局状态竞争、文件时间戳精度
- 修复一处会让 Windows 桌面闪黑框的子进程启动(动效探针未走静默启动)
---
## 0.2.4 — 已并入 0.2.5(原 tag 发布失败)
这一批的主线是「把存档区做成真正能用」,以及把验证方式从「看起来对」改成「能被证伪」。 这一批的主线是「把存档区做成真正能用」,以及把验证方式从「看起来对」改成「能被证伪」。
Generated
+8 -8
View File
@@ -5129,7 +5129,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -5144,7 +5144,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
@@ -5159,7 +5159,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5171,7 +5171,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"flate2", "flate2",
@@ -5191,15 +5191,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.4" version = "0.2.5"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.4" version = "0.2.5"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5207,7 +5207,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.4" version = "0.2.5"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"serde", "serde",
+1 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.4" version = "0.2.5"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
+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.4" } unlucky-core = { path = "../core", version = "0.2.5" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+5 -5
View File
@@ -22,15 +22,15 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.4" } unlucky-design = { path = "../design", version = "0.2.5" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 # 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.4" } unlucky-core = { path = "../core", version = "0.2.5" }
# 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.4" } unlucky-presenter = { path = "../presenter", version = "0.2.5" }
# 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.4" } unlucky-cli = { path = "../cli", version = "0.2.5" }
# 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.4" } unlucky-i18n = { path = "../i18n", version = "0.2.5" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
+113 -13
View File
@@ -27,6 +27,7 @@ use unlucky_design::typography::TypeRole;
use crate::app::components::buttons; use crate::app::components::buttons;
use crate::app::components::feedback::dialog::scrim; use crate::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry; use crate::app::components::feedback::panel::DialogGeometry;
use crate::app::components::motion;
use crate::pages::frame; use crate::pages::frame;
use crate::theme::Ui; use crate::theme::Ui;
@@ -58,37 +59,72 @@ pub(crate) fn layer<'a>(
base: Element<'a, Message>, base: Element<'a, Message>,
modal: &'a Modal, modal: &'a Modal,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// M256:模态进出只由**一个**进度驱动——遮罩做画布色→scrim 色的**预合成**插值(不透明),
// 卡片做 0.96→1.0 的缩放。`progress = 1` 时两者都退化成改造前的原值(出图基线不变)。
let progress = motion::modal().progress;
let backdrop = mouse_area( let backdrop = mouse_area(
container(Space::new()) container(Space::new())
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| container::Style { .style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color(ui))), background: Some(Background::Color(backdrop_color_at(ui, progress))),
..container::Style::default() ..container::Style::default()
}), }),
) )
.on_press(Message::Dialog(DialogMessage::Dismiss)); .on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。 // 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(card_plane(ui, modal)) let centered = container(motion::scaled(
.width(Length::Fill) card_plane(ui, modal),
.height(Length::Fill) motion::modal_scale(progress),
.align_x(Alignment::Center) ))
.align_y(Alignment::Center) .width(Length::Fill)
.padding(ui.space(Sp::X6)); .height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into() iced::widget::stack![base, backdrop, centered].into()
} }
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。 /// 遮罩色(静息 = 完全展开):**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
/// ///
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把 /// 不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把整窗
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档 /// **提亮**(r1 实测 (9,21,28) → (129,138,145));且 α 混色在 wgpu/tiny-skia 两档合成口径不同
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。 /// (linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
/// ///
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。 /// 只在测试构建里存在:让 [`super::tests`] 断言**视图真正用的那一份**;产品路径一律走
/// [`backdrop_color_at`](`progress = 1` 那一端逐位相同)。
#[cfg(test)]
pub(super) fn backdrop_color(ui: &Ui) -> Color { pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui) backdrop_color_at(ui, 1.0)
}
/// 遮罩色在**动画进度** `progress` 上的取值(M256):画布色 → scrim 色的预合成插值。
///
/// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级**
/// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经
/// 合成好的不透明色从画布色插值到 scrim 色,全程 `a = 1.0`,两个后端出图一致。
/// `1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。
///
/// ⚠️ **口径(复审 P2-3 订正)**:`0` 时这一层是**整块画布色的不透明矩形**,它把页面内容
/// **整块遮住**(不是"透明所以看不见",也不是"看得到页面")——D27 禁面板 α,所以只有这一种
/// 可行实现。观感是「画面先被画布色盖住、随进度变暗到 scrim」,不是「页面透过淡遮罩隐隐可见」。
pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color {
let t = progress.clamp(0.0, 1.0);
if t >= 1.0 {
return scrim(ui);
}
let from = ui.layer(MaterialLayer::Canvas);
let to = scrim(ui);
Color {
r: from.r + (to.r - from.r) * t,
g: from.g + (to.g - from.g) * t,
b: from.b + (to.b - from.b) * t,
// 预合成色两端都是不透明的:这里显式钉死 1.0,不给"淡入=半透明"留口子。
a: 1.0,
}
} }
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。 /// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
@@ -297,3 +333,67 @@ fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Messag
.wrapping(iced::widget::text::Wrapping::WordOrGlyph) .wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into() .into()
} }
#[cfg(test)]
mod motion_tests {
//! 模态进场的**可判读数值取证**(M256 / tower 门槛 E):缩放值与遮罩色逐档钉住。
//!
//! 抓得住:把淡入做回「调 α」(`a < 1` 即红,D27)、把 `progress = 1` 做成近似值(与
//! `backdrop_color` 不等即红)、把缩放方向做反。抓不住:**观感**(截图见 artifacts/motion1)。
use super::*;
use crate::app::components::motion;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
const FAMILIES: crate::theme::Families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn luma(color: Color) -> f32 {
0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
}
/// 遮罩:`0` 是整块画布色(**把页面内容盖住**,不是透明)、`1` 是原遮罩(逐位相同)、
/// 全程不透明且单调变暗。
#[test]
fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
let canvas = ui.layer(MaterialLayer::Canvas);
let rest = backdrop_color(&ui);
assert_eq!(
backdrop_color_at(&ui, 0.0),
canvas,
"{flavor:?}: 起手同画布"
);
assert_eq!(
backdrop_color_at(&ui, 1.0),
rest,
"{flavor:?}: 到位逐位相同"
);
let mut previous = luma(canvas);
for step in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
let color = backdrop_color_at(&ui, step);
assert_eq!(color.a, 1.0, "{flavor:?}: 遮罩必须全程不透明(D27)");
let current = luma(color);
assert!(
current <= previous + 1.0e-6,
"{flavor:?}: 遮罩必须单调变暗({previous} → {current})"
);
previous = current;
println!(
"[motion1] {flavor:?} t={step:.2} scale={:.3} rgb({:.3},{:.3},{:.3})",
motion::modal_scale(step),
color.r,
color.g,
color.b
);
}
}
}
}
+9
View File
@@ -118,6 +118,15 @@ pub struct App {
pub(super) width: f32, pub(super) width: f32,
/// 展示转换缓存(presenter 快照 → 页面视图模型)。 /// 展示转换缓存(presenter 快照 → 页面视图模型)。
pub(super) view: pages::Model, pub(super) view: pages::Model,
/// **换页过渡期的旧页快照**(M256):页面切换时把上一帧的 `view` 移进来,退场腿走完就丢掉。
///
/// 为什么必须存在 `App` 里而不是线程局部:`pages::page_with_settings_transition` 返回的
/// `Element<'a>` **借**模型里的字符串,`'a` 由调用方决定 ⇒ 线程局部的借用守卫活不过函数体,
/// 只有 `App` 的字段能给出 `&'a Model`。
///
/// **用完即焚**:`App::sync_motion` 在 `motion::needs_frames()` 为假的那一拍把它置 `None`——
/// 这是长时间常驻的启动器,留着就是「每换一次页多一份模型」的线性增长。
pub(super) previous_view: Option<pages::Model>,
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态** /// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。 /// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
pub(super) view_dirty: bool, pub(super) view_dirty: bool,
@@ -126,6 +126,8 @@ impl App {
last_tick: std::time::Instant::now(), last_tick: std::time::Instant::now(),
width, width,
view, view,
// M256:换页过渡期的「旧页」快照。启动时没有旧页。
previous_view: None,
view_dirty: false, view_dirty: false,
watch_until: None, watch_until: None,
window: None::<window::Id>, window: None::<window::Id>,
+29 -2
View File
@@ -9,6 +9,7 @@ use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
use super::{mods_shot, saves_shot}; use super::{mods_shot, saves_shot};
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::components::motion;
use crate::app::view; use crate::app::view;
use crate::pages; use crate::pages;
@@ -81,7 +82,7 @@ impl App {
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104) // 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width); let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。 // M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::super::view::model( let next = super::super::view::model(
&self.state, &self.state,
width, width,
self.height, self.height,
@@ -91,6 +92,15 @@ impl App {
self.lang(), self.lang(),
&self.content, &self.content,
); );
// M256:换页 / 换层(屏 或 详情 id 变了)时才把**上一帧的模型**移进 `previous_view`。
// 用 `replace`(移动)而不是克隆:旧页要的是**转换开始那一刻的快照**,之后 presenter 再怎么
// 刷新都不该动它(否则会看到"两个页面同时在动内容")。
// 同一屏的普通刷新(下载进度 / 主题 / 语言)不换——否则每来一条事件就多留一份模型。
if (self.view.screen, detail_key(&self.view)) != (next.screen, detail_key(&next)) {
self.previous_view = Some(std::mem::replace(&mut self.view, next));
} else {
self.view = next;
}
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。 // M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form(); self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
@@ -98,7 +108,15 @@ impl App {
self.rebuild_account_page(width); self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量 // M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。 // (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
self.dialog_model = self.dialog_modal(); //
// M256:**退场期间要留住旧框**。逻辑状态(`state.dialogs`)在用户点下去的那一拍就关掉了
// (所以 `dialog_modal()` 变 `None`),但画面还要缩着退出去 150ms——那一份模型必须留到
// 退场跑完(`App::sync_motion` 收到 `take_modal_closed()` 时置 `None`)。
// 判据用 `motion::modal_closing()` 而不是"有没有动效":关着动效时它恒为假 ⇒ 当场清。
let next_modal = self.dialog_modal();
if next_modal.is_some() || !motion::modal_closing() {
self.dialog_model = next_modal;
}
self.view_dirty = false; self.view_dirty = false;
self.refreshes += 1; self.refreshes += 1;
} }
@@ -232,3 +250,12 @@ impl App {
self.mods_failed_seen = Some(key); self.mods_failed_seen = Some(key);
} }
} }
/// 详情的身份键(`None` = 不在详情页):与 `app/update/motion_sync.rs` 的 `detail` 同一判据。
fn detail_key(model: &pages::Model) -> Option<&str> {
model
.instances
.detail
.as_ref()
.map(|detail| detail.id.as_str())
}
+65 -20
View File
@@ -108,14 +108,12 @@ mod tests {
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训) //! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//! //!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是 //! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。 //! 「`update()`(订阅表在这里求值)→ `view()`」,于是守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表, //! 现在全部改走**真消息**,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」的假绿)。
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。 //! 时间轴用 `motion::advance_by` 摆(不等真实时钟);动效全局态是 thread-local,天然隔离。
//!
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
use super::*; use super::*;
use crate::app::components::motion::{Slot, FRAME_MS}; use crate::app::components::motion::FRAME_MS;
use crate::cli::Options; use crate::cli::Options;
use crate::pages; use crate::pages;
use crate::theme::Families; use crate::theme::Families;
@@ -132,6 +130,9 @@ mod tests {
}; };
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 /// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
///
/// 定档走 `motion::configure_for_test`(P2-5):产品入口读宿主机「减少动效」开关,用例在关了
/// 系统动画的机器上会红;真探针的接线另有专门用例守。
fn headless() -> App { fn headless() -> App {
App::with_presenter( App::with_presenter(
Options::default(), Options::default(),
@@ -148,7 +149,7 @@ mod tests {
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
fn idle_app() -> App { fn idle_app() -> App {
motion::reset(); motion::reset();
motion::configure(MotionScale::STANDARD, None); motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless(); let mut app = headless();
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档) // 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
app.window = Some(iced::window::Id::unique()); app.window = Some(iced::window::Id::unique());
@@ -238,7 +239,7 @@ mod tests {
#[test] #[test]
fn without_a_window_nothing_animates() { fn without_a_window_nothing_animates() {
motion::reset(); motion::reset();
motion::configure(MotionScale::STANDARD, None); motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless(); let mut app = headless();
assert!(app.window.is_none(), "前提:还没拿到窗口句柄"); assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
let _ = app.update(Message::Boot); let _ = app.update(Message::Boot);
@@ -253,8 +254,7 @@ mod tests {
} }
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 /// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
/// /// 变异证明:把 `Pump::play_page` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
#[test] #[test]
fn no_scale_requests_no_frames_at_all() { fn no_scale_requests_no_frames_at_all() {
motion::reset(); motion::reset();
@@ -262,7 +262,7 @@ mod tests {
app.window = Some(iced::window::Id::unique()); app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot); let _ = app.update(Message::Boot);
app.watch_until = None; app.watch_until = None;
motion::configure(MotionScale::NONE, None); motion::configure_for_test(MotionScale::NONE, None);
// 三种状态变化:换页 / 换详情 / 再换回来 // 三种状态变化:换页 / 换详情 / 再换回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
@@ -278,11 +278,9 @@ mod tests {
motion::reset(); motion::reset();
} }
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。 /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔(不会「动画与 Busy 各订一条」)。
/// /// 口径是**全仓 `src/**`**,只看代码行;唯一允许的第二个文件是 `pages/harness.rs`
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看 /// (`pages_demo` 这条独立 example 自己的 app,不进产品路径)。
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
#[test] #[test]
fn there_is_exactly_one_frame_subscription() { fn there_is_exactly_one_frame_subscription() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
@@ -341,10 +339,57 @@ mod tests {
} }
} }
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 /// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。
/// 口径:`pages/**` 的产品代码不许出现时长的任何入口(`DurationToken` / `page_spec` /
/// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档由
/// `components/motion/transition.rs` 那张表决定(D31)。
///
/// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴(变异:
/// 在 `pages/download.rs` 顶上加 `use unlucky_design::motion::DurationToken;` ⇒ 红)。
/// **抓不住**:①某档数字写错(那是 `components/motion/tests` 的登记用例);②绕过符号名的
/// 路径(转一手、通配导入)——只按符号名扫,不做类型分析。
#[test] #[test]
fn slots_are_the_three_documented_ones() { fn pages_cannot_reach_for_a_duration() {
assert_eq!(Slot::ALL.len(), 3); let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); .join("src")
.join("pages");
let mut files = Vec::new();
collect_rust_files(&dir, &mut files);
assert!(
files.len() >= 20,
"只扫到 {} 个页面文件:扫描逻辑坏了",
files.len()
);
const FORBIDDEN: [&str; 5] = [
"DurationToken",
"page_spec",
"modal_spec",
"FRAME_MS",
"advance(",
];
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
// 只看产品代码(测试与文档注释不算:`#[cfg(test)]` 之后整段跳过)。
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"));
for symbol in FORBIDDEN {
if code.clone().any(|line| line.contains(symbol)) {
let name = path
.strip_prefix(&dir)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
hits.push((name, symbol));
}
}
}
assert!(
hits.is_empty(),
"页面层不许直接碰时长/时间轴(用哪一档是 `transition.rs` 的表决定的):{hits:?}"
);
} }
} }
+39 -9
View File
@@ -7,10 +7,33 @@
use iced::Task; use iced::Task;
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::components::motion;
use crate::app::dialog::{export, Message as DialogMessage}; use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App; use crate::app::state::App;
/// 关掉当前这个框(**唯一出口**,M256)。
///
/// 逻辑状态是**当场**关的(`close_dialog()` 立刻清 `state.dialogs`)——延迟关闭会让队列里那个
/// 框在退场期间仍然"活着"(`current()` 还是它),所有按"有没有框"判断的代码都会看到一个已经
/// 点过「确定」的框。所以退场只发生在**画面上**:`refresh_view` 在 `motion::modal_closing()`
/// 期间保留旧框的视图模型,`App::sync_motion` 在退场走完那一拍把它置 `None`。
///
/// 关着动效(`MotionScale::NONE` / 截图通道)时 `begin_modal_close()` 返回 `true`,
/// `modal_closing()` 恒假 ⇒ 当场清,退回改造前的行为。
fn close(app: &mut App) {
let _ = motion::begin_modal_close();
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。退场的第一帧必须在这一拍画出来。
app.refresh_view();
}
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> { pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
// M256:退场动画期间**吞掉**弹窗消息。框正在缩小,这时再点一次「删除世界」不该再生效
// (主按钮的 `take_confirm` 已经在上一次点击里核销过目标,但用户看到的仍是"框还在")。
if motion::modal_closing() {
return Task::none();
}
match message { match message {
DialogMessage::Primary => { DialogMessage::Primary => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。 // M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
@@ -20,7 +43,10 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。 // 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
let current = app.state.dialogs.current().map(|request| request.id); let current = app.state.dialogs.current().map(|request| request.id);
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) { if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
app.close_dialog(); // M256:走统一出口(逻辑当拍关,画面多留 150ms 退场)。
// 目标已经在上一行核销 ⇒ 退场期间再点一次主按钮不会删第二个世界
// (那一拍的消息还会被 `modal_closing` 那道闸门吞掉)。
close(app);
if let Some(instance) = app.selected_instance_id() { if let Some(instance) = app.selected_instance_id() {
app.state app.state
.saves .saves
@@ -31,12 +57,11 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir }, unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
))); )));
} }
app.refresh_view();
return Task::none(); return Task::none();
} }
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。 // 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else { let Some(card) = app.dialog_card.clone() else {
app.close_dialog(); close(app);
return Task::none(); return Task::none();
}; };
let done = export::export(&card.report, app.lang()); let done = export::export(&card.report, app.lang());
@@ -64,10 +89,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
if let Some(current) = app.state.dialogs.current().map(|request| request.id) { if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
app.state.saves.close_confirm(current); app.state.saves.close_confirm(current);
} }
app.close_dialog(); // M256:统一出口(有动效时先退场)。「立即重画」这条要求由 `close` 里的
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒 // `refresh_view` 满足:退场的第一帧必须在这一拍画出来。
// (r1 复审 P3-1)。 close(app);
app.refresh_view();
Task::none() Task::none()
} }
DialogMessage::Toggle => { DialogMessage::Toggle => {
@@ -121,6 +145,12 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。 /// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool { pub(super) fn escape(app: &mut App) -> bool {
// M256:退场窗口里 Esc **也算被吃掉**。`on_dialog` 那道闸门管不到这里(Esc 走的是
// `Message::Agent(Key)` 这条独立路径),而逻辑状态已经关掉 ⇒ 不加这一行,Esc 会落到
// AI 栏去收起面板,用户看到的是「框还在缩、AI 栏先没了」。
if motion::modal_closing() {
return true;
}
let Some(current) = app.state.dialogs.current().map(|request| request.id) else { let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
return false; return false;
}; };
@@ -129,8 +159,8 @@ pub(super) fn escape(app: &mut App) -> bool {
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。 // 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。 // 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
app.state.saves.close_confirm(current); app.state.saves.close_confirm(current);
app.close_dialog(); // M256:统一出口(Esc 也走退场动画,而不是硬跳)。
app.refresh_view(); close(app);
true true
} }
+273 -41
View File
@@ -2,27 +2,16 @@
//! //!
//! ## 为什么这条不能在 `view` 里 //! ## 为什么这条不能在 `view` 里
//! //!
//! iced 0.14 的处理顺序是: //! iced 0.14 的顺序:`update()`(**订阅表在这里求值**,iced_winit `src/lib.rs:1338`)→
//! //! `build_user_interfaces()`(这时才调 `view`,`src/lib.rs:1233`)⇒ **订阅表的求值早于视图**。
//! ```text //! 第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,`frames()` 要等下一条消息才打开,而那一帧
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338) //! 的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,用户看到的是一次 12/8px 的偏移闪跳(截图通道
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233) //! 因为 `has_screenshot()` 恒真,**结构上**不可能暴露这条)。所以起跑搬到这里。
//! ```
//!
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
//! **结构上**不可能暴露这条。
//!
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
//! //!
//! ## 标识从哪来 //! ## 标识从哪来
//! //!
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走 //! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」,
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑, //! `nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,不看屏键大小——见 [`nav_rank`])。
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
use unlucky_presenter::router::Route; use unlucky_presenter::router::Route;
@@ -30,12 +19,24 @@ use crate::app::components::motion;
use crate::pages; use crate::pages;
use super::boot_env::MotionFixture; use super::boot_env::MotionFixture;
use crate::app::message::nav_item_of;
use crate::shell::NavItem;
use super::*; use super::*;
impl App { impl App {
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。 /// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
pub(in crate::app) fn sync_motion(&mut self) { pub(in crate::app) fn sync_motion(&mut self) {
// M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。
// 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的
// 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。
// M256 r2(P2-2):退场走完只在**队列里确实没有框**时才丢掉画面模型——退场途中开了新框时
// `observe_modal(true)` 已把旧退场当场收尾(并留下一次「已关」),照样置空会闪一下。
if motion::take_modal_closed() && self.state.dialogs.current().is_none() {
self.dialog_model = None;
self.view_dirty = true;
}
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时 // **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义—— // `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。 // 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
@@ -49,7 +50,16 @@ impl App {
Route::Detail(id) => Some(id.as_str()), Route::Detail(id) => Some(id.as_str()),
_ => None, _ => None,
}; };
motion::observe(screen, detail); motion::observe(screen, nav_rank(route), detail);
// M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`:
// 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。
motion::observe_modal(self.state.dialogs.current().is_some());
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
if !motion::needs_frames() {
self.previous_view = None;
}
} }
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。 /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
@@ -133,34 +143,256 @@ fn screen_key(route: &Route) -> Option<u64> {
.map(|index| index as u64 + 1) .map(|index| index as u64 + 1)
} }
/// 这一屏在**导航轨**上的次序(`NavItem::ALL` 的下标)——左右方向的唯一真源。
///
/// 为什么不能用屏键(`Screen::ALL` 的下标):两处顺序不同。`Screen::ALL` 是
/// `… Content / Settings / Logs`,而导航轨是 `… Logs / Settings / Ai` ⇒ 用屏键会把
/// **Logs↔Settings 的左右镜像**(复审 P2-1)。`Content` 与 `Download` 共用导航项,`rank` 相等,
/// 方向退回按屏键比大小(`Download ↔ Content` 判成同级水平位移)。
///
/// 账户头像(`NavItem::Account`)与 AI(`NavItem::Ai`)不在这个集合里 / 不归 pages 画,
/// 返回 `None`——它们的切换本来就不起跑页面轨道。
fn nav_rank(route: &Route) -> Option<u8> {
let item = nav_item_of(route);
NavItem::ALL
.iter()
.position(|candidate| *candidate == item)
.map(|index| index as u8)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。 const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test] #[test]
fn only_the_five_screens_get_a_key() { fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keys: Vec<Option<u64>> = [ let keyed = |route: &Route| (screen_key(route), nav_rank(route));
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!( assert_eq!(
keys[5], keyed(&Route::Detail("survival".to_string())),
Some(1), (Some(1), Some(0)),
"详情页归「实例」那一屏(与 Screen::of 同源)" "详情归「实例」"
); );
assert_eq!(keys[6], None, "账户页不归 pages 画"); assert_eq!(
assert_eq!(keys[7], None, "AI 独立页不归 pages 画"); keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
} }
} }
+3 -1
View File
@@ -142,9 +142,11 @@ impl App {
APP_TITLE, APP_TITLE,
background, background,
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。 // M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
pages::page_with_settings( pages::page_with_settings_transition(
&self.ui, &self.ui,
&self.view, &self.view,
// M256:换页过渡期的旧页快照(`App::sync_motion` 在动画结束当拍把它丢掉)。
self.previous_view.as_ref(),
pages::settings::States { pages::settings::States {
appearance: Some(&self.appearance), appearance: Some(&self.appearance),
groups: &self.settings, groups: &self.settings,
+140 -59
View File
@@ -1,65 +1,69 @@
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。 //! **动效帧泵**(M192 建,M256 接上页面方向与模态):design 的 motion 令牌 → iced 的**有界**帧订阅。
//! //!
//! ## 这个模块解决什么 //! ## 这个模块解决什么
//! //!
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 / //! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的 //! `MotionScale::NONE` 降级),但适配层长期只有一个消费点。本模块是那条中间层:
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧** //! **谁在动**(页面轨道 / 模态轨道)、**动多久朝哪动**([`pump`] 与 `transition` 的表)、
//! ([`needs_frames`])。 //! **什么时候该订阅帧**([`needs_frames`])。
//! //!
//! ## 有界泵的契约(四条,都有守卫) //! ## 有界泵的契约(五条,都有守卫)
//! //!
//! | 契约 | 为什么 | 守在哪 | //! | 契约 | 为什么 | 守在哪 |
//! |---|---|---| //! |---|---|---|
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` | //! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump/tests.rs`、`app/subscription.rs` |
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) | //! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | `pump/tests.rs` |
//! | **时间轴推进**不密于 [`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` | //! | **模态完全展开后不占帧** | 对话框开着可能几分钟,动画只有开/关那两段 | `pump/tests.rs::an_open_modal_stops_requesting_frames` |
//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` | //! | **时间轴推进**不密于 [`pump::FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump/tests.rs` |
//! //! | 起跑**必须发生在 `update` 侧**([`observe`] / [`observe_modal`]),`view` 侧只采样 | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上 | `app::subscription::tests` |
//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**:
//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**),
//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。
//!
//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支,
//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳
//! 天然不会双订阅——它们共享同一个布尔。
//! //!
//! ## `MotionScale::NONE` 是**真归零** //! ## `MotionScale::NONE` 是**真归零**
//! //!
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是 //! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play_page` 在 `is_off()` 时**不插入轨道**,
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见 //! [`frame`] 于是返回 [`PageFrame::STILL`](位移**严格** `(0,0)`、不画旧页),[`modal`] 要么
//! `pump::tests::none_scale_never_starts_a_track`。 //! 不画要么 `progress = 1.0`(缩放严格 1.0),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。
//!
//! ## 系统「减少动效」(M256 接的真入口)
//!
//! [`configure`] 收的是一个**已经算好**的 `MotionScale`(来源在 `app/update/boot_env.rs`:
//! 截图 / CPU / env)。系统偏好是**上限**,在这里与它合成:
//!
//! | 系统偏好 | 结果 | 为什么 |
//! |---|---|---|
//! | [`crate::platform::motion::ReduceMotion::Reduced`] | `NONE` | 用户明确要求减少动效,优先于默认档 |
//! | `AnimationsOn` | 传入的档 | —— |
//! | [`crate::platform::motion::ReduceMotion::Unknown`] | 传入的档 | **不许**因为探针失败就静默关掉动效(那会变成「某些机器上动效神秘消失」) |
//!
//! 探针本身**只在启动时读一次并缓存**(`OnceLock`),不在帧路径上。
//! //!
//! ## 为什么是 thread-local 而不是 `App` 的字段 //! ## 为什么是 thread-local 而不是 `App` 的字段
//! //!
//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要 //! 它是**时间轴**(一张轨道表 + 一个时钟),不是应用状态:没有业务语义、不需要跨进程持久。
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在 //! iced 在事件循环线程上串行调 `update` / `view`,所以一个线程局部就够,且**测试天然隔离**。
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, //! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs`)。
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
//! //!
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了 //! ## 位移走 `padding`
//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。
//! //!
//! ## 谁在什么时候调 //! 位移套在 `container` 的 `padding` 上:动画期间内容比容器少几像素,末帧归零后与静息**逐像素相同**。
//! //! 不做逐像素 α(D27:CPU 档禁面板半透明)也不做模糊(同上),所以两个渲染后端出图一致。
//! | 时机 | 调用 | 在哪 | //! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。
//! |---|---|---|
//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) |
//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` |
//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` |
//!
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
//!
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
mod pump; mod pump;
mod transition;
mod zoom;
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动), // `Pump` / `MAX_STEP_MS` 只被测试与文档引用(产品路径拿不到时长与时间轴),显式放过:
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 // 它们是对外口径的一部分(`FRAME_MS` 被 `app/subscription.rs` 的守卫用)。
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
#[allow(unused_imports)] #[allow(unused_imports)]
pub use pump::{offset, spec, Slot, Spec, FRAME_MS}; pub use pump::{Pump, FRAME_MS, MAX_STEP_MS};
// 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。
// (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。)
#[allow(unused_imports)]
pub use transition::{
enter_offset, exit_offset, modal_scale, modal_spec, page_spec, Kind, Leg, ModalFrame,
ModalSpec, PageFrame, PageSpec, MODAL_SCALE_FROM,
};
pub use zoom::scaled;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::time::Instant; use std::time::Instant;
@@ -69,10 +73,10 @@ use iced::{Element, Length, Padding};
use unlucky_design::motion::MotionScale; use unlucky_design::motion::MotionScale;
use crate::platform::motion::ReduceMotion;
// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报 // clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等 // (换过三种写法都照报,属于误报;仓内 `pages/appearance/probe.rs` 等两处已有同样的 `allow`)。
// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对
// 展开后的 item 报的)。
thread_local! { thread_local! {
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
#[allow(clippy::missing_const_for_thread_local)] #[allow(clippy::missing_const_for_thread_local)]
@@ -80,13 +84,49 @@ thread_local! {
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。 /// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
#[allow(clippy::missing_const_for_thread_local)] #[allow(clippy::missing_const_for_thread_local)]
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) }; static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
/// **测试专用**:`Some(档)` = 钉住这个档,[`configure`] 既不再读系统偏好、也不吃传入值。
#[allow(clippy::missing_const_for_thread_local)]
static FORCED_SCALE: Cell<Option<MotionScale>> = const { Cell::new(None) };
} }
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。 /// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
///
/// **系统「减少动效」在这里合成**(见模块文档的表):`Reduced` ⇒ `NONE`;`Unknown` ⇒ 不动。
pub fn configure(scale: MotionScale, pin: Option<f64>) { pub fn configure(scale: MotionScale, pin: Option<f64>) {
// 三条定档路径**都汇到这里**:①测试/`on_boot` 直接调;②`App::on_boot` → `configure_motion`
// → 这里(`app/update.rs:276`);③`boot_env::motion_scale(..)` 算出的 env / 后端 tier 也是经②
// 喂进来的。所以「钉住某个档」必须在这个函数里生效——堵调用点或堵 `boot_env` 都会被后一次
// `on_boot` 覆盖(r2 复审实测:`idle_app()` 先钉 STANDARD,Boot 之后变 HALF/NONE)。
let scale = match FORCED_SCALE.with(Cell::get) {
Some(forced) => forced,
None => effective_scale(scale, ReduceMotion::probe(), pin.is_some()),
};
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
} }
/// **测试专用**定档:绕过系统偏好探针,只落档位(M256 r2 / 复审 P2-5)。
///
/// 为什么必须有它:`configure` 会读宿主机「减少动效」开关,而用例在关了系统动画的机器上会红;
/// 用例一律走这里才与宿主机无关。**至少保留一条**故意走真探针的用例
/// (`tests::modal::configure_goes_through_the_real_os_probe`),否则等于把「OS 偏好真的参与合成」
/// 这条判据悄悄删掉。
#[cfg(test)]
pub fn configure_for_test(scale: MotionScale, pin: Option<f64>) {
FORCED_SCALE.with(|forced| forced.set(Some(scale)));
configure(scale, pin);
}
/// 档位与系统偏好的**合成本身**(纯函数,可断言)。
///
/// `pinned` = 取证相位钉住(`ULL_MOTION_AT`):多帧序列图要在同一台机器上重复拍到同一相位,
/// 所以钉住时**不**被系统偏好降级(否则取证机一旦开了"减少动效"就一张图都拍不出来)。
pub fn effective_scale(scale: MotionScale, reduce: ReduceMotion, pinned: bool) -> MotionScale {
match reduce {
ReduceMotion::Reduced if !pinned => MotionScale::NONE,
_ => scale,
}
}
/// 当前档位。 /// 当前档位。
pub fn scale() -> MotionScale { pub fn scale() -> MotionScale {
PUMP.with(|pump| pump.borrow().scale()) PUMP.with(|pump| pump.borrow().scale())
@@ -105,27 +145,66 @@ pub fn needs_frames() -> bool {
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
/// ///
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页), /// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
/// `detail` 是详情页的实例 id。 /// `rank` 是这一屏在**导航轨**(`shell::nav::NavItem::ALL`)里的次序——**左右方向只看它**,
/// /// 不看屏键大小(两处顺序不同:`Screen::ALL` 里 Settings 在 Logs 前,导航轨里反过来),
/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1) /// `detail` 是详情页的实例 id。方向与深度由 [`pump::Pump::observe`] 判:
/// /// **同级换页走水平、进/出深层走纵向,旧页反向退场**。
/// 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` 是几分钟前的值, /// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。 /// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
pub fn observe(screen: Option<u64>, detail: Option<&str>) { pub fn observe(screen: Option<u64>, rank: Option<u8>, detail: Option<&str>) {
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail)); let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, rank, detail));
if started { if started {
LAST_FRAME.with(|last| last.set(Some(Instant::now()))); LAST_FRAME.with(|last| last.set(Some(Instant::now())));
} }
} }
/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。 /// 当前这一帧的页面位移(**只采样、无副作用**):`pages::render` 每帧调一次。
pub fn sample() -> (f32, f32) { pub fn frame() -> PageFrame {
PUMP.with(|pump| pump.borrow().offsets()) PUMP.with(|pump| pump.borrow().page_frame())
}
/// 弹窗的**逻辑**开关(每帧 update 侧同步一次):`true` = 队列里有框。
///
/// 返回 `true` = 起跑了进场轨道(调用方要重置帧基准)。
pub fn observe_modal(open: bool) -> bool {
let started = PUMP.with(|pump| pump.borrow_mut().observe_modal(open));
if started {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
started
}
/// 请求关掉弹窗。返回 `true` = **已经关了**(关着动效 / 本来就没开),调用方立刻
/// `close_dialog()`;返回 `false` = 退场动画在跑,真正的关闭由 [`take_modal_closed`] 交回。
pub fn begin_modal_close() -> bool {
let done = PUMP.with(|pump| pump.borrow_mut().begin_modal_close());
if !done {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
done
}
/// 退场是否已走完(取走一次)。**在 `update` 侧调**:拿到 `true` 就把框真正关掉。
pub fn take_modal_closed() -> bool {
PUMP.with(|pump| pump.borrow_mut().take_modal_closed())
}
/// 退场是否正在跑(`app/update/dialog.rs` 据此吞掉弹窗消息:框已经在缩小,再点一次「删除」
/// 不该再生效)。
pub fn modal_closing() -> bool {
PUMP.with(|pump| pump.borrow().modal_closing())
}
/// 这一帧模态该画成什么样(缩放进度 + 遮罩浓度都用它)。
pub fn modal() -> ModalFrame {
PUMP.with(|pump| pump.borrow().modal_frame())
}
/// 当前页面轨道是哪一档(`None` = 静息;日志与断言用,产品路径只采样位移)。
#[allow(dead_code)]
pub fn kind() -> Option<Kind> {
PUMP.with(|pump| pump.borrow().kind())
} }
/// 推进时间轴(`App::on_frame` 每帧调一次)。 /// 推进时间轴(`App::on_frame` 每帧调一次)。
@@ -142,7 +221,7 @@ pub fn advance() {
}); });
} }
/// 把入场位移套到树上。 /// 把位移套到树上。
/// ///
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的 /// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套, /// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
@@ -168,6 +247,8 @@ pub fn shifted<'a, M: 'a>(
pub fn reset() { pub fn reset() {
PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new()); PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new());
LAST_FRAME.with(|last| last.set(None)); LAST_FRAME.with(|last| last.set(None));
// 钉住也要清:「故意走真探针」的那条用例靠 `reset()` 回到未钉住状态。
FORCED_SCALE.with(|forced| forced.set(None));
} }
/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。 /// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。
@@ -1,348 +0,0 @@
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
//!
//! 这一层不认识 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,317 @@
//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。
//!
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
//!
//! ## 两条轨道(M256):页面的两腿 + 模态的进出,各走各的时间轴(互不替代)
//!
//! | 轨道 | 触发 | 结束 |
//! |---|---|---|
//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 |
//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] |
//!
//! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。
use unlucky_design::motion::{DurationToken, MotionScale};
mod modal;
use super::transition::{
enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec,
};
pub(crate) use modal::Modal;
/// 逐帧**推进时间轴**的最短间隔(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`) |
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;
/// 一条正在跑的页面轨道。
#[derive(Debug, Clone, Copy)]
struct PageTrack {
kind: Kind,
spec: PageSpec,
start_ms: f64,
}
/// 帧泵:一条时间轴 + 最多一条页面轨道 + 一个模态状态机。
///
/// **默认是关的**(`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,
page: Option<PageTrack>,
modal: Modal,
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
close_pending: bool,
screen: Option<u64>,
/// 该屏在**导航轨**上的次序(`shell::nav::NavItem::ALL` 下标;`None` = 不在轨上)。
rank: Option<u8>,
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,
page: None,
modal: Modal::Hidden,
close_pending: false,
screen: None,
rank: 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.rank = None;
self.detail = None;
self.pending_ms = 0.0;
self.modal = Modal::Hidden;
self.close_pending = false;
if scale.is_off() {
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
self.page = None;
}
}
/// 当前档位。
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.page.is_some() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
}
/// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。
fn start_now(&self) -> f64 {
if self.pin.is_some() {
0.0
} else {
self.now_ms
}
}
/// 当前动画时刻(钉住相位时恒为钉住值)。
fn elapsed_ms(&self) -> f64 {
self.pin.unwrap_or(self.now_ms)
}
/// 把某一档时长换算成实际毫秒(`MotionScale` 是唯一缩放口)。
fn leg_ms(&self, duration: DurationToken) -> f64 {
f64::from(self.scale.ms(duration))
}
// ------------------------------------------------------------ 页面切换
/// 起跑一次页面切换(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。
///
/// 新轨道**取代**上一条:同一时刻只有一次页面切换(否则位移会叠加)。
pub fn play_page(&mut self, kind: Kind) -> bool {
self.page = None;
if self.scale.is_off() {
return false;
}
let spec = page_spec(kind);
self.page = Some(PageTrack {
kind,
spec,
start_ms: self.start_now(),
});
true
}
/// 当前页面轨道(测试与日志用;产品路径只看采样出来的位移)。
#[allow(dead_code)]
pub fn kind(&self) -> Option<Kind> {
self.page.map(|track| track.kind)
}
/// 页主体的这一帧采样(**只采样,无副作用**)。
///
/// 两条腿各走各的时长:进场读 `enter.duration`,退场读 `exit.duration`(更短)。
/// `exiting` 在退场腿走完的那一帧就变假 ⇒ 调用方不再画旧页(不长留一份幽灵树)。
pub fn page_frame(&self) -> PageFrame {
let Some(track) = &self.page else {
return PageFrame::STILL;
};
let elapsed = self.elapsed_ms() - track.start_ms;
let enter_t = leg_fraction(elapsed, self.leg_ms(track.spec.enter.duration));
let exit_t = leg_fraction(elapsed, self.leg_ms(track.spec.exit.duration));
PageFrame {
enter: enter_offset(
track.spec.enter_from,
track.spec.enter.easing.sample(enter_t),
),
exit: exit_offset(track.spec.exit_to, track.spec.exit.easing.sample(exit_t)),
exiting: exit_t < 1.0,
}
}
/// 内容**标识**变了就起跑对应的一档;**必须在 update 侧调**(见 `App::sync_motion`)。
///
/// 三条判据(顺序即优先级):
/// 1. 第一次看到**归 pages 画的屏** → [`Kind::Boot`];
/// 2. 屏变了 → [`Kind::Peer`],方向由**屏在导航表里的先后**决定(更靠后的屏 = 向右推进);
/// 3. 屏没变但详情变了 → 列表→详情 = [`Kind::Push`](深一层)、详情→列表 = [`Kind::Pop`],
/// 两个不同的详情之间横跳按同级处理。
///
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。
///
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
pub fn observe(&mut self, screen: Option<u64>, rank: Option<u8>, detail: Option<&str>) -> bool {
let kind = if !self.painted {
self.painted = true;
screen.map(|_| Kind::Boot)
} else if self.screen != screen {
match (self.screen, screen, self.rank, rank) {
// 左右方向 = **用户在导航轨上看到的次序**(`rank`),不是内部屏键的大小:
// 两者顺序不同(`Screen::ALL` 里 Settings 在 Logs 前,导航轨里 Logs 在 Settings
// 前)⇒ 用屏键会把 Logs↔Settings 镜像。同 rank(Download / Content 共用导航项)
// 时退到屏键比大小,方向仍是"向后 / 向前"。
(Some(old_key), Some(new_key), Some(old_rank), Some(new_rank)) => {
Some(Kind::Peer {
forward: (new_rank, new_key) > (old_rank, old_key),
})
}
// 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。
(_, Some(_), _, _) => Some(Kind::Boot),
_ => None,
}
} else if screen.is_some() && self.detail.as_deref() != detail {
Some(match (self.detail.is_some(), detail.is_some()) {
(false, true) => Kind::Push,
(true, false) => Kind::Pop,
// 两个不同的详情之间横跳:深度没变,按同级换内容处理。
_ => Kind::Peer { forward: true },
})
} else {
None
};
self.screen = screen;
self.rank = rank;
if self.detail.as_deref() != detail {
self.detail = detail.map(str::to_owned);
}
kind.is_some_and(|kind| self.play_page(kind))
}
// ------------------------------------------------------------ 模态
// ------------------------------------------------------------ 时间轴
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
///
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
/// 的唯一出口)④钉住相位时时间轴不前进(取证专用)。
pub fn advance(&mut self, dt_ms: f64) -> bool {
if !self.is_animating() {
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;
self.expire();
true
}
/// 到期轨道当场清掉(③)。
fn expire(&mut self) {
let now = self.elapsed_ms();
if let Some(track) = self.page {
let enter = self.leg_ms(track.spec.enter.duration);
let exit = self.leg_ms(track.spec.exit.duration);
if now - track.start_ms >= enter.max(exit) {
self.page = None;
}
}
let spec = modal_spec();
match self.modal {
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
self.modal = Modal::Open
}
Modal::Closing { start_ms, .. }
if now - start_ms >= self.leg_ms(spec.exit.duration) =>
{
self.modal = Modal::Hidden;
self.close_pending = true;
}
_ => {}
}
}
}
/// `elapsed / duration` 夹到 `0..=1`(`duration <= 0` ⇒ 直接到位,不产生 NaN)。
fn leg_fraction(elapsed: f64, duration: f64) -> f32 {
if duration <= 0.0 {
return 1.0;
}
(elapsed / duration).clamp(0.0, 1.0) as f32
}
impl Default for Pump {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,134 @@
//! 模态状态机与采样(`pump` 的子模块,只为守 400 行硬线而拆)。
//!
//! | 状态 | 进入条件 | 离开条件 |
//! |---|---|---|
//! | [`Modal::Hidden`] | `configure` / 退场走完 / 被别处硬关 | 开框 |
//! | [`Modal::Opening`] | `observe_modal(true)` | 进场腿走完 ⇒ `Open` |
//! | [`Modal::Open`] | 进场走完 | `begin_modal_close()` |
//! | [`Modal::Closing`] | `begin_modal_close()` | 退场腿走完 ⇒ `Hidden` + 交回一次「已关」 |
//!
//! **`Open` 是"没有轨道"的稳态**:对话框开着可能几分钟,那不是动画,一帧都不该订阅。
use super::super::transition::{modal_spec, ModalFrame};
use super::{leg_fraction, Pump};
/// 模态的状态机。
///
/// 为什么要状态机而不是一条进度:**弹窗开着的时候不能订阅帧**。`Open` 是一个"没有轨道"的
/// 稳态([`Pump::is_animating`] 为假),只有起手与收尾那两段是动画。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Modal {
/// 没有弹窗。
Hidden,
/// 进场中。
Opening { start_ms: f64 },
/// 完全展开(稳态,不占帧)。
Open,
/// 退场中。`from` = 起手那一刻的进度:进场途中被关掉时它是**当前进度**,不是 1.0
/// (否则会先跳到完全展开再往回缩 = 反向硬跳)。
Closing { start_ms: f64, from: f32 },
}
impl Pump {
/// 弹窗的**逻辑**开关(每帧 update 侧同步一次)。
///
/// - 开了 → 从 [`Modal::Hidden`] 起跑进场;已经在进/开着就不动。
/// - 关了(不是走 [`Pump::begin_modal_close`] 的那条路,即框已经被别处直接关掉)→
/// 立刻落 [`Modal::Hidden`](没有框可画,也就没有退场可言)。
///
/// 返回 `true` = 起跑了进场轨道。
pub fn observe_modal(&mut self, open: bool) -> bool {
if open {
// 退场途中来了新框:**当场收尾**旧退场(`Hidden` + 待关),再走进场。不收尾的话
// 新框会按递减的 progress 画(看起来是倒放),而且 `modal_closing()` 仍为真 ⇒
// `on_dialog` 的闸门会把新框的按钮全吞掉(P2-2 的另两条后果)。
if matches!(self.modal, Modal::Closing { .. }) {
self.modal = Modal::Hidden;
self.close_pending = true;
}
if matches!(self.modal, Modal::Hidden) {
if self.scale.is_off() {
self.modal = Modal::Open;
return false;
}
self.modal = Modal::Opening {
start_ms: self.start_now(),
};
return true;
}
return false;
}
if !matches!(self.modal, Modal::Closing { .. }) {
self.modal = Modal::Hidden;
self.close_pending = false;
}
false
}
/// 请求关掉弹窗:有动效就起跑退场(返回 `false`,真正的关闭由 [`Pump::take_modal_closed`]
/// 交回调用方),关着动效就返回 `true`(**立刻关,不留 0 时长动画**)。
pub fn begin_modal_close(&mut self) -> bool {
if self.scale.is_off() || matches!(self.modal, Modal::Hidden) {
self.modal = Modal::Hidden;
self.close_pending = false;
return true;
}
// 已经在退场 ⇒ 保持原样(重复的关闭请求不该重算起点)。
if matches!(self.modal, Modal::Closing { .. }) {
return false;
}
// **起点是当前进度**:进场走到 20% 就关 ⇒ 从 0.20 往回缩;已展开 ⇒ 从 1.0 缩。
// 不这么做就是「先跳到 1.0 再退」的反向硬跳(复审 r2 的 P2-2)。
let from = self.modal_frame().progress;
self.modal = Modal::Closing {
start_ms: self.start_now(),
from,
};
false
}
/// 退场是否已经走完(取走一次,之后返回 `false`)。
pub fn take_modal_closed(&mut self) -> bool {
std::mem::take(&mut self.close_pending)
}
/// 退场是否正在跑(这段时间里的弹窗消息要被吞掉:框已经在缩小,再点一次「删除」不该再生效)。
pub fn modal_closing(&self) -> bool {
matches!(self.modal, Modal::Closing { .. })
}
/// 这一帧模态该画成什么样。
pub fn modal_frame(&self) -> ModalFrame {
match self.modal {
Modal::Hidden => ModalFrame::HIDDEN,
Modal::Open => ModalFrame {
visible: true,
progress: 1.0,
},
Modal::Opening { start_ms } => {
let spec = modal_spec();
let t = leg_fraction(
self.elapsed_ms() - start_ms,
self.leg_ms(spec.enter.duration),
);
ModalFrame {
visible: true,
progress: spec.enter.easing.sample(t),
}
}
Modal::Closing { start_ms, from } => {
let spec = modal_spec();
let t = leg_fraction(
self.elapsed_ms() - start_ms,
self.leg_ms(spec.exit.duration),
);
ModalFrame {
visible: true,
// 从起手进度**连续**缩到 0:`from * (1 - eased)`。`from = 1.0` 时与改造前
// 逐帧等价;进场途中关则是"从半开接着缩"。
progress: from * (1.0 - spec.exit.easing.sample(t)),
}
}
}
}
}
@@ -1,345 +0,0 @@
//! 帧泵的守卫(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(), "钉住的相位不会自然结束(取证专用)");
}
@@ -0,0 +1,389 @@
//! 帧泵与过渡表的守卫(M192 建,M256 扩)。全部用**本地** `Pump` 摆时间轴:不起窗口、
//! 不等真实时间,所以不会假绿。
//!
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs`:这一组只管算术与表。
//!
//! 每条用例都写清「抓得住什么 / 抓不住什么」(T27 之后的纪律)。判据是「泵帧数 + 端点 +
//! 单调性」,不是像素比对:像素只能证明"某一张图有没有偏移",证明不了"中间有 19 个相位"。
//!
//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
mod modal;
mod timeline;
use super::pump::Pump;
use super::transition::{page_spec, Kind, ModalFrame, PageFrame};
use super::*;
use unlucky_design::motion::MotionScale;
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
fn standard() -> Pump {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, None);
pump
}
/// 按「每帧 16 ms」推进到没有轨道为止,返回推进了多少帧(上限 1250 帧 = 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
}
/// 一路采样所有中间态(含终点),用完自己 settle。
fn samples(pump: &mut Pump) -> Vec<PageFrame> {
let mut out = vec![pump.page_frame()];
while pump.is_animating() {
pump.advance(FRAME_MS);
out.push(pump.page_frame());
}
out
}
// ------------------------------------------------------------------ 规格表
/// 规格**全部**来自 design 的令牌表,且落在 M3E 的短档(150~300ms)。
///
/// 抓得住:谁把某档改成 400ms 以上("更显眼"的诱惑),或把幅度改成非 4 的倍数。
/// 抓不住:观感好不好——那要截图(D30),不在这条用例的能力范围里。
#[test]
fn every_transition_is_built_from_registered_tokens() {
let kinds = [
Kind::Boot,
Kind::Peer { forward: true },
Kind::Peer { forward: false },
Kind::Push,
Kind::Pop,
];
for kind in kinds {
let spec = page_spec(kind);
for leg in [spec.enter, spec.exit] {
assert!(
(150..=300).contains(&leg.duration.ms()),
"{}: {}ms 不在 MD3E 短档(150~300)",
kind.name(),
leg.duration.ms()
);
assert!(leg.easing.sample(0.0).abs() < 1.0e-6);
assert!((leg.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
}
// 进略长于出(M3E:进场要看清,退场只是让位)
assert!(
spec.enter.duration.ms() > spec.exit.duration.ms(),
"{}: 进场必须比退场长(进 {} / 出 {})",
kind.name(),
spec.enter.duration.ms(),
spec.exit.duration.ms()
);
// 幅度落在 4px 栅格上,且退场幅度小于进场幅度
let (inx, iny) = spec.enter_from;
let (outx, outy) = spec.exit_to;
for value in [inx, iny, outx, outy] {
assert_eq!(value % 4.0, 0.0, "{}: {value} 不在 4px 栅格上", kind.name());
}
assert!(
inx.abs().max(iny.abs()) >= outx.abs().max(outy.abs()),
"{}: 退场幅度不该大于进场幅度",
kind.name()
);
}
// 四档的动作方向钉住(谁改方向,报告与出图都要跟着改)
assert_eq!(
page_spec(Kind::Peer { forward: true }).enter_from,
(12.0, 0.0)
);
assert_eq!(page_spec(Kind::Peer { forward: true }).exit_to, (-8.0, 0.0));
assert_eq!(
page_spec(Kind::Peer { forward: false }).enter_from,
(-12.0, 0.0)
);
assert_eq!(page_spec(Kind::Push).enter_from, (0.0, 16.0));
assert_eq!(page_spec(Kind::Push).exit_to, (0.0, -8.0));
assert_eq!(page_spec(Kind::Pop).enter_from, (0.0, -16.0));
}
// ------------------------------------------------------------------ 页面切换
/// **进入是 12~20 帧、落在端点、中途有严格中间态**(塔给的判据原文)。
///
/// 抓得住:①把时长改到 300ms 以外(帧数跑出窗口);②把 `enter_offset` 改成直接返回 0
/// ("硬跳",中间态消失);③把末帧没归零(`PageFrame::STILL` 的端点断言红)。
/// 抓不住:这一帧画出来好不好看(截图的事)。
#[test]
fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() {
let mut pump = standard();
assert!(pump.observe(Some(2), Some(1), None), "首次观察要起跑 Boot");
let frames = settle(&mut pump);
assert!(
(12..=20).contains(&frames),
"进入应落在 12~20 帧,实测 {frames} 帧"
);
// 重跑一次,逐帧看端点与单调性
pump.observe(Some(3), Some(1), None); // 同级换页:medium2 = 300ms
let trace = samples(&mut pump);
assert!(trace.len() >= 12, "中间态太少:{}", trace.len());
assert_eq!(
trace.last().copied(),
Some(PageFrame::STILL),
"末帧必须归位"
);
assert_eq!(trace[0].enter, (12.0, 0.0), "首帧必须满幅(向右推进)");
assert!(
trace
.iter()
.skip(1)
.any(|f| f.enter.0 > 0.0 && f.enter.0 < 12.0),
"中途必须存在**严格介于两端之间**的相位"
);
// 单调收敛到 0(缓动是单调曲线,取整也不会反弹)
let mut previous = trace[0].enter.0;
for frame in trace.iter().skip(1) {
assert!(
frame.enter.0 <= previous,
"位移必须单调归零:{previous} → {}",
frame.enter.0
);
previous = frame.enter.0;
}
}
/// **旧页反向退场**:同一拍里新页从 +12 归零、旧页向 −8 走;退场腿更短,先结束。
///
/// 抓得住:把 `exit` 位移删掉/写成同向("只挪新页"),或让退场腿和进场腿一样长。
/// 抓不住:旧页**内容**是否正确(那是 `pages::page_with_settings_transition` 的职责,
/// 归 `app` 层的用例)。
#[test]
fn the_old_page_moves_the_other_way_and_leaves_first() {
let mut pump = standard();
pump.observe(Some(2), Some(1), None);
settle(&mut pump);
assert!(pump.observe(Some(3), Some(1), None));
let first = pump.page_frame();
assert_eq!(first.enter, (12.0, 0.0));
assert_eq!(first.exit, (0.0, 0.0), "退场起点是 0");
assert!(first.exiting, "退场腿刚开始");
let mut saw_half = false;
let mut exit_finished_at = None;
let mut frames = 0;
while pump.is_animating() {
frames += 1;
pump.advance(FRAME_MS);
let frame = pump.page_frame();
if frame.exit.0 < 0.0 && frame.exit.0 > -8.0 {
saw_half = true;
}
if !frame.exiting && exit_finished_at.is_none() {
exit_finished_at = Some(frames);
}
}
assert!(saw_half, "旧页必须有严格中间态(不是一步到位)");
let exit_frames = exit_finished_at.expect("退场腿必须在进场腿之前结束");
assert!(
exit_frames < frames,
"退场({exit_frames} 帧)必须比进场({frames} 帧)先结束"
);
}
/// **方向由导航深度决定**:同级换页走水平、进深层走纵向。
///
/// 抓得住:把四档动作表合并成一档("都往右下飘"),或把 `forward` 判据反了(回到左边却向右推)。
/// 抓不住:导航表本身的顺序对不对(那是 `pages::Screen::ALL` / `screen_key` 的事)。
#[test]
fn direction_follows_navigation_depth() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
settle(&mut pump);
// 向右推进(屏键 1 → 3)
pump.observe(Some(3), Some(1), None);
assert_eq!(pump.kind(), Some(Kind::Peer { forward: true }));
assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移");
settle(&mut pump);
// 向左退回(3 → 1)
pump.observe(Some(1), Some(0), None);
assert_eq!(pump.kind(), Some(Kind::Peer { forward: false }));
assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像");
settle(&mut pump);
// 进深层(同屏:列表 → 详情)
pump.observe(Some(1), Some(0), Some("instance-7"));
assert_eq!(pump.kind(), Some(Kind::Push));
let push = pump.page_frame();
assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)");
assert_eq!(push.exit, (0.0, 0.0));
settle(&mut pump);
// 出深层(详情 → 列表)
pump.observe(Some(1), Some(0), None);
assert_eq!(pump.kind(), Some(Kind::Pop));
assert_eq!(pump.page_frame().enter, (0.0, -16.0), "出深层必须镜像");
}
/// **左右方向看导航轨序,不看屏键**(复审 P2-1 的守卫)。
///
/// 两处顺序不同:`Screen::ALL` 是 `… Content / Settings / Logs`,导航轨是 `… Logs / Settings / Ai`。
/// 用屏键判方向会让 **Logs↔Settings 镜像**。
///
/// 抓得住:把 `Pump::observe` 的方向改回「比较屏键」(`new > old`)——本用例里
/// Settings(键 4 / **序 3**) → Logs(键 5 / **序 2**) 会被判成向右推进,第一条断言立即红。
/// 也抓得住「谁调了 `Screen::ALL` 的顺序」:这里**只引用轨序**,与那张表的声明顺序解耦。
/// 抓不住:导航轨本身的视觉位置(那是 `shell/nav.rs` 的几何,归它的守卫)。
#[test]
fn direction_comes_from_the_nav_rail_not_the_screen_table() {
let mut pump = standard();
pump.observe(Some(4), Some(3), None); // Settings:屏键 4、轨序 3
settle(&mut pump);
// 轨序 3 → 2(向左)。若按屏键 4 → 5,会被判成向右 —— 那正是被修的 bug。
pump.observe(Some(5), Some(2), None);
assert_eq!(
pump.kind(),
Some(Kind::Peer { forward: false }),
"Settings → Logs 在导航轨上是向左"
);
assert_eq!(pump.page_frame().enter, (-12.0, 0.0));
settle(&mut pump);
// 反向:轨序 2 → 3 = 向右
pump.observe(Some(4), Some(3), None);
assert_eq!(pump.kind(), Some(Kind::Peer { forward: true }));
assert_eq!(pump.page_frame().enter, (12.0, 0.0));
}
/// **`MotionScale::NONE` 下位移与缩放严格为 0**(不是"很小")。
///
/// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()`
/// 会短暂给出非 0 的位移、`samples` 会增长、帧订阅次数不再是 0。
/// 抓不住:OS 偏好有没有真的读到(那是 `platform::motion` 的用例与真机取证)。
#[test]
fn none_scale_is_strictly_zero_not_merely_small() {
let mut pump = Pump::new(); // 出厂即 NONE
assert!(pump.scale().is_off());
// 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`)
for (screen, rank, detail) in [
(Some(1), Some(0), None),
(Some(2), Some(1), None),
(Some(2), Some(1), Some("instance-7")),
(Some(2), Some(1), None),
] {
assert!(!pump.observe(screen, rank, detail), "关掉动效时不许起跑");
let frame = pump.page_frame();
assert_eq!(frame.enter.0, 0.0);
assert_eq!(frame.enter.1, 0.0);
assert_eq!(frame.exit.0, 0.0);
assert_eq!(frame.exit.1, 0.0);
assert!(!frame.exiting, "NONE 档不许画旧页");
}
// 模态:进场直接到 1.0(缩放严格 1.0),退场直接关
assert!(!pump.observe_modal(true), "NONE 档不起模态轨道");
assert_eq!(
pump.modal_frame(),
ModalFrame {
visible: true,
progress: 1.0
}
);
assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6, "缩放严格 1.0");
assert!(pump.begin_modal_close(), "NONE 档必须**当场**关,不留退场");
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
assert!(!pump.is_animating());
for _ in 0..120 {
assert!(!pump.advance(FRAME_MS), "NONE 档没有任何可推进的");
}
assert_eq!(pump.samples(), 0, "NONE 档的帧采样次数必须是 0");
}
// ------------------------------------------------------------------ 触发判据
/// 重复绘制**永远不会**重新起跑(否则每帧都重启动画 = 永远停不下来)。
///
/// 抓得住:把「标识没变 ⇒ 不起跑」的判断写错(比如每个 view 都 `play_page`)——30 次重复绘制后
/// `is_animating()` 仍为真、帧泵永远停不下来,本条红。
/// 抓不住:真实屏幕上"重画是否真的只是重画"(那要看 iced 的订阅表,归 `app::subscription` 的用例)。
#[test]
fn repeated_paints_never_restart_the_animation() {
let mut pump = standard();
pump.observe(Some(3), Some(1), Some("instance-9"));
settle(&mut pump);
for _ in 0..30 {
assert!(!pump.observe(Some(3), Some(1), Some("instance-9")));
assert_eq!(pump.page_frame(), PageFrame::STILL);
}
assert!(!pump.is_animating());
}
/// 不归 pages 画的屏(账户 / AI 独立页)**不起跑**;回到某一屏照样跑。
///
/// 抓得住:把 `screen == None` 也当成一次换页起跑(会白跑几百毫秒的帧却没人采样)。
/// 抓不住:账户页 / AI 页自己的渲染(它们不走 `pages::render`,不归本 pump 管)。
#[test]
fn screens_rendered_elsewhere_never_start_a_track() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
settle(&mut pump);
assert!(
!pump.observe(None, None, None),
"没有 pages 树可动 ⇒ 不许白开帧"
);
assert!(!pump.is_animating());
assert!(
pump.observe(Some(1), Some(0), None),
"从账户页回来仍然是换页"
);
assert_eq!(pump.kind(), Some(Kind::Boot), "没有旧页可退 ⇒ 按首屏方向");
}
/// **过渡中途再换页要能重入**(塔的门槛 D):新的进场、旧的立刻被取代,不叠加、不卡死。
///
/// 抓得住:把「新轨道取代旧轨道」改成 push(位移会叠加成 24px)、或让旧轨道继续占帧
/// (帧数会明显超出 12~20 那一档)。
/// 抓不住:`App::previous_view` 的丢弃(那条在 `app/update/motion_sync.rs` 的用例里)。
#[test]
fn a_second_navigation_mid_flight_takes_over_cleanly() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
settle(&mut pump);
pump.observe(Some(2), Some(1), None); // 往右推进
for _ in 0..4 {
pump.advance(FRAME_MS); // 走 3~5 帧就再点一次
}
let mid = pump.page_frame();
assert!(
mid.enter.0 < 12.0 && mid.enter.0 > 0.0,
"第一次过渡还在半路"
);
pump.observe(Some(1), Some(0), None); // 立刻退回左边
let restarted = pump.page_frame();
assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)");
assert_eq!(
restarted.exit,
(0.0, 0.0),
"旧页从 0 重新开始,不与上一次叠加"
);
let frames = settle(&mut pump);
assert!(
(12..=20).contains(&frames),
"重入之后仍要在 12~20 帧内停下,实测 {frames}"
);
assert_eq!(pump.page_frame(), PageFrame::STILL);
}
@@ -0,0 +1,315 @@
//! 模态 / 系统偏好 / 有界泵的降级守卫(M256)。从 `tests/mod.rs` 拆出来守 400 行硬线。
use super::super::pump::FRAME_MS;
use super::super::transition::{modal_spec, page_spec, Kind, ModalFrame, PageFrame};
use super::super::*;
use super::{settle, standard};
use unlucky_design::motion::{DurationToken, MotionScale};
/// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。
///
/// 抓得住:谁把某一腿改长/改短到不再"进长于出",或把起点缩放从令牌换回字面量。
/// 抓不住:这套时长在真机上顺不顺眼(D30 的事)。
#[test]
fn modal_spec_is_short_and_asymmetric() {
let spec = modal_spec();
assert_eq!(spec.enter.duration, DurationToken::Short4); // 200
assert_eq!(spec.exit.duration, DurationToken::Short3); // 150
assert!(spec.enter.duration.ms() > spec.exit.duration.ms());
assert!((modal_scale(0.0) - unlucky_design::motion::DIALOG_ENTER_SCALE).abs() < 1.0e-6);
assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6);
// 单调(不弹跳:M3E 的对话框不许回弹)
let mut previous = modal_scale(0.0);
for step in 1..=20 {
let value = modal_scale(step as f32 / 20.0);
assert!(value >= previous, "缩放倒退了:{previous} → {value}");
previous = value;
}
}
// ------------------------------------------------------------------ 模态
/// 模态:进场 12~13 帧(200ms)、展开后**不再要帧**、退场反向且更短、走完交回一次「已关」。
///
/// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒
/// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed`
/// 不返回 `true`(框永远关不掉)。
/// 抓不住:缩放在**屏幕上**是从中心还是从某个角长出来的(命中与几何都没验,见报告)。
#[test]
fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() {
let mut pump = standard();
assert!(pump.observe_modal(true), "开框要起跑进场");
assert!(pump.modal_frame().visible);
assert!(pump.modal_frame().progress < 0.5, "起手时是收起状态");
let enter_frames = settle(&mut pump) as usize;
assert!(
(10..=14).contains(&enter_frames),
"进场 200ms 应约 12 帧,实测 {enter_frames}"
);
assert_eq!(
pump.modal_frame(),
ModalFrame {
visible: true,
progress: 1.0
}
);
assert!(
!pump.is_animating(),
"**展开后不许占帧**(对话框可能开几分钟,那不是动画)"
);
assert!(!pump.observe_modal(true), "已经开着 ⇒ 不重新起跑");
// 退场:反向(1 → 0),且比进场短
assert!(!pump.begin_modal_close(), "有动效时不许当场关");
assert!(pump.modal_closing());
let mut trace = vec![pump.modal_frame().progress];
while pump.is_animating() {
pump.advance(FRAME_MS);
trace.push(pump.modal_frame().progress);
}
let exit_frames = trace.len() - 1;
assert!(
exit_frames < enter_frames,
"退场({exit_frames} 帧)必须短于进场({enter_frames} 帧)"
);
assert!(
trace.windows(2).all(|pair| pair[0] >= pair[1]),
"退场进度必须单调下降:{trace:?}"
);
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN, "走完即收起");
assert!(pump.take_modal_closed(), "必须交回一次「已关」");
assert!(
!pump.take_modal_closed(),
"只交回一次(重复关会把下一个框也关掉)"
);
}
/// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。
///
/// 抓得住:把 `observe_modal(false)` 改成也起退场 ⇒ `is_animating()` 为真,本条红。
/// 抓不住:那条硬关路径在真机上多久发生一次(`state/refresh.rs` 的 settle 分支,未统计)。
#[test]
fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() {
let mut pump = standard();
pump.observe_modal(true);
settle(&mut pump);
assert!(pump.modal_frame().visible);
assert!(!pump.observe_modal(false));
assert!(!pump.is_animating());
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
assert!(!pump.take_modal_closed(), "没有退场就没有「待关」");
}
/// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。
///
/// 抓得住:把 `configure` 里 `tracks.clear()` 那段删掉(轨道留着 ⇒ 仍在占帧)。
/// 抓不住:真实用户什么时候会中途改系统开关(那要改系统设置,见报告的未验清单)。
#[test]
fn switching_to_none_mid_flight_settles_immediately() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
pump.configure(MotionScale::NONE, None);
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
assert_eq!(pump.page_frame(), PageFrame::STILL);
}
// ------------------------------------------------------------------ 系统偏好合成
/// 系统「减少动效」三态与档位的合成(塔的硬约束 C):**`Unknown` 什么都不改**。
///
/// 抓得住:把 `Unknown` 也降级成 `NONE`("探针失败就静默关动效"),或把 `Reduced` 漏了。
/// 抓不住:真机注册表/系统设置里的值(那要改系统设置,见报告"没验的部分")。
#[test]
fn reduced_motion_only_triggers_on_an_explicit_reduced() {
use crate::platform::motion::ReduceMotion;
for scale in [MotionScale::STANDARD, MotionScale::HALF, MotionScale::NONE] {
assert_eq!(
effective_scale(scale, ReduceMotion::Reduced, false),
MotionScale::NONE,
"系统明确要求减少动效 ⇒ 归零"
);
assert_eq!(
effective_scale(scale, ReduceMotion::AnimationsOn, false),
scale,
"系统里动效开着 ⇒ 用原档"
);
assert_eq!(
effective_scale(scale, ReduceMotion::Unknown, false),
scale,
"**读不到 ≠ 要减少**:探针失败不许静默关掉动效"
);
assert_eq!(
effective_scale(scale, ReduceMotion::Reduced, true),
scale,
"取证相位钉住时不被系统偏好降级(多帧序列图要可复现)"
);
}
}
// ------------------------------------------------------------------ 取证相位
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
///
/// 抓得住:把 `start_now`/`elapsed_ms` 的钉住分支删掉(进度会随时间漂,序列图不再可复现)。
/// 抓不住:**出图本身**——这条只证明"相位恒定",图得另外拍(见报告的未验清单)。
#[test]
fn pinned_phase_freezes_the_clock() {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, Some(150.0));
pump.observe(Some(1), Some(0), None); // 300ms 档 ⇒ 钉在 150 = 一半
for _ in 0..10 {
pump.advance(FRAME_MS);
}
let frame = pump.page_frame();
let expected = 16.0 * (1.0 - page_spec(Kind::Boot).enter.easing.sample(0.5));
assert!(
(frame.enter.1 - expected.round()).abs() < 1.0,
"钉在 150/300 的位移应约 {expected},实测 {}",
frame.enter.1
);
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
}
/// **退场途中来了新框**:当场收尾旧退场、重新走进场(复审 P2-2)。
///
/// 覆盖三条后果里的 ①(不再按递减进度倒放)与 ③(退场到期那拍不会整层消失一帧);
/// ②(闸门把新框的按钮全吞)由 `!modal_closing()` 间接证明——闸门判据就是它。
///
/// 抓得住:把 `observe_modal` 里「`Closing` 先当场收尾」那段删掉 ⇒ 本条红(progress 递减、
/// 且 `modal_closing()` 仍为真)。
/// 抓不住:真实 UI 上「框 A 缩到一半、框 B 从 0.96 长起来」那一帧的观感(D30)。
#[test]
fn a_new_modal_during_the_exit_restarts_the_entrance() {
let mut pump = standard();
assert!(pump.observe_modal(true), "框 A 进场");
settle(&mut pump);
assert!(!pump.begin_modal_close(), "关 A:退场开始");
for _ in 0..3 {
pump.advance(FRAME_MS);
}
let halfway = pump.modal_frame().progress;
assert!(
halfway > 0.0 && halfway < 1.0,
"退场走到一半,实测 {halfway}"
);
// 框 B 这时弹出来
assert!(pump.observe_modal(true), "新框必须起跑新的进场轨道");
assert_eq!(
pump.modal_frame().progress,
0.0,
"①重新走进场:进度从 0 开始,而不是接着倒放"
);
assert!(!pump.modal_closing(), "②闸门必须松开(否则新框按钮全被吞)");
assert!(
pump.take_modal_closed(),
"旧退场被当场收尾,交回一次「已关」"
);
// ③之后每一帧都必须可见(不许闪一帧空的模态层)
let mut frames = 0;
while pump.is_animating() {
assert!(pump.modal_frame().visible, "③中途不许整层消失");
pump.advance(FRAME_MS);
frames += 1;
}
assert!(frames > 0);
assert_eq!(pump.modal_frame().progress, 1.0, "进场必须走到完全展开");
}
/// **产品入口真的把系统偏好合成进来**(复审 P2-5 要求的「故意走真探针」那条)。
///
/// 断言的是**自洽**而不是某个具体值:`configure` 的结果必须等于
/// `effective_scale(STANDARD, ReduceMotion::probe(), false)`。于是在「系统关掉动画」的机器上
/// 它仍然是绿的(那时两边都是 `NONE`),而一旦有人把产品入口接回「直接用传入档位」就会红。
///
/// 抓得住:`motion::configure` 里那一行 `effective_scale(...)` 被去掉。
/// 抓不住:宿主机上「用户设的那一格到底读对没有」(那要改系统设置,见报告的未验清单)。
#[test]
fn configure_goes_through_the_real_os_probe() {
use crate::platform::motion::ReduceMotion;
reset();
configure(MotionScale::STANDARD, None); // 产品入口:不绕过探针
let expected = effective_scale(MotionScale::STANDARD, ReduceMotion::probe(), false);
assert_eq!(
scale(),
expected,
"产品入口必须真的把系统偏好合成进来(读不到 = 不改档)"
);
reset();
}
/// **进场途中被关掉**:进度必须从**当前值**接着缩,不许先跳到 1.0 再退(复审 r2 的 P2-2)。
///
/// 抓得住:把 `begin_modal_close` 的起点改回恒 `1.0`(旧实现)⇒ 下面那条
/// `(关掉后首帧)<=(关掉前一帧)` 立刻红——那就是"反向硬跳"。
/// 抓不住:那一帧在真实屏幕上有多刺眼(D30 的观感,不是断言)。
#[test]
fn closing_during_the_entrance_does_not_jump_to_fully_open() {
let mut pump = standard();
assert!(pump.observe_modal(true), "起跑进场");
for _ in 0..3 {
pump.advance(FRAME_MS); // 走到进场的 1/4 左右
}
let before = pump.modal_frame().progress;
assert!(
before > 0.0 && before < 1.0,
"前提:进场走到半路,实测 {before}"
);
assert!(!pump.begin_modal_close(), "有动效 ⇒ 起跑退场");
let first = pump.modal_frame().progress;
assert!(
first <= before + 1.0e-6,
"关掉后的首帧进度不许**变大**({before} → {first} 就是反向硬跳)"
);
assert!(
(first - before).abs() < 0.05,
"起点必须接在当前进度上({before} → {first})"
);
// 之后单调缩到 0,且必须真的走完(交回一次「已关」)
let mut previous = first;
while pump.is_animating() {
pump.advance(FRAME_MS);
let now = pump.modal_frame().progress;
assert!(
now <= previous + 1.0e-6,
"退场必须单调缩小:{previous} → {now}"
);
previous = now;
}
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
assert!(pump.take_modal_closed());
}
/// **钉住的档压过任何来源的取值**(复审 r2 的根因机制)。
///
/// `App::on_boot` 会再调一次 `configure`(`app/update.rs:276`),值来自
/// `boot_env::motion_scale(...)`(本机默认 HALF、`ULL_MOTION=off` 时 NONE)。本用例把那次调用
/// 的**取值**直接喂进来:钉住 STANDARD 之后,`configure(HALF)` / `configure(NONE)` 都改不动它。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那一段 ⇒ 两行断言全红(= r2 之前的真红)。
/// 抓不住:`boot_env` 自己那张分支表(env / 后端 tier 的取值规则,不在本 mission 的 scope)。
#[test]
fn a_pinned_scale_survives_every_later_configure() {
reset();
configure_for_test(MotionScale::STANDARD, None);
configure(MotionScale::HALF, None); // = 本机 ICED_BACKEND 未设时 boot_env 的默认档
assert_eq!(scale(), MotionScale::STANDARD);
configure(MotionScale::NONE, None); // = ULL_MOTION=off 时 boot_env 的取值
assert_eq!(
scale(),
MotionScale::STANDARD,
"钉住的档必须压过 on_boot 的取值"
);
reset();
}
@@ -0,0 +1,103 @@
//! 有界泵的算术守卫(M256 从 `tests/mod.rs` 拆出,守 400 行硬线):采样频率上限、到期即退订、
//! stale 帧基准不许一帧杀完、以及 `MotionScale::HALF` 的**语义**(时长减半 ≠ 半速)。
//!
//! 这些全是「本地 `Pump` + 摆时间轴」就能判死的算术性质,所以不需要窗口、不需要真实时间。
use super::super::pump::{Pump, FRAME_MS, MAX_STEP_MS};
use super::super::transition::PageFrame;
use super::{settle, standard};
use unlucky_design::motion::{DurationToken, MotionScale};
// ------------------------------------------------------------------ 有界泵
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
///
/// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。
/// 抓不住:整窗重绘次数(那跟随渲染器,见 `FRAME_MS` 的文档)。
#[test]
fn a_finished_animation_stops_asking_for_frames() {
let mut pump = standard();
assert!(pump.observe(Some(1), Some(0), None));
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
settle(&mut pump);
assert!(!pump.is_animating(), "动画结束必须**当场**退订");
assert_eq!(pump.page_frame(), PageFrame::STILL);
let before = pump.samples();
for _ in 0..60 {
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
}
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
}
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
///
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
/// 抓不住:**整窗重绘**频率——那跟随渲染器,与这条闸门无关。
#[test]
fn frames_are_capped_at_sixty_per_second() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
let mut advanced = 0;
for _ in 0..200 {
if pump.advance(1.0) {
advanced += 1;
}
}
assert!(
(11..=13).contains(&advanced),
"200 ms 内应 12±1 次推进,实测 {advanced}"
);
assert_eq!(pump.samples(), advanced);
assert!(
(pump.now_ms() - 192.0).abs() <= 16.0,
"时间轴应约 192 ms,实测 {}",
pump.now_ms()
);
}
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
///
/// 抓得住:把 `MAX_STEP_MS` 的夹取删掉(一次 60 s 的 dt 会把轨道当场杀掉 ⇒ 动画一帧走完)。
/// 抓不住:真机上「空闲醒来第一帧的 dt 到底多长」(那要真窗口,见报告的未验清单)。
#[test]
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
assert!(pump.advance(60_000.0)); // 60 秒的"空闲间隔"当 dt
assert!(pump.is_animating(), "一次超长 dt 不许把轨道当场删掉");
assert!(pump.now_ms() <= MAX_STEP_MS + 1.0e-6);
assert!(pump.page_frame().enter.1 > 0.0, "进度不许一步到位");
settle(&mut pump);
assert!(!pump.is_animating());
}
/// 半时长档(CPU 渲染兜底)真的把时长减半:同样的推进,轨道更早结束。
///
/// 顺带钉住 `MotionScale::HALF` 的**语义**(M256 核清项②):是「时长 ×0.5」=更快,
/// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。
/// 抓不住:**观感上"快一倍"是否合适**(那是 D30 的截图/录屏判断,不是断言)。
#[test]
fn half_scale_halves_the_duration_not_the_speed() {
assert_eq!(MotionScale::HALF.factor(), 0.5);
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
let mut full = standard();
full.observe(Some(1), Some(0), None);
let full_frames = settle(&mut full);
let mut half = Pump::new();
half.configure(MotionScale::HALF, None);
half.observe(Some(1), Some(0), None);
let half_frames = settle(&mut half);
assert!(half_frames > 0 && full_frames > 0);
assert!(
half_frames * 2 <= full_frames + 2,
"HALF 应约用一半帧数:full={full_frames} half={half_frames}"
);
}
@@ -0,0 +1,230 @@
//! 动效**规格表**与位移数学(M256):一次页面切换的方向/深度、两腿时长、模态的进出。
//!
//! 这一层是纯函数:不认识 iced、不认识时钟、不读环境变量。改观感只改这里的一张表。
//!
//! ## 为什么要有「方向」和「两腿」
//!
//! M192 只做了一次「新页从下方归位」(三个槽一张表)。M3 Expressive 的页面过渡不是这样:
//! 它是 **shared-axis**——同级换页走水平轴、进深层走纵深轴,而且**旧页反向退场**(两腿同时跑)。
//! 所以本表对每种切换给出四件东西:进场腿、退场腿、新页起点位移、旧页终点位移。
//!
//! | 切换 | 轴 | 新页从哪来 | 旧页去哪 | 进场 / 退场 |
//! |---|---|---|---|---|
//! | [`Kind::Boot`] 首屏 | 纵向 | 下方 `+X4`(16) | —— | `medium2` 300 / —— |
//! | [`Kind::Peer`] 同级换页 | 水平 | 右/左 `±X3`(±12) | 反向 `∓X2`(∓8) | `medium2` 300 / `medium1` 250 |
//! | [`Kind::Push`] 进深层(列表→详情) | 纵向 | 下方 `+X4`(16) | 上方 `-X2`(-8) | `medium1` 250 / `short4` 200 |
//! | [`Kind::Pop`] 出深层(详情→列表) | 纵向 | 上方 `-X4`(-16) | 下方 `+X2`(8) | `medium1` 250 / `short4` 200 |
//!
//! 三条口径(都有守卫):①**进略长于出**(300>250、250>200)——M3 的进场要看得清,退场只是让位;
//! ②幅度全部落在 4px 栅格上(`Space` 令牌,不新造数字),且退场幅度**小于**进场幅度;
//! ③进/退曲线不同:进场 `Standard`(屏内元素起止都平滑),退场 `StandardAccelerate`
//! (M3 的「离场加速」口径,让旧页干脆地让出注意力)。
use unlucky_design::motion::{DurationToken, Easing, DIALOG_ENTER_SCALE};
use unlucky_design::spacing::Space;
/// 一次页面切换的方向与深度来源(判据在 `Pump::observe`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// 首屏进入:进程内第一次看到归 pages 画的屏。没有旧页。
Boot,
/// **同级**换页:新页在导航表里更靠右(`forward`)或更靠左。外屏(账户 / AI)来回也走这一档。
Peer {
/// `true` = 新页在导航顺序里更靠后(向右推进)。
forward: bool,
},
/// **进深层**:同一屏里从列表进详情(导航深度 +1)。
Push,
/// **出深层**:详情退回列表(导航深度 -1)。
Pop,
}
impl Kind {
/// 稳定名字(日志 / 断言失败信息用;产品路径不读这一档)。
#[allow(dead_code)]
pub const fn name(self) -> &'static str {
match self {
Kind::Boot => "boot",
Kind::Peer { forward: true } => "peer-forward",
Kind::Peer { forward: false } => "peer-back",
Kind::Push => "push",
Kind::Pop => "pop",
}
}
/// 这一档有没有旧页参与退场(首屏没有)。
#[allow(dead_code)]
pub const fn has_old_page(self) -> bool {
!matches!(self, Kind::Boot)
}
}
/// 一条腿:时长档 × 缓动档(两个都是 design 令牌,不是毫秒字面量)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Leg {
/// 时长档(经 `MotionScale` 缩放后生效)。
pub duration: DurationToken,
/// 缓动曲线。
pub easing: Easing,
}
/// 一次页面切换的完整规格。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageSpec {
/// 新页那一腿。
pub enter: Leg,
/// 旧页那一腿(退场)。
pub exit: Leg,
/// 新页的**起点**位移(终点恒为 `(0, 0)`);正值 = 内容先被推到右下再归位。
pub enter_from: (f32, f32),
/// 旧页的**终点**位移(起点恒为 `(0, 0)`)。
pub exit_to: (f32, f32),
}
/// 切换 → 规格(**唯一**一张页面动效表)。
pub const fn page_spec(kind: Kind) -> PageSpec {
// 进场腿:屏内整块内容换人,用 Standard(起止都平滑);退场腿用 StandardAccelerate
// (M3 的离场加速,旧页干脆让位)。
const ENTER: Leg = Leg {
duration: DurationToken::Medium1,
easing: Easing::Standard,
};
const EXIT: Leg = Leg {
duration: DurationToken::Short4,
easing: Easing::StandardAccelerate,
};
const PEER_ENTER: Leg = Leg {
duration: DurationToken::Medium2,
easing: Easing::Standard,
};
const PEER_EXIT: Leg = Leg {
duration: DurationToken::Medium1,
easing: Easing::StandardAccelerate,
};
// 幅度:只从间距令牌取(4 的倍数),退场幅度**小于**进场幅度(M3:退场不跟进场抢注意力)。
let far = Space::X4.dp(); // 16
let near = Space::X3.dp(); // 12
let short = Space::X2.dp(); // 8
match kind {
Kind::Boot => PageSpec {
enter: PEER_ENTER,
exit: PEER_EXIT,
enter_from: (0.0, far),
exit_to: (0.0, 0.0),
},
Kind::Peer { forward: true } => PageSpec {
enter: PEER_ENTER,
exit: PEER_EXIT,
enter_from: (near, 0.0),
exit_to: (-short, 0.0),
},
Kind::Peer { forward: false } => PageSpec {
enter: PEER_ENTER,
exit: PEER_EXIT,
enter_from: (-near, 0.0),
exit_to: (short, 0.0),
},
Kind::Push => PageSpec {
enter: ENTER,
exit: EXIT,
enter_from: (0.0, far),
exit_to: (0.0, -short),
},
Kind::Pop => PageSpec {
enter: ENTER,
exit: EXIT,
enter_from: (0.0, -far),
exit_to: (0.0, short),
},
}
}
/// 模态的进 / 出两腿。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModalSpec {
/// 进场:强调减速(M3 里"出现在屏上"的主曲线)。
pub enter: Leg,
/// 退场:强调加速,且**短于**进场。
pub exit: Leg,
}
/// 模态规格(唯一来源)。
///
/// 时长取 `short4`(200ms)/ `short3`(150ms):对话框是「用户刚做了一件事的回应」,
/// 属于 M3 的短档;比起页面切换它必须更短,否则会觉得界面在拖。
#[allow(clippy::missing_const_for_fn)]
pub const fn modal_spec() -> ModalSpec {
ModalSpec {
enter: Leg {
duration: DurationToken::Short4,
easing: Easing::EmphasizedDecelerate,
},
exit: Leg {
duration: DurationToken::Short3,
easing: Easing::EmphasizedAccelerate,
},
}
}
/// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageFrame {
/// 新页位移(终点恒 `(0,0)`)。
pub enter: (f32, f32),
/// 旧页位移(起点恒 `(0,0)`)。
pub exit: (f32, f32),
/// 旧页是否还该被画出来(退场腿没走完)。`false` ⇒ 调用方**不要**再画旧页。
pub exiting: bool,
}
impl PageFrame {
/// 静息:新页在位、没有旧页。`MotionScale::NONE` 下**恒为**这个值(位移严格 0)。
pub const STILL: Self = Self {
enter: (0.0, 0.0),
exit: (0.0, 0.0),
exiting: false,
};
}
/// 一帧模态采样。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModalFrame {
/// 要不要把这一层画出来(`false` ⇒ 不画遮罩也不画卡片)。
pub visible: bool,
/// `0` = 刚起手(缩小 + 遮罩最淡),`1` = 完全展开(缩放 1.0 + 遮罩最浓)。
pub progress: f32,
}
impl ModalFrame {
/// 没有弹窗。
pub const HIDDEN: Self = Self {
visible: false,
progress: 0.0,
};
}
/// 模态的起始缩放(design 令牌 `DIALOG_ENTER_SCALE` 的再导出:视图侧只认这一条路径)。
pub const MODAL_SCALE_FROM: f32 = DIALOG_ENTER_SCALE;
/// 模态当前缩放:`progress = 0` 时是 [`MODAL_SCALE_FROM`],`1` 时是 1.0。
pub fn modal_scale(progress: f32) -> f32 {
let p = progress.clamp(0.0, 1.0);
MODAL_SCALE_FROM + (1.0 - MODAL_SCALE_FROM) * p
}
/// 端点固定的线性插值:`progress = 0` → `from`,`progress = 1` → `0`。取整到整像素。
///
/// 为什么取整:整页位移落在整像素上,动画期间文字不会因亚像素采样而发虚(M192 的同一口径);
/// 末帧恒为 `(0, 0)` ⇒ 与静息逐像素相同。
pub fn enter_offset(from: (f32, f32), progress: f32) -> (f32, f32) {
let remaining = 1.0 - progress.clamp(0.0, 1.0);
((from.0 * remaining).round(), (from.1 * remaining).round())
}
/// 从 `(0, 0)` 走到 `to` 的位移(旧页退场)。取整口径同上。
pub fn exit_offset(to: (f32, f32), progress: f32) -> (f32, f32) {
let done = progress.clamp(0.0, 1.0);
((to.0 * done).round(), (to.1 * done).round())
}
@@ -0,0 +1,254 @@
//! 缩放层(M256):把一棵子树整体缩放(模态卡片进出场用)。
//!
//! ## 为什么自己写一个
//!
//! iced 0.14 的控件清单里**没有**通用的 transform / opacity 控件(`iced_widget` 只有 `stack` /
//! `opaque` / `float` / `pin`)。但渲染器层是有的:`Renderer::with_transformation` 在**两个后端
//! 都实现了**——`iced_tiny_skia-0.14.1/src/lib.rs:211-217` 与 `iced_wgpu-0.14.0/src/lib.rs:682-688`
//! 各是一行 `layers.push_transformation(...)`。而且 tiny_skia 对文字也乘了 `transformation`:
//! `layer.rs:73-96` 把字号、行高、bounds 都按 `scale_factor()` 缩过 ⇒ **文字是真缩放**,
//! 不是"只缩背景、字不动"。
//!
//! ## 静息态**逐像素不变**
//!
//! `scale == 1.0` 时直接 `content.draw(..)`,**不套**变换:出图基线(D30)不受这层影响,
//! 也不给渲染器多压一层矩阵。
//!
//! ## 已知局限(登记在报告里)
//!
//! 命中测试用的是**布局矩形**(未缩放),所以缩放途中的点击落点仍按 1.0 的框算。
//! 模态只在开/关那 150~200 ms 里缩放,而那两段本来就不该被点击(`app/update/dialog.rs`
//! 在退场期间直接吞掉弹窗消息),所以这件事没有可观察的后果。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Element, Event, Length, Rectangle, Size, Transformation, Vector};
/// 把 `content` 缩放 `scale` 倍(**绕自身中心**),布局尺寸不变。
pub fn scaled<'a, Message, Theme, Renderer>(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
scale: f32,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
Element::new(Scaled {
content: content.into(),
scale,
})
}
/// 绕**中心**缩放的变换:`T(c) · S · T(-c)`。
///
/// `Mul` 的乘法序是**先右后左**(`impl Mul for Transformation` 用 `self.0 * rhs.0`,点按
/// `p' = M·p` 变换)⇒ 这个式子算出来是 `p → s(p − c) + c = s·p + (1−s)c`,**不动点正是 `c`**。
///
/// ⚠️ 上一次写反了:`T(-c) · S · T(c)` 会得到 `s·p − (1−s)c`,不动点在 `(−cx, −cy)` ——窗口外的
/// 镜像点,于是"缩放"变成了"从左上斜滑进来"(复审 r3 用真 iced_core 实跑抓到:card 中心
/// (700,450) 漂到 (644,414))。`tests::about_center_keeps_the_center_fixed` 把这条钉住。
fn about_center(bounds: Rectangle, scale: f32) -> Transformation {
let (cx, cy) = (
bounds.x + bounds.width / 2.0,
bounds.y + bounds.height / 2.0,
);
Transformation::translate(cx, cy)
* Transformation::scale(scale)
* Transformation::translate(-cx, -cy)
}
struct Scaled<'a, Message, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
scale: f32,
}
impl<Message, Theme, Renderer> Scaled<'_, Message, Theme, Renderer> {
/// 这条变换要不要真的套(`1.0` 时什么都不做)。
fn transformation(&self, bounds: Rectangle) -> Option<Transformation> {
// 0.1% 以下视为到位:亚像素缩放只会让文字发虚,没有观感收益。
if (self.scale - 1.0).abs() < 1.0e-3 {
return None;
}
Some(about_center(bounds, self.scale.max(0.01)))
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Scaled<'_, Message, Theme, Renderer>
where
Renderer: iced::advanced::Renderer,
{
fn tag(&self) -> tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut Tree) {
self.content.as_widget().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
// 先算变换(借 layout 的 bounds),再借用 content —— 两个借用互不重叠。
let transformation = self.transformation(layout.bounds());
let content = self.content.as_widget();
match transformation {
None => content.draw(tree, renderer, theme, style, layout, cursor, viewport),
Some(transformation) => renderer.with_transformation(transformation, |renderer| {
content.draw(tree, renderer, theme, style, layout, cursor, viewport);
}),
}
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn iced::advanced::widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::{Point, Size};
/// **绕中心缩放:中心不动、尺寸按倍率**(复审 r3 的 P1 就是这条从未被断言过)。
///
/// 抓得住:①把两个 `translate` 乘反(上一版的 bug:不动点落在 `(−cx, −cy)`,窗口外的镜像点
/// ⇒ 卡片从左上斜滑进来)——中心断言立刻以几十像素的偏差红;②倍率写错 / 漏乘(尺寸断言红);
/// ③把 `about_center` 改成纯缩放(不含平移)——同样红。
/// 抓不住:**像素**。这里断言的是几何矩阵;两个渲染后端都按同一个 `Transformation` 画
/// (`filled`/`text` 的 `bounds * transformation`),所以"几何等价 ⇒ 像素等价"成立的前提是
/// **后端真的把矩阵乘上了**——那一条由真机截图看,不由本用例保证。
#[test]
fn about_center_keeps_the_center_fixed() {
// 默认窗口 1180×760 里居中的卡片(DialogGeometry::max_width = 560)
for (rect, scale) in [
(
Rectangle::new(Point::new(310.0, 220.0), Size::new(560.0, 320.0)),
0.96_f32,
),
(
Rectangle::new(Point::new(310.0, 220.0), Size::new(560.0, 320.0)),
0.5,
),
(
Rectangle::new(Point::new(0.0, 0.0), Size::new(384.0, 288.0)),
1.04,
),
] {
let matrix = about_center(rect, scale);
let center = rect.center();
let moved = center * matrix;
assert!(
(moved.x - center.x).abs() < 1.0e-3 && (moved.y - center.y).abs() < 1.0e-3,
"scale={scale}: 中心必须不动,实测 ({}, {}) 应为 ({}, {})",
moved.x,
moved.y,
center.x,
center.y
);
let size = rect.size() * matrix;
assert!(
(size.width - rect.width * scale).abs() < 1.0e-3
&& (size.height - rect.height * scale).abs() < 1.0e-3,
"scale={scale}: 尺寸必须是 {scale} 倍,实测 {size:?}"
);
// 缩小时左上角必须往中心方向收(不是往外飞)
if scale < 1.0 {
let corner = rect.position() * matrix;
assert!(
corner.x > rect.x && corner.x < center.x,
"scale={scale}: 左上角 x 应收向中心,实测 {}",
corner.x
);
}
}
}
}
+91 -5
View File
@@ -179,12 +179,59 @@ fn type_roles_pick_the_display_stack_for_big_text() {
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有 /// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。 /// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
/// ///
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法) /// ## 为什么这条现在要在**自己的进程**里量(M262,ubuntu CI 真红)
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。 ///
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。 /// `install::status()` 读的是**进程级**的静态槽:`install.rs:146` 的
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。 /// `static STATE: OnceLock<Mutex<CjkInstall>>`。而同一个 test binary(`unlucky-app-iced --lib`)
/// 里还有别的用例会**写**它,路径如下:
///
/// ```text
/// app/update/page_messages.rs:206 the_language_message_switches_the_catalog_and_rebuilds_the_view
/// → App::update(Message::Language(Fixed(ZhCn)))
/// → app/update/page_messages.rs:146 set_language → ui/lang.rs:135 log_cjk
/// → fonts.rs:266 cjk_for(Some("zh_cn")) → install.rs:311 after_probe
/// → install.rs:317-318 Step::Start → install/worker.rs:74 update(Downloading{bytes})
/// ```
///
/// 触发条件:那个用例用 `Options::default()`(`screenshot: None` ⇒ `construct.rs:106`
/// `set_offline(false)`)构造 App,而机器上**没有**中文字体时 `zh_cn` 判出 `NeedsDownload`。
/// ubuntu runner 正是这一档(Windows 开发机装了中文字体,策略停在 `System`,所以本地看不出来)。
/// 之后那条后台线程会真去拉 16.9 MiB(`install/worker.rs:88`),槽在下载期间一直是
/// `Downloading`。它和本用例**并行**跑(默认多线程 harness),于是本用例读到的 `after`
/// 完全可能是**别人**在飞的那条下载。
///
/// 上一版直接比 `install::status()` 的前后快照 = 拿一把全进程共享的尺子量,谁并行谁赢。
/// 修法不是放宽断言,而是**把尺子放进只有本用例的进程**:`current_exe()` + `--exact`
/// 重新起一次本 test binary、只跑这一条(`ULL_FONT_PURITY_CHILD=1` 防递归)。子进程里槽的初值
/// 就是 `Unknown`,没有谁跟它抢,判据因此是确定性的。
///
/// 抓得住:
/// - 把 `install::after_probe(cjk_report.strategy)` 加回求值路径(r2 判为 bug 的写法)——
/// 子进程里槽从 `Unknown` 变成 `Downloading { bytes: 17772300 }`,`!after.is_downloading()`
/// 当场红(实测,见报告「变异证明」);
/// - 更宽的回归:求值路径**任何**写槽的动作(连 `Unknown → NotNeeded` 这种「无害改写」也算),
/// 由「前后必须逐值相等」那条守;
/// - 判据本身失效(这张脸表不再判出 `NeedsDownload`)—— 单独一条断言,免得上面两条变成假绿。
///
/// 抓不住:
/// - 别的用例会不会写槽(本用例主动换进程,**不再**替全进程背书);
/// - `App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发);
/// - 起进程本身失败(`current_exe` 拿不到、禁 fork 的环境)——那时是**红**,不是软过;
/// - 子进程里跑了「几条」这件事只由 stdout 的 `1 passed` 兜着(改名会让它红,而不是变 0 条假绿)。
#[test] #[test]
fn resolving_fonts_never_starts_a_download() { fn resolving_fonts_never_starts_a_download() {
if std::env::var_os(CHILD_ENV).is_some() {
evaluation_starts_no_download();
return;
}
rerun_in_own_process();
}
/// 子进程标记(防递归:父进程起子进程,子进程直接跑判据)。
const CHILD_ENV: &str = "ULL_FONT_PURITY_CHILD";
/// 判据本体(只在**没有别的用例抢全局槽**的进程里跑)。
fn evaluation_starts_no_download() {
use unlucky_i18n::font::Strategy; use unlucky_i18n::font::Strategy;
use crate::fonts::install; use crate::fonts::install;
@@ -202,13 +249,52 @@ fn resolving_fonts_never_starts_a_download() {
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin)); let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
let after = install::status(); let after = install::status();
assert_eq!(
before,
install::CjkInstall::Unknown,
"隔离失效:这个进程里槽的初值已被谁改过({before:?}),下面的前后比较就没有意义"
);
assert_eq!( assert_eq!(
resolved.cjk.strategy, resolved.cjk.strategy,
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC), Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么" "这张脸表必须判出「需要下载」,否则后面的断言证明不了什么"
); );
assert!( assert!(
!after.is_downloading(), !after.is_downloading(),
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})" "求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
); );
assert_eq!(
after, before,
"求值字体一次都不许写状态槽(比「不再是下载中」更强:任何改写都算越界)"
);
}
/// 用 `current_exe()` 把本 test binary 再起一次,只跑 [`resolving_fonts_never_starts_a_download`]。
///
/// 子进程经 `unlucky_core::platform::child::quiet` 起(M272):仓规是全树硬约束,测试代码不是
/// 例外——这条 test binary 本身是控制台程序,父进程将来若换到 GUI 侧(或 CI 用别的方式直接跑
/// 二进制)也照样不该新建窗口。`quiet` 只动创建 flag,`.output()` 的管道语义一个字不变。
fn rerun_in_own_process() {
let exe = std::env::current_exe().expect("取本 test binary 的路径");
let name = "fonts::tests::resolving_fonts_never_starts_a_download";
let output = unlucky_core::platform::child::quiet(&exe)
.args(["--exact", "--nocapture", "--test-threads=1", name])
.env(CHILD_ENV, "1")
// 截图通道的钉子会直接替换 `status()` 的返回值(`install.rs:157` 先看它),
// 留着就等于把尺子换成别人的常数。
.env_remove(crate::fonts::install::CAPTURE_ENV)
.output()
.unwrap_or_else(|error| panic!("起不了隔离子进程 {}:{error}", exe.display()));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"隔离子进程里的判据红了(退出码 {:?})\n--- stdout ---\n{stdout}\n--- stderr ---\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("1 passed"),
"子进程里必须**真**跑了一条用例(`--exact` 名字对不上会 0 passed 退出 0,那是假绿):\n{stdout}"
);
} }
+58 -29
View File
@@ -58,11 +58,9 @@
//! //!
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`) //! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
//! //!
//! 设置页的四条填页线各自在自己的文件里定义消息与状态,本文件的 [`Message`] 只留一个 //! 设置页的四条填页线各自定义消息与状态,本文件的 [`Message`] 只留一个 [`Message::Settings`]
//! [`Message::Settings`](详见 `pages/settings/mod.rs` 的「消息 / 状态」两节);`Message` 与 //! (见 `pages/settings/mod.rs`)。`Message` / `Screen` 在 M101 r2 搬到 [`message`] / [`screen`],
//! `Screen` 本身也在 M101 r2 搬到 [`message`] / [`screen`](`pages/mod.rs` 合并后 397 行,只剩 //! 这里的 `pub use` 保证 `pages::Message` / `Action` / `Screen` 三条路径一字未变。
//! 3 行余量),这里的 `pub use` 保证了 `pages::Message` / `pages::Action` / `pages::Screen`
//! 三条路径一字未变。
//! //!
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条) //! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
//! //!
@@ -81,16 +79,13 @@
//! //!
//! ### 偏离 #7 的收口记录(M90 r2 / P1-1) //! ### 偏离 #7 的收口记录(M90 r2 / P1-1)
//! //!
//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有任何控件**的标题栏留白带。而 //! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有控件**的留白带,而五种窗口消息的构造点
//! `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / `Close` 五种消息的**构造点只有 //! 只有 `shell/mod.rs`、窗口又是 `decorations: false` ⇒ 拖不动、缩不了、点不到关闭。
//! `shell/mod.rs`**,窗口又是 `decorations: false` ⇒ 交互模式下拖不动、缩不了、点不到关闭。 //! 修法:`shell::view_with_content` 把外壳(标题栏 + 三条热区 + 导航轨)与内容区拆开,产品页把
//! //! [`page`] 塞进内容槽位;`shell::view`(M68 骨架卡)留给 `Route::Agent` 回落(删了会让私有模块
//! 修法:`shell::view_with_content(ui, nav, title, content, forced)` 把外壳(标题栏 + 三条热区 + //! 里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红)。
//! 导航轨)与内容区拆开,产品页把 [`page`] 的树塞进内容槽位;`shell::view`(M68 骨架卡)保留给 //! `pages::app` 现在**只服务 harness**:`shell` 的回调类型是 `crate::app::Message`,套不进
//! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红。 //! `pages::Message` 的树里。
//!
//! `pages::app`(本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带,
//! 因为 `shell` 的回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
//! //!
//! ## 截图通道 //! ## 截图通道
//! //!
@@ -167,6 +162,7 @@ pub fn page<'a>(
render( render(
ui, ui,
model, model,
None,
settings::States { settings::States {
appearance: None, appearance: None,
groups: settings::demo_groups(), groups: settings::demo_groups(),
@@ -195,6 +191,7 @@ pub fn page_with_appearance<'a>(
render( render(
ui, ui,
model, model,
None,
settings::States { settings::States {
appearance: Some(appearance), appearance: Some(appearance),
groups: settings::demo_groups(), groups: settings::demo_groups(),
@@ -207,12 +204,10 @@ pub fn page_with_appearance<'a>(
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。 /// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
/// ///
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层 /// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层只借用
/// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线 /// 一帧;加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`)。`language` 是 M106 加的
/// 都不必来这里动一行。 /// (「当前档位 + 生效语言」只有 `App` 知道,设置页 General 组要画它);harness 那三条入口仍用
/// `language` 是 M106 加的:「当前档位 + 生效语言」只有 `App` 知道(`App::language()`),而设置页 /// [`Language::default`]。
/// 的 General 组要画它。唯一调用点是 `app/view.rs`;harness 那三条入口([`page`] /
/// [`page_with_appearance`] / [`app_with_appearance`])仍用 [`Language::default`]。
pub fn page_with_settings<'a>( pub fn page_with_settings<'a>(
ui: &'a Ui, ui: &'a Ui,
model: &'a Model, model: &'a Model,
@@ -220,10 +215,48 @@ pub fn page_with_settings<'a>(
language: Language, language: Language,
launch_form: &'a instances::LaunchForm, launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
render(ui, model, states, language, launch_form) page_with_settings_transition(ui, model, None, states, language, launch_form)
}
/// 同 [`page_with_settings`],但带上**上一帧的模型**(M256:换页时旧页要反向退场)。
///
/// 另开一条而不是加形参:上面那条是全仓(含 `pages::harness` / `pages_demo`)的既有调用点,
/// 它们没有旧页可传。`previous` 必须由调用方(`App`)持有——`Element<'a>` 会借它里面的字符串,
/// 线程局部的借用守卫活不过函数体。
pub fn page_with_settings_transition<'a>(
ui: &'a Ui,
model: &'a Model,
previous: Option<&'a Model>,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(ui, model, previous, states, language, launch_form)
} }
fn render<'a>( fn render<'a>(
ui: &'a Ui,
model: &'a Model,
previous: Option<&'a Model>,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
// M192 / M256:主体入场(首屏 / 同级换页 / 进深层)。**这里只采样**——「标识变了就起跑」
// 在 update 侧(`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上。
let frame = motion::frame();
let current = motion::shifted(body(ui, model, states, language, launch_form), frame.enter);
// 旧页反向退场:退场腿没走完才画(`exiting` 一假就丢掉);`opaque` 让幽灵**吃掉**自己的点击。
let Some(older) = previous.filter(|_| frame.exiting) else {
return current;
};
let ghost = motion::shifted(body(ui, older, states, language, launch_form), frame.exit);
iced::widget::stack![iced::widget::opaque(ghost), current].into()
}
/// 一页的完整主体:页头 + 内容列,外加自更新提示条(M159)。
fn body<'a>(
ui: &'a Ui, ui: &'a Ui,
model: &'a Model, model: &'a Model,
states: settings::States<'a>, states: settings::States<'a>,
@@ -256,19 +289,14 @@ fn render<'a>(
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。 // M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么; // 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。 // 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) { match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page] Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3)) .spacing(ui.space(Space::X3))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into(), .into(),
None => page, None => page,
}; }
// M192:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧
// (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。
// 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。
motion::shifted(tree, motion::sample())
} }
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。 /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
@@ -300,6 +328,7 @@ pub fn app_with_appearance<'a>(
let body = render( let body = render(
ui, ui,
model, model,
None,
settings::States { settings::States {
appearance, appearance,
groups: settings::demo_groups(), groups: settings::demo_groups(),
@@ -10,7 +10,7 @@
//! | 顺序层级 | [`an_empty_priority_follows_the_region`] | 让 `order_layer` 恒为 `Region` | //! | 顺序层级 | [`an_empty_priority_follows_the_region`] | 让 `order_layer` 恒为 `Region` |
//! | 超长路径不写盘 | [`an_overlong_install_root_is_refused_before_writing`] | 删掉 `Prefs::validate` 的长度判据 | //! | 超长路径不写盘 | [`an_overlong_install_root_is_refused_before_writing`] | 删掉 `Prefs::validate` 的长度判据 |
use std::path::Path; use std::path::{Path, PathBuf};
use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore; use unlucky_core::setting::store::SettingStore;
@@ -65,7 +65,30 @@ fn the_three_keys_are_written_and_read_back() {
/// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。 /// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。
/// ///
/// 变异证明:把 `prefs_store::effective` 里 `override_root` 那段删掉(当作永远没设)⇒ 后半段红。 /// ## 为什么不再写死 `D:/JDKs`(M262)
///
/// `D:/JDKs` 在 Windows 上是**绝对路径**、在 Linux 上是**相对路径**(`D:` 只是个普通目录名),
/// 而 `JavaInstallRoot::resolve`(`crates/core/src/game/java/location.rs:128`)对两者的处理是
/// 两件事:绝对路径原样用,相对路径拼到数据根后面。于是同一条 `assert_eq!(root, "D:/JDKs")`
/// 在两个平台上验的是**两个不同的语义**:Windows 上验「设了就不拼数据根」(对),Linux 上验
/// 「绝对路径被当成相对路径拼了」——ubuntu CI 因此必红。落点现在由临时目录给出,两个平台上都是
/// 绝对路径,断言回到它本来的意思。
///
/// 抓得住:
/// - `effective` 忽略 `java.installRoot`(整段红:落点会变成数据根下的 `java-managed`,
/// `root_layer` 也退回 `DataMode`。实测:把 `override_root` 恒置空 ⇒ 红在 `root_layer`);
/// - 「设了却仍按数据根下的 `java-managed` 来」(实测:让 `JavaInstallRoot::resolve` 的 custom 分支
/// 返回 `launcher.java_managed()` ⇒ 红在 `root` 那句,错误的落点被打出来);
/// - 相对路径的落点规则(第二段:相对路径**必须**拼数据根)。
///
/// 抓不住:
/// - **「绝对路径被当成相对路径拼」**:`Path::join` 对一个绝对路径返回的就是它自己,于是
/// `is_absolute()` 那一支在两个平台上都**观测不到**——实测把那一支删成恒 `home().join(root)`,
/// 本用例仍然全绿。上一版写死 `D:/JDKs` 之所以在 Windows 上绿而在 Linux 上红,靠的不是
/// 「拼不拼」这条分支,而是 `D:/JDKs` 在两个平台上**根本不是一类路径**。要让这一支有守卫,
/// 得换成一个「绝对路径 join 之后会变」的平台(没有),或者直接断言 `is_absolute` 的判据本身;
/// - 路径归一化(`D:/JDKs` → `D:\JDKs` 这类)与分隔符风格;
/// - Standard 数据模式下拿不到系统标准目录的那一臂(`root_unresolved`,本用例钉的是 portable)。
#[test] #[test]
fn an_empty_install_root_follows_the_data_mode() { fn an_empty_install_root_follows_the_data_mode() {
let dir = root("prefs-root"); let dir = root("prefs-root");
@@ -87,15 +110,66 @@ fn an_empty_install_root_follows_the_data_mode() {
effective.root effective.root
); );
let custom_root = custom_install_root();
assert!(
!custom_root.starts_with(&dir),
"custom 落点必须不在数据根里(否则「拼了数据根」这条也能过):{} vs {}",
custom_root.display(),
dir.display()
);
let custom = Prefs { let custom = Prefs {
install_root: "D:/JDKs".to_string(), install_root: custom_root.display().to_string(),
saved_install_root: "D:/JDKs".to_string(), saved_install_root: custom_root.display().to_string(),
..Prefs::default() ..Prefs::default()
}; };
let effective = prefs_store::effective(&file, &custom); let effective = prefs_store::effective(&file, &custom);
assert_eq!(effective.root_layer, RootLayer::Setting); assert_eq!(effective.root_layer, RootLayer::Setting);
assert_eq!(effective.root_mode, "custom"); assert_eq!(effective.root_mode, "custom");
assert_eq!(effective.root, "D:/JDKs", "设了就按它来,不再拼数据根"); assert_eq!(
effective.root,
custom_root.display().to_string(),
"设了就按它来,不再拼数据根"
);
assert!(
!effective.root_unresolved,
"custom 落点不该是「解析失败」:{effective:?}"
);
// 相对路径那一档(上一版写死的 `D:/JDKs` 在 Linux 上就落进这里):按数据根解析,
// 但来源层仍是设置键。两个平台上语义相同——这正是上一版抓不住的那一半。
let relative = Prefs {
install_root: "jdks-relative".to_string(),
saved_install_root: "jdks-relative".to_string(),
..Prefs::default()
};
let effective = prefs_store::effective(&file, &relative);
assert_eq!(effective.root_layer, RootLayer::Setting);
assert_eq!(effective.root_mode, "custom");
assert_eq!(
effective.root,
dir.join("jdks-relative").display().to_string(),
"相对路径按数据根解析(绝对路径才原样用)"
);
let _ = std::fs::remove_dir_all(&custom_root);
}
/// 一个**平台无关的绝对路径**(且不在数据根里):custom 落点的替身。
///
/// 不能写死 `D:/JDKs` / `/opt/jdks`:前者在 Linux 上是相对路径,后者在 Windows 上是相对路径
/// (见 [`an_empty_install_root_follows_the_data_mode`] 的文档)。临时目录两边都是绝对路径。
fn custom_install_root() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-custom-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建 custom 落点");
dir
} }
/// `java.vendorPriority` 留空 ⇒ 地区默认序;排过 ⇒ 顺序与来源层都变。 /// `java.vendorPriority` 留空 ⇒ 地区默认序;排过 ⇒ 顺序与来源层都变。
+10 -5
View File
@@ -1,6 +1,6 @@
//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196):整窗透明度与窗口圆角的**判据**、 //! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196 / M256):整窗透明度与窗口圆角的
//! 两处 `unsafe` 落点、系统语言真值源、以及数据根派生——每一件各归一处(判据不碰 FFI, //! **判据**、三处 `unsafe` 落点、系统语言真值源、系统「减少动效」偏好、以及数据根派生——每一件
//! FFI 只住落点文件)。 //! 各归一处(判据不碰 FFI,FFI 只住落点文件)。
//! //!
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
@@ -9,8 +9,11 @@
//! - [`win32::transparency`] = 整窗 α 的 FFI 落点(补 `WS_EX_LAYERED` + //! - [`win32::transparency`] = 整窗 α 的 FFI 落点(补 `WS_EX_LAYERED` +
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`); //! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
//! - [`win32::console`] = M196 的控制台附着落点(`AttachConsole`):GUI 子系统进程的输出 //! - [`win32::console`] = M196 的控制台附着落点(`AttachConsole`):GUI 子系统进程的输出
//! 为什么在 cmd 里会静默丢失,以及唯一的补救。两个落点的放行范围各自收在一个文件里, //! 为什么在 cmd 里会静默丢失,以及唯一的补救;
//! `unsafe-scope` 门禁的期望集合就是这两个路径(集合相等,不是黑名单); //! - [`motion`] = M256:系统「减少动效」偏好的**三态**探针(Windows 走
//! `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)`,Linux/macOS 走子进程 + 超时)。
//! 三处落点的放行范围各自收在一个文件里,`unsafe-scope` 门禁的期望集合就是这三个路径
//! (集合相等,不是黑名单);
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
//! //!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
@@ -26,6 +29,7 @@
//! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 | //! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 |
//! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) | //! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) |
//! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) | //! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) |
//! | [`motion`] | 系统「减少动效」三态探针(M256;缓存 + 子进程超时) | **有**(仅 Windows 一支;Linux/macOS 走子进程) |
//! | [`win32`] | Windows 的实现与探针(DWM / 分层 / 控制台附着) | **有**(两处落点:`transparency` + `console`) | //! | [`win32`] | Windows 的实现与探针(DWM / 分层 / 控制台附着) | **有**(两处落点:`transparency` + `console`) |
//! //!
//! 两个新平台模块**只有文档、没有代码**:M182 的裁决是不实现(macOS 要动手就得先迁移原生标题栏, //! 两个新平台模块**只有文档、没有代码**:M182 的裁决是不实现(macOS 要动手就得先迁移原生标题栏,
@@ -41,6 +45,7 @@ pub mod host;
pub mod linux; pub mod linux;
pub mod locale; pub mod locale;
pub mod macos; pub mod macos;
pub mod motion;
pub mod win32; pub mod win32;
/// 窗口圆角的**判定**(M116 / M182)住在 `win32::corner`:判据是用户策略 × 宿主平台 × 渲染档。 /// 窗口圆角的**判定**(M116 / M182)住在 `win32::corner`:判据是用户策略 × 宿主平台 × 渲染档。
+260
View File
@@ -0,0 +1,260 @@
//! 系统「减少动效」偏好(M256):**三态**探针,只在启动时读一次。
//!
//! ## 为什么要它
//!
//! `DevDocs/PlanDoc/14-motion-and-animation.md` §6 的第 1 条未核项:`MotionScale` 到底有没有
//! 被系统偏好驱动。M256 核清的答案是**没有**——在此之前它只有三个来源(`ULL_MOTION` 环境变量、
//! 截图通道、渲染后端档),操作系统里那句「动画效果」从没人读过。本模块是那条缺失的入口。
//!
//! ## 三态,不是两态(这条是硬约束)
//!
//! | 值 | 含义 | 调用方该做什么 |
//! |---|---|---|
//! | [`ReduceMotion::Reduced`] | 系统明确要求减少动效 | 把档位降到 `MotionScale::NONE` |
//! | [`ReduceMotion::AnimationsOn`] | 系统里动效是开着的 | 用原来的档位 |
//! | [`ReduceMotion::Unknown`] | 读不到(命令缺失 / 无桌面 / 超时 / 平台未实现) | **用原来的档位**(什么都不改) |
//!
//! `Unknown` **不等于** `Reduced`:探针失败就静默关掉动效,会出现「某些机器上动效神秘消失」,
//! 那种问题比「少一个动效」难查得多。这条有守卫(`components::motion::tests`)。
//!
//! ## 廉价、一次、可超时
//!
//! - 结果缓存在 [`std::sync::OnceLock`] 里:**进程内只探一次**(`motion::configure` 是唯一调用点,
//! 它在 `on_boot` 跑一次;配置重载时读的是同一个缓存,不会重复 spawn)。
//! - 子进程有 [`COMMAND_TIMEOUT`] 上限:`gsettings` / `defaults` 在坏环境里会挂住,
//! 超时按 `Unknown` 处理并 `kill` 掉。
//! - 子进程经 `unlucky_core::platform::child::quiet` 起(M272):仓规「spawn 控制台程序必须走
//! `quiet`」是全树硬约束(`crates/core/tests/child_quiet_guard.rs` 钉着),探针不是例外——
//! 唯一调用点在这里。
//!
//! ## `quiet` 不影响「可杀」
//!
//! `child::quiet` 返回的就是普通 [`std::process::Command`](Windows 上多一个
//! `CREATE_NO_WINDOW`,其它平台原样返回),`.spawn()` 拿到的 [`std::process::Child`] 仍归
//! `run` 自己,所以 **300 ms 超时 + 超时 `kill` + `wait` 一个字没丢**。
//!
//! | 平台 | 来源 | `unsafe` |
//! |---|---|---|
//! | Windows | `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)` | **有**(收在 [`system_animations_enabled`] 一个函数里,全仓第三处放行,门禁 `unsafe-scope` 已登记) |
//! | Linux | `gsettings get org.gnome.desktop.interface enable-animations` | 没有 |
//! | macOS | `defaults read com.apple.universalaccess reduceMotion` | 没有(**未在真机验证**:本仓无 Mac 实机,键名与权限都可能有差异) |
//! | 其它 | 恒 `Unknown` | —— |
use std::sync::OnceLock;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use unlucky_core::platform::child::quiet;
/// 系统偏好:「减少动效」是否要生效。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReduceMotion {
/// 系统要求减少动效(装配层应降到 `MotionScale::NONE`)。
Reduced,
/// 系统里动效是开着的(装配层按原档位)。
AnimationsOn,
/// 读不到(**不等于** Reduced:装配层按原档位,见模块文档)。
Unknown,
}
impl ReduceMotion {
/// 进程内只探一次的入口(`motion::configure` 唯一调用点)。
pub fn probe() -> Self {
static CACHE: OnceLock<ReduceMotion> = OnceLock::new();
*CACHE.get_or_init(detect)
}
/// 稳定名(启动日志与断言失败信息用)。
pub const fn as_str(self) -> &'static str {
match self {
ReduceMotion::Reduced => "reduced",
ReduceMotion::AnimationsOn => "animations-on",
ReduceMotion::Unknown => "unknown",
}
}
/// 是否是「系统要求减少动效」(唯一该改档位的取值)。
pub const fn is_reduced(self) -> bool {
matches!(self, ReduceMotion::Reduced)
}
}
/// 子进程读偏好的超时上限(毫秒)。
///
/// 为什么是这个量级:`gsettings` / `defaults` 正常在 10~40 ms 内返回;300 ms 已经足够覆盖冷启动
/// 的 D-Bus / cfprefsd 唤醒,再长就等于让启动卡在这一步。
pub const COMMAND_TIMEOUT: u128 = 300;
#[cfg(target_os = "windows")]
fn detect() -> ReduceMotion {
match system_animations_enabled() {
Some(true) => ReduceMotion::AnimationsOn,
Some(false) => ReduceMotion::Reduced,
None => ReduceMotion::Unknown,
}
}
#[cfg(target_os = "windows")]
const SPI_GETCLIENTAREAANIMATION: u32 = 0x1042;
/// 系统「动画效果」是不是开着(`None` = 调用失败)。
///
/// `SPI_GETCLIENTAREAANIMATION`(`winuser.h`,Win10+ 稳定可用)就是「设置 → 辅助功能 →
/// 视觉效果 → 动画效果」那个开关的真值源;`pvParam` 是 `BOOL*`,非 0 = 动画开着。
///
/// ## 为什么允许 unsafe(全仓第三处,已登记进 `unsafe-scope` 门禁)
///
/// 这个开关**没有**安全的 Rust API 可达(注册表里的对应键不稳定、且与"最佳性能"混在一起),
/// 只有 Win32 一条路。放行范围收在这一个函数里,别的判据照旧是纯函数。
#[cfg(target_os = "windows")]
#[allow(unsafe_code)]
fn system_animations_enabled() -> Option<bool> {
use core::ffi::c_void;
#[link(name = "user32")]
extern "system" {
/// `BOOL SystemParametersInfoW(UINT, UINT, PVOID, UINT)`。
fn SystemParametersInfoW(action: u32, param: u32, data: *mut c_void, win_ini: u32) -> i32;
}
let mut enabled: i32 = 0;
// SAFETY: ①`SPI_GETCLIENTAREAANIMATION` 的 `pvParam` 契约是「指向 BOOL 的可写指针」,
// 这里给的是栈上 `i32` 的地址,类型与对齐都符合(Win32 `BOOL` = 32 位 int);
// ②`uiParam`/`fWinIni` 对这条 action 不使用,传 0;③本调用只写这 4 个字节,不持有指针、
// 不转移所有权,返回后栈变量即可读;④结果为 0 表示失败,此时按 `None`(Unknown)处理,
// 不去猜 `enabled` 的值。
let ok = unsafe {
SystemParametersInfoW(
SPI_GETCLIENTAREAANIMATION,
0,
(&mut enabled as *mut i32).cast(),
0,
)
};
(ok != 0).then_some(enabled != 0)
}
#[cfg(target_os = "linux")]
fn detect() -> ReduceMotion {
// GNOME 的开关;Cinnamon / MATE 同键名。KDE 的 `kdeglobals` 走另一条路(未实现,记 Unknown)。
match run(
COMMAND,
&["get", "org.gnome.desktop.interface", "enable-animations"],
) {
Some(text) => match text.trim() {
"false" => ReduceMotion::Reduced,
"true" => ReduceMotion::AnimationsOn,
_ => ReduceMotion::Unknown,
},
None => ReduceMotion::Unknown,
}
}
#[cfg(target_os = "linux")]
const COMMAND: &str = "gsettings";
#[cfg(target_os = "macos")]
fn detect() -> ReduceMotion {
match run(
COMMAND,
&["read", "com.apple.universalaccess", "reduceMotion"],
) {
Some(text) => match text.trim() {
"1" | "true" | "YES" => ReduceMotion::Reduced,
"0" | "false" | "NO" => ReduceMotion::AnimationsOn,
// 键不存在(这台机器没改过这个开关)时 `defaults` 会报错退出 ⇒ 走不到这里;
// 其它输出按 Unknown,不猜。
_ => ReduceMotion::Unknown,
},
None => ReduceMotion::Unknown,
}
}
#[cfg(target_os = "macos")]
const COMMAND: &str = "defaults";
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
fn detect() -> ReduceMotion {
ReduceMotion::Unknown
}
/// 跑一条短命令并取回 stdout(**带超时**,超时 `kill` 掉并返回 `None`)。
///
/// 为什么自己写而不是 `Command::output()`:`output()` 会一直等到子进程退出,坏环境里(没有
/// session bus 的 `gsettings`)就是无限期挂住——那会让**启动**卡死,是比"没有动效"严重得多的事故。
///
/// 子进程经 [`quiet`] 起(M272,仓规硬约束)。`quiet` 只改创建 flag、返回的仍是普通
/// `std::process::Command`,所以下面这套自己写的等待循环拿得到 `Child` 句柄,**超时 `kill`
/// 照旧**——「安静」与「可杀」在这里不冲突。
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn run(program: &str, args: &[&str]) -> Option<String> {
use std::io::Read;
use std::process::Stdio;
use std::time::{Duration, Instant};
let mut child = quiet(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_millis(COMMAND_TIMEOUT as u64);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(5)),
Err(_) => return None,
}
}
// 输出只有几个字节(`true` / `false` / `1`),管道容量远大于它 ⇒ 先等进程退出再读不会死锁。
let mut text = String::new();
child.stdout.take()?.read_to_string(&mut text).ok()?;
Some(text)
}
#[cfg(test)]
mod tests {
use super::*;
/// 三态必须**三**个值可区分:`Unknown` 不能被当成 `Reduced`(M256 的硬约束 C)。
///
/// 抓得住:把 `is_reduced()` 写成「不是 AnimationsOn 就算 Reduced」(= 探针失败就静默关动效,
/// 于是某些机器上动效神秘消失)——第一条断言立刻红。
/// 抓不住:真机上这三个值读得对不对(那要改系统设置,见报告的未验清单)。
#[test]
fn unknown_is_not_reduced() {
assert!(!ReduceMotion::Unknown.is_reduced());
assert!(ReduceMotion::Reduced.is_reduced());
assert!(!ReduceMotion::AnimationsOn.is_reduced());
let names = [
ReduceMotion::Reduced.as_str(),
ReduceMotion::AnimationsOn.as_str(),
ReduceMotion::Unknown.as_str(),
];
let unique: std::collections::BTreeSet<&str> = names.iter().copied().collect();
assert_eq!(unique.len(), 3, "三个取值的名字必须互不相同:{names:?}");
}
/// 探针在**本机**要么给出两个确定值之一,要么 `Unknown`——但它必须有返回值、不许 panic,
/// 且**第二次调用走缓存**(同一进程内结果恒定)。
///
/// 抓得住:把缓存去掉、第二次重新 spawn,在坏环境里就会看到不同结果(或让这条用例超时)。
/// 抓不住:真机上"读到的是不是用户设的那一格"(那要改系统设置,见报告的未验清单)。
#[test]
fn probe_is_cached_and_total() {
let first = ReduceMotion::probe();
let second = ReduceMotion::probe();
assert_eq!(first, second, "同一次进程内探针结果必须恒定(走缓存)");
assert!(matches!(
first,
ReduceMotion::Reduced | ReduceMotion::AnimationsOn | ReduceMotion::Unknown
));
}
}
+2 -2
View File
@@ -22,10 +22,10 @@ path = "src/lib.rs"
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, # 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 # 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.4" } unlucky-core = { path = "../core", version = "0.2.5" }
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, # agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
# core 侧五个端口在 src/commands/agent/ports/ 装配)。 # core 侧五个端口在 src/commands/agent/ports/ 装配)。
unlucky-agent = { path = "../agent", version = "0.2.4" } unlucky-agent = { path = "../agent", version = "0.2.5" }
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 # `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
+4 -1
View File
@@ -60,7 +60,10 @@ pub(crate) struct PinnedKey {
/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。 /// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。
pub(crate) const PRODUCTION: &[PinnedKey] = &[ pub(crate) const PRODUCTION: &[PinnedKey] = &[
// >>> PINNED-KEYS >>> // >>> PINNED-KEYS >>>
PinnedKey { key_id: "ull-release", public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122" }, PinnedKey {
key_id: "ull-release",
public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122",
},
// <<< PINNED-KEYS <<< // <<< PINNED-KEYS <<<
]; ];
+93 -3
View File
@@ -5,6 +5,7 @@
//! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。 //! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。
use std::path::Path; use std::path::Path;
use std::time::{Duration, SystemTime};
use crate::fixture::{ use crate::fixture::{
link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot, link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot,
@@ -96,22 +97,111 @@ fn snapshot_catches_a_new_empty_directory() {
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。 /// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
/// ///
/// 抓得住:目录节点只记路径不记 mtime(那样 `Node::Dir{mtime_ns}` 相同,第二条断言红)。 /// ## 为什么不再「写个文件然后赌 mtime 变了」(M262)
/// 抓不住:某些文件系统对 mtime 的粗粒度(本机 NTFS 是 100ns 级;真粗到同一 tick 时这条会软过)。 ///
/// 上一版只做「建目录 → 拍快照 → 写文件 → 再拍快照 → `assert_ne!(node)`」,靠的是**文件系统
/// 恰好把两次操作记成不同的 mtime**。这一条在 windows CI 上真红(写文件与建目录落在同一 tick),
/// 而它自己的注释还写着「真粗到同一 tick 时会软过」——既不可靠也不软过,是个真红。
///
/// 现在两个 mtime 都由本用例**显式给定**([`set_dir_mtime`]),不来自时钟:t0 = 1_000_000_000s,
/// t1 = t0 + 3600s,两者都是整秒、都对任何常见粒度(NTFS 100ns / ext4 1ns / HFS+ 1s / FAT 2s)
/// 无余数,于是「快照读到的目录 mtime」与「我们钉的那个值」可以逐 ns 比对。
///
/// 抓得住:
/// - `Node::Dir` 不再携带 mtime(记 0 / 记常量)—— 逐 ns 断言当场红;
/// - 快照不再重新读元数据(缓存 / 只记路径)—— 同上;
/// - 目录节点整个丢失(`expect` 红)。
///
/// 抓不住:
/// - **文件系统会不会自己在条目增删时抬高目录 mtime**:本用例把这件事绕开了(两次 mtime 都是我们
/// 钉的),所以「NTFS 真机上目录 mtime 会不会被延时更新」这件事依然**没有**用例守——它有自己
/// 的用例(`snapshot_catches_a_new_empty_directory` 比的是路径集合,不靠 mtime);
/// - 粒度比 3600s 还粗的文件系统(常见 FS 里没有;真遇到会红在「逐 ns 相等」那句,不会软过);
/// - 「目录里多了一个文件」这件事本身(那由 [`snapshot_catches_a_new_empty_directory`] 与
/// 下面 `watched/new.txt` 那一条单独守,不靠 mtime)。
#[test] #[test]
fn snapshot_notices_a_directory_mtime_change() { fn snapshot_notices_a_directory_mtime_change() {
let root = TempRoot::new("snap-dirmtime"); let root = TempRoot::new("snap-dirmtime");
let dir = root.path().join("watched"); let dir = root.path().join("watched");
std::fs::create_dir_all(&dir).expect("建目录"); std::fs::create_dir_all(&dir).expect("建目录");
// 两个明确的时刻(整秒,任何常见 filetime 粒度都能原样存下)。
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
let t1 = t0 + Duration::from_secs(3_600);
set_dir_mtime(&dir, t0);
let before = snapshot(root.path()); let before = snapshot(root.path());
// 场景照旧是真的:往目录里加了一个文件(条目增删是 mtime 变的**真实**来源)。
std::fs::write(dir.join("new.txt"), b"x").expect("写文件"); std::fs::write(dir.join("new.txt"), b"x").expect("写文件");
// 但差异由本用例给出,而不是「期望这次写恰好落在另一个 tick」。
set_dir_mtime(&dir, t1);
let after = snapshot(root.path()); let after = snapshot(root.path());
// 场景本身要成立(否则下面比的是一个没变的世界)。
assert!(
!before.contains_key("watched/new.txt")
&& matches!(after.get("watched/new.txt"), Some(Node::File { .. })),
"先把「目录里真多了一个文件」这条落实:before={:?} after={:?}",
before.get("watched/new.txt"),
after.get("watched/new.txt")
);
let before_node = before.get("watched").expect("有该目录节点"); let before_node = before.get("watched").expect("有该目录节点");
let after_node = after.get("watched").expect("有该目录节点"); let after_node = after.get("watched").expect("有该目录节点");
assert_ne!( assert_ne!(
before_node, after_node, before_node, after_node,
"目录里加了文件,目录节点(含 mtime)必须变" "目录 mtime 变了,目录节点必须变(mtime 是我们钉的,不看时钟)"
); );
assert_eq!(
dir_mtime_ns(after_node),
t1.duration_since(SystemTime::UNIX_EPOCH)
.expect("t1 在纪元之后")
.as_nanos(),
"快照里的目录 mtime 必须就是我们钉的那个值(逐 ns)"
);
assert_eq!(
dir_mtime_ns(before_node),
t0.duration_since(SystemTime::UNIX_EPOCH)
.expect("t0 在纪元之后")
.as_nanos()
);
}
/// 给**目录**显式钉一个 mtime(std-only;两个平台的句柄打开方式不同)。
#[cfg(windows)]
fn set_dir_mtime(dir: &Path, at: SystemTime) {
use std::os::windows::fs::OpenOptionsExt;
// 0x0100 = FILE_WRITE_ATTRIBUTES:`SetFileTime` 要的就是这个权限,而 GENERIC_WRITE
// (`OpenOptions::write(true)`)在**目录**上会被拒(os error 5)。
// 0x0200_0000 = FILE_FLAG_BACKUP_SEMANTICS:不开它 `CreateFile` 根本打不开目录句柄。
let handle = std::fs::OpenOptions::new()
.access_mode(0x0100)
.custom_flags(0x0200_0000)
.open(dir)
.unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display()));
handle
.set_modified(at)
.unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display()));
}
/// 同 [`set_dir_mtime`](`futimens` 走普通目录句柄,不需要额外权限)。
#[cfg(not(windows))]
fn set_dir_mtime(dir: &Path, at: SystemTime) {
let handle = std::fs::File::open(dir)
.unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display()));
handle
.set_modified(at)
.unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display()));
}
/// 目录节点的 mtime(纳秒)。节点不是目录就直接红:本用例只谈目录。
fn dir_mtime_ns(node: &Node) -> u128 {
match node {
Node::Dir { mtime_ns } => *mtime_ns,
other => panic!("watched 应当是目录节点:{other:?}"),
}
} }
/// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。 /// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。
+5
View File
@@ -144,6 +144,11 @@ fn a_truly_missing_versions_dir_stays_empty() {
} }
/// 目录里直接子项的名字(排序后,用于「有没有新增」的判据)。 /// 目录里直接子项的名字(排序后,用于「有没有新增」的判据)。
///
/// cfg 必须与唯一的调用点(`a_junction_on_the_instances_dir_must_not_write_into_the_game_root`)
/// 对齐:那条用例是 `#[cfg(windows)]`(目录联接夹具只在 Windows 上建得出来),Linux 上调用点
/// 整块编掉,未 gate 的 helper 就成了 `dead_code`(CI 的 `-D warnings` 会让 clippy 退出 101)。
#[cfg(windows)]
fn listing(dir: &std::path::Path) -> Vec<String> { fn listing(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir) let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("读目录") .expect("读目录")
+20
View File
@@ -8,6 +8,15 @@
//! //!
//! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`], //! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`],
//! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。 //! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。
//!
//! ## [`MotionScale::HALF`] 是什么(读实现的结论,不是注释的措辞)
//!
//! `MotionScale` 只有一个 `factor`,`ms(token) = token.ms() * factor`(见 `spring` 模块的
//! `motion_scale_scales_or_kills_duration`),所以 `HALF` 的语义是**时长减半**(同一段位移在
//! 一半的时间里走完 ⇒ 观感上**更快**,不是"半速")。`spring_ms` 同理:收敛时间减半。
//!
//! `spring.rs` 里 `HALF` 的行内注释写作「半速」,那是**措辞错误**(MOTION1 核清):本行文档是
//! 事实口径,改 `spring.rs` 的注释不在 M256 的 scope 内,已作为 finding 报给 tower。
mod spring; mod spring;
@@ -19,6 +28,17 @@ pub use spring::{
SPRING_STANDARD_SPATIAL_SLOW, SPRING_STANDARD_SPATIAL_SLOW,
}; };
/// 模态(对话框 / 危险确认)**进场**的起始缩放:`0.96 → 1.0`。
///
/// 出处:`DevDocs/PlanDoc/14-motion-and-animation.md` §3 的 A3 行——「遮罩淡入 + 卡片
/// scale 0.96→1(**不弹跳**)」。**退场走同一条线反向**(1.0 → 0.96),所以只需要这一个常量。
///
/// 为什么不是 0.8 或 0.9:0.9 以下的缩放会让卡片里的正文在动画期间小到读不清(对话框是
/// 「必须读」的界面,不是装饰);0.96 是 M3 里 dialog 的形变幅度口径,也让「它在放大」这件事
/// 恰好可察觉而不夸张。**不弹跳**:曲线只取单调段(`*Decelerate` / `*Accelerate`),
/// 弹簧类曲线不用于模态。
pub const DIALOG_ENTER_SCALE: f32 = 0.96;
/// 动效时长档位(M3 动效令牌,单位毫秒)。 /// 动效时长档位(M3 动效令牌,单位毫秒)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DurationToken { pub enum DurationToken {
+1 -1
View File
@@ -12,7 +12,7 @@ publish.workspace = true
# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 # 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。
# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 # 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。
[dependencies] [dependencies]
unlucky-design = { path = "../design", version = "0.2.4" } unlucky-design = { path = "../design", version = "0.2.5" }
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): # 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式; # - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
+3 -3
View File
@@ -22,12 +22,12 @@ serde_json.workspace = true
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。 # 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 # host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.4" } unlucky-core = { path = "../core", version = "0.2.5" }
# M251(D54 第 1/4 条):截图缩略图在**后台线程**里解码,解码能力只能来自 palette-extract # M251(D54 第 1/4 条):截图缩略图在**后台线程**里解码,解码能力只能来自 palette-extract
# (`image` 一个字节都不进 presenter,否则「解码集中在一处」这条边界当场作废)。 # (`image` 一个字节都不进 presenter,否则「解码集中在一处」这条边界当场作废)。
# 方向只能是 presenter → palette-extract(反向不许);那边也不许反过来依赖 core/presenter。 # 方向只能是 presenter → palette-extract(反向不许);那边也不许反过来依赖 core/presenter。
unlucky-palette-extract = { path = "../palette-extract", version = "0.2.4" } unlucky-palette-extract = { path = "../palette-extract", version = "0.2.5" }
[lints] [lints]
workspace = true workspace = true
@@ -45,6 +45,6 @@ update-test-source = ["unlucky-core/update-test-source"]
# `manifest.json.sig`,没有它 core 一律拒装)。这里打开 core 的 `update-test-key`:它**只**把一把 # `manifest.json.sig`,没有它 core 一律拒装)。这里打开 core 的 `update-test-key`:它**只**把一把
# **公开**的测试公钥并进信任表,不开任何数据源开关(与 `update-test-source` 是两件事)。 # **公开**的测试公钥并进信任表,不开任何数据源开关(与 `update-test-source` 是两件事)。
# 只出现在 `[dev-dependencies]`:库/发布构建拿不到它。守门:`packaging/check_test_key_off.py`。 # 只出现在 `[dev-dependencies]`:库/发布构建拿不到它。守门:`packaging/check_test_key_off.py`。
unlucky-core = { path = "../core", features = ["update-test-key"] } unlucky-core = { path = "../core", version = "0.2.5", features = ["update-test-key"] }
# 夹具用同一套种子现签清单(与 `crates/core/src/update/tests/support_sign.rs` 同源)。 # 夹具用同一套种子现签清单(与 `crates/core/src/update/tests/support_sign.rs` 同源)。
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
+8 -3
View File
@@ -7,11 +7,16 @@
| 通道 | 位置 | 后端 | | 通道 | 位置 | 后端 |
|---|---|---| |---|---|---|
| CI 冒烟截屏 | `.github/workflows/ci.yml:101` | `--software-renderer`(强制 CPU) | | CI 冒烟截屏 | `.github/workflows/ci.yml` 的 `test` 作业「出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)」那一步 | `--software-renderer`(强制 CPU) |
| CI 交互冒烟 | `.github/workflows/ci.yml:246`、`:283` | `--software-renderer` | | CI 交互冒烟 | 同上文件的 `runtime-smoke` 作业「在 Xvfb 里跑一帧并截图」、`runtime-smoke-macos` 作业「尝试截图」两步 | `--software-renderer` |
| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh:90` | `--software-renderer` | | Linux 容器冒烟 | `packaging/linux-smoke-docker.sh` 里「2) 跑容器:构建 → xvfb 截图 → 像素判定」那一段的 `xvfb-run` | `--software-renderer` |
| D30 视觉验收(人工,按协议出图) | `DevDocs/PlanDoc/`(视觉验收协议) | 同上(CI 同款命令) | | D30 视觉验收(人工,按协议出图) | `DevDocs/PlanDoc/`(视觉验收协议) | 同上(CI 同款命令) |
> **锚点一律用「作业名 + 步骤名」,不用 `文件:行号`。** 这条规则不是本次改动造出来的:本次改动的
> base(`main`)上,上面 ci.yml 那三处行号里只有 `:101` 对得上,`:246` / `:283` 早就指偏了(分别落在
> 依赖安装步和一行注释上;`linux-smoke-docker.sh:90` 那时是对的)。也就是说行号锚点本来就在腐,
> 而 workflow 只会越来越长——要定位就按上表的名字 `grep` 作业名 / 步骤名,别再写回行号。
`--renderer=auto`(默认)会挑 wgpu;`--software-renderer` 强制 tiny-skia。CI 走后者是**有意的**: `--renderer=auto`(默认)会挑 wgpu;`--software-renderer` 强制 tiny-skia。CI 走后者是**有意的**:
容器/无 GPU 机器上没有 Vulkan/DX12 设备,硬要 wgpu 会红在一句「adapter 创建失败」上,与皮肤无关。 容器/无 GPU 机器上没有 Vulkan/DX12 设备,硬要 wgpu 会红在一句「adapter 创建失败」上,与皮肤无关。
+1 -1
View File
@@ -13,7 +13,7 @@
| 节 | 主题 | 承载文件 | | 节 | 主题 | 承载文件 |
|----|------|---------| |----|------|---------|
| §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` | | §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` |
| §2 | 版本来源(唯一真相源) | `packaging/docs/artifacts-and-version.md` | | §2 | 版本来源(唯一真相源);§2.1 里程碑快照**提交信息**怎么写(M268) | `packaging/docs/artifacts-and-version.md` |
| §3 | 怎么构建(本地 / CI) | `packaging/docs/build-and-ci.md` | | §3 | 怎么构建(本地 / CI) | `packaging/docs/build-and-ci.md` |
| §4 | 每平台运行时依赖 | `packaging/docs/runtime-deps.md` | | §4 | 每平台运行时依赖 | `packaging/docs/runtime-deps.md` |
| §5 | 软渲染兜底 | `packaging/docs/runtime-deps.md` | | §5 | 软渲染兜底 | `packaging/docs/runtime-deps.md` |
+137 -17
View File
@@ -17,12 +17,30 @@ version, so "version drifted from Cargo.toml" is caught.
python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3 python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3
python3 packaging/check_pe_version.py --self-test python3 packaging/check_pe_version.py --self-test
# 什么时候退出非 0(判据清单;排查红步骤时照这张表读日志)
逐产物(退出码 1,每条一条 `error:`):
1. 文件不存在;
2. 小于 0x100 字节,或不是可解析的 PE(MZ / PE 头不对);
3. 找不到 `FIXEDFILEINFO` 签名 0xFEEF04BD —— **版本资源根本没进产物**;
4. 缺结构键 `VS_VERSION_INFO` / `StringFileInfo` / `VarFileInfo` / `Translation`;
5. 缺承诺字段名,或字段值不等于承诺值(CompanyName / ProductName / FileDescription / LegalCopyright);
6. `OriginalFilename` 不是 `<bin>.exe`;
7. 给了 `--expect-version` 时:①四段式版本字符串不在资源里;②`dwFileVersion` 与它不一致;
③签名之后不足 16 字节、读不到 `dwFileVersion`(`FIXEDFILEINFO` 被截断)。
其它:`--self-test` 自检失败 → 1;没给产物也没给 `--self-test` → 2(打印用法)。
输出本身**不再是**失败原因:`safe_streams()` 保证「汉字打不出来」不会把一条通过的判据变成红。
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import struct import struct
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -109,14 +127,89 @@ def check(path: Path, expect_version: str | None) -> list[str]:
print( print(
f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)" f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)"
f" / 版本资源={'有' if has_resource else '无'}" f" / 版本资源={'有' if has_resource else '无'}",
# 进度行走在 stderr 的判据之前:CI 的 stdout 是块缓冲,不 flush 就会「错误先、摘要后」,读起来像两份日志。
flush=True,
) )
return failures return failures
def safe_streams() -> None:
"""把 stdout/stderr 钉成「汉字打不出来也不许抛异常」。
CI 的 stdout 是**管道**:GitHub Actions 按 UTF-8 解码,而 Windows runner 是 en-US 镜像、默认代码页
1252,于是 `print("ok: 2 个产物的版本资源齐全")` 会 `UnicodeEncodeError` —— 判据全过也红,而且崩在
`print` 上,日志里只剩一段 traceback(`::error::` 注解一条都没有)。本机 cp936 能编汉字,复现不出来。
管道里钉 UTF-8(Actions 那边正好按 UTF-8 解),终端里只放宽 `errors`(不改编码,免得本机控制台变乱码)。
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None) # Python < 3.7 没有,那就只能用默认行为
if reconfigure is None:
continue
try:
if stream.isatty():
reconfigure(errors="backslashreplace")
else:
reconfigure(encoding="utf-8", errors="backslashreplace")
except (ValueError, OSError):
pass
def synthetic_pe(stem: str, version: tuple[int, int, int, int], *, with_resource: bool = True) -> bytes:
"""合成一个 MZ/PE 头合法的最小文件:`with_resource` 决定版本资源齐不齐。
两种用途:`False` 是**负控**(判据必须报错),`True` 是**正控**(判据全过 —— 否则「绿的时候能不能
正常打印/正常退出 0」这条根本没法验)。
"""
pe_offset = 0x40
body = bytearray(0x100)
body[0:2] = b"MZ"
struct.pack_into("<I", body, 0x3C, pe_offset)
body[pe_offset : pe_offset + 4] = PE_MAGIC
struct.pack_into("<H", body, pe_offset + 24 + 0x44, 2) # Subsystem = WINDOWS_GUI
if not with_resource:
return bytes(body)
ms = (version[0] << 16) | version[1]
ls = (version[2] << 16) | version[3]
fixed = FIXED_SIGNATURE + struct.pack("<I", 0x00010000) + struct.pack("<II", ms, ls) + struct.pack("<II", ms, ls)
dotted = ".".join(str(part) for part in version)
texts = [
"VS_VERSION_INFO",
"StringFileInfo",
"VarFileInfo",
"Translation",
"CompanyName",
EXPECTED_STRINGS["CompanyName"],
"ProductName",
EXPECTED_STRINGS["ProductName"],
"FileDescription",
EXPECTED_STRINGS["FileDescription"],
"LegalCopyright",
EXPECTED_STRINGS["LegalCopyright"],
dotted,
f"{stem}.exe",
]
return bytes(body) + fixed + b"".join(utf16(text) for text in texts)
def spawn_with_io_encoding(artifact: Path, expect: str, io_encoding: str) -> subprocess.CompletedProcess[str]:
"""按指定 I/O 编码起一个真子进程跑本脚本(复刻 CI 上的 Windows 管道环境)。"""
env = {key: value for key, value in os.environ.items() if key not in ("PYTHONUTF8", "PYTHONIOENCODING")}
env["PYTHONIOENCODING"] = io_encoding
return subprocess.run(
[sys.executable, str(Path(__file__).resolve()), str(artifact), "--expect-version", expect],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
check=False,
)
def self_test() -> int: def self_test() -> int:
"""口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。""" """口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」+ 「管道编码不许崩」。"""
import os
import tempfile import tempfile
failures = 0 failures = 0
@@ -137,19 +230,45 @@ def self_test() -> int:
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错 # 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。 # (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
fake = bytearray(0x200) with tempfile.TemporaryDirectory() as work:
fake[0:2] = b"MZ" red = Path(work) / "red.exe"
struct.pack_into("<I", fake, 0x3C, 0x40) red.write_bytes(synthetic_pe("red", (0, 2, 3, 0), with_resource=False))
fake[0x40:0x44] = PE_MAGIC problems = check(red, "0.2.3")
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2) if not problems:
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle: print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
handle.write(bytes(fake)) failures += 1
name = handle.name
problems = check(Path(name), "0.2.3") # 正控:同一套判据对「齐全」的合成 PE 必须绿,否则下面的「绿的时候能不能正常退出」无从验起。
os.unlink(name) green = Path(work) / "green.exe"
if not problems: green.write_bytes(synthetic_pe("green", (0, 2, 3, 0)))
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr) problems = check(green, "0.2.3")
failures += 1 if problems:
print(f"FAIL: 版本资源齐全的合成 PE 竟然报错 {problems}(判据过度)", file=sys.stderr)
failures += 1
# 回归(v0.2.4 Release run 43031591576 两个 Windows 槽位红在这里):CI 的 stdout 是管道,
# Windows runner 默认代码页 1252 打不出汉字。下面两条用**真子进程 + 真管道**把那个环境复刻出来:
# 判据全过时必须退 0(不是崩在 print 上),判据没过时必须退 1 且报错文本还在(不许被吞成 traceback)。
for case, artifact, expect, want in (
("cp1252 绿", green, "0.2.3", 0),
("cp437 绿", green, "0.2.3", 0),
("cp1252 红", red, "0.2.3", 1),
):
result = spawn_with_io_encoding(artifact, expect, "cp1252" if "cp1252" in case else "cp437")
crashed = "Traceback" in result.stderr or "UnicodeEncodeError" in result.stderr
if crashed or result.returncode != want:
print(
f"FAIL: {case} 时退出码 {result.returncode}(期望 {want})"
f"{',且崩在编码上' if crashed else ''}:{result.stderr.strip()[-300:]}",
file=sys.stderr,
)
failures += 1
if want == 0 and "ok:" not in result.stdout:
print(f"FAIL: {case} 时 stdout 没有 ok 行:{result.stdout.strip()[-200:]}", file=sys.stderr)
failures += 1
if want == 1 and "error:" not in result.stderr:
print(f"FAIL: {case} 时报错文本丢了:{result.stderr.strip()[-200:]}", file=sys.stderr)
failures += 1
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败") print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
return 0 if failures == 0 else 1 return 0 if failures == 0 else 1
@@ -159,7 +278,7 @@ def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)") parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)")
parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe") parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe")
parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)") parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)")
parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红)") parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红/齐全必绿 + 管道编码)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.self_test: if args.self_test:
@@ -185,4 +304,5 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
safe_streams()
raise SystemExit(main()) raise SystemExit(main())
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# PE 版本资源读回门禁(M190 判据在 CI 上的入口;M264 从 release-build.yml 拆出来)。
#
# 为什么是脚本而不是内联 step:`.github/workflows/release-build.yml` 撞着 400 行硬门禁
# (`guards.yml` 的 file-size,用户硬约束,不许抬 LIMIT),而「读回两个产物 + 同源取版本号 +
# 失败逐条转注解」内联进去必然顶上线。拆法与 `packaging/linux-smoke-docker.sh` /
# `packaging/linux-check-docker.sh` 同一思路:workflow 只留一行调用。
#
# 判据一行没改,仍在 `packaging/check_pe_version.py`:
# ①读的是**产物字节**(构建日志里打印过不算证据);②版本号与 `Cargo.toml` 同源;
# ③任何一条判据没过就非 0 退出(含「产物不存在」「找不到 python」——工具缺失等于门禁失效,不降级)。
# 本脚本只做「调用 + 把失败原因变成 `::error::` 注解」:v0.2.4 的 run 43031591576 两个 Windows
# 槽位红在这里时,注解 API 上只剩一句 `Process completed with exit code 1.`,只能靠猜。
#
# 用法:
# bash packaging/ci-pe-version-gate.sh [build_dir] # 默认 target/release
set -euo pipefail
BUILD_DIR="${1:-target/release}"
# 仓库根按脚本位置反推:workflow 在仓库根跑,但本地从别处调用时也不该依赖 cwd。
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
PY=$(command -v python3 || command -v python || true)
if [ -z "$PY" ]; then
echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"
exit 1
fi
# 先把「产物到底是哪个文件、多大」落成日志:这一步红了,第一件要排除的就是「读的不是那个文件」。
# `|| echo` 是刻意的:让「文件不存在」的判据由检查器自己报(它的措辞比 ls 更适合当注解)。
ls -l "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \
|| echo "::warning::ls 没列出产物(文件可能不存在),判据交给下面的检查器"
# 版本号与 `Cargo.toml` **同源**:`[workspace.package] version = "x.y.z"`。不在这里写死任何版本。
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
echo "expect-version=$version"
log=$(mktemp "${TMPDIR:-/tmp}/pe-version.XXXXXX.log" 2>/dev/null || true)
if [ -n "$log" ]; then
trap 'rm -f "$log"' EXIT INT TERM
echo "log: $log"
else
# 连临时文件都建不出来时**不判红**:判据是产物的字节,不是本脚本的管道。原因看原始输出。
echo "::warning::mktemp 失败,失败原因只能看下面的原始输出(判据不变)"
fi
status=0
"$PY" packaging/check_pe_version.py \
"$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" \
--expect-version "$version" 2>&1 | tee "${log:-/dev/null}" || status=$?
if [ "$status" -ne 0 ]; then
if [ -n "$log" ]; then
# 上限 30 行:正常失败是「每个产物几条判据」,异常失败(traceback)也不会被截掉开头。
line_no=0
while IFS= read -r line; do
line_no=$((line_no + 1))
if [ "$line_no" -le 30 ]; then echo "::error::PE 版本资源读回:$line"; fi
done < "$log"
fi
exit "$status"
fi
exit 0
+47
View File
@@ -51,3 +51,50 @@
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中), - `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。 这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。 - 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
### 2.1 里程碑快照的提交信息该长什么样(M268)
发布提交的信息**会出现在公开界面上**(commit 列表、PR、`git log`,以及——没有 `run-name` 时——Actions 的运行名),
所以它是对外文案,不是内部工作记录。
默认行为(GitHub 文档原文):`run-name` 省略或只有空白时,运行名取「事件相关的信息」,push / pull_request 触发的
run 就是**提交信息原文**(PR 则是 PR 标题)。M268 之前 `.github/workflows/` 里 `run-name` 零命中,于是 v0.2.4 那一条的
标题直接是提交信息:「release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)」——D41 快照模型的实现术语
(无父提交、树取自 dev)泄漏到了任何人点开仓库就能看到的地方。v0.2.1~v0.2.3 那几条看着正常,只是因为那几次的
提交信息本身干净,不是因为流水线做了什么。
现在的兜底:`release.yml` 与 `release-guard.yml` 都加了显式 `run-name`(`Release v0.2.4` / `Release guard · main`),
发布相关的 run 名不再由提交信息决定。`release-publish.yml` / `release-build.yml` 是 `workflow_call` 可复用工作流,
**不产生自己的 run**,所以那里不加(理由写在该文件头部注释里)。**但提交信息本身照样公开可见,规矩照旧要守。**
规矩只有两条:
1. **版本 + 一句话**,例如 `release: v0.2.4`。想交代一句就写对外的话:
`release: v0.2.4(首个带 Linux 产物的稳定版)`。
2. **不许写实现细节**:「孤儿提交」「无父」「树 = dev <hash>」「squash 了哪几个提交」这类快照模型的内部术语一个都不要。
过程写进 `DevDocs/` 或 PR 描述,不写提交信息。
正面例子(就这样写):
```text
release: v0.2.4
```
反面例子(真实发生过,就是泄漏到 Actions 列表的那一条):
```text
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
```
问题不在长度,在于这段话讲的是**我们怎么做的快照**(无父提交、树取自 dev 68362e5),跟用户无关,还暴露了内部模型。
**抓得住什么 / 抓不住什么**:
- 抓得住(只有这两条):加了 `run-name` 的两个工作流——`Release`(= `release.yml`)与 `Release guard`——的运行名不再
由提交信息决定,Actions 列表里固定是 `Release v0.2.4` / `Release guard · main` 这类人读名字。下次谁把提交信息写成
内部术语,**这两行不会显示它**。(已发生的那条 v0.2.4 不会追溯改名:历史 run 名是快照。)
- 抓不住(一半):**同一份提交信息在别的 run 上照样公开**。`ci.yml` / `guards.yml` / `dev-guards.yml` /
`gpu-evidence.yml` / `signing-status.yml` 都还是默认行为(没加 `run-name`),所以发布提交推进 `main` 时,那几行仍以
提交信息为名。要么给它们也加 `run-name`,要么就别在提交信息里写内部术语——**目前是后者**。
- 抓不住(另一半):**提交信息本身**。`git log` / commit 页面 / PR 里那段字照旧公开,`run-name` 管不到;这一层只有本节
+ review 时人看一眼。没有任何门禁会因此变红——写一个能判「这段话是不是内部术语」的脚本不现实,别指望它存在。
+137 -1
View File
@@ -63,7 +63,8 @@ python packaging/make_artifacts.py --slot windows-arm64 \
`CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 原生优先、Hostx64 次之;`-find` 的斜杠/反斜杠两种形态都试)。 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 原生优先、Hostx64 次之;`-find` 的斜杠/反斜杠两种形态都试)。
顺序不能反:只覆盖链接器而不给 `LIB`,撞上的就是 `LNK1181: cannot open input file 'kernel32.lib'`。 顺序不能反:只覆盖链接器而不给 `LIB`,撞上的就是 `LNK1181: cannot open input file 'kernel32.lib'`。
**`cmd //c` 不能直接收「带引号的 vcvarsall 路径」**:MSYS 会把 `"` 改写成 `\"`,cmd 的 `/c` 剥离规则接不住, **`cmd //c` 不能直接收「带引号的 vcvarsall 路径」**:MSYS 会把 `"` 改写成 `\"`,cmd 的 `/c` 剥离规则接不住,
结果 `LIB`/`INCLUDE` 全空(M164 复审实测;`cmd //c` 只调**不含引号**的路径是好的)。故本步往 `$RUNNER_TEMP` 结果 `LIB`/`INCLUDE` 全空(M164 复审实测;`cmd //c` 只调**不含引号**的路径是好的)。当时的报错原文是
`'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令`(将来搜到这句、想知道来龙去脉,就看这一段)。故本步往 `$RUNNER_TEMP`
写一个 CRLF 的 wrapper `.bat`(`call "<vcvarsall>" arm64` + `set`),`cmd //c` 只收相对文件名——引号留在 写一个 CRLF 的 wrapper `.bat`(`call "<vcvarsall>" arm64` + `set`),`cmd //c` 只收相对文件名——引号留在
`.bat` 内部由 cmd 自己解析,路径带空格也不受影响。 `.bat` 内部由 cmd 自己解析,路径带空格也不受影响。
- **编译前体检已降级为「只取证」**(M164):M141/M142 的版本用 `find` 猜 `um/arm64/kernel32.lib` 在不在、猜缺就 - **编译前体检已降级为「只取证」**(M164):M141/M142 的版本用 `find` 猜 `um/arm64/kernel32.lib` 在不在、猜缺就
@@ -137,3 +138,138 @@ python packaging/make_artifacts.py --slot windows-arm64 \
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21), Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是 但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。 系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
### 构建缓存 key:必须带镜像维度(`Swatinem/rust-cache`,M270)
**事故形态(M269 端到端复现)**:`linux-x64` 发布构建红在链接期,`libaws_lc_sys-*.rlib` 里的 `bcm.o` 引用
`__isoc23_sscanf` / `__isoc23_strtol` —— 那是 glibc ≥ 2.38 的头把 `sscanf` / `strtol` 改写成 `__isoc23_*` 之后才有的符号,
而链接发生在 `ubuntu-22.04`(glibc 2.35)。干净 22.04 容器里同一条命令 **exit 0**,干净 24.04 也 exit 0 ⇒ 不是「镜像版本
差异」本身,是**缓存把 24.04 编出来的 `.o` 搬到了 22.04**。
**key 是怎么算出来的**(`rust-cache@v2` 的 `src/config.ts`;`add-job-id-key` 与 `add-rust-environment-hash-key` 两项默认都是 true):
```
key = prefix-key「v0-rust」
+ 「-<key 输入>」 ← 本仓 M270 才第一次用(下文「修法」)
+ 「-<GITHUB_JOB>」 ← 是 job **id**,不是 job 的 `name:`
+ 「-<os.type()>-<os.arch()>」 ← Linux-x64 / Windows_NT-x64 / Darwin-arm64,**没有镜像标签**
+ 「-<env hash 前 8 位>」 ← rustc -vV(release + host + commit-hash)+ CARGO*/CC/CFLAGS/CXX/CMAKE/RUST* 的 sha1
restoreKey = 到这里为止的一整串 ← 兜底恢复 key(**不含**锁哈希)
key += 「-<manifest/lock hash 前 8 位>」 ← 各 Cargo.toml(版本号被抹平)+ Cargo.lock + .cargo/config.toml
cacheKey = key
```
两条恢复路径(`src/restore.ts` 就是一句 `restoreCache(paths, cacheKey, [restoreKey])`):
- ① 主 key **逐字节相同** → `full match = true`,**不清理** `target/`,直接吃别人的产物;
- ② 主 key 未命中、`restoreKey` **前缀匹配**到某条已有缓存 → `full match = false`,先 `cleanTargetDir`(只清 profile 层),
依赖的 `.o` / `.rlib` 留着算 fresh —— M269 那条「依赖 fresh + 旧系统链接」正是走这条。
**为什么 CI 与发布两侧会撞**:`ci.yml` 的 job id 是 `build`(跑 `ubuntu-latest` = 24.04),`release-build.yml` 的 job id
也是 `build`(跑 `ubuntu-22.04`);`rust-toolchain.toml` 是 `channel = "stable"`(同一天两边解析到同一版 rustc),
`Cargo.lock` 也是同一份 ⇒ env hash 与锁哈希全相同 ⇒ 两个 key **逐字节相同**。这串表达式里**没有任何一项**能反映出镜像。
`RUSTFLAGS` 也救不了:它只写在 `cargo build` 那一步的 `env:` 里,cache 步根本看不到它。
**为什么 cargo 自己不发现**:freshness 判据是 fingerprint,记的是编译器版本、features、profile、依赖 metadata ——
而 `-C metadata` 里**没有 glibc 版本**。于是恢复来的 `.rlib` 被判 fresh、不重编,问题一路拖到最后的链接。
**修法**(两个文件里 7 处 `Swatinem/rust-cache@v2`;全仓共 8 处,见本节末尾):
- **发布侧**:`key: ${{ inputs.os }}`(只用镜像标签,**不带 slot**)+ `save-if: ${{ github.ref_type != 'tag' }}`
(tag run 只读不写)。两条各有单独小节。
- **CI 侧**:三个矩阵 job 用 `key: ${{ matrix.os }}`(自动跟 `runs-on`);三个单 OS job 用它自己的镜像标签字面量。
后者的 `key:` 与 `runs-on:` 是**两份手写文本**:改 `runs-on` 时必须一起改 `key:`,否则镜像维度会静默失准(不报错,
只是继续跨镜像共用)。
- `key` 输入落在**前缀段**(`v0-rust-<key>-<job>-…`,在 `restoreKey` 定格之前),所以主 key 与兜底 `restoreKey`
同时带上镜像维度,**两条恢复路径一起分开**。
#### 发布侧 key 为什么只取 `inputs.os`(不带 slot)
两个方向同时成立,去掉 slot 才对:
- **必须断的**:`linux-x64` @ `ubuntu-22.04` vs `ci/build` @ `ubuntu-latest` ⇒ 不同 key。这就是 M269 那条路径。
- **故意保留的**:`windows-x64` @ `windows-latest` vs `ci/build` @ `windows-latest` ⇒ **同 key**。同镜像、同 rustc、
同一份锁 ⇒ 借 main 上的热缓存是安全的:M270 之前 Windows 走的正是这条路,而 `windows-x64` 一直在 `PUBLISH_SLOTS`
里、产物是绿的。slot 本来就是冗余维度(每个槽位的 `inputs.os` 互不相同)。
- 代价与取舍:加了 slot 的那一版会把上面这一对也分开,两者的实质差异只是 `RUSTFLAGS=-C target-feature=+crt-static`。
合并回同一把 key 后 cargo 按 fingerprint 区分(`RUSTFLAGS` 是否被计入本仓没有实测;但这是 M270 前线上一直成立的
状态),最坏情况是多编一遍,不会错编。
#### 发布侧为什么「只读不写」(tag run 的缓存作用域)
- 官方文档(`docs.github.com` → dependency-caching → "Restrictions for accessing a cache"):一次 run 只能恢复
**当前分支**或**默认分支(main)**创建的缓存;不同 tag 名之间互不可读(`release-a` 写的缓存,`release-b` 读不到)。
- 推论一:发布侧的 key 是 `ubuntu-22.04` / `ubuntu-22.04-arm` / `windows-11-arm` / `macos-*`,而 **main 上一个 job 都
不跑这些标签**(`ci.yml` 只有 `ubuntu-latest` / `windows-latest` / `macos-latest`)⇒ 这四个槽位**每次发布都是冷编**,
连 registry / git 源码也拿不到(rust-cache 是「一把 key 一个 entry」,所有路径捆在一起,没有分路径缓存)。
唯一还能借到热缓存的是 `windows-x64`(见上一小节)。
- 推论二:发布侧写出去的缓存只能被**同名 tag 的下一次 run** 读到,实际就是一次性死缓存;它还占 per-repo 的 10 GB
配额(超出按最后访问时间 LRU 淘汰、7 天未访问过期),会把 main 上真正在用的 CI 热缓存挤掉。**所以 tag run 不写。**
- 为什么不是写死 `save-if: false`:把「写」留给唯一有效的场景 —— **在 main 上预热**。`gh workflow run release.yml`
(在 main 的末梢上跑)那次 run 的缓存作用域是 **main**,之后从 main 末梢切出来的 tag run 才读得到它(锁哈希与
rustc 版本一致才命中)。真要彻底禁写就把这一行改成 `false`,那时预热也要临时改文件才做得到。
#### 演算(核心判据;r1 换了 key,r0 那张表作废)
把上面的拼接写成脚本,对 15 个站点(ci 9:clippy/test/build 各 2 个 OS + 3 个单 OS job;发布 6)× 2 个
`Cargo.lock` 状态两两判定:
| 关系 | 改前(7 处都没有 `key:`) | 改后(`key: inputs.os` + `save-if`) |
| --- | --- | --- |
| ① `rel/windows-x64` ↔ `ci/build`(**同镜像**) | 主 key 相同 | 主 key **仍相同**(故意保留 / 安全) |
| ① `rel/linux-x64` ↔ `ci/build`(**跨镜像**) | 主 key 相同 ⇒ **M269 的 bug** | **不相同**(必须断) |
| ② **跨镜像** `restoreKey` 前缀命中 | **2 组**(`ci/build@ubuntu-latest` ↔ `rel/linux-x64` 两个方向) | **0 组** |
拼出来(env / 锁哈希用占位值,取值不影响结论):
```
改后 ci/build@ubuntu-latest cacheKey = v0-rust-ubuntu-latest-build-Linux-x64-3f9a1c07-b2d6e815
改后 rel/linux-x64@22.04 cacheKey = v0-rust-ubuntu-22.04-build-Linux-x64-3f9a1c07-b2d6e815
↑ 前缀 v0-rust-ubuntu- 之后立刻分叉(`l` vs `2`);两边 restoreKey 都更长 ⇒ 前缀命中不可能
改后 ci/build@windows-latest cacheKey = v0-rust-windows-latest-build-Windows_NT-x64-3f9a1c07-b2d6e815
改后 rel/windows-x64 cacheKey = v0-rust-windows-latest-build-Windows_NT-x64-3f9a1c07-b2d6e815
↑ 逐字节相同(同镜像,这是要保留的复用);锁哈希不同时退化成路径②,仍是同镜像,安全
```
脚本按「**跨镜像**」这一条断言(15 个站点两两 + 两个锁状态):改后任意两个 **runner 标签不同**的站点之间,主 key
相同 0 对、`restoreKey` 前缀命中 0 组。仍存在的相同/前缀关系全部落在**同一个镜像标签内部** —— 也就是
`rel/windows-x64` ↔ `ci/build` 那一对(同 `windows-latest`,正是要保留的复用)以及同站点换锁哈希那种(本来就该
复用依赖)。判据的输出是一次性的脚本,跑完即删;上面这几行就是它的结论。
#### `cache-targets` 到底关不关:**不关**
- **CI 侧**:关掉就只缓存 registry / git 源码,每个 job 都要从头编整棵依赖树(M269 干净容器实测同一条命令 18m43s
量级),日更矩阵每次提交都付这笔钱,收益为 0 —— 加镜像维度之后 `target/` 的恢复永远来自**同一镜像标签**,
M269 那条路径已经断了。
- **发布侧**:关掉确实能连 target 缓存一起省,但它**把 Windows 借 main 热缓存这条路一起掐掉**(target 不进缓存就
没得借),而 `windows-x64` 是发布侧唯一还能借到的一档。发布侧的死缓存问题由 `save-if` 解决,比关 targets 精准:
**该写的一次都不落,该借的一次都不少。**
- 被同一把 key 一起管住的还有 `cache-bin`:`~/.cargo/bin` 里是 `dtolnay/rust-toolchain` 从 `static.rust-lang.org`
装下来的**官方预编译** rustup 代理(不是在这台 runner 上编的),与 `target/` 同域、同一个 key,不引入额外风险。
#### 抓得住什么 / 抓不住什么
- 抓得住**跨镜像复用**:不同 runner 标签之间(`ubuntu-22.04` ↔ `ubuntu-latest`)不再共享 `target/`,两条恢复路径
都断,判据就是上面那张表。
- **抓不住「同一个镜像标签、但底层镜像悄悄升级」**:`ubuntu-latest` 从 24.04 升到 26.04、或 22.04 打一次 glibc 补丁时,
标签不变 ⇒ key 不变 ⇒ 旧缓存照样能恢复。这一档**没有自动化机制**,靠手动 bump:改 `prefix-key`(`v0-rust` →
`v1-rust`)或改对应 `key:` 的字面量,一次性作废旧缓存。**别把这修法当成「以后不会再有这类问题」。**
- 顺带:`rust-toolchain.toml` 是 `stable`,rustc 一升版 env hash 就变 ⇒ **编译器升级自动冷启动**,这一档本来就不在
风险里。
#### 代价与一次性影响
- CI 侧:缓存条数从「每 job 一条」变成「每 job × 每镜像一条」;三个单 OS job 各多一次冷编。
- 发布侧:只有 `windows-x64` 还能借 main 的热缓存;`linux-x64` / `linux-arm64` / `windows-arm64` / `macos-*`
**每次发布都冷编** —— 这是断开 M269 那条路径的必然代价(22.04 在 main 侧没有任何产出者)。想省就按上面
「在 main 上预热」那条做。
- 合入后所有旧 key 的缓存一次性失配,靠 LRU 自然淘汰;第一次 CI run 是冷缓存,别把它当成回归。
- 全仓第 8 处 `rust-cache` 在 `.github/workflows/dev-guards.yml:128`(job id `clippy`,只跑 `ubuntu-latest`),
**本次刻意不动**:它唯一的 runner 就是 `ubuntu-latest`,job id 也不与发布侧 `build` 重名,今天不可能跨镜像相撞。
副作用是它的 key 与 `ci/clippy` 从此不再逐字节相同(改前是相同的)⇒ 合入后 dev-guards 那次 clippy 会冷编一次,
一次性、无害。
**没验的部分**:真实 Actions 上的缓存命中 / 未命中本地验不了 —— 本机只能证明 key 字符串(上面的演算)、YAML 仍被真
解析器接受(`python packaging/check_workflows_yaml.py`)、以及结构正确。「tag run 只能读 main / 同名 tag」这条来自
官方文档而不是观测;下一次 tag 正好顺手验三件事:`Cache restored from key:` 是否变成 `No cache found.`(linux-x64
应当是)、`Compiling aws-lc-sys` 是否重编、日志里还有没有 `__isoc23_*`。
+10 -5
View File
@@ -14,7 +14,7 @@
| 环节 | 在哪 | 今天的状态 | | 环节 | 在哪 | 今天的状态 |
|---|---|---| |---|---|---|
| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 | | 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一段(多行,fmt 保持的形态) |
| 钥 ↔ 信任表对账 | `sign_manifest.py --check-pinned-keyring` | 有;**今天必然红**(表空 ⇒ 「信任表是空的」) | | 钥 ↔ 信任表对账 | `sign_manifest.py --check-pinned-keyring` | 有;**今天必然红**(表空 ⇒ 「信任表是空的」) |
| 签名 | `sign_manifest.py --sign dist/manifest.json` → `.sig` | 有;**今天必然红**(`ULL_MANIFEST_SIGNING_KEY` 没有,前面先红) | | 签名 | `sign_manifest.py --sign dist/manifest.json` → `.sig` | 有;**今天必然红**(`ULL_MANIFEST_SIGNING_KEY` 没有,前面先红) |
| 回验 | `sign_manifest.py --verify-pinned dist/manifest.json` | 有;**今天必然红**(表空 ⇒ 「无从回验」) | | 回验 | `sign_manifest.py --verify-pinned dist/manifest.json` | 有;**今天必然红**(表空 ⇒ 「无从回验」) |
@@ -60,7 +60,8 @@ python3 packaging/sign_manifest.py --generate-key --key-id <key-id,例 ull-rel
# 名字必须**逐字**是:ULL_MANIFEST_SIGNING_KEY # 名字必须**逐字**是:ULL_MANIFEST_SIGNING_KEY
# (等价 CLI:gh secret set ULL_MANIFEST_SIGNING_KEY,然后按提示粘贴,别用 -b 把值留在 shell 历史里) # (等价 CLI:gh secret set ULL_MANIFEST_SIGNING_KEY,然后按提示粘贴,别用 -b 把值留在 shell 历史里)
# ③ 把打印出来的那一行 PinnedKey { … } 贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动) # ③ 把打印出来的那一段 PinnedKey(4 行,含结尾逗号)贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动)
# 那个形状就是 cargo fmt 会保持的形态 —— 贴完**不用**再跑 fmt(压成一行反而会被 fmt 拆开)
grep -n 'PINNED-KEYS' crates/core/src/update/trust.rs grep -n 'PINNED-KEYS' crates/core/src/update/trust.rs
# 贴完提交这次改动 —— 公钥进 git 是对的:它是信任锚,本来就该公开可审计 # 贴完提交这次改动 —— 公钥进 git 是对的:它是信任锚,本来就该公开可审计
@@ -78,7 +79,7 @@ python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # 期望
| 步 | 成功长什么样 | 失败长什么样 | | 步 | 成功长什么样 | 失败长什么样 |
|---|---|---| |---|---|---|
| ① | 打印 ① 私钥 JSON、② 一行 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 | | ① | 打印 ① 私钥 JSON、② 一段 4 行的 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 |
| ② | `gh secret list` 里能看到 `ULL_MANIFEST_SIGNING_KEY` | 列表里没有 / 名字拼错(大小写、下划线) | | ② | `gh secret list` 里能看到 `ULL_MANIFEST_SIGNING_KEY` | 列表里没有 / 名字拼错(大小写、下划线) |
| ③ | `check_signing_ready.py` 的 ① 从「未就绪」翻成「就绪」 | ① 仍说 0 条 ⇒ 贴到了标记**外面**(读数里会点名「另有 N 条在标记之外(key_id:…)」) | | ③ | `check_signing_ready.py` 的 ① 从「未就绪」翻成「就绪」 | ① 仍说 0 条 ⇒ 贴到了标记**外面**(读数里会点名「另有 N 条在标记之外(key_id:…)」) |
| ④ | `ok: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 | | ④ | `ok: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 |
@@ -104,7 +105,7 @@ sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验
secret,名字不对就是空串 ⇒ 签名步红,而错误文案会反过来指控「Secret 没配」。`release-publish.yml` secret,名字不对就是空串 ⇒ 签名步红,而错误文案会反过来指控「Secret 没配」。`release-publish.yml`
已经声明了这一个(`required: false`,缺省走 fail-closed),照抄名字即可。 已经声明了这一个(`required: false`,缺省走 fail-closed),照抄名字即可。
2. **只配 Secret 没钉表**:`--check-pinned-keyring` 报「信任表是空的(fail-closed)」,并**直接打印** 2. **只配 Secret 没钉表**:`--check-pinned-keyring` 报「信任表是空的(fail-closed)」,并**直接打印**
该贴的那一行 `PinnedKey`(只含公钥,安全)。照贴即可。 该贴的那一段 4 行 `PinnedKey`(只含公钥,安全)。照贴即可,注意连 `error:` 前缀一起删掉再贴。
3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒ 3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒
他们能验签),但发不了新版。 他们能验签),但发不了新版。
4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**, 4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**,
@@ -175,6 +176,10 @@ python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」, ②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」,
首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。 首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。
* **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。 * **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。
* **粘贴块「过一遍 fmt 不变形」只在有 `rustfmt` 的机器上是硬证据**:`sign_manifest.py --self-test` 会在
临时副本上真跑一遍 `rustfmt` 比对;PATH 里没有 `rustfmt` 时它会打一行 `note: ... 跳过`,只剩
「形状 == `trust.rs` 现存条目的形状」这条结构断言(抓得住回退成单行/缩进走样,抓不住 rustfmt 版本或
`.rustfmt.toml` 改配置带来的排版变化)。
* **ref 门只在本地验证过**(抽 `run:` 块用 Git Bash 跑 + 接线用例的文本变异),没有在真 Actions 上看过 * **ref 门只在本地验证过**(抽 `run:` 块用 Git Bash 跑 + 接线用例的文本变异),没有在真 Actions 上看过
`secrets` 的求值结果 —— 第一次真跑请确认非 main ref 的 ② 显示「未判定」。 `secrets` 的求值结果 —— 第一次真跑请确认非 main ref 的 ② 显示「未判定」。
@@ -187,4 +192,4 @@ python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
| `packaging/tests/signing_ready_wiring_selftest.py` | 接线自测:不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性 + 三处变异 | | `packaging/tests/signing_ready_wiring_selftest.py` | 接线自测:不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性 + 三处变异 |
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁;种子只在 main 注入) | | `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁;种子只在 main 注入) |
| `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 | | `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 |
| `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量) | | `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量 + 粘贴块形态) |
+6 -3
View File
@@ -57,15 +57,18 @@ manifest.json(字节)──签名──> manifest.json.sig
## 3. 一次性录入(只做一次) ## 3. 一次性录入(只做一次)
```bash ```bash
# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey # ① 生成:打印一段 Secret JSON + 一段可以直接贴进 trust.rs 的 PinnedKey(多行,4 行)
python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a
``` ```
* 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret: * 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret:
Settings → Secrets and variables → Actions → New repository secret,名字 Settings → Secrets and variables → Actions → New repository secret,名字
**`ULL_MANIFEST_SIGNING_KEY`**。 **`ULL_MANIFEST_SIGNING_KEY`**。
* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `crates/core/src/update/trust.rs` 的 * 打印出来的 **`PinnedKey { … }` 那一段(4 行,含结尾逗号)**贴进 `crates/core/src/update/trust.rs` 的
`>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。 `>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。
* 那个形状就是 `cargo fmt`(`.rustfmt.toml` 的 `max_width = 100`)**会保持**的形态:**贴完不用再跑
`cargo fmt`**。反过来,把它压成一行反而会被 fmt 拆开 —— 那种「粘贴 → 下一轮 fmt 改动 → 谁忘了跑 fmt
就把红带进 main」的复发路径由 `sign_manifest.py --self-test` 钉住(`render_pinned_key` 的形态断言)。
* 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。 * 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。
### 私钥纪律(违反任何一条都算事故) ### 私钥纪律(违反任何一条都算事故)
@@ -97,7 +100,7 @@ python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用
| 现象 | 原因 | 怎么补 | | 现象 | 原因 | 怎么补 |
|---|---|---| |---|---|---|
| `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 | | `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 |
| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 | | `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那段(4 行)贴进标记之间 |
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 | | `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue | | `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
+116 -16
View File
@@ -25,7 +25,10 @@ import hashlib
import json import json
import os import os
import re import re
import shutil
import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
FORMAT = 1 FORMAT = 1
@@ -33,8 +36,11 @@ ALG = "ed25519"
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY" KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
SIG_SUFFIX = ".sig" SIG_SUFFIX = ".sig"
TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trust.rs" TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trust.rs"
RUSTFMT_TOML = Path(__file__).resolve().parent.parent / ".rustfmt.toml"
PIN_BEGIN = ">>> PINNED-KEYS >>>" PIN_BEGIN = ">>> PINNED-KEYS >>>"
PIN_END = "<<< PINNED-KEYS <<<" PIN_END = "<<< PINNED-KEYS <<<"
# 粘贴块排版:`trust.rs` 里数组元素缩进 4 空格、结构体字段再缩进 4(= 元素缩进 + 4)。
PASTE_INDENT = " "
KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"') KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"')
# Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。 # Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。
# 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek —— # 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek ——
@@ -85,7 +91,66 @@ def pinned_keys(trust_text: str) -> list[tuple[str, str]]:
raise SystemExit(f"error: {TRUST_RS} 里找不到 {PIN_BEGIN} / {PIN_END} 标记") raise SystemExit(f"error: {TRUST_RS} 里找不到 {PIN_BEGIN} / {PIN_END} 标记")
return KID_RE.findall(trust_text[begin:end]) return KID_RE.findall(trust_text[begin:end])
def render_pinned_key(key_id: str, public_key_hex: str) -> str:
"""渲染一条**粘贴即可、`cargo fmt` 不再改形**的 `PinnedKey`(M265)。
为什么必须多行:单行形态即使 `key_id` 为空也有 113 字符,超出 `.rustfmt.toml` 的 `max_width = 100`,
所以 `cargo fmt` **必然**把它拆成下面这个形状 —— T30 的 fmt 红就是这么进的 main(人贴单行 → 下一轮
fmt 改动 → 谁忘了跑 fmt 就带红进主干)。反过来说这个形状过一遍 rustfmt 不变,`--self-test` 会验。
"""
pad = PASTE_INDENT
return (
f"{pad}PinnedKey {{\n"
f'{pad} key_id: "{key_id}",\n'
f'{pad} public_key_hex: "{public_key_hex}",\n'
f"{pad}}},"
)
def rustfmt_max_width() -> int:
"""`.rustfmt.toml` 里的 `max_width`(读不到就用 rustfmt 的默认 100)。"""
try:
for line in RUSTFMT_TOML.read_text(encoding="utf-8").splitlines():
key, _, value = line.partition("=")
if key.strip() == "max_width":
return int(value.strip())
except (OSError, ValueError):
pass
return 100
def pinned_entry_blocks(trust_text: str) -> list[str]:
"""标记之间每条 `PinnedKey { … }` 的**原样文本**(含缩进与结尾逗号)。
与 `pinned_keys()` 互补:那个只看字段值(跨行跨不跨行都吃),这个看**排版**。
"""
begin, end = trust_text.find(PIN_BEGIN), trust_text.find(PIN_END)
if begin < 0 or end < 0 or end < begin:
return []
return re.findall(r"^[ \t]*PinnedKey \{.*?^[ \t]*\},", trust_text[begin:end], re.MULTILINE | re.DOTALL)
def rustfmt_keeps_shape(snippet: str) -> bool | None:
"""把 `snippet` 按 `trust.rs` 的上下文贴进临时副本、真跑一遍 `rustfmt`:形态被保持 ⇒ True。
`rustfmt` 不在 PATH 上 ⇒ None(自测退化成结构断言,并把「跳过」说出来,不装成验过)。
"""
rustfmt = shutil.which("rustfmt")
if not rustfmt:
return None
args = [rustfmt, "--edition", "2021"]
if RUSTFMT_TOML.is_file():
args += ["--config-path", str(RUSTFMT_TOML)]
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "pasted.rs"
src.write_text(
"struct PinnedKey {\n key_id: &'static str,\n public_key_hex: &'static str,\n}\n\n"
f"const PRODUCTION: &[PinnedKey] = &[\n // {PIN_BEGIN}\n{snippet}\n // {PIN_END}\n];\n",
encoding="utf-8",
newline="\n",
)
result = subprocess.run([*args, str(src)], capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"rustfmt 跑挂了:{result.stderr.strip()}")
# 比 `snippet in text` 更硬:fmt 之后标记之间那一条必须**逐字节**还是原样(含缩进与结尾逗号)。
return pinned_entry_blocks(src.read_text(encoding="utf-8")) == [snippet]
def do_generate_key(key_id: str) -> int: def do_generate_key(key_id: str) -> int:
seed = os.urandom(32) seed = os.urandom(32)
@@ -97,8 +162,9 @@ def do_generate_key(key_id: str) -> int:
secret = json.dumps({"key_id": key_id, "seed_hex": seed.hex()}, ensure_ascii=False) secret = json.dumps({"key_id": key_id, "seed_hex": seed.hex()}, ensure_ascii=False)
print("# ① 私钥(**只**贴进仓库 Secret " + KEY_ENV + ",别写进任何文件/日志/提交):") print("# ① 私钥(**只**贴进仓库 Secret " + KEY_ENV + ",别写进任何文件/日志/提交):")
print(secret) print(secret)
print("# ② 公钥(贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间):") print("# ② 公钥(整段贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间;")
print(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},') print("# 形状就是 `cargo fmt` 会保持的那一种 —— 贴完直接提交,不用再跑 fmt):")
print(render_pinned_key(key_id, pk_hex))
print(f"# 公钥十六进制:{pk_hex}") print(f"# 公钥十六进制:{pk_hex}")
return 0 return 0
@@ -163,8 +229,9 @@ def do_check_pinned(key_path: str | None) -> int:
# 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。 # 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。
print("error: 信任表是空的(fail-closed):客户端一把公钥都没有,任何清单都验不过,所以拒绝发布。", file=sys.stderr) print("error: 信任表是空的(fail-closed):客户端一把公钥都没有,任何清单都验不过,所以拒绝发布。", file=sys.stderr)
print(f"error: 本次签名钥 = key_id {key_id} / 公钥 {pk_hex}", file=sys.stderr) print(f"error: 本次签名钥 = key_id {key_id} / 公钥 {pk_hex}", file=sys.stderr)
print("error: 补法:把这行贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr) print("error: 补法:把下面这段贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr)
print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', file=sys.stderr) for line in render_pinned_key(key_id, pk_hex).splitlines():
print(f"error: {line}", file=sys.stderr)
print("error: 手册:packaging/docs/signing.md §3", file=sys.stderr) print("error: 手册:packaging/docs/signing.md §3", file=sys.stderr)
return 1 return 1
for pinned_id, pinned_hex in keys: for pinned_id, pinned_hex in keys:
@@ -235,30 +302,63 @@ def do_self_test() -> int:
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"), "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"),
] ]
failures = 0 failures = 0
def fail(message: str) -> None:
nonlocal failures
print(f"FAIL: {message}", file=sys.stderr)
failures += 1
for index, (seed_hex, pk_hex, message_hex, sig_hex) in enumerate(vectors, 1): for index, (seed_hex, pk_hex, message_hex, sig_hex) in enumerate(vectors, 1):
seed, pk, message = bytes.fromhex(seed_hex), bytes.fromhex(pk_hex), bytes.fromhex(message_hex) seed, pk, message = bytes.fromhex(seed_hex), bytes.fromhex(pk_hex), bytes.fromhex(message_hex)
if public_key(seed).hex() != pk_hex: if public_key(seed).hex() != pk_hex:
print(f"FAIL vector {index}: public key mismatch", file=sys.stderr) fail(f"vector {index}: public key mismatch")
failures += 1
if sign(seed, message).hex() != sig_hex: if sign(seed, message).hex() != sig_hex:
print(f"FAIL vector {index}: signature mismatch", file=sys.stderr) fail(f"vector {index}: signature mismatch")
failures += 1
if not verify(pk, message, bytes.fromhex(sig_hex)): if not verify(pk, message, bytes.fromhex(sig_hex)):
print(f"FAIL vector {index}: verify rejected", file=sys.stderr) fail(f"vector {index}: verify rejected")
failures += 1
tampered = bytearray(message) tampered = bytearray(message)
tampered.append(0) tampered.append(0)
if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)): if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)):
print(f"FAIL vector {index}: tampered message still verifies", file=sys.stderr) fail(f"vector {index}: tampered message still verifies")
failures += 1
# 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。 # 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。
sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}' sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}'
if pinned_keys(sample) != [("k1", "ab" * 32)]: if pinned_keys(sample) != [("k1", "ab" * 32)]:
print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr) fail("pinned_keys() 没解析出示例条目")
failures += 1 # 多行形态(M261):`trust.rs` 里那一条是 `cargo fmt` 生成的,字段一行一个 —— 靠 `KID_RE` 的 `\s*`
# 跨行命中。若解析器退化成按行匹配,一次 fmt 就会把整张信任表**静默**读成空表(= 全量客户端验签失败)。
multiline = f'{PIN_BEGIN}\n PinnedKey {{\n key_id: "k1",\n public_key_hex: "{"ab" * 32}",\n }},\n{PIN_END}'
if pinned_keys(multiline) != [("k1", "ab" * 32)]:
fail("pinned_keys() 没解析出多行(rustfmt)形态的条目")
if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []: if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []:
print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr) fail("pinned_keys() 在空表上应返回空")
failures += 1 # 粘贴块形态(M265):`--generate-key` 与「信任表是空的」报错里给人贴的那段 `PinnedKey` 必须是
# rustfmt **不会改形**的多行形态。单行形态是合法 Rust、也照样能解析,所以这条路一旦回退成单行,
# 除了「谁忘了跑 fmt」之外没有任何东西会红 —— T30 的 fmt 红就是这么进的 main。
hex64 = "ab" * 32
rendered = render_pinned_key("k1", hex64)
want = f' PinnedKey {{\n key_id: "k1",\n public_key_hex: "{hex64}",\n }},'
if rendered != want:
fail(f"粘贴块不是多行形态:{rendered!r}")
# 为什么 rustfmt 必然把它排成这样:单行(哪怕 `key_id` 为空)就超出 `max_width`,而多行每行都不超。
width = rustfmt_max_width()
one_line = f' PinnedKey {{ key_id: "", public_key_hex: "{hex64}" }},'
if len(one_line) <= width or any(len(line) > width for line in rendered.splitlines()):
fail(f"粘贴块与 max_width={width} 的关系变了(单行 {len(one_line)} 字符应当超宽)")
# 判据的核心:形状 = `trust.rs` 里**现存**条目的排班(字段值抹平后逐行比对)。
shape = lambda text: [re.sub(r'"[^"]*"', '""', line) for line in text.splitlines()]
blocks = pinned_entry_blocks(TRUST_RS.read_text(encoding="utf-8") if TRUST_RS.is_file() else "")
if not blocks:
print("note: trust.rs 标记之间没有条目,跳过「与现存排版一致」那条", file=sys.stderr)
for block in blocks:
if shape(block) != shape(rendered):
fail(f"粘贴块排版与 trust.rs 现存条目不一致:{block!r}")
if pinned_keys(f"{PIN_BEGIN}\n{rendered}\n{PIN_END}") != [("k1", hex64)]:
fail("粘贴块贴进标记之间后解析不出来(贴了等于没贴)")
keeps = rustfmt_keeps_shape(rendered)
if keeps is None:
print("note: PATH 里没有 rustfmt,跳过「真跑一遍 fmt 不改形」那条", file=sys.stderr)
elif not keeps:
fail("真跑一遍 rustfmt 改了粘贴块的形态")
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败") print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
return 0 if failures == 0 else 1 return 0 if failures == 0 else 1