1 Commits
Author SHA1 Message Date
AskaEth d517f28140 Unlucky Launcher v0.2.1
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Single-commit source snapshot of the v0.2.1 release.
2026-11-06 12:35:56 +08:00
658 changed files with 5794 additions and 79639 deletions

No files matched your search

+1 -95
View File
@@ -132,59 +132,7 @@ jobs:
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。 # 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条 # 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。 # `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
# - run: cargo test --workspace --all-targets
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
# 事各自对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
# 注解是匿名侧唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
log="$RUNNER_TEMP/cargo-test.log"
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
# 不是 tee 的码)。
set +e
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "cargo exit=$code"
if [ "$code" -ne 0 ]; then
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
fi
exit "$code"
- name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows'
shell: pwsh
env:
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
ULL_XDEV_TEST_WAIVE: "1"
run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE
Write-Host "cargo exit=$code"
if ($code -ne 0) {
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
build: build:
name: build release (${{ matrix.os }}) name: build release (${{ matrix.os }})
@@ -207,18 +155,6 @@ jobs:
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档 # 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。 # (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
- run: cargo build --workspace --release - run: cargo build --workspace --release
# M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本——
# 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫
# 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。
- name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py \
--gui target/release/unlucky-launcher-iced.exe \
--cli target/release/ull.exe
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。 # 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里 # 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
@@ -304,33 +240,3 @@ jobs:
name: runtime-smoke-screenshot-macos name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/ path: ${{ runner.temp }}/smoke/
if-no-files-found: warn if-no-files-found: warn
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
#
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
# (r1 复审 P2-4)。
#
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
#
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
update-e2e:
name: update-e2e (windows x64)
runs-on: windows-latest
timeout-minutes: 45
env:
# 同 test 作业:跨卷 e2e 守卫在没有第二块卷的 runner 上需要显式豁免,否则会判红。
ULL_XDEV_TEST_WAIVE: "1"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: update-e2e
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
+17 -29
View File
@@ -1,16 +1,12 @@
name: Guards name: Guards
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): # 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 # 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层 # 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身 # 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) # 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 # 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处) # 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91)
# #
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件, # M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转: # **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
@@ -58,11 +54,7 @@ jobs:
run: | run: |
set -uo pipefail set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。 tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
exit 1
fi
echo "$tree" echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -117,9 +109,7 @@ jobs:
exit 1 exit 1
fi fi
echo "$tree" echo "$tree"
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头): hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
if [ -n "$hits" ]; then if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:" echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /' printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -179,22 +169,22 @@ jobs:
fi fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。 # D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件, # `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
# 判据是**集合相等**而不是「黑名单命中」: # 判据必须是**集合相等**而不是「黑名单命中」:
# - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); # - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope: unsafe-scope:
name: unsafe 落点守门(集合必须恰好等于期望) name: unsafe 落点守门(集合必须恰好一个文件)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合 - name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
run: | run: |
set -uo pipefail set -uo pipefail
EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs' EXPECTED='crates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。 # 扫描口径(M91 r3):一个小词法器,而不是正则。
# #
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
@@ -328,27 +318,25 @@ jobs:
} }
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开, # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。 # 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort || true) done | LC_ALL=C sort)
echo "期望(恰好这两处,集合相等):$EXPECTED" echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:" echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true) extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(不在期望集合里):" echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
printf '%s\n' "$extra" | sed 's/^/ - /' printf '%s\n' "$extra" | sed 's/^/ - /'
fi fi
if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):" echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
fi fi
echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。" echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
exit 1 exit 1
fi fi
echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合" echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
file-size: file-size:
name: 文件行数 ≤400 守门 name: 文件行数 ≤400 守门
-400
View File
@@ -1,400 +0,0 @@
name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。
on:
workflow_call:
inputs:
slot:
description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)"
type: string
required: true
os:
description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)"
type: string
required: true
build_dir:
description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)"
type: string
required: true
rustflags:
description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)"
type: string
required: false
default: ""
# (M149)微软登录用的 Azure 公共客户端 id(公共客户端 id 随二进制一起分发,不按高密级处理,但**不进 git
# 文件**:真值放仓库 Secret,用户在仓库设置里录入)。`required: false` 是刻意的——调用方没传(本地、
# 没有 runner 的镜像站、以及 Secret 尚未录入时)求值为空串,`client_id.rs` 把空白当「没配」,
# 于是非官方构建不烘 id,官方构建才烘。**调用方必须显式传**(`secrets: inherit` 或按名传);
# 没传时这里是空,不会报错。
secrets:
ULL_MSA_CLIENT_ID:
description: "Azure 公共客户端 id,烘进发布二进制(构建期 option_env!);留空 = 不烘"
required: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。
permissions:
contents: read
jobs:
build:
name: build (${{ inputs.slot }})
runs-on: ${{ inputs.os }}
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
# 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
# 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
# `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
# 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
# M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
# 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
# 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
if: inputs.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
[ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
# ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
Microsoft.VisualStudio.Component.Windows11SDK.26100 \
Microsoft.VisualStudio.Component.Windows11SDK.22621; do
hit=""
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
done
# ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
done
for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
[ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
for ver in "$root"/*/; do
libs=( "$ver"um/arm64/*.lib )
say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
done
done
# ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
hit=""
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
say notice "link.exe 候选 $p → ${hit:-未命中}"
done
echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
# (M149)`ULL_MSA_CLIENT_ID` 经 `option_env!` 烘进二进制(`crates/core/src/auth/microsoft/client_id.rs`):
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
if [ "$SLOT" = "windows-arm64" ]; then
rustup target add aarch64-pc-windows-msvc
rustup target list --installed
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ -x "$vswhere" ]; then
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
mkdir -p "$tmp"
vcvars=""
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
# 只赌一种就等于再写一个「静默退化成 warning」。
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
[ -n "$vcvars" ] && break
done
dump=""
if [ -n "$vcvars" ]; then
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
{ printf '@echo off\r\n'
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
fi
for key in LIB INCLUDE; do
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
if [ -n "$value" ]; then
export "$key=$value"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
echo "env $key=${value:0:90}…(${#value} 字符)"
else
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
fi
done
found=""
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
[ -n "$found" ] && break
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
[ -n "$hit" ] && found=${hit//\\//}
done
if [ -n "$found" ]; then
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
echo "linker: $found"
else
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
fi
else
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
fi
fi
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
# 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们
# 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。
if [ "$SLOT" = "windows-arm64" ]; then
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
fi
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
echo "expect-version=$version"
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: PE 子系统门禁(GUI=WINDOWS_GUI/2,CLI=CONSOLE/3;判据与理由见 packaging/README.md §15)
if: runner.os == 'Windows'
shell: bash
run: |
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py --gui "${{ inputs.build_dir }}/unlucky-launcher-iced.exe" --cli "${{ inputs.build_dir }}/ull.exe"
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明,
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
if: inputs.slot == 'linux-x64'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb xauth \
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
out="$RUNNER_TEMP/linux-smoke"
mkdir -p "$out/data"
log="$out/launcher.log"
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
set +e
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "launcher exit=$code"
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
ls -l "$out"
exit "$code"
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
if: always() && inputs.slot == 'linux-x64'
uses: actions/upload-artifact@v4
with:
name: linux-x64-first-frame
path: ${{ runner.temp }}/linux-smoke/
if-no-files-found: warn
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ inputs.slot }}
path: dist
if-no-files-found: error
retention-days: 14
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
- name: arm64 失败收尾注解(红不挡发布)
if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
shell: bash
env:
SLOT: ${{ inputs.slot }}
run: |
set -euo pipefail
if [ "$SLOT" = "linux-arm64" ]; then
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
else
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
fi
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
-94
View File
@@ -1,94 +0,0 @@
name: Release guard
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
#
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
#
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
#
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 /
# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
#
# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
# 该红的红、该绿的绿。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
release-semantics:
name: 发布标记语义守门(push tag → 稳定版)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 标记表自检(push tag ⇒ 稳定版)
run: python3 packaging/release_marker.py --self-test
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
run: python3 packaging/check_release_semantics.py
- name: 变异自测(守门抓不抓得住回归)
run: python3 packaging/check_release_semantics.py --self-mutation
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
run: python3 packaging/check_publish_slots.py --self-test
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
run: python3 packaging/check_publish_slots.py
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
run: python3 packaging/check_test_source_off.py --self-test
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
run: python3 packaging/check_test_source_off.py
# M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出
# 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本
# 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。
- name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析)
run: python3 packaging/sign_manifest.py --self-test
- name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红)
run: python3 packaging/check_pe_version.py --self-test
# M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01
# (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫
# 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow,
# 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。
# PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。
- name: workflow YAML 自检(注入 0x01 / 坏结构必须红)
run: python3 packaging/check_workflows_yaml.py --self-test
- name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符)
run: python3 packaging/check_workflows_yaml.py
# M190 r3:`update-test-key` 是**信任锚的开关**(把公开的测试公钥并进信任表)。它只许由
# `crates/presenter` 的 `[dev-dependencies]` 打开;进了发布构建就等于「谁都能签出官方更新」。
# 判据:core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内、
# 其余(workflow / packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红。
- name: 测试公钥守门自检(注入 workflow / dependencies / 根 Cargo.toml 三处变异)
run: python3 packaging/check_test_key_off.py --self-test
- name: 测试公钥不进发布构建(只许 presenter 的 dev-dependencies)
run: python3 packaging/check_test_key_off.py
-221
View File
@@ -1,221 +0,0 @@
name: Release / publish(可复用)
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
# `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的
# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。
on:
workflow_call:
secrets:
PUBLISH_TOKEN:
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
required: true
# (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方
# `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串——
# 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。
# `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed
# (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。
ULL_MANIFEST_SIGNING_KEY:
description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release"
required: false
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
permissions:
contents: write
jobs:
release:
name: GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
# 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
# `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
# 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
# **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧,
# 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。
# 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」,
# 不是静默丢弃(这条是任务里点名要验的反方向)。
# `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。
# **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS
# 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。
# 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml
# 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。
# macOS 两档仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64 linux-x64"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
shell: bash
run: |
set -euo pipefail
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
set -euo pipefail
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
keep=" "
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
for f in dist/*; do
base=${f##*/}
[ "$base" = "SHA256SUMS.txt" ] && continue
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
case "$keep" in
*" $platform_arch "*)
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
# 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
# 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
shell: bash
env:
TAG: ${{ github.ref_name }}
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
run: |
set -euo pipefail
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
python3 packaging/make_manifest.py --self-test
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
# 后者在 Release 正文里已有,两处各写一份迟早漂。
python3 packaging/make_manifest.py \
--dist dist \
--version "$TAG" \
--channel stable \
--asset-base-url "$RELEASE_BASE/$TAG" \
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量,
# 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release:
# ① `--self-test`:RFC 8032 向量(签名脚本自己没坏)
# ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住
# (签出一份客户端不认的清单,比不发版更糟)
# ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig`
# 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。
- name: 签更新清单(Ed25519;签不上就阻断发布)
shell: bash
env:
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
set +x
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi
"$PY" packaging/sign_manifest.py --self-test
if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then
echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。"
echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs"
exit 1
fi
"$PY" packaging/sign_manifest.py --check-pinned-keyring
"$PY" packaging/sign_manifest.py --sign dist/manifest.json
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
ls -l dist/manifest.json dist/manifest.json.sig
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ github.event.inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
# 三种触发各自创建什么(那张表是唯一真相源):
# push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
# dispatch prerelease=true → create / edit 都带预发布标记
# dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
# 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
# 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
# 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
# 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
# 进程替换里的失败会被 `set -e` 吞掉。
marks=/tmp/release-marks.sh
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
source "$marks"
echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource`
# 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
+323 -78
View File
@@ -1,33 +1,13 @@
name: Release name: Release
# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` / # 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 +
# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带 # SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。
# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、 # **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试
# 不碰标记)。 # 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M184 起是 `windows-x64` + `linux-x64`**。 # 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
# `linux-x64` 本轮翻牌的依据在 `packaging/docs/linux-evidence.md`:x86_64 槽位在 `ubuntu-22.04`(glibc 2.35,与本地
# WSLg 验证同一地板)真编出 release 产物、真开窗渲出一帧,`release-build.yml` 里还有一条同槽位的 xvfb 运行时门禁
# ——「声明为 ci 就必须真能跑出帧」,不是「矩阵里写了就算支持」。
# **`linux-arm64` 本轮只构建、不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟)。
# 解锁条件写在 `packaging/release-notes.md` 与 `packaging/docs/compliance-support-boundaries.md` §9 ——真机跑一帧之后
# 才把键加进 `PUBLISH_SLOTS`,不拿没跑过的包去发(M184 tower 裁决)。
# `windows-arm64` 仍留矩阵里取证、红了不挡 x64 发布(M142/M164:它那五轮红全红在一个误判的编译前体检步,该门禁已拆,
# 判定权交回编译器)。`macos-*` 两档从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走
# `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
# 名单 ⇄ 产物两个方向都有守卫(M164):`packaging/check_publish_slots.py`(PR 阶段在 release-guard.yml,发布侧在
# release-publish.yml 的 `--dist` 模式)——声明了缺产物 ⇒ 红;有产物没声明 ⇒ `::warning::` 明说未发布。
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与 # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
#
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
# cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on: on:
push: push:
@@ -36,17 +16,13 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
all_slots: all_slots:
description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)" description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows"
type: boolean type: boolean
default: false default: false
prerelease: prerelease:
# 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上—— description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
type: boolean type: boolean
default: false default: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -63,7 +39,7 @@ jobs:
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy` # M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。 # 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
plan: plan:
name: 选槽位(默认四个:Windows 两档 + Linux 两档) name: 选槽位(默认两个 Windows)
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.pick.outputs.matrix }} matrix: ${{ steps.pick.outputs.matrix }}
@@ -83,25 +59,16 @@ jobs:
SLOTS = [ SLOTS = [
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。 # Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"), dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是 # Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费;GitHub 文档的 Standard runners 也列
# **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`), # `windows-11-vs2026-arm`)。M140 前是在 windows-latest 上交叉,**CI 从未成功过一次**(v0.2.1 首跑红在
# 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉; # 工具链体检,M141/M142 在此修)。原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release", dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
rustflags="-C target-feature=+crt-static", mode="ci"), rustflags="-C target-feature=+crt-static", mode="ci"),
# ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。 # ↓ 四档**未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
# Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。 # (将来验证 Linux/macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
# 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里: # 同步 README §1/§9 与 release-notes.md。Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移。
# `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见 dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="probe"),
# `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机 dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="probe"),
# 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。
# **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"),
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"),
# ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
# (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
# 同步 README §1/§9 与 release-notes.md。
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。 # macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"), dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"),
dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"), dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
@@ -111,45 +78,323 @@ jobs:
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr) print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
PY PY
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
build: build:
name: build (${{ matrix.slot }}) name: build (${{ matrix.slot }})
needs: plan needs: plan
strategy: strategy:
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。 runs-on: ${{ matrix.os }}
permissions: steps:
contents: read - uses: actions/checkout@v4
uses: ./.github/workflows/release-build.yml
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static`
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e——
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出),
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。
- name: Windows ARM64 工具链体检(红不挡发布,只取证)
if: matrix.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
scan
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
v=""
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
v=""
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
scan
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
exit 1
fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
set -euo pipefail
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with: with:
slot: ${{ matrix.slot }} name: dist-${{ matrix.slot }}
os: ${{ matrix.os }} path: dist
build_dir: ${{ matrix.build_dir }} if-no-files-found: error
rustflags: ${{ matrix.rustflags }} retention-days: 14
secrets:
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。 # (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式, # 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白 - name: arm64 失败收尾注解(红不挡发布)
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。 if: failure() && matrix.slot == 'windows-arm64'
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }} shell: bash
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
release: release:
name: GitHub Release name: GitHub Release
needs: build needs: build
# `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里 # `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
# 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步: # 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag' if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
uses: ./.github/workflows/release-publish.yml env:
secrets: # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 # **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据),
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串, # **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。 # README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }} PUBLISH_SLOTS: "windows-x64"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
set -euo pipefail
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
keep=" "
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
for f in dist/*; do
base=${f##*/}
[ "$base" = "SHA256SUMS.txt" ] && continue
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
case "$keep" in
*" $platform_arch "*)
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。
for slot in $PUBLISH_SLOTS; do
stem=${slot//-/_}
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
echo "::error::发布白名单缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
Generated
+8 -170
View File
@@ -368,12 +368,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -666,12 +660,6 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.11.0" version = "0.11.0"
@@ -858,33 +846,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.1"
@@ -931,16 +892,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -1026,30 +977,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.42" version = "0.8.42"
@@ -1167,12 +1094,6 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@@ -3166,16 +3087,6 @@ dependencies = [
"futures-io", "futures-io",
] ]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -3421,15 +3332,6 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -4223,15 +4125,6 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4295,15 +4188,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -4504,16 +4388,6 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "sse-stream" name = "sse-stream"
version = "0.2.6" version = "0.2.6"
@@ -4864,21 +4738,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@@ -4909,12 +4768,6 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_writer"
version = "1.2.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -5129,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -5144,36 +4997,32 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
"log", "log",
"unlucky-cli", "unlucky-cli",
"unlucky-core",
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"unlucky-agent", "unlucky-agent",
"unlucky-core", "unlucky-core",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"ed25519-dalek",
"flate2", "flate2",
"futures-util", "futures-util",
"md-5", "md-5",
@@ -5191,15 +5040,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.3" version = "0.2.1"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.3" version = "0.2.1"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5207,9 +5056,8 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"ed25519-dalek",
"serde", "serde",
"serde_json", "serde_json",
"unlucky-core", "unlucky-core",
@@ -6119,16 +5967,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winresource"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+1 -8
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.3" version = "0.2.1"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
@@ -71,13 +71,6 @@ md-5 = "0.10"
# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 # 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。
zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] }
# M190(core::update::ed25519):清单签名**验签**用官方实现,不再自写曲线算术。
# M190 r1 复审的裁定:信任锚不许用自写密码学(非规范编码/小阶点/批量验证差异的后果是
# 「验签通过但本不该通过」)。BSD-3-Clause,在 deny.toml 白名单内。
# 只开 `std`(不用 `rand_core`/`batch`/`fast` 这类可选面):本仓的签名侧在
# `packaging/sign_manifest.py`,二进制里只有「验」这一半。
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
# M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`, # M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`,
# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 # 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。
flate2 = "1" flate2 = "1"
+42 -146
View File
@@ -4,8 +4,6 @@
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and > **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases> > release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.3`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no (Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
@@ -45,14 +43,6 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and - **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID. killed as trees, not as one PID.
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
and every field falls back on its own: instance → global setting → factory default, so an instance
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
the backend row only appears for game versions that have it. The CLI and the GUI read the same
resolved values (`crates/core/src/game/launch_overrides.rs`).
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals - **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the (exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
@@ -66,17 +56,8 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`). import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
- **Import conflicts are decided per file** — before anything is written, the scan lists every
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
that do not conflict are never rewritten, and the report still names every conflict afterwards.
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version, - **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*. loader and sort filters, paged results, and one-click *install into this instance*.
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
the target instance (project type, game version, loader); project details and version lists follow,
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
time. Being offline is its own state — "not connected" — never "no matching content".
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the - **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
library (packs go through the import pipeline above); CurseForge's *install* is disabled because library (packs go through the import pipeline above); CurseForge's *install* is disabled because
its file endpoint is not wired; and an item whose game version or loader does not match the target its file endpoint is not wired; and an item whose game version or loader does not match the target
@@ -102,17 +83,16 @@ Three tracks on one page:
- **Third-party community auth servers** (authlib-injector style). - **Third-party community auth servers** (authlib-injector style).
- **Local offline profiles**, for isolated testing. - **Local offline profiles**, for isolated testing.
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public **Microsoft sign-in needs an Azure public client id, and no release binary carries one.** The id is
client id they need (a public client id, not a credential — it ships inside the binary), so there is not in this repo and CI injects none, so set `ULL_MSA_CLIENT_ID=<id>` before launching, or build with
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the it (`ULL_MSA_CLIENT_ID=<id> cargo build`, which bakes it in via `option_env!`). Without one, sign-in
variable is read at startup, so restart the launcher after changing it. Building from source bakes in stops before any network call and the alert says exactly what is missing: the title "Microsoft
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then
anywhere stops before any network call, and its alert says exactly what is missing: the title restart the launcher — a running process does not pick up a new variable), and
"Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with `Code: msa_not_configured`. It offers no Retry button, because retrying in that same process cannot
it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that help. Every other sign-in failure keeps the older shape: one category line plus its own `Code:`
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus line, with the full reason only in the launcher log. Community auth servers and local profiles need
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local no id.
profiles need no id.
### Look and language ### Look and language
@@ -179,101 +159,41 @@ The dispatch rule is deliberately narrow, so nothing that used to work changed m
that its typo took effect. The subcommand table lives in exactly one place that its typo took effect. The subcommand table lives in exactly one place
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help` (`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`, lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `update`, `agent`, `version`, `help`. `diag`, `config`, `java`, `agent`, `version`, `help`.
### Updates, and the stable channel
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
`ull update check` / `ull update apply` both build the same `core::update::Job`
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
*renamed* but not overwritten, which is why the swap is a rename.
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
no artifact for this platform / the check itself failed (offline, broken manifest, channel
mismatch). A failed check is never reported as "up to date".
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
- **Nothing installs itself behind your back.** The check at startup can be turned off
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
### Where the logs are
Everything the launcher writes on its own side lives under `<data root>/logs/`. The settings page has
a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has
*Open folder* as well. The data root is `<exe folder>/.ULL` in portable mode, `<per-user app data>/ULL`
in standard mode, and `<custom root>/.ULL` in custom mode — the same value the settings page displays
as the launcher data directory.
| Path | What it is |
|---|---|
| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, **the named reason when a render backend fails to start**, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. |
| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. |
| `logs/game/<session-id>.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. |
| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. |
| `logs/install.log` | Install pipeline journal, one line per stage. |
| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. |
| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. |
The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`,
`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long
enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft
initialises, the game never gets that far, and the honest evidence is the session JSONL plus
`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is
hiding.
### Not done yet ### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy. This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer.** You download a file and run it — there is no setup step, and updates replace that - **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a
file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)). newer version for you, and there is no update source decided yet.
- **The update path has only been exercised against a local directory source.** The whole chain
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
The first observation on the live stable channel happens after `0.2.3` ships.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*, - **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. In the settings frame: *Save*, *Reset *Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save. edit them rather than on a Save.
- **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow - **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself `linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its been downloaded and run end to end. Treat them as untested output, not as "supported because the
theme/backend lines — and that same check is now a per-slot CI gate on every release matrix lists them".
(`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop - **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why
distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified. the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts
Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared
delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app. (`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch`
`linux-arm64` is built (native `ubuntu-22.04-arm`) but **not** published — no ARM64 machine has ever run it, so probe with `continue-on-error`, not a gate.
the artifact stays as build evidence only.
- **macOS is not built by the release workflow, and it is not finalised.** It has two rounds of real-device
evidence (Apple Silicon), which is why the build exists at all — but the menu-bar name, the placement of the
three window buttons (macOS puts them top-left; ours are drawn top-right), and 300s+ steady state still need a
real machine. macOS builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` probe with
`continue-on-error`, not a gate; `gh workflow run release.yml -f all_slots=true` builds the two slots for
evidence, but they are never published.
- **The content library is cramped at low window heights**, and the window height is not wired into - **The content library is cramped at low window heights**, and the window height is not wired into
the page model yet — the page lays itself out for a nominal height rather than the one it gets. the page model yet — the page lays itself out for a nominal height rather than the one it gets.
- **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the - **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
translate — which is exactly the `静默失败` you can see in the English screenshot above. translate — which is exactly the `静默失败` you can see in the English screenshot above.
- **The in-app assistant has no model behind it** (see above). The MCP server does. - **The in-app assistant has no model behind it** (see above). The MCP server does.
- **Pack import has two global policies**, keep-user or overwrite, not a per-file choice; the import
report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install path. - **Install progress is stage-level**, not byte-level, on the content-library install path.
- **No native file chooser.** Import takes drag-and-drop or a pasted path. - **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is - **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes: unverified there.
[`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
## Download and run ## Download and run
@@ -285,28 +205,20 @@ Grab the file for your platform from
| Slot | Asset | Shape | | Slot | Asset | Shape |
|------|-------|-------| |------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run | | `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack | | `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **built, not published**, see below | | `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **built, not published** (no runtime evidence yet) | | `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not built** by the release workflow | | `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow | | `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset | | every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
**This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots `windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is
named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard The row stays because the architecture is still supported and the slot still builds (unpublished) in
(`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that `.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
exist for an unlisted slot get an explicit "not published" warning instead of being deleted quietly, and a only downloadable Windows asset.
`mode: ci` slot that produced nothing at all warns too — a red slot must never be visible only as a missing row.
Of the unlisted rows, CI builds two. `windows-arm64` is built natively on GitHub's `windows-11-arm` runner
(Windows 11 Arm64 + Visual Studio 2026); every ARM64 failure so far came from a pre-build toolchain probe that
aborted before `cargo build` ran, that probe is now evidence-only, and the next CI run is the first real answer to
whether the slot can produce a binary. `linux-arm64` is built natively on `ubuntu-22.04-arm` (same glibc 2.35 floor
as x64) and is unpublished for a different reason: **nothing has ever run it on ARM64 hardware**. One frame plus the
theme/backend log lines on a real ARM64 machine is the unlock condition; until then the artifact is build evidence.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -314,18 +226,6 @@ documents are attached to the release next to it — downloading the Windows bin
licence. Download links never carry implementation detail in the name, and the binary inside an licence. Download links never carry implementation detail in the name, and the binary inside an
archive is always called `unlucky-launcher`. archive is always called `unlucky-launcher`.
On Linux x64 that is the whole install:
```bash
tar -xzf unlucky-launcher_<version>_linux_x64.tar.gz
cd unlucky-launcher_<version>_linux_x64
./unlucky-launcher # add --software-renderer if there is no usable GPU adapter
```
It asks for glibc 2.35+ (the slot is built on Ubuntu 22.04), `libxkbcommon`, `fontconfig` and `libGL`; on
Debian/Ubuntu `sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6`
covers the load-time set, and the bare `ull_<version>_linux_x64` beside the archive needs no display at all.
The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it
`dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so `dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so
it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35 it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35
@@ -343,7 +243,7 @@ upstream cargo. On Linux, X11 is the preferred path today and Wayland is the lon
Capability differences are feature gates with runtime probing, never a compile-time "this platform Capability differences are feature gates with runtime probing, never a compile-time "this platform
gets less". gets less".
First run with a Microsoft account? Nothing to configure — official builds carry the client id; see First run with a Microsoft account? Set `ULL_MSA_CLIENT_ID` first — see
[Accounts](#accounts). Nothing else needs configuring. [Accounts](#accounts). Nothing else needs configuring.
## Build from source ## Build from source
@@ -366,9 +266,6 @@ cargo deny check licenses bans sources # dependency licences (re
What the build produces: What the build produces:
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
— see [Accounts](#accounts).
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed - `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
@@ -392,8 +289,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|----------|---------| |----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) | | `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
## Architecture and module map ## Architecture and module map
-59
View File
@@ -1,59 +0,0 @@
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
> 给主线的改动清单、兼容性说明与合并指南见
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
## 已验证
| 项 | 状态 | 证据 |
| --- | --- | --- |
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
## 快速开始
完整配方(工具链、patchelf、linker、门禁)见
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
```sh
termux-x11 :0 -ac &
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
```
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
## 已知边界
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
PC 的 Linux CI 不受影响。
## 后续
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
2. 剪贴板/IME 在 termux-x11 下的行为验证
3. narrator/flite 与音频设备(当前 missing 但不阻断)
+32 -122
View File
@@ -4,8 +4,6 @@
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里: > **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases> > <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是 Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器 **一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
@@ -35,11 +33,6 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。 与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位, - **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。 每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、 - **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在 `crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。 日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
@@ -51,15 +44,8 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或 - **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的 CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。 导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果, - **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。 以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线); - **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是 CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。 禁用态,并把不匹配写清楚。没有一条是静默失败的。
@@ -82,13 +68,12 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **第三方社区认证服**(authlib-injector 一类)。 - **第三方社区认证服**(authlib-injector 一类)。
- **本地离线档案**,用于隔离测试。 - **本地离线档案**,用于隔离测试。
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是 **微软登录需要一个 Azure 公共客户端 id,而发布版二进制里没有。** 这个 id 不在仓库里,CI 也不注入,所以
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值: 启动前设 `ULL_MSA_CLIENT_ID=<id>`(或构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`,它通过
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`, `option_env!` 烧进二进制)。没有它时点登录**不会联网**,弹窗直接说清是这一项没配:标题「微软登录未配置」,
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题 正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后要**重启启动器**——已在运行的进程读不到新设的环境
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带 变量),并带 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是 登录失败仍是老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
### 外观与语言 ### 外观与语言
@@ -143,88 +128,35 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 **未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 `unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、 (`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、 `install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、
`agent`、`version`、`help`。 `version`、`help`。
### 自更新与稳定通道
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
渠道不符)。失败绝不会被说成「已是最新」。
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 日志在哪
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
![设置页「通用」分组:日志文件夹一行,显示 D:/ULL-Demo/.ULL/logs,旁边是「打开目录」与「复制路径」两个按钮,下面是解释文案](docs/images/logs-folder-zh.png)
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
数据根按三种模式取值:便携是 `<exe 目录>/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是
`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。
| 路径 | 是什么 |
|---|---|
| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择、**渲染后端起不来时的具名原因**,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 |
| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 |
| `logs/game/<session-id>.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 |
| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 |
| `logs/install.log` | 安装流水:每个阶段一行。 |
| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 |
| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 |
游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、
`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏
弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与
`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
### 还没做 ### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。 这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西 - **没有安装包,也没有自动更新通道。** 下个文件直接跑,没有任何东西替你检查新版本,更新源也还没定。
(见[自更新与稳定通道](#自更新与稳定通道))。 - **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
观察要等 `0.2.3` 发出去之后。
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
(日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。) - **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
- **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编 `macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
`linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染 「矩阵里有就算支持」。
后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里 - **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
**这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。 (macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到 Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 `workflow_dispatch`
(`hwnd=none`);后者交给桌面合成器(`corner=policy=system`),我们不自己画。 的探针、带 `continue-on-error`,不是门禁。
`linux-arm64` 只是**编出来了,没发布**——没有任何 ARM64 机器跑过它一帧,产物只作构建证据留着。
- **macOS 不由发布流水线构建,也未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、
三个窗口按钮的位置(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,
首次启动要自己清掉 Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动
`workflow_dispatch` 的探针、带 `continue-on-error`,不是门禁;`gh workflow run release.yml -f all_slots=true`
能编这两档取证,但它们从不发布。
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它 - **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
真正拿到的高度。 真正拿到的高度。
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子 - **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的 (`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。 `静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。 - **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。 - **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。 - **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。
## 下载与运行 ## 下载与运行
@@ -235,37 +167,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
| 槽位 | 产物 | 形态 | | 槽位 | 产物 | 形态 |
|------|------|------| |------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 | | `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 | | `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) | | `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** | | `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** | | `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 |
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
**本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位 `windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标 ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
**不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`): Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
名单里点名了却没产物 ⇒ Release 变红(`linux-x64` 构建红了就挡住整次发布,靠的就是这条);产物在、名单没点名 它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
⇒ 显式打「未发布」警告,不再静默删掉;`mode: ci` 的槽位本次连产物都没有(那一档红了)⇒ 也打警告。
不发布的行里 CI 真编两档。`windows-arm64` 在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + Visual Studio
2026)上编来取证——**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**,`cargo build` 一次都没跑过;
M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。`linux-arm64` 在原生
`ubuntu-22.04-arm`(与 x64 同 glibc 地板)上编,不发布的理由完全不同:**没有任何 ARM64 机器跑过它**。
解锁条件 = 真机渲出一帧 + 主题/后端日志行,之后才把键加进 `PUBLISH_SLOTS`。
Linux x64 的安装就是解开归档直接跑:
```bash
tar -xzf unlucky-launcher_<版本>_linux_x64.tar.gz
cd unlucky-launcher_<版本>_linux_x64
./unlucky-launcher # 没有可用 GPU 适配器就加 --software-renderer
```
载入期要 glibc 2.35+、`libxkbcommon`、`fontconfig`、`libGL`;Debian/Ubuntu 上
`sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` 就够。
与归档并列的裸 `ull_<版本>_linux_x64` 连显示都不需要。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -284,7 +197,7 @@ Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。W
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异 (MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。 一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。 第一次用微软账户?先设 `ULL_MSA_CLIENT_ID`——见[账户](#账户)。其它都不需要配置。
## 从源码构建 ## 从源码构建
@@ -305,8 +218,6 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
编出来的 bin: 编出来的 bin:
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
cargo build`);官方发布产物已内置,见[账户](#账户)。
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo - `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成 的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。 `unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
@@ -326,8 +237,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|----------|------| |----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 构建默认四档(`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(发布 `windows-x64` 与 `linux-x64`;`linux-x64` 另有一条同槽位的 Xvfb 首帧门禁;`-f all_slots=true` 才整表六槽位取证) | | `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
## 架构与模块地图 ## 架构与模块地图
-144
View File
@@ -1,144 +0,0 @@
# Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
## 方案
### 1. 平台识别
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
### 2. JDK:发现 + 安装
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
### 3. 字体
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
### 4. 窗口(横屏优先)
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
### 5. GPU(启动器)
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
### 6. Minecraft 运行链
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
下载兜底到阿里云 Maven(`install/libraries.rs`)。
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
加载不了 glibc natives。
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
## 改动清单
| 文件 | 改动 | 平台隔离 |
| --- | --- | --- |
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
| `core/platform/mod.rs` | 导出新模块 | — |
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
## 行为变化与兼容性
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
默认回退序**不含** termux,PC 行为不变。
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
## 合并指南
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败:
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
- 同测试:长路径被脱敏成 `<opaque>`
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
所以在 Termux 上跑门禁看不出来):
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
行为不变、用例原样留在原处。
## 验证矩阵(本机)
| 项 | 结果 |
| --- | --- |
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
## 后续路线
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
4. narrator/flite 与音频设备(当前缺失但不阻断)。
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
## 回滚策略
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.3" } unlucky-core = { path = "../core", version = "0.2.1" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+1 -1
View File
@@ -45,7 +45,7 @@ pub use chat::{
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
}; };
pub use exec::{ToolExecOutcome, ToolExecutor}; pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind}; pub use provider::{ProviderConfig, ProviderError, ProviderKind};
/// 一轮对话最多几轮工具调用。 /// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8; pub const DEFAULT_MAX_STEPS: usize = 8;
+86 -70
View File
@@ -14,11 +14,6 @@ use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。 /// Ollama 默认端点(research/02 §5.2)。
@@ -59,35 +54,6 @@ impl ProviderKind {
} }
} }
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
///
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
/// 未知值给 `None`(由调用方回落默认,不猜)。
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim() {
"openai_compatible" => Some(Self::OpenAiCompatible),
"anthropic" => Some(Self::Anthropic),
"ollama" => Some(Self::Ollama),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
#[must_use]
pub fn setting_value(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 设置页展示名。 /// 设置页展示名。
#[must_use] #[must_use]
pub fn display_name(self) -> &'static str { pub fn display_name(self) -> &'static str {
@@ -208,22 +174,18 @@ impl ProviderConfig {
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> { pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() { if !self.is_enabled() {
return Err(ProviderError::classified( return Err(ProviderError::new(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
)); ));
} }
if self.model.trim().is_empty() { if self.model.trim().is_empty() {
return Err(ProviderError::classified( return Err(ProviderError::new("没有配置模型名(model)"));
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
} }
if self.resolved_base_url().trim().is_empty() { if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::classified( return Err(ProviderError::new(format!(
ProviderFailure::NotConfigured, "{} 需要填写端点(base_url)",
format!("{} 需要填写端点(base_url)", self.kind.display_name()), self.kind.display_name()
)); )));
} }
if self.kind.requires_api_key() if self.kind.requires_api_key()
&& self && self
@@ -231,10 +193,10 @@ impl ProviderConfig {
.as_deref() .as_deref()
.is_none_or(|key| key.trim().is_empty()) .is_none_or(|key| key.trim().is_empty())
{ {
return Err(ProviderError::classified( return Err(ProviderError::new(format!(
ProviderFailure::NotConfigured, "{} 需要 API key",
format!("{} 需要 API key", self.kind.display_name()), self.kind.display_name()
)); )));
} }
Ok(()) Ok(())
} }
@@ -277,38 +239,20 @@ impl fmt::Debug for ProviderConfig {
} }
} }
/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的 /// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
/// `ErrorReport` 翻译,见 [`failure`])。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError { pub struct ProviderError {
kind: ProviderFailure,
message: String, message: String,
} }
impl ProviderError { impl ProviderError {
/// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。 /// 从可读文本构造。
#[must_use] #[must_use]
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
Self { Self {
kind: ProviderFailure::Other,
message: message.into(), message: message.into(),
} }
} }
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 失败分类(「测试连接」按它分档)。
#[must_use]
pub fn kind(&self) -> ProviderFailure {
self.kind
}
} }
impl fmt::Display for ProviderError { impl fmt::Display for ProviderError {
@@ -320,5 +264,77 @@ impl fmt::Display for ProviderError {
impl std::error::Error for ProviderError {} impl std::error::Error for ProviderError {}
#[cfg(test)] #[cfg(test)]
#[path = "provider/tests.rs"] mod tests {
mod tests; use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
@@ -1,62 +0,0 @@
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
//!
//! ## 为什么不是字符串匹配
//!
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
//! 一度偶发红)。
//!
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
//!
//! ## 六档口径
//!
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
//! |---|---|---|
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderFailure {
/// 配置不完整:**没有发出任何请求**。
NotConfigured,
/// 鉴权失败(401 / 403 / 凭据被拒)。
Auth,
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound,
/// 端点不可达(传输层失败,没拿到任何回应)。
Unreachable,
/// 超时(调用方时限或传输层超时)。
Timeout,
/// 其它(协议 / 解析 / 5xx / 未知)。
Other,
}
impl ProviderFailure {
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfigured => "not_configured",
Self::Auth => "auth",
Self::ModelNotFound => "model_not_found",
Self::Unreachable => "unreachable",
Self::Timeout => "timeout",
Self::Other => "other",
}
}
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
#[must_use]
pub const fn sent_a_request(self) -> bool {
!matches!(self, Self::NotConfigured)
}
}
-107
View File
@@ -1,107 +0,0 @@
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
//!
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
+83 -60
View File
@@ -47,7 +47,7 @@ use std::sync::Arc;
use futures_util::StreamExt; use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::{ErrorKind, ProviderError as RigError}; use rig_core::error::ProviderError as RigError;
use rig_core::message::{ use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
@@ -64,7 +64,7 @@ use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture, ProviderFuture,
}; };
use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind}; use super::provider::{ProviderConfig, ProviderError, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0"; pub const RIG_VERSION_PIN: &str = "=0.45.0";
@@ -275,63 +275,9 @@ fn to_reply(
}) })
} }
/// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` / /// rig 错误 → 本方错误(文案面向用户)。
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
///
/// | rig 侧信号 | 本方分类 |
/// |---|---|
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
fn map_error(error: RigError) -> ProviderError { fn map_error(error: RigError) -> ProviderError {
let report = error.report(); ProviderError::new(error.to_string())
let kind = match report.kind {
ErrorKind::Timeout => ProviderFailure::Timeout,
ErrorKind::Http => {
if transport_timed_out(&error) {
ProviderFailure::Timeout
} else {
ProviderFailure::Unreachable
}
}
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
Some(401 | 403) => ProviderFailure::Auth,
Some(404) => ProviderFailure::ModelNotFound,
_ if matches!(
report.code.as_deref(),
Some("model_not_found" | "model_not_found_error")
) =>
{
ProviderFailure::ModelNotFound
}
_ => ProviderFailure::Other,
},
_ => ProviderFailure::Other,
};
ProviderError::classified(kind, report.message)
}
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
///
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
/// TLS)都归 `Unreachable`。
fn transport_timed_out(error: &RigError) -> bool {
let RigError::Http(http_error) = error else {
return false;
};
let mut source = std::error::Error::source(http_error);
for _ in 0..8 {
let Some(cause) = source else { return false };
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
} }
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -341,5 +287,82 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
} }
#[cfg(test)] #[cfg(test)]
#[path = "rig/tests.rs"] mod tests {
mod tests; use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}
-82
View File
@@ -1,82 +0,0 @@
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
//!
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
.is_ok()
);
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
+4 -20
View File
@@ -22,15 +22,12 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.3" } unlucky-design = { path = "../design", version = "0.2.1" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.3" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.3" } unlucky-presenter = { path = "../presenter", version = "0.2.1" }
# 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 +57,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.3" } unlucky-cli = { path = "../cli", version = "0.2.1" }
# 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.3" } unlucky-i18n = { path = "../i18n", version = "0.2.1" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
@@ -90,19 +87,6 @@ iced = { version = "=0.14.0", default-features = false, features = [
default = ["gpu"] default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 # 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"] gpu = ["iced/wgpu"]
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
# ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 \
# cargo run -p unlucky-app-iced --features update-test-source
# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里
# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。
# 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。
update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"]
[lints] [lints]
workspace = true workspace = true
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
-14
View File
@@ -1,14 +0,0 @@
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
//!
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
//!
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("unlucky-launcher-iced");
}
+3 -4
View File
@@ -18,7 +18,6 @@
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。 //! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。 //! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
use crate::app::diag::diag;
use unlucky_presenter::dto::AppCommand; use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change; use crate::pages::appearance::Change;
@@ -55,7 +54,7 @@ impl App {
} else { } else {
line.push_str(" layered=skipped outcome=no-window"); line.push_str(" layered=skipped outcome=no-window");
} }
diag!("{line}"); eprintln!("{line}");
} }
/// 设置页改了一项外观。 /// 设置页改了一项外观。
@@ -124,8 +123,8 @@ impl App {
self.appearance_prefs = prefs; self.appearance_prefs = prefs;
match self.bridge.path() { match self.bridge.path() {
Some(path) => diag!("[settings] 外观已写回 {}", path.display()), Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"), None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
} }
} }
} }
+12 -27
View File
@@ -12,7 +12,6 @@
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic //! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use crate::app::diag::diag;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use iced::Task; use iced::Task;
@@ -31,8 +30,6 @@ use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics; mod diagnostics;
mod fallback; mod fallback;
mod termux_gpu;
mod termux_window;
use fallback::{on_failure, respawn, OnFailure}; use fallback::{on_failure, respawn, OnFailure};
@@ -42,8 +39,6 @@ pub(crate) fn run(options: Options) -> iced::Result {
let boot_started = Instant::now(); let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打) // ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
termux_gpu::enable();
let boot = boot_probe(&options); let boot = boot_probe(&options);
let decision = boot.decision; let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision); diagnostics::report_boot(&options, &boot.probe, &decision);
@@ -52,10 +47,10 @@ pub(crate) fn run(options: Options) -> iced::Result {
let font_started = Instant::now(); let font_started = Instant::now();
let resolved = fonts::resolve(); let resolved = fonts::resolve();
let font_ms = font_started.elapsed(); let font_ms = font_started.elapsed();
diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0); eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
diag!("{}", resolved.describe()); eprintln!("{}", resolved.describe());
if resolved.has_synthetic() { if resolved.has_synthetic() {
diag!( eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理" "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
); );
} }
@@ -65,7 +60,7 @@ pub(crate) fn run(options: Options) -> iced::Result {
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。 // 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。 // **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() { if options.screenshot.is_some() && options.shot_flavor().is_none() {
diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
} }
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
diagnostics::report_theme(&options, flavor); diagnostics::report_theme(&options, flavor);
@@ -87,14 +82,14 @@ pub(crate) fn run(options: Options) -> iced::Result {
} }
Some(OnFailure::Refuse) => { Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。 // 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
diag!( eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options options
.screenshot .screenshot
.as_ref() .as_ref()
.map_or_else(String::new, |path| path.display().to_string()) .map_or_else(String::new, |path| path.display().to_string())
); );
diag!( eprintln!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。" "[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
); );
Err(iced::Error::GraphicsCreationFailed(error)) Err(iced::Error::GraphicsCreationFailed(error))
@@ -136,20 +131,10 @@ fn launch(
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳** // M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。 // ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先), let (size, size_source) = acceptance::window_size(
// 失败就退回 1180×760 的验收尺寸。
let (default_size, default_source) = termux_window::default_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height), (WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height), (MIN_SIZE.width, MIN_SIZE.height),
) );
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
let (size, size_source) =
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
let size_source = if size_source == "default" {
default_source
} else {
size_source
};
let settings = diagnostics::window_settings(size); let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2) // 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source); diagnostics::report_window(&settings, options.scale_factor, size_source);
@@ -193,13 +178,13 @@ fn boot_probe(options: &Options) -> Boot {
if let Some(kind) = renderer::forced_fallback() { if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind); let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value()); std::env::set_var("ICED_BACKEND", kind.env_value());
diag!( eprintln!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV, renderer::FALLBACK_ENV,
kind.env_value(), kind.env_value(),
kind.env_value() kind.env_value()
); );
diag!("[probe] {}", decision.describe()); eprintln!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None` // 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。 // ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() { let (probe, probe_ms) = if acceptance::enabled() {
@@ -233,11 +218,11 @@ fn boot_probe(options: &Options) -> Boot {
if cfg!(feature = "gpu") { if cfg!(feature = "gpu") {
match probe_ms { match probe_ms {
Some(elapsed) => diag!( Some(elapsed) => eprintln!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)", "[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0 elapsed.as_secs_f64() * 1000.0
), ),
None => diag!( None => eprintln!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)", "[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name() options.backend.name()
), ),
@@ -35,11 +35,6 @@ pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
size: iced::Size::new(size.0, size.1), size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE), min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。 // 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
//
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
decorations: false, decorations: false,
resizable: true, resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式 // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
+4 -8
View File
@@ -9,7 +9,6 @@
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程 //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。 //! 只能建一次事件循环。
use crate::app::diag::diag;
use crate::renderer::Decision; use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind}; use crate::renderer::{self, acceptance, Kind};
@@ -59,10 +58,10 @@ pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2]
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。 /// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| { let exe = std::env::current_exe().unwrap_or_else(|err| {
diag!("[gpu] 降级重开失败:拿不到自身路径({err})"); eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None)); std::process::exit(exit_code(None));
}); });
diag!( eprintln!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。", 下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(), fallback.name(),
@@ -79,7 +78,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let code = match status { let code = match status {
Ok(status) => exit_code(status.code()), Ok(status) => exit_code(status.code()),
Err(err) => { Err(err) => {
diag!("[gpu] 降级重开失败:{err}"); eprintln!("[gpu] 降级重开失败:{err}");
exit_code(None) exit_code(None)
} }
}; };
@@ -108,10 +107,7 @@ pub(super) fn fallback_command(
args: impl IntoIterator<Item = std::ffi::OsString>, args: impl IntoIterator<Item = std::ffi::OsString>,
fallback: Kind, fallback: Kind,
) -> std::process::Command { ) -> std::process::Command {
// 子进程就是本 GUI exe 自己(PE Subsystem=2,本来就不会分到控制台),所以这一处今天闪不了; let mut command = std::process::Command::new(program);
// 走 `quiet` 是为了让「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住——将来
// 某天把降级重开换成控制台辅助程序时,不会从这里漏出一块黑框(M217)。
let mut command = unlucky_core::platform::child::quiet(program);
command.args(args).envs(fallback_env(fallback)); command.args(args).envs(fallback_env(fallback));
command command
.stdin(std::process::Stdio::inherit()) .stdin(std::process::Stdio::inherit())
@@ -1,58 +0,0 @@
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
//!
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
//!
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
use std::path::{Path, PathBuf};
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
pub(super) fn enable() {
if !cfg!(target_os = "linux") {
return;
}
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|| std::env::var_os("VK_DRIVER_FILES").is_some()
{
return;
}
let Some(prefix) = termux::prefix(&ProcessEnv) else {
return;
};
let icd = icd_path(&prefix);
if icd.is_file() {
std::env::set_var("VK_ICD_FILENAMES", &icd);
std::env::set_var("VK_DRIVER_FILES", &icd);
}
}
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
fn icd_path(prefix: &Path) -> PathBuf {
prefix.join(ICD_REL)
}
#[cfg(test)]
mod tests {
use super::icd_path;
#[test]
fn icd_path_is_under_the_glibc_prefix() {
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
assert_eq!(
path,
std::path::PathBuf::from(
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
)
);
}
}
@@ -1,117 +0,0 @@
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
//!
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
//!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 屏幕四周留白(像素)。
const MARGIN: f32 = 32.0;
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
const MAX_SCALE: f32 = 2.0;
/// `xdpyinfo` 里尺寸那一行的前缀。
const DIMENSIONS_PREFIX: &str = "dimensions:";
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
///
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
/// 这里不复制常量。
#[must_use]
pub(super) fn default_size(
default: (f32, f32),
min: (f32, f32),
) -> Option<((f32, f32), &'static str)> {
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
return None;
}
let screen = x_screen_size()?;
Some((fit_screen(default, min, screen), "termux-screen"))
}
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
#[must_use]
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
return default;
}
let available = (
(screen.0 - MARGIN * 2.0).max(1.0),
(screen.1 - MARGIN * 2.0).max(1.0),
);
let scale = (available.0 / default.0)
.min(available.1 / default.1)
.min(MAX_SCALE);
let width = (default.0 * scale).max(min.0).min(screen.0);
let height = (default.1 * scale).max(min.1).min(screen.1);
(width, height)
}
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> {
// 非 Windows 上 `quiet` 是原样返回(POSIX 没有「spawn 就新开一个终端」这回事);走它是为了
// 不在整棵 `crates/` 的子进程守卫里开一个「Termux 专用」例外(M217)。
let output = unlucky_core::platform::child::quiet("xdpyinfo")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text
.lines()
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
let value = line.split_whitespace().nth(1)?;
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::fit_screen;
const DEFAULT: (f32, f32) = (1180.0, 760.0);
const MIN: (f32, f32) = (880.0, 560.0);
fn close(actual: (f32, f32), expected: (f32, f32)) {
assert!(
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
"expected {expected:?}, got {actual:?}"
);
}
#[test]
fn landscape_screen_scales_up_keeping_aspect() {
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
close(
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
(1875.58, 1208.0),
);
}
#[test]
fn portrait_screen_still_fits_width() {
// 1272×2066:宽度是瓶颈 → 1208×778。
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
}
#[test]
fn huge_screen_is_capped_at_two_times() {
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
}
#[test]
fn tiny_screen_never_goes_below_min_or_overflow() {
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
assert_eq!(width, 800.0, "宽度受屏幕限制");
assert_eq!(height, 560.0, "高度不小于最小尺寸");
}
#[test]
fn invalid_screen_falls_back_to_default() {
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
}
}
-169
View File
@@ -1,169 +0,0 @@
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
//!
//! ## 为什么要有这一层
//!
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
//!
//! 现在这些行同时进 `launch::launcher_log`:
//!
//! ```text
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
//! ```
//!
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
//!
//! ## 哪些行进日志、哪些只进 stderr
//!
//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。**
//!
//! | 进日志 | 只进 stderr |
//! |---|---|
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) |
//! | **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` / `[gpu] 失败详情(具名)`(`renderer::report_failure`)、`[gpu] iced_wgpu: …Selected: AdapterInfo…`(`log_bridge`)、`[probe] request=… -> ICED_BACKEND=…`(`probe::apply_with_preflight`)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]` / gallery 页面里的 `[gallery]`) |
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
//! | 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒 ≈ 4.8 小时才滚一次)、`[shot]` 的通道提示 | — |
//!
//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体——
//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。
/// 行首没有 `[tag]` 时的默认 scope。
pub const APP_SCOPE: &str = "app";
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
pub(crate) fn emit(line: &str) {
eprintln!("{line}");
let (scope, message) = split_scope(line);
unlucky_core::launch::launcher_log::record(scope, message);
}
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
///
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
let Some(rest) = line.strip_prefix('[') else {
return (APP_SCOPE, line);
};
let Some((scope, tail)) = rest.split_once(']') else {
return (APP_SCOPE, line);
};
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
return (APP_SCOPE, line);
}
(scope, tail.trim_start())
}
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
macro_rules! diag {
($($arg:tt)*) => {{
crate::app::diag::emit(&format!($($arg)*));
}};
}
pub(crate) use diag;
/// 串行化「会改 `launcher_log` 全局目录」的用例。
///
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
#[cfg(test)]
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::split_scope;
#[test]
fn the_leading_bracket_becomes_the_scope() {
assert_eq!(
split_scope("[font] 求值耗时 12 ms"),
("font", "求值耗时 12 ms")
);
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
// 正文里的方括号不是标签
assert_eq!(
split_scope("[page] 占位动作 [x] pressed"),
("page", "占位动作 [x] pressed")
);
}
#[test]
fn lines_without_a_tag_fall_back_to_the_default_scope() {
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
assert_eq!(
split_scope("[a b] 空白不是标签"),
("app", "[a b] 空白不是标签")
);
}
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
///
/// ## 为什么要「重试」
///
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
///
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
#[test]
fn the_launcher_log_rolls_over_and_stays_bounded() {
use unlucky_core::launch::launcher_log;
let _guard = super::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建夹具目录");
let path = dir.join(launcher_log::FILE_NAME);
let rolled = dir.join("launcher.1.log");
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
for _ in 0..50 {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&rolled);
launcher_log::set_dir(&dir);
if launcher_log::path() != Some(path.clone()) {
continue; // 这一轮被别人的 set_dir 抢先了
}
launcher_log::record("boot", "第一行");
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
if launcher_log::path() != Some(path.clone()) {
continue;
}
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
if launcher_log::path() != Some(path.clone()) {
continue; // 追加落到了别人的目录,这一轮不算
}
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
let size = std::fs::metadata(&path).expect("新文件").len();
assert!(
size < launcher_log::MAX_BYTES,
"滚完之后新文件必须远小于上限:{size}"
);
let text = std::fs::read_to_string(&path).expect("读新文件");
assert!(text.contains("滚过之后这一行"), "{text}");
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
launcher_log::record("boot", "第二次滚动");
assert!(rolled.is_file());
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
std::fs::remove_dir_all(&dir).ok();
return;
}
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
}
}
-390
View File
@@ -1,390 +0,0 @@
//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。
//!
//! ## 为什么先补这一层
//!
//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**,
//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事
//! 在此之前根本无处落脚。本模块是最小可用的一层:
//!
//! ```text
//! presenter AppState::dialogs(唯一队列)
//! │ current()
//! ▼
//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里)
//! │
//! ▼
//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮
//! ```
//!
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
//!
//! ## 崩溃卡片额外带什么
//!
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 /
//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段
//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**,
//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。
//!
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
//! 不重算:**界面不读盘、不猜原因**。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
use crate::app::state::App;
pub(super) mod export;
#[cfg(test)]
mod tests;
pub(super) mod view;
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
#[derive(Debug, Clone)]
pub struct CrashCard {
/// 现场。
pub report: CrashReport,
/// 「详细崩溃说明」是否展开。
pub expanded: bool,
/// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。
pub dialog: DialogId,
/// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。
///
/// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点**
/// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的
/// 结果不能挂在一条不显示的通道上。
pub outcome: Option<String>,
}
/// 弹窗消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 主按钮。
Primary,
/// 次按钮。
Secondary,
/// 点遮罩 / Esc。
Dismiss,
/// 展开 / 收起详细说明。
Toggle,
/// 打开日志文件夹。
OpenFolder,
/// 复制全部路径。
CopyPaths,
/// 复制第 `index` 条路径(`Modal::paths` 的下标)。
CopyPath(usize),
}
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(super) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(super) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
// --------------------------------------------------------------- 与 App 的接线
impl App {
/// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。
pub(super) fn dialog_modal(&self) -> Option<Modal> {
let request = self.state.dialogs.current()?;
let view = DialogRequestView::of(request);
let card = self
.dialog_card
.as_ref()
.filter(|card| card.dialog == request.id);
Some(current(self.lang(), &view, card))
}
/// 收到一次启动收尾:失败就弹框(成功不打扰)。
///
/// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。
pub(super) fn open_failure_dialog(
&mut self,
finished: &unlucky_presenter::dto::LaunchFinished,
) {
let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok
|| finished.failure.is_some();
if !failed {
return;
}
let Some(report) = unlucky_core::launch::crash_report::failing_launch() else {
return;
};
if !finished.session.is_empty() && report.session != finished.session {
// 登记的是另一场(并发启动):不拿它去解释这一场。
return;
}
let catalog = self.lang().catalog();
let crashed = report.started();
let title = catalog.text(if crashed {
Key::CrashDialogTitleCrashed
} else {
Key::CrashDialogTitleFailed
});
let summary = self.dialog_summary(&report);
let at = self.lang();
let id = self.state.dialogs.push(
DialogSource::UserAction,
title,
summary,
DialogKind::Confirm {
primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(),
secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(),
},
);
self.dialog_card = Some(CrashCard {
report,
// 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。
expanded: true,
dialog: id,
outcome: None,
});
self.view_dirty = true;
}
/// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。
fn dialog_summary(&self, report: &CrashReport) -> String {
let catalog = self.lang().catalog();
match report.exit_code {
Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]),
None => catalog.text(Key::CrashDialogTitleFailed).to_string(),
}
}
/// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。
pub(super) fn close_dialog(&mut self) {
let closed = self.state.dialogs.current().map(|request| request.id);
self.state.dialogs.close_current();
if self
.dialog_card
.as_ref()
.is_some_and(|card| closed == Some(card.dialog))
{
self.dialog_card = None;
}
self.view_dirty = true;
}
/// 展开 / 收起详细说明。
pub(super) fn toggle_dialog_detail(&mut self) {
if let Some(card) = &mut self.dialog_card {
card.expanded = !card.expanded;
self.view_dirty = true;
}
}
}
-217
View File
@@ -1,217 +0,0 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`super`] 分开是因为职责不同:那边管**弹窗与事实**,这边管**把事实带出界面**。
//! 打包本体在 `unlucky_core::launch::crash_bundle`(脱敏、上限、清单都在那一处),
//! 本文件只负责「放哪、叫什么名字、怎么打开」。
use std::path::PathBuf;
use unlucky_core::launch::crash_bundle::{self, BundleEntry, BundleSpec};
use unlucky_core::launch::crash_report::CrashReport;
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Exported {
/// 给用户看的那一句(已本地化)。
pub text: String,
/// 是否成功。
pub ok: bool,
/// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。
pub truncated: usize,
}
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
///
/// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」);
/// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。
pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported {
let catalog = Catalog::of(lang);
let Some(logs) = &report.launcher_logs else {
return Exported {
text: catalog.render(
Key::CrashDialogExportFailed,
&[("error", "no launcher log dir")],
),
ok: false,
truncated: 0,
};
};
let stamp =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now())
.replace([':', '.', 'T'], "-")
.trim_end_matches('Z')
.to_string();
let name = if report.session.is_empty() {
&report.instance
} else {
&report.session
};
let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip"));
match crash_bundle::write_bundle(&spec_of(report), &target) {
Ok(bundle) => {
let mut text = catalog.render(
Key::CrashDialogExportDone,
&[
("bytes", &bundle.bytes.to_string()),
("path", &bundle.path.display().to_string()),
],
);
if bundle.truncated > 0 {
text.push('\n');
text.push_str(&catalog.render(
Key::CrashDialogExportTruncated,
&[("count", &bundle.truncated.to_string())],
));
}
Exported {
text,
ok: true,
truncated: bundle.truncated,
}
}
Err(error) => Exported {
text: catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
ok: false,
truncated: 0,
},
}
}
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
fn spec_of(report: &CrashReport) -> BundleSpec {
let mut entries: Vec<BundleEntry> = Vec::new();
let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
if path.exists() {
entries.push(BundleEntry::new(group, path));
}
};
if let Some(logs) = &report.launcher_logs {
push(&mut entries, "launcher", &logs.join("launcher.log"));
push(&mut entries, "launcher", &logs.join("launcher.1.log"));
push(&mut entries, "launcher", &logs.join("install.log"));
}
if let Some(session) = &report.session_log {
// 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id,
// 也不会出现「清单写 <redacted:token> 而包里是原始 id」(r1 复审 P2-2)。
if session.exists() {
entries.push(BundleEntry::named("session", session, "session.jsonl"));
}
}
for artifact in &report.artifacts {
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
if artifact.source != "latest_log" {
push(&mut entries, "game", &artifact.path);
}
}
if let Some(latest) = report.latest_log_path() {
push(&mut entries, "game", &latest);
}
if let Some(json) = &report.version_json {
push(&mut entries, "instance", json);
}
let mut secrets = vec![report.session.clone()];
secrets.retain(|secret| !secret.is_empty());
BundleSpec {
instance: report.name.clone(),
version: report.version.clone(),
session: report.session.clone(),
exit_code: report.exit_code,
exit_type: report.exit_type.clone(),
uptime_ms: report.uptime_ms,
command: report.command.clone(),
java: report.java.clone(),
system: report.system.clone(),
notes: report.note_lines(),
missing: report.missing_lines(),
entries,
secrets,
home: std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(PathBuf::from),
}
}
/// 打开「日志文件夹」:有现场就开实例运行目录,否则开启动器日志目录。
///
/// 平台差异只在这里一处(`explorer` / `open` / `xdg-open`),不往业务代码里散写 `cfg`。
pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String {
let catalog = Catalog::of(lang);
let target = report
.run_dir
.clone()
.filter(|dir| dir.is_dir())
.or_else(|| report.launcher_logs.clone().filter(|dir| dir.is_dir()));
let Some(dir) = target else {
return catalog.render(
Key::CrashDialogOpenFailed,
&[("error", "no folder on disk")],
);
};
match open_dir(&dir) {
Ok(()) => String::new(),
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
}
}
/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。
///
/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。
pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
if !dir.is_dir() {
return Err(format!("{}(目录不存在)", dir.display()));
}
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
}
/// 平台各自的「打开这个目录」实现。
///
/// ## 为什么这三处**故意**不走 `unlucky_core::platform::child::quiet`(M217)
///
/// 这一处 spawn 的**目的就是让桌面上出现一个可见的东西**(文件管理器窗口 / Finder 窗口 /
/// 桌面环境的文件管理器)。`quiet` 的语义是「把子进程的窗口抑制掉」,用在这里是把目的本身
/// 抑制掉——改错了用户点「打开文件夹」就只看到「打不开」的报错。
///
/// 而且从窗口角度也不需要它:`explorer.exe` / `open` / `xdg-open` 自己都是 GUI 侧程序,由
/// 无控制台的 GUI 父进程 spawn 时 Windows 不会给它们新建控制台。所以这三处在
/// `crates/core/tests/child_quiet_guard.rs` 的 `ALLOWED_ROLES` 里登记成「目录打开器」角色,
/// **不是**漏网。别把它们改成 `quiet`。
#[cfg(target_os = "windows")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
std::process::Command::new("explorer")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(target_os = "macos")]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
std::process::Command::new("open")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
std::process::Command::new("xdg-open")
.arg(dir)
.spawn()
.map(|_| ())
.map_err(|error| error.to_string())
}
/// 「复制路径」的一段文本(每条一行,标签 + 路径)。
pub(crate) fn paths_text(report: &CrashReport) -> String {
report
.path_lines()
.into_iter()
.map(|(label, path)| format!("{label}: {}", path.display()))
.collect::<Vec<_>>()
.join("\n")
}
-344
View File
@@ -1,344 +0,0 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
//!
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
use std::path::PathBuf;
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView};
use crate::app::state::App;
use crate::cli::Options;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn headless() -> App {
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
App::with_presenter(
Options::default(),
FAMILIES,
unlucky_design::color::SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
let run_dir = PathBuf::from("/instances/survival");
let artifacts = if with_crash_report {
vec![ArtifactFact {
source: "crash_report".to_string(),
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
excerpt: Some("Description: Initializing game".to_string()),
}]
} else {
Vec::new()
};
CrashReport {
instance: "survival".to_string(),
name: "生存".to_string(),
version: "1.20.4-fabric".to_string(),
session: "9f2c".to_string(),
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
launcher_logs: Some(PathBuf::from("/logs")),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 1_000,
command: "javaw -Xmx4096m".to_string(),
java: "javaw.exe (21)".to_string(),
system: "os=windows".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
}
}
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
///
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
#[test]
fn queue_drives_the_modal_through_refresh_view() {
let mut app = headless();
app.refresh_view();
assert!(
app.dialog_model.is_none(),
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
);
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
app.state.dialogs.push(
DialogSource::UserAction,
title,
"正文一行",
DialogKind::Confirm {
primary: "主要".to_string(),
secondary: "次要".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
assert_eq!(modal.title, title);
assert_eq!(modal.primary, "主要");
assert_eq!(modal.secondary.as_deref(), Some("次要"));
}
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
///
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
/// 英文,本用例第一处断言立刻红。
#[test]
fn runtime_chinese_reaches_the_body() {
let lang = Lang::ZhCn;
let card = CrashCard {
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"游戏崩溃",
"",
DialogKind::Confirm {
primary: String::new(),
secondary: String::new(),
},
);
let request = queue.current().expect("当前条");
let view = DialogRequestView::of(request);
let modal = current(lang, &view, Some(&card));
let body = modal.body_text();
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
assert!(body.contains("退出码 1"), "{body}");
assert!(body.contains("游戏进程起来了"), "{body}");
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
assert!(body.contains("没有生成崩溃报告"), "{body}");
assert!(body.contains("捕获"), "{body}");
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
let en = current(Lang::En, &view, Some(&card));
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
}
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
#[test]
fn two_failure_shapes_say_two_different_things() {
let lang = Lang::ZhCn;
let with = report(true, vec!["stack line".to_string()]);
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
assert_ne!(with.evidence(), without.evidence());
let with_card = CrashCard {
report: with.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let without_card = CrashCard {
report: without.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"t",
"",
DialogKind::Error {
primary: "p".to_string(),
hint: None,
},
);
let request = queue.current().unwrap();
let view = DialogRequestView::of(request);
let crash = current(lang, &view, Some(&with_card));
let no_log = current(lang, &view, Some(&without_card));
assert_ne!(crash.body_text(), no_log.body_text());
assert!(crash.body_text().contains("写出了崩溃报告"));
assert!(!no_log.body_text().contains("写出了崩溃报告"));
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
assert!(path_rows(&lang.catalog(), &without)
.iter()
.any(|(_, path)| path.ends_with("latest.log")));
}
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
#[test]
fn closing_the_dialog_clears_the_card() {
let mut app = headless();
crash_report::publish(report(false, vec!["line".to_string()]));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 1_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_some(), "失败必须弹框");
assert!(app.dialog_card.is_some());
app.close_dialog();
assert!(app.state.dialogs.current().is_none());
assert!(app.dialog_card.is_none());
}
/// 成功收尾不打扰用户。
#[test]
fn a_successful_launch_does_not_pop_anything() {
let mut app = headless();
crash_report::publish(report(false, Vec::new()));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Ok,
exit_code: Some(0),
uptime_ms: 90_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_none());
}
/// P2-1:**卡片那一层只能是卡片大**。
///
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
///
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
#[test]
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
use iced::Length;
let mut app = headless();
app.state.dialogs.push(
DialogSource::UserAction,
"t",
"b",
DialogKind::Confirm {
primary: "p".to_string(),
secondary: "s".to_string(),
},
);
let modal = {
let request = app.state.dialogs.current().expect("当前条");
let view = DialogRequestView::of(request);
super::current(app.lang(), &view, None)
};
let plane = super::view::card_plane(app.ui(), &modal);
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
assert_ne!(
hint.width,
Length::Fill,
"卡片层宽度不能 Fill(会撑成整窗)"
);
assert_ne!(
hint.height,
Length::Fill,
"卡片层高度不能 Fill(会撑成整窗)"
);
}
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
///
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
///
/// 抓不住:具体色值对不对(那是令牌层的事)。
#[test]
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
let backdrop = super::view::backdrop_color(&ui);
assert_eq!(
backdrop.a, 1.0,
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
);
let canvas = crate::theme::to_iced(
unlucky_design::color::Scheme::baseline(flavor)
.get(MaterialLayer::Canvas.surface_role()),
);
assert!(
luma(backdrop) < luma(canvas),
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
luma(backdrop),
luma(canvas)
);
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
}
}
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
///
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
#[test]
fn boot_facts_are_recorded_into_the_launcher_log() {
// 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。
let _guard = crate::app::diag::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建目录");
unlucky_core::launch::launcher_log::set_dir(&dir);
let app = headless();
crate::app::update::boot_env::record_boot_log(&app);
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
let text = std::fs::read_to_string(&path).expect("launcher.log");
assert!(text.contains("[boot] app boot"), "{text}");
assert!(text.contains("theme="), "{text}");
assert!(text.contains("window="), "{text}");
}
#[path = "tests/export.rs"]
mod export;
@@ -1,85 +0,0 @@
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
//!
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
use unlucky_i18n::Lang;
use super::report;
use crate::app::dialog::export;
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
#[test]
fn export_writes_a_bundle_even_without_a_crash_report() {
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
spec_report.launcher_logs = Some(root.join("logs"));
spec_report.session_log = None;
spec_report.run_dir = None;
spec_report.version_json = None;
let done = export::export(&spec_report, Lang::ZhCn);
let text = done.text.clone();
assert!(done.ok, "{text}");
assert_eq!(done.truncated, 0);
assert!(text.contains("已导出"), "{text}");
assert!(text.contains("字节"), "{text}");
let exports = root.join("logs").join("exports");
let files: Vec<_> = std::fs::read_dir(&exports)
.expect("导出目录")
.filter_map(Result::ok)
.collect();
assert_eq!(files.len(), 1, "一次导出只该落一个包");
let size = files[0].metadata().expect("元数据").len();
assert!(size > 0, "包不该是空文件");
assert!(
text.contains(&size.to_string()),
"报出来的字节数要与磁盘一致:{text} / {size}"
);
}
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
///
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
/// 抓不住:那句话排版好不好看(视觉)。
#[test]
fn a_truncated_export_says_so_in_the_notice() {
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
let mut big = String::new();
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
big.push_str(line);
}
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
let mut report = report(false, Vec::new());
report.launcher_logs = Some(root.join("logs"));
report.run_dir = Some(root.join("game"));
report.session_log = None;
report.version_json = None;
let done = export::export(&report, Lang::ZhCn);
assert!(done.ok, "{}", done.text);
assert_eq!(
done.truncated, 1,
"这份 latest.log 必须真的被截断(>4 MiB)"
);
assert!(
done.text.contains("已截断"),
"界面上没有说「有文件被截断」:{}",
done.text
);
}
-299
View File
@@ -1,299 +0,0 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
//!
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
//!
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
//! 立刻停。所以三层的顺序是:
//!
//! ```text
//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
//! ```
//!
//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons;
use crate::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::{Message as DialogMessage, Modal};
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0;
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
const CARD_HEIGHT: f32 = 660.0;
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
///
/// ## 三层的命中语义(r1 复审 P2-1 的修法)
///
/// ```text
/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ]
/// ```
///
/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里
/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。
/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它
/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」
/// 只存在于注释里。
pub(crate) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
modal: &'a Modal,
) -> Element<'a, Message> {
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color(ui))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(card_plane(ui, modal))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
///
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
///
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui)
}
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
///
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
iced::widget::opaque(card(ui, modal))
}
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
// 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
let geometry = DialogGeometry::of(ui);
let shape = Component::Dialog.spec().shape;
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
header = header.push(role(
ui,
TypeRole::HeadlineSmall,
true,
modal.title.as_str(),
));
for line in &modal.body {
header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface)));
}
let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
// **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。
if card.expanded {
if let Some(title) = &card.detail_title {
middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str()));
}
for line in &card.detail_lines {
middle = middle.push(mono(ui, line.clone()));
}
middle = middle.push(frame::divider(ui).map(Message::Page));
}
for (index, (label, path)) in card.paths.iter().enumerate() {
middle = middle.push(
button(
column![
role(ui, TypeRole::LabelMedium, false, label.as_str()),
mono(ui, path.clone()),
]
.spacing(0.0)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::Dialog(DialogMessage::CopyPath(index))),
);
}
}
let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill);
if let Some(notice) = &modal.notice {
footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary)));
}
footer = footer.push(actions(ui, modal));
// 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面
// (实测第一版就是这样:导出/关闭两个按钮整排不可见)。
let body = if modal.card.is_some() {
column![
header,
scrollable(middle).height(Length::Fixed(if modal.notice.is_some() {
SCROLL_HEIGHT_WITH_NOTICE
} else {
SCROLL_HEIGHT
})),
frame::divider(ui).map(Message::Page),
footer,
]
} else {
column![header, frame::divider(ui).map(Message::Page), footer,]
}
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
container(body)
.width(Length::Fixed(geometry.max_width))
.max_height(CARD_HEIGHT)
.padding(geometry.padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(shape),
..container::Style::default()
})
.into()
}
/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。
///
/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样,
/// 「导出诊断包」被挤成两行单字)。
fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
column_actions = column_actions.push(
row![
buttons::outlined(
ui,
buttons::Button::new(
card.actions[1].as_str(),
Message::Dialog(DialogMessage::OpenFolder),
),
),
buttons::outlined(
ui,
buttons::Button::new(
card.actions[2].as_str(),
Message::Dialog(DialogMessage::CopyPaths),
),
),
buttons::text(
ui,
buttons::Button::new(
card.toggle.as_str(),
Message::Dialog(DialogMessage::Toggle),
),
),
]
.spacing(ui.space(Sp::X2))
.align_y(Alignment::Center),
);
}
let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2));
if let Some(secondary) = &modal.secondary {
line = line.push(buttons::text(
ui,
buttons::Button::new(
secondary.as_str(),
Message::Dialog(DialogMessage::Secondary),
),
));
}
if !modal.blocking_progress {
line = line.push(buttons::filled(
ui,
buttons::Button::new(
modal.primary.as_str(),
Message::Dialog(DialogMessage::Primary),
),
));
}
column_actions.push(line.align_y(Alignment::Center)).into()
}
/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。
fn role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
) -> Element<'a, Message> {
frame::text_role(
ui,
role,
emphasized,
content,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。
fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(Font {
family: iced::font::Family::Name(ui.families().mono),
..Font::DEFAULT
})
.color(ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。
fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodyMedium);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
-121
View File
@@ -1,121 +0,0 @@
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
//!
//! ## 为什么不在页面里现拼路径
//!
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
//!
//! 本模块只做两件事:
//!
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
//!
//! ## 谁来登记
//!
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
//! `platform::data::layout()`——同一份解析链的缓存版本。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_presenter::settings_bridge::SettingsBridge;
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
let home = config.parent()?.parent()?;
Some(LauncherLayout::new(home).logs())
}
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
pub(crate) fn register(bridge: &SettingsBridge) {
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
}
/// 本进程的日志目录。
///
/// - 已登记且解析出来 ⇒ 那条;
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
pub(crate) fn logs_dir() -> Option<PathBuf> {
match LOGS_DIR.get() {
Some(known) => known.clone(),
None => crate::platform::data::layout().map(|layout| layout.logs()),
}
}
/// 确保日志目录存在(不在就建),返回给用户看的路径。
///
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir)
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
if !dir.is_dir() {
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
}
Ok(dir.to_path_buf())
}
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
///
/// # Errors
///
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
pub(crate) fn open() -> Result<PathBuf, String> {
let dir = logs_dir().ok_or_else(|| {
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
.to_string()
})?;
let dir = prepare(&dir)?;
crate::app::dialog::export::open_dir(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
///
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
#[test]
fn the_log_folder_is_the_logs_partition_next_to_config() {
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
assert_eq!(
logs_dir_of(&config),
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
);
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
}
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
///
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
#[test]
fn prepare_creates_the_folder_and_reports_the_same_path() {
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".ULL/logs");
assert!(!dir.is_dir(), "夹具目录不该已经存在");
let prepared = prepare(&dir).expect("应当建得出来");
assert_eq!(prepared, dir);
assert!(dir.is_dir(), "打开之前必须真的存在");
std::fs::remove_dir_all(&root).ok();
}
/// 失败文本带路径(「不许只在成功时可用」)。
#[test]
fn a_failure_names_the_path_it_could_not_use() {
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
std::fs::write(&file, b"x").expect("写夹具");
let error = prepare(&file).expect_err("一个文件不是目录");
assert!(error.contains(&file.display().to_string()), "{error}");
std::fs::remove_file(&file).ok();
}
}
-2
View File
@@ -67,8 +67,6 @@ pub enum Message {
Captured(window::Screenshot), Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。 /// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage), Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message),
} }
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
-3
View File
@@ -30,9 +30,6 @@
mod appearance; mod appearance;
mod backend; mod backend;
mod boot; mod boot;
pub(crate) mod diag;
mod dialog;
pub(crate) mod logs_folder;
mod message; mod message;
mod state; mod state;
mod subscription; mod subscription;
-44
View File
@@ -56,9 +56,6 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 /// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: /// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 /// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
///
/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅,
/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1); pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 /// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
@@ -133,12 +130,6 @@ pub struct App {
pub(super) refreshes: u64, pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool, pub(super) auto_selected: bool,
/// 上一次看到的**中文字体下载状态**(M168)。
///
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
/// 而空闲态不会为了它每拍白重建一次。
pub(super) font_seen: crate::fonts::install::CjkInstall,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32, pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
@@ -177,9 +168,6 @@ pub struct App {
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」, /// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。 /// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64, pub(super) install_cancelled_seen: u64,
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
/// ///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -190,22 +178,6 @@ pub struct App {
pub(super) account_form: pages::account::Form, pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。 /// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page, pub(super) account: pages::account::Page,
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
///
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
pub(super) dialog_card: Option<super::dialog::CrashCard>,
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
///
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache, pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -214,19 +186,3 @@ pub struct App {
/// [`crate::platform::win32::corner::CornerApplied`]。 /// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>, pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
} }
impl App {
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
///
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
///
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
#[cfg(test)]
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
&self.state.settings_groups
}
}
+2 -23
View File
@@ -4,7 +4,6 @@
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出, //! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。 //! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
use crate::app::diag::diag;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Instant; use std::time::Instant;
@@ -23,37 +22,17 @@ pub(super) fn app_options(options: &Options) -> AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。 // `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。 // 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(), data_root: options.data_dir.clone(),
mock: options.screenshot.is_some() && !real_host_in_screenshot(), mock: options.screenshot.is_some(),
} }
} }
/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。
///
/// ## 为什么需要它(M201)
///
/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发**
/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」
/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。
/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。
///
/// ## 边界(别把它当产品行为)
///
/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**;
/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧);
/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。
fn real_host_in_screenshot() -> bool {
std::env::var("ULL_SCREENSHOT_REAL_HOST")
.ok()
.is_some_and(|raw| raw.trim() == "1")
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。 /// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() { pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) { if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return; return;
} }
if let Some(started) = STARTED.get() { if let Some(started) = STARTED.get() {
diag!( eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0 started.elapsed().as_secs_f64() * 1000.0
); );
@@ -41,9 +41,6 @@ impl App {
Some(root) => SettingsBridge::with_path(config_path(root)), Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(), None => SettingsBridge::open(),
}; };
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
crate::app::logs_folder::register(&bridge);
Self::with_presenter(options, families, flavor, backend, bridge) Self::with_presenter(options, families, flavor, backend, bridge)
} }
@@ -89,14 +86,9 @@ impl App {
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some()); let lang = LangState::boot(&bridge, options.screenshot.is_some());
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
crate::fonts::install::set_offline(options.screenshot.is_some());
let mut view = pages::Model::new( let mut view = pages::Model::new(
pages::Screen::Instances, pages::Screen::Instances,
width, width,
height,
Some(pages::Screen::Instances.nav()), Some(pages::Screen::Instances.nav()),
); );
view.lang = lang.lang(); view.lang = lang.lang();
@@ -121,9 +113,6 @@ impl App {
frames: 0, frames: 0,
refreshes: 0, refreshes: 0,
auto_selected: false, auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height, height,
appearance, appearance,
appearance_prefs, appearance_prefs,
@@ -135,11 +124,7 @@ impl App {
agent_host: super::presenter::AgentHost::from_env(), agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(), download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0,
dialog_card: None,
dialog_model: None,
content: pages::content::PageState::default(), content: pages::content::PageState::default(),
account_form: pages::account::Form::new(), account_form: pages::account::Form::new(),
account: pages::account::Page::build( account: pages::account::Page::build(
@@ -154,10 +139,6 @@ impl App {
}; };
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui(); app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log(); app.lang.log();
-52
View File
@@ -6,11 +6,8 @@
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::view;
use crate::pages; use crate::pages;
use unlucky_presenter::router::Route;
use super::App; use super::App;
impl App { impl App {
@@ -37,26 +34,6 @@ impl App {
self.state.toasts.push(ToastKind::Info, text); self.state.toasts.push(ToastKind::Info, text);
} }
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
if self.state.launch_ends != self.launch_ends_seen {
self.launch_ends_seen = self.state.launch_ends;
if let Some(finished) = self.state.last_launch_end.as_deref() {
let text = view::instances::launch_toast(finished, self.lang());
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
&& finished.failure.is_none()
{
ToastKind::Success
} else {
ToastKind::Error
};
self.state.toasts.push(kind, text);
// M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。
let ended = finished.clone();
self.open_failure_dialog(&ended);
}
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category; let category = self.view.download.category;
@@ -69,45 +46,16 @@ impl App {
self.view = super::super::view::model( self.view = super::super::view::model(
&self.state, &self.state,
width, width,
self.height,
self.options.force_state, self.options.force_state,
category, category,
&draft, &draft,
self.lang(), self.lang(),
&self.content, &self.content,
); );
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。 // 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width); self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
self.dialog_model = self.dialog_modal();
self.view_dirty = false; self.view_dirty = false;
self.refreshes += 1; self.refreshes += 1;
} }
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
} }
+2 -3
View File
@@ -5,7 +5,6 @@
//! //!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。 //! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::prefs::ThemeMode;
@@ -31,7 +30,7 @@ impl App {
self.appearance.set_flavor(flavor); self.appearance.set_flavor(flavor);
let previous = self.ui.name(); let previous = self.ui.name();
self.rebuild_ui(); self.rebuild_ui();
diag!( eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)", "[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous, previous,
self.ui.name() self.ui.name()
@@ -49,7 +48,7 @@ impl App {
let panel_alpha = self.appearance.effective_panel_alpha(); let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() { self.ui = match self.appearance.derived_scheme() {
Some(scheme) => { Some(scheme) => {
diag!( eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level() self.appearance.palette_level()
); );
-256
View File
@@ -11,14 +11,10 @@
//! | 条件 | 为什么需要 33 ms 节拍 | //! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---| //! |---|---|
//! | 截图通道 | 第 3 帧要落盘 | //! | 截图通道 | 第 3 帧要落盘 |
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | //! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 | //! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | //! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//! //!
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 //! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//! //!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2) //! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
@@ -47,7 +43,6 @@ use std::time::Instant;
use iced::{window, Subscription}; use iced::{window, Subscription};
use super::components::motion;
use super::message::Message; use super::message::Message;
use super::state::App; use super::state::App;
@@ -90,261 +85,10 @@ impl App {
/// 现在要不要**时钟**(见模块文档的表)。 /// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool { pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot() self.has_screenshot()
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|| motion::needs_frames()
|| !self.state.toasts.is_empty() || !self.state.toasts.is_empty()
|| !self.state.running().is_empty() || !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self || self
.watch_until .watch_until
.is_some_and(|deadline| Instant::now() < deadline) .is_some_and(|deadline| Instant::now() < deadline)
} }
} }
#[cfg(test)]
mod tests {
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
//!
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
//!
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
use super::*;
use crate::app::components::motion::{Slot, FRAME_MS};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
///
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
fn idle_app() -> App {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
///
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
#[test]
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
let mut app = idle_app();
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(
app.watch_until.is_none(),
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
);
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
assert!(
app.needs_frames(),
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
);
run_until_idle();
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
assert!(
!app.needs_frames(),
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
);
motion::reset();
}
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
#[test]
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
let mut app = idle_app();
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
run_until_idle();
assert!(!app.needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
run_until_idle();
assert!(!app.needs_frames());
motion::reset();
}
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
#[test]
fn pages_that_render_elsewhere_do_not_start_a_track() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
assert!(
!motion::needs_frames(),
"账户页没有 pages 树可动,不许白开帧"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
run_until_idle();
motion::reset();
}
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
#[test]
fn without_a_window_nothing_animates() {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
let _ = app.update(Message::Boot);
app.watch_until = None;
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
run_until_idle();
motion::reset();
}
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
///
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
#[test]
fn no_scale_requests_no_frames_at_all() {
motion::reset();
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
motion::configure(MotionScale::NONE, None);
// 三种状态变化:换页 / 换详情 / 再换回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(!app.needs_frames());
run_until_idle();
assert!(!motion::needs_frames());
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
motion::reset();
}
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
///
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
#[test]
fn there_is_exactly_one_frame_subscription() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rust_files(&src, &mut files);
assert!(
files.len() >= 40,
"只扫到 {} 个文件:扫描逻辑坏了",
files.len()
);
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join(
"
",
);
let count = code.matches("window::frames()").count();
if count > 0 {
let name = path
.strip_prefix(&src)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
hits.push((name, count));
}
}
hits.sort();
let expected = [
("app/subscription.rs".to_string(), 1_usize),
("pages/harness.rs".to_string(), 1_usize),
];
assert_eq!(
hits, expected,
"`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\
与 `pages/harness.rs`(独立 example 的自有 app),且各一处"
);
}
/// 递归收集 `dir/**/*.rs`。
fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("读目录") {
let path = entry.expect("目录项").path();
if path.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
#[test]
fn slots_are_the_three_documented_ones() {
assert_eq!(Slot::ALL.len(), 3);
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
}
}
-2
View File
@@ -12,8 +12,6 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)] #[cfg(test)]
mod appearance; mod appearance;
#[cfg(test)] #[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter; mod presenter;
use super::message::Message; use super::message::Message;
+2 -2
View File
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings); assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP); assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
drop(_tree); drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。 // 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0)); app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0); assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
} }
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。 /// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
@@ -1,145 +0,0 @@
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
//!
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
//!
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
LoaderInfo, LoaderKind,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::message::Message;
use crate::app::state::{App, TICK};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
#[derive(Debug, Default)]
struct FailingSaveBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
}
impl Backend for FailingSaveBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command.clone());
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
self.queued.push(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Error,
text: "内存超出范围".to_string(),
remaining_ms: 0,
}));
}
}
}
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
fn snapshot() -> Vec<InstanceSummary> {
vec![InstanceSummary {
id: "survival".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: LaunchArgsView {
instance_id: "survival".to_string(),
memory_mb: Some(4096),
memory_effective_mb: 4096,
..LaunchArgsView::default()
},
}]
}
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let backend = FailingSaveBackend {
dispatched: Rc::clone(&dispatched),
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
};
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
///
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
/// 下面的前置断言会红)。
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
#[test]
fn failed_save_keeps_the_draft_through_the_whole_loop() {
let (mut app, dispatched) = app_with_snapshot();
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
app.advance(TICK);
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
assert!(!app.launch_form.dirty);
// 用户把内存改成非法值(host 会拒),然后点保存。
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Memory("4g".to_string()),
)));
assert_eq!(app.launch_form.memory, "4g");
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Save,
)));
assert!(
dispatched
.borrow()
.iter()
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
"保存要把命令派发出去"
);
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
app.advance(TICK);
assert_eq!(
app.launch_form.memory, "4g",
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
);
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
}
+12 -102
View File
@@ -20,15 +20,11 @@
//! //!
//! | 场景 | 时钟 | 周期 | 谁触发 | //! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---| //! |---|---|---|---|
//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | //! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] | //! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
//! //!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
//! 下一拍就退回心跳(见 `components::motion`)。
use crate::app::components::motion;
use crate::app::diag::diag;
use std::time::Instant; use std::time::Instant;
use iced::{window, Task}; use iced::{window, Task};
@@ -48,44 +44,16 @@ use super::CAPTURE_AT_FRAME;
mod account; mod account;
mod agent; mod agent;
mod appearance; mod appearance;
pub(super) mod boot_env; mod boot_env;
mod clock; mod clock;
mod content; mod content;
mod content_pack;
mod dialog;
mod dialog_fixture;
mod download; mod download;
mod instances;
mod motion_sync;
mod page_messages; mod page_messages;
mod settings; mod settings;
mod update_action;
/// 这一条键盘事件是不是 `Esc` 按下。
fn is_escape(event: &iced::keyboard::Event) -> bool {
matches!(
event,
iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}
)
}
impl App { impl App {
/// 一条消息进来,改状态并返回后续任务。 /// 一条消息进来,改状态并返回后续任务。
///
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
pub(super) fn update(&mut self, message: Message) -> Task<Message> { pub(super) fn update(&mut self, message: Message) -> Task<Message> {
let task = self.route_message(message);
self.sync_motion();
task
}
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
fn route_message(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::Boot => self.on_boot(), Message::Boot => self.on_boot(),
Message::WindowReady(id) => { Message::WindowReady(id) => {
@@ -104,7 +72,7 @@ impl App {
match hwnd { match hwnd {
Some(_) => self.apply_window_alpha("boot"), Some(_) => self.apply_window_alpha("boot"),
None => { None => {
diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None); self.log_appearance("boot", None);
} }
} }
@@ -118,7 +86,7 @@ impl App {
iced::theme::Mode::Dark => SchemeFlavor::Dark, iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light, iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
}; };
diag!("[theme] 系统主题 = {mode:?}"); eprintln!("[theme] 系统主题 = {mode:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。 // 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现 // 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。 // `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
@@ -148,16 +116,7 @@ impl App {
Task::none() Task::none()
} }
Message::Page(message) => self.on_page(message), Message::Page(message) => self.on_page(message),
Message::Dialog(message) => dialog::on_dialog(self, message), Message::Agent(message) => self.on_agent(message),
Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message {
if is_escape(event) && dialog::escape(self) {
return Task::none();
}
}
self.on_agent(message)
}
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => { Message::Select(nav) => {
self.state.navigate(route_of(nav)); self.state.navigate(route_of(nav));
@@ -185,7 +144,7 @@ impl App {
Message::Captured(screenshot) => self.on_captured(&screenshot), Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => { Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收) // gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
diag!("[gallery] {} pressed", message.id()); eprintln!("[gallery] {} pressed", message.id());
Task::none() Task::none()
} }
} }
@@ -198,16 +157,12 @@ impl App {
fn on_boot(&mut self) -> Task<Message> { fn on_boot(&mut self) -> Task<Message> {
let mut tasks = vec![window::latest().map(Message::WindowReady)]; let mut tasks = vec![window::latest().map(Message::WindowReady)];
// M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。
// 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。
self.configure_motion();
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。 // M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络; // 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨 // 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
// 「没配置」与「配了但登录失败」。 // 「没配置」与「配了但登录失败」。
diag!("{}", unlucky_cli::commands::msa_client_id_log_line()); eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line());
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
@@ -218,8 +173,6 @@ impl App {
self.appearance.apply_env(); self.appearance.apply_env();
self.rebuild_ui(); self.rebuild_ui();
self.log_appearance("boot", None); self.log_appearance("boot", None);
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
boot_env::record_boot_log(self);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() { if self.options.wants_system_theme() {
@@ -238,7 +191,7 @@ impl App {
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope, // 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。 // 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = boot_env::env_start_route() { if let Some(route) = boot_env::env_start_route() {
diag!("[boot] ULL_START_ROUTE → {route:?}"); eprintln!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route); self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。 // 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true; self.auto_selected = true;
@@ -246,10 +199,6 @@ impl App {
self.ensure_content_loaded(); self.ensure_content_loaded();
} }
account::force_account_state(self); account::force_account_state(self);
// M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。
if let Some(mode) = dialog_fixture::apply(self) {
diag!("[boot] ULL_DIALOG_FIXTURE → {mode}");
}
self.auto_select(); self.auto_select();
self.view_dirty = true; self.view_dirty = true;
@@ -261,66 +210,27 @@ impl App {
Task::batch(tasks) Task::batch(tasks)
} }
/// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。
fn configure_motion(&mut self) {
let pin = boot_env::env_motion_pin();
let gpu = matches!(
super::backend::effective_kind(),
Some(crate::renderer::Kind::Wgpu)
);
let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some());
motion::configure(scale, pin);
// 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。
diag!(
"[motion] scale={:.2} pin={:?} gpu={} screenshot={}",
motion::scale().factor(),
pin,
gpu,
self.options.screenshot.is_some()
);
}
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。 /// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
/// ///
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> { fn on_heartbeat(&mut self) -> Task<Message> {
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
// 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把
// 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」
// 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。
motion::advance();
if !self.needs_frames() { if !self.needs_frames() {
let now = Instant::now(); let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK); let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
self.last_tick = now; self.last_tick = now;
self.advance(elapsed); self.advance(elapsed);
} }
// M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒
// 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。
self.motion_fixture_after_idle();
heartbeat() heartbeat()
} }
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第 /// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
/// [`CAPTURE_AT_FRAME`] 帧出图。
/// ///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。 /// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> { fn on_frame(&mut self) -> Task<Message> {
self.frames += 1; self.frames += 1;
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
motion::advance();
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
self.motion_fixture();
}
let now = Instant::now(); let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick); let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK { if elapsed >= TICK {
@@ -346,9 +256,9 @@ impl App {
}; };
match crate::shot::save(screenshot, &path) { match crate::shot::save(screenshot, &path) {
Ok(summary) => { Ok(summary) => {
diag!("{}", summary.describe()); eprintln!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。 // M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
diag!( eprintln!(
"[shot] screen={} theme={} renderer={} route={:?}", "[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(), self.options.screen.name(),
crate::shot::flavor_slug(&self.ui), crate::shot::flavor_slug(&self.ui),
@@ -356,7 +266,7 @@ impl App {
self.state.route() self.state.route()
); );
} }
Err(error) => diag!("[shot] 失败:{error}"), Err(error) => eprintln!("[shot] 失败:{error}"),
} }
iced::exit() iced::exit()
+2 -25
View File
@@ -95,7 +95,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request)); self.dispatch(AppCommand::AddAuthlibAccount(request));
} }
} }
Panel::Microsoft => return self.on_account(Account::StartDevice), Panel::Microsoft => return self.on_account(Account::StartBrowser),
Panel::None => {} Panel::None => {}
}, },
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -136,8 +136,7 @@ impl App {
match account.kind { match account.kind {
AccountKind::Microsoft => { AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft; self.account_form.panel = Panel::Microsoft;
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。 self.dispatch(AppCommand::StartBrowserLogin);
self.dispatch(AppCommand::StartDeviceLogin);
} }
AccountKind::Authlib => { AccountKind::Authlib => {
self.account_form.open(Track::Authlib); self.account_form.open(Track::Authlib);
@@ -174,23 +173,6 @@ pub(super) fn force_account_state(app: &mut App) {
app.state.device_login_started = app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600)); Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
} }
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
//
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
"msa-fallback" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
app.state.apply_device_login(
unlucky_presenter::dto::DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
)
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
);
}
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)` // 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => { "authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib; app.account_form.panel = crate::pages::account::Panel::Authlib;
@@ -216,11 +198,6 @@ pub(super) fn force_account_state(app: &mut App) {
"closed" => { "closed" => {
let _ = app.on_account(crate::pages::account::Message::Close); let _ = app.on_account(crate::pages::account::Message::Close);
} }
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
"msa-entry" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态) // 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => { "add" => {
app.account_form.section_open = true; app.account_form.section_open = true;
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
let _ = app.on_account(Account::StartDevice); let _ = app.on_account(Account::StartDevice);
match &app.account.device { match &app.account.device {
DevicePanel::Code { DevicePanel::Code {
code, countdown, .. code, remaining, ..
} => { } => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)"); assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
} }
other => panic!("进行中状态必须可见,实际 {other:?}"), other => panic!("进行中状态必须可见,实际 {other:?}"),
} }
+5 -6
View File
@@ -29,7 +29,6 @@
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻 //! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。 //! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
use crate::app::diag::diag;
use iced::keyboard::{key, Key}; use iced::keyboard::{key, Key};
use iced::Task; use iced::Task;
@@ -142,10 +141,10 @@ impl App {
self.drain_agent(); self.drain_agent();
} }
SubmitOutcome::Failed(failure) => { SubmitOutcome::Failed(failure) => {
diag!("[agent] 提交失败({})", failure.code()); eprintln!("[agent] 提交失败({})", failure.code());
} }
SubmitOutcome::Rejected => { SubmitOutcome::Rejected => {
diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住"); eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
} }
} }
} }
@@ -205,7 +204,7 @@ impl App {
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm { if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow; self.agent.focus = Focus::ConfirmAllow;
diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)"); eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm { } else if !pending && on_confirm {
self.agent.focus = Focus::Composer; self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false; self.agent.keyboard_mode = false;
@@ -280,7 +279,7 @@ impl App {
} }
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent { if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
diag!("[agent] rail=detached → Route::Agent"); eprintln!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent); self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。 // 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true; self.auto_selected = true;
@@ -372,7 +371,7 @@ impl App {
let _ = self.on_agent(message); let _ = self.on_agent(message);
} else { } else {
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。 // 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
diag!( eprintln!(
"[agent] Enter on focus={} handled by the composer", "[agent] Enter on focus={} handled by the composer",
self.agent.focus.name() self.agent.focus.name()
); );
+1 -2
View File
@@ -28,7 +28,6 @@
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 //! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
//! (M116 取消了 8/12/16 固定档)。 //! (M116 取消了 8/12/16 固定档)。
use crate::app::diag::diag;
use crate::platform; use crate::platform;
use crate::platform::win32::corner::{self, CornerApplied}; use crate::platform::win32::corner::{self, CornerApplied};
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome}; use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
@@ -74,7 +73,7 @@ impl App {
"frame=? layered=?".to_owned(), "frame=? layered=?".to_owned(),
), ),
}; };
diag!( eprintln!(
"{}", "{}",
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by) corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
); );
+2 -149
View File
@@ -1,11 +1,8 @@
//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。 //! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。
//! //!
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线): //! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组 //! **纯函数、无状态**,只是取值表长到了七条。
//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处,
//! 帧泵自己不读环境变量。
use unlucky_design::motion::MotionScale;
use unlucky_presenter::router::Route; use unlucky_presenter::router::Route;
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。 /// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
@@ -27,147 +24,3 @@ pub(super) fn env_start_route() -> Option<Route> {
_ => None, _ => None,
} }
} }
/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。
///
/// ## 为什么档位在这里算
///
/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端
/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。
/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略
/// 只有一处,测试也不必摆环境变量。
///
/// | 条件 | 档位 | 为什么 |
/// |---|---|---|
/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) |
/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) |
/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 |
/// | 其余(`wgpu`) | `STANDARD` | —— |
/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) |
///
/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up),
/// 所以本函数只认上面这些来源。
pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale {
match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) {
Some("off") | Some("none") => return MotionScale::NONE,
Some("half") => return MotionScale::HALF,
Some("full") => return MotionScale::STANDARD,
_ => {}
}
if pinned {
// 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。
return MotionScale::STANDARD;
}
if screenshot {
return MotionScale::NONE;
}
if gpu {
MotionScale::STANDARD
} else {
MotionScale::HALF
}
}
/// `ULL_MOTION_AT=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
///
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
pub(super) fn env_motion_pin() -> Option<f64> {
std::env::var("ULL_MOTION_AT")
.ok()?
.trim()
.parse::<f64>()
.ok()
.filter(|ms| ms.is_finite() && *ms >= 0.0)
}
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
///
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
#[derive(Debug, Clone)]
pub(super) enum MotionFixture {
/// 跳到一个一级页(走「换页」那一档)。
Route(Route),
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
Detail,
}
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() {
"instances" => MotionFixture::Route(Route::Instances),
"download" => MotionFixture::Route(Route::Download),
"logs" => MotionFixture::Route(Route::Logs),
"settings" => MotionFixture::Route(Route::Settings),
"content" => MotionFixture::Route(Route::Content),
"detail" => MotionFixture::Detail,
_ => return None,
})
}
/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。
///
/// ## 为什么需要它(真机空闲取证)
///
/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话
/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与
/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路:
/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。
///
/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()`
/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。
pub(super) fn env_motion_fixture_at() -> Option<u32> {
std::env::var("ULL_MOTION_FIXTURE_AT")
.ok()?
.trim()
.parse::<u32>()
.ok()
.filter(|beats| *beats > 0)
}
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
///
/// ## 为什么这一拍只能「先记、后落」
///
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
///
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
///
/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走
/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」
/// 的进程也会留下 `launcher.log`。
pub(crate) fn record_boot_log(app: &super::super::state::App) {
use unlucky_core::launch::launcher_log;
launcher_log::record("boot", "app boot");
launcher_log::record(
"boot",
&format!(
"theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}",
app.ui().name(),
app.lang(),
app.state.route(),
app.width,
app.height,
app.has_screenshot(),
app.state.instances.len(),
),
);
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
// 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到
// stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。
launcher_log::record(
"boot",
&format!(
"backend request={} effective={} renderer_fallback={}",
app.options.backend.name(),
super::super::backend::effective_backend(app.options.backend).name(),
std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()),
),
);
}
+4 -36
View File
@@ -17,7 +17,6 @@
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干, //! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。 //! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
use crate::app::diag::diag;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use unlucky_presenter::dto::AppCommand; use unlucky_presenter::dto::AppCommand;
@@ -43,16 +42,6 @@ impl App {
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true; self.view_dirty = true;
} }
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
// 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
let font_status = crate::fonts::install::status();
if font_status != self.font_seen {
self.font_seen = font_status;
self.view_dirty = true;
}
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
self.hydrate_java_scan();
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s, // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -75,14 +64,10 @@ impl App {
let reloaded = let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some(); !self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded { if reloaded {
diag!("[settings] 配置文件变了,重读主题 + 外观"); eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme(); self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance(); self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
} }
if self.view_dirty { if self.view_dirty {
@@ -93,8 +78,8 @@ impl App {
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)—— // 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
diag!( eprintln!(
"[tick] n={} frames={} refreshes={} clock={} motion={}", "[tick] n={} frames={} refreshes={} clock={}",
self.ticks, self.ticks,
self.frames, self.frames,
self.refreshes, self.refreshes,
@@ -102,10 +87,7 @@ impl App {
"frames" "frames"
} else { } else {
"idle-1hz" "idle-1hz"
}, }
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
// 也是「动画期间重绘成本」的记账口径,见报告)
crate::app::components::motion::samples()
); );
} }
} }
@@ -129,26 +111,12 @@ impl App {
if events.is_empty() { if events.is_empty() {
return; return;
} }
let mut clean_scan = false;
for event in events { for event in events {
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
if matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
pack
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
) {
clean_scan = true;
}
self.state.apply(event); self.state.apply(event);
} }
self.view_dirty = true; self.view_dirty = true;
self.auto_select(); self.auto_select();
self.refresh_view(); self.refresh_view();
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
if clean_scan {
self.continue_import_after_clean_scan();
}
} }
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。 /// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
+144 -43
View File
@@ -16,10 +16,9 @@
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时 //! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use crate::app::diag::diag;
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource, AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
ExportRequestView, ImportRequestView, PackCommand,
}; };
use crate::pages::content::{Blocker, Content}; use crate::pages::content::{Blocker, Content};
@@ -78,12 +77,7 @@ impl App {
self.with_query(|query| query.loader = loader) self.with_query(|query| query.loader = loader)
} }
pages::content::Message::Select(id) => { pages::content::Message::Select(id) => {
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板) self.content.selected = Some(id);
self.content.selected = Some(id.clone());
self.state.content.request_detail(&id);
if let Some(request) = self.content_detail_request() {
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
}
self.view_dirty = true; self.view_dirty = true;
} }
pages::content::Message::SetTarget(id) => { pages::content::Message::SetTarget(id) => {
@@ -104,26 +98,155 @@ impl App {
self.dispatch(AppCommand::InstallContent(Box::new(request))); self.dispatch(AppCommand::InstallContent(Box::new(request)));
} }
} }
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
pages::content::Message::RetryFailed => {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if let Some(request) = model.retry_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M113: import/export —— 导入/导出区(细节在 `on_pack`) // M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message), pages::content::Message::Pack(message) => self.on_pack(message),
} }
Task::none() Task::none()
} }
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
overwrite,
})
}
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。 /// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
/// ///
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求** /// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。 /// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> { fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?; let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export( Some(pages::content::request_export(
&self.content.pack, &self.content.pack,
@@ -176,26 +299,6 @@ impl App {
.is_some_and(|results| results.curseforge) .is_some_and(|results| results.curseforge)
} }
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
///
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref());
Some(ContentDetailRequest {
project: hit.id.clone(),
source: hit.source,
game_version: target.map(|target| target.mc_version.clone()),
loader: target.map(|target| target.loader),
})
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。 /// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
/// ///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`), /// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
@@ -209,10 +312,10 @@ impl App {
if !model.can_install() { if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做 // 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) { for reason in model.blocker_reasons(catalog) {
diag!("[content] 装入被挡下:{reason}"); eprintln!("[content] 装入被挡下:{reason}");
} }
if model.blockers.contains(&Blocker::NeedsPipeline) { if model.blockers.contains(&Blocker::NeedsPipeline) {
diag!("[content] 整合包装入未实现(M111)"); eprintln!("[content] 整合包装入未实现(M111)");
} }
return None; return None;
} }
@@ -231,8 +334,6 @@ impl App {
instance: target.id.clone(), instance: target.id.clone(),
game_version: target.mc_version.clone(), game_version: target.mc_version.clone(),
loader: target.loader, loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
}) })
} }
} }
@@ -1,240 +0,0 @@
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
//!
//! ## 为什么单独一个文件
//!
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
//!
//! ## 两条口径(与原来同一句)
//!
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
//! 状态,不进 presenter;
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
//!
//! ## M160 加的那条纪律
//!
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
use unlucky_presenter::dto::{
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
};
use crate::pages;
use super::*;
impl App {
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
if let Some(request) = self.pack_import_request() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
request,
)))));
}
}
PackMessage::PickPage(page) => {
self.content.pack.pick_page = page;
self.view_dirty = true;
}
PackMessage::TogglePick(path) => {
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
// (所以"一次都没点过"仍然等于 `KeepAll`)。
let choice = self.content.pack.pick_choice(&path);
let flipped = match choice {
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
};
self.content.pack.pick_choices.insert(path, flipped);
self.view_dirty = true;
}
PackMessage::PickKeepAll => {
self.content.pack.pick_overwrite_all = false;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::PickOverwriteAll => {
self.content.pack.pick_overwrite_all = true;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::ConfirmImport => {
let decisions = self
.content
.pack
.decisions(self.state.pack.import.scan.as_ref());
if let Some(request) = self.pack_import_request_with(decisions) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::CancelImport => {
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
if let Some(request) =
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
{
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 预检干净(没有冲突)时直接往下导。
///
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
/// 结论也只会是"保留用户的"。
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
fn pack_import_request(&self) -> Option<ImportRequestView> {
self.pack_import_request_with(ConflictDecisionsView::default())
}
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
fn pack_import_request_with(
&self,
decisions: ConflictDecisionsView,
) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
decisions,
})
}
}
-104
View File
@@ -1,104 +0,0 @@
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
use iced::Task;
use unlucky_presenter::toast::ToastKind;
use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message {
DialogMessage::Primary => {
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else {
app.close_dialog();
return Task::none();
};
let done = export::export(&card.report, app.lang());
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
// (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
app.state.toasts.push(
if done.ok {
ToastKind::Success
} else {
ToastKind::Error
},
done.text.clone(),
);
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(done.text);
}
app.view_dirty = true;
app.refresh_view();
Task::none()
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
app.refresh_view();
Task::none()
}
DialogMessage::Toggle => {
app.toggle_dialog_detail();
Task::none()
}
DialogMessage::OpenFolder => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let problem = export::open_folder(&card.report, app.lang());
if !problem.is_empty() {
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(problem.clone());
}
app.state.toasts.push(ToastKind::Error, problem);
app.view_dirty = true;
app.refresh_view();
}
Task::none()
}
DialogMessage::CopyPaths => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(export::paths_text(&card.report))
}
DialogMessage::CopyPath(index) => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(path.display().to_string())
}
}
}
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool {
if app.state.dialogs.current().is_none() {
return false;
}
app.close_dialog();
app.refresh_view();
true
}
@@ -1,152 +0,0 @@
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
//!
//! ## 为什么要有它
//!
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
//!
//! 取值:
//!
//! | 值 | 现场 |
//! |---|---|
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
//!
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
use std::path::{Path, PathBuf};
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_scan::{self, CrashSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use crate::app::state::App;
/// 现场根(`%TEMP%/ull-m187-fixture-<pid>`)。
fn root() -> PathBuf {
std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id()))
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, text);
}
/// 出图用的夹具(`None` = 没有开关,什么都不做)。
pub(super) fn apply(app: &mut App) -> Option<String> {
let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?;
let mode = mode.trim().to_lowercase();
if !app.has_screenshot() {
return None;
}
let root = root();
let _ = std::fs::remove_dir_all(&root);
let logs = root.join("logs");
write(
&logs.join("launcher.log"),
"2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\
2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\
2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n",
);
write(&logs.join("install.log"), "installed 1.20.4-fabric\n");
let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e";
let session_log = root
.join("sessions")
.join("game")
.join(format!("{session}.jsonl"));
write(
&session_log,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\
{\"kind\":\"exit\",\"payload\":{\"code\":1}}\n",
);
let tail: Vec<String> = vec![
"[main/INFO]: Loading Minecraft 1.20.4".to_string(),
"java.lang.RuntimeException: mixin injection failed".to_string(),
"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(),
];
let run_dir = root.join("instances").join("survival");
let latest = run_dir.join("logs").join("latest.log");
let version_json = root
.join("game")
.join("versions")
.join("1.20.4-fabric")
.join("1.20.4-fabric.json");
write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n");
let mut artifacts: Vec<ArtifactFact> = Vec::new();
if mode != "no-logs" {
write(
&run_dir
.join("crash-reports")
.join("crash-2026-11-08_15.30.19-client.txt"),
"---- Minecraft Crash Report ----\n\
Time: 2026-11-08 15:30:19\n\
Description: Initializing game\n\n\
java.lang.RuntimeException: mixin injection failed\n\
\tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\
\tat net.minecraft.client.main.Main.main(Main.java:51)\n",
);
write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n");
let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
artifacts = crash_scan::scan(&run_dir, since, 8)
.into_iter()
.map(|artifact| ArtifactFact {
source: artifact.source.as_str().to_string(),
path: artifact.path,
excerpt: artifact.excerpt,
})
.collect();
}
let report = CrashReport {
instance: "survival".to_string(),
name: "生存服的 1.20.4".to_string(),
version: "1.20.4-fabric".to_string(),
session: session.to_string(),
session_log: Some(session_log),
version_json: Some(version_json),
launcher_logs: Some(logs),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 7_400,
command: "javaw -Xmx4096m -Djava.library.path=<home>… ".to_string(),
java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(),
system: "os=windows version=10.0.26100 arch=x86_64".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
};
crash_report::publish(report);
let finished = LaunchFinished {
id: "survival".to_string(),
session: session.to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 7_400,
failure: None,
};
app.open_failure_dialog(&finished);
if mode == "export" {
// 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。
let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary);
}
Some(mode)
}
/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。
#[allow(dead_code)]
pub(super) fn crash_report_source() -> &'static str {
CrashSource::CrashReport.as_str()
}
-174
View File
@@ -1,174 +0,0 @@
//! 实例页「启动参数」区的消息落点(M157)。
//!
//! 三件事:
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 启动参数区的一条消息。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else {
return Task::none();
};
if !self.launch_form.bound_to(&instance) {
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
return Task::none();
}
match message {
InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text;
self.launch_form.dirty = true;
}
InstanceMessage::Game(text) => {
self.launch_form.game = text;
self.launch_form.dirty = true;
}
InstanceMessage::Memory(text) => {
self.launch_form.memory = text;
self.launch_form.dirty = true;
}
InstanceMessage::Java(text) => {
self.launch_form.java = text;
self.launch_form.dirty = true;
}
InstanceMessage::Backend(id) => {
self.launch_form.backend = id;
self.launch_form.dirty = true;
}
InstanceMessage::Reset => {
self.launch_form.reset();
}
InstanceMessage::Save => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch();
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::{InstanceMessage, LaunchForm};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn app_with_form() -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::mock::MockBackend::new()),
SettingsBridge::memory_only(),
);
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
app.state.navigate(unlucky_presenter::router::Route::Detail(
INSTANCE.to_string(),
));
app.launch_form = LaunchForm::from_view(
Some(INSTANCE),
&LaunchArgsView {
instance_id: INSTANCE.to_string(),
..LaunchArgsView::default()
},
);
app
}
/// 测试用的实例 id(mock 后端的实例快照里有它)。
const INSTANCE: &str = "survival";
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
#[test]
fn editing_marks_dirty_and_reset_waits_for_save() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
assert_eq!(app.launch_form.memory, "6144");
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
assert_eq!(app.launch_form.backend, "vulkan");
let _ = app.on_instance_message(InstanceMessage::Reset);
assert_eq!(app.launch_form.memory, "");
assert_eq!(app.launch_form.backend, "default");
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form();
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
assert_eq!(app.launch_form.memory, "");
assert!(!app.launch_form.dirty);
}
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
///
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
#[test]
fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save);
assert!(
app.launch_form.dirty,
"派发后仍要保留脏标记,等回程快照确认"
);
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
}
}
@@ -1,166 +0,0 @@
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
//!
//! ## 为什么这条不能在 `view` 里
//!
//! iced 0.14 的处理顺序是:
//!
//! ```text
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
//! ```
//!
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
//! **结构上**不可能暴露这条。
//!
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
//!
//! ## 标识从哪来
//!
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
use unlucky_presenter::router::Route;
use crate::app::components::motion;
use crate::pages;
use super::boot_env::MotionFixture;
use super::*;
impl App {
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
pub(in crate::app) fn sync_motion(&mut self) {
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
if self.window.is_none() && !self.has_screenshot() {
return;
}
let route = self.state.route();
let screen = screen_key(route);
let detail = match route {
Route::Detail(id) => Some(id.as_str()),
_ => None,
};
motion::observe(screen, detail);
}
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
pub(in crate::app) fn motion_fixture(&mut self) {
let Some(fixture) = super::boot_env::env_motion_fixture() else {
return;
};
let route = match (fixture, self.frames) {
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
(MotionFixture::Detail, 1) => Route::Instances,
(MotionFixture::Detail, 2) => {
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
Route::Detail(first)
}
(MotionFixture::Route(route), 1) => route,
_ => return,
};
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
///
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
/// 返回 `true` = 这一拍真的跳了页。
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
use std::sync::atomic::{AtomicU32, Ordering};
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
let Some(target) = boot_env::env_motion_fixture_at() else {
return false;
};
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
if beat != target {
return false;
}
let route = match boot_env::env_motion_fixture() {
Some(MotionFixture::Route(route)) => route,
Some(MotionFixture::Detail) => {
let Some(id) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return false;
};
Route::Detail(id)
}
None => Route::Download,
};
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
true
}
}
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
///
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<Screen>`,这里
/// 再折成 `Option<u64>` 给帧泵用。
fn screen_key(route: &Route) -> Option<u64> {
let screen = pages::Screen::of(route)?;
pages::Screen::ALL
.iter()
.position(|candidate| *candidate == screen)
.map(|index| index as u64 + 1)
}
#[cfg(test)]
mod tests {
use super::*;
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
#[test]
fn only_the_five_screens_get_a_key() {
let keys: Vec<Option<u64>> = [
Route::Instances,
Route::Download,
Route::Content,
Route::Settings,
Route::Logs,
Route::Detail("survival".to_string()),
Route::Accounts,
Route::Agent,
]
.iter()
.map(screen_key)
.collect();
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
assert_eq!(
keys[5],
Some(1),
"详情页归「实例」那一屏(与 Screen::of 同源)"
);
assert_eq!(keys[6], None, "账户页不归 pages 画");
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
}
}
@@ -21,7 +21,6 @@
use unlucky_i18n::{Lang, LangChoice}; use unlucky_i18n::{Lang, LangChoice};
use crate::app::diag::diag;
use crate::pages; use crate::pages;
use crate::ui::lang::LangState; use crate::ui::lang::LangState;
@@ -55,8 +54,6 @@ impl App {
} }
} }
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change), pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发 // M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -91,27 +88,11 @@ impl App {
self.view_dirty = true; self.view_dirty = true;
self.refresh_view(); self.refresh_view();
} }
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
pages::Message::Instances(message) => return self.on_instance_message(message),
// M114:账户页(三轨登录 / 列表动作 / 表单输入) // M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message), pages::Message::Account(message) => return self.on_account(message),
// M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推
// (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。
pages::Message::Action(pages::Action::OpenLogs) => {
return match crate::app::logs_folder::open() {
Ok(dir) => {
diag!("[logs] 已打开日志文件夹 {}", dir.display());
Task::none()
}
Err(error) => {
diag!("[logs] 打开日志文件夹失败:{error}");
iced::clipboard::write(error)
}
};
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => { pages::Message::Action(action) => {
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
} }
} }
@@ -142,7 +123,9 @@ impl App {
self.lang = LangState::of(choice); self.lang = LangState::of(choice);
self.lang.log(); self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) { if !self.bridge.persist_ui_language(choice.setting_value()) {
diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"); eprintln!(
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
} }
self.lang.log_cjk(); self.lang.log_cjk();
self.view_dirty = true; self.view_dirty = true;
@@ -1,170 +1,17 @@
//! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。 //! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
//! //!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在 //! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
//! //!
//! | 消息 | 做什么 | //! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
//! |---|---|
//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) |
//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) |
//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` |
//!
//! ## 为什么「保存」不派 `AppCommand::SetSetting`
//!
//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会
//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路
//! (`SettingStore::set` + `save`),只是不经过那条通道。
//!
//! ## 为什么「测试」读文件而不是读草稿
//!
//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
use super::super::*; use super::super::*;
use crate::pages::settings::ai::{Probe, ProbeResult};
impl App { impl App {
/// 助手分组的一条消息。 /// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_ai_about( pub(super) fn on_settings_ai_about(
&mut self, &mut self,
message: pages::settings::ai_about::Message, message: pages::settings::ai_about::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::ai_about::Message; match message {}
match message {
Message::ToggleEnabled(enabled) => {
self.settings.ai_about.enabled = enabled;
self.touch();
}
Message::PickProvider(provider) => {
self.settings.ai_about.provider = provider;
self.touch();
}
Message::EditBaseUrl(value) => {
self.settings.ai_about.base_url = value;
self.touch();
}
Message::EditModel(value) => {
self.settings.ai_about.model = value;
self.touch();
}
Message::EditKey(secret) => {
self.settings.ai_about.key = secret;
self.touch();
}
Message::Reveal(reveal) => {
self.settings.ai_about.reveal = reveal;
self.view_dirty = true;
}
Message::Save => return self.save_ai_settings(),
Message::Test => return self.test_ai_connection(),
Message::ProbeDone(result) => {
self.settings.ai_about.probe = Probe::Done(*result);
self.view_dirty = true;
}
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch(&mut self) {
self.settings.ai_about.dirty = true;
self.settings.ai_about.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。
///
/// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。
/// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判),
/// 否则外部一改文件就把用户正在输入的内容冲掉。
pub(in crate::app) fn hydrate_ai_settings(&mut self) {
if self.options.screenshot.is_some() {
let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string());
if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) {
self.settings.ai_about = state;
}
return;
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
return;
};
let snapshot = pages::settings::ai::store::load(&path);
if self.settings.ai_about.hydrate(snapshot) {
self.view_dirty = true;
}
}
/// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。
fn save_ai_settings(&mut self) -> Task<Message> {
use pages::settings::ai::{store, SaveNotice};
if let Err(refusal) = self.settings.ai_about.validate() {
self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal));
self.view_dirty = true;
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.notice = Some(SaveNotice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let saved = store::save(&path, &self.settings.ai_about);
match saved {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.ai_about.dirty = false;
self.settings.ai_about.hydrate(snapshot);
self.settings.ai_about.key = pages::settings::ai::Secret::default();
self.settings.ai_about.notice = Some(SaveNotice::Saved);
}
Err(reason) => {
self.settings.ai_about.notice =
Some(pages::settings::ai::SaveNotice::Failed(reason));
}
}
self.view_dirty = true;
Task::none()
}
/// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。
fn test_ai_connection(&mut self) -> Task<Message> {
if !self.settings.ai_about.can_test() {
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome(
unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl,
},
0,
));
self.view_dirty = true;
return Task::none();
};
self.settings.ai_about.probe = Probe::Running;
self.view_dirty = true;
Task::perform(
async move {
let started = std::time::Instant::now();
let outcome = unlucky_cli::commands::agent::probe::probe_file(
&path,
unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT,
);
ProbeResult::from_outcome(outcome, started.elapsed().as_millis())
},
|result| {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(
pages::settings::ai_about::Message::ProbeDone(Box::new(result)),
),
))
},
)
} }
} }
@@ -1,75 +1,18 @@
//! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。 //! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//! //!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事: //! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环 //! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//! (`host/dispatch.rs` 的 `SetSetting` 分支:`settings::set` 落盘 → `apply_tuning` → 重新发
//! `CoreEvent::SettingsChanged`)。
//! //!
//! ## 不做乐观回显 //! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
//!
//! 这里**不**改任何「当前值」:界面上的值是 presenter 快照(`pages::Model.settings`)的投影,
//! 只有 host 把 `SettingsChanged` 发回来才会变。理由与下载页的安装/启动同一条纪律——本地改一份、
//! host 回来再改一次,两边打架时界面就会各说一套(尤其是写盘失败:乐观回显会让用户以为改成了)。
//!
//! ## 文本行为什么要「应用」才提交
//!
//! 见 `pages/settings/download.rs` 的文件头:每敲一个键写一次盘会把半截值落进去。所以
//! `Edit` 只改本地草稿(`view_dirty` 都不用翻:草稿由 `States` 直接读),`Commit` 才派发。
//! 提交之后草稿当场丢掉——快照随即成为唯一真值,写失败时行上会回到文件里的值 + presenter 的
//! 错误 Toast 说清原因(比留着一个没生效的草稿更诚实)。
use super::super::*; use super::super::*;
use unlucky_presenter::dto::{find_row, AppCommand};
impl App { impl App {
/// Downloads 分组的一条消息。 /// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_download( pub(super) fn on_settings_download(
&mut self, &mut self,
message: pages::settings::download::Message, message: pages::settings::download::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::download::Message; match message {}
match message {
Message::Set { key, value } => {
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
Message::Edit { key, value } => {
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
self.settings.download.edit(&key, value);
Task::none()
}
Message::Commit { key } => {
let value = self.pending_text(&key);
self.settings.download.clear(&key);
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
// `settings::set` 忽略(那条分支先认键、再解析值)。
Message::Action { key } => {
self.dispatch(AppCommand::SetSetting {
key,
value: String::new(),
});
Task::none()
} }
} }
}
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
///
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
fn pending_text(&self, key: &str) -> String {
self.settings
.download
.pending(key)
.or_else(|| find_row(&self.view.settings, key).map(|row| row.value_text.clone()))
.unwrap_or_default()
}
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
@@ -1,332 +0,0 @@
//! Downloads 组的**写回闭环**(M201):`控件 → Message → AppCommand::SetSetting → 真 host 写盘
//! → CoreEvent::SettingsChanged → App 的快照与页面模型`。
//!
//! 为什么这条断言必须在 app 层:链子的两头都是「有状态的东西」——中间断在任何一环,页面层测试
//! 都看不见(它只拿到一份已经画好的模型)。要问的是「用户点了以后,**盘上和 App 认的值**变了吗」,
//! 而不是「按钮的 `on_press` 里有没有构造消息」。
//!
//! 两个后端都测,因为它们证明的事不同:
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 前四条 | 假后端(按 host 形状回快照) | 搬运:消息 → 命令、命令 → 快照、快照 → 模型 |
//! | 最后一条 | **真 `CoreHost` + 临时数据根** | 整条链:命令 → `settings::set` → `launcher.json` 真的变了 |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SettingControl, SettingGroupView, SettingRowView,
};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::pages::settings::download;
use crate::theme::Families;
use super::*;
/// 这一组要断言的那个键(与 presenter 快照里的键同名)。
const CONCURRENCY: &str = "download.concurrency";
/// 一个文本键(代理地址)。
const PROXY_URL: &str = "download.proxy_url";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一份只有下载组的快照:并发 = `concurrency`,代理 = `proxy`。
///
/// 行形状照 presenter 的 `host::settings::download::group` 抄(choice + 档位选项,text + 原文),
/// 只保留本文件断言要用的那两行——这里守的是 app 侧的搬运,不是 presenter 的行文案。
fn snapshot(concurrency: &str, proxy: &str) -> Vec<SettingGroupView> {
let options = ["4", "8", "16"];
let selected = i32::try_from(
options
.iter()
.position(|item| *item == concurrency)
.unwrap_or(1),
)
.expect("装得下");
vec![SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
CONCURRENCY,
"并发数",
"同时下载的文件数",
&options,
selected,
))
.with(SettingRowView::text(
PROXY_URL,
"HTTP 代理",
"manual 模式生效",
proxy,
))]
}
/// 记下令、并按 host 的形状回一条 `SettingsChanged` 的后端。
struct Hostish {
commands: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
/// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。
state: RefCell<(String, String)>,
}
impl Backend for Hostish {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.commands.borrow_mut().push(command.clone());
if let AppCommand::SetSetting { key, value } = command {
let mut state = self.state.borrow_mut();
if key == CONCURRENCY {
state.0 = value;
} else if key == PROXY_URL {
state.1 = value;
}
// host 写盘之后重新发一份快照(`dispatch.rs` 的 `SetSetting` 分支就这一行)。
self.queued
.push(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
&state.0, &state.1,
))));
}
}
}
/// 一个装着 [`Hostish`] 的 App + 那本命令账。
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let commands = Rc::new(RefCell::new(Vec::new()));
let backend = Hostish {
commands: Rc::clone(&commands),
queued: Vec::new(),
state: RefCell::new(("8".to_string(), "http://127.0.0.1:7890".to_string())),
};
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
// 首屏:收一份 host 的快照(产品路径在 `on_boot` 的 `RefreshAll` 之后就是这样)。
app.state
.apply(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
"8",
"http://127.0.0.1:7890",
))));
app.refresh_view();
(app, commands)
}
/// 一条设置页消息走完漏斗。
fn send(app: &mut App, message: pages::settings::download::Message) {
let message = pages::Message::Settings(pages::settings::GroupMessage::Download(message));
let _ = app.on_page(message);
}
/// **主用例**:点一个档位 ⇒ 命令发出去了 ⇒ host 回的快照进了 App ⇒ 页面模型跟着换。
///
/// 抓得住:
/// 1. handler 不派发 `AppCommand::SetSetting`(写回那一行被删)——这时假后端收不到命令、
/// 也回不出事件,快照停在旧值,红(变异验证见报告);
/// 2. `pages::Model` 不从快照取设置(`view::model` 那行 `settings:` 被删)——`app.view.settings`
/// 是空的,`current_value` 返回 `None`,红;
/// 3. App 侧**乐观回显**(点完就地改本地值、不等事件)——把假后端的回事件删掉后第 2 段仍会绿,
/// 所以本用例的「红」只证明闭合回路;它抓不住「有没有多存一份本地值」这一半。
///
/// 抓不住:真实点击(`Chip` 的 `on_press` 是否被真的按下)、host 是否真的落盘(那是最后一条的活)。
#[test]
fn changing_a_choice_value_lands_in_the_app_snapshot_and_the_page_model() {
let (mut app, commands) = app();
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"开局认的是 host 给的那份"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "4".to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == CONCURRENCY && value == "4"
)),
"写回必须走已有的 `AppCommand::SetSetting`(实际下发:{:?})",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("4"),
"App 的设置快照必须跟着 host 回来那份变(不是本地 State 变)"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("4"),
"页面模型(这一帧要画的值)也必须是 4:控件画的是 `Model.settings`"
);
}
/// 文本行:`Edit` 只改草稿(**不发指令**),`Commit` 才带着草稿去写盘。
///
/// 抓得住:`Edit` 也派发(每敲一个键写一次盘)、`Commit` 用快照旧值而不是草稿(用户输入丢失)。
/// 抓不住:输入框本身的按键路径(`text_field` 的 `on_input` 是组件既有实现)。
#[test]
fn typing_a_text_value_only_commits_on_demand() {
let (mut app, commands) = app();
send(
&mut app,
pages::settings::download::Message::Edit {
key: PROXY_URL.to_string(),
value: "http://127.0.0.1:1080".to_string(),
},
);
assert!(
commands.borrow().is_empty(),
"输入过程中不许写盘(半截值会落进 launcher.json)"
);
send(
&mut app,
pages::settings::download::Message::Commit {
key: PROXY_URL.to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == PROXY_URL && value == "http://127.0.0.1:1080"
)),
"提交时带的必须是**草稿**里的那一串(不是快照里的旧值):{:?}",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), PROXY_URL).as_deref(),
Some("http://127.0.0.1:1080"),
"提交后快照换成 host 回来的新值"
);
}
/// 动作行(「重新检测地区」):派发的是那一行的键,值留空(presenter 那条分支先认键、不看值)。
///
/// 抓得住:动作按钮被写成「什么都不发」、或者把键发成按钮文案。
#[test]
fn an_action_row_dispatches_its_key() {
let (mut app, commands) = app();
let key = "download.action.detect_region".to_string();
send(
&mut app,
pages::settings::download::Message::Action { key: key.clone() },
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key: sent, .. } if sent == &key
)),
"动作行必须走「按 schema 键写设置」那一条既有通路:{:?}",
commands.borrow()
);
}
/// 快照里没有本组时,页面**不许**自己编一行(`controls_of` 空 = 退回演示行)。
///
/// 抓得住:把演示行当成真值画(M193 用户的投诉形状)。
/// 抓不住:`settings/mod.rs` 那条退回路径本身(那是 `demo_card` 的事)。
#[test]
fn an_empty_snapshot_yields_no_controls() {
let (mut app, _commands) = app();
app.state.settings_groups.clear();
app.refresh_view();
assert!(download::controls_of(app.settings_snapshot()).is_empty());
// 有快照时,本组的控件种类就是 presenter 给的那几行(这里两份:choice + text)。
let kinds = download::controls_of(&snapshot("8", ""));
assert_eq!(
kinds,
vec![SettingControl::Choice, SettingControl::Text],
"控件种类照快照的 `control` 字段画,不许自己另定"
);
}
/// **真 host 端到端**:点「16」⇒ 「设置页里改了就生效」这句话的正面证据。
///
/// 链子全长:`Message::Set` → `AppCommand::SetSetting` → 真 `CoreHost` 的 `dispatch.rs` 分支
/// (`settings::set` 校验 + 落盘 + `apply_tuning` + 重新发 `SettingsChanged`)→ `AppState` →
/// `pages::Model::settings`。断言落在**磁盘上那串字节**上,不是任何中间变量。
///
/// 抓得住:
/// 1. 只改本地、不派发(`Message::Set` 的分支被删)⇒ `launcher.json` 里没有 16,红;
/// 2. 派发了但 handler 用了错误的键/值 ⇒ 文件里那个键还是出厂 8,红;
/// 3. 快照没跟着 host 的回流走(`view::model` 那行 `settings:` 被删)⇒ 页面模型空,红;
/// 4. 写盘校验被绕过(`settings::set` 不再 `save()`)⇒ 文件不存在/没变,红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑,`Chip::filter` 的 `on_press` 不在其中)、
/// 引擎是否立刻吃到新值(`apply_tuning` 与 `concurrency_probe` 由 presenter 的
/// `the_concurrency_setting_reaches_the_real_download_engine` 守)、以及界面像素(截图的事)。
#[test]
fn a_choice_change_reaches_the_real_settings_file() {
let dir = std::env::temp_dir().join(format!("ull-m201-writeback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"临时根里没有这个键 ⇒ 快照给的是出厂 8(也是引擎的生效值)"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "16".to_string(),
},
);
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"concurrency\": 16"),
"磁盘上必须真的有 16(这才是「设置页里改了就生效」):{written}"
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("16"),
"host 把新快照发回来了,App 认的也是 16"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("16"),
"页面模型也换成了 16(下一帧控件画的就是它)"
);
std::fs::remove_dir_all(&dir).ok();
}
+14 -333
View File
@@ -1,34 +1,18 @@
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。 //! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//! //!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在 //! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事: //! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//! //!
//! | 消息 | 做什么 | //! 消息进来的通道有两条,都不经过共享文件:
//! |---|---| //! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) | //! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 | //! (`app::Message::Page` 是既有通道,不用加新变体)。
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//! //!
//! ## 为什么「设为默认」不攒到「保存」里 //! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! //! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致), //! `the_group_dispatch_has_no_catch_all_arm` 会红。
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
use super::super::*; use super::super::*;
use crate::app::diag::diag;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
/// M197 的两个落点(各自一个 `impl App` 块):真下载一个 JDK / 三个设置键。
mod downloads;
mod prefs;
impl App { impl App {
/// Java 分组的一条消息。 /// Java 分组的一条消息。
@@ -36,317 +20,14 @@ impl App {
&mut self, &mut self,
message: pages::settings::java::Message, message: pages::settings::java::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::java::Message;
match message { match message {
Message::Scan => return self.start_java_scan(), // M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
Message::CancelScan => { // 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
self.settings.java.scan.cancel(); #[cfg(test)]
self.hydrate_java_scan(); pages::settings::java::Message::Ping => {
}
Message::AddDraft(value) => {
self.settings.java.add_draft = value;
self.settings.java.notice = None;
self.view_dirty = true;
}
Message::Add => self.add_java_manual(),
Message::SelectRuntime(binary) => self.select_java_default(&binary),
Message::EditMemory(value) => {
self.settings.java.memory = value;
self.touch_java();
}
Message::EditJvm(value) => {
self.settings.java.jvm = value;
self.touch_java();
}
Message::Save => self.save_java_settings(),
// M197:真下载一个 JDK + 三个设置键。落点各自一个 `impl App` 块(见 `mod` 声明)。
Message::Download(major) => return downloads::on_download(self, major),
Message::EditInstallRoot(value) => return prefs::on_edit_install_root(self, value),
Message::ToggleAutoInstall(on) => return prefs::on_toggle_auto_install(self, on),
Message::MoveVendor { index, up } => return prefs::on_move_vendor(self, index, up),
Message::ResetPriority => return prefs::on_reset_priority(self),
Message::SavePrefs => return prefs::on_save_prefs(self),
}
Task::none()
}
/// 三键写盘成功之后,请 presenter 重发一次设置快照(M204)。
///
/// ## 为什么需要这一拍
///
/// 本组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
/// 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`),
/// **不经过** `AppCommand::SetSetting` 的写盘分支。而 `CoreEvent::SettingsChanged` 全仓只有两处
/// 发出(`dispatch.rs` 的 `SetSetting` 分支、`host/mod.rs` 的 `refresh_all`)——不请一次,
/// `AppState::settings_groups` 里 java 组那几行会停在旧值,直到下一次任何别的写入。
///
/// ## 为什么不是 `AppCommand::RefreshAll`
///
/// `refresh_all()` 除重发快照之外还会 `versions::spawn_list`(拉版本清单)与
/// `start_update_check`(自更新检查)——每点一次「保存内存」打两个网络请求,代价与动作不相称。
/// 动作键 `JAVA_REFRESH_ACTION` 走的是同一条 `SetSetting` 分支的四个副作用臂,不写盘、不发网络。
///
/// ## 抓得住 / 抓不住
///
/// 抓得住:`tests/java_refresh.rs` 的 `saving_the_java_form_refreshes_the_host_settings_snapshot`
/// 用**真 `CoreHost` + 临时数据根**跑完整链,摘掉这一行的 dispatch 就红(实测)。
/// 抓不住:presenter 是否会因为**别的原因**(下一次外部写盘)自己刷新——所以那条用例的判据是
/// 「写盘后**当场**快照里有新值」,而不是「最终会有」。
fn request_settings_refresh(&mut self) {
self.dispatch(AppCommand::SetSetting {
key: unlucky_presenter::dto::JAVA_REFRESH_ACTION.to_string(),
value: String::new(),
});
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch_java(&mut self) {
self.settings.java.dirty = true;
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
///
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
///
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
///
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
pub(in crate::app) fn hydrate_java_settings(&mut self) {
let seeded = self.options.screenshot.is_some();
if seeded {
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
if raw.trim() != "live" {
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
self.settings.java = state;
}
return;
}
// `ULL_JAVA_STATE=live`:不灌种子,继续往下走真路径(读本机配置 + 真扫盘)。
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java = pages::settings::java::State::default();
return;
};
let snapshot = pages::settings::java::store::load(&config);
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
let changed = self.settings.java.hydrate(snapshot);
// M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)+ 它们的生效值与来源层。
let prefs_changed = self
.settings
.java
.prefs
.hydrate(pages::settings::java::prefs_store::load(&config));
self.settings.java.effective =
pages::settings::java::prefs_store::effective(&config, &self.settings.java.prefs);
let scan_changed = self.hydrate_java_scan();
self.settings.java.context = self.java_context();
if changed || prefs_changed || scan_changed {
self.view_dirty = true;
}
// 自动扫一次(首屏就该有真数据)。**没有 `cfg!(test)` 分叉**:单测走的也是这条生产入口,
// 删掉这一行 / 把实参翻成 `true` ⇒ `tests/java.rs` 的
// `the_first_hydrate_starts_a_real_scan_by_itself` 立刻红(r3 复审 P3-3 的缺口)。
//
// 实参为什么恒为 `false`:截图档的 `empty|two|editing` 在上面就 `return` 了,能走到这里的
// 只有「不是截图」与「`live` 档」两种,而 `live` 按产品路径算(见本函数文档 D30 的例外)。
self.auto_scan_java_if_due(false);
}
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
///
/// 抽出来是为了让「取消过就不再自动扫」(r2 复审 P2-2)有个窄的落点;而**首屏真的调了它**
/// 这件事由生产入口自己守(`tests/java.rs` 的那条首屏用例),不再靠 `cfg!(test)` 把调用关掉。
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
let report = self.settings.java.scan.report();
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
let _ = self.start_java_scan();
return true;
}
false
}
/// 把后台扫描的快照灌进状态。返回是否变了。
///
/// M197 起这一拍**也**灌一次下载作业的快照:`app/update/clock.rs` 每拍只调这一个入口,
/// 而复用它比改心跳的调用点便宜(`clock.rs` 不在本 mission 的 scope 里)。
/// 代价写在 `settings/java/downloads.rs`:下载期间进度按 1 Hz 刷新。
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
let report = self.settings.java.scan.report();
let mut changed = self.settings.java.apply_scan(&report);
if self.hydrate_java_downloads() {
changed = true;
}
if changed {
self.view_dirty = true;
}
changed
}
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
fn start_java_scan(&mut self) -> Task<Message> {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let manual = pages::settings::java::store::load_manual(&config)
.into_iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let roots = pages::settings::java::store::managed_roots(&config);
self.settings.java.notice = None;
if self.settings.java.scan.start(roots, manual, true) {
self.hydrate_java_scan();
}
self.view_dirty = true; self.view_dirty = true;
Task::none() Task::none()
} }
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
fn add_java_manual(&mut self) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
let draft = self.settings.java.add_draft.clone();
match pages::settings::java::store::add_manual(&config, &draft) {
Ok((binary, row)) => {
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
if !self
.settings
.java
.runtimes
.iter()
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
{
self.settings.java.runtimes.push(row);
self.settings
.java
.runtimes
.sort_by_key(|runtime| runtime.major);
}
self.settings.java.add_draft = String::new();
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
self.settings.java.scanned = true;
self.settings.java.notice = Some(Notice::Added(binary));
}
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
}
self.view_dirty = true;
}
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
fn select_java_default(&mut self, binary: &str) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::set_selected(&config, binary) {
Ok(snapshot) => {
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
// M204:这一笔也写进 launcher.json 了(`java.selected_path`),请 host 重发快照。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
fn save_java_settings(&mut self) {
let state = &self.settings.java;
if let Err(refusal) = state.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
self.view_dirty = true;
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::save(&config, &self.settings.java) {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.java.dirty = false;
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::Saved);
// M204:内存 + JVM 参数已经落盘(一次 `store.save()`),请 host 重发快照。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
///
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
/// (页面就不画门槛那一行,也不假装有要求)。
fn java_context(&self) -> Context {
let tiles = self.state.tiles();
let routed = match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => {
tiles.iter().find(|cell| &cell.instance.id == id)
}
_ => None,
};
let Some(cell) = routed.or_else(|| tiles.first()) else {
return Context::default();
};
let instance = cell.instance;
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
Context {
required: Some(Requirement {
instance: instance.name.clone(),
version: instance.mc_version.clone(),
minimum: requirement.minimum,
maximum: requirement.maximum,
}),
layer: Some(match instance.launch.memory_mb {
Some(mb) => Layer::Instance {
instance: instance.name.clone(),
mb,
},
None => Layer::Global,
}),
effective_memory_mb: Some(instance.launch.memory_effective_mb),
}
}
}
/// 写盘失败 → 回执:三档都走 i18n key。
///
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
/// 界面上给一句页面自己的「写不进去」。
///
/// `pub(super)`:`tests/java.rs` 直接钉这一层的映射(M199 起页面侧已经撞不到 schema 那一档)。
pub(super) fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
use pages::settings::java::store::WriteError;
match error {
WriteError::Refused(refusal) => Notice::Refused(refusal),
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
WriteError::Schema(detail) => {
diag!("[java] 设置写盘失败:{detail}");
Notice::WriteFailed
} }
} }
} }
@@ -1,118 +0,0 @@
//! 「下载一个 JDK」的 App 层落点(M197)。
//!
//! 三件事:
//!
//! | 函数 | 谁调 | 干什么 |
//! |---|---|---|
//! | [`App::start_java_download`] | `Download(major)` 消息 | 本地判据(目录/承接方/落点/是否已在跑)→ 起后台作业 |
//! | [`App::hydrate_java_downloads`] | 心跳那一拍(经 `hydrate_java_scan`) | 把作业快照灌成视图差量;**刚成功**时重扫 + 按 `java.auto_install` 决定要不要设为默认 |
//!
//! ## 为什么轮询挂在 `hydrate_java_scan` 上
//!
//! M175 的扫描已经是「后台线程 + 快照 + 心跳比对」的形状,`app/update/clock.rs` 每拍只调
//! `hydrate_java_scan()` 这一个入口。下载作业是同一族东西(后台线程 + 快照),所以复用那一拍,
//! `clock.rs` 一个字节都不用改——代价是**下载期间进度按 1 Hz 刷新**(帧时钟的判据在
//! `app/subscription.rs` 的 `needs_frames`,那是本 mission scope 之外的文件)。百分比一秒动一次
//! 对一次 100 MB 级的下载是够的;要紧的是它**会动**,而不是每秒动 60 次。
//!
//! ## 装完之后为什么要重扫
//!
//! 列表里那一行「已安装:…」来自**真扫盘的结论**(M175 的 `ScanSlot`),不是另存的安装记录。
//! 装完不重扫,界面上就会出现「下载成功」与「列表里没有它」并存——正是本仓最忌讳的那种自相矛盾。
use iced::Task;
use crate::app::message::Message;
use crate::app::state::App;
use crate::pages;
use unlucky_core::game::java::catalog;
use crate::pages::settings::java::{prefs_store, store};
impl App {
/// 下载并安装某个大版本(`Download(major)` 的落点)。
///
/// 四道本地判据,任何一道不过都在下载区顶上写清原因(点了没反应是禁止的):
/// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点;
/// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。
pub(super) fn start_java_download(&mut self, major: u32) {
use pages::settings::java::downloads::{gh_proxy_of_config, Refusal, Request};
self.settings.java.download_notice = None;
self.settings.java.notice = None;
self.view_dirty = true;
if catalog::find(major).is_none() {
self.settings.java.download_notice = Some(Refusal::UnknownMajor);
return;
}
if self.settings.java.downloads.busy() {
self.settings.java.download_notice = Some(Refusal::Busy);
return;
}
let Some(config) = store::config_of(&self.bridge) else {
self.settings.java.download_notice = Some(Refusal::NoStorage);
return;
};
let priority = self.settings.java.priority_vendors();
let domestic = self.settings.java.effective.domestic;
if catalog::vendors_for(major, &priority, domestic).is_empty() {
self.settings.java.download_notice = Some(Refusal::NoVendor);
return;
}
let saved_root = self.settings.java.prefs.saved_install_root.clone();
let Some(cache_root) = prefs_store::cache_root(&config) else {
self.settings.java.download_notice = Some(Refusal::NoStorage);
return;
};
let Some(install_root) = prefs_store::install_root(&config, &saved_root) else {
// 有设置文件但解析不出落点(standard 模式读不到系统目录):说这一句,
// 而不是含糊的「没有可用的设置文件」。
self.settings.java.download_notice = Some(Refusal::RootUnresolved);
return;
};
let started = self.settings.java.downloads.start(Request {
major,
cache_root,
install_root,
priority,
// `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认,
// `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。
gh_proxy: gh_proxy_of_config(&config),
});
if !started {
self.settings.java.download_notice = Some(Refusal::Busy);
}
}
/// 把作业快照灌成视图差量(心跳那一拍调)。返回是否变了。
pub(in crate::app) fn hydrate_java_downloads(&mut self) -> bool {
let report = self.settings.java.downloads.report();
if report == self.settings.java.download_seen {
return false;
}
let fresh = report
.success
.clone()
.filter(|success| self.settings.java.download_seen.success.as_ref() != Some(success));
self.settings.java.download_seen = report;
if let Some(success) = fresh {
// 新装上的一份:先重扫(列表要出现它),再按 `java.auto_install` 决定默认值。
let _ = self.start_java_scan();
if self.settings.java.prefs.saved_auto_install
&& self.settings.java.selected.trim().is_empty()
{
self.select_java_default(&success.binary);
}
}
self.view_dirty = true;
true
}
}
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
pub(super) fn on_download(app: &mut App, major: u32) -> Task<Message> {
app.start_java_download(major);
Task::none()
}
@@ -1,126 +0,0 @@
//! 三个设置键的 App 层落点(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
//!
//! ## 为什么这三项单独一个「保存」
//!
//! M175 的「保存」只写内存 + JVM 参数(`java.default_memory_mb` / `java.extra_args`),那是**运行时
//! 参数**;这三项是**装到哪、按什么顺序找、缺 JDK 时怎么准备**——改了它们的副作用是「下一次下载 /
//! 下一次扫描的落点变了」,与内存那两项混在一个按钮里,用户就不清楚自己刚保存的到底是哪一半。
//! 于是两段各有自己的脏标记与保存按钮,各回读各的键。
//!
//! ## 保存之后为什么立刻重扫
//!
//! `java.installRoot` 改了 ⇒ 托管根的集合变了 ⇒ 「已检测到的 JDK」那份列表可能整份不同。
//! 保存成功就重扫一次,界面不会出现「落点写着新目录,列表还是旧目录里的 JDK」。
//!
//! ## 回读校验
//!
//! `prefs_store::save` 返回的是**落盘后从磁盘读回来**的真值,[`Prefs::adopt`] 把草稿与
//! 「落盘真值」两半都对齐到它:用户看到的就是磁盘上那份(要是 schema 把值规整过,界面显示的是
//! 规整后的结果,而不是他刚敲的那串——这一条正是「改完回读验证已落盘」)。
use iced::Task;
use crate::app::message::Message;
use crate::app::state::App;
use crate::pages;
use super::super::java::write_notice;
impl App {
/// `java.installRoot` 草稿(只改草稿,不写盘)。
pub(super) fn edit_java_install_root(&mut self, value: String) {
self.settings.java.prefs.install_root = value;
self.settings.java.notice = None;
self.settings.java.download_notice = None;
self.view_dirty = true;
}
/// `java.auto_install` 开关(只改草稿)。
pub(super) fn toggle_java_auto_install(&mut self, on: bool) {
self.settings.java.prefs.auto_install = on;
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 发行版顺序上移 / 下移一行。
pub(super) fn move_java_vendor(&mut self, index: usize, up: bool) {
let domestic = self.settings.java.effective.domestic;
if up {
self.settings.java.prefs.move_up(index, domestic);
} else {
self.settings.java.prefs.move_down(index, domestic);
}
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 恢复地区默认序(= 把这个键写回空,让地区那一层重新生效)。
pub(super) fn reset_java_priority(&mut self) {
self.settings.java.prefs.reset_priority();
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 保存三个键:本地校验 → 落盘 → **用读回的真值**刷新草稿与生效值 → 重扫一次。
pub(super) fn save_java_prefs(&mut self) {
use pages::settings::java::Notice;
self.view_dirty = true;
if let Err(refusal) = self.settings.java.prefs.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
return;
};
match pages::settings::java::prefs_store::save(&config, &self.settings.java.prefs) {
Ok(snapshot) => {
self.settings.java.prefs.adopt(snapshot);
self.settings.java.effective = pages::settings::java::prefs_store::effective(
&config,
&self.settings.java.prefs,
);
// 落点 / 顺序变了:托管根的集合可能整份不同,立刻重扫一次。
// 顺序要紧:`start_java_scan` 会清 `notice`,所以回执写在它之后。
let _ = self.start_java_scan();
self.settings.java.notice = Some(Notice::PrefsSaved);
// M204:三键也已落盘(`java.installRoot` / `java.auto_install` / `java.vendorPriority`
// 一次 `store.save()`),请 host 重发快照——否则 `AppState::settings_groups` 里
// java 组那三行停在旧值,要等下一次任何别的写入才跟上。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
}
}
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
pub(super) fn on_edit_install_root(app: &mut App, value: String) -> Task<Message> {
app.edit_java_install_root(value);
Task::none()
}
/// `ToggleAutoInstall` 的转发。
pub(super) fn on_toggle_auto_install(app: &mut App, on: bool) -> Task<Message> {
app.toggle_java_auto_install(on);
Task::none()
}
/// `MoveVendor` 的转发。
pub(super) fn on_move_vendor(app: &mut App, index: usize, up: bool) -> Task<Message> {
app.move_java_vendor(index, up);
Task::none()
}
/// `ResetPriority` 的转发。
pub(super) fn on_reset_priority(app: &mut App) -> Task<Message> {
app.reset_java_priority();
Task::none()
}
/// `SavePrefs` 的转发。
pub(super) fn on_save_prefs(app: &mut App) -> Task<Message> {
app.save_java_prefs();
Task::none()
}
@@ -256,12 +256,8 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless(); let mut app = headless();
app.view_dirty = false; app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings( let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory( pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
"4096".to_string(),
)),
))); )));
assert!( assert!(
@@ -269,132 +265,3 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了" "java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
); );
} }
// --------------------------------------------------------------- M174:助手配置的活路径
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-ai-app-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
let app = App::with_presenter(
Options::default(),
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, path)
}
fn ai(message: pages::settings::ai_about::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(message),
))
}
/// **M174 的活路径**:设置页那四条消息(改端点/模型/密钥)+ 保存,真的写进 `launcher.json`。
///
/// 抓得住:handler 没接 `Save`、`store::save` 没写某一个键、写错键名、密钥没落盘。
/// 抓不住:用户真的点了那颗按钮(那是 iced 事件循环的事),也抓不住「保存后 agent 读到了」
/// (那一半由 `unlucky-cli` 的 `probe_file` 用例证明:它读的就是这个文件)。
#[test]
fn saving_the_ai_form_writes_the_real_settings_file() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("save");
let _ = app.update(ai(Ai::PickProvider(
pages::settings::ai::ProviderId::OpenAiCompatible,
)));
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
"sk-live-app-path",
))));
let _ = app.update(ai(Ai::Save));
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
assert!(
text.contains("\"base_url\": \"http://127.0.0.1:8080/v1\""),
"{text}"
);
assert!(text.contains("\"model\": \"qwen2.5-7b\""), "{text}");
assert!(
text.contains("\"provider\": \"openai_compatible\""),
"{text}"
);
assert!(text.contains("\"api_key\": \"sk-live-app-path\""), "{text}");
// 保存之后:草稿清干净、回执是「已保存」、密钥输入框清空(值已进文件,不再留在 UI 状态里)。
let state = &app.settings.ai_about;
assert_eq!(state.notice, Some(pages::settings::ai::SaveNotice::Saved));
assert!(!state.dirty);
assert!(state.key.is_empty());
assert_eq!(state.key_chars, "sk-live-app-path".chars().count());
let printed = format!("{state:?}");
assert!(
!printed.contains("sk-live-app-path"),
"UI 状态泄漏密钥:{printed}"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **非法值不保存**:端点没有协议头时,保存被拦下、文件不动、回执是具名拒绝原因。
#[test]
fn refusing_to_save_leaves_the_file_untouched() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("refuse");
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
let _ = app.update(ai(Ai::EditModel("qwen".to_string())));
let _ = app.update(ai(Ai::Save));
let saved = std::fs::read_to_string(&path).expect("第一次应当写成");
let _ = app.update(ai(Ai::EditBaseUrl("api.deepseek.com/v1".to_string())));
let _ = app.update(ai(Ai::Save));
assert_eq!(
app.settings.ai_about.notice,
Some(pages::settings::ai::SaveNotice::Refused(
pages::settings::ai::state::Refusal::BaseUrl
)),
"没有协议头的端点必须被拦下,且要说清原因"
);
let after = std::fs::read_to_string(&path).expect("读文件");
assert_eq!(saved, after, "被拒绝的保存不许改动文件:{after}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **未配置时不发请求**(GUI 侧那一半):缺模型名时点「测试连接」不会起任务、不留 Running 态。
#[test]
fn testing_without_configuration_does_nothing() {
use pages::settings::ai_about::Message as Ai;
let (mut app, path) = bridged_app("no-test");
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
// 模型名空着 ⇒ `can_test()` 为假
assert!(app.settings.ai_about.missing().is_some());
let _ = app.update(ai(Ai::Test));
assert_eq!(
app.settings.ai_about.probe,
pages::settings::ai::Probe::Idle,
"未配置时点测试不该有任何探测态(更不该发请求)"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
#[path = "tests/java.rs"]
mod java;
/// M204:Java 组写盘之后请 host 重发快照(动作键 `JAVA_REFRESH_ACTION`)。
#[path = "tests/java_refresh.rs"]
mod java_refresh;
@@ -1,374 +0,0 @@
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
//!
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
use super::bridged_app;
use crate::app::Message;
use crate::pages;
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
/// 一条 Java 分组消息 → 页面消息。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
///
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
/// 一个字节都没落盘、也没有任何提示。
///
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
/// JVM 回到空、`dirty` 变假)。
#[test]
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
let (mut app, path) = bridged_app("java-select");
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"6144".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
"C:/jdk-21/bin/java.exe".to_string(),
)));
assert_eq!(
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
"设为默认必须真的写进去"
);
assert_eq!(
app.settings.java.memory, "6144",
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert_eq!(
app.settings.java.jvm, "-XX:+UseG1GC",
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert!(
app.settings.java.dirty,
"内存 / JVM 参数还没保存,不许标成已保存"
);
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r3 P3-3:首屏自动扫必须有落脚点
/// **首屏自动扫一次盘**这件事必须在测试里有落脚点(r3 复审 P3-3)。
///
/// 现场:`hydrate_java_settings` 末尾那次 `if !cfg!(test) { auto_scan_java_if_due(...) }` 整行删掉、
/// 或把实参翻成 `true`(截图抑制档),全套测试仍是绿的——于是「设置页从不自动检测本机 Java」
/// 这个**用户实测的头号抱怨**回来时没人拦。
///
/// 为什么这条不是「再把判据测一遍」:判据(`auto_scan_due` 的四条)与取消语义已经有用例;这里要
/// 证明的是**首屏这条路真的调了它**。所以走的是生产入口(`construct.rs` 构造时与心跳都调
/// `hydrate_java_settings`)与生产作业(真扫盘),没有 `cfg!(test)` 分叉、也没有注入假作业。
///
/// 判据为什么同步可判、且与线程快慢无关:`ScanSlot::start_with` 在起线程**之前**就把 `running`
/// 置真(同一把锁里);万一扫描线程已经跑完,`finished` 会留着。所以「真的起过一次扫描」= 这两个
/// 位里至少有一个(空槽两者都是假)——不用等线程,也不看这台机器扫到了什么。
///
/// 变异证明:①删掉 `self.auto_scan_java_if_due(false);` 这一行 ⇒ 两位仍是假 ⇒ 红;
/// ②把它改成 `self.auto_scan_java_if_due(true);` ⇒ 被 `auto_scan_due` 的 `!screenshot` 挡掉 ⇒ 红。
#[test]
fn the_first_hydrate_starts_a_real_scan_by_itself() {
let (mut app, path) = bridged_app("java-autoscan");
// 构造那一刻已经自动扫过一次(同一个入口);这里要重新演一遍「首屏」,所以换一个空槽。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
app.hydrate_java_settings();
let report = app.settings.java.scan.report();
assert!(
report.running || report.finished,
"首屏 hydrate 没有自动发起扫描(快照是空的 {report:?}):设置页就不会自己检测本机 Java\
(r3 P3-3 的回归)"
);
// 收尾:取消这次真扫盘,别让它在后台继续探测本机 PATH 上的 java。
app.settings.java.scan.cancel();
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 一个会一直卡住的扫描作业(直到被取消)。
fn held_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<pages::settings::java::RuntimeRow> {
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Vec::new()
}
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
///
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
///
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
#[test]
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
HOLD.store(false, Ordering::SeqCst);
let (mut app, path) = bridged_app("java-cancel");
// 构造时已经自动扫过一次(首屏那条生产入口,见上一条用例);换成空槽,让本用例自己起。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
// cancelled=true / finished=false —— 正是复审指认的那个形状。
let slot = app.settings.java.scan.clone();
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
slot.cancel();
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!slot.report().running, "取消之后线程必须退出");
assert!(slot.report().cancelled);
assert!(!slot.report().finished);
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
app.hydrate_java_scan();
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
assert!(app.settings.java.scanning.is_none());
// 心跳那条路:走**生产入口**(`clock.rs` 每发现配置文件变了就调 `hydrate_java_settings`),
// 它末尾那次自动发起必须被「取消过」这一条挡住。
app.hydrate_java_settings();
assert!(
!slot.report().running,
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销):{:?}",
slot.report()
);
app.hydrate_java_scan();
assert!(
app.settings.java.scanning.is_none(),
"状态行不许回到「正在扫描」"
);
assert!(
app.settings.java.cancelled,
"取消标记要保持到下一次手动扫描为止"
);
HOLD.store(true, Ordering::SeqCst);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
///
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
/// `Not saved: IO 失败 @ …`。
///
/// M199 起页面侧已经撞不到这一档了(`java.extra_args` 超长先被 `State::validate` 具名拒掉,见下一条
/// 用例),所以这里直接钉 `write_notice` 这一层的映射:`Schema` 只能变成页面自己的 key。
///
/// 变异证明:把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 红;把它拼进
/// `Notice::Refused` 的 detail ⇒ 红(Debug 里会出现那句中文)。
#[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
use pages::settings::java::store::WriteError;
let notice = super::super::java::write_notice(WriteError::Schema(
"IO 失败 @ /tmp/launcher.json".to_string(),
));
assert_eq!(notice, pages::settings::java::Notice::WriteFailed);
let printed = format!("{notice:?}");
assert!(
!printed.contains("IO 失败"),
"core 的中文句子漏进了界面文案:{printed}"
);
}
/// 粘了 2000 字符的 JVM 参数:保存被**具名理由**拦下,而且文件一个字节都不动。
///
/// 现场(r3 复审 P3-4):`java.extra_args` 的 `max_len=1024` 只在 schema 那一档,页面侧不判长度
/// ⇒ 用户得到的是那句通用的「写不进去」,具体原因只在 stderr。本用例走的是 App 那条真保存路径。
///
/// 变异证明:删掉 `State::validate` 里的 `MAX_JVM_ARGS` 判据 ⇒ notice 变回
/// `Notice::WriteFailed`(schema 那一档),本条红。
#[test]
fn an_overlong_jvm_line_is_refused_with_its_own_reason() {
use pages::settings::java::Notice;
let (mut app, path) = bridged_app("java-jvm-long");
let too_long = "-D".to_string() + &"x".repeat(2000);
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
let _ = app.update(java(pages::settings::java::Message::Save));
let notice = app
.settings
.java
.notice
.clone()
.expect("保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("超长参数要给自己的原因(「太长」),不是那句通用的「写不进去」:{notice:?}");
};
assert_eq!(
reason.label(),
unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
"拒绝原因必须落到「太长」那条文案上(中英各一句,见 i18n 金表)"
);
assert!(!path.exists(), "被本地校验拒掉的保存不许碰磁盘");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- M197:三个设置键 + 真下载的落点
/// 三键改完点保存:**真落盘**、草稿被读回值收尾、不再脏,并且保存顺手重扫一次。
///
/// 变异证明:`save_java_prefs` 里不调 `prefs.adopt(snapshot)` ⇒ 草稿还是 `" D:/JDKs "`、
/// `dirty` 仍为真 ⇒ 本条红;不调 `start_java_scan` ⇒ `scan.report().running` 那条红。
#[test]
fn saving_the_three_keys_writes_them_and_reads_them_back() {
use pages::settings::java::prefs_store;
let (mut app, path) = bridged_app("java-prefs");
// 落点用这个临时数据根下的绝对路径:Windows / macOS / Linux 上都是绝对的,
// 断言于是与平台无关(写 "D:/JDKs" 在非 Windows 上是相对路径,会被解析到数据根下)。
let home = path.parent().unwrap().parent().unwrap().to_path_buf();
let jdks = home.join("JDKs").display().to_string();
let _ = app.update(java(pages::settings::java::Message::EditInstallRoot(
format!(" {jdks} "),
)));
let _ = app.update(java(pages::settings::java::Message::ToggleAutoInstall(
false,
)));
let _ = app.update(java(pages::settings::java::Message::MoveVendor {
index: 0,
up: false,
}));
assert!(app.settings.java.prefs.dirty(), "改过三键就该是脏的");
let _ = app.update(java(pages::settings::java::Message::SavePrefs));
assert_eq!(
app.settings.java.notice,
Some(pages::settings::java::Notice::PrefsSaved)
);
assert!(
!app.settings.java.prefs.dirty(),
"保存并把读回值当草稿之后不该还脏"
);
assert_eq!(
app.settings.java.prefs.install_root, jdks,
"草稿必须等于磁盘上那份(trim 过的),不是用户刚敲的原样"
);
assert!(
!app.settings.java.prefs.saved_auto_install,
"auto_install 要真变成关"
);
// 冷读:另起一次 load,确认三个键都在文件里。
let snapshot = prefs_store::load(&path);
assert_eq!(snapshot.install_root, jdks);
assert!(!snapshot.auto_install);
assert_eq!(snapshot.priority.len(), 4, "下移之后落盘的是整条链");
assert_ne!(snapshot.priority[0], "temurin", "顺序真的被改过");
assert!(
app.settings.java.scan.report().running,
"改了落点必须重扫一次,否则列表还停在旧目录里"
);
// 生效值也跟着换了层级(设了 installRoot ⇒ 落点来自设置)。
assert_eq!(
app.settings.java.effective.root, jdks,
"生效落点必须是刚保存的那一个"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// `java.auto_install` 开着且还没有默认 JDK:装完之后自动把新装的那份设为默认。
///
/// 变异证明:把 `hydrate_java_downloads` 里那段 `saved_auto_install && selected.is_empty()`
/// 的判据删掉 ⇒ 本条红(`selected` 还是空);把关掉那一档的断言反过来说明判据不是恒真。
#[test]
fn auto_install_adopts_the_fresh_jdk_when_no_default_is_set() {
use pages::settings::java::downloads::{Report, Success};
let (mut app, path) = bridged_app("java-adopt");
app.settings.java.prefs.saved_auto_install = true;
app.settings.java.selected = String::new();
app.settings.java.downloads.seed_report(Report {
job: None,
tries: Vec::new(),
failure: None,
success: Some(Success {
major: 21,
vendor: "Eclipse Temurin",
root: "D:/jdks/temurin/21".to_string(),
binary: "D:/jdks/temurin/21/bin/java.exe".to_string(),
source: Some("tuna".to_string()),
reused: false,
}),
});
assert!(app.hydrate_java_downloads(), "快照变了就要重建视图");
assert_eq!(
app.settings.java.selected, "D:/jdks/temurin/21/bin/java.exe",
"auto_install 开着 + 没有默认 JDK ⇒ 新装的那份自动成为默认"
);
let snapshot = pages::settings::java::store::load(&path);
assert_eq!(
snapshot.selected, "D:/jdks/temurin/21/bin/java.exe",
"而且真写进了 java.selected_path"
);
// 关掉 auto_install 时同样的成功快照**不**改默认。
let (mut app, path) = bridged_app("java-adopt-off");
app.settings.java.prefs.saved_auto_install = false;
app.settings.java.selected = String::new();
app.settings.java.downloads.seed_report(Report {
job: None,
tries: Vec::new(),
failure: None,
success: Some(Success {
major: 17,
vendor: "Eclipse Temurin",
root: "D:/jdks/temurin/17".to_string(),
binary: "D:/jdks/temurin/17/bin/java.exe".to_string(),
source: None,
reused: true,
}),
});
assert!(app.hydrate_java_downloads());
assert_eq!(app.settings.java.selected, "", "关掉就不许替用户改默认");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// 点一个目录里没有的大版本:立刻在下载区顶上写清原因,**不**起线程。
///
/// 变异证明:把 `start_java_download` 里 `catalog::find(major).is_none()` 那道判据删掉 ⇒
/// `download_notice` 为 `None`,本条红(点了却什么都不说是禁止的)。
#[test]
fn an_unknown_major_is_refused_on_the_spot() {
use pages::settings::java::downloads::Refusal;
let (mut app, path) = bridged_app("java-dl-refuse");
let _ = app.update(java(pages::settings::java::Message::Download(99)));
assert_eq!(
app.settings.java.download_notice,
Some(Refusal::UnknownMajor)
);
assert!(!app.settings.java.downloads.busy(), "不该起任何作业");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
@@ -1,307 +0,0 @@
//! M204:Java 组**写盘成功之后请 host 重发设置快照**这一拍(`JAVA_REFRESH_ACTION`)。
//!
//! ## 为什么需要这一条(缺口是什么)
//!
//! Java 组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
//! 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`)——
//! 那是**同步、原子**的一次落盘,但**不经过** `AppCommand::SetSetting` 的写盘分支。而
//! `CoreEvent::SettingsChanged` 全仓只有两处发出:`host/dispatch.rs` 的 `SetSetting` 分支与
//! `host/mod.rs` 的 `refresh_all`。于是三键写成功之后,`AppState::settings_groups` 里 java 组那几行
//! 会**停在旧值**,直到下一次任何别的写入(改并发、改主题……)顺手带一份新快照回来。
//!
//! M204 的修法:落盘成功之后派发一条动作键(`dto::JAVA_REFRESH_ACTION`,`host::settings::set`
//! 见到它只返回 `Ok(())`,不写盘),让 `SetSetting` 分支照常重发 `SettingsChanged`。**没有**改用
//! `RefreshAll`:那个还会 `versions::spawn_list` 与 `start_update_check`,每点一次保存打两个网络请求。
//!
//! ## 两条用例证明的事不同
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 第一条 | **真 `CoreHost` + 临时数据根** | 写盘 ⇒ 快照真的变了(磁盘 + 快照两头的字节级证据) |
//! | 第二条 | 假后端(记账)+ 本地校验拒绝 | **被拒的保存不许请求刷新**(那一拍只该跟在真落盘之后) |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{find_row, AppCommand, AppEvent, Backend, SettingGroupView};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use super::*;
/// 本组要断言的键(与 presenter 快照 / schema 同名)。
const JVM: &str = "java.extra_args";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一条 Java 分组消息 → 页面消息(与 `tests/java.rs` 同一个漏斗)。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 快照里某个键的**上屏文本**(`None` = 快照里没这一行)。
///
/// 用 `value_text` 而不是控件序号:Java 组这三个键里 `extra_args` / `selected_path` 是 text 行,
/// `value_text` 就是文件里的原文;内存那一行是四档 choice(见报告「没做/没验」第 ③ 条),
/// 它的**当前值**表达在 `options[selected]` 上,所以本文件不对内存行做取值断言——那会把
/// 「presenter 的四档表达不了 6144」这件事伪装成绿。
fn row_value(groups: &[SettingGroupView], key: &str) -> Option<String> {
find_row(groups, key).map(|row| row.value_text.clone())
}
/// **主用例**:改内存 + JVM 参数 ⇒ 点保存 ⇒ 磁盘变了 **且** host 发回来的快照也变了。
///
/// 链子全长(与本组的产品路径逐段对应):`Message::EditMemory/EditJvm` → `State` 草稿 →
/// `Message::Save` → `store::save`(一次 `store.save()` 写两个键)→ `request_settings_refresh()`
/// → 真 `CoreHost` 的 `SetSetting` 分支(`settings::set` 认动作键、不写盘)→
/// `CoreEvent::SettingsChanged(settings::groups(env))` → `AppState::settings_groups` →
/// `pages::Model::settings`。断言落在**磁盘字节**与**快照文本**上,不落在任何中间变量上。
///
/// 抓得住:
/// 1. 摘掉 `request_settings_refresh()` 的 dispatch(`save_java_settings` 里那一行)⇒ 快照仍是
/// 空串 ⇒ 红(**这就是本用例的变异验证**,且是本文件第二条都不红的唯一原因);
/// 2. 写盘没真发生(`store::save` 的 `save()` 被删)⇒ 磁盘断言红;
/// 3. 动作键在 presenter 侧被当成真键(`host::settings::set` 少了那条早退)⇒ `store.set` 报
/// 「未知键」⇒ 页面拿到 error Toast、快照不更新 ⇒ 红;
/// 4. 快照回流被丢掉(`view::model` 的 `settings:` 那行被删)⇒ `app.view.settings` 空 ⇒ 红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑);「页面上的值是不是旧值」这一半——写盘成功后
/// 页面本来就该显示新值,旧值那一半由 `tests/java.rs` 的拒绝用例守(本地校验不过时文件一个字节
/// 不动、回执是具名原因)。
#[test]
fn saving_the_java_form_refreshes_the_host_settings_snapshot() {
let dir = std::env::temp_dir().join(format!("ull-m204-java-refresh-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
row_value(app.settings_snapshot(), JVM).as_deref(),
Some(""),
"临时根里没有 `java.extra_args` ⇒ 快照这一行是空串(出厂值就是没有额外参数)"
);
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"6144".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"default_memory_mb\": 6144"),
"磁盘上必须真的有 6144:{written}"
);
assert!(
written.contains("-XX:+UseG1GC"),
"磁盘上必须真的有那段 JVM 参数:{written}"
);
assert_eq!(
row_value(app.settings_snapshot(), JVM).as_deref(),
Some("-XX:+UseG1GC"),
"host 必须为刚写进去的三键重发一次快照(否则 AppState 里这一行还是旧值)"
);
assert_eq!(
row_value(&app.view.settings, JVM).as_deref(),
Some("-XX:+UseG1GC"),
"页面模型也得跟着换:下一帧画的是 `Model.settings`"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 被拒的保存不许请求刷新
/// 只记账、不回事件的后端(本用例只关心「有没有发出那条动作键」)。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
/// 本地校验把保存拦下来时,**一次刷新都不许请求**:那一拍只该跟在真落盘之后。
///
/// 抓得住:把 `request_settings_refresh()` 挪到 `validate()` 之前(或无条件调用)⇒ 这里能看见
/// 动作键 ⇒ 红。抓不住:写盘失败的回执文案(那是 `tests/java.rs` 的 `write_notice` 用例)。
#[test]
fn a_refused_save_does_not_ask_for_a_refresh() {
use pages::settings::java::Notice;
let commands = Rc::new(RefCell::new(Vec::new()));
let dir = std::env::temp_dir().join(format!("ull-m204-java-refuse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&commands))),
SettingsBridge::with_path(&path),
);
// 128 MB 低于 schema 的下限(512):本地校验就该拦下,不写盘。
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"128".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
// 越界的内存要在本地就被拒(具名原因 = schema 下限 512 那条文案,不是通用的「写不进去」)。
let notice = app
.settings
.java
.notice
.clone()
.expect("被拒的保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("越界的内存必须是具名的本地拒绝:{notice:?}");
};
assert_eq!(
reason.label(),
unlucky_i18n::Key::SettingsJavaReasonMemoryRange,
"拒绝原因要落到「超出允许范围」那条文案上"
);
assert!(!path.exists(), "被拒的保存不许碰磁盘");
assert!(
!commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, .. }
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
)),
"没落盘就没有可刷新的事实(实际下发:{:?})",
commands.borrow()
);
std::fs::remove_dir_all(&dir).ok();
}
/// 真的落盘时,动作键**必须**发出去(正面那一半;与第一条互为背书)。
///
/// 为什么还要这一条:第一条用真 `CoreHost`,它的失败可能是「快照没回来」的任意一环;这一条把
/// 「到底有没有派出那条动作键」单独钉住,红的行号不同,排查时不必再猜是哪一环。
///
/// 抓得住:`request_settings_refresh()` 的键名写错(比如漏了 `action` 段)⇒ presenter 会把它当
/// 真键报「未知键」,而这条用例在本层就能看见键名对不对。
/// 抓不住:presenter 收到动作键之后有没有真的重发快照(那是第一条的事)。
#[test]
fn a_successful_save_dispatches_the_refresh_action_key() {
let commands = Rc::new(RefCell::new(Vec::new()));
let dir = std::env::temp_dir().join(format!("ull-m204-java-dispatch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&commands))),
SettingsBridge::with_path(&path),
);
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
assert!(path.exists(), "这一条的前提是保存真的落盘了(临时根可写)");
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, .. }
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
)),
"落盘之后必须派发动作键(实际下发:{:?})",
commands.borrow()
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 「立即扫描」真的接线
/// 「立即扫描」在 App 层是**真接线**的:`Message::Scan` ⇒ 起一次真扫盘(不是只画了颗按钮)。
///
/// 为什么这一条住在本文件(而不是 `tests/java.rs`):那个文件 374 行、`tests.rs` 正好 400 行,
/// 再开一个模块文件就要往 `tests.rs` 加一行 `mod`(400 → 401,撞 `gate` 的 400 行红线)。
/// 主题上也勉强算同一族:两者都是「设置页那一组对 App 做了什么」的守卫(这条守扫描入口,
/// 上面几条守写回闭环)。
///
/// 抓得住:`on_settings_java` 的 `Message::Scan => return self.start_java_scan()` 被改成
/// `Task::none()`(只画按钮不接线 —— 用户在早期投诉里点名的那一条)⇒ `report().running`
/// 仍是 `false`、`scanning` 仍是 `None` ⇒ 红(实测见报告「变异验证」M3)。
/// 抓不住:扫描**结论**是否写对了(那是 `pages/settings/java/tests/runtime.rs` 的事),
/// 也抓不住真实鼠标点击(本用例从 `Message` 起跑)。
#[test]
fn pressing_scan_starts_a_real_scan() {
// 用截图档构造(`Options.screenshot = Some(..)`)**只为一个目的**:让首屏那次
// `auto_scan_java_if_due` 不发生(`hydrate_java_settings` 的截图分支灌种子后直接 return),
// 于是「开局没有扫描在跑」这个前提是确定的。手动点「立即扫描」走的是**同一条产品代码**
// (`start_java_scan` → `ScanSlot::start`),截图档不拦它。
let dir = std::env::temp_dir().join(format!("ull-m204-java-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options {
screenshot: Some(std::path::PathBuf::from("m204-scan.png")),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
SettingsBridge::with_path(&path),
);
assert!(
!app.settings.java.scan.report().running,
"这一条的前提:构造时没有扫描在跑"
);
let _ = app.update(java(pages::settings::java::Message::Scan));
let report = app.settings.java.scan.report();
assert!(
report.running,
"「立即扫描」必须真的起一条扫描线程:{report:?}"
);
assert!(
app.settings.java.scanning.is_some(),
"状态行要能区分「正在扫描」(`scanning` 不能还是 None)"
);
// 别把这条用例起的真扫盘留给后面的用例:置取消位,线程会在下一个候选边界退出。
app.settings.java.scan.cancel();
std::fs::remove_dir_all(&dir).ok();
}
@@ -1,234 +0,0 @@
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
use crate::app::diag::diag;
use iced::Task;
use unlucky_presenter::dto::AppCommand;
use crate::pages::UpdateAction;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 一条更新动作。
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
match action {
UpdateAction::CheckNow => {
if self.update_busy() {
return self.refuse_busy("立即检查");
}
self.dispatch(AppCommand::CheckUpdate)
}
UpdateAction::Apply => {
if self.update_busy() {
return self.refuse_busy("下载并安装");
}
self.dispatch(AppCommand::ApplyUpdate)
}
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
UpdateAction::RestartNow => return self.restart_now(),
}
Task::none()
}
/// 有没有一条检查/下载正在跑。
///
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
///
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
fn update_busy(&self) -> bool {
let view = &self.state.update;
view.applying || view.outcome.is_checking()
}
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
fn refuse_busy(&self, what: &str) -> Task<Message> {
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
Task::none()
}
/// 立即重启:按同样的命令行拉起自己,然后退出。
///
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
/// 文件、并把取证进程拖成死循环。
fn restart_now(&mut self) -> Task<Message> {
if self.options.screenshot.is_some() {
diag!("[update] 截图通道不重启(忽略「立即重启」)");
return Task::none();
}
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
// 子进程是本 GUI exe 自己(Subsystem=2,不会分到控制台);走 `quiet` 是为了让
// 「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住(M217)。
let spawned = unlucky_core::platform::child::quiet(&exe)
.args(&args)
.spawn();
match spawned {
Ok(child) => diag!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
exe.display()
),
Err(error) => {
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
return Task::none();
}
}
}
Err(error) => {
diag!("[update] 取 current_exe 失败:{error}");
return Task::none();
}
}
iced::exit()
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::UpdateAction;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
struct Recorder {
seen: Arc<Mutex<Vec<AppCommand>>>,
}
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.seen
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(command);
}
fn tick(&mut self, _elapsed: std::time::Duration) {}
}
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder {
seen: Arc::clone(&seen),
}),
SettingsBridge::memory_only(),
);
(app, seen)
}
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.last()
.cloned()
.expect("应当派发过一条命令")
}
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
#[test]
fn every_update_action_reaches_the_backend() {
let (mut app, seen) = app();
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate);
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
let _ = app.on_update_action(UpdateAction::Dismiss);
assert_eq!(last(&seen), AppCommand::DismissUpdate);
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
}
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
///
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
#[test]
fn pressing_while_busy_dispatches_nothing() {
let (mut app, seen) = app();
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
let _ = app.on_update_action(UpdateAction::CheckNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"正在检查时再按「立即检查」不许派发第二条命令"
);
// 检查回来(失败也算回来):不忙了,能再点。
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
);
let _ = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
app.state.update.applying = true;
let _ = app.on_update_action(UpdateAction::Apply);
let _ = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
before,
"applying 期间不许派发任何新的检查/安装"
);
// 应用结束:恢复可点。
app.state.update.applying = false;
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
}
/// 截图通道不重启:取证进程不许把自己重启成死循环。
#[test]
fn the_screenshot_channel_never_restarts() {
let (mut app, seen) = app();
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
let _ = app.on_update_action(UpdateAction::RestartNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+4 -79
View File
@@ -21,58 +21,12 @@ use super::state::{log_first_frame, App};
use super::APP_TITLE; use super::APP_TITLE;
mod account_page; mod account_page;
pub(super) mod instances; mod instances;
mod logs; mod logs;
mod rail; mod rail;
mod update;
use instances::{detail_of, runtime_status_row, supporting_line}; use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model; use logs::logs_model;
use update::update_of;
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
///
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-m3-light` / `ull-m3-dark`),
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
/// D30 §2.1 的「哪一行日志证明它是哪个组/主题」:出图通道专用的一行身份日志。
///
/// ## 为什么必须有
///
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-m3-light`。
///
/// ## 为什么只在变化时打
///
/// `App::view` 每帧都调用;每帧一行会把 `launcher.log` 刷满,反而没人能从中找到那一行。
/// 判据是(组序号,主题名)这一对——同一次截图进程里最多两行(首帧 + 主题切一次)。
///
/// ## 为什么是 `try_lock` 而不是 `lock`
///
/// 这里在 UI 线程的每帧路径上。`try_lock` 拿不到锁就**直接返回**(这一帧不打,下一帧还会再来,
/// 身份行只是晚一拍、不会丢),于是「界面帧永远不会被这把锁挡住」是**代码保证的**,不是靠
/// 「今天只有一个调用点、锁里只有一次比较」这种会随改动失效的前提。M204 r1 复审把原先那句
/// 「拿不到锁就不打」和当时用的阻塞 `lock()` 对上了:注释承诺的保证必须由代码提供。
///
/// **非截图通道一律不打**:交互模式下用户每点一次左列就多一行日志,没有价值。
fn note_screenshot_group(screenshot: bool, route: &Route, group: usize, theme: &str) {
if !screenshot {
return;
}
let Ok(mut last) = SHOT_GROUP_LOGGED.try_lock() else {
return;
};
let seen = (group, theme.to_string());
if last.as_ref() == Some(&seen) {
return;
}
*last = Some(seen);
drop(last);
crate::app::diag::diag!("[shot] settings route={route:?} group={group} theme={theme}");
}
impl App { impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。 /// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -89,18 +43,6 @@ impl App {
pub(super) fn view(&self) -> Element<'_, Message> { pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame(); log_first_frame();
// D30 §2.1:出图通道要有一行能**自证身份**的日志——「这张图是哪个设置组、哪个主题」。
// 上一次 M201 的事故就是拿外观页当下载组的证据;图不自证身份,结论就无处落地。
// 只对设置页打(左列那几组才有「哪个组」这件事)。
if self.state.route() == &Route::Settings {
note_screenshot_group(
self.options.screenshot.is_some(),
self.state.route(),
self.appearance.selected_group,
self.ui().name(),
);
}
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage, // `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。 // 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery { if self.options.screen == Screen::Gallery {
@@ -131,8 +73,7 @@ impl App {
}; };
let account = self.rail_account(); let account = self.rail_account();
// M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。 match pages::Screen::of(self.state.route()) {
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account( Some(_) => shell::view_with_account(
&self.ui, &self.ui,
shell::NavRail { shell::NavRail {
@@ -148,13 +89,9 @@ impl App {
pages::settings::States { pages::settings::States {
appearance: Some(&self.appearance), appearance: Some(&self.appearance),
groups: &self.settings, groups: &self.settings,
// M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。
settings: &self.view.settings,
}, },
// M106:设置页 General 组要画「当前档位 + 生效语言」。 // M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(), self.language(),
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
&self.launch_form,
) )
.map(Message::Page), .map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width) agent::docked(&self.ui, session, agent_catalog, self.width)
@@ -183,11 +120,6 @@ impl App {
self.options.force_state, self.options.force_state,
self.lang(), self.lang(),
), ),
};
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree,
} }
} }
} }
@@ -204,7 +136,6 @@ impl App {
pub(super) fn model( pub(super) fn model(
state: &AppState, state: &AppState,
width: f32, width: f32,
height: f32,
forced: Option<crate::cli::ForcedState>, forced: Option<crate::cli::ForcedState>,
category: pages::Category, category: pages::Category,
draft: &pages::InstallDraft, draft: &pages::InstallDraft,
@@ -223,7 +154,7 @@ pub(super) fn model(
id: cell.instance.id.clone(), id: cell.instance.id.clone(),
name: cell.instance.name.clone(), name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog), supporting: supporting_line(cell.instance, catalog),
status: runtime_status_row(&cell.instance.runtime, catalog), status: runtime_status(&cell.instance.runtime, catalog),
}) })
.collect(); .collect();
@@ -239,18 +170,12 @@ pub(super) fn model(
pages::Model { pages::Model {
screen, screen,
window_width: width, window_width: width,
window_height: height,
active: route.nav(), active: route.nav(),
instances: pages::Instances { rows, detail }, instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()), content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang), logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
settings: state.settings_groups.clone(),
lang, lang,
forced: forced.map(|state| match state { forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。 // `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+7 -111
View File
@@ -18,13 +18,10 @@
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、 //! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。 //! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome,
};
use crate::pages::{Detail, LaunchFacts, Meta}; use crate::pages::{Detail, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。 /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -62,7 +59,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
} }
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 /// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> { pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime { match runtime {
InstanceRuntime::Idle => None, InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言 // 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
@@ -74,101 +71,11 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
InstanceRuntime::Running { pid } => { InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())])) Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
} }
// 失败有**两种形状**,各说各的话(复审 P1-1): // `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息); InstanceRuntime::Failed { reason } => {
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码), Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
// 绝不说成「启动游戏进程失败」。
//
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
} }
} }
/// 一条失败状态行。
///
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
match cause {
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(*stage, catalog)),
("detail", detail.as_str()),
],
),
LaunchFailureCause::Stage { stage, .. } => catalog.render(
Key::InstancesFailed,
&[("reason", failure_stage_label(*stage, catalog))],
),
LaunchFailureCause::Crashed { exit_code } => {
let reason = match exit_code {
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
};
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
LaunchFailureCause::SilentFailure { .. } => {
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
}
}
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
///
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
other => runtime_status(other, catalog),
}
}
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
///
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
})
}
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
///
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
#[must_use]
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
let catalog = lang.catalog();
if let Some(failure) = &finished.failure {
return catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(failure.stage, catalog)),
("detail", failure.detail.as_str()),
],
);
}
let outcome = catalog.text(match finished.outcome {
LogOutcome::Ok => Key::LaunchOutcomeNormal,
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
});
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
catalog.render(
Key::LaunchToastEnded,
&[("outcome", outcome), ("seconds", seconds.as_str())],
)
} }
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。 /// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
@@ -228,17 +135,6 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime, instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
), ),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
} }
} }
+2 -6
View File
@@ -74,9 +74,7 @@ fn crashed_only() -> AppState {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
exit_code: Some(1), summary: "崩溃(exit 1)".to_string(),
uptime_ms: 3_000,
failure: None,
})); }));
state state
@@ -168,9 +166,7 @@ fn an_imported_crash_file_still_shows_its_findings() {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
exit_code: Some(1), summary: "崩溃(exit 1)".to_string(),
uptime_ms: 3_000,
failure: None,
})); }));
state.apply_core(CoreEvent::DiagnosisReady(report( state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt", "file:crash-2026-11-02_19.40.15-client.txt",
+15 -5
View File
@@ -7,9 +7,6 @@ use unlucky_presenter::dto::{
}; };
use unlucky_presenter::mock_data::mock_instances; use unlucky_presenter::mock_data::mock_instances;
#[path = "tests/launch.rs"]
mod launch;
fn instance(runtime: InstanceRuntime) -> InstanceSummary { fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary { InstanceSummary {
id: "survival".to_string(), id: "survival".to_string(),
@@ -25,7 +22,6 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(), isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(), version_id: "1.20.4".to_string(),
version_present: true, version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
} }
} }
@@ -42,7 +38,6 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
model( model(
state, state,
1180.0, 1180.0,
760.0,
forced, forced,
pages::Category::All, pages::Category::All,
&draft, &draft,
@@ -126,6 +121,21 @@ fn a_busy_instance_offers_stop_instead_of_play() {
assert!(detail.can_stop, "启动中要能 Stop"); assert!(detail.can_stop, "启动中要能 Stop");
} }
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test] #[test]
fn timestamps_are_sliced_not_guessed() { fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog(); let en = Lang::En.catalog();
@@ -1,149 +0,0 @@
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
//!
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
use super::*;
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
#[test]
fn failed_instances_can_be_retried() {
// 进程**没起来**:说「哪一步 + 原文」。
let failed = instance(InstanceRuntime::Failed {
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
});
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
assert_eq!(
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process")
);
// 详情页给全文(阶段 + 原文)。
assert_eq!(
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process: os error 5")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
///
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
#[test]
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
let line = |cause: LaunchFailureCause, lang: Lang| {
let runtime = InstanceRuntime::Failed { cause };
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
(row, detail)
};
// ① 没起来:说阶段。
let (row, detail) = line(
LaunchFailureCause::Stage {
stage: LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
Lang::En,
);
assert_eq!(row, "Launch failed · Starting the game process");
assert_eq!(
detail,
"Launch failed · Starting the game process: os error 5"
);
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
assert!(
!detail.contains("Starting the game process"),
"进程起来了,不许说「启动游戏进程」:{detail}"
);
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
let (row, zh) = line(
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
Lang::ZhCn,
);
assert_eq!(row, "启动失败 · 退出码 0,没起来");
assert_eq!(zh, row);
assert!(row.contains("没起来"), "{row}");
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
assert_eq!(row, "Launch failed · The game crashed");
}
///
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
///
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
#[test]
fn the_launch_toast_speaks_the_interface_language() {
use unlucky_presenter::dto::{
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
};
let finished = |outcome, failure| LaunchFinished {
id: "survival".to_string(),
session: "20261031T120000-abcd12".to_string(),
outcome,
exit_code: Some(1),
uptime_ms: 12_500,
failure,
};
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
let ok = finished(LogOutcome::Ok, None);
let en = super::instances::launch_toast(&ok, Lang::En);
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
assert_eq!(en, "Finished normally · 12.5s");
assert_eq!(zh, "正常结束 · 12.5 秒");
assert_ne!(en, zh, "切语言时提示语要变");
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
let failed = finished(
LogOutcome::Crashed,
Some(LaunchFailureView::new(
LaunchFailureStage::InstanceConfig,
"permission denied (os error 5)",
)),
);
let en = super::instances::launch_toast(&failed, Lang::En);
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
assert!(
en.starts_with("Launch failed · Reading the instance settings"),
"{en}"
);
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
for text in [&en, &zh] {
assert!(
text.contains("permission denied (os error 5)"),
"原文(取证信息)不许丢:{text}"
);
}
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
assert!(
!zh.contains("Launch failed"),
"中文提示里混了英文前缀:{zh}"
);
}
/// 有没有中文字符(上面那条「不许混语言」的判据)。
fn is_cjk(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
-157
View File
@@ -1,157 +0,0 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
skipped: view.skipped.clone(),
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view);
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}
-13
View File
@@ -52,9 +52,6 @@ impl Dispatch {
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。 /// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> { pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
// 只有 `RawWindowHandle::Win32` 那条臂用到它,其余平台只剩 `_ => None`
// ⇒ 无条件导入在非 Windows 上是 `unused_imports`(M147)。
#[cfg(target_os = "windows")]
use window::raw_window_handle::RawWindowHandle; use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?; let handle = handle.window_handle().ok()?;
@@ -102,21 +99,11 @@ mod tests {
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。 // 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5)); let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED"); assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
// 「做不到」的语义随平台不同(M147 实证:这条断言原先按 Windows 写死,Ubuntu 上
// 622 passed / 1 failed):Windows 上 FFI 真被调用并失败 ⇒ `Failed`;非 Windows 走
// `transparency.rs` 的空实现 ⇒ `Unsupported`。两边都不许表现成 `Applied` / `Opaque`。
#[cfg(target_os = "windows")]
assert!( assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }), matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}", "{:?}",
invalid.alpha invalid.alpha
); );
#[cfg(not(target_os = "windows"))]
assert_eq!(
invalid.alpha,
AlphaOutcome::Unsupported,
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
);
} }
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`), /// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
-5
View File
@@ -10,8 +10,6 @@
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法 //! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton) //! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
//! divider.rs ← COMPBASE(15 §22) //! divider.rs ← COMPBASE(15 §22)
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它)
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13) //! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
//! inputs/ ← 同上(§9/§20/§11/§8) //! inputs/ ← 同上(§9/§20/§11/§8)
//! feedback/ ← 同上(§12/§14/§7/§15) //! feedback/ ← 同上(§12/§14/§7/§15)
@@ -53,10 +51,7 @@ pub mod gallery;
pub mod inputs; pub mod inputs;
pub mod interaction; pub mod interaction;
pub mod layout; pub mod layout;
/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。
pub mod motion;
pub mod navigation; pub mod navigation;
pub mod scroll;
pub mod selection; pub mod selection;
#[cfg(test)] #[cfg(test)]
@@ -1,182 +0,0 @@
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。
//!
//! ## 这个模块解决什么
//!
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧**
//! ([`needs_frames`])。
//!
//! ## 有界泵的契约(四条,都有守卫)
//!
//! | 契约 | 为什么 | 守在哪 |
//! |---|---|---|
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` |
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) |
//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` |
//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` |
//!
//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**:
//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**),
//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。
//!
//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支,
//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳
//! 天然不会双订阅——它们共享同一个布尔。
//!
//! ## `MotionScale::NONE` 是**真归零**
//!
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见
//! `pump::tests::none_scale_never_starts_a_track`。
//!
//! ## 为什么是 thread-local 而不是 `App` 的字段
//!
//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程,
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
//!
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了
//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。
//!
//! ## 谁在什么时候调
//!
//! | 时机 | 调用 | 在哪 |
//! |---|---|---|
//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) |
//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` |
//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` |
//!
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
//!
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
mod pump;
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动),
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
#[allow(unused_imports)]
pub use pump::{offset, spec, Slot, Spec, FRAME_MS};
use std::cell::{Cell, RefCell};
use std::time::Instant;
use iced::widget::container;
use iced::{Element, Length, Padding};
use unlucky_design::motion::MotionScale;
// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等
// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对
// 展开后的 item 报的)。
thread_local! {
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
#[allow(clippy::missing_const_for_thread_local)]
static PUMP: RefCell<pump::Pump> = const { RefCell::new(pump::Pump::new()) };
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
#[allow(clippy::missing_const_for_thread_local)]
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
}
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
pub fn configure(scale: MotionScale, pin: Option<f64>) {
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
}
/// 当前档位。
pub fn scale() -> MotionScale {
PUMP.with(|pump| pump.borrow().scale())
}
/// 已推进的帧数。
pub fn samples() -> u64 {
PUMP.with(|pump| pump.borrow().samples())
}
/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。
pub fn needs_frames() -> bool {
PUMP.with(|pump| pump.borrow().is_animating())
}
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
///
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
/// `detail` 是详情页的实例 id。
///
/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1)
///
/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces`
/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插,
/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开,
/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。
///
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
pub fn observe(screen: Option<u64>, detail: Option<&str>) {
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail));
if started {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
}
/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。
pub fn sample() -> (f32, f32) {
PUMP.with(|pump| pump.borrow().offsets())
}
/// 推进时间轴(`App::on_frame` 每帧调一次)。
pub fn advance() {
let now = Instant::now();
let elapsed_ms = LAST_FRAME.with(|last| {
let previous = last.replace(Some(now));
previous.map_or(0.0, |previous| {
now.saturating_duration_since(previous).as_secs_f64() * 1000.0
})
});
PUMP.with(|pump| {
pump.borrow_mut().advance(elapsed_ms);
});
}
/// 把入场位移套到树上。
///
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。
/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。
pub fn shifted<'a, M: 'a>(
element: impl Into<Element<'a, M>>,
(dx, dy): (f32, f32),
) -> Element<'a, M> {
container(element.into())
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding {
top: dy,
right: 0.0,
bottom: 0.0,
left: dx,
})
.into()
}
#[cfg(test)]
pub fn reset() {
PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new());
LAST_FRAME.with(|last| last.set(None));
}
/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。
#[cfg(test)]
pub fn advance_by(ms: f64) {
PUMP.with(|pump| {
pump.borrow_mut().advance(ms);
});
}
#[cfg(test)]
mod tests;
@@ -1,348 +0,0 @@
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
//!
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
//!
//! ## 三个概念
//!
//! | 名字 | 是什么 | 谁定 |
//! |---|---|---|
//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 |
//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) |
//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 |
//!
//! ## 为什么规格表在代码里而不是页面里
//!
//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种
//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。
use std::collections::BTreeMap;
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
use unlucky_design::spacing::Space;
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
///
/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看:
///
/// | 什么 | 频率 | 由谁决定 |
/// |---|---|---|
/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 |
/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) |
///
/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。
/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排**
/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。
pub const FRAME_MS: f64 = 16.0;
/// 单次推进的**最大步进**(4 帧 = 64 ms)。
///
/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果
/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完
/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道:
/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。
pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0;
/// 一个动效槽。
///
/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加
/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对:
/// 新的一次内容变化取代上一次。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Slot {
/// 首屏进入:进程内只在第一帧跑一次。
Boot,
/// 换页:路由换了(导航轨点选、返回、启动直达)。
Screen,
/// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。
Detail,
}
impl Slot {
/// 全部槽(遍历顺序稳定,写日志/断言用)。
pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail];
/// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。
#[cfg(test)]
pub const fn name(self) -> &'static str {
match self {
Slot::Boot => "boot",
Slot::Screen => "screen",
Slot::Detail => "detail",
}
}
}
/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spec {
/// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。
pub duration: DurationToken,
/// 缓动曲线。
pub easing: Easing,
}
/// 槽 → 规格(**唯一**一张表)。
///
/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/
/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**:
///
/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为
/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的;
/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完
/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。
pub const fn spec(slot: Slot) -> Spec {
match slot {
Slot::Boot => Spec {
duration: DurationToken::Medium4,
easing: Easing::Standard,
},
Slot::Screen => Spec {
duration: DurationToken::Medium2,
easing: Easing::Standard,
},
Slot::Detail => Spec {
duration: DurationToken::Medium3,
easing: Easing::Standard,
},
}
}
/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。
///
/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子,
/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。
pub fn offset(slot: Slot) -> (f32, f32) {
match slot {
Slot::Boot => (0.0, Space::X4.dp()),
Slot::Screen => (0.0, Space::X3.dp()),
Slot::Detail => (Space::X2.dp(), 0.0),
}
}
/// 一条正在跑的轨道。
#[derive(Debug, Clone, Copy)]
struct Track {
/// 起跑时刻(动画时间轴的坐标,毫秒)。
start_ms: f64,
spec: Spec,
}
/// 帧泵:一条时间轴 + 每槽一条轨道。
///
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
#[derive(Debug)]
pub struct Pump {
scale: MotionScale,
/// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。
pin: Option<f64>,
now_ms: f64,
pending_ms: f64,
tracks: BTreeMap<Slot, Track>,
screen: Option<u64>,
detail: Option<String>,
painted: bool,
samples: u64,
}
impl Pump {
/// 出厂态:关着、没跑过、没有轨道。
pub const fn new() -> Self {
Self {
scale: MotionScale::NONE,
pin: None,
now_ms: 0.0,
pending_ms: 0.0,
tracks: BTreeMap::new(),
screen: None,
detail: None,
painted: false,
samples: 0,
}
}
/// 定档(启动一拍调一次)。
///
/// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于
/// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次
/// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。
pub fn configure(&mut self, scale: MotionScale, pin: Option<f64>) {
self.scale = scale;
self.pin = pin;
self.painted = false;
self.screen = None;
self.detail = None;
self.pending_ms = 0.0;
if scale.is_off() {
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
self.tracks.clear();
}
}
/// 当前档位。
pub fn scale(&self) -> MotionScale {
self.scale
}
/// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。
pub fn samples(&self) -> u64 {
self.samples
}
/// 时间轴(毫秒)。
#[cfg(test)]
pub fn now_ms(&self) -> f64 {
self.now_ms
}
/// 现在有没有动画在跑(= 要不要订阅帧)。
pub fn is_animating(&self) -> bool {
!self.tracks.is_empty()
}
/// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。
///
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
pub fn play(&mut self, slot: Slot) -> bool {
self.tracks.clear();
if self.scale.is_off() {
return false;
}
// 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」,
// 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。
let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms };
self.tracks.insert(
slot,
Track {
start_ms,
spec: spec(slot),
},
);
true
}
/// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。
pub fn progress(&self, slot: Slot) -> f32 {
let Some(track) = self.tracks.get(&slot) else {
return 1.0;
};
let duration = f64::from(self.scale.ms(track.spec.duration));
if duration <= 0.0 {
return 1.0;
}
let elapsed = self.elapsed_ms() - track.start_ms;
let t = (elapsed / duration).clamp(0.0, 1.0) as f32;
track.spec.easing.sample(t)
}
/// 当前动画时刻(钉住相位时恒为钉住值)。
fn elapsed_ms(&self) -> f64 {
self.pin.unwrap_or(self.now_ms)
}
/// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。
///
/// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释)
///
/// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→
/// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`,
/// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表
/// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里
/// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。
///
/// 三条判据(顺序即优先级):
/// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`];
/// 2. 屏变了 → [`Slot::Screen`];
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
///
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**——
/// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回
/// `Some`,仍然会跑换页那一档。
///
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
pub fn observe(&mut self, screen: Option<u64>, detail: Option<&str>) -> bool {
let mut started = false;
if !self.painted {
self.painted = true;
if screen.is_some() {
started = self.play(Slot::Boot);
}
} else if self.screen != screen {
if screen.is_some() {
started = self.play(Slot::Screen);
}
} else if self.detail.as_deref() != detail && screen.is_some() {
started = self.play(Slot::Detail);
}
self.screen = screen;
if self.detail.as_deref() != detail {
self.detail = detail.map(str::to_owned);
}
started
}
/// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。
///
/// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0`
/// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。
pub fn offsets(&self) -> (f32, f32) {
let mut dx = 0.0_f32;
let mut dy = 0.0_f32;
for slot in Slot::ALL {
let remaining = 1.0 - self.progress(slot);
let (ox, oy) = offset(slot);
dx += ox * remaining;
dy += oy * remaining;
}
(dx.round(), dy.round())
}
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
///
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
/// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表
/// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。
pub fn advance(&mut self, dt_ms: f64) -> bool {
if self.tracks.is_empty() {
self.pending_ms = 0.0;
return false;
}
if dt_ms.is_finite() && dt_ms > 0.0 {
// ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。
self.pending_ms += dt_ms.min(MAX_STEP_MS);
}
if self.pending_ms < FRAME_MS {
return false;
}
let step = std::mem::take(&mut self.pending_ms);
if self.pin.is_none() {
self.now_ms += step;
}
self.samples += 1;
let now = self.elapsed_ms();
let finished: Vec<Slot> = self
.tracks
.iter()
.filter(|(_, track)| {
let duration = f64::from(self.scale.ms(track.spec.duration));
duration <= 0.0 || now - track.start_ms >= duration
})
.map(|(slot, _)| *slot)
.collect();
for slot in finished {
self.tracks.remove(&slot);
}
true
}
}
impl Default for Pump {
fn default() -> Self {
Self::new()
}
}
@@ -1,345 +0,0 @@
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
//!
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。
use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS};
use super::*;
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
fn standard() -> Pump {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, None);
pump
}
/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。
fn settle(pump: &mut Pump) -> u64 {
let mut frames = 0;
for _ in 0..1250 {
if !pump.is_animating() {
break;
}
if pump.advance(FRAME_MS) {
frames += 1;
}
}
frames
}
// ------------------------------------------------------------------ 令牌表
/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。
#[test]
fn every_slot_spec_is_a_registered_token_pair() {
for slot in Slot::ALL {
let spec = spec(slot);
let ms = spec.duration.ms();
assert!(
(250..=500).contains(&ms),
"{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)",
slot.name(),
ms
);
// 缓动必须是真的曲线(端点 = 0/1)
assert!(spec.easing.sample(0.0).abs() < 1.0e-6);
assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
// 位移必须来自间距栅格(4 的倍数),且不为零
let (dx, dy) = offset(slot);
assert!(
dx > 0.0 || dy > 0.0,
"{}: 位移为 0 就没有入场感",
slot.name()
);
assert_eq!(dx % 4.0, 0.0);
assert_eq!(dy % 4.0, 0.0);
assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name());
}
// 表本身钉住:谁改了这三档,报告与出图都要跟着改
assert_eq!(spec(Slot::Screen).duration.ms(), 300);
assert_eq!(spec(Slot::Detail).duration.ms(), 350);
assert_eq!(spec(Slot::Boot).duration.ms(), 400);
}
// ------------------------------------------------------------------ 有界泵
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
///
/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把
/// `is_animating` 改成恒真。两条都让本用例红。
#[test]
fn a_finished_animation_stops_asking_for_frames() {
let mut pump = standard();
assert!(pump.play(Slot::Screen), "起跑必须成功");
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
settle(&mut pump);
assert!(
!pump.is_animating(),
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
);
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位");
// 再推进也不该有任何采样(订阅表里已经没有帧了)
let before = pump.samples();
for _ in 0..60 {
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
}
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
}
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
///
/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
#[test]
fn frames_are_capped_at_sixty_per_second() {
let mut pump = standard();
pump.play(Slot::Screen);
// 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器)
let mut advanced = 0;
for _ in 0..200 {
if pump.advance(1.0) {
advanced += 1;
}
}
assert!(
advanced <= 13,
"200 ms 内最多 13 次推进(200/16),实测 {advanced} 次"
);
assert!(
advanced >= 11,
"也别少得离谱(掉帧会看起来卡),实测 {advanced}"
);
assert_eq!(pump.samples(), advanced);
// 时间轴按真实时间走,不受「帧被合并」影响
assert!(
(pump.now_ms() - 192.0).abs() <= 16.0,
"时间轴应约 192 ms(12 × 16),实测 {}",
pump.now_ms()
);
}
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
///
/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒
/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与
/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。
#[test]
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
let mut pump = standard();
pump.play(Slot::Screen);
// 60 秒的「空闲间隔」当 dt
assert!(pump.advance(60_000.0));
assert!(
pump.is_animating(),
"一次超长 dt 不许把轨道当场删掉(那就等于没有动画)"
);
assert!(
pump.now_ms() <= MAX_STEP_MS + 1.0e-6,
"单次推进最多 {:?} ms,实测 {}",
MAX_STEP_MS,
pump.now_ms()
);
assert!(
pump.progress(Slot::Screen) < 1.0,
"进度不许一步到位(那就是闪跳)"
);
// 正常帧间隔照样把动画跑完(没被抖动卡死)
settle(&mut pump);
assert!(!pump.is_animating());
}
/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。
#[test]
fn progress_is_monotone_and_lands_on_one() {
let mut pump = standard();
pump.play(Slot::Detail);
let mut previous = 0.0;
for _ in 0..40 {
let value = pump.progress(Slot::Detail);
assert!(value >= previous, "进度倒退了:{previous} → {value}");
previous = value;
if !pump.advance(FRAME_MS) {
break;
}
}
assert_eq!(previous, 1.0, "末帧必须到位");
}
// ------------------------------------------------------------------ NONE 降级
/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。
///
/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里
/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。
#[test]
fn none_scale_never_starts_a_track() {
let mut pump = Pump::new(); // 出厂即 NONE
assert!(pump.scale().is_off());
// 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道
assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑");
assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态");
assert!(!pump.is_animating());
assert!(!pump.observe(Some(2), None));
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(!pump.observe(Some(2), Some("instance-7")));
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(!pump.is_animating());
for _ in 0..120 {
assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的");
}
assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0");
}
/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。
#[test]
fn switching_to_none_mid_flight_settles_immediately() {
let mut pump = standard();
pump.play(Slot::Screen);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
pump.configure(MotionScale::NONE, None);
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
assert_eq!(pump.progress(Slot::Screen), 1.0);
assert_eq!(pump.offsets(), (0.0, 0.0));
}
/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。
#[test]
fn half_scale_finishes_in_half_the_frames() {
let mut full = standard();
full.play(Slot::Screen);
let full_frames = settle(&mut full);
let mut half = Pump::new();
half.configure(MotionScale::HALF, None);
half.play(Slot::Screen);
let half_frames = settle(&mut half);
assert!(half_frames > 0 && full_frames > 0);
assert!(
half_frames * 2 <= full_frames + 2,
"半速档应约用一半帧数:full={full_frames} half={half_frames}"
);
}
// ------------------------------------------------------------------ 触发判据
/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。
#[test]
fn identity_changes_pick_the_right_slot() {
let mut pump = standard();
// 首帧:Boot(位移是纵向 16)
assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽");
let (dx, dy) = pump.offsets();
assert_eq!(dx, 0.0);
assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移");
assert_eq!(
pump.progress(Slot::Screen),
1.0,
"首帧不该顺带起跑 Screen 槽"
);
settle(&mut pump);
assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑");
assert_eq!(pump.offsets(), (0.0, 0.0));
// 换屏:Screen(纵向)
assert!(pump.observe(Some(2), None));
let (dx, dy) = pump.offsets();
assert_eq!(dx, 0.0);
assert_eq!(dy, offset(Slot::Screen).1);
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
settle(&mut pump);
// 同屏换详情:Detail(横向)
assert!(pump.observe(Some(2), Some("instance-7")));
let (dx, dy) = pump.offsets();
assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场");
assert_eq!(dy, 0.0);
assert!(pump.progress(Slot::Detail) < 1.0);
settle(&mut pump);
assert!(!pump.observe(Some(2), Some("instance-7")));
assert_eq!(pump.offsets(), (0.0, 0.0));
}
/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。
#[test]
fn screens_rendered_elsewhere_never_start_a_track() {
let mut pump = standard();
assert!(pump.observe(Some(1), None), "首屏开机跑 Boot");
settle(&mut pump);
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
assert!(!pump.is_animating());
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(
pump.observe(Some(1), None),
"从账户页回到某一屏仍然是换页,要跑"
);
assert_eq!(pump.offsets().1, offset(Slot::Screen).1);
}
/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。
#[test]
fn a_new_transition_supersedes_the_previous_one() {
let mut pump = standard();
assert_eq!(
pump.offsets().1,
0.0,
"还没 observe 时没有位移(采样是纯函数)"
);
pump.observe(Some(1), None);
assert_eq!(pump.offsets().1, offset(Slot::Boot).1);
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
pump.observe(Some(2), None);
assert_eq!(
pump.offsets().1,
offset(Slot::Screen).1,
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
);
}
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
#[test]
fn repeated_paints_never_restart_the_animation() {
let mut pump = standard();
pump.observe(Some(3), Some("instance-9"));
let frames = settle(&mut pump);
assert!(frames > 0);
for _ in 0..30 {
assert!(!pump.observe(Some(3), Some("instance-9")));
assert_eq!(pump.offsets(), (0.0, 0.0));
}
assert!(!pump.is_animating(), "重复绘制不该起新轨道");
}
// ------------------------------------------------------------------ 取证相位
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
#[test]
fn pinned_phase_freezes_the_clock() {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, Some(150.0));
pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半
for _ in 0..10 {
pump.advance(FRAME_MS);
}
let progress = pump.progress(Slot::Screen);
assert!(
(progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6,
"钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}"
);
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
}
-224
View File
@@ -1,224 +0,0 @@
//! 滚动条**并排**在内容右侧,不压在内容上(M173)。
//!
//! ## 根因(读 iced 0.14 的源码,不是猜测)
//!
//! `scrollable` 的竖直滚动条是**浮层**,而且内容区不扣任何横向内边距:
//!
//! - 内容拿到的约束仍是整个视口宽——`iced_widget-0.14.2/src/scrollable.rs:437` 那条
//! `layout::padded(limits, self.width, self.height, Padding::ZERO, …)`:`Scrollbar::spacing`
//! 默认 `None`,右内边距**恒 0**;
//! - 轨道按视口右边缘定位——同文件 `scrollable.rs:1970-1980`
//! `x = bounds.x + bounds.width - total_scrollbar_width`,`total_scrollbar_width =
//! width.max(scroller_width) + 2 * margin`。
//!
//! 于是轨道(本仓 6 + 2×2 = **10dp**)整条压在内容最右边那 10dp 上。屏幕上的观感就是
//! 「滚动条骑在卡片右边缘」。
//!
//! ## 为什么不用 iced 自己的 `Scrollbar::spacing(Some(..))`
//!
//! 它**只在滑块可见时**才预留(`scrollable.rs:478-500`:`if state.is_scrollbar_visible
//! { padding } else { 0.0 }`)。长页面 ⇄ 短页面切换时内容宽度会跳 10dp,卡片会横移——
//! 那是另一个缺陷(`M173` 的判据要求「有无滚动条时内容宽度一致」)。
//!
//! ## 本模块的做法:恒定槽位
//!
//! 内容外面包一层「右侧恒留 `slot`」的容器,再交给 `scrollable`:
//!
//! ```text
//! 视口 0 ───────────────────────── 视口宽 − slot │ slot │
//! │ 内容(可滚) │ 轨道 │
//! ```
//!
//! `内容可用宽度 = 视口宽 − slot` 与滑块在不在**无关**(因为内边距来自本模块,不来自 iced 的
//! 可见性判断)。`slot = rail_width + 2 * margin` 与 iced 的 `total_scrollbar_width` 同口径,
//! 轨道整个落在槽里,滑块再内缩 `margin`,所以内容与滑块之间永远隔着 `margin`。
//!
//! 逻辑像素(dp):高 DPI 下 iced 按缩放因子整体放大,槽与轨道同比例,二者不会重新重叠。
//!
//! ## 谁该用
//!
//! 所有「内容宽度吃满视口」的竖直滚动区。设置页(`pages/settings/frame.rs`)是第一个用户;
//! 实例页列表列 / 详情列、日志面板、内容库正文、版本管理侧栏目前仍是 `scrollable(...)` 裸调,
//! 同一处重叠在那些页面上照样存在(M173 的 finding 里有清单)。
use iced::widget::{container, scrollable};
use iced::{Element, Length, Padding, Theme};
/// 滚动条轨占的横向槽宽(dp)= 轨宽 + 两侧留白。
///
/// 与 iced 的 `total_scrollbar_width`(`width.max(scroller_width) + 2 * margin`)同一个式子:
/// 参数里的 `rail_width` 传的是 `Scrollbar::width` 与 `scroller_width` 的同一个值(本仓两者相等)。
pub fn rail_slot(rail_width: f32, margin: f32) -> f32 {
rail_width + 2.0 * margin
}
/// 内容可用宽度(dp)= 视口宽 − 槽宽。
///
/// 这条式子是本模块的**判据**:`components/scroll.rs` 的测试用 iced 的布局引擎真量一遍,
/// `pages/settings/tests/scroll_fit.rs` 在产品那条 `frame::scroll` 上再量一遍。
/// 负数按 0 处理——视口比槽还窄时(产品窗口不会发生)宁可内容零宽,也不给 iced 送负宽。
#[cfg(test)]
pub fn content_width(viewport: f32, slot: f32) -> f32 {
(viewport - slot).max(0.0)
}
/// 竖直滚动区:内容右侧恒留 [`rail_slot`] 的槽,滚动条画在槽里。
///
/// `style` 就是 `scrollable::style` 回调(配色由调用方按令牌给),本模块只管几何。
pub fn vertical<'a, Message, Renderer>(
rail_width: f32,
margin: f32,
style: impl Fn(&Theme, scrollable::Status) -> scrollable::Style + 'a,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::text::Renderer + 'a,
{
scrollable(beside(rail_slot(rail_width, margin), content))
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(rail_width)
.scroller_width(rail_width)
.margin(margin),
))
.height(Length::Fill)
.style(style)
.into()
}
/// 内容 + 右侧槽位。容器宽度仍是 `Fill`(= 视口宽),扣掉的是**内容的**可用宽度。
fn beside<'a, Message, Renderer>(
slot: f32,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
container(content)
.width(Length::Fill)
.padding(Padding {
right: slot,
..Padding::ZERO
})
.into()
}
#[cfg(test)]
mod tests {
//! ## 为什么这里的守卫是**真的布局**而不是源码扫描
//!
//! iced 的控件树支持在测试里跑一遍布局:`iced_core` 在 debug 构建下给 `()` 实现了全套
//! `Renderer` trait(`iced_core-0.14.0/src/renderer/null.rs`),所以 `Element<_, _, Theme, ()>`
//! 能被 `Widget::layout` 真正量出尺寸——**不需要开窗、不需要 GPU**。
//!
//! 于是「内容可用宽度 = 视口宽 − 槽宽」是一条数值断言,而不是「看图觉得对」;把
//! [`beside`] 的 `padding` 去掉,下面的 `content_width` 立刻变成 434 而断言红。
//!
//! ⚠️ 诚实边界:`()` 的 Renderer 实现带 `#[cfg(debug_assertions)]`,所以这些测试在
//! `cargo test --release` 下编不过(本仓 CI/本地一律 dev profile 跑测试)。
use iced::advanced::layout::Limits;
use iced::advanced::widget::Tree;
use iced::widget::{container, scrollable, space};
use iced::{Rectangle, Size};
use super::*;
/// 本仓的轨道参数(真源是 `pages/frame/pieces.rs` 的两个常量,这里按「用户取值」复核)。
const RAIL: f32 = 6.0;
const MARGIN: f32 = 2.0;
/// 设置页表单列在 1180 宽窗口下的列宽(`measure_cap_px` = 424)。
const VIEWPORT: f32 = 424.0;
/// 视口高(1180×760 减去页头/页边距后的量级;只要求「长页 > 视口」「短页 < 视口」)。
const VIEWPORT_HEIGHT: f32 = 600.0;
/// 长页面(一定出滑块)与短页面(一定不出滑块)的内容高。
const LONG: f32 = 4000.0;
const SHORT: f32 = 120.0;
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
///
/// 节点树:`scrollable → [槽容器(Fill 宽,右内边距 = slot) → [内容]]`,见
/// `iced_core/src/layout.rs:201`(`padded` 的节点恒为「自己 + 一个子节点」)。
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
let filler: Element<'_, (), Theme, ()> = container(space())
.width(Length::Fill)
.height(Length::Fixed(content_height))
.into();
let mut element: Element<'_, (), Theme, ()> =
vertical::<(), ()>(RAIL, MARGIN, scrollable::default, filler);
let mut tree = Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&Limits::new(Size::ZERO, Size::new(VIEWPORT, VIEWPORT_HEIGHT)),
);
let gutter = node.children()[0].bounds();
let content = node.children()[0].children()[0].bounds();
(gutter, content)
}
/// **本 mission 的核心判据**:内容可用宽度 = 视口宽 − 滚动条槽,且与有没有滑块无关。
#[test]
fn the_content_width_leaves_the_rail_its_own_slot() {
let slot = rail_slot(RAIL, MARGIN);
assert_eq!(slot, RAIL + 2.0 * MARGIN, "槽宽口径 = 轨宽 + 两侧留白");
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(
long.width,
content_width(VIEWPORT, slot),
"长页面(出滑块)的内容宽度必须是「视口宽 − 槽宽」"
);
assert_eq!(
short.width,
content_width(VIEWPORT, slot),
"短页面(不出滑块)的内容宽度必须**一样**:否则滑块一出现内容就横跳"
);
assert_eq!(VIEWPORT - long.width, slot, "右侧扣掉的正好是槽宽");
// 滑块落在槽的中间:内容右缘 → margin → 滑块 → margin → 视口右缘。
// 轨道按 iced 的算法定位(`scrollable.rs:1970-2005`:槽宽 = 轨宽 + 2×margin,滑块再内缩 margin),
// 所以「内容右缘 + margin」就是滑块的左缘。
let content_right = long.x + long.width;
let pill_left = content_right + MARGIN;
let pill_right = pill_left + RAIL;
assert_eq!(
pill_right + MARGIN,
VIEWPORT,
"滑块必须整个落在槽内(两侧各留 margin),否则「并排」变成「压着」"
);
}
/// 槽容器自己仍是满宽(`Fill`),否则页面为它预留的 424 会被再吃掉一截。
#[test]
fn the_slot_wrapper_keeps_the_full_viewport_width() {
let (gutter, _) = laid_out(LONG);
assert_eq!(gutter.width, VIEWPORT);
}
/// 左边界与宽度在长/短两态下**逐项相同**(M173 明写的「两张对照图 + 数值」里的数值部分)。
#[test]
fn long_and_short_pages_share_the_same_left_edge_and_width() {
let (_, long) = laid_out(LONG);
let (_, short) = laid_out(SHORT);
assert_eq!(long.x, short.x, "左边界必须一致");
assert_eq!(long.width, short.width, "宽度必须一致");
assert_eq!(short.x, 0.0, "槽只占右侧,左侧内边距为 0");
}
#[test]
fn content_width_never_goes_negative() {
assert_eq!(content_width(RAIL, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(0.0, rail_slot(RAIL, MARGIN)), 0.0);
assert_eq!(content_width(VIEWPORT, 0.0), VIEWPORT);
}
}
@@ -14,24 +14,6 @@
//! M3 的 chip **没有圆形状态层**:hover / pressed 就是把状态层叠在**自己的容器**上, //! M3 的 chip **没有圆形状态层**:hover / pressed 就是把状态层叠在**自己的容器**上,
//! 而容器尺寸 = 命中区尺寸。所以 chip 直接走 `button` 的样式闭包(与按钮家族同一条产线), //! 而容器尺寸 = 命中区尺寸。所以 chip 直接走 `button` 的样式闭包(与按钮家族同一条产线),
//! 不需要「48 外壳 + 40 圆」那套。 //! 不需要「48 外壳 + 40 圆」那套。
//!
//! ## 「选中」与「当前项」:两个档位,一套颜色(M206)
//!
//! 这两件事**不是**「选中 / 未选中」,而是「可点的选中」与「不可点的当前项」——它们必须长得
//! **一样**(同一个视觉语汇),差别只在**有没有反馈**:
//!
//! | 档位 | 构造器 | 容器 / 文字 | 对比度下限 | 五态反馈 | 什么时候用 |
//! |---|---|---|---|---|---|
//! | A 可点选中 | [`Chip::filter`] `(label, true, msg)` | `SecondaryContainer` / `OnSecondaryContainer` | **≥ 4.5:1**(WCAG AA 正文;实测 7.21:1 浅 / 7.21:1 深) | 有(hover/pressed 状态层) | 点它**会改变**选中项(下载组的分段单选、预置项开关) |
//! | B 不可点当前项 | [`Chip::current`] | **同 A,一个像素都不差** | **≥ 4.5:1**(同 7.21:1) | **无**(没有可点性就没有反馈) | 「这就是当前生效的那个」,再点一次没有动作(Java 组的「默认」) |
//! | (不是档位)暂不可用 | [`Chip::badge`] / [`.disabled()`](Chip::disabled) | `OnSurface`@12% / @38% | **不适用**:它是禁用语汇(实测 1.84:1 浅 / 2.28:1 深) | — | 真的点不了(下载进行中把「安装」灰掉、LTS/基线这类标签) |
//!
//! **判据只有一句**:这个 chip 点下去**会不会改变**它表示的值?
//! 会 → A;不会(它已经是当前值)→ B;**根本不能点**(忙/不可用)→ 禁用档。
//! 尤其:**不许拿 `badge` 表示「当前默认」**——那是把「不可用」的语汇贴在「答案」上,
//! M204 实测出来就是整页最不可读的一段(1.84:1)。这一条不靠调用方记住,`Chip::current`
//! 与 `paint::current_item` 的文档 + `pages::settings::java::tests::marker` 的三条断言
//! (对比度 / 同族 / 禁用语义)一起把它钉在组件层。
use iced::widget::{button, container, row, text}; use iced::widget::{button, container, row, text};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow}; use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
@@ -56,8 +38,6 @@ pub struct Chip<'a, M> {
trailing: Option<IconKind>, trailing: Option<IconKind>,
on_press: Option<M>, on_press: Option<M>,
state: Option<Interaction>, state: Option<Interaction>,
/// 「当前项」标记([`Chip::current`]):选中配色照画,但**不**降成禁用配色、也不吃五态。
marker: bool,
} }
impl<'a, M> Chip<'a, M> { impl<'a, M> Chip<'a, M> {
@@ -76,49 +56,6 @@ impl<'a, M> Chip<'a, M> {
Self::new(label, ChipVariant::Input, false, on_press) Self::new(label, ChipVariant::Input, false, on_press)
} }
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
///
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**。
///
/// ⚠️ **它不是「当前项」**(M206 纠正):`badge` 走的是**禁用**配色(`OnSurface`@38% 叠
/// surface,实测 **1.84:1 浅 / 2.28:1 深**),那是「暂不可用」的语汇。要表达「这行就是当前
/// 生效的那个」,用 [`Chip::current`]——它和下载组的选中 chip 同一套颜色,7.21:1。
/// 仍然用 `badge` 的两处都是**真禁用语义**:下载进行中把「安装」灰掉、以及 LTS/基线/推荐这类标签。
pub fn badge(label: &'a str) -> Self {
Self {
label,
variant: ChipVariant::Filter,
selected: true,
leading: None,
trailing: None,
on_press: None,
state: None,
marker: false,
}
}
/// **当前项**标记:`SecondaryContainer` + `OnSecondaryContainer`(与可点的选中 chip
/// **同一套配色**),没有消息 ⇒ 也不吃 hover / pressed / 禁用态。
///
/// 语义:「这一行就是当前生效的那个」。它与 [`Chip::filter`]`(label, true, msg)` 的差别
/// **只在有没有动作**——点当前值不会改变任何东西,所以给它一个永远不响的消息是撒谎
/// (`Message::Noop` 已删,见 `Chip::badge` 的旧注)。判据表见本文档开头的「选中与当前项」。
///
/// 为什么不能写成「`filter(.., true, ..)` + `.disabled()`」:`disabled()` 会走禁用配色
/// (38% 文字、12% 容器),当前项就又变成不可读了——这正是 M206 要修的那件事。
pub fn current(label: &'a str) -> Self {
Self {
label,
variant: ChipVariant::Filter,
selected: true,
leading: None,
trailing: None,
on_press: None,
state: None,
marker: true,
}
}
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。 /// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
/// ///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的 /// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
@@ -139,7 +76,6 @@ impl<'a, M> Chip<'a, M> {
trailing: None, trailing: None,
on_press: Some(on_press), on_press: Some(on_press),
state: None, state: None,
marker: false,
} }
} }
@@ -160,25 +96,6 @@ impl<'a, M> Chip<'a, M> {
self.state = Some(state); self.state = Some(state);
self self
} }
/// 静息时的外观——**唯一**把「这颗 chip 属于哪个档位」翻成颜色的地方。
///
/// `chip()` 用它求文字/图标色,测试也用它量对比度:档位的判据(当前项 / 选中 / 禁用)
/// 只有这一处,所以「实现改回旧配色」这类变异会被
/// `pages::settings::java::tests::marker` 的对比度断言直接抓住。
pub fn paint(&self, ui: &Ui) -> paint::ChipPaint {
let radius = ChipGeometry::of(ui).radius;
if self.marker {
return paint::current_item(ui, radius);
}
// 没有消息 = 禁用(`Chip::badge` 与 `.disabled()` 都走这里)。
let state = if self.on_press.is_none() {
Interaction::Disabled
} else {
self.state.unwrap_or(Interaction::Enabled)
};
paint::chip(ui, self.variant, self.selected, state, radius)
}
} }
/// 画一个 chip。 /// 画一个 chip。
@@ -186,14 +103,22 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
let geometry = ChipGeometry::of(ui); let geometry = ChipGeometry::of(ui);
let variant = spec.variant; let variant = spec.variant;
let selected = spec.selected; let selected = spec.selected;
let marker = spec.marker; let disabled = spec.on_press.is_none();
let forced = spec.state; let forced = spec.state;
// 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变), // 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变),
// 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。档位的判据在 `Chip::paint` 一处 // 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。
// (M206:当前项标记走 `paint::current_item`——**不是**禁用态。它的不可点性与「忙/不可用」 let at_rest = paint::chip(
// 是两件事:前者是「已经选中的那个」(选中配色),后者是「暂不可用」(38% 文字))。 ui,
let at_rest = spec.paint(ui); variant,
selected,
if disabled {
Interaction::Disabled
} else {
forced.unwrap_or(Interaction::Enabled)
},
geometry.radius,
);
let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap); let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap);
if let Some(kind) = spec.leading { if let Some(kind) = spec.leading {
@@ -236,13 +161,8 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
left: geometry.pad, left: geometry.pad,
}) })
.style(move |_theme, status| { .style(move |_theme, status| {
// 标记不吃五态:没有可点性就没有反馈(`--force-state` 也不该把它画成 hover)。
let paint = if marker {
paint::current_item(ui, geometry.radius)
} else {
let state = forced.unwrap_or_else(|| Interaction::from_status(status)); let state = forced.unwrap_or_else(|| Interaction::from_status(status));
paint::chip(ui, variant, selected, state, geometry.radius) let paint = paint::chip(ui, variant, selected, state, geometry.radius);
};
button::Style { button::Style {
background: paint.frame.fill.map(Background::Color), background: paint.frame.fill.map(Background::Color),
text_color: paint.label, text_color: paint.label,
@@ -77,30 +77,3 @@ pub fn chip(
icon: ui.color(icon_role), icon: ui.color(icon_role),
} }
} }
/// 「**当前项**」标记的外观(M206):**就是选中 chip 的那一套**,一个像素都不另起一套。
///
/// 为什么要一个具名入口而不是让调用点自己拼 `chip(.., Filter, true, Enabled, ..)`:
///
/// 1. 这个语义(「这行就是当前生效的那个」)此前被画成了 [`super::super::Chip::badge`] 的
/// **禁用**配色(`OnSurface`@38% 叠 surface,M204 实测 **1.84:1 浅 / 2.28:1 深**)——那是
/// 「暂不可用」的语汇,而当前项不是不可用,它是**已经选中的那个**。修法不是给禁用配色调亮,
/// 而是让当前项改用**选中**的语汇;
/// 2. 与左侧导航里相邻的「下载」组的选中 chip(`Chip::filter(.., true, ..)`,容器
/// `SecondaryContainer`、文字 `OnSecondaryContainer`,实测 **7.21:1 浅 / 7.21:1 深**)
/// **同族**:两页表达「选中」只用一套颜色,用户不用在两页之间切换心智模型。身份由
/// `pages::settings::java::tests::marker` 的
/// `the_current_default_marker_speaks_the_download_groups_language` 钉住——谁把其中一页
/// 改成另一套配色,那条立刻红。具体的 8 bit 色值不写在这里:`tests/no_hardcoded_tokens.rs`
/// 连注释里的十六进制色值也拦,色值只该在报告与令牌表里出现。
///
/// 与选中 chip 的唯一差别**不在颜色**:标记没有消息(`on_press == None`),所以它不吃 hover /
/// pressed / disabled 五态(见 `Chip::current` 的文档)。对比度下限是 [`crate::components::selection::paint`]
/// 同一条正文口径(WCAG AA 4.5:1,`unlucky_design::palette::wcag::AA_TEXT`)。
///
/// 与选中 chip 的唯一差别**不在颜色**:标记没有消息(`on_press == None`),所以它不吃 hover /
/// pressed / disabled 五态(见 `Chip::current` 的文档)。对比度下限是 [`crate::components::selection::paint`]
/// 同一条正文口径(WCAG AA 4.5:1,`unlucky_design::palette::wcag::AA_TEXT`)。
pub fn current_item(ui: &Ui, radius: f32) -> ChipPaint {
chip(ui, ChipVariant::Filter, true, Interaction::Enabled, radius)
}
@@ -35,7 +35,7 @@ mod radio;
mod switch; mod switch;
pub use check::{checkbox, checkbox_layer_role}; pub use check::{checkbox, checkbox_layer_role};
pub use chip::{chip, current_item, ChipVariant}; pub use chip::{chip, ChipVariant};
pub use radio::{radio, radio_layer_role}; pub use radio::{radio, radio_layer_role};
pub use switch::{switch, switch_layer}; pub use switch::{switch, switch_layer};
+15 -91
View File
@@ -18,31 +18,18 @@
//! ## 不做什么 //! ## 不做什么
//! //!
//! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活, //! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
//! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。 //! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
//! //!
//! ## 中文那一半(M92 / D28 后半 / M168 实装下载) //! ## 中文那一半(M92 / D28 后半)
//! //!
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`), //! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI), //! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、 //! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是 //! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
//! cosmic-text 的全局状态,不是本模块能管的事)。 //! cosmic-text 的全局状态,不是本模块能管的事)。
//!
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
//!
//! ```text
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
//! ```
//!
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
pub mod cdn;
mod cjk; mod cjk;
pub mod install;
use cjk::CjkReport; use cjk::CjkReport;
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch}; use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
@@ -152,64 +139,15 @@ pub fn resolve() -> Resolved {
resolve_with(None) resolve_with(None)
} }
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
///
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
fn register_termux_fonts() {
use std::sync::OnceLock;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| {
if !termux::is_termux(&ProcessEnv) {
return;
}
const CANDIDATES: &[&str] = &[
"/system/fonts/NotoSansCJK-Regular.ttc",
"/system/fonts/NotoSansCJKSC-Regular.otf",
"/system/fonts/DroidSansFallback.ttf",
];
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
return;
};
let db = font_system.raw().db_mut();
for path in CANDIDATES {
if db.load_font_file(path).is_ok() {
return;
}
}
});
}
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
///
/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是
/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入,
/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。
pub fn resolve_with(setting: Option<&str>) -> Resolved { pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts();
resolve_with_faces(setting, system_faces().as_deref())
}
/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。
///
/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。
///
/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`)
/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发,
/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。
/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
///
/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。
pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved {
let platform = Platform::current(); let platform = Platform::current();
let faces = system_faces();
let is_installed = |family: &str| -> bool { let is_installed = |family: &str| -> bool {
faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family)) faces
.as_ref()
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
}; };
let families = [ let families = [
@@ -233,11 +171,11 @@ pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Reso
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」, // 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。 // 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
let weights = [Weight::Regular, Weight::Medium, Weight::Bold] let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
.map(|weight| weight_report(platform, families[0].family, weight, faces)); .map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」, // D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
let cjk_report = cjk::probe_with(setting, faces); let cjk_report = cjk::probe_with(setting, faces.as_deref());
Resolved { Resolved {
platform, platform,
@@ -247,34 +185,20 @@ pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Reso
} }
} }
/// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里 /// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值**
/// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 /// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
/// ///
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上 /// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形, /// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
/// 那就是白块。 /// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`,
/// /// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。
/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源
/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。
/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器
/// 会白下一次 17 MiB。
///
/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经
/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`**
/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳
/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。
pub fn cjk_for(setting: Option<&str>) -> CjkReport { pub fn cjk_for(setting: Option<&str>) -> CjkReport {
let report = cjk::probe_with(setting, system_faces().as_deref()); cjk::probe_with(setting, system_faces().as_deref())
install::after_probe(report.strategy);
report
} }
/// 一条系统字面(族名 + 字重)。 /// 一条系统字面(族名 + 字重)。
///
/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一
/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Face { struct Face {
family: String, family: String,
weight: u16, weight: u16,
} }
-173
View File
@@ -1,173 +0,0 @@
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
//!
//! ## 一句话
//!
//! ```text
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
//! core 的 FontSpec(值)
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
//! ```
//!
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
//! 单测)。
//!
//! ## 「不联网」的三条路
//!
//! | 情形 | 谁拦住 |
//! |---|---|
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
//!
//! ## 设置改写只换 URL,不换哈希
//!
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
use std::cell::Cell;
use std::path::PathBuf;
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
use unlucky_i18n::font::{CjkFont, FetchError, Source};
use crate::platform::data;
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
#[derive(Debug, Clone)]
pub struct CdnFetcher {
cache_root: PathBuf,
spec: FontSpec,
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
///
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
last_from_cache: Cell<Option<bool>>,
}
impl CdnFetcher {
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
///
/// # Errors
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
Ok(Self {
cache_root,
spec: spec_of(font)?,
last_from_cache: Cell::new(None),
})
}
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
///
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
#[must_use]
pub fn has_cached(&self) -> bool {
core_font::cache_path(&self.cache_root, &self.spec).is_file()
}
/// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。
///
/// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。
#[must_use]
pub fn was_cached(&self) -> Option<bool> {
self.last_from_cache.get()
}
/// 缓存文件落点(诊断日志用)。
#[must_use]
pub fn cache_file(&self) -> PathBuf {
core_font::cache_path(&self.cache_root, &self.spec)
}
}
impl unlucky_i18n::font::Fetcher for CdnFetcher {
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
self.last_from_cache
.set(outcome.as_ref().ok().map(|fetch| fetch.from_cache));
outcome.map(|fetch| fetch.bytes)
}
}
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
///
/// # Errors
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
let Source::Pinned {
urls,
sha256,
bytes,
} = font.source;
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
if let Some(override_url) = data::font_cdn_override() {
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
candidates.insert(0, override_url);
}
FontSpec::new(candidates, sha256, bytes)
.map_err(|error| FetchError::Integrity(error.to_string()))
}
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
fn translate(error: FontFetchError) -> FetchError {
match error {
FontFetchError::Network(message) => FetchError::Network(message),
FontFetchError::Integrity(message) => FetchError::Integrity(message),
FontFetchError::Disk(message) => FetchError::Disk(message),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::font::NOTO_SANS_SC;
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
///
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
#[test]
fn the_pin_survives_the_trip_to_core() {
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
let Source::Pinned {
urls,
sha256,
bytes,
} = NOTO_SANS_SC.source;
assert!(
spec.urls.len() >= urls.len(),
"候选不该变少:{:?}",
spec.urls
);
assert_eq!(spec.sha256, sha256);
assert_eq!(spec.bytes, bytes);
assert_eq!(
spec.urls[spec.urls.len() - urls.len()],
urls[0],
"内置首选不许被挤掉"
);
}
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
#[test]
fn an_empty_cache_root_is_not_a_hit() {
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
assert!(!fetcher.has_cached());
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
assert!(fetcher
.cache_file()
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
}
}
+2 -3
View File
@@ -19,9 +19,8 @@
//! //!
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); //! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` //! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过); //! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`);
//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] / //! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
//! //!
//! ## `ui.language` 的接线(M106) //! ## `ui.language` 的接线(M106)
//! //!
-361
View File
@@ -1,361 +0,0 @@
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
//!
//! ## 一句话
//!
//! ```text
//! i18n::font::decide(判据) → Strategy
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
//! fonts::install::register(注册) → iced 的字体系统里多一个族
//! ```
//!
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
//!
//! | 入口 | 时机 | 谁来 |
//! |---|---|---|
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
//!
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
//!
//! ## 为什么下载在后台线程而不是启动那一拍
//!
//! 一份 16.9 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
//!
//! ## 失败**不是**致命错误
//!
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
//! (「下载失败不崩」)。
//!
//! ## 状态是一份进程级快照
//!
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
//!
//! ## 截图通道
//!
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
use std::sync::{Mutex, OnceLock};
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchClass {
/// 网络层。
Network,
/// 校验层(哈希 / 魔数 / 大小上限)。
Integrity,
/// 磁盘层。
Disk,
}
impl FetchClass {
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Integrity => "integrity",
Self::Disk => "disk",
}
}
}
impl From<&FetchError> for FetchClass {
fn from(error: &FetchError) -> Self {
match error {
FetchError::Network(_) => Self::Network,
FetchError::Integrity(_) => Self::Integrity,
FetchError::Disk(_) => Self::Disk,
}
}
}
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CjkInstall {
/// 还没判定(启动前 / 判据没跑过)。
Unknown,
/// 英文环境:不需要中文字体(D28 的第一条)。
NotNeeded,
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
System {
/// 命中的族名。
family: String,
},
/// 探测不出(拿不到系统字体库)——**不下载**。
Unprobeable,
/// 正在下载。
Downloading {
/// 字节数(pin 里的期望值;用来写「16.9 MB」这种可核对的量)。
bytes: u64,
},
/// 已就绪(下载完成并注册进字体系统)。
Ready {
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
family: String,
/// 是否零网络命中缓存。
from_cache: bool,
},
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
Failed {
/// 类别。
class: FetchClass,
/// 人话原因(日志/诊断用;UI 只按类别选词)。
message: String,
},
}
impl CjkInstall {
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
#[must_use]
pub const fn is_downloading(&self) -> bool {
matches!(self, Self::Downloading { .. })
}
/// 一行日志片段(`cjk-install=…`)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Unknown => "cjk-install=unknown".to_owned(),
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
Self::Ready { family, from_cache } => {
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
}
Self::Failed { class, message } => {
format!("cjk-install=failed({},{message})", class.as_str())
}
}
}
}
/// 进程级状态(一份)。
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
}
/// 当前状态(设置页那一行、心跳、日志都读它)。
#[must_use]
pub fn status() -> CjkInstall {
if let Some(forced) = capture_override() {
return forced;
}
slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
fn capture_override() -> Option<CjkInstall> {
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
FORCED
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
.clone()
}
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
#[must_use]
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
let value = raw.trim();
Some(match value {
"not-needed" => CjkInstall::NotNeeded,
"unprobeable" => CjkInstall::Unprobeable,
"system" => CjkInstall::System {
family: "Noto Sans SC".to_owned(),
},
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
"ready" => CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
"failed" | "failed:network" => CjkInstall::Failed {
class: FetchClass::Network,
message: "capture fixture".to_owned(),
},
"failed:integrity" => CjkInstall::Failed {
class: FetchClass::Integrity,
message: "capture fixture".to_owned(),
},
"failed:disk" => CjkInstall::Failed {
class: FetchClass::Disk,
message: "capture fixture".to_owned(),
},
_ => return None,
})
}
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
///
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
pub fn ensure_for(
strategy: Strategy,
fetcher: &dyn Fetcher,
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
) -> CjkInstall {
match font::ensure(strategy, fetcher) {
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
family: family.to_owned(),
},
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
Ok(family) => CjkInstall::Ready {
family,
from_cache: false,
},
Err(message) => CjkInstall::Failed {
class: FetchClass::Disk,
message,
},
},
Err(error) => CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
},
}
}
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
///
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
/// 什么都不做(已经在下载 / 已就绪)。
Skip,
/// 只把状态改成这个(零网络)。
Apply(CjkInstall),
/// 真的发起一次下载。
Start(&'static font::CjkFont),
}
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
///
/// | 当前状态 | 策略 | 这一步 |
/// |---|---|---|
/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) |
/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) |
/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) |
/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) |
///
/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②)
///
/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」
/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**:
/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在
/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是
/// 常见情形。
///
/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底)
///
/// | 档 | panic 语义 | 线程 panic 之后 |
/// |---|---|---|
/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) |
/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 |
///
/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`),
/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的
/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。
///
/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外
/// 产生网络流量并可能在出图过程中注册新字体。
#[must_use]
pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step {
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
return Step::Skip;
}
match strategy {
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
Strategy::System { family } => Step::Apply(CjkInstall::System {
family: family.to_owned(),
}),
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
// 出图通道需要下载 ⇒ **什么都不做**(也不改状态):写「不需要下载」是谎话,
// 写「正在下载」也是谎话(并没有在下载)。
Strategy::NeedsDownload(_) if offline => Step::Skip,
Strategy::NeedsDownload(font) => Step::Start(font),
}
}
/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。
#[must_use]
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
plan_with_offline(strategy, current, offline())
}
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
///
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB);
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网;
/// * 出图通道([`set_offline`])→ 永不发起。
pub fn after_probe(strategy: Strategy) -> CjkInstall {
match plan(strategy, &status()) {
Step::Skip => status(),
Step::Apply(state) => {
update(state.clone());
state
}
Step::Start(font) => {
worker::start(font);
status()
}
}
}
/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。
///
/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体——
/// 这既产生流量,也让「同一份代码出同一张图」不再成立。
pub fn set_offline(offline: bool) {
OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed);
}
/// 当前是否处于 [`set_offline`] 的状态。
#[must_use]
pub fn offline() -> bool {
OFFLINE.load(std::sync::atomic::Ordering::Relaxed)
}
/// 进程级联网开关(默认允许)。
static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
pub(super) fn update(state: CjkInstall) {
let previous = {
let mut guard = slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::replace(&mut *guard, state.clone())
};
if previous != state {
eprintln!("[font] {}", state.describe());
}
}
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
mod worker;
#[cfg(test)]
#[path = "install/tests.rs"]
mod tests;
-369
View File
@@ -1,369 +0,0 @@
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
//!
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
use std::cell::RefCell;
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
use super::*;
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
struct FakeFetcher {
calls: RefCell<usize>,
seen: RefCell<Option<&'static str>>,
result: Result<Vec<u8>, FetchError>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
Self {
calls: RefCell::new(0),
seen: RefCell::new(None),
result,
}
}
fn calls(&self) -> usize {
*self.calls.borrow()
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
*self.calls.borrow_mut() += 1;
*self.seen.borrow_mut() = Some(font.family);
self.result.clone()
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
let recorder = std::rc::Rc::clone(&seen);
let register = move |bytes: Vec<u8>| {
recorder.borrow_mut().push(bytes.len());
Ok("Noto Sans SC".to_owned())
};
(seen, register)
}
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
///
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
/// `CdnFetcher` 的缓存判定承担)。
#[test]
fn the_three_idle_strategies_never_touch_the_fetcher() {
let (registered, register) = recorder();
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(
ensure_for(Strategy::NotNeeded, &idle, &register),
CjkInstall::NotNeeded
);
assert_eq!(
ensure_for(Strategy::System { family: "SimSun" }, &idle, &register),
CjkInstall::System {
family: "SimSun".to_owned()
}
);
assert_eq!(
ensure_for(Strategy::Unprobeable, &idle, &register),
CjkInstall::Unprobeable
);
assert_eq!(
idle.calls(),
0,
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
);
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
let working = FakeFetcher::new(Ok(vec![9; 4096]));
assert_eq!(
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, &register),
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false
}
);
assert_eq!(working.calls(), 1);
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
assert_eq!(*registered.borrow(), vec![4096]);
}
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
///
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
#[test]
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
let (registered, register) = recorder();
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, &register);
assert_eq!(
state,
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned()
}
);
assert!(registered.borrow().is_empty(), "校验失败不许注册");
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
}
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
#[test]
fn every_failure_class_survives_the_trip() {
let (_, register) = recorder();
for (error, class) in [
(FetchError::Network("超时".to_owned()), FetchClass::Network),
(
FetchError::Integrity("哈希不符".to_owned()),
FetchClass::Integrity,
),
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
] {
let fetcher = FakeFetcher::new(Err(error));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, &register);
match state {
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
other => panic!("应当是 Failed:{other:?}"),
}
}
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
let state = ensure_for(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&fetcher,
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
);
assert!(matches!(
state,
CjkInstall::Failed {
class: FetchClass::Disk,
..
}
));
}
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
///
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
///
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
///
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
#[test]
fn a_started_or_finished_install_is_not_restarted() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
let ready = CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
};
for current in [downloading, ready] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Skip,
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
);
}
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
for current in [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::Failed {
class: FetchClass::Network,
message: "超时".to_owned(),
},
] {
assert_eq!(
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), &current, false),
Step::Start(&NOTO_SANS_SC),
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
);
}
}
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
///
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
///
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
#[test]
fn the_idle_strategies_only_apply_state() {
assert_eq!(
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
Step::Apply(CjkInstall::NotNeeded)
);
assert_eq!(
plan_with_offline(
Strategy::System { family: "SimSun" },
&CjkInstall::Unknown,
false
),
Step::Apply(CjkInstall::System {
family: "SimSun".to_owned()
})
);
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
// 不存在停在 Downloading 的状态。
assert_eq!(
plan_with_offline(
Strategy::Unprobeable,
&CjkInstall::Downloading { bytes: 1 },
false
),
Step::Skip,
"在飞的下载不许被别的策略改写状态"
);
}
/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。
///
/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)——
/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。
/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。
#[test]
fn every_strategy_skips_while_a_download_is_in_flight() {
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
for strategy in [
Strategy::NeedsDownload(&NOTO_SANS_SC),
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &downloading, false),
Step::Skip,
"{strategy:?}:下载中不许改状态、不许起第二条线程"
);
}
}
/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。
///
/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示
/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。
/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。
#[test]
fn the_screenshot_channel_never_starts_a_download() {
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
true
),
Step::Skip,
"出图通道不许联网"
);
assert_eq!(
plan_with_offline(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&CjkInstall::Unknown,
false
),
Step::Start(&NOTO_SANS_SC),
"正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)"
);
// 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。
for strategy in [
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::Unprobeable,
] {
assert_eq!(
plan_with_offline(strategy, &CjkInstall::Unknown, false),
plan_with_offline(strategy, &CjkInstall::Unknown, true),
"{strategy:?}:不联网的策略与通道无关"
);
}
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
///
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
#[test]
fn the_capture_switch_parses_every_state() {
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
assert!(matches!(
parse_capture("system"),
Some(CjkInstall::System { .. })
));
assert!(matches!(
parse_capture("downloading"),
Some(CjkInstall::Downloading { .. })
));
assert!(matches!(
parse_capture("ready"),
Some(CjkInstall::Ready {
from_cache: true,
..
})
));
for (raw, class) in [
("failed", FetchClass::Network),
("failed:network", FetchClass::Network),
("failed:integrity", FetchClass::Integrity),
("failed:disk", FetchClass::Disk),
] {
match parse_capture(raw) {
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
other => panic!("{raw} 应解析成 Failed:{other:?}"),
}
}
assert_eq!(parse_capture("bogus"), None);
assert_eq!(parse_capture(""), None);
}
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
#[test]
fn every_state_describes_itself_on_one_line() {
let states = [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::System {
family: "SimSun".to_owned(),
},
CjkInstall::Unprobeable,
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false,
},
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned(),
},
];
for state in states {
let line = state.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk-install="), "{line}");
}
}
-164
View File
@@ -1,164 +0,0 @@
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
//!
//! ## 为什么拆出来
//!
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
//!
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
//!
//! ## 为什么下载在后台线程
//!
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
//!
//! ## 线程里做了什么(顺序即判据)
//!
//! ```text
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限)
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
//! ```
//!
//! ## panic 这件事分两档说(复审 r3 P2-1)
//!
//! | 档 | panic 语义 | 线程 panic 之后 |
//! |---|---|---|
//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 |
//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` |
//!
//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径
//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`,
//! 那属于发布 profile 的决定,不在本 mission)。
//!
//! ## 诚实边界
//!
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为
//! (那要真跑一个 `--release` 的 panic 用例)。
use unlucky_i18n::font::{self, Strategy};
use super::{ensure_for, update, CjkInstall, FetchClass};
use crate::fonts::cdn::CdnFetcher;
use crate::platform::data;
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
///
/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是
/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。
pub(super) fn start(font: &'static font::CjkFont) {
let font::Source::Pinned { bytes, .. } = font.source;
let Some(cache_root) = data::cache_root() else {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: "数据根解析不出来,字体无处可落".to_owned(),
});
return;
};
let fetcher = match CdnFetcher::new(font, cache_root) {
Ok(fetcher) => fetcher,
Err(error) => {
update(CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
});
return;
}
};
let cache_file = fetcher.cache_file();
update(CjkInstall::Downloading { bytes });
eprintln!(
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
font.family,
cache_file.display(),
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
fetcher.has_cached()
);
let spawned = std::thread::Builder::new()
.name("ull-cjk-font".to_owned())
.spawn(move || run(font, fetcher));
if let Err(error) = spawned {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: format!("起不了字体下载线程({error})"),
});
}
}
/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。
fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) {
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
ensure_for(
Strategy::NeedsDownload(font),
&fetcher,
&register_into_font_system,
)
})) {
Ok(state) => state,
Err(_) => CjkInstall::Failed {
class: FetchClass::Disk,
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(),
},
};
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
let from_cache = fetcher.was_cached().unwrap_or(false);
let state = match state {
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
other => other,
};
match &state {
CjkInstall::Ready { family, from_cache } => eprintln!(
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} \
(中文字形现在由它兜底,界面不需要重启)"
),
CjkInstall::Failed { class, message } => eprintln!(
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
class.as_str()
),
other => eprintln!("[font] cjk-download {}", other.describe()),
}
update(state);
}
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
///
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
///
/// # Errors
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
let lock = iced::advanced::graphics::text::font_system();
let mut font_system = lock
.write()
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
font_system.load_font(std::borrow::Cow::Owned(bytes));
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
// 那个借用活过守卫(E0597)。
let families: Vec<String> = font_system
.raw()
.db()
.faces()
.flat_map(|face| face.families.iter())
.map(|(family, _)| family.clone())
.collect();
families
.iter()
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
.cloned()
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
}
-39
View File
@@ -173,42 +173,3 @@ fn type_roles_pick_the_display_stack_for_big_text() {
); );
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text); assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
} }
/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。
///
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
///
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
#[test]
fn resolving_fonts_never_starts_a_download() {
use unlucky_i18n::font::Strategy;
use crate::fonts::install;
// 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。
let latin: Vec<Face> = ["Segoe UI", "Consolas"]
.iter()
.map(|family| Face {
family: (*family).to_owned(),
weight: 400,
})
.collect();
let before = install::status();
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
let after = install::status();
assert_eq!(
resolved.cjk.strategy,
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
);
assert!(
!after.is_downloading(),
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
);
}
+1 -1
View File
@@ -14,7 +14,7 @@
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
//! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) | //! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) |
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— `unsafe` 落点之一;另一半是 M196 的控制台附着 | 窗口创建 / 业务开关 | //! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 |
//! //!
//! ## 消费纪律 //! ## 消费纪律
//! //!
+2 -44
View File
@@ -1,4 +1,4 @@
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里。 //! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
//! //!
//! 一个二进制两种用法(M82),分发在进 UI 之前完成: //! 一个二进制两种用法(M82),分发在进 UI 之前完成:
//! //!
@@ -15,49 +15,13 @@
//! 给服务器 / CI / Termux 这类没有图形库的机器用。 //! 给服务器 / CI / Termux 这类没有图形库的机器用。
//! //!
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。 //! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。
//!
//! ## 为什么这里是 WINDOWS GUI 子系统(M196)
//!
//! 下面那条 `#![cfg_attr(windows, windows_subsystem = "windows")]` 直接改的是**产物 PE 头**的
//! `Subsystem` 字段(CUI=3 → WINDOWS_GUI=2),这是「双击不再多一个常驻控制台宿主窗口」的
//! 唯一机制:PE 头写 3 时由系统在启动时分配控制台(那个黑框),写 2 时系统不分配。
//!
//! **为什么放这一层而不是 `build.rs`**:PE 子系统是**每个 bin 目标**的属性,而 `cfg_attr` 就是
//! 它的原生长相——rustc 读到它就把标志写进链接出来的 PE 里,没有任何别的路径能改到那一行。
//! `build.rs` 做不到这件事:它只能在编译**之前**跑(`cargo:rustc-link-arg` 之类确实能加链接参数,
//! 但那是「顺手改了链接命令行」的间接法,且在交叉/多 bin 时按 crate 而非按 target 生效),
//! 更麻烦的是它把「这条 bin 是 GUI」这条事实挪到一个**看不见 bin 声明**的地方——本 crate 的
//! `[[bin]]` 名是 `unlucky-launcher-iced`(不是 crate 名),读 `build.rs` 的人得先在 Cargo.toml
//! 里找回来。放 `main.rs` 顶上,事实与产物一一对应:改这个文件 = 改这个 bin。
//!
//! 代价与补救见 [`unlucky_app_iced::platform::win32::console`]:GUI 子系统进程**不继承**父进程
//! 的控制台,所以 `main()` 第一步就把输出接回父控制台(`AttachConsole`),CLI 子命令、
//! `--diff`、`--screenshot` 的日志在 cmd / 终端里照样看得见;双击时没有父控制台,附着失败、
//! 也绝不新建窗口。判据是**产物 PE 头的读数**,不是「代码看起来对」:
//! `packaging/check_pe_subsystem.py` 读 `Subsystem` 字段,非 2 即红(`release-build.yml` 里跑)。
//!
//! 历史:同样的修复曾做过一次,但打在已退役的 Slint 树(`37d3039`,只改了 `crates/app/src/main.rs`),
//! 而 `crates/app` 自 M118 起不在 workspace、不进发布矩阵——所以那一笔从未到这里。
// M196:PE 头的子系统 = WINDOWS_GUI(2)。没有它,双击产物会多一个控制台宿主窗口。
// 这一行只对 bin 目标有意义(lib 没有 PE 头),所以放在 main.rs 而不是 lib.rs。
#![cfg_attr(windows, windows_subsystem = "windows")]
use std::process::ExitCode; use std::process::ExitCode;
use unlucky_app_iced::platform::win32::console::attach_parent_console;
use unlucky_app_iced::shot; use unlucky_app_iced::shot;
use unlucky_app_iced::Options; use unlucky_app_iced::Options;
fn main() -> ExitCode { fn main() -> ExitCode {
// M196 第一步:GUI 子系统进程默认没有控制台(stdout/stderr 是 0 号无效句柄),
// 不接回来 CLI 输出与所有 eprintln 都会静默丢失。四种启动情形的实测表在
// `platform/win32/console.rs` 顶部:重定向/管道两个句柄本来就可用(一个字节都不动),
// cmd 里直接跑要 attach,双击时 attach 必失败且不会新建窗口。
let console = attach_parent_console();
if std::env::var_os("ULL_ACCEPTANCE").is_some() {
eprintln!("[console] {}", console.describe());
}
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(code) = run_cli_if_requested(&args) { if let Some(code) = run_cli_if_requested(&args) {
return code; return code;
@@ -79,15 +43,9 @@ fn main() -> ExitCode {
); );
// M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。 // M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。
eprintln!( eprintln!(
"验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\ "验收诊断:ULL_ACCEPTANCE=1([env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸" ,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
); );
// M201:设置页要出「带真值」的验收图,而截图通道默认强制 mock(mock 不发
// `CoreEvent::SettingsChanged`)。这一条把**数据源**换成真 host,其余一字不改。
eprintln!(
"取证:ULL_SCREENSHOT_REAL_HOST=1 让 --screenshot 接真 host(默认 mock;\
出图将不再可复现,只用于验收真值屏)"
);
ExitCode::from(2) ExitCode::from(2)
} }
} }
-3
View File
@@ -38,9 +38,6 @@ pub mod widgets;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
#[path = "account/tests_msa.rs"]
mod tests_msa;
#[cfg(test)]
#[path = "account/tests_text.rs"] #[path = "account/tests_text.rs"]
mod tests_text; mod tests_text;
+55 -88
View File
@@ -2,15 +2,9 @@
//! //!
//! ## 为什么不是「一个转圈」 //! ## 为什么不是「一个转圈」
//! //!
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, //! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。 //! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! //! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
//! ## 文案一律**借用模型里的串**(M178)
//!
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
use iced::widget::{column, row}; use iced::widget::{column, row};
use iced::{Alignment, Element}; use iced::{Alignment, Element};
@@ -19,7 +13,7 @@ use unlucky_design::color::Role;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure}; use unlucky_presenter::dto::LoginFailure;
use crate::app::components::buttons::{self, Button}; use crate::app::components::buttons::{self, Button};
use crate::pages::frame; use crate::pages::frame;
@@ -42,40 +36,34 @@ pub fn panel<'a>(
DevicePanel::Code { DevicePanel::Code {
code, code,
link, link,
countdown, remaining,
expired, expired,
note, } => device_code(ui, code, link, *remaining, *expired, catalog),
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
DevicePanel::Browser { DevicePanel::Browser {
link, link,
countdown, remaining,
expired, expired,
} => browser(ui, link, countdown, *expired, catalog), } => browser(ui, link, *remaining, *expired, catalog),
DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog), DevicePanel::Failed { failure } => failed(ui, *failure, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog), DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
} }
} }
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方 /// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
/// 没登记时在预检处直接回退设备码)。
///
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![ let body = column![row![
buttons::filled( buttons::filled(
ui, ui,
Button::new( Button::new(
catalog.text(Key::AccountDeviceSignIn), catalog.text(Key::AccountDeviceBrowser),
Account::StartDevice.into_page(), Account::StartBrowser.into_page(),
) )
), ),
buttons::outlined( buttons::outlined(
ui, ui,
Button::new( Button::new(
catalog.text(Key::AccountDeviceBrowser), catalog.text(Key::AccountDeviceUseCode),
Account::StartBrowser.into_page(), Account::StartDevice.into_page(),
) )
), ),
buttons::text( buttons::text(
@@ -88,14 +76,7 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
] ]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.align_y(Alignment::Center),] .align_y(Alignment::Center),]
.spacing(ui.space(Space::X3)) .spacing(ui.space(Space::X3));
.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceFallbackHint),
ui.color(Role::OnSurfaceVariant),
));
frame::card(ui, body) frame::card(ui, body)
} }
@@ -124,14 +105,13 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body) frame::card(ui, body)
} }
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。 /// 设备码等待态:**码 + 链接 + 倒计时**。
fn device_code<'a>( fn device_code<'a>(
ui: &'a Ui, ui: &'a Ui,
code: &'a str, code: &'a str,
link: &'a str, link: &'a str,
countdown: &'a str, remaining: Option<u64>,
expired: bool, expired: bool,
note: Option<BrowserLoginNote>,
catalog: Catalog, catalog: Catalog,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let mut actions = row![ let mut actions = row![
@@ -170,7 +150,9 @@ fn device_code<'a>(
), ),
)); ));
let mut body = column![ let countdown = countdown_line(ui, remaining, expired, catalog);
let body = column![
frame::text_role( frame::text_role(
ui, ui,
TypeRole::LabelMedium, TypeRole::LabelMedium,
@@ -192,7 +174,7 @@ fn device_code<'a>(
link, link,
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
countdown_line(ui, countdown, expired), countdown,
frame::text_role( frame::text_role(
ui, ui,
TypeRole::BodySmall, TypeRole::BodySmall,
@@ -200,21 +182,17 @@ fn device_code<'a>(
catalog.text(Key::AccountDeviceHint), catalog.text(Key::AccountDeviceHint),
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
actions,
] ]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3));
// 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」, frame::card(ui, body)
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
if let Some(text) = note_text(ui, note, catalog) {
body = body.push(text);
}
frame::card(ui, body.push(actions))
} }
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。 /// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
fn browser<'a>( fn browser<'a>(
ui: &'a Ui, ui: &'a Ui,
link: &'a str, link: &'a str,
countdown: &'a str, remaining: Option<u64>,
expired: bool, expired: bool,
catalog: Catalog, catalog: Catalog,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
@@ -259,19 +237,37 @@ fn browser<'a>(
link, link,
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
), ),
countdown_line(ui, countdown, expired), countdown_line(ui, remaining, expired, catalog),
actions, actions,
] ]
.spacing(ui.space(Space::X3)); .spacing(ui.space(Space::X3));
frame::card(ui, body) frame::card(ui, body)
} }
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。 /// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> { fn countdown_line<'a>(
ui: &'a Ui,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
if expired { if expired {
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error)); return frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountDeviceExpired),
ui.color(Role::Error),
);
} }
frame::text_role( let text = remaining.map_or_else(String::new, |secs| {
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
});
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
frame::text_role_owned(
ui, ui,
TypeRole::LabelMedium, TypeRole::LabelMedium,
false, false,
@@ -280,45 +276,16 @@ fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, P
) )
} }
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
catalog.text(super::model::note_key(note))
}
/// 回退说明那一行(`None` = 没有这回事)。
fn note_text<'a>(
ui: &'a Ui,
note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Option<Element<'a, PageMessage>> {
let note = note?;
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
Some(frame::text_role(
ui,
TypeRole::BodySmall,
false,
browser_note_text(note, catalog),
ui.color(Role::OnSurfaceVariant),
))
}
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
/// ///
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; /// 三点修订叠在这里:
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调; /// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」; /// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。 /// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
fn failed<'a>( /// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
ui: &'a Ui, /// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。
failure: LoginFailure, fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
note: Option<BrowserLoginNote>, let body = catalog.text(failure_body_key(failure)).to_string();
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut body = catalog.text(failure_body_key(failure)).to_string();
if let Some(note) = note {
body.push_str("\n\n");
body.push_str(browser_note_text(note, catalog));
}
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new(); let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
if !matches!(failure, LoginFailure::MsaNotConfigured) { if !matches!(failure, LoginFailure::MsaNotConfigured) {
+2 -17
View File
@@ -7,9 +7,7 @@
use iced::widget::image; use iced::widget::image;
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
};
use unlucky_presenter::state::AppState; use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache}; use super::avatar::{Avatar, Cache};
@@ -166,18 +164,6 @@ impl Form {
} }
} }
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
///
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
#[must_use]
pub const fn note_key(note: BrowserLoginNote) -> Key {
match note {
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
}
}
/// 列表行 / 当前账户卡片用的数据。 /// 列表行 / 当前账户卡片用的数据。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Row { pub struct Row {
@@ -267,8 +253,7 @@ impl Page {
.map(|account| row_of(account, avatars, lang)) .map(|account| row_of(account, avatars, lang))
.collect(); .collect();
let active = rows.iter().position(|row| row.active); let active = rows.iter().position(|row| row.active);
let remaining = state.device_code_remaining(); let device = device_panel(&state.device_login, state.device_code_remaining());
let device = device_panel(&state.device_login, remaining, lang.catalog());
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、 // 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。 // 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
let panel = if matches!(device, DevicePanel::Hidden) { let panel = if matches!(device, DevicePanel::Hidden) {
+9 -40
View File
@@ -10,10 +10,7 @@
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 //! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。 //! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
use unlucky_presenter::dto::{
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。 /// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -28,23 +25,17 @@ pub enum DevicePanel {
code: String, code: String,
/// 验证链接 /// 验证链接
link: String, link: String,
/// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」) /// 剩余秒数
/// remaining: Option<u64>,
/// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径 /// 是否已过期
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
countdown: String,
/// 是否已过期(决定颜色与「重试」按钮)
expired: bool, expired: bool,
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
note: Option<BrowserLoginNote>,
}, },
/// 授权码流:等浏览器回调 /// 授权码流:等浏览器回调
Browser { Browser {
/// 授权链接(可复制) /// 授权链接(可复制)
link: String, link: String,
/// 倒计时那行已渲染好的文案(同上) /// 剩余秒数
countdown: String, remaining: Option<u64>,
/// 是否已过期 /// 是否已过期
expired: bool, expired: bool,
}, },
@@ -52,8 +43,6 @@ pub enum DevicePanel {
Failed { Failed {
/// 原因码(页面据此选 i18n) /// 原因码(页面据此选 i18n)
failure: LoginFailure, failure: LoginFailure,
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
note: Option<BrowserLoginNote>,
}, },
/// 成功 /// 成功
Succeeded { Succeeded {
@@ -87,13 +76,8 @@ pub enum AuthlibPanel {
} }
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。 /// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
pub(super) fn device_panel( pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel {
view: &DeviceLoginView,
remaining: Option<u64>,
catalog: Catalog,
) -> DevicePanel {
let expired = remaining == Some(0); let expired = remaining == Some(0);
let countdown = countdown_text(remaining, expired, catalog);
match view.phase { match view.phase {
DeviceLoginPhase::Idle => DevicePanel::Hidden, DeviceLoginPhase::Idle => DevicePanel::Hidden,
DeviceLoginPhase::Requesting => DevicePanel::Requesting, DeviceLoginPhase::Requesting => DevicePanel::Requesting,
@@ -101,22 +85,20 @@ pub(super) fn device_panel(
if view.user_code.trim().is_empty() { if view.user_code.trim().is_empty() {
DevicePanel::Browser { DevicePanel::Browser {
link: view.verification_uri.clone(), link: view.verification_uri.clone(),
countdown, remaining,
expired, expired,
} }
} else { } else {
DevicePanel::Code { DevicePanel::Code {
code: view.user_code.clone(), code: view.user_code.clone(),
link: view.qr_link.clone(), link: view.qr_link.clone(),
countdown, remaining,
expired, expired,
note: view.browser_note,
} }
} }
} }
DeviceLoginPhase::Failed => DevicePanel::Failed { DeviceLoginPhase::Failed => DevicePanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other), failure: view.failure.unwrap_or(LoginFailure::Other),
note: view.browser_note,
}, },
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(), name: view.account_name.clone(),
@@ -124,19 +106,6 @@ pub(super) fn device_panel(
} }
} }
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
///
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
if expired {
return catalog.text(Key::AccountDeviceExpired).to_string();
}
remaining.map_or_else(String::new, |secs| {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
})
}
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。 /// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) { pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
match view.phase { match view.phase {
+5 -8
View File
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
} }
} }
pub(super) fn build(state: &AppState, form: &Form) -> Page { fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。 // 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。 // 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0) Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
@@ -212,15 +212,12 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
match &page.device { match &page.device {
DevicePanel::Code { DevicePanel::Code {
code, code,
countdown, remaining,
expired, expired,
.. ..
} => { } => {
assert_eq!(code, "ABCD-EFGH"); assert_eq!(code, "ABCD-EFGH");
assert!( assert_eq!(*remaining, Some(900));
countdown.contains("15:00"),
"倒计时要画成 mm:ss:{countdown}"
);
assert!(!expired); assert!(!expired);
} }
other => panic!("等待态应该是设备码面板,实际 {other:?}"), other => panic!("等待态应该是设备码面板,实际 {other:?}"),
@@ -235,10 +232,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
let page = build(&state, &Form::new()); let page = build(&state, &Form::new());
match &page.device { match &page.device {
DevicePanel::Code { DevicePanel::Code {
countdown, expired, .. remaining, expired, ..
} => { } => {
assert_eq!(*remaining, Some(0));
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)"); assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
} }
other => panic!("过期态仍是设备码面板,实际 {other:?}"), other => panic!("过期态仍是设备码面板,实际 {other:?}"),
} }
@@ -1,126 +0,0 @@
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
//!
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::login::browser_note_text;
use super::model::{DevicePanel, Form, Page};
use super::tests::build;
use super::tests_text::{has_cjk, on_screen};
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
fn build_zh(state: &AppState, form: &Form) -> Page {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
}
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
///
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
#[test]
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
application."
.to_string(),
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
}
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
)));
let page = build(&state, &Form::new());
// 1. 面板态带着说明码(不是只剩一个用户码)
match &page.device {
DevicePanel::Code { note, code, .. } => {
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
assert_eq!(code, "ABCD-EFGH");
}
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
}
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
assert!(
text.contains("http://localhost/auth"),
"提示里要给出可照抄的重定向 URI:{text}"
);
assert!(
text.to_lowercase().contains("redirect uri"),
"提示里要点名「重定向 URI」这一项:{text}"
);
assert!(
!text.contains("invalid_request"),
"原始报文不许上屏:{text}"
);
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
let zh = build_zh(&state, &Form::new());
let zh_text = on_screen(&zh);
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
let note_text = browser_note_text(
BrowserLoginNote::RedirectNotRegistered,
Lang::ZhCn.catalog(),
);
assert!(
note_text.contains("http://localhost/auth"),
"中文文案也要给出可照抄的重定向 URI:{note_text}"
);
assert!(note_text.contains("重定向 URI"), "{note_text}");
assert!(note_text.contains("设备码"), "{note_text}");
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
assert!(
!zh_text.contains(forbidden),
"中文界面混进了原始报文:{zh_text}"
);
assert!(
!note_text.contains(forbidden),
"词条混进了原始报文:{note_text}"
);
}
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
assert_eq!(
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
"Could not confirm that browser sign-in is usable, so it switched to a device code."
);
}
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
#[test]
fn a_failed_device_login_keeps_the_fallback_note() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed_as(
LoginFailure::Unreachable,
"连不上 login.microsoftonline.com",
)
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Failed { failure, note } => {
assert_eq!(*failure, LoginFailure::Unreachable);
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
}
other => panic!("应是失败面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
}
+10 -20
View File
@@ -16,7 +16,7 @@
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::AccountNoticeCode; use unlucky_presenter::dto::AccountNoticeCode;
use super::login::{browser_note_text, failure_body_key, failure_title_key}; use super::login::{failure_body_key, failure_title_key};
use super::model::{AuthlibPanel, DevicePanel, Hint, Page}; use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。 /// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
@@ -60,33 +60,23 @@ pub(super) fn on_screen(page: &Page) -> String {
DevicePanel::Code { DevicePanel::Code {
code, code,
link, link,
countdown, remaining,
note, expired,
..
} => { } => {
out.push_str(code); out.push_str(code);
out.push_str(link); out.push_str(link);
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。 if *expired {
out.push_str(countdown); out.push_str(catalog.text(Key::AccountDeviceExpired));
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`), } else if let Some(secs) = remaining {
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。 let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
if let Some(note) = note { out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
out.push_str(browser_note_text(*note, catalog));
} }
} }
DevicePanel::Browser { DevicePanel::Browser { link, .. } => out.push_str(link),
link, countdown, .. DevicePanel::Failed { failure } => {
} => {
out.push_str(link);
out.push_str(countdown);
}
DevicePanel::Failed { failure, note } => {
out.push_str(catalog.text(failure_title_key(*failure))); out.push_str(catalog.text(failure_title_key(*failure)));
out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
}
} }
DevicePanel::Succeeded { name } => { DevicePanel::Succeeded { name } => {
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)])); out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
Loaded 100 of 658 files, more files were not shown because too many files have changed in this diff. Show more