Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d1dec495 |
No files matched your search
+29
-90
@@ -3,10 +3,6 @@ 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
|
||||||
@@ -14,66 +10,18 @@ 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:
|
||||||
# 加了 schedule 之后这一行必须带上 event_name:定时运行落在默认分支上,`github.ref` 同样是
|
group: ci-${{ 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:
|
||||||
@@ -107,8 +55,6 @@ 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
|
||||||
@@ -131,17 +77,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、版本号墨迹条数、图标药丸 40×40),这些数字
|
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸
|
||||||
# 按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上这套字体不存在,回退字体的字高与行距
|
# 40×40),这些数字按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上
|
||||||
# 一变就是假红。同一条限制也写在 `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的
|
# 这套字体不存在,回退字体的字高与行距一变就是假红。同一条限制也写在
|
||||||
# 模块文档里(非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
|
# `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的模块文档里
|
||||||
|
# (非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
|
||||||
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
|
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -183,20 +129,22 @@ 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)。**别再写 `--exclude unlucky-app`**:它已不是成员,
|
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
|
||||||
# 只会打一条 `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
|
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
|
||||||
|
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
|
||||||
#
|
#
|
||||||
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
|
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
|
||||||
# M144 的第三条红 `test (windows)` 只留下 `Process completed with exit code 1`,而 cargo 自己的失败码
|
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
|
||||||
# 是 **101** ⇒ 那个 1 的来源不明(包装层洗码 / 进程被杀,两说都未排除);job log 要登录才能读
|
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
|
||||||
# (匿名 403),但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事
|
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
|
||||||
# 各对准一种「看不见」:
|
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
|
||||||
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑 7 个 test
|
# 事各自对准一种「看不见」:
|
||||||
# binary 就退出),后面还有没有第二条失败完全看不到;
|
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
|
||||||
# 2) `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,注解是匿名侧
|
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
|
||||||
# 唯一能取到的失败正文;
|
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
|
||||||
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、零诊断」
|
# 注解是匿名侧唯一能取到的失败正文;
|
||||||
# 分开——这正是 M144 没定的那一半。
|
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
|
||||||
|
# 只给码、零诊断」三种情形分开——这正是 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: |
|
||||||
@@ -219,8 +167,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,
|
||||||
@@ -256,10 +204,6 @@ 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
|
||||||
@@ -302,10 +246,9 @@ 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
|
||||||
with:
|
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
|
||||||
key: ubuntu-latest
|
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
|
||||||
# 用 debug 而不是 release:「运行时渲染通路能通」与优化无关,release 的 lto="fat" + codegen-units=1
|
# M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
|
||||||
# 只会把这一步拖到十几分钟。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: |
|
||||||
@@ -344,8 +287,6 @@ 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: |
|
||||||
@@ -389,8 +330,6 @@ 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
|
||||||
|
|||||||
@@ -180,10 +180,9 @@ jobs:
|
|||||||
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
||||||
|
|
||||||
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
|
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
|
||||||
# M196 加第二处(控制台附着 `AttachConsole`)、M256 加第三处(系统「减少动效」探针
|
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
|
||||||
# `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 起由词法器直接抓)。
|
||||||
@@ -195,16 +194,17 @@ jobs:
|
|||||||
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合
|
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
EXPECTED=$'crates/app-iced/src/platform/motion.rs\ncrates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
|
EXPECTED=$'crates/app-iced/src/platform/win32/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` 就是其中之一(在它尾部加
|
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
|
||||||
# 作用域 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,6 +217,7 @@ 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
|
||||||
@@ -233,6 +234,7 @@ 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,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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,os,build_dir,rustflags}` → `inputs.{slot,os,build_dir,rustflags}`)。
|
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
|
||||||
|
# `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 没有
|
||||||
# **M270 给 `Swatinem/rust-cache` 加了 `key`(= 镜像标签)与 `save-if`(tag run 不写)**:机制与取舍见 `packaging/docs/build-and-ci.md` §3「构建缓存」。
|
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
|
||||||
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
|
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
|
||||||
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
|
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
|
||||||
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
|
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
|
||||||
@@ -69,20 +70,7 @@ 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
|
||||||
@@ -141,12 +129,15 @@ jobs:
|
|||||||
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
|
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
|
||||||
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
|
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
|
||||||
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
|
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
|
||||||
# (M164)arm64 槽位在编译前把工具链**显式**备齐(不再靠「runner 反正自带」):① `rustup target add
|
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
|
||||||
# aarch64-pc-windows-msvc`(原生 runner 上幂等 no-op,把「缺 std」挡在编译前);② `LIB`/`INCLUDE` 用微软
|
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
|
||||||
# 自己的 `vcvarsall.bat arm64` 取(**不手搓路径**,少一个目录就是 LNK1181)并写进 `$GITHUB_ENV`;
|
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
|
||||||
# ③ `link.exe` 由 vswhere 定位后导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 优先)。
|
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
|
||||||
# **②必须先于③**:只给链接器不给 LIB 撞的就是 `LNK1181: cannot open input file 'kernel32.lib'`。
|
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
|
||||||
# 三道都是「定位不到就打印原因继续」——真缺工具链时让链接器自己报错。
|
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
|
||||||
|
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
|
||||||
|
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
|
||||||
|
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
|
||||||
- name: cargo build --release --locked
|
- name: cargo build --release --locked
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -162,10 +153,12 @@ 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 的 /c 剥离接不住 ⇒ LIB/INCLUDE 全空、只剩 `set` 的空输出(M164 复审实测)。故往
|
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
|
||||||
# `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat(引号留在 .bat 的 `call "..."` 里),`cmd //c`
|
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
|
||||||
# 只收不含引号的文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
|
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
|
||||||
|
# .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=""
|
||||||
@@ -219,19 +212,24 @@ 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` 不等于它进了 PE——
|
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于
|
||||||
# 资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式版本号、FIXEDFILEINFO
|
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式
|
||||||
# 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。只在 Windows 槽位跑:Linux/macOS 产物
|
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
|
||||||
# 没有 PE 头,那边 `build.rs` 整段不执行(见 `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**。
|
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
|
||||||
# 步骤体本身在 `packaging/ci-pe-version-gate.sh`:读回 + 同源取版本号 + 失败逐条转 `::error::` 注解
|
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
|
||||||
# (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: |
|
||||||
bash packaging/ci-pe-version-gate.sh "$BUILD_DIR"
|
set -euo pipefail
|
||||||
|
PY=$(command -v python3 || command -v python)
|
||||||
|
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
|
||||||
|
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
|
||||||
|
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
||||||
|
echo "expect-version=$version"
|
||||||
|
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
|
||||||
|
|
||||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -291,9 +289,11 @@ 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`)。**只在 linux-x64 跑**:判据按 x64 软渲染实测标定,`linux-arm64`
|
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
|
||||||
# 上一次都没验过,写成 `runner.os == 'Linux'` 就是给没验证过的槽位加会红的判据(M171 同类事故)。
|
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
|
||||||
|
# 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,8 +380,9 @@ jobs:
|
|||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
|
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
|
||||||
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。M164 起本槽位的红
|
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
|
||||||
# **只可能来自真实构建链路**(体检永不 exit 1):红了就把上面的编译器/链接器原文当根因,别再回去改体检。
|
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 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 失败收尾注解(红不挡发布)
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
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`,它做四件事:
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
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`、
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
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、
|
||||||
|
|||||||
+1
-25
@@ -5,31 +5,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0.2.5 — 当前版本
|
## 0.2.4 — 未发布(当前 `dev`)
|
||||||
|
|
||||||
> 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
@@ -5129,7 +5129,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-agent"
|
name = "unlucky-agent"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
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.5"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
@@ -5159,7 +5159,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-cli"
|
name = "unlucky-cli"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5171,7 +5171,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-core"
|
name = "unlucky-core"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -5191,15 +5191,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-design"
|
name = "unlucky-design"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-i18n"
|
name = "unlucky-i18n"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-palette-extract"
|
name = "unlucky-palette-extract"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"unlucky-design",
|
"unlucky-design",
|
||||||
@@ -5207,7 +5207,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-presenter"
|
name = "unlucky-presenter"
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ members = [
|
|||||||
exclude = ["crates/app"]
|
exclude = ["crates/app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.5"
|
version = "0.2.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
authors = ["AskaEth <i@yeij.top>"]
|
authors = ["AskaEth <i@yeij.top>"]
|
||||||
|
|||||||
@@ -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.5" }
|
unlucky-core = { path = "../core", version = "0.2.4" }
|
||||||
|
|
||||||
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
||||||
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||||
unlucky-design = { path = "../design", version = "0.2.5" }
|
unlucky-design = { path = "../design", version = "0.2.4" }
|
||||||
|
|
||||||
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
|
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
|
||||||
unlucky-core = { path = "../core", version = "0.2.5" }
|
unlucky-core = { path = "../core", version = "0.2.4" }
|
||||||
|
|
||||||
# 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.5" }
|
unlucky-presenter = { path = "../presenter", version = "0.2.4" }
|
||||||
|
|
||||||
# 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.5" }
|
unlucky-cli = { path = "../cli", version = "0.2.4" }
|
||||||
|
|
||||||
# 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.5" }
|
unlucky-i18n = { path = "../i18n", version = "0.2.4" }
|
||||||
|
|
||||||
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
||||||
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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;
|
||||||
|
|
||||||
@@ -59,72 +58,37 @@ 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_at(ui, progress))),
|
background: Some(Background::Color(backdrop_color(ui))),
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.on_press(Message::Dialog(DialogMessage::Dismiss));
|
.on_press(Message::Dialog(DialogMessage::Dismiss));
|
||||||
|
|
||||||
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
|
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
|
||||||
let centered = container(motion::scaled(
|
let centered = container(card_plane(ui, modal))
|
||||||
card_plane(ui, modal),
|
.width(Length::Fill)
|
||||||
motion::modal_scale(progress),
|
.height(Length::Fill)
|
||||||
))
|
.align_x(Alignment::Center)
|
||||||
.width(Length::Fill)
|
.align_y(Alignment::Center)
|
||||||
.height(Length::Fill)
|
.padding(ui.space(Sp::X6));
|
||||||
.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`] 断言**视图真正用的那一份**;产品路径一律走
|
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
|
||||||
/// [`backdrop_color_at`](`progress = 1` 那一端逐位相同)。
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(super) fn backdrop_color(ui: &Ui) -> Color {
|
pub(super) fn backdrop_color(ui: &Ui) -> Color {
|
||||||
backdrop_color_at(ui, 1.0)
|
scrim(ui)
|
||||||
}
|
|
||||||
|
|
||||||
/// 遮罩色在**动画进度** `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` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
|
||||||
@@ -333,67 +297,3 @@ 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -118,15 +118,6 @@ 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,8 +126,6 @@ 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>,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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;
|
||||||
|
|
||||||
@@ -82,7 +81,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 的安装草稿)。
|
||||||
let next = super::super::view::model(
|
self.view = super::super::view::model(
|
||||||
&self.state,
|
&self.state,
|
||||||
width,
|
width,
|
||||||
self.height,
|
self.height,
|
||||||
@@ -92,15 +91,6 @@ 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`,
|
||||||
@@ -108,15 +98,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -250,12 +232,3 @@ 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())
|
|
||||||
}
|
|
||||||
@@ -108,12 +108,14 @@ mod tests {
|
|||||||
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
|
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
|
||||||
//!
|
//!
|
||||||
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
|
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
|
||||||
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是守卫全绿、生产路径上却根本播不出来。
|
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
|
||||||
//! 现在全部改走**真消息**,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」的假绿)。
|
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
|
||||||
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟);动效全局态是 thread-local,天然隔离。
|
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
|
||||||
|
//!
|
||||||
|
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::components::motion::FRAME_MS;
|
use crate::app::components::motion::{Slot, FRAME_MS};
|
||||||
use crate::cli::Options;
|
use crate::cli::Options;
|
||||||
use crate::pages;
|
use crate::pages;
|
||||||
use crate::theme::Families;
|
use crate::theme::Families;
|
||||||
@@ -130,9 +132,6 @@ 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(),
|
||||||
@@ -149,7 +148,7 @@ mod tests {
|
|||||||
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
||||||
fn idle_app() -> App {
|
fn idle_app() -> App {
|
||||||
motion::reset();
|
motion::reset();
|
||||||
motion::configure_for_test(MotionScale::STANDARD, None);
|
motion::configure(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());
|
||||||
@@ -239,7 +238,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn without_a_window_nothing_animates() {
|
fn without_a_window_nothing_animates() {
|
||||||
motion::reset();
|
motion::reset();
|
||||||
motion::configure_for_test(MotionScale::STANDARD, None);
|
motion::configure(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);
|
||||||
@@ -254,7 +253,8 @@ 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_for_test(MotionScale::NONE, None);
|
motion::configure(MotionScale::NONE, None);
|
||||||
|
|
||||||
// 三种状态变化:换页 / 换详情 / 再换回来
|
// 三种状态变化:换页 / 换详情 / 再换回来
|
||||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||||
@@ -278,9 +278,11 @@ mod tests {
|
|||||||
motion::reset();
|
motion::reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔(不会「动画与 Busy 各订一条」)。
|
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
|
||||||
/// 口径是**全仓 `src/**`**,只看代码行;唯一允许的第二个文件是 `pages/harness.rs`
|
///
|
||||||
/// (`pages_demo` 这条独立 example 自己的 app,不进产品路径)。
|
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
|
||||||
|
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `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");
|
||||||
@@ -339,57 +341,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。
|
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
|
||||||
/// 口径:`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 pages_cannot_reach_for_a_duration() {
|
fn slots_are_the_three_documented_ones() {
|
||||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
assert_eq!(Slot::ALL.len(), 3);
|
||||||
.join("src")
|
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
|
||||||
.join("pages");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
collect_rust_files(&dir, &mut files);
|
|
||||||
assert!(
|
|
||||||
files.len() >= 20,
|
|
||||||
"只扫到 {} 个页面文件:扫描逻辑坏了",
|
|
||||||
files.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
const FORBIDDEN: [&str; 5] = [
|
|
||||||
"DurationToken",
|
|
||||||
"page_spec",
|
|
||||||
"modal_spec",
|
|
||||||
"FRAME_MS",
|
|
||||||
"advance(",
|
|
||||||
];
|
|
||||||
let mut hits = Vec::new();
|
|
||||||
for path in files {
|
|
||||||
let text = std::fs::read_to_string(&path).expect("读源文件");
|
|
||||||
// 只看产品代码(测试与文档注释不算:`#[cfg(test)]` 之后整段跳过)。
|
|
||||||
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
|
|
||||||
let code = product
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim_start().starts_with("//"));
|
|
||||||
for symbol in FORBIDDEN {
|
|
||||||
if code.clone().any(|line| line.contains(symbol)) {
|
|
||||||
let name = path
|
|
||||||
.strip_prefix(&dir)
|
|
||||||
.unwrap_or(&path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
|
||||||
hits.push((name, symbol));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
hits.is_empty(),
|
|
||||||
"页面层不许直接碰时长/时间轴(用哪一档是 `transition.rs` 的表决定的):{hits:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,33 +7,10 @@
|
|||||||
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:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
|
||||||
@@ -43,10 +20,7 @@ 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)) {
|
||||||
// M256:走统一出口(逻辑当拍关,画面多留 150ms 退场)。
|
app.close_dialog();
|
||||||
// 目标已经在上一行核销 ⇒ 退场期间再点一次主按钮不会删第二个世界
|
|
||||||
// (那一拍的消息还会被 `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
|
||||||
@@ -57,11 +31,12 @@ 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 {
|
||||||
close(app);
|
app.close_dialog();
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
let done = export::export(&card.report, app.lang());
|
let done = export::export(&card.report, app.lang());
|
||||||
@@ -89,9 +64,10 @@ 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);
|
||||||
}
|
}
|
||||||
// M256:统一出口(有动效时先退场)。「立即重画」这条要求由 `close` 里的
|
app.close_dialog();
|
||||||
// `refresh_view` 满足:退场的第一帧必须在这一拍画出来。
|
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||||
close(app);
|
// (r1 复审 P3-1)。
|
||||||
|
app.refresh_view();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
DialogMessage::Toggle => {
|
DialogMessage::Toggle => {
|
||||||
@@ -145,12 +121,6 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -159,8 +129,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);
|
||||||
// M256:统一出口(Esc 也走退场动画,而不是硬跳)。
|
app.close_dialog();
|
||||||
close(app);
|
app.refresh_view();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,27 @@
|
|||||||
//!
|
//!
|
||||||
//! ## 为什么这条不能在 `view` 里
|
//! ## 为什么这条不能在 `view` 里
|
||||||
//!
|
//!
|
||||||
//! iced 0.14 的顺序:`update()`(**订阅表在这里求值**,iced_winit `src/lib.rs:1338`)→
|
//! iced 0.14 的处理顺序是:
|
||||||
//! `build_user_interfaces()`(这时才调 `view`,`src/lib.rs:1233`)⇒ **订阅表的求值早于视图**。
|
//!
|
||||||
//! 第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,`frames()` 要等下一条消息才打开,而那一帧
|
//! ```text
|
||||||
//! 的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,用户看到的是一次 12/8px 的偏移闪跳(截图通道
|
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
|
||||||
//! 因为 `has_screenshot()` 恒真,**结构上**不可能暴露这条)。所以起跑搬到这里。
|
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
|
||||||
|
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
|
||||||
|
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
|
||||||
|
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
|
||||||
|
//! **结构上**不可能暴露这条。
|
||||||
|
//!
|
||||||
|
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
|
||||||
|
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
|
||||||
//!
|
//!
|
||||||
//! ## 标识从哪来
|
//! ## 标识从哪来
|
||||||
//!
|
//!
|
||||||
//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」,
|
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
|
||||||
//! `nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,不看屏键大小——见 [`nav_rank`])。
|
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
|
||||||
|
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
|
||||||
|
|
||||||
use unlucky_presenter::router::Route;
|
use unlucky_presenter::router::Route;
|
||||||
|
|
||||||
@@ -19,24 +30,12 @@ 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()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
|
||||||
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
|
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
|
||||||
@@ -50,16 +49,7 @@ impl App {
|
|||||||
Route::Detail(id) => Some(id.as_str()),
|
Route::Detail(id) => Some(id.as_str()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
motion::observe(screen, nav_rank(route), detail);
|
motion::observe(screen, 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`])。
|
||||||
@@ -143,256 +133,34 @@ 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;
|
|
||||||
|
|
||||||
const FAMILIES: Families = Families {
|
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
||||||
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 the_rail_order_and_the_screen_table_disagree_on_purpose() {
|
fn only_the_five_screens_get_a_key() {
|
||||||
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
|
let keys: Vec<Option<u64>> = [
|
||||||
|
Route::Instances,
|
||||||
|
Route::Download,
|
||||||
|
Route::Content,
|
||||||
|
Route::Settings,
|
||||||
|
Route::Logs,
|
||||||
|
Route::Detail("survival".to_string()),
|
||||||
|
Route::Accounts,
|
||||||
|
Route::Agent,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(screen_key)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keyed(&Route::Detail("survival".to_string())),
|
keys[5],
|
||||||
(Some(1), Some(0)),
|
|
||||||
"详情归「实例」"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
keyed(&Route::Accounts),
|
|
||||||
(None, None),
|
|
||||||
"账户不归 pages 画、也不在轨上"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
keyed(&Route::Agent),
|
|
||||||
(None, Some(4)),
|
|
||||||
"AI 不归 pages 画,但在轨最下面"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
nav_rank(&Route::Content),
|
|
||||||
Some(1),
|
Some(1),
|
||||||
"内容库与 Download 共用导航项"
|
"详情页归「实例」那一屏(与 Screen::of 同源)"
|
||||||
);
|
);
|
||||||
|
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
||||||
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
|
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,11 +142,9 @@ impl App {
|
|||||||
APP_TITLE,
|
APP_TITLE,
|
||||||
background,
|
background,
|
||||||
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
|
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
|
||||||
pages::page_with_settings_transition(
|
pages::page_with_settings(
|
||||||
&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,
|
||||||
|
|||||||
@@ -1,69 +1,65 @@
|
|||||||
//! **动效帧泵**(M192 建,M256 接上页面方向与模态):design 的 motion 令牌 → iced 的**有界**帧订阅。
|
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。
|
||||||
//!
|
//!
|
||||||
//! ## 这个模块解决什么
|
//! ## 这个模块解决什么
|
||||||
//!
|
//!
|
||||||
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
|
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
|
||||||
//! `MotionScale::NONE` 降级),但适配层长期只有一个消费点。本模块是那条中间层:
|
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的
|
||||||
//! **谁在动**(页面轨道 / 模态轨道)、**动多久朝哪动**([`pump`] 与 `transition` 的表)、
|
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧**
|
||||||
//! **什么时候该订阅帧**([`needs_frames`])。
|
//! ([`needs_frames`])。
|
||||||
//!
|
//!
|
||||||
//! ## 有界泵的契约(五条,都有守卫)
|
//! ## 有界泵的契约(四条,都有守卫)
|
||||||
//!
|
//!
|
||||||
//! | 契约 | 为什么 | 守在哪 |
|
//! | 契约 | 为什么 | 守在哪 |
|
||||||
//! |---|---|---|
|
//! |---|---|---|
|
||||||
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump/tests.rs`、`app/subscription.rs` |
|
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` |
|
||||||
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | `pump/tests.rs` |
|
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) |
|
||||||
//! | **模态完全展开后不占帧** | 对话框开着可能几分钟,动画只有开/关那两段 | `pump/tests.rs::an_open_modal_stops_requesting_frames` |
|
//! | **时间轴推进**不密于 [`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::FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump/tests.rs` |
|
//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` |
|
||||||
//! | 起跑**必须发生在 `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_page` 在 `is_off()` 时**不插入轨道**,
|
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是
|
||||||
//! [`frame`] 于是返回 [`PageFrame::STILL`](位移**严格** `(0,0)`、不画旧页),[`modal`] 要么
|
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见
|
||||||
//! 不画要么 `progress = 1.0`(缩放严格 1.0),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。
|
//! `pump::tests::none_scale_never_starts_a_track`。
|
||||||
//!
|
|
||||||
//! ## 系统「减少动效」(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`,所以一个线程局部就够,且**测试天然隔离**。
|
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在
|
||||||
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs`)。
|
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程,
|
||||||
|
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
|
||||||
//!
|
//!
|
||||||
//! ## 位移走 `padding`
|
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了
|
||||||
|
//! `#[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;
|
|
||||||
|
|
||||||
// `Pump` / `MAX_STEP_MS` 只被测试与文档引用(产品路径拿不到时长与时间轴),显式放过:
|
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动),
|
||||||
// 它们是对外口径的一部分(`FRAME_MS` 被 `app/subscription.rs` 的守卫用)。
|
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分
|
||||||
|
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use pump::{Pump, FRAME_MS, MAX_STEP_MS};
|
pub use pump::{offset, spec, Slot, Spec, FRAME_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;
|
||||||
@@ -73,10 +69,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 { … }` 的初始化也报
|
||||||
// (换过三种写法都照报,属于误报;仓内 `pages/appearance/probe.rs` 等两处已有同样的 `allow`)。
|
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等
|
||||||
|
// 两处已有同样的 `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)]
|
||||||
@@ -84,49 +80,13 @@ 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())
|
||||||
@@ -145,66 +105,27 @@ pub fn needs_frames() -> bool {
|
|||||||
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
||||||
///
|
///
|
||||||
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
|
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
|
||||||
/// `rank` 是这一屏在**导航轨**(`shell::nav::NavItem::ALL`)里的次序——**左右方向只看它**,
|
/// `detail` 是详情页的实例 id。
|
||||||
/// 不看屏键大小(两处顺序不同:`Screen::ALL` 里 Settings 在 Logs 前,导航轨里反过来),
|
///
|
||||||
/// `detail` 是详情页的实例 id。方向与深度由 [`pump::Pump::observe`] 判:
|
/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1)
|
||||||
/// **同级换页走水平、进/出深层走纵向,旧页反向退场**。
|
///
|
||||||
|
/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces`
|
||||||
|
/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插,
|
||||||
|
/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开,
|
||||||
|
/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。
|
||||||
///
|
///
|
||||||
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
|
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
|
||||||
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
|
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
|
||||||
pub fn observe(screen: Option<u64>, rank: Option<u8>, detail: Option<&str>) {
|
pub fn observe(screen: Option<u64>, detail: Option<&str>) {
|
||||||
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, rank, detail));
|
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, 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 frame() -> PageFrame {
|
pub fn sample() -> (f32, f32) {
|
||||||
PUMP.with(|pump| pump.borrow().page_frame())
|
PUMP.with(|pump| pump.borrow().offsets())
|
||||||
}
|
|
||||||
|
|
||||||
/// 弹窗的**逻辑**开关(每帧 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` 每帧调一次)。
|
||||||
@@ -221,7 +142,7 @@ pub fn advance() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把位移套到树上。
|
/// 把入场位移套到树上。
|
||||||
///
|
///
|
||||||
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
|
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
|
||||||
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
|
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
|
||||||
@@ -247,8 +168,6 @@ 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` 读真实时钟,单元测试不能等它)。
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
|
||||||
|
//!
|
||||||
|
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
|
||||||
|
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
|
||||||
|
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
|
||||||
|
//!
|
||||||
|
//! ## 三个概念
|
||||||
|
//!
|
||||||
|
//! | 名字 | 是什么 | 谁定 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 |
|
||||||
|
//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) |
|
||||||
|
//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 |
|
||||||
|
//!
|
||||||
|
//! ## 为什么规格表在代码里而不是页面里
|
||||||
|
//!
|
||||||
|
//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种
|
||||||
|
//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
|
||||||
|
use unlucky_design::spacing::Space;
|
||||||
|
|
||||||
|
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
|
||||||
|
///
|
||||||
|
/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看:
|
||||||
|
///
|
||||||
|
/// | 什么 | 频率 | 由谁决定 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 |
|
||||||
|
/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) |
|
||||||
|
///
|
||||||
|
/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。
|
||||||
|
/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排**
|
||||||
|
/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。
|
||||||
|
pub const FRAME_MS: f64 = 16.0;
|
||||||
|
|
||||||
|
/// 单次推进的**最大步进**(4 帧 = 64 ms)。
|
||||||
|
///
|
||||||
|
/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果
|
||||||
|
/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完
|
||||||
|
/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道:
|
||||||
|
/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。
|
||||||
|
pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0;
|
||||||
|
|
||||||
|
/// 一个动效槽。
|
||||||
|
///
|
||||||
|
/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加
|
||||||
|
/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对:
|
||||||
|
/// 新的一次内容变化取代上一次。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum Slot {
|
||||||
|
/// 首屏进入:进程内只在第一帧跑一次。
|
||||||
|
Boot,
|
||||||
|
/// 换页:路由换了(导航轨点选、返回、启动直达)。
|
||||||
|
Screen,
|
||||||
|
/// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。
|
||||||
|
Detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slot {
|
||||||
|
/// 全部槽(遍历顺序稳定,写日志/断言用)。
|
||||||
|
pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail];
|
||||||
|
|
||||||
|
/// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Slot::Boot => "boot",
|
||||||
|
Slot::Screen => "screen",
|
||||||
|
Slot::Detail => "detail",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Spec {
|
||||||
|
/// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。
|
||||||
|
pub duration: DurationToken,
|
||||||
|
/// 缓动曲线。
|
||||||
|
pub easing: Easing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 槽 → 规格(**唯一**一张表)。
|
||||||
|
///
|
||||||
|
/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/
|
||||||
|
/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**:
|
||||||
|
///
|
||||||
|
/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为
|
||||||
|
/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的;
|
||||||
|
/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完
|
||||||
|
/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。
|
||||||
|
pub const fn spec(slot: Slot) -> Spec {
|
||||||
|
match slot {
|
||||||
|
Slot::Boot => Spec {
|
||||||
|
duration: DurationToken::Medium4,
|
||||||
|
easing: Easing::Standard,
|
||||||
|
},
|
||||||
|
Slot::Screen => Spec {
|
||||||
|
duration: DurationToken::Medium2,
|
||||||
|
easing: Easing::Standard,
|
||||||
|
},
|
||||||
|
Slot::Detail => Spec {
|
||||||
|
duration: DurationToken::Medium3,
|
||||||
|
easing: Easing::Standard,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。
|
||||||
|
///
|
||||||
|
/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子,
|
||||||
|
/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。
|
||||||
|
pub fn offset(slot: Slot) -> (f32, f32) {
|
||||||
|
match slot {
|
||||||
|
Slot::Boot => (0.0, Space::X4.dp()),
|
||||||
|
Slot::Screen => (0.0, Space::X3.dp()),
|
||||||
|
Slot::Detail => (Space::X2.dp(), 0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条正在跑的轨道。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Track {
|
||||||
|
/// 起跑时刻(动画时间轴的坐标,毫秒)。
|
||||||
|
start_ms: f64,
|
||||||
|
spec: Spec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帧泵:一条时间轴 + 每槽一条轨道。
|
||||||
|
///
|
||||||
|
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
|
||||||
|
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Pump {
|
||||||
|
scale: MotionScale,
|
||||||
|
/// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。
|
||||||
|
pin: Option<f64>,
|
||||||
|
now_ms: f64,
|
||||||
|
pending_ms: f64,
|
||||||
|
tracks: BTreeMap<Slot, Track>,
|
||||||
|
screen: Option<u64>,
|
||||||
|
detail: Option<String>,
|
||||||
|
painted: bool,
|
||||||
|
samples: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pump {
|
||||||
|
/// 出厂态:关着、没跑过、没有轨道。
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
scale: MotionScale::NONE,
|
||||||
|
pin: None,
|
||||||
|
now_ms: 0.0,
|
||||||
|
pending_ms: 0.0,
|
||||||
|
tracks: BTreeMap::new(),
|
||||||
|
screen: None,
|
||||||
|
detail: None,
|
||||||
|
painted: false,
|
||||||
|
samples: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定档(启动一拍调一次)。
|
||||||
|
///
|
||||||
|
/// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于
|
||||||
|
/// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次
|
||||||
|
/// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。
|
||||||
|
pub fn configure(&mut self, scale: MotionScale, pin: Option<f64>) {
|
||||||
|
self.scale = scale;
|
||||||
|
self.pin = pin;
|
||||||
|
self.painted = false;
|
||||||
|
self.screen = None;
|
||||||
|
self.detail = None;
|
||||||
|
self.pending_ms = 0.0;
|
||||||
|
if scale.is_off() {
|
||||||
|
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
||||||
|
self.tracks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前档位。
|
||||||
|
pub fn scale(&self) -> MotionScale {
|
||||||
|
self.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。
|
||||||
|
pub fn samples(&self) -> u64 {
|
||||||
|
self.samples
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间轴(毫秒)。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn now_ms(&self) -> f64 {
|
||||||
|
self.now_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 现在有没有动画在跑(= 要不要订阅帧)。
|
||||||
|
pub fn is_animating(&self) -> bool {
|
||||||
|
!self.tracks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。
|
||||||
|
///
|
||||||
|
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
|
||||||
|
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
|
||||||
|
pub fn play(&mut self, slot: Slot) -> bool {
|
||||||
|
self.tracks.clear();
|
||||||
|
if self.scale.is_off() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」,
|
||||||
|
// 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。
|
||||||
|
let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms };
|
||||||
|
self.tracks.insert(
|
||||||
|
slot,
|
||||||
|
Track {
|
||||||
|
start_ms,
|
||||||
|
spec: spec(slot),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。
|
||||||
|
pub fn progress(&self, slot: Slot) -> f32 {
|
||||||
|
let Some(track) = self.tracks.get(&slot) else {
|
||||||
|
return 1.0;
|
||||||
|
};
|
||||||
|
let duration = f64::from(self.scale.ms(track.spec.duration));
|
||||||
|
if duration <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let elapsed = self.elapsed_ms() - track.start_ms;
|
||||||
|
let t = (elapsed / duration).clamp(0.0, 1.0) as f32;
|
||||||
|
track.spec.easing.sample(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前动画时刻(钉住相位时恒为钉住值)。
|
||||||
|
fn elapsed_ms(&self) -> f64 {
|
||||||
|
self.pin.unwrap_or(self.now_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。
|
||||||
|
///
|
||||||
|
/// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释)
|
||||||
|
///
|
||||||
|
/// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→
|
||||||
|
/// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`,
|
||||||
|
/// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表
|
||||||
|
/// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里
|
||||||
|
/// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。
|
||||||
|
///
|
||||||
|
/// 三条判据(顺序即优先级):
|
||||||
|
/// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`];
|
||||||
|
/// 2. 屏变了 → [`Slot::Screen`];
|
||||||
|
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
|
||||||
|
///
|
||||||
|
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**——
|
||||||
|
/// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回
|
||||||
|
/// `Some`,仍然会跑换页那一档。
|
||||||
|
///
|
||||||
|
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
|
||||||
|
pub fn observe(&mut self, screen: Option<u64>, detail: Option<&str>) -> bool {
|
||||||
|
let mut started = false;
|
||||||
|
if !self.painted {
|
||||||
|
self.painted = true;
|
||||||
|
if screen.is_some() {
|
||||||
|
started = self.play(Slot::Boot);
|
||||||
|
}
|
||||||
|
} else if self.screen != screen {
|
||||||
|
if screen.is_some() {
|
||||||
|
started = self.play(Slot::Screen);
|
||||||
|
}
|
||||||
|
} else if self.detail.as_deref() != detail && screen.is_some() {
|
||||||
|
started = self.play(Slot::Detail);
|
||||||
|
}
|
||||||
|
self.screen = screen;
|
||||||
|
if self.detail.as_deref() != detail {
|
||||||
|
self.detail = detail.map(str::to_owned);
|
||||||
|
}
|
||||||
|
started
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。
|
||||||
|
///
|
||||||
|
/// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0`
|
||||||
|
/// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。
|
||||||
|
pub fn offsets(&self) -> (f32, f32) {
|
||||||
|
let mut dx = 0.0_f32;
|
||||||
|
let mut dy = 0.0_f32;
|
||||||
|
for slot in Slot::ALL {
|
||||||
|
let remaining = 1.0 - self.progress(slot);
|
||||||
|
let (ox, oy) = offset(slot);
|
||||||
|
dx += ox * remaining;
|
||||||
|
dy += oy * remaining;
|
||||||
|
}
|
||||||
|
(dx.round(), dy.round())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
|
||||||
|
///
|
||||||
|
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
|
||||||
|
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
|
||||||
|
/// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表
|
||||||
|
/// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。
|
||||||
|
pub fn advance(&mut self, dt_ms: f64) -> bool {
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
self.pending_ms = 0.0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if dt_ms.is_finite() && dt_ms > 0.0 {
|
||||||
|
// ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。
|
||||||
|
self.pending_ms += dt_ms.min(MAX_STEP_MS);
|
||||||
|
}
|
||||||
|
if self.pending_ms < FRAME_MS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let step = std::mem::take(&mut self.pending_ms);
|
||||||
|
if self.pin.is_none() {
|
||||||
|
self.now_ms += step;
|
||||||
|
}
|
||||||
|
self.samples += 1;
|
||||||
|
|
||||||
|
let now = self.elapsed_ms();
|
||||||
|
let finished: Vec<Slot> = self
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, track)| {
|
||||||
|
let duration = f64::from(self.scale.ms(track.spec.duration));
|
||||||
|
duration <= 0.0 || now - track.start_ms >= duration
|
||||||
|
})
|
||||||
|
.map(|(slot, _)| *slot)
|
||||||
|
.collect();
|
||||||
|
for slot in finished {
|
||||||
|
self.tracks.remove(&slot);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Pump {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。
|
|
||||||
//!
|
|
||||||
//! 这一层不认识 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
//! 模态状态机与采样(`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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
|
||||||
|
//!
|
||||||
|
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。
|
||||||
|
|
||||||
|
use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
|
||||||
|
fn standard() -> Pump {
|
||||||
|
let mut pump = Pump::new();
|
||||||
|
pump.configure(MotionScale::STANDARD, None);
|
||||||
|
pump
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。
|
||||||
|
fn settle(pump: &mut Pump) -> u64 {
|
||||||
|
let mut frames = 0;
|
||||||
|
for _ in 0..1250 {
|
||||||
|
if !pump.is_animating() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if pump.advance(FRAME_MS) {
|
||||||
|
frames += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 令牌表
|
||||||
|
|
||||||
|
/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。
|
||||||
|
#[test]
|
||||||
|
fn every_slot_spec_is_a_registered_token_pair() {
|
||||||
|
for slot in Slot::ALL {
|
||||||
|
let spec = spec(slot);
|
||||||
|
let ms = spec.duration.ms();
|
||||||
|
assert!(
|
||||||
|
(250..=500).contains(&ms),
|
||||||
|
"{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)",
|
||||||
|
slot.name(),
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
// 缓动必须是真的曲线(端点 = 0/1)
|
||||||
|
assert!(spec.easing.sample(0.0).abs() < 1.0e-6);
|
||||||
|
assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
|
||||||
|
// 位移必须来自间距栅格(4 的倍数),且不为零
|
||||||
|
let (dx, dy) = offset(slot);
|
||||||
|
assert!(
|
||||||
|
dx > 0.0 || dy > 0.0,
|
||||||
|
"{}: 位移为 0 就没有入场感",
|
||||||
|
slot.name()
|
||||||
|
);
|
||||||
|
assert_eq!(dx % 4.0, 0.0);
|
||||||
|
assert_eq!(dy % 4.0, 0.0);
|
||||||
|
assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name());
|
||||||
|
}
|
||||||
|
// 表本身钉住:谁改了这三档,报告与出图都要跟着改
|
||||||
|
assert_eq!(spec(Slot::Screen).duration.ms(), 300);
|
||||||
|
assert_eq!(spec(Slot::Detail).duration.ms(), 350);
|
||||||
|
assert_eq!(spec(Slot::Boot).duration.ms(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 有界泵
|
||||||
|
|
||||||
|
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把
|
||||||
|
/// `is_animating` 改成恒真。两条都让本用例红。
|
||||||
|
#[test]
|
||||||
|
fn a_finished_animation_stops_asking_for_frames() {
|
||||||
|
let mut pump = standard();
|
||||||
|
assert!(pump.play(Slot::Screen), "起跑必须成功");
|
||||||
|
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||||
|
|
||||||
|
settle(&mut pump);
|
||||||
|
assert!(
|
||||||
|
!pump.is_animating(),
|
||||||
|
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
|
||||||
|
);
|
||||||
|
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位");
|
||||||
|
|
||||||
|
// 再推进也不该有任何采样(订阅表里已经没有帧了)
|
||||||
|
let before = pump.samples();
|
||||||
|
for _ in 0..60 {
|
||||||
|
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
|
||||||
|
}
|
||||||
|
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
|
||||||
|
///
|
||||||
|
/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。
|
||||||
|
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
|
||||||
|
#[test]
|
||||||
|
fn frames_are_capped_at_sixty_per_second() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.play(Slot::Screen);
|
||||||
|
|
||||||
|
// 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器)
|
||||||
|
let mut advanced = 0;
|
||||||
|
for _ in 0..200 {
|
||||||
|
if pump.advance(1.0) {
|
||||||
|
advanced += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
advanced <= 13,
|
||||||
|
"200 ms 内最多 13 次推进(200/16),实测 {advanced} 次"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
advanced >= 11,
|
||||||
|
"也别少得离谱(掉帧会看起来卡),实测 {advanced}"
|
||||||
|
);
|
||||||
|
assert_eq!(pump.samples(), advanced);
|
||||||
|
// 时间轴按真实时间走,不受「帧被合并」影响
|
||||||
|
assert!(
|
||||||
|
(pump.now_ms() - 192.0).abs() <= 16.0,
|
||||||
|
"时间轴应约 192 ms(12 × 16),实测 {}",
|
||||||
|
pump.now_ms()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
|
||||||
|
///
|
||||||
|
/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒
|
||||||
|
/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与
|
||||||
|
/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。
|
||||||
|
#[test]
|
||||||
|
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.play(Slot::Screen);
|
||||||
|
|
||||||
|
// 60 秒的「空闲间隔」当 dt
|
||||||
|
assert!(pump.advance(60_000.0));
|
||||||
|
assert!(
|
||||||
|
pump.is_animating(),
|
||||||
|
"一次超长 dt 不许把轨道当场删掉(那就等于没有动画)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pump.now_ms() <= MAX_STEP_MS + 1.0e-6,
|
||||||
|
"单次推进最多 {:?} ms,实测 {}",
|
||||||
|
MAX_STEP_MS,
|
||||||
|
pump.now_ms()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pump.progress(Slot::Screen) < 1.0,
|
||||||
|
"进度不许一步到位(那就是闪跳)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 正常帧间隔照样把动画跑完(没被抖动卡死)
|
||||||
|
settle(&mut pump);
|
||||||
|
assert!(!pump.is_animating());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。
|
||||||
|
#[test]
|
||||||
|
fn progress_is_monotone_and_lands_on_one() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.play(Slot::Detail);
|
||||||
|
let mut previous = 0.0;
|
||||||
|
for _ in 0..40 {
|
||||||
|
let value = pump.progress(Slot::Detail);
|
||||||
|
assert!(value >= previous, "进度倒退了:{previous} → {value}");
|
||||||
|
previous = value;
|
||||||
|
if !pump.advance(FRAME_MS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(previous, 1.0, "末帧必须到位");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ NONE 降级
|
||||||
|
|
||||||
|
/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里
|
||||||
|
/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。
|
||||||
|
#[test]
|
||||||
|
fn none_scale_never_starts_a_track() {
|
||||||
|
let mut pump = Pump::new(); // 出厂即 NONE
|
||||||
|
assert!(pump.scale().is_off());
|
||||||
|
|
||||||
|
// 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道
|
||||||
|
assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑");
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态");
|
||||||
|
assert!(!pump.is_animating());
|
||||||
|
assert!(!pump.observe(Some(2), None));
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
assert!(!pump.observe(Some(2), Some("instance-7")));
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
assert!(!pump.is_animating());
|
||||||
|
|
||||||
|
for _ in 0..120 {
|
||||||
|
assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的");
|
||||||
|
}
|
||||||
|
assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。
|
||||||
|
#[test]
|
||||||
|
fn switching_to_none_mid_flight_settles_immediately() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.play(Slot::Screen);
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
assert!(pump.is_animating());
|
||||||
|
|
||||||
|
pump.configure(MotionScale::NONE, None);
|
||||||
|
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
|
||||||
|
assert_eq!(pump.progress(Slot::Screen), 1.0);
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。
|
||||||
|
#[test]
|
||||||
|
fn half_scale_finishes_in_half_the_frames() {
|
||||||
|
let mut full = standard();
|
||||||
|
full.play(Slot::Screen);
|
||||||
|
let full_frames = settle(&mut full);
|
||||||
|
|
||||||
|
let mut half = Pump::new();
|
||||||
|
half.configure(MotionScale::HALF, None);
|
||||||
|
half.play(Slot::Screen);
|
||||||
|
let half_frames = settle(&mut half);
|
||||||
|
|
||||||
|
assert!(half_frames > 0 && full_frames > 0);
|
||||||
|
assert!(
|
||||||
|
half_frames * 2 <= full_frames + 2,
|
||||||
|
"半速档应约用一半帧数:full={full_frames} half={half_frames}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 触发判据
|
||||||
|
|
||||||
|
/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。
|
||||||
|
#[test]
|
||||||
|
fn identity_changes_pick_the_right_slot() {
|
||||||
|
let mut pump = standard();
|
||||||
|
|
||||||
|
// 首帧:Boot(位移是纵向 16)
|
||||||
|
assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽");
|
||||||
|
let (dx, dy) = pump.offsets();
|
||||||
|
assert_eq!(dx, 0.0);
|
||||||
|
assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移");
|
||||||
|
assert_eq!(
|
||||||
|
pump.progress(Slot::Screen),
|
||||||
|
1.0,
|
||||||
|
"首帧不该顺带起跑 Screen 槽"
|
||||||
|
);
|
||||||
|
settle(&mut pump);
|
||||||
|
assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑");
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
|
||||||
|
// 换屏:Screen(纵向)
|
||||||
|
assert!(pump.observe(Some(2), None));
|
||||||
|
let (dx, dy) = pump.offsets();
|
||||||
|
assert_eq!(dx, 0.0);
|
||||||
|
assert_eq!(dy, offset(Slot::Screen).1);
|
||||||
|
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
|
||||||
|
settle(&mut pump);
|
||||||
|
|
||||||
|
// 同屏换详情:Detail(横向)
|
||||||
|
assert!(pump.observe(Some(2), Some("instance-7")));
|
||||||
|
let (dx, dy) = pump.offsets();
|
||||||
|
assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场");
|
||||||
|
assert_eq!(dy, 0.0);
|
||||||
|
assert!(pump.progress(Slot::Detail) < 1.0);
|
||||||
|
settle(&mut pump);
|
||||||
|
assert!(!pump.observe(Some(2), Some("instance-7")));
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。
|
||||||
|
#[test]
|
||||||
|
fn screens_rendered_elsewhere_never_start_a_track() {
|
||||||
|
let mut pump = standard();
|
||||||
|
assert!(pump.observe(Some(1), None), "首屏开机跑 Boot");
|
||||||
|
settle(&mut pump);
|
||||||
|
|
||||||
|
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
|
||||||
|
assert!(!pump.is_animating());
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pump.observe(Some(1), None),
|
||||||
|
"从账户页回到某一屏仍然是换页,要跑"
|
||||||
|
);
|
||||||
|
assert_eq!(pump.offsets().1, offset(Slot::Screen).1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。
|
||||||
|
#[test]
|
||||||
|
fn a_new_transition_supersedes_the_previous_one() {
|
||||||
|
let mut pump = standard();
|
||||||
|
assert_eq!(
|
||||||
|
pump.offsets().1,
|
||||||
|
0.0,
|
||||||
|
"还没 observe 时没有位移(采样是纯函数)"
|
||||||
|
);
|
||||||
|
pump.observe(Some(1), None);
|
||||||
|
assert_eq!(pump.offsets().1, offset(Slot::Boot).1);
|
||||||
|
|
||||||
|
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
|
||||||
|
pump.observe(Some(2), None);
|
||||||
|
assert_eq!(
|
||||||
|
pump.offsets().1,
|
||||||
|
offset(Slot::Screen).1,
|
||||||
|
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
|
||||||
|
#[test]
|
||||||
|
fn repeated_paints_never_restart_the_animation() {
|
||||||
|
let mut pump = standard();
|
||||||
|
pump.observe(Some(3), Some("instance-9"));
|
||||||
|
let frames = settle(&mut pump);
|
||||||
|
assert!(frames > 0);
|
||||||
|
|
||||||
|
for _ in 0..30 {
|
||||||
|
assert!(!pump.observe(Some(3), Some("instance-9")));
|
||||||
|
assert_eq!(pump.offsets(), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
assert!(!pump.is_animating(), "重复绘制不该起新轨道");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 取证相位
|
||||||
|
|
||||||
|
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
|
||||||
|
#[test]
|
||||||
|
fn pinned_phase_freezes_the_clock() {
|
||||||
|
let mut pump = Pump::new();
|
||||||
|
pump.configure(MotionScale::STANDARD, Some(150.0));
|
||||||
|
pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半
|
||||||
|
|
||||||
|
for _ in 0..10 {
|
||||||
|
pump.advance(FRAME_MS);
|
||||||
|
}
|
||||||
|
let progress = pump.progress(Slot::Screen);
|
||||||
|
assert!(
|
||||||
|
(progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6,
|
||||||
|
"钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}"
|
||||||
|
);
|
||||||
|
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
|
||||||
|
}
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
//! 帧泵与过渡表的守卫(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);
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
//! 模态 / 系统偏好 / 有界泵的降级守卫(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();
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//! 有界泵的算术守卫(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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
//! 动效**规格表**与位移数学(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())
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
//! 缩放层(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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -179,59 +179,12 @@ 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`。
|
||||||
///
|
///
|
||||||
/// ## 为什么这条现在要在**自己的进程**里量(M262,ubuntu CI 真红)
|
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
|
||||||
///
|
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
|
||||||
/// `install::status()` 读的是**进程级**的静态槽:`install.rs:146` 的
|
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
|
||||||
/// `static STATE: OnceLock<Mutex<CjkInstall>>`。而同一个 test binary(`unlucky-app-iced --lib`)
|
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
|
||||||
/// 里还有别的用例会**写**它,路径如下:
|
|
||||||
///
|
|
||||||
/// ```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;
|
||||||
@@ -249,52 +202,13 @@ fn evaluation_starts_no_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,9 +58,11 @@
|
|||||||
//!
|
//!
|
||||||
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
|
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
|
||||||
//!
|
//!
|
||||||
//! 设置页的四条填页线各自定义消息与状态,本文件的 [`Message`] 只留一个 [`Message::Settings`]
|
//! 设置页的四条填页线各自在自己的文件里定义消息与状态,本文件的 [`Message`] 只留一个
|
||||||
//! (见 `pages/settings/mod.rs`)。`Message` / `Screen` 在 M101 r2 搬到 [`message`] / [`screen`],
|
//! [`Message::Settings`](详见 `pages/settings/mod.rs` 的「消息 / 状态」两节);`Message` 与
|
||||||
//! 这里的 `pub use` 保证 `pages::Message` / `Action` / `Screen` 三条路径一字未变。
|
//! `Screen` 本身也在 M101 r2 搬到 [`message`] / [`screen`](`pages/mod.rs` 合并后 397 行,只剩
|
||||||
|
//! 3 行余量),这里的 `pub use` 保证了 `pages::Message` / `pages::Action` / `pages::Screen`
|
||||||
|
//! 三条路径一字未变。
|
||||||
//!
|
//!
|
||||||
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
|
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
|
||||||
//!
|
//!
|
||||||
@@ -79,13 +81,16 @@
|
|||||||
//!
|
//!
|
||||||
//! ### 偏离 #7 的收口记录(M90 r2 / P1-1)
|
//! ### 偏离 #7 的收口记录(M90 r2 / P1-1)
|
||||||
//!
|
//!
|
||||||
//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有控件**的留白带,而五种窗口消息的构造点
|
//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有任何控件**的标题栏留白带。而
|
||||||
//! 只有 `shell/mod.rs`、窗口又是 `decorations: false` ⇒ 拖不动、缩不了、点不到关闭。
|
//! `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / `Close` 五种消息的**构造点只有
|
||||||
//! 修法:`shell::view_with_content` 把外壳(标题栏 + 三条热区 + 导航轨)与内容区拆开,产品页把
|
//! `shell/mod.rs`**,窗口又是 `decorations: false` ⇒ 交互模式下拖不动、缩不了、点不到关闭。
|
||||||
//! [`page`] 塞进内容槽位;`shell::view`(M68 骨架卡)留给 `Route::Agent` 回落(删了会让私有模块
|
//!
|
||||||
//! 里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红)。
|
//! 修法:`shell::view_with_content(ui, nav, title, content, forced)` 把外壳(标题栏 + 三条热区 +
|
||||||
//! `pages::app` 现在**只服务 harness**:`shell` 的回调类型是 `crate::app::Message`,套不进
|
//! 导航轨)与内容区拆开,产品页把 [`page`] 的树塞进内容槽位;`shell::view`(M68 骨架卡)保留给
|
||||||
//! `pages::Message` 的树里。
|
//! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红。
|
||||||
|
//!
|
||||||
|
//! `pages::app`(本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带,
|
||||||
|
//! 因为 `shell` 的回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
|
||||||
//!
|
//!
|
||||||
//! ## 截图通道
|
//! ## 截图通道
|
||||||
//!
|
//!
|
||||||
@@ -162,7 +167,6 @@ 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(),
|
||||||
@@ -191,7 +195,6 @@ 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(),
|
||||||
@@ -204,10 +207,12 @@ pub fn page_with_appearance<'a>(
|
|||||||
|
|
||||||
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
|
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
|
||||||
///
|
///
|
||||||
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层只借用
|
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层
|
||||||
/// 一帧;加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`)。`language` 是 M106 加的
|
/// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线
|
||||||
/// (「当前档位 + 生效语言」只有 `App` 知道,设置页 General 组要画它);harness 那三条入口仍用
|
/// 都不必来这里动一行。
|
||||||
/// [`Language::default`]。
|
/// `language` 是 M106 加的:「当前档位 + 生效语言」只有 `App` 知道(`App::language()`),而设置页
|
||||||
|
/// 的 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,
|
||||||
@@ -215,48 +220,10 @@ 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> {
|
||||||
page_with_settings_transition(ui, model, None, states, language, launch_form)
|
render(ui, model, 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>,
|
||||||
@@ -289,14 +256,19 @@ fn body<'a>(
|
|||||||
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
|
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
|
||||||
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
|
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
|
||||||
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
|
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
|
||||||
match update::banner(ui, &model.update, model.lang.catalog()) {
|
let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) {
|
||||||
Some(banner) => column![banner, page]
|
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 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
|
||||||
@@ -328,7 +300,6 @@ 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, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
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,30 +65,7 @@ fn the_three_keys_are_written_and_read_back() {
|
|||||||
|
|
||||||
/// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。
|
/// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。
|
||||||
///
|
///
|
||||||
/// ## 为什么不再写死 `D:/JDKs`(M262)
|
/// 变异证明:把 `prefs_store::effective` 里 `override_root` 那段删掉(当作永远没设)⇒ 后半段红。
|
||||||
///
|
|
||||||
/// `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");
|
||||||
@@ -110,66 +87,15 @@ 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: custom_root.display().to_string(),
|
install_root: "D:/JDKs".to_string(),
|
||||||
saved_install_root: custom_root.display().to_string(),
|
saved_install_root: "D:/JDKs".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!(
|
assert_eq!(effective.root, "D:/JDKs", "设了就按它来,不再拼数据根");
|
||||||
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` 留空 ⇒ 地区默认序;排过 ⇒ 顺序与来源层都变。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196 / M256):整窗透明度与窗口圆角的
|
//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196):整窗透明度与窗口圆角的**判据**、
|
||||||
//! **判据**、三处 `unsafe` 落点、系统语言真值源、系统「减少动效」偏好、以及数据根派生——每一件
|
//! 两处 `unsafe` 落点、系统语言真值源、以及数据根派生——每一件各归一处(判据不碰 FFI,
|
||||||
//! 各归一处(判据不碰 FFI,FFI 只住落点文件)。
|
//! FFI 只住落点文件)。
|
||||||
//!
|
//!
|
||||||
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
||||||
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
||||||
@@ -9,11 +9,8 @@
|
|||||||
//! - [`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 里会静默丢失,以及唯一的补救。两个落点的放行范围各自收在一个文件里,
|
||||||
//! - [`motion`] = M256:系统「减少动效」偏好的**三态**探针(Windows 走
|
//! `unsafe-scope` 门禁的期望集合就是这两个路径(集合相等,不是黑名单);
|
||||||
//! `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 档也允许
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
//! | [`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 要动手就得先迁移原生标题栏,
|
||||||
@@ -45,7 +41,6 @@ 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`:判据是用户策略 × 宿主平台 × 渲染档。
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
//! 系统「减少动效」偏好(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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.5" }
|
unlucky-core = { path = "../core", version = "0.2.4" }
|
||||||
# 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.5" }
|
unlucky-agent = { path = "../agent", version = "0.2.4" }
|
||||||
# `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
|
||||||
|
|||||||
@@ -60,10 +60,7 @@ 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 {
|
PinnedKey { key_id: "ull-release", public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122" },
|
||||||
key_id: "ull-release",
|
|
||||||
public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122",
|
|
||||||
},
|
|
||||||
// <<< PINNED-KEYS <<<
|
// <<< PINNED-KEYS <<<
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
//! 这里用**同一份** `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,
|
||||||
@@ -97,111 +96,22 @@ fn snapshot_catches_a_new_empty_directory() {
|
|||||||
|
|
||||||
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
|
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
|
||||||
///
|
///
|
||||||
/// ## 为什么不再「写个文件然后赌 mtime 变了」(M262)
|
/// 抓得住:目录节点只记路径不记 mtime(那样 `Node::Dir{mtime_ns}` 相同,第二条断言红)。
|
||||||
///
|
/// 抓不住:某些文件系统对 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`(与「空树」区分开)。
|
||||||
|
|||||||
@@ -144,11 +144,6 @@ 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("读目录")
|
||||||
|
|||||||
@@ -8,15 +8,6 @@
|
|||||||
//!
|
//!
|
||||||
//! **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;
|
||||||
|
|
||||||
@@ -28,17 +19,6 @@ 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 {
|
||||||
|
|||||||
@@ -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.5" }
|
unlucky-design = { path = "../design", version = "0.2.4" }
|
||||||
|
|
||||||
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
|
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
|
||||||
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
|
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
|
||||||
|
|||||||
@@ -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.5" }
|
unlucky-core = { path = "../core", version = "0.2.4" }
|
||||||
|
|
||||||
# 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.5" }
|
unlucky-palette-extract = { path = "../palette-extract", version = "0.2.4" }
|
||||||
|
|
||||||
[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", version = "0.2.5", features = ["update-test-key"] }
|
unlucky-core = { path = "../core", 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
|
||||||
@@ -7,16 +7,11 @@
|
|||||||
|
|
||||||
| 通道 | 位置 | 后端 |
|
| 通道 | 位置 | 后端 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| CI 冒烟截屏 | `.github/workflows/ci.yml` 的 `test` 作业「出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)」那一步 | `--software-renderer`(强制 CPU) |
|
| CI 冒烟截屏 | `.github/workflows/ci.yml:101` | `--software-renderer`(强制 CPU) |
|
||||||
| CI 交互冒烟 | 同上文件的 `runtime-smoke` 作业「在 Xvfb 里跑一帧并截图」、`runtime-smoke-macos` 作业「尝试截图」两步 | `--software-renderer` |
|
| CI 交互冒烟 | `.github/workflows/ci.yml:246`、`:283` | `--software-renderer` |
|
||||||
| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh` 里「2) 跑容器:构建 → xvfb 截图 → 像素判定」那一段的 `xvfb-run` | `--software-renderer` |
|
| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh:90` | `--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
@@ -13,7 +13,7 @@
|
|||||||
| 节 | 主题 | 承载文件 |
|
| 节 | 主题 | 承载文件 |
|
||||||
|----|------|---------|
|
|----|------|---------|
|
||||||
| §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` |
|
| §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` |
|
||||||
| §2 | 版本来源(唯一真相源);§2.1 里程碑快照**提交信息**怎么写(M268) | `packaging/docs/artifacts-and-version.md` |
|
| §2 | 版本来源(唯一真相源) | `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` |
|
||||||
|
|||||||
+17
-137
@@ -17,30 +17,12 @@ 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
|
||||||
|
|
||||||
@@ -127,89 +109,14 @@ 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
|
||||||
@@ -230,45 +137,19 @@ def self_test() -> int:
|
|||||||
|
|
||||||
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
|
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
|
||||||
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
|
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
|
||||||
with tempfile.TemporaryDirectory() as work:
|
fake = bytearray(0x200)
|
||||||
red = Path(work) / "red.exe"
|
fake[0:2] = b"MZ"
|
||||||
red.write_bytes(synthetic_pe("red", (0, 2, 3, 0), with_resource=False))
|
struct.pack_into("<I", fake, 0x3C, 0x40)
|
||||||
problems = check(red, "0.2.3")
|
fake[0x40:0x44] = PE_MAGIC
|
||||||
if not problems:
|
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2)
|
||||||
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
|
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
|
||||||
failures += 1
|
handle.write(bytes(fake))
|
||||||
|
name = handle.name
|
||||||
# 正控:同一套判据对「齐全」的合成 PE 必须绿,否则下面的「绿的时候能不能正常退出」无从验起。
|
problems = check(Path(name), "0.2.3")
|
||||||
green = Path(work) / "green.exe"
|
os.unlink(name)
|
||||||
green.write_bytes(synthetic_pe("green", (0, 2, 3, 0)))
|
if not problems:
|
||||||
problems = check(green, "0.2.3")
|
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
|
||||||
if problems:
|
failures += 1
|
||||||
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
|
||||||
@@ -278,7 +159,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:
|
||||||
@@ -304,5 +185,4 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
safe_streams()
|
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -51,50 +51,3 @@
|
|||||||
- `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 时人看一眼。没有任何门禁会因此变红——写一个能判「这段话是不是内部术语」的脚本不现实,别指望它存在。
|
|
||||||
@@ -63,8 +63,7 @@ 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` 只调**不含引号**的路径是好的)。当时的报错原文是
|
结果 `LIB`/`INCLUDE` 全空(M164 复审实测;`cmd //c` 只调**不含引号**的路径是好的)。故本步往 `$RUNNER_TEMP`
|
||||||
`'\"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` 在不在、猜缺就
|
||||||
@@ -138,138 +137,3 @@ 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_*`。
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
| 环节 | 在哪 | 今天的状态 |
|
| 环节 | 在哪 | 今天的状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一段(多行,fmt 保持的形态) |
|
| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 |
|
||||||
| 钥 ↔ 信任表对账 | `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,8 +60,7 @@ 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(4 行,含结尾逗号)贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动)
|
# ③ 把打印出来的那一行 PinnedKey { … } 贴进 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 是对的:它是信任锚,本来就该公开可审计
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # 期望
|
|||||||
|
|
||||||
| 步 | 成功长什么样 | 失败长什么样 |
|
| 步 | 成功长什么样 | 失败长什么样 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ① | 打印 ① 私钥 JSON、② 一段 4 行的 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 |
|
| ① | 打印 ① 私钥 JSON、② 一行 `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: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 |
|
||||||
@@ -105,7 +104,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)」,并**直接打印**
|
||||||
该贴的那一段 4 行 `PinnedKey`(只含公钥,安全)。照贴即可,注意连 `error:` 前缀一起删掉再贴。
|
该贴的那一行 `PinnedKey`(只含公钥,安全)。照贴即可。
|
||||||
3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒
|
3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒
|
||||||
他们能验签),但发不了新版。
|
他们能验签),但发不了新版。
|
||||||
4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**,
|
4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**,
|
||||||
@@ -176,10 +175,6 @@ 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 的 ② 显示「未判定」。
|
||||||
|
|
||||||
@@ -192,4 +187,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 向量) |
|
||||||
@@ -57,18 +57,15 @@ manifest.json(字节)──签名──> manifest.json.sig
|
|||||||
## 3. 一次性录入(只做一次)
|
## 3. 一次性录入(只做一次)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ① 生成:打印一段 Secret JSON + 一段可以直接贴进 trust.rs 的 PinnedKey(多行,4 行)
|
# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey
|
||||||
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 { … }` 那一段(4 行,含结尾逗号)**贴进 `crates/core/src/update/trust.rs` 的
|
* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `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 是对的:它是信任锚,本来就该公开可审计)。
|
||||||
|
|
||||||
### 私钥纪律(违反任何一条都算事故)
|
### 私钥纪律(违反任何一条都算事故)
|
||||||
@@ -100,7 +97,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` 那段(4 行)贴进标记之间 |
|
| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 |
|
||||||
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
|
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
|
||||||
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
|
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
|
||||||
|
|
||||||
|
|||||||
+16
-116
@@ -25,10 +25,7 @@ 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
|
||||||
@@ -36,11 +33,8 @@ 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 ——
|
||||||
@@ -91,66 +85,7 @@ 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)
|
||||||
@@ -162,9 +97,8 @@ 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("# 形状就是 `cargo fmt` 会保持的那一种 —— 贴完直接提交,不用再跑 fmt):")
|
print(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},')
|
||||||
print(render_pinned_key(key_id, pk_hex))
|
|
||||||
print(f"# 公钥十六进制:{pk_hex}")
|
print(f"# 公钥十六进制:{pk_hex}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -229,9 +163,8 @@ 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)
|
||||||
for line in render_pinned_key(key_id, pk_hex).splitlines():
|
print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', file=sys.stderr)
|
||||||
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:
|
||||||
@@ -302,63 +235,30 @@ 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:
|
||||||
fail(f"vector {index}: public key mismatch")
|
print(f"FAIL vector {index}: public key mismatch", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
if sign(seed, message).hex() != sig_hex:
|
if sign(seed, message).hex() != sig_hex:
|
||||||
fail(f"vector {index}: signature mismatch")
|
print(f"FAIL vector {index}: signature mismatch", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
if not verify(pk, message, bytes.fromhex(sig_hex)):
|
if not verify(pk, message, bytes.fromhex(sig_hex)):
|
||||||
fail(f"vector {index}: verify rejected")
|
print(f"FAIL vector {index}: verify rejected", file=sys.stderr)
|
||||||
|
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)):
|
||||||
fail(f"vector {index}: tampered message still verifies")
|
print(f"FAIL vector {index}: tampered message still verifies", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
# 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。
|
# 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。
|
||||||
sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}'
|
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)]:
|
||||||
fail("pinned_keys() 没解析出示例条目")
|
print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr)
|
||||||
# 多行形态(M261):`trust.rs` 里那一条是 `cargo fmt` 生成的,字段一行一个 —— 靠 `KID_RE` 的 `\s*`
|
failures += 1
|
||||||
# 跨行命中。若解析器退化成按行匹配,一次 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}") != []:
|
||||||
fail("pinned_keys() 在空表上应返回空")
|
print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr)
|
||||||
# 粘贴块形态(M265):`--generate-key` 与「信任表是空的」报错里给人贴的那段 `PinnedKey` 必须是
|
failures += 1
|
||||||
# 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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user