1 Commits
Author SHA1 Message Date
AskaEth a5d1dec495 release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +08:00
916 changed files with 113112 additions and 8430 deletions

No files matched your search

+46
View File
@@ -166,6 +166,10 @@ jobs:
- name: cargo test --workspace --all-targets(windows,失败升注解) - name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
env:
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
ULL_XDEV_TEST_WAIVE: "1"
run: | run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet, # pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。 # 不会把它改掉,所以它是 cargo 自己的码。
@@ -203,6 +207,18 @@ jobs:
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档 # 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。 # (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
- run: cargo build --workspace --release - run: cargo build --workspace --release
# M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本——
# 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫
# 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。
- name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3)
if: runner.os == 'Windows'
shell: bash
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py \
--gui target/release/unlucky-launcher-iced.exe \
--cli target/release/ull.exe
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。 # 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里 # 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
@@ -288,3 +304,33 @@ jobs:
name: runtime-smoke-screenshot-macos name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/ path: ${{ runner.temp }}/smoke/
if-no-files-found: warn if-no-files-found: warn
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
#
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
# (r1 复审 P2-4)。
#
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
#
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
update-e2e:
name: update-e2e (windows x64)
runs-on: windows-latest
timeout-minutes: 45
env:
# 同 test 作业:跨卷 e2e 守卫在没有第二块卷的 runner 上需要显式豁免,否则会判红。
ULL_XDEV_TEST_WAIVE: "1"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: update-e2e
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
+8
View File
@@ -49,6 +49,14 @@ jobs:
name: 架构与合规守门(同 main 口径) name: 架构与合规守门(同 main 口径)
uses: ./.github/workflows/guards.yml uses: ./.github/workflows/guards.yml
# GPU 证据门禁(M253 r1 P2-1):与 main **同一份** workflow,理由同上 —— 判据只能有一份。
# dev 上 `evidence` 照样显式跳过(无 GPU、无产物,见该文件的 probe job),所以实际拿到的是
# `selftest` 那一半:19 条向量 + 三个退出码 0/1/2 + 接线自检,成本就是一次 python 自测。
# 不另加「只跑自测」的开关:那会给这道门禁多开一个「可以合法关掉真判据」的旋钮。
gpu-evidence:
name: GPU 证据门禁(同 main 口径;无 GPU 只跑自测)
uses: ./.github/workflows/gpu-evidence.yml
fmt: fmt:
name: fmt (rustfmt --check) name: fmt (rustfmt --check)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+233
View File
@@ -0,0 +1,233 @@
name: GPU evidence
# GPU 截图证据门禁(M253:把 M249 的 `packaging/check_gpu_shot.py` 接进 CI)。
#
# **为什么新开一个文件,不并进 guards.yml**:guards.yml 已是 399 行、红线 400(`file-size` 那道门
# 就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。这与
# `release-guard.yml` 当初单独成文的理由逐字相同(那份文件头:「那份文件已经 398 行(400 行红线),
# 再挂一个 job 必越线」)。
#
# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。
# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验
# (33 条「改绿」变异逐条重放、10 类封闭清单),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、
# 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」):
# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物;
# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。
# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。
# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示
# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么,
# 并打 `::warning::` 说明「本次没有判过任何真实产物」。
#
# 载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。
# 所以改这道门禁时请连自检一起想:自检只认「上面那张封闭清单」,不宣称「不可能被改成总是绿」。
#
# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。
# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
shot_log:
description: 'stderr 日志路径(需带 ULL_GPU_LOG=info,例 DevDocs/artifacts/gpu-probe/a1-gpu.stderr.log);留空则按下面的约定路径判'
required: false
default: ''
shot_png:
description: '同一运行的 PNG 路径;负例(expect_refuse=true)可留空'
required: false
default: ''
expect_refuse:
description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)'
type: boolean
default: false
# 日常线 `dev` 复用**同一份**判据与接线(r1 P2-1):`dev-guards.yml` 里
# `uses: ./.github/workflows/gpu-evidence.yml`。理由与 guards.yml 的 `workflow_call:` 逐字相同:
# 判据只能有一份,两份各自演进迟早出现「dev 绿、main 红」的双真相;而 `dev-guards.yml` 文件头
# 自己写着「空跑的门禁等于没有」。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的
# 是 `selftest` 那一半:19 条向量 + 三个退出码 + 接线自检,成本就是一次 python 自测。
workflow_call:
jobs:
selftest:
name: GPU 判据自测(不需 GPU;跳过 ≠ 已验证)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)
id: vectors
run: python3 packaging/check_gpu_shot.py --self-test
# 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍:
# 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。
- name: 退出码语义(0 过 / 1 判据不过 / 2 用法错)
id: exitcodes
run: |
set -uo pipefail
work="$RUNNER_TEMP/gpu-shot-exitcodes"
mkdir -p "$work"
# 用自测脚手架现造一份能过的一对(同一份 synth_log/synth_png,避免第二套手工拼装)
python3 - "$work" <<'PY'
import sys
from pathlib import Path
sys.path[:0] = ["packaging", "packaging/tests"]
from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png
root = Path(sys.argv[1])
(root / "pass.stderr.log").write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8")
(root / "pass.png").write_bytes(synth_png("content", SELFTEST_SIZE))
(root / "red.stderr.log").write_text(synth_log(renderer="tiny-skia"), encoding="utf-8")
PY
expect() { # expect <期望退出码> <说明> <命令...>
want="$1"; note="$2"; shift 2
set +e
"$@" > "$work/out.txt" 2>&1
got=$?
set -e
cat "$work/out.txt"
if [ "$got" -ne "$want" ]; then
echo "::error::退出码语义不对:$note 期望 $want,实际 $got。判据脚本的 0/1/2 是整条门禁的接口,变了就是门禁变了。"
exit 1
fi
echo "OK: $note → exit=$got"
}
expect 0 "正例(合成日志 + 合成 PNG,六条判据全过)" \
python3 packaging/check_gpu_shot.py "$work/pass.stderr.log" "$work/pass.png"
expect 1 "CPU 档日志(renderer=tiny-skia,判据点名判红)" \
python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png"
expect 2 "用法错(一个位置参数都没给)" \
python3 packaging/check_gpu_shot.py
- name: 接线自检(33 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring)
id: wiring
run: python3 packaging/tests/gpu_shot_ci_selftest.py
probe:
name: GPU 证据 — 探测可用产物与硬件适配器
runs-on: ubuntu-latest
outputs:
available: ${{ steps.detect.outputs.available }}
log: ${{ steps.detect.outputs.log }}
png: ${{ steps.detect.outputs.png }}
reason: ${{ steps.detect.outputs.reason }}
steps:
- uses: actions/checkout@v4
- name: 探测样例产物与硬件适配器(只探测、不判定)
id: detect
env:
SHOT_LOG: ${{ github.event.inputs.shot_log }}
SHOT_PNG: ${{ github.event.inputs.shot_png }}
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
EVENT: ${{ github.event_name }}
run: |
set -uo pipefail
default_dir=DevDocs/artifacts/gpu-probe
default_stem=gpu-shot
log_path=""
png_path=""
available=false
reason=""
if [ -n "${SHOT_LOG:-}" ]; then
# ① dispatch 显式点了名:**给了却找不到 = 红**。若「明确说要判、东西不在」能退化成跳过,
# 那又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip。
if [ ! -f "${SHOT_LOG}" ]; then
echo "::error::dispatch 指定了 shot_log=${SHOT_LOG},但工作区里没这个文件 —— 显式要求判定的输入缺失按红处理(不跳过)"
exit 1
fi
if [ -n "${SHOT_PNG:-}" ] && [ ! -f "${SHOT_PNG}" ]; then
echo "::error::dispatch 指定了 shot_png=${SHOT_PNG},但工作区里没这个文件 —— 同上,按红处理"
exit 1
fi
if [ "${EXPECT_REFUSE:-false}" != "true" ] && [ -z "${SHOT_PNG:-}" ]; then
echo "::error::正例(expect_refuse=false)必须给 shot_png;只有负例可以省略 PNG"
exit 1
fi
log_path="${SHOT_LOG}"
png_path="${SHOT_PNG:-}"
available=true
reason="dispatch 显式指定产物(${log_path})"
elif [ -f "${default_dir}/${default_stem}.stderr.log" ] && [ -f "${default_dir}/${default_stem}.png" ]; then
# ② 约定路径:一次取证 = 一份干净的 stderr + 同一次运行的那张图
# (`--diff`/拼接会骗过判据,见 gpu-evidence.md §5 第 1/7 条)。
log_path="${default_dir}/${default_stem}.stderr.log"
png_path="${default_dir}/${default_stem}.png"
available=true
reason="工作区里有约定产物(${default_dir}/${default_stem}.*)"
else
# ③ 什么都没有 —— 这是 GitHub 托管 runner 的常态:托管 runner 没有 GPU,
# 而样例产物也不在仓库里(`DevDocs/` 被 .gitignore 忽略,图不进仓库)。
# 必须显式跳过 + 写明原因,不许静默 pass。
if compgen -G '/dev/dri/renderD*' >/dev/null; then
adapter='本机有硬件适配器(/dev/dri/renderD*)'
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
adapter='本机有硬件适配器(nvidia-smi -L 有输出)'
else
adapter='无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi)'
fi
reason="${adapter},且工作区里没有可用的样例产物(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=${EVENT})"
fi
{
echo "available=${available}"
echo "log=${log_path}"
echo "png=${png_path}"
echo "reason=${reason}"
} >> "$GITHUB_OUTPUT"
if [ "${available}" = "true" ]; then
echo "GPU 证据:${reason} —— 交给 evidence job 跑真实产物判据(0 过 / 1 判据不过)"
else
echo "GPU 证据:${reason},跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)"
# warning 而不是 notice:这一趟运行**看起来是绿的**,必须在 UI 上留一条黄条说清它没判过。
echo "::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」"
fi
{
echo "## GPU 证据(M253)"
echo
if [ "${available}" = "true" ]; then
echo "- 结论:**跑真实产物判据**(${reason})"
echo "- 产物:${log_path}"
if [ -n "${png_path}" ]; then echo "- PNG:${png_path}"; else echo "- PNG:无(负例口径 ${EXPECT_REFUSE:-false})"; fi
echo "- 判定看 \`evidence\` job 的退出码(0 过 / 1 判据不过 / 2 用法错);这里是「有东西可判」,不是「判过了」。"
else
echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}"
echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。"
echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。"
echo "- 想让它变成真判定,只有两条**在这个 CI 运行里真能执行**的路:"
echo " ① \`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`,且两个文件必须**已被 checkout 下来**"
echo " —— checkout 只拿已跟踪文件,所以产物得先提交进被 dispatch 的那个 ref;"
echo " ② 把一对产物提交到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`"
echo " 再对那个 ref 跑一次(\`${default_dir}\` 在 .gitignore 里,要用 \`git add -f\`;"
echo " 或改放到任一已跟踪目录,用 ① 的输入指过去)。"
echo " 就地往 workspace 里拷文件(不提交)在 CI 上做不到:那次运行 checkout 的树里没有 DevDocs/。"
fi
} >> "$GITHUB_STEP_SUMMARY"
evidence:
name: GPU 证据 — 真实产物判据
needs: [selftest, probe]
# 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行,UI 上是灰色 skipped,
# 不是一个绿色 ✓。这正是「跳过」与「通过」分开写的地方(r1 复审实核过这条 if 的语义)。
if: needs.probe.outputs.available == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)
id: judge
env:
LOG: ${{ needs.probe.outputs.log }}
PNG: ${{ needs.probe.outputs.png }}
EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }}
run: |
set -uo pipefail
echo "GPU 证据:判 ${LOG}"
if [ "${EXPECT_REFUSE:-false}" = "true" ]; then
if [ -n "${PNG}" ]; then
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse "$PNG"
else
python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse
fi
else
python3 packaging/check_gpu_shot.py "$LOG" "$PNG"
fi
+29 -17
View File
@@ -1,12 +1,16 @@
name: Guards name: Guards
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): # 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 # 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层 # 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身 # 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) # 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 # 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91) # 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处)
# #
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件, # M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转: # **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
@@ -54,7 +58,11 @@ jobs:
run: | run: |
set -uo pipefail set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
tree=$(cargo tree -p unlucky-core --edges normal --prefix none) # 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
exit 1
fi
echo "$tree" echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -109,7 +117,9 @@ jobs:
exit 1 exit 1
fi fi
echo "$tree" echo "$tree"
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden") # 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
if [ -n "$hits" ]; then if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:" echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /' printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -169,22 +179,22 @@ jobs:
fi fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的 # D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。 # M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
# 判据必须是**集合相等**而不是「黑名单命中」: # 判据是**集合相等**而不是「黑名单命中」:
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); # - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope: unsafe-scope:
name: unsafe 落点守门(集合必须恰好一个文件) name: unsafe 落点守门(集合必须恰好等于期望)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点 - name: 全仓显式放行 unsafe 的文件集合 == 期望集合
run: | run: |
set -uo pipefail set -uo pipefail
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。 # 扫描口径(M91 r3):一个小词法器,而不是正则。
# #
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
@@ -318,25 +328,27 @@ jobs:
} }
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开, # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。 # 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort) done | LC_ALL=C sort || true)
echo "期望(恰好这一个):$EXPECTED" echo "期望(恰好这两处,集合相等):$EXPECTED"
echo "实际扫到的集合:" echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true) extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(D36 只允许一个):" echo "::error::多出来的 unsafe 落点(不在期望集合里):"
printf '%s\n' "$extra" | sed 's/^/ - /' printf '%s\n' "$extra" | sed 's/^/ - /'
fi fi
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。" echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
fi fi
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。" echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
exit 1 exit 1
fi fi
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED" echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
file-size: file-size:
name: 文件行数 ≤400 守门 name: 文件行数 ≤400 守门
+213 -60
View File
@@ -1,9 +1,14 @@
name: Release / build(可复用) name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。 # Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# 本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,唯一的改动是四个矩阵键换成同名入参: # M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、 # `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。其余 `name` / `if` / `env` / `run` / `uses` 与拆分前逐行相同。 # `matrix.rustflags` → `inputs.rustflags`。
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`), # 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。 # 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE # `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
@@ -67,64 +72,56 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static` # (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要 # M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。 # 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e—— # 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出), # `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测 # 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就 # M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。 # 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
- name: Windows ARM64 工具链体检(红不挡发布,只取证) # 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
if: inputs.slot == 'windows-arm64' if: inputs.slot == 'windows-arm64'
shell: bash shell: bash
run: | run: |
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关 set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; } say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio") shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs; vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。 [ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib" # ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib") for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath="" Microsoft.VisualStudio.Component.Windows11SDK.26100 \
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true) Microsoft.VisualStudio.Component.Windows11SDK.22621; do
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib") hit=""
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处 [ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。 if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true) else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
scan
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
v=""
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done done
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})" # ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
v="" for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \ hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4) say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}" done
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}" for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi [ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的 for ver in "$root"/*/; do
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。 libs=( "$ver"um/arm64/*.lib )
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100) say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348 done
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id" done
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then # ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})" for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \ hit=""
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)" [ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
scan say notice "link.exe 候选 $p → ${hit:-未命中}"
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi done
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
exit 1
fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。 # M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。 # 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
@@ -132,14 +129,107 @@ jobs:
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。 # 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」, # 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。 # 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
- name: cargo build --release --locked - name: cargo build --release --locked
shell: bash shell: bash
env: env:
RUSTFLAGS: ${{ inputs.rustflags }} RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }} ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: | run: |
set -euo pipefail set -euo pipefail
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
if [ "$SLOT" = "windows-arm64" ]; then
rustup target add aarch64-pc-windows-msvc
rustup target list --installed
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
if [ -x "$vswhere" ]; then
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
mkdir -p "$tmp"
vcvars=""
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
# 只赌一种就等于再写一个「静默退化成 warning」。
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
[ -n "$vcvars" ] && break
done
dump=""
if [ -n "$vcvars" ]; then
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
{ printf '@echo off\r\n'
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
fi
for key in LIB INCLUDE; do
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
if [ -n "$value" ]; then
export "$key=$value"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
echo "env $key=${value:0:90}…(${#value} 字符)"
else
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
fi
done
found=""
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
[ -n "$found" ] && break
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
[ -n "$hit" ] && found=${hit//\\//}
done
if [ -n "$found" ]; then
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
echo "linker: $found"
else
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
fi
else
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
fi
fi
cargo build --release --locked --bin unlucky-launcher-iced --bin ull cargo build --release --locked --bin unlucky-launcher-iced --bin ull
# 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们
# 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。
if [ "$SLOT" = "windows-arm64" ]; then
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
fi
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
if: runner.os == 'Windows'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
echo "expect-version=$version"
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -192,6 +282,56 @@ jobs:
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1 echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi fi
- name: PE 子系统门禁(GUI=WINDOWS_GUI/2,CLI=CONSOLE/3;判据与理由见 packaging/README.md §15)
if: runner.os == 'Windows'
shell: bash
run: |
PY=$(command -v python3 || command -v python)
"$PY" packaging/check_pe_subsystem.py --gui "${{ inputs.build_dir }}/unlucky-launcher-iced.exe" --cli "${{ inputs.build_dir }}/ull.exe"
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明,
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
if: inputs.slot == 'linux-x64'
shell: bash
env:
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb xauth \
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
out="$RUNNER_TEMP/linux-smoke"
mkdir -p "$out/data"
log="$out/launcher.log"
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
set +e
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "launcher exit=$code"
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
ls -l "$out"
exit "$code"
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
if: always() && inputs.slot == 'linux-x64'
uses: actions/upload-artifact@v4
with:
name: linux-x64-first-frame
path: ${{ runner.temp }}/linux-smoke/
if-no-files-found: warn
- name: 打包(产物 + SHA256SUMS) - name: 打包(产物 + SHA256SUMS)
shell: bash shell: bash
env: env:
@@ -239,9 +379,22 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」—— # (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。 # 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
- name: arm64 失败收尾注解(红不挡发布) - name: arm64 失败收尾注解(红不挡发布)
if: failure() && inputs.slot == 'windows-arm64' if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
shell: bash shell: bash
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release" env:
SLOT: ${{ inputs.slot }}
run: |
set -euo pipefail
if [ "$SLOT" = "linux-arm64" ]; then
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
else
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
fi
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
+94
View File
@@ -0,0 +1,94 @@
name: Release guard
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
#
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
#
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
#
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 /
# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
#
# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
# 该红的红、该绿的绿。
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
release-semantics:
name: 发布标记语义守门(push tag → 稳定版)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 标记表自检(push tag ⇒ 稳定版)
run: python3 packaging/release_marker.py --self-test
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
run: python3 packaging/check_release_semantics.py
- name: 变异自测(守门抓不抓得住回归)
run: python3 packaging/check_release_semantics.py --self-mutation
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
run: python3 packaging/check_publish_slots.py --self-test
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
run: python3 packaging/check_publish_slots.py
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
run: python3 packaging/check_test_source_off.py --self-test
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
run: python3 packaging/check_test_source_off.py
# M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出
# 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本
# 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。
- name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析)
run: python3 packaging/sign_manifest.py --self-test
- name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红)
run: python3 packaging/check_pe_version.py --self-test
# M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01
# (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫
# 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow,
# 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。
# PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。
- name: workflow YAML 自检(注入 0x01 / 坏结构必须红)
run: python3 packaging/check_workflows_yaml.py --self-test
- name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符)
run: python3 packaging/check_workflows_yaml.py
# M190 r3:`update-test-key` 是**信任锚的开关**(把公开的测试公钥并进信任表)。它只许由
# `crates/presenter` 的 `[dev-dependencies]` 打开;进了发布构建就等于「谁都能签出官方更新」。
# 判据:core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内、
# 其余(workflow / packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红。
- name: 测试公钥守门自检(注入 workflow / dependencies / 根 Cargo.toml 三处变异)
run: python3 packaging/check_test_key_off.py --self-test
- name: 测试公钥不进发布构建(只许 presenter 的 dev-dependencies)
run: python3 packaging/check_test_key_off.py
+114 -44
View File
@@ -4,12 +4,17 @@ name: Release / publish(可复用)
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、 # job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、 # `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。 # tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为): # 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。 # ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。 # 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个 # ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与 # 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是 # `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是 # **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。 # 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。 # 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
@@ -22,6 +27,14 @@ on:
PUBLISH_TOKEN: PUBLISH_TOKEN:
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)" description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
required: true required: true
# (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方
# `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串——
# 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。
# `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed
# (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。
ULL_MANIFEST_SIGNING_KEY:
description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release"
required: false
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。 # 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
permissions: permissions:
@@ -35,11 +48,21 @@ jobs:
contents: write contents: write
env: env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)── # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着 # **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
# arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据), # 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
# **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步 # `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
# README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。 # 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
PUBLISH_SLOTS: "windows-x64" # **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧,
# 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。
# 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」,
# 不是静默丢弃(这条是任务里点名要验的反方向)。
# `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。
# **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS
# 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。
# 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml
# 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。
# macOS 两档仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64 linux-x64"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -48,6 +71,17 @@ jobs:
path: dist path: dist
merge-multiple: true merge-multiple: true
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
shell: bash
run: |
set -euo pipefail
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布) - name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash shell: bash
run: | run: |
@@ -64,36 +98,76 @@ jobs:
echo "publish: $base" echo "publish: $base"
;; ;;
*) *)
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)" echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f" rm -f "$f"
;; ;;
esac esac
done done
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS - name: 重算全量 SHA256SUMS
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
ls -l dist ls -l dist
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个 # 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。 # 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。 # 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
for slot in $PUBLISH_SLOTS; do
stem=${slot//-/_}
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
echo "::error::发布白名单缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
cd dist cd dist
rm -f SHA256SUMS.txt rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt cat SHA256SUMS.txt
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
shell: bash
env:
TAG: ${{ github.ref_name }}
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
run: |
set -euo pipefail
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
python3 packaging/make_manifest.py --self-test
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
# 后者在 Release 正文里已有,两处各写一份迟早漂。
python3 packaging/make_manifest.py \
--dist dist \
--version "$TAG" \
--channel stable \
--asset-base-url "$RELEASE_BASE/$TAG" \
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量,
# 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release:
# ① `--self-test`:RFC 8032 向量(签名脚本自己没坏)
# ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住
# (签出一份客户端不认的清单,比不发版更糟)
# ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig`
# 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。
- name: 签更新清单(Ed25519;签不上就阻断发布)
shell: bash
env:
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
set +x
PY=$(command -v python3 || command -v python)
if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi
"$PY" packaging/sign_manifest.py --self-test
if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then
echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。"
echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs"
exit 1
fi
"$PY" packaging/sign_manifest.py --check-pinned-keyring
"$PY" packaging/sign_manifest.py --sign dist/manifest.json
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
ls -l dist/manifest.json dist/manifest.json.sig
- name: 生成 Release 说明(支持矩阵模板) - name: 生成 Release 说明(支持矩阵模板)
shell: bash shell: bash
env: env:
@@ -119,29 +193,25 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md) base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push # ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。 # 三种触发各自创建什么(那张表是唯一真相源):
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。 # push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
create_mark=() # dispatch prerelease=true → create / edit 都带预发布标记
edit_mark=() # dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
case "${PRERELEASE:-}" in # 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
true) # 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
create_mark=(--prerelease) # 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
edit_mark=(--prerelease) # 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
;; # 进程替换里的失败会被 `set -e` 吞掉。
false) marks=/tmp/release-marks.sh
edit_mark=(--prerelease=false) python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
;; source "$marks"
"") echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 github.event.inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。 # Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md) # manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource`
# 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。 # 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber gh release upload "$TAG" "${assets[@]}" --clobber
+50 -21
View File
@@ -1,20 +1,32 @@
name: Release name: Release
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 + # 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
# SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。 # `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试 # `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
# 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都 # 不碰标记)。
# 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。 # **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M184 起是 `windows-x64` + `linux-x64`**。
# `linux-x64` 本轮翻牌的依据在 `packaging/docs/linux-evidence.md`:x86_64 槽位在 `ubuntu-22.04`(glibc 2.35,与本地
# WSLg 验证同一地板)真编出 release 产物、真开窗渲出一帧,`release-build.yml` 里还有一条同槽位的 xvfb 运行时门禁
# ——「声明为 ci 就必须真能跑出帧」,不是「矩阵里写了就算支持」。
# **`linux-arm64` 本轮只构建、不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟)。
# 解锁条件写在 `packaging/release-notes.md` 与 `packaging/docs/compliance-support-boundaries.md` §9 ——真机跑一帧之后
# 才把键加进 `PUBLISH_SLOTS`,不拿没跑过的包去发(M184 tower 裁决)。
# `windows-arm64` 仍留矩阵里取证、红了不挡 x64 发布(M142/M164:它那五轮红全红在一个误判的编译前体检步,该门禁已拆,
# 判定权交回编译器)。`macos-*` 两档从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走
# `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
# 名单 ⇄ 产物两个方向都有守卫(M164):`packaging/check_publish_slots.py`(PR 阶段在 release-guard.yml,发布侧在
# release-publish.yml 的 `--dist` 模式)——声明了缺产物 ⇒ 红;有产物没声明 ⇒ `::warning::` 明说未发布。
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与 # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
# #
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**, # M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改: # 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build / # - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。 # cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算 # - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。 # SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。 # `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on: on:
@@ -24,13 +36,17 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
all_slots: all_slots:
description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows" description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
type: boolean type: boolean
default: false default: false
prerelease: prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)" # 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上——
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
type: boolean type: boolean
default: true default: false
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -47,7 +63,7 @@ jobs:
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy` # M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。 # 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
plan: plan:
name: 选槽位(默认两个 Windows) name: 选槽位(默认四个:Windows 两档 + Linux 两档)
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.pick.outputs.matrix }} matrix: ${{ steps.pick.outputs.matrix }}
@@ -67,16 +83,25 @@ jobs:
SLOTS = [ SLOTS = [
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。 # Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"), dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费;GitHub 文档的 Standard runners 也列 # Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
# `windows-11-vs2026-arm`)。M140 前是在 windows-latest 上交叉,**CI 从未成功过一次**(v0.2.1 首跑红在 # **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
# 工具链体检,M141/M142 在此修)。原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。 # 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release", dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
rustflags="-C target-feature=+crt-static", mode="ci"), rustflags="-C target-feature=+crt-static", mode="ci"),
# ↓ 四档**未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编 # ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。
# (将来验证 Linux/macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS + # Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。
# 同步 README §1/§9 与 release-notes.md。Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移。 # 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里:
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="probe"), # `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="probe"), # `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机
# 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。
# **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"),
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"),
# ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
# (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
# 同步 README §1/§9 与 release-notes.md。
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。 # macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"), dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"),
dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"), dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
@@ -113,8 +138,9 @@ jobs:
release: release:
name: GitHub Release name: GitHub Release
needs: build needs: build
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) # `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
# 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 # 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。 # tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag' if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 # `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
@@ -124,3 +150,6 @@ jobs:
secrets: secrets:
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串,
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
+121
View File
@@ -0,0 +1,121 @@
name: Signing status(播报,不是门禁)
# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。**
# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的),
# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。
#
# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION`
# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
#
# 口径四条(由 `packaging/tests/signing_ready_wiring_selftest.py` 的接线判据机器校验,不是靠注释自律):
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。
# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 ——
# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow,
# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。
# ④ **生产签名种子只在 `refs/heads/main` 上注入**,且**没有 `pull_request` 触发**(M255 r1 P2-1)。
# 这条是本文件唯一的**安全**口径,别为了「PR 上也能看读数」把它放开:
# - 仓库内分支的 PR / 任意分支的 dispatch **拿得到**仓库 Secret(只有 fork / Dependabot 拿不到);
# - 而这把种子能给**全量已安装客户端**签出被信任的清单(自更新链 = 用户端 RCE),
# 既有设计把它锁在 tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`)。
# - 播报**不需要**在 PR 上跑:PR 改了 `check_signing_ready.py` 时,CI 会在那个 PR 自己的代码上执行它,
# 若同时注入种子,等于把种子交给任何能推分支的人。
# 门由两行 env 表达(同一个条件、同一个地方可审):钥给 main,非 main 则给 `ULL_SIGNING_KEY_WITHHELD`
# 一个原因串 —— 于是 ② 读成「未判定」而不是「没配」(把策略说成缺配是一次假读数)。
#
# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头;
# 人类要做的那几步:`packaging/docs/signing-runbook.md`。
on:
push:
branches: [main]
# ⚠ 刻意**没有** `pull_request`(见口径④)。要读 PR 的签名就绪度,就跑 push 到分支或对着 main dispatch。
workflow_dispatch:
permissions:
contents: read
jobs:
# 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。
# 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、
# 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。
selftest:
name: 判据自测(三行/退出码/变异重放/接线)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据本体(三行 / 0/1/2 / 两向退出码变异 / --key 用法错 / 指针章节)
run: python3 packaging/tests/signing_ready_selftest.py
# 接线单独一份(400 行红线):播报不是门禁、**生产种子只在 main 注入**、文档指针真的存在,
# 以及「去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红」三处变异重放。
- name: 接线(ref 门 / 不被别的 workflow 复用 / 无 error 级注解 / 文档路径存在性 + 三处变异)
run: python3 packaging/tests/signing_ready_wiring_selftest.py
- name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错)
run: |
set -uo pipefail
work="$RUNNER_TEMP/signing-ready"
mkdir -p "$work"
# 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」,
# 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。
run_and_show() { # run_and_show <说明> <命令...>
note="$1"; shift
echo "── $note"
python3 packaging/check_signing_ready.py "$@" 2>&1 || true
echo
}
run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)"
# 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。
python3 - "$work" <<'PY'
import sys
from pathlib import Path
trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8")
begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>")
end = trust.index("<<< PINNED-KEYS <<<")
fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n'
Path(sys.argv[1], "trust-with-fake-key.rs").write_text(
trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8"
)
PY
run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \
--trust "$work/trust-with-fake-key.rs"
run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true
# 播报:只把三行搬进 job summary,不看颜色。
status:
name: 签名就绪度(播报)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ② 要读「环境里有没有钥」,所以把 Secret 传进来 —— **只在 main ref 上**(口径④)。
# 门是下面这两行 env(同一个条件),不是 `if:`:这样非 main 的运行照样跑完三行读数,
# 只是 ② 读成「未判定」,而不是整个 job 被跳过后一片空白。
# ⚠ 这两行是安全边界,接线自检钉着它们(去掉 ref 门 / 加回 pull_request 都会当场红)。
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
env:
ULL_MANIFEST_SIGNING_KEY: ${{ github.ref == 'refs/heads/main' && secrets.ULL_MANIFEST_SIGNING_KEY || '' }}
ULL_SIGNING_KEY_WITHHELD: ${{ github.ref == 'refs/heads/main' && '' || '非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)' }}
run: |
set -uo pipefail
set +e
python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1
rc=$?
set -e
cat "$RUNNER_TEMP/signing-ready.txt"
case "$rc" in
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
1) echo "::warning::签名未就绪(或 ② 未判定):该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
esac
{
echo
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
echo "- **Secret 只在 \`refs/heads/main\` 上注入**(生产签名种子锁在 main / tag 后面);非 main ref 的 ② 显示「未判定」而不是「没配」。"
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
+5
View File
@@ -30,3 +30,8 @@ __pycache__/
# OS # OS
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# 签名私钥:绝不入库(M255 手册产出的本地副本)
.signing/
**/signing-secret*.json
**/ull-signing/
+58
View File
@@ -0,0 +1,58 @@
# 更新日志
> 自上而下由新到旧。版本号与 `Cargo.toml` 同源;二进制 `unlucky-launcher --version` 打同一个号。
> 只记**对外可感知的变化**(用户能看到、能用到、能感觉到稳的),不记内部重构流水账。
---
## 0.2.4 — 未发布(当前 `dev`)
这一批的主线是「把存档区做成真正能用」,以及把验证方式从「看起来对」改成「能被证伪」。
**存档**
- 存档列表可重命名(行内输入,回车即提交)、删除(二次确认)、备份导出为 zip
- **游戏运行时写操作被拦下并给出明确原因**——此前在 Linux 上删一个正在跑的世界会成功
- 截图区从「一行文件名」变成**缩略图墙**:真解码、最大边 160px、单次最多 60 张、单张超 8 MiB 跳过;「未生成预览」与「文件过大」是两种不同的格子,失败逐条可见
**外观**
- **强调色自定义真的生效**:填了色,按钮 / 进度条 / 图标跟随;中性底与描边不动(既定语义)
- 非法色值给红框 + 结构化原因,且**不写盘**;手打过程中不闪红,离开输入格或按回车才判
- 三处组件配色改走主题角色(描边、进度轨道、Elevated 底色),不再对着设计稿比色值
**交互**
- 单行输入框支持**回车提交**:存档重命名、强调色两处接上;未接的既有调用点行为不变
**发布与验证**
- GPU 截图证据门禁接进 CI:无 GPU 时**显式跳过**,「跳过」与「通过」分开呈现,不会伪装成已验证
- 新增**签名就绪度**播报与操作手册:一条命令看清「签名还差什么」;播报只播报,不是门禁
- 修掉一处会让 CI 把**生产签名种子**交给 PR 代码的接线(仓库内分支的 PR 拿得到仓库 Secret)
- 合并态全量测试在每次合并后的 tip 上重跑,不再只看定向验证
**已知问题**
- **自动更新链路是断的**:正式签名信任表为空,任何发布二进制都验不过(fail-closed)。通道本身已接到底,缺的是「一把被钉住的公钥 + 一份签名 Release」
- Ink 主题下 Elevated 卡片层级几乎看不出(对比约 1.03:1)
- Windows 路径在界面上仍显示正斜杠
- macOS 两档构建仍是 `probe`,从未真构建、也没在真机跑过
---
## 0.2.3 — 2026-11-08
- 首次进入**稳定更新通道**(`latest` 指向本版;此前的版本只在预览通道)
- 断点与续传、镜像选路、崩溃弹窗与现场日志导出等前序能力在本版收敛完成
> 注:`main` 采用孤儿快照发布,`v0.2.3..HEAD` 之类的区间日志不可用;本条目按发布记录与 README 口径编写。
---
## 0.2.1 — 2026-11-06
- 早期公开版本:安装、启动、日志与设置四项主流程可用
- 具体条目从简(发布记录以 tag 为准)
---
## 0.2.0 — 2026-11-05
- 首个公开预览版本
- 具体条目从简(发布记录以 tag 为准)
Generated
+170 -8
View File
@@ -368,6 +368,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -660,6 +666,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.11.0" version = "0.11.0"
@@ -846,6 +858,33 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.1"
@@ -892,6 +931,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -977,6 +1026,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.42" version = "0.8.42"
@@ -1094,6 +1167,12 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@@ -3087,6 +3166,16 @@ dependencies = [
"futures-io", "futures-io",
] ]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -3332,6 +3421,15 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -4125,6 +4223,15 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4188,6 +4295,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -4388,6 +4504,16 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "sse-stream" name = "sse-stream"
version = "0.2.6" version = "0.2.6"
@@ -4738,6 +4864,21 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.2+spec-1.1.0" version = "1.1.2+spec-1.1.0"
@@ -4768,6 +4909,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_writer"
version = "1.2.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -4982,7 +5129,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -4997,7 +5144,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
@@ -5007,23 +5154,26 @@ dependencies = [
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"unlucky-agent", "unlucky-agent",
"unlucky-core", "unlucky-core",
"winresource",
] ]
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"ed25519-dalek",
"flate2", "flate2",
"futures-util", "futures-util",
"md-5", "md-5",
@@ -5041,15 +5191,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.1" version = "0.2.4"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.1" version = "0.2.4"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5057,11 +5207,13 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.1" version = "0.2.4"
dependencies = [ dependencies = [
"ed25519-dalek",
"serde", "serde",
"serde_json", "serde_json",
"unlucky-core", "unlucky-core",
"unlucky-palette-extract",
] ]
[[package]] [[package]]
@@ -5968,6 +6120,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winresource"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+8 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.1" version = "0.2.4"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
@@ -71,6 +71,13 @@ md-5 = "0.10"
# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 # 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。
zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] }
# M190(core::update::ed25519):清单签名**验签**用官方实现,不再自写曲线算术。
# M190 r1 复审的裁定:信任锚不许用自写密码学(非规范编码/小阶点/批量验证差异的后果是
# 「验签通过但本不该通过」)。BSD-3-Clause,在 deny.toml 白名单内。
# 只开 `std`(不用 `rand_core`/`batch`/`fast` 这类可选面):本仓的签名侧在
# `packaging/sign_manifest.py`,二进制里只有「验」这一半。
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
# M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`, # M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`,
# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 # 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。
flate2 = "1" flate2 = "1"
+129 -30
View File
@@ -4,6 +4,8 @@
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and > **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases> > release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.4`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no (Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
@@ -43,6 +45,14 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and - **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID. killed as trees, not as one PID.
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
and every field falls back on its own: instance → global setting → factory default, so an instance
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
the backend row only appears for game versions that have it. The CLI and the GUI read the same
resolved values (`crates/core/src/game/launch_overrides.rs`).
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals - **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the (exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
@@ -56,8 +66,17 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`). import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
- **Import conflicts are decided per file** — before anything is written, the scan lists every
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
that do not conflict are never rewritten, and the report still names every conflict afterwards.
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version, - **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*. loader and sort filters, paged results, and one-click *install into this instance*.
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
the target instance (project type, game version, loader); project details and version lists follow,
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
time. Being offline is its own state — "not connected" — never "no matching content".
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the - **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
library (packs go through the import pipeline above); CurseForge's *install* is disabled because library (packs go through the import pipeline above); CurseForge's *install* is disabled because
its file endpoint is not wired; and an item whose game version or loader does not match the target its file endpoint is not wired; and an item whose game version or loader does not match the target
@@ -160,42 +179,101 @@ The dispatch rule is deliberately narrow, so nothing that used to work changed m
that its typo took effect. The subcommand table lives in exactly one place that its typo took effect. The subcommand table lives in exactly one place
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help` (`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`, lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `agent`, `version`, `help`. `diag`, `config`, `java`, `update`, `agent`, `version`, `help`.
### Updates, and the stable channel
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
`ull update check` / `ull update apply` both build the same `core::update::Job`
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
*renamed* but not overwritten, which is why the swap is a rename.
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
no artifact for this platform / the check itself failed (offline, broken manifest, channel
mismatch). A failed check is never reported as "up to date".
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
- **Nothing installs itself behind your back.** The check at startup can be turned off
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
### Where the logs are
Everything the launcher writes on its own side lives under `<data root>/logs/`. The settings page has
a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has
*Open folder* as well. The data root is `<exe folder>/.ULL` in portable mode, `<per-user app data>/ULL`
in standard mode, and `<custom root>/.ULL` in custom mode — the same value the settings page displays
as the launcher data directory.
| Path | What it is |
|---|---|
| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, **the named reason when a render backend fails to start**, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. |
| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. |
| `logs/game/<session-id>.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. |
| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. |
| `logs/install.log` | Install pipeline journal, one line per stage. |
| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. |
| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. |
The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`,
`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long
enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft
initialises, the game never gets that far, and the honest evidence is the session JSONL plus
`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is
hiding.
### Not done yet ### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy. This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a - **No installer.** You download a file and run it — there is no setup step, and updates replace that
newer version for you, and there is no update source decided yet. file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)).
- **The update path has only been exercised against a local directory source.** The whole chain
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
The first observation on the live stable channel happens after `0.2.3` ships.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*, - **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset *Open folder*, *Delete*. In the settings frame: *Save*, *Reset
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save. edit them rather than on a Save.
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`, - **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself
been downloaded and run end to end. Treat them as untested output, not as "supported because the was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its
matrix lists them". theme/backend lines — and that same check is now a per-slot CI gate on every release
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why (`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified.
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window
builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app.
probe with `continue-on-error`, not a gate. `linux-arm64` is built (native `ubuntu-22.04-arm`) but **not** published — no ARM64 machine has ever run it, so
the artifact stays as build evidence only.
- **macOS is not built by the release workflow, and it is not finalised.** It has two rounds of real-device
evidence (Apple Silicon), which is why the build exists at all — but the menu-bar name, the placement of the
three window buttons (macOS puts them top-left; ours are drawn top-right), and 300s+ steady state still need a
real machine. macOS builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` probe with
`continue-on-error`, not a gate; `gh workflow run release.yml -f all_slots=true` builds the two slots for
evidence, but they are never published.
- **The content library is cramped at low window heights**, and the window height is not wired into - **The content library is cramped at low window heights**, and the window height is not wired into
the page model yet — the page lays itself out for a nominal height rather than the one it gets. the page model yet — the page lays itself out for a nominal height rather than the one it gets.
- **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the - **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
translate — which is exactly the `静默失败` you can see in the English screenshot above. translate — which is exactly the `静默失败` you can see in the English screenshot above.
- **The in-app assistant has no model behind it** (see above). The MCP server does. - **The in-app assistant has no model behind it** (see above). The MCP server does.
- **Pack import has two global policies**, keep-user or overwrite, not a per-file choice; the import
report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install path. - **Install progress is stage-level**, not byte-level, on the content-library install path.
- **No native file chooser.** Import takes drag-and-drop or a pasted path. - **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is - **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes: possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only. [`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
## Download and run ## Download and run
@@ -207,20 +285,28 @@ Grab the file for your platform from
| Slot | Asset | Shape | | Slot | Asset | Shape |
|------|-------|-------| |------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run | | `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below | | `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory | | `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **built, not published**, see below |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same | | `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **built, not published** (no runtime evidence yet) |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same | | `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not built** by the release workflow |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same | | `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow |
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset | | every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is **This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress. named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it
The row stays because the architecture is still supported and the slot still builds (unpublished) in writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in (`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that
only downloadable Windows asset. exist for an unlisted slot get an explicit "not published" warning instead of being deleted quietly, and a
`mode: ci` slot that produced nothing at all warns too — a red slot must never be visible only as a missing row.
Of the unlisted rows, CI builds two. `windows-arm64` is built natively on GitHub's `windows-11-arm` runner
(Windows 11 Arm64 + Visual Studio 2026); every ARM64 failure so far came from a pre-build toolchain probe that
aborted before `cargo build` ran, that probe is now evidence-only, and the next CI run is the first real answer to
whether the slot can produce a binary. `linux-arm64` is built natively on `ubuntu-22.04-arm` (same glibc 2.35 floor
as x64) and is unpublished for a different reason: **nothing has ever run it on ARM64 hardware**. One frame plus the
theme/backend log lines on a real ARM64 machine is the unlock condition; until then the artifact is build evidence.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -228,6 +314,18 @@ documents are attached to the release next to it — downloading the Windows bin
licence. Download links never carry implementation detail in the name, and the binary inside an licence. Download links never carry implementation detail in the name, and the binary inside an
archive is always called `unlucky-launcher`. archive is always called `unlucky-launcher`.
On Linux x64 that is the whole install:
```bash
tar -xzf unlucky-launcher_<version>_linux_x64.tar.gz
cd unlucky-launcher_<version>_linux_x64
./unlucky-launcher # add --software-renderer if there is no usable GPU adapter
```
It asks for glibc 2.35+ (the slot is built on Ubuntu 22.04), `libxkbcommon`, `fontconfig` and `libGL`; on
Debian/Ubuntu `sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6`
covers the load-time set, and the bare `ull_<version>_linux_x64` beside the archive needs no display at all.
The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it
`dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so `dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so
it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35 it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35
@@ -294,7 +392,8 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|----------|---------| |----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) |
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
## Architecture and module map ## Architecture and module map
+112 -25
View File
@@ -4,6 +4,8 @@
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里: > **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases> > <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是 Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器 **一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
@@ -33,6 +35,11 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。 与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位, - **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。 每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、 - **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在 `crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。 日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
@@ -44,8 +51,15 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或 - **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的 CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。 导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果, - **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。 以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线); - **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是 CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。 禁用态,并把不匹配写清楚。没有一条是静默失败的。
@@ -129,35 +143,88 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 **未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 `unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、 (`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、 `install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
`version`、`help`。 `agent`、`version`、`help`。
### 自更新与稳定通道
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
渠道不符)。失败绝不会被说成「已是最新」。
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 日志在哪
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
![设置页「通用」分组:日志文件夹一行,显示 D:/ULL-Demo/.ULL/logs,旁边是「打开目录」与「复制路径」两个按钮,下面是解释文案](docs/images/logs-folder-zh.png)
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
数据根按三种模式取值:便携是 `<exe 目录>/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是
`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。
| 路径 | 是什么 |
|---|---|
| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择、**渲染后端起不来时的具名原因**,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 |
| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 |
| `logs/game/<session-id>.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 |
| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 |
| `logs/install.log` | 安装流水:每个阶段一行。 |
| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 |
| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 |
游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、
`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏
弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与
`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
### 还没做 ### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。 这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包,也没有自动更新通道。** 下个文件直接跑,没有任何东西替你检查新版本,更新源也还没定。 - **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 (见[自更新与稳定通道](#自更新与稳定通道))。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
观察要等 `0.2.3` 发出去之后。
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、 (日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。)
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是 - **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编
「矩阵里有就算支持」。 `linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置 后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉 **这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。
Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 `workflow_dispatch` 「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到
的探针、带 `continue-on-error`,不是门禁。 (`hwnd=none`);后者交给桌面合成器(`corner=policy=system`),我们不自己画。
`linux-arm64` 只是**编出来了,没发布**——没有任何 ARM64 机器跑过它一帧,产物只作构建证据留着。
- **macOS 不由发布流水线构建,也未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、
三个窗口按钮的位置(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,
首次启动要自己清掉 Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动
`workflow_dispatch` 的探针、带 `continue-on-error`,不是门禁;`gh workflow run release.yml -f all_slots=true`
能编这两档取证,但它们从不发布。
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它 - **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
真正拿到的高度。 真正拿到的高度。
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子 - **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的 (`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。 `静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。 - **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。 - **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),目前只在 CPU 渲染下验证过一次。 - **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
## 下载与运行 ## 下载与运行
@@ -168,18 +235,37 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
| 槽位 | 产物 | 形态 | | 槽位 | 产物 | 形态 |
|------|------|------| |------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 | | `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 | | `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 | | `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 | | `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 | | `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** |
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的 **本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进 (`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`, **不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`):
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。 名单里点名了却没产物 ⇒ Release 变红(`linux-x64` 构建红了就挡住整次发布,靠的就是这条);产物在、名单没点名
⇒ 显式打「未发布」警告,不再静默删掉;`mode: ci` 的槽位本次连产物都没有(那一档红了)⇒ 也打警告。
不发布的行里 CI 真编两档。`windows-arm64` 在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + Visual Studio
2026)上编来取证——**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**,`cargo build` 一次都没跑过;
M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。`linux-arm64` 在原生
`ubuntu-22.04-arm`(与 x64 同 glibc 地板)上编,不发布的理由完全不同:**没有任何 ARM64 机器跑过它**。
解锁条件 = 真机渲出一帧 + 主题/后端日志行,之后才把键加进 `PUBLISH_SLOTS`。
Linux x64 的安装就是解开归档直接跑:
```bash
tar -xzf unlucky-launcher_<版本>_linux_x64.tar.gz
cd unlucky-launcher_<版本>_linux_x64
./unlucky-launcher # 没有可用 GPU 适配器就加 --software-renderer
```
载入期要 glibc 2.35+、`libxkbcommon`、`fontconfig`、`libGL`;Debian/Ubuntu 上
`sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` 就够。
与归档并列的裸 `ull_<版本>_linux_x64` 连显示都不需要。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -240,7 +326,8 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|----------|------| |----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → 构建默认四档(`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(发布 `windows-x64` 与 `linux-x64`;`linux-x64` 另有一条同槽位的 Xvfb 首帧门禁;`-f all_slots=true` 才整表六槽位取证) |
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
## 架构与模块地图 ## 架构与模块地图
+15 -3
View File
@@ -1,6 +1,6 @@
# Termux 适配:方案、改动清单与合并指南 # Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,14 个提交,HEAD `38d3024`) > 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。 > 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR ## TL;DR
@@ -97,8 +97,11 @@
## 合并指南 ## 合并指南
1. `git fetch origin && git rebase origin/dev`(分支基于 `7d8b3b9` 之后的 dev; 1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
dev 若前进,先 rebase 再推)。 (M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` / 2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。 `test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败: 3. 本机已知、**不影响 PC CI** 的三条失败:
@@ -107,6 +110,15 @@
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂) - `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。 4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。 5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
所以在 Termux 上跑门禁看不出来):
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
行为不变、用例原样留在原处。
## 验证矩阵(本机) ## 验证矩阵(本机)
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.4" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+1 -1
View File
@@ -45,7 +45,7 @@ pub use chat::{
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
}; };
pub use exec::{ToolExecOutcome, ToolExecutor}; pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderKind}; pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 一轮对话最多几轮工具调用。 /// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8; pub const DEFAULT_MAX_STEPS: usize = 8;
+70 -86
View File
@@ -14,6 +14,11 @@ use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[path = "provider/failure.rs"]
mod failure;
pub use failure::ProviderFailure;
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。 /// Ollama 默认端点(research/02 §5.2)。
@@ -54,6 +59,35 @@ impl ProviderKind {
} }
} }
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
///
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
/// 未知值给 `None`(由调用方回落默认,不猜)。
#[must_use]
pub fn from_setting(value: &str) -> Option<Self> {
match value.trim() {
"openai_compatible" => Some(Self::OpenAiCompatible),
"anthropic" => Some(Self::Anthropic),
"ollama" => Some(Self::Ollama),
"disabled" => Some(Self::Disabled),
_ => None,
}
}
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
#[must_use]
pub fn setting_value(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 设置页展示名。 /// 设置页展示名。
#[must_use] #[must_use]
pub fn display_name(self) -> &'static str { pub fn display_name(self) -> &'static str {
@@ -174,18 +208,22 @@ impl ProviderConfig {
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> { pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() { if !self.is_enabled() {
return Err(ProviderError::new( return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
)); ));
} }
if self.model.trim().is_empty() { if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)")); return Err(ProviderError::classified(
ProviderFailure::NotConfigured,
"没有配置模型名(model)",
));
} }
if self.resolved_base_url().trim().is_empty() { if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要填写端点(base_url)", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要填写端点(base_url)", self.kind.display_name()),
))); ));
} }
if self.kind.requires_api_key() if self.kind.requires_api_key()
&& self && self
@@ -193,10 +231,10 @@ impl ProviderConfig {
.as_deref() .as_deref()
.is_none_or(|key| key.trim().is_empty()) .is_none_or(|key| key.trim().is_empty())
{ {
return Err(ProviderError::new(format!( return Err(ProviderError::classified(
"{} 需要 API key", ProviderFailure::NotConfigured,
self.kind.display_name() format!("{} 需要 API key", self.kind.display_name()),
))); ));
} }
Ok(()) Ok(())
} }
@@ -239,20 +277,38 @@ impl fmt::Debug for ProviderConfig {
} }
} }
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 /// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
/// `ErrorReport` 翻译,见 [`failure`])。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError { pub struct ProviderError {
kind: ProviderFailure,
message: String, message: String,
} }
impl ProviderError { impl ProviderError {
/// 从可读文本构造。 /// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
#[must_use] #[must_use]
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
Self { Self {
kind: ProviderFailure::Other,
message: message.into(), message: message.into(),
} }
} }
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 失败分类(「测试连接」按它分档)。
#[must_use]
pub fn kind(&self) -> ProviderFailure {
self.kind
}
} }
impl fmt::Display for ProviderError { impl fmt::Display for ProviderError {
@@ -264,77 +320,5 @@ impl fmt::Display for ProviderError {
impl std::error::Error for ProviderError {} impl std::error::Error for ProviderError {}
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "provider/tests.rs"]
use super::*; mod tests;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
@@ -0,0 +1,62 @@
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
//!
//! ## 为什么不是字符串匹配
//!
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
//! 一度偶发红)。
//!
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
//!
//! ## 六档口径
//!
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
//! |---|---|---|
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderFailure {
/// 配置不完整:**没有发出任何请求**。
NotConfigured,
/// 鉴权失败(401 / 403 / 凭据被拒)。
Auth,
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound,
/// 端点不可达(传输层失败,没拿到任何回应)。
Unreachable,
/// 超时(调用方时限或传输层超时)。
Timeout,
/// 其它(协议 / 解析 / 5xx / 未知)。
Other,
}
impl ProviderFailure {
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfigured => "not_configured",
Self::Auth => "auth",
Self::ModelNotFound => "model_not_found",
Self::Unreachable => "unreachable",
Self::Timeout => "timeout",
Self::Other => "other",
}
}
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
#[must_use]
pub const fn sent_a_request(self) -> bool {
!matches!(self, Self::NotConfigured)
}
}
+107
View File
@@ -0,0 +1,107 @@
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
//!
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn setting_vocabulary_round_trips() {
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
// agent 侧读出来是 None(M174 的根因)。
for kind in [
ProviderKind::OpenAiCompatible,
ProviderKind::Anthropic,
ProviderKind::Ollama,
ProviderKind::Disabled,
] {
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
}
assert_eq!(
ProviderKind::from_setting(" ollama "),
Some(ProviderKind::Ollama)
);
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
assert_eq!(ProviderKind::from_setting(""), None);
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
assert_eq!(
unlucky_core::setting::keys::AI_PROVIDERS,
[
ProviderKind::OpenAiCompatible.setting_value(),
ProviderKind::Anthropic.setting_value(),
ProviderKind::Ollama.setting_value(),
ProviderKind::Disabled.setting_value(),
]
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
+60 -83
View File
@@ -47,7 +47,7 @@ use std::sync::Arc;
use futures_util::StreamExt; use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::ProviderError as RigError; use rig_core::error::{ErrorKind, ProviderError as RigError};
use rig_core::message::{ use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
@@ -64,7 +64,7 @@ use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture, ProviderFuture,
}; };
use super::provider::{ProviderConfig, ProviderError, ProviderKind}; use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0"; pub const RIG_VERSION_PIN: &str = "=0.45.0";
@@ -275,9 +275,63 @@ fn to_reply(
}) })
} }
/// rig 错误 → 本方错误(文案面向用户)。 /// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` /
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
///
/// | rig 侧信号 | 本方分类 |
/// |---|---|
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
fn map_error(error: RigError) -> ProviderError { fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string()) let report = error.report();
let kind = match report.kind {
ErrorKind::Timeout => ProviderFailure::Timeout,
ErrorKind::Http => {
if transport_timed_out(&error) {
ProviderFailure::Timeout
} else {
ProviderFailure::Unreachable
}
}
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
Some(401 | 403) => ProviderFailure::Auth,
Some(404) => ProviderFailure::ModelNotFound,
_ if matches!(
report.code.as_deref(),
Some("model_not_found" | "model_not_found_error")
) =>
{
ProviderFailure::ModelNotFound
}
_ => ProviderFailure::Other,
},
_ => ProviderFailure::Other,
};
ProviderError::classified(kind, report.message)
}
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
///
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
/// TLS)都归 `Unreachable`。
fn transport_timed_out(error: &RigError) -> bool {
let RigError::Http(http_error) = error else {
return false;
};
let mut source = std::error::Error::source(http_error);
for _ in 0..8 {
let Some(cause) = source else { return false };
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
} }
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "rig/tests.rs"]
use super::*; mod tests;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}
+82
View File
@@ -0,0 +1,82 @@
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
//!
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
.is_ok()
);
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
+18 -5
View File
@@ -22,15 +22,15 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.4" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 # 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.4" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.1" } unlucky-presenter = { path = "../presenter", version = "0.2.4" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -60,11 +60,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli → # 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.1" } unlucky-cli = { path = "../cli", version = "0.2.4" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.1" } unlucky-i18n = { path = "../i18n", version = "0.2.4" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
@@ -90,6 +90,19 @@ iced = { version = "=0.14.0", default-features = false, features = [
default = ["gpu"] default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。 # 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"] gpu = ["iced/wgpu"]
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
# ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 \
# cargo run -p unlucky-app-iced --features update-test-source
# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里
# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。
# 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。
update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"]
[lints] [lints]
workspace = true workspace = true
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
+14
View File
@@ -0,0 +1,14 @@
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
//!
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
//!
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("unlucky-launcher-iced");
}
+261
View File
@@ -0,0 +1,261 @@
//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。
//!
//! ## 四段(与 presenter 的 `AdoptState` 一一对应)
//!
//! | 段 | 画什么 | 从哪来 | 文件 |
//! |---|---|---|---|
//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] |
//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] |
//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] |
//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] |
//!
//! 「现在画哪一段」的判据只有一处:presenter 的 `AdoptState::stage`,外面套一条收窄
//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由:
//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换;
//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。
//!
//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`],
//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。
//!
//! ## 三条纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀,
//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。
//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes`
//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录),
//! 真正的后果原文在确认按钮之前一定会出现。
//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。
//!
//! ## 为什么是「盖一层」而不是实例页里的一段
//!
//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、
//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是**
//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。
//!
//! ## 表单字段归 `App`,事件真相归 presenter
//!
//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程);
//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半,
//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。
mod panes;
mod pick;
mod shell;
/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。
pub(crate) mod shot;
mod text;
#[cfg(test)]
mod tests;
use iced::widget::{container, mouse_area, opaque, Space};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView,
};
use crate::app::components::feedback::dialog::scrim;
use crate::theme::Ui;
use super::message::Message;
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
const CARD_WIDTH: f32 = 640.0;
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
const CARD_MAX_HEIGHT: f32 = 620.0;
/// 正在等哪一条命令的回程。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Busy {
/// 空闲(可以点)
#[default]
Idle,
/// 正在扫描 `versions/`
Scanning,
/// 正在预检(零写盘)
Checking,
/// 正在真正登记
Importing,
}
impl Busy {
/// 有没有在等回程。
#[must_use]
pub const fn waiting(self) -> bool {
!matches!(self, Self::Idle)
}
}
/// 向导的表单态(**归 `App`**,页面借一帧)。
#[derive(Debug, Default)]
pub struct Form {
/// 面板开着吗
pub open: bool,
/// 选中的版本 id(`None` = 还没选)
pub selected: Option<String>,
/// 展示名(空 = 用版本 id)
pub name: String,
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
pub target: AdoptTargetView,
/// 在等在飞的那一条命令
pub busy: Busy,
/// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。
///
/// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的
/// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次
/// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的
/// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。
pub checked: bool,
}
impl Form {
/// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。
pub fn open(&mut self) {
*self = Self {
open: true,
busy: Busy::Scanning,
..Self::default()
};
}
/// 关掉面板(全清)。
pub fn close(&mut self) {
*self = Self::default();
}
/// 回程到了:不在等任何东西了。
pub fn settled(&mut self) {
self.busy = Busy::Idle;
}
/// 当前该画哪一段:`Preview` 只在用户**点过预检**之后才算数,其余按 `AdoptState::stage()`。
///
/// 这条收窄是为了「点选共享档 ⇒ 立刻看到后果原文」而不把用户直接甩到确认页:
/// 自动预检的结果落在 Pick 段里当**提示**读,用户点「预检」之后才进确认段。
#[must_use]
pub fn stage(&self, adopt_state: &AdoptState) -> AdoptStage {
match adopt_state.stage() {
AdoptStage::Preview if !self.checked => AdoptStage::Pick,
other => other,
}
}
/// 后台算出来、且**对得上当前选择**的预检(对不上 = 过期的结果,一律不画)。
///
/// 两个判据缺一不可:
/// - **目标档一致**:改了档之后旧 plan 的 `notes` 说的是别的目录;
/// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录
/// (版本目录档的后果原文里带 `versions/<id>`,拿它当新版本的后果就是撒谎)。
///
/// 没有选中版本时也返回 `None`:那一刻没有任何版本可言,画谁的预检都不成立。
/// 这两条都不是「选中即重扫」带来的——`Select` 只改本地字段、不发命令(落点见
/// `app/update/adopt.rs`),所以过期判定只能在这里做。
#[must_use]
pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> {
let selected = self.selected.as_deref()?;
adopt_state
.plan
.as_ref()
.filter(|plan| plan.target == self.target && plan.version_id == selected)
}
/// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。
#[must_use]
pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> {
let id = self.selected.as_deref()?;
adopt_state
.scan
.as_ref()?
.candidates
.iter()
.find(|candidate| candidate.version_id == id)
}
}
/// 向导消息(`crate::app::Message::Adopt` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdoptMessage {
/// 打开向导(实例页页头「导入」)
Open,
/// 关掉(点遮罩 / 关闭按钮)
Close,
/// 选中一个候选版本
Select(String),
/// 改展示名
Name(String),
/// 改运行目录档
Target(AdoptTargetView),
/// 预检(零写盘)
Check,
/// 确认登记(唯一写盘入口)
Confirm,
/// 从 Preview / Failed 退回候选列表(等价于重新扫一次)
Back,
}
/// 向导消息 → app 消息。
fn adopt(message: AdoptMessage) -> Message {
Message::Adopt(message)
}
// --------------------------------------------------------------- 视图
/// 把向导叠在页面之上(没打开时原样返回页面树)。
pub(super) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
form: &'a Form,
adopt_state: &'a AdoptState,
lang: Lang,
) -> Element<'a, Message> {
if !form.open {
return base;
}
let catalog = lang.catalog();
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(scrim(ui))),
..container::Style::default()
}),
)
.on_press(adopt(AdoptMessage::Close));
// 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。
let centered = container(opaque(shell::card(ui, form, adopt_state, catalog)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 中间区(按段交给 `pick` / `panes`)。
pub(super) fn middle<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
match stage {
AdoptStage::Pick => pick::pick(
ui,
form,
adopt_state.scan.as_ref(),
form.fresh_plan(adopt_state),
catalog,
),
AdoptStage::Preview => panes::preview(ui, form.fresh_plan(adopt_state), catalog),
AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog),
AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog),
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Preview / Failed / Done 三段。
//!
//! | 段 | 画什么 | 判据 |
//! |---|---|---|
//! | Preview | 生效运行目录(**回程给的**)+ 隔离档 + `notes` 原文逐条 | `AdoptEvent::Planned` |
//! | Failed | 稳定码 + `problems[]` 逐条 | `AdoptEvent::Failed` |
//! | Done | 实例名 + 运行目录 + `notes` 原文逐条 | `AdoptEvent::Done` |
//!
//! 三段都不自己算路径、不自己写后果句:`run_dir` 与 `notes` 是 core 的返回值原样。
//! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道**
//! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView};
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{field_line, isolation_label, mono, problem_line, wrapped};
/// 预检结果:落点 + 隔离档 + 后果原文。
pub(super) fn preview<'a>(
ui: &'a Ui,
plan: Option<&'a AdoptPlanView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(plan) = plan else {
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![
field_line(ui, catalog.text(Key::AdoptInstanceName), &plan.name),
field_line(
ui,
catalog.text(Key::AdoptPlanRunDir),
&plan.run_dir.display().to_string()
),
field_line(
ui,
catalog.text(Key::AdoptPlanIsolation),
&isolation_label(plan.isolation, catalog),
),
]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
// 后果话术:core `target_notes()` 的原文,一条一行、一个字不改
for note in &plan.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
body.into()
}
/// 失败:稳定码 + **problems 逐条**。
pub(super) fn failed<'a>(
ui: &'a Ui,
failure: Option<&'a AdoptFailureView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(failure) = failure else {
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![mono(ui, failure.code.clone())]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
if failure.problems.is_empty() {
body = body.push(wrapped(
ui,
failure.message.clone(),
ui.color(Role::OnSurfaceVariant),
));
return body.into();
}
body = body.push(wrapped(
ui,
catalog.text(Key::AdoptProblemCount).to_string(),
ui.color(Role::OnSurfaceVariant),
));
for problem in &failure.problems {
body = body.push(problem_line(ui, problem));
}
body.into()
}
/// 完成:实例名 + 运行目录 + 提示原文。
pub(super) fn done<'a>(
ui: &'a Ui,
done: Option<&'a AdoptDoneView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(done) = done else {
return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant));
};
let mut body = column![
field_line(ui, catalog.text(Key::AdoptInstanceName), &done.name),
field_line(
ui,
catalog.text(Key::AdoptPlanRunDir),
&done.run_dir.display().to_string()
),
field_line(
ui,
catalog.text(Key::AdoptPlanIsolation),
&isolation_label(done.isolation, catalog),
),
]
.spacing(ui.space(Sp::X2))
.width(Length::Fill);
for note in &done.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
body.into()
}
+228
View File
@@ -0,0 +1,228 @@
//! Pick 段:候选列表 + 目标档 + 实例名。
//!
//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来
//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。
//!
//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色);
//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。
use iced::widget::{button, column, container, row};
use iced::{Alignment, Background, Border, Color, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{label, problem_line, target_label, wrapped};
use super::{adopt, AdoptMessage, Form};
/// Pick 段整体。
pub(super) fn pick<'a>(
ui: &'a Ui,
form: &'a Form,
scan: Option<&'a AdoptScanView>,
plan: Option<&'a AdoptPlanView>,
catalog: Catalog,
) -> Element<'a, Message> {
let Some(scan) = scan else {
// 扫描还没回来:一行进度文案(不做假进度条)
return wrapped(
ui,
catalog.text(Key::AdoptScanning).to_string(),
ui.color(Role::OnSurfaceVariant),
);
};
let mut body = column![].spacing(ui.space(Sp::X3)).width(Length::Fill);
if scan.is_empty() {
let dir = scan.versions_dir.display().to_string();
body = body.push(wrapped(
ui,
catalog.render(Key::AdoptEmpty, &[("dir", &dir)]),
ui.color(Role::OnSurfaceVariant),
));
} else {
body = body.push(label(ui, catalog.text(Key::AdoptSelectLabel)));
body = body.push(candidate_list(ui, form, scan, catalog));
}
body = body.push(frame::divider(ui).map(Message::Page));
body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel)));
body = body.push(target_row(ui, form, catalog));
// 后果原文(契约 §5.2):共享档 / 版本目录档一被点选就显示 core `target_notes()` 的原文——
// 这一条来自**零写盘**的自动预检(见 `AdoptMessage::Target` 的落点),不是界面另写的一句。
if let Some(plan) = plan {
for note in &plan.notes {
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
}
}
body = body.push(text_field(
ui,
Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)),
|value| adopt(AdoptMessage::Name(value)),
));
body.into()
}
/// 候选列表(可滚,定高:列表再长页脚按钮也留在卡里)。
fn candidate_list<'a>(
ui: &'a Ui,
form: &'a Form,
scan: &'a AdoptScanView,
catalog: Catalog,
) -> Element<'a, Message> {
let rows = column(
scan.candidates
.iter()
.map(|candidate| candidate_row(ui, form, candidate, catalog))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
rows.into()
}
/// 一条候选。
fn candidate_row<'a>(
ui: &'a Ui,
form: &'a Form,
candidate: &'a AdoptCandidateView,
catalog: Catalog,
) -> Element<'a, Message> {
let selected = form.selected.as_deref() == Some(candidate.version_id.as_str());
let mut items = column![row![
frame::text_role(
ui,
TypeRole::BodyLarge,
selected,
candidate.version_id.as_str(),
ui.color(Role::OnSurface),
)
.map(Message::Page),
container(status(ui, candidate, selected, catalog))
.width(Length::Fill)
.align_x(Alignment::End),
]
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
// 问题逐条(不折叠):候选阶段就把「为什么不能导入」说清楚
for problem in &candidate.problems {
items = items.push(problem_line(ui, problem));
}
let body = container(items)
.width(Length::Fill)
.padding(ui.space(Sp::X2))
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Medium),
..Border::default()
},
..container::Style::default()
});
if !candidate.adoptable() {
// 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路)
return body.into();
}
button(body)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: true,
})
.on_press(adopt(AdoptMessage::Select(candidate.version_id.clone())))
.into()
}
/// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。
fn status<'a>(
ui: &'a Ui,
candidate: &AdoptCandidateView,
selected: bool,
catalog: Catalog,
) -> Element<'a, Message> {
if !candidate.adoptable() {
let count = candidate.problems.len().to_string();
return wrapped(
ui,
catalog.render(Key::AdoptBlocked, &[("count", &count)]),
ui.color(Role::Error),
);
}
if let Some(name) = candidate.registered.first() {
return wrapped(
ui,
catalog.render(Key::AdoptRegistered, &[("name", name)]),
ui.color(Role::OnSurfaceVariant),
);
}
if selected {
return chip(ui, Chip::current(catalog.text(Key::AdoptReady)));
}
wrapped(
ui,
catalog.text(Key::AdoptReady).to_string(),
ui.color(Role::OnSurfaceVariant),
)
}
/// 三枚目标 chip(缺省档在最前;共享 / 版本目录档必须**明确点选**)。
fn target_row<'a>(ui: &'a Ui, form: &'a Form, catalog: Catalog) -> Element<'a, Message> {
let chips = AdoptTargetView::ALL
.into_iter()
.map(|target| {
chip(
ui,
Chip::filter(
target_label(target, catalog),
form.target == target,
adopt(AdoptMessage::Target(target)),
),
)
})
.collect::<Vec<_>>();
column![
row(chips).spacing(ui.space(Sp::X2)).width(Length::Fill),
target_hint(ui, form.target, catalog),
]
.spacing(ui.space(Sp::X1))
.width(Length::Fill)
.into()
}
/// 目标档的一句话说明(点选后立刻换)。**不是后果原文**:原文在 Preview 段逐条画
/// `plan.notes`(core `target_notes()`)。
fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Element<'a, Message> {
let key = match target {
AdoptTargetView::InstanceDir => Key::AdoptTargetInstanceDirHint,
AdoptTargetView::Shared => Key::AdoptTargetSharedHint,
AdoptTargetView::VersionDir => Key::AdoptTargetVersionDirHint,
};
let color = if target.touches_foreign_dir() {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
};
wrapped(ui, catalog.text(key).to_string(), color)
}
+198
View File
@@ -0,0 +1,198 @@
//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。
//!
//! | 函数 | 职责 |
//! |---|---|
//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 |
//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) |
//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 |
//!
//! ## 这个文件不是「纯搬移」
//!
//! 它**起于**一次搬移:`app/adopt.rs` 落笔 412 行越过 `components::gate` 的 400 行硬线,
//! 于是把卡壳 / 标题 / 页脚搬到这里。但**同一个提交里**这三块还改过行为与几何,逐条如下
//! (复审对 diff 时按这几条看,别按「一行未改」看):
//!
//! | 改了什么 | 为什么 |
//! |---|---|
//! | `card` 拆成「定高 + 中间区可滚」(Pick / Failed)与「按内容收高」(Preview / Done)两种几何 | 原来只给 `max_height`,内容一高就被裁:页脚按钮与实例名整排不可见(出图时抓到) |
//! | `footer` 的「关闭」只在 Pick / Preview 出现 | Failed / Done 的主按钮就是关闭,再给一枚文字关闭就是两枚一模一样的按钮 |
//! | `subtitle` 的 Preview 文案改成「版本名 · 目标档」 | 原来打「隔离档 · 目标档」,同屏两个「共用目录」 |
//!
//! 调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。
use iced::widget::{column, container, row, scrollable, Space};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptStage, AdoptState};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::text::{target_label, wrapped};
use super::{adopt, middle, AdoptMessage, Form, CARD_MAX_HEIGHT, CARD_WIDTH};
/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。
pub(super) fn card<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
catalog: Catalog,
) -> Element<'a, Message> {
let stage = form.stage(adopt_state);
let middle = middle(ui, form, adopt_state, stage, catalog);
let head = column![
frame::text_role(
ui,
TypeRole::HeadlineSmall,
true,
title_text(stage, catalog),
ui.color(Role::OnSurface),
)
.map(Message::Page),
wrapped(
ui,
subtitle(stage, adopt_state, catalog),
ui.color(Role::OnSurfaceVariant)
),
frame::divider(ui).map(Message::Page),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
let tail = column![
frame::divider(ui).map(Message::Page),
footer(ui, form, adopt_state, stage, catalog),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
// 列表段(Pick / Failed)**定高 + 中间区可滚**:内容再长,页脚按钮也不会被裁掉
// (第一版就是被裁掉的)。短内容段(Preview / Done)按内容高收,不留一大片空白。
let list_stage = matches!(stage, AdoptStage::Pick | AdoptStage::Failed);
let card = if list_stage {
let body = column![
head,
scrollable(middle)
.height(Length::Fill)
.style(frame::scroll_style(ui)),
tail,
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.height(Length::Fixed(CARD_MAX_HEIGHT))
} else {
let body = column![head, middle, tail]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.max_height(CARD_MAX_HEIGHT)
};
card.padding(ui.space(Sp::X6))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(ShapeScale::ExtraLarge),
..container::Style::default()
})
.into()
}
/// 段标题(四段各一句)。
pub(super) fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str {
match stage {
AdoptStage::Pick => catalog.text(Key::AdoptTitle),
AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle),
AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle),
AdoptStage::Done => catalog.text(Key::AdoptDoneTitle),
}
}
/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。
pub(super) fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String {
match stage {
AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(),
// 预检:版本名 · 目标档(隔离档在正文里单独一行,重复一遍只会更吵)
AdoptStage::Preview => adopt_state
.plan
.as_ref()
.map(|plan| format!("{} · {}", plan.name, target_label(plan.target, catalog)))
.unwrap_or_default(),
AdoptStage::Failed => adopt_state
.failure
.as_ref()
.map(|failure| failure.message.clone())
.unwrap_or_default(),
AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(),
}
}
/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。
pub(super) fn footer<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
let busy = form.busy.waiting();
let primary: Element<'a, Message> = match stage {
AdoptStage::Pick => {
let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy;
if ready {
buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)),
)
} else {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview)))
}
}
AdoptStage::Preview if !busy => buttons::filled(
ui,
Button::new(
catalog.text(Key::AdoptConfirm),
adopt(AdoptMessage::Confirm),
),
),
AdoptStage::Preview => {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm)))
}
// 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了)
AdoptStage::Failed | AdoptStage::Done => buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
),
};
let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill);
// Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录)
if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)),
));
}
actions = actions.push(container(Space::new()).width(Length::Fill));
// 失败/完成段的主按钮就是「关闭」,再给一枚文字「关闭」就是两枚一模一样的按钮
if matches!(stage, AdoptStage::Pick | AdoptStage::Preview) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
));
}
actions = actions.push(primary);
column![actions].width(Length::Fill).into()
}
+205
View File
@@ -0,0 +1,205 @@
//! 出图用的向导状态夹具(D30 视觉验收的输入)。
//!
//! ## 为什么要有它
//!
//! 向导是**四段有状态**的界面,而截图通道只跑一次 `view()`(第 3 帧落盘),没有真人点按钮。
//! 组件库的做法是 `--screen=gallery` + `ForcedState`,页面层的做法是 `pages/instances/demo.rs`
//! 的 `DEMO`——同一类要求,同一类做法:**给渲染器一份确定的状态**,而不是让截图去赌异步时序。
//!
//! ## 哪些是真、哪些是夹具(复审请照这条核对)
//!
//! | 内容 | 来源 |
//! |---|---|
//! | 候选 / 问题 / 失败原因 | **夹具**(合成的 id 与问题文本,只为把版面撑到真实形态) |
//! | 运行目录 / 隔离档 | 夹具(固定路径,便于两张图逐像素对照) |
//! | **后果原文** | **真**:`unlucky_core::game::adopt::target_notes()` 现算,一个字不抄 |
//! | 文案(标题/标签/按钮) | **真**:`unlucky_i18n` 目录 |
//! | 渲染 | **真**:`--software-renderer`(tiny-skia)1180×760,与产品路径同一棵控件树 |
//!
//! 也就是说:这些图证明**版面与文案**,不证明 core 的判据(判据的证据在 `host/adopt/tests.rs`
//! 与 `core/tests/adopt/`)。
//!
//! ## 怎么用
//!
//! ```text
//! ULL_ADOPT_STATE=pick|pick-selected|empty|preview|shared|failed|done \
//! unlucky-launcher-iced.exe --software-renderer --screenshot <path> [--screenshot-theme dark]
//! ```
//!
//! 只在 `--screenshot` 已经给了的情况下生效(交互模式读这个环境变量没有意义,直接忽略)。
use std::path::PathBuf;
use unlucky_core::game::adopt::{target_notes, AdoptTarget};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptState, AdoptTargetView,
};
use super::{Busy, Form};
/// 夹具里的版本目录(只是**画**出来的路径,不碰盘)。
const VERSIONS_DIR: &str = "D:/minecraft/.minecraft/versions";
/// 夹具里的数据区运行目录。
const INSTANCE_RUN_DIR: &str = "D:/ULL/.ULL/data/instances/Better-MC";
/// 夹具里的游戏根。
const GAME_ROOT: &str = "D:/minecraft/.minecraft";
/// 环境变量名(见模块文档)。
pub(crate) const ENV: &str = "ULL_ADOPT_STATE";
/// 按名字造一份「打开着的向导」;不认识的名字返回 `None`(不影响正常启动)。
pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> {
let name = name.trim().to_ascii_lowercase();
let mut form = Form {
open: true,
..Form::default()
};
let mut state = AdoptState::default();
match name.as_str() {
// 扫描完成、还没选版本(列表里三态齐全:可导入 / 已登记 / 有问题)
"pick" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
form.busy = Busy::Idle;
}
// 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态)
"pick-selected" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
form.selected = Some("1.20.1".to_string());
form.busy = Busy::Idle;
}
// 游戏根下真没有版本(空态)
"empty" => {
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: PathBuf::from(VERSIONS_DIR),
candidates: Vec::new(),
})));
form.busy = Busy::Idle;
}
// 选中缺省档(InstanceDir)并点过预检
"preview" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Planned(Box::new(plan_view(
AdoptTargetView::InstanceDir,
AdoptIsolationView::Custom,
INSTANCE_RUN_DIR,
))));
form.selected = Some("1.20.1".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
// 选中共享档(Shared):后果原文由 core `target_notes()` 现算
"shared" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Planned(Box::new(plan_view(
AdoptTargetView::Shared,
AdoptIsolationView::Shared,
GAME_ROOT,
))));
form.selected = Some("1.20.1".to_string());
form.target = AdoptTargetView::Shared;
form.checked = true;
form.busy = Busy::Idle;
}
// 预检失败:两条问题逐条列出(不许折叠成一句)
"failed" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记:清单不是合法 JSON".to_string(),
problems: vec![
AdoptProblemView {
code: "manifest_invalid_json".to_string(),
message: format!(
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
),
},
AdoptProblemView {
code: "missing_parent".to_string(),
message: "版本 1.19.4 继承的父版本 1.19.3 不在盘上".to_string(),
},
],
})));
form.selected = Some("1.19.4".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
// 登记完成
"done" => {
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "2f1c0a5e-7f4b-4c1d-9a2e-6b8d5c3a1f70".to_string(),
name: "Better MC".to_string(),
version_id: "1.20.1-fabric-0.15.0".to_string(),
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from(INSTANCE_RUN_DIR),
dir_created: true,
notes: target_notes(&AdoptTarget::InstanceDir, &PathBuf::from(INSTANCE_RUN_DIR)),
})));
form.selected = Some("1.20.1-fabric-0.15.0".to_string());
form.checked = true;
form.busy = Busy::Idle;
}
_ => return None,
}
Some((form, state))
}
/// 一份扫描结果(三态齐全)。
fn scan_view() -> AdoptScanView {
AdoptScanView {
versions_dir: PathBuf::from(VERSIONS_DIR),
candidates: vec![
AdoptCandidateView {
version_id: "1.20.1".to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems: Vec::new(),
},
AdoptCandidateView {
version_id: "1.20.1-fabric-0.15.0".to_string(),
jar_present: false,
inherits: vec!["1.20.1-fabric-0.15.0".to_string(), "1.20.1".to_string()],
registered: vec!["Better MC".to_string()],
problems: Vec::new(),
},
AdoptCandidateView {
version_id: "1.19.4".to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems: vec![AdoptProblemView {
code: "manifest_invalid_json".to_string(),
message: format!(
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
),
}],
},
],
}
}
/// 一份预检结果。**`notes` 由 core 现算**(共享档/版本目录档的后果原文只有一处)。
fn plan_view(
target: AdoptTargetView,
isolation: AdoptIsolationView,
run_dir: &str,
) -> AdoptPlanView {
let core_target = match target {
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
AdoptTargetView::Shared => AdoptTarget::Shared,
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
};
AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target,
isolation,
run_dir: PathBuf::from(run_dir),
notes: target_notes(&core_target, &PathBuf::from(run_dir)),
}
}
+287
View File
@@ -0,0 +1,287 @@
//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。
//!
//! 抓得住:
//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待);
//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」);
//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。
//!
//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView,
};
use super::*;
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
AdoptCandidateView {
version_id: version_id.to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems,
}
}
fn state_with_scan() -> AdoptState {
let mut state = AdoptState::default();
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: std::path::PathBuf::from("/game/versions"),
candidates: vec![candidate("1.20.1", Vec::new())],
})));
state
}
/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。
#[test]
fn open_and_close_reset_the_form() {
let mut form = Form::default();
form.open();
assert!(form.open);
assert_eq!(form.busy, Busy::Scanning);
assert_eq!(form.target, AdoptTargetView::InstanceDir);
form.selected = Some("1.20.1".to_string());
form.name = "生存".to_string();
form.close();
assert!(!form.open);
assert!(form.selected.is_none());
assert!(form.name.is_empty());
// 再开一次也不该带着上一轮的选中项
form.open();
assert!(form.selected.is_none());
}
/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。
#[test]
fn settling_keeps_what_the_user_typed() {
let mut form = Form::default();
form.open();
form.selected = Some("1.20.1".to_string());
form.name = "生存".to_string();
form.settled();
assert_eq!(form.busy, Busy::Idle);
assert_eq!(form.selected.as_deref(), Some("1.20.1"));
assert_eq!(form.name, "生存");
}
/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。
#[test]
fn candidate_is_none_when_the_selection_vanished() {
let state = state_with_scan();
let mut form = Form {
selected: Some("1.20.1".to_string()),
..Form::default()
};
assert!(form.candidate(&state).is_some());
form.selected = Some("9.9.9".to_string());
assert!(form.candidate(&state).is_none());
}
/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。
#[test]
fn the_four_stages_project_from_the_event_state() {
let catalog = Lang::ZhCn.catalog();
let mut state = state_with_scan();
// Pick
assert_eq!(state.stage(), AdoptStage::Pick);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptTitle),
"标题走目录"
);
assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制"));
// Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改
let note =
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::Shared,
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
notes: vec![note.clone()],
})));
assert_eq!(state.stage(), AdoptStage::Preview);
assert_eq!(
state.plan.as_ref().map(|plan| plan.notes.first().cloned()),
Some(Some(note.clone())),
"后果原文必须原样留着"
);
// Failed:problems 逐条(不折叠)
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记".to_string(),
problems: vec![
AdoptProblemView {
code: "manifest_missing".to_string(),
message: "没有清单".to_string(),
},
AdoptProblemView {
code: "missing_parent".to_string(),
message: "缺父版本 1.20.1".to_string(),
},
],
})));
assert_eq!(state.stage(), AdoptStage::Failed);
assert_eq!(
state.failure.as_ref().map(|f| f.problems.len()),
Some(2),
"两条问题一条都不许吞"
);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptFailureTitle)
);
// Done
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "id-1".to_string(),
name: "1.20.1".to_string(),
version_id: "1.20.1".to_string(),
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
dir_created: false,
notes: vec![note],
})));
assert_eq!(state.stage(), AdoptStage::Done);
assert_eq!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptDoneTitle)
);
}
/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。
#[test]
fn both_catalogs_have_the_wizard_wording() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = lang.catalog();
for key in [
Key::AdoptTitle,
Key::AdoptSubtitle,
Key::AdoptScanning,
Key::AdoptEmpty,
Key::AdoptSelectLabel,
Key::AdoptReady,
Key::AdoptTargetLabel,
Key::AdoptTargetInstanceDir,
Key::AdoptTargetShared,
Key::AdoptTargetVersionDir,
Key::AdoptNameLabel,
Key::AdoptPreview,
Key::AdoptConfirm,
Key::AdoptBack,
Key::AdoptClose,
Key::AdoptPlanTitle,
Key::AdoptPlanRunDir,
Key::AdoptPlanIsolation,
Key::AdoptFailureTitle,
Key::AdoptProblemCount,
Key::AdoptDoneTitle,
Key::AdoptDoneHint,
] {
let value = catalog.text(key);
assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的");
assert_ne!(
value,
format!("{key:?}"),
"{key:?} 在 {lang:?} 没进目录(回落成了 key 名)"
);
}
}
// 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道)
let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint);
assert!(hint.contains(".minecraft"), "{hint}");
}
/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页:
/// 预检结果到了也只是 Pick 段里的提示,直到用户点过「预检」(`checked`) 才进 Preview。
///
/// 抓得住:把 `Form::stage` 写回直接取 `AdoptState::stage()`(点一次共享档就跳到确认页,
/// 实例名还没来得及改);或忘了 `fresh_plan` 的目标一致性判据(改档之后画上一档的后果原文)。
#[test]
fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() {
let mut state = state_with_scan();
let note =
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::Shared,
isolation: AdoptIsolationView::Shared,
run_dir: std::path::PathBuf::from("/game"),
notes: vec![note.clone()],
})));
let mut form = Form {
open: true,
selected: Some("1.20.1".to_string()),
target: AdoptTargetView::Shared,
..Form::default()
};
// 自动预检回来但用户没点「预检」:仍是 Pick 段,notes 当作提示可读
assert_eq!(form.stage(&state), AdoptStage::Pick);
assert_eq!(
form.fresh_plan(&state)
.map(|plan| plan.notes.first().cloned()),
Some(Some(note.clone())),
"后果原文必须能取到"
);
// 点过「预检」之后才进确认段
form.checked = true;
assert_eq!(form.stage(&state), AdoptStage::Preview);
// 换了目标档:上一档的预检作废(不再当后果原文画)
form.target = AdoptTargetView::VersionDir;
assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画");
}
/// **P2-2**:`fresh_plan` 必须按**版本 id** 过期,不只是目标档。
///
/// 场景:先选 1.20.1 + 版本目录档(notes 说 `versions/1.20.1`),再改选 1.19.4——目标档没变,
/// 旧的预检结果若不按版本过滤就会继续当「这个版本的后果原文」画出来,那是一句假话。
///
/// 抓得住:`fresh_plan` 只看 `plan.target == self.target`(第一次修法就是这样漏的)。
/// 抓不住:core 真的会不会为 1.19.4 算出同一段 notes(那是 `target_notes()` 的活)。
#[test]
fn fresh_plan_expires_when_the_selected_version_changes() {
let mut state = state_with_scan();
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::VersionDir,
isolation: AdoptIsolationView::Isolated,
run_dir: std::path::PathBuf::from("/game/versions/1.20.1"),
notes: vec!["运行目录 = 版本目录 /game/versions/1.20.1".to_string()],
})));
let mut form = Form {
open: true,
selected: Some("1.20.1".to_string()),
target: AdoptTargetView::VersionDir,
checked: true,
..Form::default()
};
assert!(
form.fresh_plan(&state).is_some(),
"对得上版本与目标档时应当可读"
);
// 改选另一个版本:目标档没变,但旧预检说的是别的版本的目录
form.selected = Some("1.19.4".to_string());
assert!(
form.fresh_plan(&state).is_none(),
"换了版本之后旧预检必须过期(否则画的是 1.20.1 的后果原文)"
);
// 没选版本时同样不该画(`None` = 没有任何版本的预检可言)
form.selected = None;
assert!(form.fresh_plan(&state).is_none());
}
+88
View File
@@ -0,0 +1,88 @@
//! 向导里的小工具:等宽行、可换行正文、标签、问题行、档位人话。
//!
//! 单独一个文件只为守 400 行硬线(AGENTS §3.1):三段视图(`pick` / `panes`)与卡壳
//! (`adopt.rs`)都要用它们,放哪儿都会让那一处越线。
use iced::widget::text::LineHeight;
use iced::widget::{column, text};
use iced::{Color, Element, Font, Length, Pixels};
use unlucky_design::color::Role;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{AdoptIsolationView, AdoptProblemView, AdoptTargetView};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
/// 段内的次级标签(`LabelMedium` + `OnSurfaceVariant`)。
pub(super) fn label<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
value,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽一行(路径 / 机读码要能逐字读)。
pub(super) fn mono<'a>(ui: &'a Ui, value: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(Font {
family: iced::font::Family::Name(ui.families().mono),
..Font::DEFAULT
})
.color(ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
/// 一段可换行的正文(长路径只按词折行会把卡顶破,所以按词或字形折)。
pub(super) fn wrapped<'a>(ui: &'a Ui, value: String, color: Color) -> Element<'a, Message> {
frame::text_role_owned(ui, TypeRole::BodySmall, false, value, color).map(Message::Page)
}
/// 「标签 + 值」一行(值用等宽)。
pub(super) fn field_line<'a>(ui: &'a Ui, label_text: &'a str, value: &str) -> Element<'a, Message> {
column![label(ui, label_text), mono(ui, value.to_string())]
.spacing(0.0)
.width(Length::Fill)
.into()
}
/// 一条问题:稳定码(等宽) + 人读原因(`Error` 色)。**一条都不许折叠**。
pub(super) fn problem_line<'a>(ui: &'a Ui, problem: &'a AdoptProblemView) -> Element<'a, Message> {
column![
mono(ui, problem.code.clone()),
wrapped(ui, problem.message.clone(), ui.color(Role::Error)),
]
.spacing(0.0)
.width(Length::Fill)
.into()
}
/// 隔离档的人话(沿用实例页已有的两条;`custom` 就是缺省档,直接说「在 ULL 数据区新建」)。
pub(super) fn isolation_label(isolation: AdoptIsolationView, catalog: Catalog) -> String {
match isolation {
AdoptIsolationView::Isolated => catalog.text(Key::InstancesIsolationIsolated).to_string(),
AdoptIsolationView::Shared => catalog.text(Key::InstancesIsolationShared).to_string(),
AdoptIsolationView::Custom => catalog.text(Key::AdoptTargetInstanceDir).to_string(),
}
}
/// 目标档的短标签。
pub(super) fn target_label(target: AdoptTargetView, catalog: Catalog) -> &'static str {
match target {
AdoptTargetView::InstanceDir => catalog.text(Key::AdoptTargetInstanceDir),
AdoptTargetView::Shared => catalog.text(Key::AdoptTargetShared),
AdoptTargetView::VersionDir => catalog.text(Key::AdoptTargetVersionDir),
}
}
+8 -5
View File
@@ -18,6 +18,7 @@
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。 //! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。 //! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
use crate::app::diag::diag;
use unlucky_presenter::dto::AppCommand; use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change; use crate::pages::appearance::Change;
@@ -54,7 +55,7 @@ impl App {
} else { } else {
line.push_str(" layered=skipped outcome=no-window"); line.push_str(" layered=skipped outcome=no-window");
} }
eprintln!("{line}"); diag!("{line}");
} }
/// 设置页改了一项外观。 /// 设置页改了一项外观。
@@ -82,7 +83,7 @@ impl App {
}); });
} }
} }
// 其余键:走 bridge(它知道怎么保留强调色这类本 mission 不管的字段)。 // 其余键:走 bridge(M250 起强调色也在这条路上,取的是上一次合法值)。
Some(_) => self.persist_appearance(), Some(_) => self.persist_appearance(),
None => {} None => {}
} }
@@ -109,7 +110,7 @@ impl App {
self.apply_window_corner("reload"); self.apply_window_corner("reload");
} }
/// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。 /// 把外观写回 `launcher.json`(本页控件管的那批字段 + 强调色;其余键原样保留)。
fn persist_appearance(&mut self) { fn persist_appearance(&mut self) {
let mut prefs = self.appearance_prefs.clone(); let mut prefs = self.appearance_prefs.clone();
prefs.bg_image_enabled = self.appearance.bg_enabled; prefs.bg_image_enabled = self.appearance.bg_enabled;
@@ -118,13 +119,15 @@ impl App {
prefs.bg_dim = self.appearance.bg_dim; prefs.bg_dim = self.appearance.bg_dim;
prefs.panel_alpha = self.appearance.panel_alpha; prefs.panel_alpha = self.appearance.panel_alpha;
prefs.glass = self.appearance.glass; prefs.glass = self.appearance.glass;
// M250:强调色也是「UI 写入字段」了——取**上一次合法值**(非法手输不写盘,见 `accent::Accent`)。
prefs.accent_color = self.appearance.accent.persist_value().to_owned();
self.bridge.persist(&prefs, self.theme_mode); self.bridge.persist(&prefs, self.theme_mode);
self.appearance_prefs = prefs; self.appearance_prefs = prefs;
match self.bridge.path() { match self.bridge.path() {
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()), Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"), None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
} }
} }
} }
+11 -10
View File
@@ -12,6 +12,7 @@
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic //! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。 //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use crate::app::diag::diag;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use iced::Task; use iced::Task;
@@ -51,10 +52,10 @@ pub(crate) fn run(options: Options) -> iced::Result {
let font_started = Instant::now(); let font_started = Instant::now();
let resolved = fonts::resolve(); let resolved = fonts::resolve();
let font_ms = font_started.elapsed(); let font_ms = font_started.elapsed();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0); diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe()); diag!("{}", resolved.describe());
if resolved.has_synthetic() { if resolved.has_synthetic() {
eprintln!( diag!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理" "[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
); );
} }
@@ -64,7 +65,7 @@ pub(crate) fn run(options: Options) -> iced::Result {
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。 // 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。 // **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() { if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)"); diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
} }
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light); let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
diagnostics::report_theme(&options, flavor); diagnostics::report_theme(&options, flavor);
@@ -86,14 +87,14 @@ pub(crate) fn run(options: Options) -> iced::Result {
} }
Some(OnFailure::Refuse) => { Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。 // 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!( diag!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。", "[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options options
.screenshot .screenshot
.as_ref() .as_ref()
.map_or_else(String::new, |path| path.display().to_string()) .map_or_else(String::new, |path| path.display().to_string())
); );
eprintln!( diag!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。" "[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
); );
Err(iced::Error::GraphicsCreationFailed(error)) Err(iced::Error::GraphicsCreationFailed(error))
@@ -192,13 +193,13 @@ fn boot_probe(options: &Options) -> Boot {
if let Some(kind) = renderer::forced_fallback() { if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind); let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value()); std::env::set_var("ICED_BACKEND", kind.env_value());
eprintln!( diag!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}", "[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV, renderer::FALLBACK_ENV,
kind.env_value(), kind.env_value(),
kind.env_value() kind.env_value()
); );
eprintln!("[probe] {}", decision.describe()); diag!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None` // 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。 // ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() { let (probe, probe_ms) = if acceptance::enabled() {
@@ -232,11 +233,11 @@ fn boot_probe(options: &Options) -> Boot {
if cfg!(feature = "gpu") { if cfg!(feature = "gpu") {
match probe_ms { match probe_ms {
Some(elapsed) => eprintln!( Some(elapsed) => diag!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)", "[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0 elapsed.as_secs_f64() * 1000.0
), ),
None => eprintln!( None => diag!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)", "[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name() options.backend.name()
), ),
@@ -35,6 +35,11 @@ pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
size: iced::Size::new(size.0, size.1), size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE), min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。 // 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
//
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
decorations: false, decorations: false,
resizable: true, resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式 // M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
+8 -4
View File
@@ -9,6 +9,7 @@
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程 //! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。 //! 只能建一次事件循环。
use crate::app::diag::diag;
use crate::renderer::Decision; use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind}; use crate::renderer::{self, acceptance, Kind};
@@ -58,10 +59,10 @@ pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2]
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。 /// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! { pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| { let exe = std::env::current_exe().unwrap_or_else(|err| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})"); diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None)); std::process::exit(exit_code(None));
}); });
eprintln!( diag!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\ "[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。", 下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(), fallback.name(),
@@ -78,7 +79,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let code = match status { let code = match status {
Ok(status) => exit_code(status.code()), Ok(status) => exit_code(status.code()),
Err(err) => { Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}"); diag!("[gpu] 降级重开失败:{err}");
exit_code(None) exit_code(None)
} }
}; };
@@ -107,7 +108,10 @@ 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 {
let mut command = std::process::Command::new(program); // 子进程就是本 GUI exe 自己(PE Subsystem=2,本来就不会分到控制台),所以这一处今天闪不了;
// 走 `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())
@@ -6,8 +6,6 @@
//! //!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。 //! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use std::process::Command;
use unlucky_core::platform::paths::ProcessEnv; use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux; use unlucky_core::platform::termux;
@@ -54,7 +52,11 @@ pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。 /// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> { fn x_screen_size() -> Option<(f32, f32)> {
let output = Command::new("xdpyinfo").output().ok()?; // 非 Windows 上 `quiet` 是原样返回(POSIX 没有「spawn 就新开一个终端」这回事);走它是为了
// 不在整棵 `crates/` 的子进程守卫里开一个「Termux 专用」例外(M217)。
let output = unlucky_core::platform::child::quiet("xdpyinfo")
.output()
.ok()?;
if !output.status.success() { if !output.status.success() {
return None; return None;
} }
+169
View File
@@ -0,0 +1,169 @@
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
//!
//! ## 为什么要有这一层
//!
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
//!
//! 现在这些行同时进 `launch::launcher_log`:
//!
//! ```text
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
//! ```
//!
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
//!
//! ## 哪些行进日志、哪些只进 stderr
//!
//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。**
//!
//! | 进日志 | 只进 stderr |
//! |---|---|
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) |
//! | **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` / `[gpu] 失败详情(具名)`(`renderer::report_failure`)、`[gpu] iced_wgpu: …Selected: AdapterInfo…`(`log_bridge`)、`[probe] request=… -> ICED_BACKEND=…`(`probe::apply_with_preflight`)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]` / gallery 页面里的 `[gallery]`) |
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
//! | 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒 ≈ 4.8 小时才滚一次)、`[shot]` 的通道提示 | — |
//!
//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体——
//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。
/// 行首没有 `[tag]` 时的默认 scope。
pub const APP_SCOPE: &str = "app";
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
pub(crate) fn emit(line: &str) {
eprintln!("{line}");
let (scope, message) = split_scope(line);
unlucky_core::launch::launcher_log::record(scope, message);
}
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
///
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
let Some(rest) = line.strip_prefix('[') else {
return (APP_SCOPE, line);
};
let Some((scope, tail)) = rest.split_once(']') else {
return (APP_SCOPE, line);
};
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
return (APP_SCOPE, line);
}
(scope, tail.trim_start())
}
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
macro_rules! diag {
($($arg:tt)*) => {{
crate::app::diag::emit(&format!($($arg)*));
}};
}
pub(crate) use diag;
/// 串行化「会改 `launcher_log` 全局目录」的用例。
///
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
#[cfg(test)]
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::split_scope;
#[test]
fn the_leading_bracket_becomes_the_scope() {
assert_eq!(
split_scope("[font] 求值耗时 12 ms"),
("font", "求值耗时 12 ms")
);
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
// 正文里的方括号不是标签
assert_eq!(
split_scope("[page] 占位动作 [x] pressed"),
("page", "占位动作 [x] pressed")
);
}
#[test]
fn lines_without_a_tag_fall_back_to_the_default_scope() {
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
assert_eq!(
split_scope("[a b] 空白不是标签"),
("app", "[a b] 空白不是标签")
);
}
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
///
/// ## 为什么要「重试」
///
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
///
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
#[test]
fn the_launcher_log_rolls_over_and_stays_bounded() {
use unlucky_core::launch::launcher_log;
let _guard = super::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建夹具目录");
let path = dir.join(launcher_log::FILE_NAME);
let rolled = dir.join("launcher.1.log");
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
for _ in 0..50 {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&rolled);
launcher_log::set_dir(&dir);
if launcher_log::path() != Some(path.clone()) {
continue; // 这一轮被别人的 set_dir 抢先了
}
launcher_log::record("boot", "第一行");
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
if launcher_log::path() != Some(path.clone()) {
continue;
}
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
if launcher_log::path() != Some(path.clone()) {
continue; // 追加落到了别人的目录,这一轮不算
}
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
let size = std::fs::metadata(&path).expect("新文件").len();
assert!(
size < launcher_log::MAX_BYTES,
"滚完之后新文件必须远小于上限:{size}"
);
let text = std::fs::read_to_string(&path).expect("读新文件");
assert!(text.contains("滚过之后这一行"), "{text}");
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
launcher_log::record("boot", "第二次滚动");
assert!(rolled.is_file());
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
std::fs::remove_dir_all(&dir).ok();
return;
}
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
}
}
+390
View File
@@ -0,0 +1,390 @@
//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。
//!
//! ## 为什么先补这一层
//!
//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**,
//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事
//! 在此之前根本无处落脚。本模块是最小可用的一层:
//!
//! ```text
//! presenter AppState::dialogs(唯一队列)
//! │ current()
//! ▼
//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里)
//! │
//! ▼
//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮
//! ```
//!
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
//!
//! ## 崩溃卡片额外带什么
//!
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 /
//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段
//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**,
//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。
//!
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
//! 不重算:**界面不读盘、不猜原因**。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
use crate::app::state::App;
pub(super) mod export;
#[cfg(test)]
mod tests;
pub(super) mod view;
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
#[derive(Debug, Clone)]
pub struct CrashCard {
/// 现场。
pub report: CrashReport,
/// 「详细崩溃说明」是否展开。
pub expanded: bool,
/// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。
pub dialog: DialogId,
/// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。
///
/// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点**
/// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的
/// 结果不能挂在一条不显示的通道上。
pub outcome: Option<String>,
}
/// 弹窗消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 主按钮。
Primary,
/// 次按钮。
Secondary,
/// 点遮罩 / Esc。
Dismiss,
/// 展开 / 收起详细说明。
Toggle,
/// 打开日志文件夹。
OpenFolder,
/// 复制全部路径。
CopyPaths,
/// 复制第 `index` 条路径(`Modal::paths` 的下标)。
CopyPath(usize),
}
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(super) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(super) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
// --------------------------------------------------------------- 与 App 的接线
impl App {
/// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。
pub(super) fn dialog_modal(&self) -> Option<Modal> {
let request = self.state.dialogs.current()?;
let view = DialogRequestView::of(request);
let card = self
.dialog_card
.as_ref()
.filter(|card| card.dialog == request.id);
Some(current(self.lang(), &view, card))
}
/// 收到一次启动收尾:失败就弹框(成功不打扰)。
///
/// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。
pub(super) fn open_failure_dialog(
&mut self,
finished: &unlucky_presenter::dto::LaunchFinished,
) {
let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok
|| finished.failure.is_some();
if !failed {
return;
}
let Some(report) = unlucky_core::launch::crash_report::failing_launch() else {
return;
};
if !finished.session.is_empty() && report.session != finished.session {
// 登记的是另一场(并发启动):不拿它去解释这一场。
return;
}
let catalog = self.lang().catalog();
let crashed = report.started();
let title = catalog.text(if crashed {
Key::CrashDialogTitleCrashed
} else {
Key::CrashDialogTitleFailed
});
let summary = self.dialog_summary(&report);
let at = self.lang();
let id = self.state.dialogs.push(
DialogSource::UserAction,
title,
summary,
DialogKind::Confirm {
primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(),
secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(),
},
);
self.dialog_card = Some(CrashCard {
report,
// 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。
expanded: true,
dialog: id,
outcome: None,
});
self.view_dirty = true;
}
/// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。
fn dialog_summary(&self, report: &CrashReport) -> String {
let catalog = self.lang().catalog();
match report.exit_code {
Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]),
None => catalog.text(Key::CrashDialogTitleFailed).to_string(),
}
}
/// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。
pub(super) fn close_dialog(&mut self) {
let closed = self.state.dialogs.current().map(|request| request.id);
self.state.dialogs.close_current();
if self
.dialog_card
.as_ref()
.is_some_and(|card| closed == Some(card.dialog))
{
self.dialog_card = None;
}
self.view_dirty = true;
}
/// 展开 / 收起详细说明。
pub(super) fn toggle_dialog_detail(&mut self) {
if let Some(card) = &mut self.dialog_card {
card.expanded = !card.expanded;
self.view_dirty = true;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`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
@@ -0,0 +1,344 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
//!
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
use std::path::PathBuf;
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use super::{current, path_rows, CrashCard, DialogRequestView};
use crate::app::state::App;
use crate::cli::Options;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn headless() -> App {
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
App::with_presenter(
Options::default(),
FAMILIES,
unlucky_design::color::SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
let run_dir = PathBuf::from("/instances/survival");
let artifacts = if with_crash_report {
vec![ArtifactFact {
source: "crash_report".to_string(),
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
excerpt: Some("Description: Initializing game".to_string()),
}]
} else {
Vec::new()
};
CrashReport {
instance: "survival".to_string(),
name: "生存".to_string(),
version: "1.20.4-fabric".to_string(),
session: "9f2c".to_string(),
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
launcher_logs: Some(PathBuf::from("/logs")),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 1_000,
command: "javaw -Xmx4096m".to_string(),
java: "javaw.exe (21)".to_string(),
system: "os=windows".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
}
}
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
///
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
#[test]
fn queue_drives_the_modal_through_refresh_view() {
let mut app = headless();
app.refresh_view();
assert!(
app.dialog_model.is_none(),
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
);
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
app.state.dialogs.push(
DialogSource::UserAction,
title,
"正文一行",
DialogKind::Confirm {
primary: "主要".to_string(),
secondary: "次要".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
assert_eq!(modal.title, title);
assert_eq!(modal.primary, "主要");
assert_eq!(modal.secondary.as_deref(), Some("次要"));
}
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
///
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
/// 英文,本用例第一处断言立刻红。
#[test]
fn runtime_chinese_reaches_the_body() {
let lang = Lang::ZhCn;
let card = CrashCard {
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"游戏崩溃",
"",
DialogKind::Confirm {
primary: String::new(),
secondary: String::new(),
},
);
let request = queue.current().expect("当前条");
let view = DialogRequestView::of(request);
let modal = current(lang, &view, Some(&card));
let body = modal.body_text();
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
assert!(body.contains("退出码 1"), "{body}");
assert!(body.contains("游戏进程起来了"), "{body}");
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
assert!(body.contains("没有生成崩溃报告"), "{body}");
assert!(body.contains("捕获"), "{body}");
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
let en = current(Lang::En, &view, Some(&card));
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
}
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
#[test]
fn two_failure_shapes_say_two_different_things() {
let lang = Lang::ZhCn;
let with = report(true, vec!["stack line".to_string()]);
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
assert_ne!(with.evidence(), without.evidence());
let with_card = CrashCard {
report: with.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let without_card = CrashCard {
report: without.clone(),
expanded: true,
dialog: 1,
outcome: None,
};
let mut queue = DialogQueue::new();
queue.push(
DialogSource::UserAction,
"t",
"",
DialogKind::Error {
primary: "p".to_string(),
hint: None,
},
);
let request = queue.current().unwrap();
let view = DialogRequestView::of(request);
let crash = current(lang, &view, Some(&with_card));
let no_log = current(lang, &view, Some(&without_card));
assert_ne!(crash.body_text(), no_log.body_text());
assert!(crash.body_text().contains("写出了崩溃报告"));
assert!(!no_log.body_text().contains("写出了崩溃报告"));
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
assert!(path_rows(&lang.catalog(), &without)
.iter()
.any(|(_, path)| path.ends_with("latest.log")));
}
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
#[test]
fn closing_the_dialog_clears_the_card() {
let mut app = headless();
crash_report::publish(report(false, vec!["line".to_string()]));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 1_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_some(), "失败必须弹框");
assert!(app.dialog_card.is_some());
app.close_dialog();
assert!(app.state.dialogs.current().is_none());
assert!(app.dialog_card.is_none());
}
/// 成功收尾不打扰用户。
#[test]
fn a_successful_launch_does_not_pop_anything() {
let mut app = headless();
crash_report::publish(report(false, Vec::new()));
let finished = LaunchFinished {
id: "survival".to_string(),
session: "9f2c".to_string(),
outcome: LogOutcome::Ok,
exit_code: Some(0),
uptime_ms: 90_000,
failure: None,
};
app.open_failure_dialog(&finished);
assert!(app.state.dialogs.current().is_none());
}
/// P2-1:**卡片那一层只能是卡片大**。
///
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
///
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
#[test]
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
use iced::Length;
let mut app = headless();
app.state.dialogs.push(
DialogSource::UserAction,
"t",
"b",
DialogKind::Confirm {
primary: "p".to_string(),
secondary: "s".to_string(),
},
);
let modal = {
let request = app.state.dialogs.current().expect("当前条");
let view = DialogRequestView::of(request);
super::current(app.lang(), &view, None)
};
let plane = super::view::card_plane(app.ui(), &modal);
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
assert_ne!(
hint.width,
Length::Fill,
"卡片层宽度不能 Fill(会撑成整窗)"
);
assert_ne!(
hint.height,
Length::Fill,
"卡片层高度不能 Fill(会撑成整窗)"
);
}
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
///
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
///
/// 抓不住:具体色值对不对(那是令牌层的事)。
#[test]
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
let backdrop = super::view::backdrop_color(&ui);
assert_eq!(
backdrop.a, 1.0,
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
);
let canvas = crate::theme::to_iced(
unlucky_design::color::Scheme::baseline(flavor)
.get(MaterialLayer::Canvas.surface_role()),
);
assert!(
luma(backdrop) < luma(canvas),
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
luma(backdrop),
luma(canvas)
);
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
}
}
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
///
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
#[test]
fn boot_facts_are_recorded_into_the_launcher_log() {
// 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。
let _guard = crate::app::diag::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建目录");
unlucky_core::launch::launcher_log::set_dir(&dir);
let app = headless();
crate::app::update::boot_env::record_boot_log(&app);
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
let text = std::fs::read_to_string(&path).expect("launcher.log");
assert!(text.contains("[boot] app boot"), "{text}");
assert!(text.contains("theme="), "{text}");
assert!(text.contains("window="), "{text}");
}
#[path = "tests/export.rs"]
mod export;
@@ -0,0 +1,85 @@
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
//!
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
use unlucky_i18n::Lang;
use super::report;
use crate::app::dialog::export;
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
#[test]
fn export_writes_a_bundle_even_without_a_crash_report() {
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
spec_report.launcher_logs = Some(root.join("logs"));
spec_report.session_log = None;
spec_report.run_dir = None;
spec_report.version_json = None;
let done = export::export(&spec_report, Lang::ZhCn);
let text = done.text.clone();
assert!(done.ok, "{text}");
assert_eq!(done.truncated, 0);
assert!(text.contains("已导出"), "{text}");
assert!(text.contains("字节"), "{text}");
let exports = root.join("logs").join("exports");
let files: Vec<_> = std::fs::read_dir(&exports)
.expect("导出目录")
.filter_map(Result::ok)
.collect();
assert_eq!(files.len(), 1, "一次导出只该落一个包");
let size = files[0].metadata().expect("元数据").len();
assert!(size > 0, "包不该是空文件");
assert!(
text.contains(&size.to_string()),
"报出来的字节数要与磁盘一致:{text} / {size}"
);
}
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
///
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
/// 抓不住:那句话排版好不好看(视觉)。
#[test]
fn a_truncated_export_says_so_in_the_notice() {
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
let mut big = String::new();
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
big.push_str(line);
}
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
let mut report = report(false, Vec::new());
report.launcher_logs = Some(root.join("logs"));
report.run_dir = Some(root.join("game"));
report.session_log = None;
report.version_json = None;
let done = export::export(&report, Lang::ZhCn);
assert!(done.ok, "{}", done.text);
assert_eq!(
done.truncated, 1,
"这份 latest.log 必须真的被截断(>4 MiB)"
);
assert!(
done.text.contains("已截断"),
"界面上没有说「有文件被截断」:{}",
done.text
);
}
+299
View File
@@ -0,0 +1,299 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
//!
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
//!
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
//! 立刻停。所以三层的顺序是:
//!
//! ```text
//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
//! ```
//!
//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons;
use crate::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::{Message as DialogMessage, Modal};
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0;
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
const CARD_HEIGHT: f32 = 660.0;
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
///
/// ## 三层的命中语义(r1 复审 P2-1 的修法)
///
/// ```text
/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ]
/// ```
///
/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里
/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。
/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它
/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」
/// 只存在于注释里。
pub(crate) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
modal: &'a Modal,
) -> Element<'a, Message> {
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color(ui))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(card_plane(ui, modal))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
///
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
///
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui)
}
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
///
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
iced::widget::opaque(card(ui, modal))
}
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
// 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
let geometry = DialogGeometry::of(ui);
let shape = Component::Dialog.spec().shape;
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
header = header.push(role(
ui,
TypeRole::HeadlineSmall,
true,
modal.title.as_str(),
));
for line in &modal.body {
header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface)));
}
let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
// **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。
if card.expanded {
if let Some(title) = &card.detail_title {
middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str()));
}
for line in &card.detail_lines {
middle = middle.push(mono(ui, line.clone()));
}
middle = middle.push(frame::divider(ui).map(Message::Page));
}
for (index, (label, path)) in card.paths.iter().enumerate() {
middle = middle.push(
button(
column![
role(ui, TypeRole::LabelMedium, false, label.as_str()),
mono(ui, path.clone()),
]
.spacing(0.0)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(0)
.style(|_theme, _status| button::Style {
background: None,
text_color: Color::TRANSPARENT,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
})
.on_press(Message::Dialog(DialogMessage::CopyPath(index))),
);
}
}
let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill);
if let Some(notice) = &modal.notice {
footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary)));
}
footer = footer.push(actions(ui, modal));
// 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面
// (实测第一版就是这样:导出/关闭两个按钮整排不可见)。
let body = if modal.card.is_some() {
column![
header,
scrollable(middle).height(Length::Fixed(if modal.notice.is_some() {
SCROLL_HEIGHT_WITH_NOTICE
} else {
SCROLL_HEIGHT
})),
frame::divider(ui).map(Message::Page),
footer,
]
} else {
column![header, frame::divider(ui).map(Message::Page), footer,]
}
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
container(body)
.width(Length::Fixed(geometry.max_width))
.max_height(CARD_HEIGHT)
.padding(geometry.padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(shape),
..container::Style::default()
})
.into()
}
/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。
///
/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样,
/// 「导出诊断包」被挤成两行单字)。
fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
if let Some(card) = &modal.card {
column_actions = column_actions.push(
row![
buttons::outlined(
ui,
buttons::Button::new(
card.actions[1].as_str(),
Message::Dialog(DialogMessage::OpenFolder),
),
),
buttons::outlined(
ui,
buttons::Button::new(
card.actions[2].as_str(),
Message::Dialog(DialogMessage::CopyPaths),
),
),
buttons::text(
ui,
buttons::Button::new(
card.toggle.as_str(),
Message::Dialog(DialogMessage::Toggle),
),
),
]
.spacing(ui.space(Sp::X2))
.align_y(Alignment::Center),
);
}
let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2));
if let Some(secondary) = &modal.secondary {
line = line.push(buttons::text(
ui,
buttons::Button::new(
secondary.as_str(),
Message::Dialog(DialogMessage::Secondary),
),
));
}
if !modal.blocking_progress {
line = line.push(buttons::filled(
ui,
buttons::Button::new(
modal.primary.as_str(),
Message::Dialog(DialogMessage::Primary),
),
));
}
column_actions.push(line.align_y(Alignment::Center)).into()
}
/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。
fn role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
) -> Element<'a, Message> {
frame::text_role(
ui,
role,
emphasized,
content,
ui.color(Role::OnSurfaceVariant),
)
.map(Message::Page)
}
/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。
fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodySmall);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(Font {
family: iced::font::Family::Name(ui.families().mono),
..Font::DEFAULT
})
.color(ui.color(Role::OnSurface))
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。
fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodyMedium);
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.width(Length::Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
.into()
}
+121
View File
@@ -0,0 +1,121 @@
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
//!
//! ## 为什么不在页面里现拼路径
//!
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
//!
//! 本模块只做两件事:
//!
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
//!
//! ## 谁来登记
//!
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
//! `platform::data::layout()`——同一份解析链的缓存版本。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_presenter::settings_bridge::SettingsBridge;
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
let home = config.parent()?.parent()?;
Some(LauncherLayout::new(home).logs())
}
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
pub(crate) fn register(bridge: &SettingsBridge) {
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
}
/// 本进程的日志目录。
///
/// - 已登记且解析出来 ⇒ 那条;
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
pub(crate) fn logs_dir() -> Option<PathBuf> {
match LOGS_DIR.get() {
Some(known) => known.clone(),
None => crate::platform::data::layout().map(|layout| layout.logs()),
}
}
/// 确保日志目录存在(不在就建),返回给用户看的路径。
///
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir)
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
if !dir.is_dir() {
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
}
Ok(dir.to_path_buf())
}
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
///
/// # Errors
///
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
pub(crate) fn open() -> Result<PathBuf, String> {
let dir = logs_dir().ok_or_else(|| {
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
.to_string()
})?;
let dir = prepare(&dir)?;
crate::app::dialog::export::open_dir(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
///
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
#[test]
fn the_log_folder_is_the_logs_partition_next_to_config() {
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
assert_eq!(
logs_dir_of(&config),
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
);
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
}
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
///
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
#[test]
fn prepare_creates_the_folder_and_reports_the_same_path() {
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".ULL/logs");
assert!(!dir.is_dir(), "夹具目录不该已经存在");
let prepared = prepare(&dir).expect("应当建得出来");
assert_eq!(prepared, dir);
assert!(dir.is_dir(), "打开之前必须真的存在");
std::fs::remove_dir_all(&root).ok();
}
/// 失败文本带路径(「不许只在成功时可用」)。
#[test]
fn a_failure_names_the_path_it_could_not_use() {
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
std::fs::write(&file, b"x").expect("写夹具");
let error = prepare(&file).expect_err("一个文件不是目录");
assert!(error.contains(&file.display().to_string()), "{error}");
std::fs::remove_file(&file).ok();
}
}
+7
View File
@@ -67,6 +67,13 @@ 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),
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
///
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
Adopt(super::adopt::AdoptMessage),
} }
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
+4
View File
@@ -27,9 +27,13 @@
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` //! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。 //! (组件源码里的文档与测试都按这个路径写)。
mod adopt;
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;
+59 -1
View File
@@ -56,6 +56,9 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 /// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: /// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 /// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
///
/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅,
/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1); pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 /// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
@@ -64,8 +67,12 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3); pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
mod boot_probe; mod boot_probe;
// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响)
mod construct; mod construct;
mod mods_shot;
mod refresh; mod refresh;
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
mod saves_shot;
mod theme; mod theme;
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。 /// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
@@ -130,12 +137,18 @@ 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):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State, pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段, /// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 强调色这类本 mission 不管的字段原样保留)。 /// 没动过的字段原样保留;M250 起强调色也在「真改了」的那一列里)。
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>, pub(super) hwnd: Option<isize>,
@@ -168,6 +181,14 @@ 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,
/// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。
///
/// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、
/// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。
pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
/// ///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -178,6 +199,27 @@ 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,
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
///
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
pub(super) adopt_form: super::adopt::Form,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
pub(super) dialog_card: Option<super::dialog::CrashCard>,
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
///
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache, pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -186,3 +228,19 @@ 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
}
}
+23 -2
View File
@@ -4,6 +4,7 @@
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出, //! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。 //! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
use crate::app::diag::diag;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Instant; use std::time::Instant;
@@ -22,17 +23,37 @@ 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(), mock: options.screenshot.is_some() && !real_host_in_screenshot(),
} }
} }
/// 取证开关 `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() {
eprintln!( diag!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0 started.elapsed().as_secs_f64() * 1000.0
); );
+39 -2
View File
@@ -13,6 +13,7 @@ use iced::window;
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path}; use unlucky_presenter::backend::{build_backend, config_path};
use unlucky_presenter::dto::Backend; use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState; use unlucky_presenter::state::AppState;
@@ -41,6 +42,9 @@ impl App {
Some(root) => SettingsBridge::with_path(config_path(root)), Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(), None => SettingsBridge::open(),
}; };
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
crate::app::logs_folder::register(&bridge);
Self::with_presenter(options, families, flavor, backend, bridge) Self::with_presenter(options, families, flavor, backend, bridge)
} }
@@ -54,7 +58,17 @@ impl App {
) -> Self { ) -> Self {
let width = WINDOW_SIZE.width; let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height; let height = WINDOW_SIZE.height;
let theme_mode = bridge.load().1; // 主题档:截图通道**不读机器配置**(D30:同一份代码在两台机器上要出同一张图)——
// 由 `--screenshot-theme` 钉死(浅 → Silver / `light`,深 → Ink / `dark`)。
// 产品路径才读 `appearance.theme_mode`。复审 P2:此前这里两种通道都读文件,
// 设置页「主题」那一行的选中态会随出图机器的配置漂移。
let theme_mode = match options.screenshot.is_some() {
true => match options.shot_flavor().unwrap_or(SchemeFlavor::Light) {
SchemeFlavor::Light => ThemeMode::Light,
SchemeFlavor::Dark => ThemeMode::Dark,
},
false => bridge.load().1,
};
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键 // 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。 // (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
// //
@@ -86,9 +100,14 @@ impl App {
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some()); let lang = LangState::boot(&bridge, options.screenshot.is_some());
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
crate::fonts::install::set_offline(options.screenshot.is_some());
let mut view = pages::Model::new( let mut view = pages::Model::new(
pages::Screen::Instances, pages::Screen::Instances,
width, width,
height,
Some(pages::Screen::Instances.nav()), Some(pages::Screen::Instances.nav()),
); );
view.lang = lang.lang(); view.lang = lang.lang();
@@ -113,6 +132,9 @@ impl App {
frames: 0, frames: 0,
refreshes: 0, refreshes: 0,
auto_selected: false, auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height, height,
appearance, appearance,
appearance_prefs, appearance_prefs,
@@ -124,7 +146,13 @@ 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(),
adopt_form: super::super::adopt::Form::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0,
mods_failed_seen: None,
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(
@@ -137,8 +165,17 @@ impl App {
avatars: pages::account::avatar::Cache::default(), avatars: pages::account::avatar::Cache::default(),
corner_applied: None, corner_applied: None,
}; };
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 // M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`),
// 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。
app.settings.language_appearance.theme = theme_mode;
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。
app.rebuild_ui(); app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
// M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`).
app.hydrate_adopt_state();
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log(); app.lang.log();
@@ -0,0 +1,97 @@
//! 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 MODS 区截图需要一屏**有内容的列表**。产品路径的列表来自后台线程(列 `mods/` → 快照),
//! 而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及完成,截出来会是
//! 「正在读取 mods/…」。M157 的启动参数区、M222 的下载页、M223 的导入都是同一个问题,
//! 处理方式一致:**状态注入**,且注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`ModListView`]),不是页面模型——`Mods::from_state` 的转换、
//! 计数、`.old` 徽标、在飞置灰走的都是产品代码;
//! - 真实链路由两条端到端用例钉住(`host::mods::tests` 在真盘上列目录 + 启停)。
/// 夹具开关是否打开(`ULL_MODS_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_MODS_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// `dir` 故意留空:夹具不碰路径(这一版页面也不画它),写一个假路径反而会被当成"真的派生结果"。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::ModListView {
use unlucky_presenter::dto::{ModEntryView, ModListView};
let entry = |file: &str, name: &str, enabled: bool, bytes: u64, old: bool| ModEntryView {
file: file.to_string(),
name: name.to_string(),
enabled,
bytes,
old,
};
// 夹具不碰路径:`dir` 留空(页面这一版不画它,它只是 DTO 的一部分)。
let dir = String::new();
ModListView {
instance: instance.to_string(),
dir,
exists: true,
entries: vec![
entry(
"sodium-fabric-0.5.11.jar",
"sodium-fabric-0.5.11.jar",
true,
1_048_576,
false,
),
entry(
"iris-fabric-1.8.5.jar",
"iris-fabric-1.8.5.jar",
true,
2_621_440,
false,
),
entry(
"jei-1.20.4-forge-17.3.0.49.jar.disabled",
"jei-1.20.4-forge-17.3.0.49.jar",
false,
1_363_148,
false,
),
entry(
"aether-1.20.1-1.0.0.jar.old",
"aether-1.20.1-1.0.0.jar",
true,
7_340_032,
true,
),
entry(
"create-1.20.1-0.5.1.f.jar.disabled",
"create-1.20.1-0.5.1.f.jar",
false,
4_194_304,
false,
),
entry(
"fabric-api-0.92.2+1.20.4.jar",
"fabric-api-0.92.2+1.20.4.jar",
true,
2_097_152,
false,
),
],
skipped: Vec::new(),
running: false,
known: true,
}
}
+173
View File
@@ -4,10 +4,16 @@
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位, //! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。 //! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
use super::{mods_shot, saves_shot};
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::view;
use crate::pages; use crate::pages;
use unlucky_presenter::router::Route;
use super::App; use super::App;
impl App { impl App {
@@ -34,6 +40,38 @@ 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);
}
}
// M224:详情页 ⇄ MODS 区的绑定与取数(换实例就重新列一次 mods/)。
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
self.sync_mods();
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure();
// M247(复审 r1 P2-1):被确认的世界已经从快照里消失 ⇒ 收起那个确认框。
// 不这样的话主按钮就是一次静默空操作(点了没删、也不说一句)。
self.settle_saves_confirm();
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category; let category = self.view.download.category;
@@ -46,16 +84,151 @@ 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;
} }
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
///
/// 判据由 presenter 给(`SavesState::take_closed_confirm`:那个世界从快照里消失了),
/// **框的 id 也由它带着**——所以只收我们自己那个框,崩溃卡片/进度框一概不碰。
///
/// 为什么必须收:目标已经作废,主按钮就成了一次**静默空操作**(用户点了「删除世界」,
/// 框关了、什么也没发生、也没一句解释)。少删是安全的,但"看起来坏了"不是可接受的结果。
fn settle_saves_confirm(&mut self) {
let Some(dialog) = self.state.saves.take_closed_confirm() else {
return;
};
if self
.state
.dialogs
.current()
.is_some_and(|current| current.id == dialog)
{
self.close_dialog();
}
}
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
/// 详情页 ⇄ MODS 区的绑定与取数。
///
/// 判据只有一条:**绑定变了**(进详情页 / 换实例 / 退出详情页)。
/// - 绑定函数自己会清掉旧快照(A 的列表不许画在 B 的详情页上,见 presenter 的 `ModsState::bind`);
/// - 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Mods(List)` 去后台列目录。
///
/// 为什么放在 `refresh_view` 里:它是**唯一**的「路由变了」漏斗(点行、返回、首屏自动选中都走它)。
/// 派发会再进一次 `refresh_view`(`dispatch` → `pump` → 重建),但那时 `bound` 已经相等,
/// 这一支直接返回,不会重复发请求、也不会无限递归。
fn sync_mods(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.mods.bound == wanted {
return;
}
self.state.bind_mods(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_MODS_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `mods_shot` 的模块文档)。
if mods_shot::enabled() {
self.state.mods.changed(mods_shot::view(&id));
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒(`watch_until`),
// 而这里只是"进详情页顺手后台列一次目录"——结果在下一拍心跳(1 Hz)落地就够了。
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红(`subscription` 的三条用例立刻红:实测过)。
self.backend
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
}
/// 详情页 ⇄ SAVES 区的绑定与取数(M235)。
///
/// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动;
/// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。
///
/// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。
fn sync_saves(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.saves.bound == wanted {
return;
}
self.state.bind_saves(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
if saves_shot::enabled() {
saves_shot::seed(self, &id);
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
// 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由:
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。
self.backend
.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id))));
}
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
///
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
/// **只弹一次**——第二次是同一件事的重复,再弹就是刷屏(用户已经看见了那句红字)。
/// 换了文件、换了实例、或失败大类变了(例如"不覆盖冲突"变成"被占用"),三元组就变了,
/// 于是照样会再弹一次:提示语跟的是"新事实",不是"点击次数"。
fn note_mods_failure(&mut self) {
let Some(failure) = self.state.mods.failed.as_ref() else {
self.mods_failed_seen = None;
return;
};
let key = (failure.instance.clone(), failure.file.clone(), failure.kind);
if self.mods_failed_seen.as_ref() == Some(&key) {
return;
}
let text = pages::mods_failure_line(failure, self.lang().catalog());
self.state.toasts.push(ToastKind::Error, text);
self.mods_failed_seen = Some(key);
}
} }
+266
View File
@@ -0,0 +1,266 @@
//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程
//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及
//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致:
//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、
//! 计数、「有图标」标记走的都是产品代码;
//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与
//! `presenter::host::saves::tests`(派生运行目录 → 快照)。
//!
//! ```text
//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \
//! unlucky-launcher-iced --screenshot=saves.png --software-renderer
//! ```
/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_SAVES_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。
///
/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」
/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。
const NEWEST: u64 = 1_714_557_600_000;
/// 比 [`NEWEST`] 早 32 天。
const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000;
/// 比 [`NEWEST`] 早 42 天。
const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
use unlucky_presenter::dto::{
SavesListView, ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind,
ScreenshotView, WorldView,
};
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
dir: dir.to_string(),
icon,
modified_ms,
};
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>, preview: ScreenshotPreview| {
ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
preview,
}
};
SavesListView {
instance: instance.to_string(),
dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(),
exists: true,
worlds: vec![
world("Better World", true, Some(NEWEST)),
world("Redstone Lab", true, Some(MIDDLE)),
world("Skyblock", false, Some(OLDEST)),
world("Test Flat", false, None),
],
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
screenshots_exists: true,
// 四张截图,把缩略图墙的**四种格子**一次摆出来(M251):
// ① 白天(真像素)② 黄昏(真像素)③ 「未生成预览」占位 ④ 具名的失败原因。
// 像素是**合成的**(下面 `scene`):夹具不连真实解码器,出图只证明墙的排版与状态,
// 解码由 presenter 的 `host::saves::thumbs` 用例用真 PNG 钉住。
screenshots: vec![
shot(
"2024-05-01_10.00.00.png",
1_887_436,
Some(NEWEST),
ScreenshotPreview::Ready(scene(1)),
),
shot(
"2024-04-30_21.14.05.png",
2_097_152,
Some(MIDDLE),
ScreenshotPreview::Ready(scene(2)),
),
shot(
"2024-04-20_08.30.00.png",
964_608,
None,
ScreenshotPreview::NotGenerated,
),
shot(
"2024-04-11_19.02.41.png",
9_437_184,
Some(OLDEST),
ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9437184 > 8388608".to_string(),
}),
),
],
skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
last_op: None,
}
}
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
///
/// | 取值 | 出图抓什么 |
/// |---|---|
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
/// | `confirm` | 删除的二次确认模态框 |
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// 默认:列表 + 行操作区。
Plain,
/// 行内重命名进行中。
Rename,
/// 删除确认模态。
Confirm,
/// 备份成功的回执。
BackedUp,
/// 写操作被「游戏在运行」挡下。
Running,
}
fn mode() -> Mode {
match std::env::var("ULL_SAVES_FIXTURE")
.unwrap_or_default()
.trim()
{
"rename" => Mode::Rename,
"confirm" => Mode::Confirm,
"backed-up" => Mode::BackedUp,
"running" => Mode::Running,
_ => Mode::Plain,
}
}
/// 注入这一屏需要的状态(**只走产品路径的入口**:`changed` / `start_rename` / `ask_delete` /
/// `dialogs.push`,一处新渲染分支都没有)。
pub fn seed(app: &mut crate::app::state::App, instance: &str) {
let mode = mode();
let mut view = view(instance);
if mode == Mode::BackedUp {
view.last_op = Some(unlucky_presenter::dto::SavesOped::BackedUp {
dir: "Better World".to_string(),
file: "Better World-20240501-100000.zip".to_string(),
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
bytes: 84_213_760,
skipped: 0,
});
}
let first = view.worlds.first().map(|world| world.dir.clone());
app.state.saves.changed(view);
let Some(dir) = first else {
return;
};
match mode {
Mode::Rename => app.state.saves.start_rename(&dir),
// 在飞闸门那条失败(`host::saves::dispatch` 在 `running` 时直接回它)。
Mode::Running => app
.state
.saves
.failed(unlucky_presenter::dto::SavesFailureView {
instance: instance.to_string(),
file: dir,
op: Some(unlucky_presenter::dto::SavesFailureOp::Rename),
kind: unlucky_presenter::dto::SavesFailureKind::GameRunning,
detail: String::new(),
}),
Mode::Confirm => {
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
let dialog = app.state.dialogs.push(
unlucky_presenter::dialogs::DialogSource::UserAction,
title,
body,
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
);
app.state.saves.ask_delete(dialog, &dir);
}
Mode::Plain | Mode::BackedUp => {}
}
}
/// 一张**合成的**缩略图(160×90 RGBA):天空渐变 + 地面 + 太阳/月亮。
///
/// 为什么合成而不是读真图:夹具的职责是「让页面有东西可画」,而它跑在截图通道的第 3 帧——
/// 真解码要真的有一张 PNG 在盘上、还要等后台线程回来。合成像素让出图**确定**(两张主题图
/// 逐像素可比),并且明确地不假装自己是解码证据(那由 presenter 的用例负责)。
///
/// `tone`:`1` = 白天(亮天空 + 绿地面),其余 = 黄昏(橙天空 + 暗地面)。同一套几何,
/// 两种配色——墙上两格一眼能看出"每格各画各的"。
fn scene(tone: u8) -> unlucky_presenter::dto::ThumbnailView {
const W: u32 = 160;
const H: u32 = 90;
// 地平线在 2/3 高度(截图里天空总比地面多)。
const HORIZON: u32 = H * 2 / 3;
let (sky_top, sky_bottom, ground, sun): ([u8; 3], [u8; 3], [u8; 3], [u8; 3]) = if tone == 1 {
(
[128, 176, 240],
[200, 224, 248],
[86, 138, 66],
[255, 246, 186],
)
} else {
([96, 66, 96], [232, 148, 96], [58, 74, 48], [255, 214, 150])
};
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for y in 0..H {
for x in 0..W {
let (r, g, b) = if y < HORIZON {
// 天空:从顶到底线性插值(`t` = 0..=255 的定点比例)。
let t = y * 255 / HORIZON.max(1);
let mix = |from: u8, to: u8| -> u8 {
let span = i32::from(to) - i32::from(from);
(i32::from(from) + span * t as i32 / 255) as u8
};
(
mix(sky_top[0], sky_bottom[0]),
mix(sky_top[1], sky_bottom[1]),
mix(sky_top[2], sky_bottom[2]),
)
} else {
// 地面:按 3px 一条的条纹给出「方块地形」的观感。
let stripe = (y / 3) % 2 == 0;
let shade: u8 = if stripe { 0 } else { 14 };
(
ground[0].saturating_sub(shade),
ground[1].saturating_sub(shade),
ground[2].saturating_sub(shade),
)
};
// 太阳/月亮:右上角一个 18px 的圆(够小,不会盖住画面主体)。
let (dx, dy) = (x as i32 - 126, y as i32 - 26);
let (r, g, b) = if dx * dx + dy * dy <= 18 * 18 {
(sun[0], sun[1], sun[2])
} else {
(r, g, b)
};
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
unlucky_presenter::dto::ThumbnailView {
width: W,
height: H,
pixels,
}
}
+154 -8
View File
@@ -5,6 +5,9 @@
//! //!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。 //! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use crate::app::message::Message;
use iced::Task;
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::prefs::ThemeMode;
@@ -30,14 +33,18 @@ impl App {
self.appearance.set_flavor(flavor); self.appearance.set_flavor(flavor);
let previous = self.ui.name(); let previous = self.ui.name();
self.rebuild_ui(); self.rebuild_ui();
eprintln!( diag!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)", "[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous, previous,
self.ui.name() self.ui.name()
); );
} }
/// 按「配色来源是否取色成功」重建 [`Ui`]。 /// 按「强调色 / 配色来源是否接管」重建 [`Ui`]。
///
/// **优先级(M250):显式强调色 > D38 取色方案 > 出厂品牌板**。手填的色是直接意图,取色是从
/// 图片派生的,所以强调色只在用户真的填了合法值时压过取色——两条路都只决定「颜色来自哪里」,
/// `flavor`(明暗 / 跟随系统)仍由 `self.appearance.flavor()` 决定。
/// ///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是 /// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的 /// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
@@ -46,17 +53,37 @@ impl App {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一** // 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。 // 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha(); let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() { // 三选一规则只在 `theme::tokens` 里写一遍:出图 harness 也调它(两头各抄一份迟早
Some(scheme) => { // 出现「图上有强调色、产品里没有」,而那种偏差在图上看不出来)。
eprintln!( let (ui, colors) = crate::theme::tokens(
self.appearance.flavor(),
self.appearance.accent.color(),
self.appearance.derived_scheme().copied(),
self.families,
);
match colors {
crate::theme::Colors::Accent => {
// M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。
diag!(
"[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;取色方案 palette={} 被压住)",
self.appearance.accent.log_token(),
self.appearance.palette_level()
);
}
crate::theme::Colors::Scheme => {
diag!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level() self.appearance.palette_level()
); );
Ui::from_scheme(*scheme, self.families)
} }
None => Ui::new(self.appearance.flavor(), self.families), crate::theme::Colors::Board => {
// 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。
if let Some(error) = self.appearance.accent.error() {
diag!("[appearance] accent 非法({error:?})→ 出厂板,错行见设置页");
}
}
} }
.with_panel_alpha(panel_alpha); self.ui = ui.with_panel_alpha(panel_alpha);
} }
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。 /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
@@ -72,6 +99,8 @@ impl App {
} }
let mode = self.bridge.load().1; let mode = self.bridge.load().1;
self.theme_mode = mode; self.theme_mode = mode;
// M229:设置页那一行画的是这个字段(唯一写者见 [`App::set_theme_mode`])。
self.settings.language_appearance.theme = mode;
match mode { match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light), ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark), ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
@@ -79,8 +108,125 @@ impl App {
} }
} }
/// 换主题档(M229:设置页「通用 → 主题」→ `pages::Message::Theme`)。
///
/// 四件事:①改 `theme_mode`(唯一真相)②同步设置页那一行的档位 ③重建 `Ui`(色板换了,
/// `Theme::name` 与 49 个角色一起变,iced 才会重算缓存)④落盘 `appearance.theme_mode`。
/// `Auto` 自己没有颜色,要等系统回答——所以返回 `iced::system::theme()` 那个 Task,
/// 由既有的 `Message::SystemTheme` 分支落地(它只在 `theme_mode == Auto` 时吃这条消息)。
pub(in crate::app) fn set_theme_mode(&mut self, mode: ThemeMode) -> Task<Message> {
self.settings.language_appearance.theme = mode;
if mode == self.theme_mode {
return Task::none();
}
self.theme_mode = mode;
let prefs = self.appearance_prefs.clone();
self.bridge.persist(&prefs, mode);
match self.bridge.path() {
Some(path) => diag!("[theme] mode={} 已写回 {}", mode.as_str(), path.display()),
None => diag!(
"[theme] mode={} 未落盘(数据根解析不出来,内存态运行):重启会回到旧值",
mode.as_str()
),
}
match mode {
ThemeMode::Light => {
self.set_flavor(SchemeFlavor::Light);
self.after_theme_switch();
Task::none()
}
ThemeMode::Dark => {
self.set_flavor(SchemeFlavor::Dark);
self.after_theme_switch();
Task::none()
}
// 跟随系统:当前先按已有深浅画,紧接着由系统主题回答覆盖
// (`set_flavor` 有「同档不重建」的保护,所以这里不必先猜一次)。
ThemeMode::Auto => iced::system::theme().map(Message::SystemTheme),
}
}
/// 换档之后重建视图模型 + 记一行取证日志(色板名就是证据:`ink` / `silver`)。
fn after_theme_switch(&mut self) {
self.view_dirty = true;
self.refresh_view();
diag!(
"[theme] mode={} board={} Theme::name={}",
self.theme_mode().as_str(),
self.board_name(),
self.ui.name()
);
}
/// 当前主题档(设置页那一行的选中态来自 `settings.groups`,这里是取证与测试的读数口)。
pub(in crate::app) fn theme_mode(&self) -> ThemeMode {
self.theme_mode
}
/// 当前色板名(日志与测试用:`ink` / `silver`;取色方案接管时仍报它所属深浅的那块)。
pub(in crate::app) fn board_name(&self) -> &'static str {
self.ui.board().name()
}
/// 当前令牌求值器(测试与截图日志用)。 /// 当前令牌求值器(测试与截图日志用)。
pub(in crate::app) fn ui(&self) -> &Ui { pub(in crate::app) fn ui(&self) -> &Ui {
&self.ui &self.ui
} }
} }
#[cfg(test)]
mod tests {
use super::super::App;
use crate::app::components::feedback::progress;
use crate::cli::Options;
use crate::pages::appearance::Change;
use crate::theme::Families;
use unlucky_design::color::{Color, SchemeFlavor};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 强调色接线(M250 ACCENTWIRE)的 App 级判据:`rebuild_ui` 真的换了令牌。
///
/// **这条是判据一的变异探针**:把 [`App::rebuild_ui`] 改回「恒走 `Ui::new` 出厂板」
/// (或在它前面短路掉 `accent.color()` 分支),下面 `assert_ne!` 当场红——`pages/appearance`
/// 那条用例只证明「`Ui::with_accent` 能给出不同的色」,抓不到「谁去调它」。
///
/// 抓得住:接线不存在 / 被短路 / 强调色解析结果没进 `Ui`。
/// 抓不住:像素(样式闭包被别处覆盖)——那是出图的事。
#[test]
fn rebuild_ui_follows_the_configured_accent() {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
);
// 出厂态:同一组件(进度指示条 = `Role::Primary`)取到的值
let (_, factory_primary) = progress::linear_colors(app.ui());
assert_eq!(app.ui().name(), "ull-silver");
// 设置页填色 → `on_appearance` 会 rebuild 一次(与真机路径同一个函数)
app.on_appearance(Change::Accent(Color::rgb(0x00, 0x6A, 0x5C).to_hex()));
let (_, accent_primary) = progress::linear_colors(app.ui());
assert_ne!(
factory_primary, accent_primary,
"填了自定义强调色,`rebuild_ui` 却还给出出厂板的 Primary ⇒ 接线没落地"
);
assert_eq!(
app.ui().flavor(),
SchemeFlavor::Light,
"强调色只换颜色来源,不改明暗"
);
}
}
+256
View File
@@ -11,10 +11,14 @@
//! | 条件 | 为什么需要 33 ms 节拍 | //! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---| //! |---|---|
//! | 截图通道 | 第 3 帧要落盘 | //! | 截图通道 | 第 3 帧要落盘 |
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | //! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 | //! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | //! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//! //!
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 //! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//! //!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2) //! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
@@ -43,6 +47,7 @@ use std::time::Instant;
use iced::{window, Subscription}; use iced::{window, Subscription};
use super::components::motion;
use super::message::Message; use super::message::Message;
use super::state::App; use super::state::App;
@@ -85,10 +90,261 @@ impl App {
/// 现在要不要**时钟**(见模块文档的表)。 /// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool { pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot() self.has_screenshot()
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|| motion::needs_frames()
|| !self.state.toasts.is_empty() || !self.state.toasts.is_empty()
|| !self.state.running().is_empty() || !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self || self
.watch_until .watch_until
.is_some_and(|deadline| Instant::now() < deadline) .is_some_and(|deadline| Instant::now() < deadline)
} }
} }
#[cfg(test)]
mod tests {
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
//!
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
//!
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
use super::*;
use crate::app::components::motion::{Slot, FRAME_MS};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
///
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
fn idle_app() -> App {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
///
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
#[test]
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
let mut app = idle_app();
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(
app.watch_until.is_none(),
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
);
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
assert!(
app.needs_frames(),
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
);
run_until_idle();
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
assert!(
!app.needs_frames(),
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
);
motion::reset();
}
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
#[test]
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
let mut app = idle_app();
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
run_until_idle();
assert!(!app.needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
run_until_idle();
assert!(!app.needs_frames());
motion::reset();
}
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
#[test]
fn pages_that_render_elsewhere_do_not_start_a_track() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
assert!(
!motion::needs_frames(),
"账户页没有 pages 树可动,不许白开帧"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
run_until_idle();
motion::reset();
}
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
#[test]
fn without_a_window_nothing_animates() {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
let _ = app.update(Message::Boot);
app.watch_until = None;
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
run_until_idle();
motion::reset();
}
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
///
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
#[test]
fn no_scale_requests_no_frames_at_all() {
motion::reset();
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
motion::configure(MotionScale::NONE, None);
// 三种状态变化:换页 / 换详情 / 再换回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(!app.needs_frames());
run_until_idle();
assert!(!motion::needs_frames());
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
motion::reset();
}
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
///
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
#[test]
fn there_is_exactly_one_frame_subscription() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rust_files(&src, &mut files);
assert!(
files.len() >= 40,
"只扫到 {} 个文件:扫描逻辑坏了",
files.len()
);
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join(
"
",
);
let count = code.matches("window::frames()").count();
if count > 0 {
let name = path
.strip_prefix(&src)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
hits.push((name, count));
}
}
hits.sort();
let expected = [
("app/subscription.rs".to_string(), 1_usize),
("pages/harness.rs".to_string(), 1_usize),
];
assert_eq!(
hits, expected,
"`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\
与 `pages/harness.rs`(独立 example 的自有 app),且各一处"
);
}
/// 递归收集 `dir/**/*.rs`。
fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("读目录") {
let path = entry.expect("目录项").path();
if path.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
#[test]
fn slots_are_the_three_documented_ones() {
assert_eq!(Slot::ALL.len(), 3);
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
}
}
+11 -7
View File
@@ -12,6 +12,8 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)] #[cfg(test)]
mod appearance; mod appearance;
#[cfg(test)] #[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter; mod presenter;
use super::message::Message; use super::message::Message;
@@ -52,11 +54,11 @@ fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = headless(Options::default(), SchemeFlavor::Light); let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state.navigate(Route::Logs); app.state.navigate(Route::Logs);
app.refresh_view(); app.refresh_view();
assert_eq!(app.ui().name(), "ull-m3-light"); assert_eq!(app.ui().name(), "ull-silver");
app.set_flavor(SchemeFlavor::Dark); app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名"); assert_eq!(app.ui().name(), "ull-ink", "切主题要换主题名");
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态"); assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由"); assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数"); assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
@@ -76,7 +78,7 @@ fn screenshot_mode_never_waits_for_the_system_theme() {
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light)); assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
let app = headless(options, SchemeFlavor::Light); let app = headless(options, SchemeFlavor::Light);
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light"); assert!(app.has_screenshot() && app.ui().name() == "ull-silver");
} }
#[test] #[test]
@@ -334,7 +336,7 @@ fn the_page_navigation_entry_also_detaches() {
fn screenshot_mode_ignores_the_settings_file_theme() { fn screenshot_mode_ignores_the_settings_file_theme() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light); let mut app = headless(screenshot_options(), SchemeFlavor::Light);
app.apply_config_theme(); app.apply_config_theme();
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行"); assert_eq!(app.ui().name(), "ull-silver", "截图模式的主题只认命令行");
} }
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。 /// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
@@ -353,7 +355,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let mut auto = headless(Options::default(), SchemeFlavor::Light); let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto); assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark)); let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统"); assert_eq!(auto.ui().name(), "ull-ink", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改 // ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark); let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
@@ -361,7 +363,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light)); let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!( assert_eq!(
pinned.ui().name(), pinned.ui().name(),
"ull-m3-dark", "ull-ink",
"配置文件说 dark,系统说 light:不许改" "配置文件说 dark,系统说 light:不许改"
); );
@@ -376,7 +378,9 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light)); let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!( assert_eq!(
explicit.ui().name(), explicit.ui().name(),
"ull-m3-dark", "ull-ink",
"--theme 优先级最高,系统主题不许盖" "--theme 优先级最高,系统主题不许盖"
); );
} }
mod theme;
+3 -3
View File
@@ -181,7 +181,7 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() {
"取色成功后 Ui 要换成解析出的方案" "取色成功后 Ui 要换成解析出的方案"
); );
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。 // 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
assert_eq!(app.ui().name(), "ull-m3-light"); assert_eq!(app.ui().name(), "ull-silver");
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES); let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
assert_ne!( assert_ne!(
app.ui().color(unlucky_design::color::Role::Primary), app.ui().color(unlucky_design::color::Role::Primary),
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings); assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP); assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
drop(_tree); drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。 // 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0)); app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0); assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
} }
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。 /// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
@@ -0,0 +1,145 @@
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
//!
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
//!
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
LoaderInfo, LoaderKind,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::message::Message;
use crate::app::state::{App, TICK};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
#[derive(Debug, Default)]
struct FailingSaveBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
}
impl Backend for FailingSaveBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command.clone());
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
self.queued.push(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Error,
text: "内存超出范围".to_string(),
remaining_ms: 0,
}));
}
}
}
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
fn snapshot() -> Vec<InstanceSummary> {
vec![InstanceSummary {
id: "survival".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: LaunchArgsView {
instance_id: "survival".to_string(),
memory_mb: Some(4096),
memory_effective_mb: 4096,
..LaunchArgsView::default()
},
}]
}
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let backend = FailingSaveBackend {
dispatched: Rc::clone(&dispatched),
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
};
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
///
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
/// 下面的前置断言会红)。
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
#[test]
fn failed_save_keeps_the_draft_through_the_whole_loop() {
let (mut app, dispatched) = app_with_snapshot();
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
app.advance(TICK);
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
assert!(!app.launch_form.dirty);
// 用户把内存改成非法值(host 会拒),然后点保存。
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Memory("4g".to_string()),
)));
assert_eq!(app.launch_form.memory, "4g");
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Save,
)));
assert!(
dispatched
.borrow()
.iter()
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
"保存要把命令派发出去"
);
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
app.advance(TICK);
assert_eq!(
app.launch_form.memory, "4g",
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
);
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
}
+187
View File
@@ -0,0 +1,187 @@
//! 主题三档(Ink / Silver / 跟随系统)的落点测试(M229;从 `app/tests.rs` 拆出守 400 行)。
//!
//! 这一份只管**用户点设置页那三枚 chip 之后**发生的事:换色板、同步设置组状态、落盘。
//! `Auto`(跟随系统)那条路在 `app::tests::the_system_theme_never_overrides_an_explicit_one`。
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一个把配置写在临时文件里的 App:测「落盘」必须真的过文件(内存桥读回来永远是出厂值)。
fn with_config() -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("ull-theme-m229-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, "{}").expect("写配置文件");
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
/// M229:设置页「通用 → 主题」三档(Ink / Silver / 跟随系统)真的换色板、真的落盘。
///
/// 抓得住:①`pages::Message::Theme` 没接进 `on_page`(点了没反应)②接了但不换 `Ui`
/// (只有选中态变了,界面颜色还是旧的)③同步设置组状态漏了(设置页首帧/刷新后画错档)
/// ④落盘漏了(重启回旧值——这里**真的过文件**:`with_config` 用 `SettingsBridge::with_path` 写临时
/// 配置,再用同一路径重新 `load()` 读回;内存桥读回来永远是出厂值,验不了落盘)。
/// 抓不住:色板好不好看、像素级差异(那要截图,见 `DevDocs/artifacts/m229/`)。
#[test]
fn the_theme_message_switches_the_board_and_persists_it() {
let (mut app, dir) = with_config();
assert_eq!(app.board_name(), "silver", "首帧是浅色主方案(Silver)");
// 选 Ink(= 深色主方案)
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Dark)));
assert_eq!(app.theme_mode(), ThemeMode::Dark);
assert_eq!(app.board_name(), "ink", "Ink 深板要真切过去");
assert_eq!(app.ui().flavor(), SchemeFlavor::Dark);
assert_eq!(
app.settings.language_appearance.theme,
ThemeMode::Dark,
"设置页那一行的档位要跟着改"
);
// 落盘:真的过了文件(重新打开同一个配置读回来就是 dark)
assert_eq!(
SettingsBridge::with_path(dir.join("config/launcher.json"))
.load()
.1,
ThemeMode::Dark,
"主题档要落盘"
);
// 选 Silver(= 浅色主方案)
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
assert_eq!(app.board_name(), "silver");
assert_eq!(app.ui().flavor(), SchemeFlavor::Light);
assert_eq!(
SettingsBridge::with_path(dir.join("config/launcher.json"))
.load()
.1,
ThemeMode::Light
);
// 同一档再点一次:不许白重建
let refreshes = app.refreshes;
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型");
std::fs::remove_dir_all(&dir).ok();
}
/// **截图通道把主题档钉死在 `--screenshot-theme`**,不读机器上的 `launcher.json`(D30 可复现)。
///
/// 复审 r2 P2-a 指出:`construct.rs` 里新加的这条分支没有回归测试——于是「哪天有人把它改回
/// `bridge.load().1`」不会有人发现,两次不同配置的机器又会出不同颜色的图。
///
/// 判据:**同一份截图参数 + 两份不同的机器配置 ⇒ 同一个档位**,且等于参数(浅→`Light`、深→`Dark`);
/// 反证:非截图通道下同样两份配置**确实**分出两个档(否则这条测试等于没测到分支)。
#[test]
fn the_screenshot_channel_pins_the_theme_mode_instead_of_reading_the_file() {
use std::path::{Path, PathBuf};
/// 写一份只含 `appearance.theme_mode` 的临时配置,并用它构造 App。
fn with_mode(
dir_name: &str,
json: &str,
options: Options,
flavor: SchemeFlavor,
) -> (App, PathBuf) {
let dir =
std::env::temp_dir().join(format!("ull-shot-theme-{dir_name}-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, json).expect("写配置文件");
let app = App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
fn cleanup(dir: &Path) {
std::fs::remove_dir_all(dir).ok();
}
let dark_file = r#"{"appearance":{"theme_mode":"dark"}}"#;
let light_file = r#"{"appearance":{"theme_mode":"light"}}"#;
// ① 截图参数给浅色:机器配置是 dark 也一样出 Light(钉死值赢)
let shot_light = Options {
screenshot: Some(PathBuf::from("unused.png")),
screenshot_theme: Some(SchemeFlavor::Light),
..Options::default()
};
let (from_dark_file, d1) = with_mode(
"light-a",
dark_file,
shot_light.clone(),
SchemeFlavor::Light,
);
let (from_light_file, d2) = with_mode("light-b", light_file, shot_light, SchemeFlavor::Light);
assert_eq!(
from_dark_file.theme_mode(),
ThemeMode::Light,
"截图模式下档位必须由 --screenshot-theme 决定,不许读文件里的 dark"
);
assert_eq!(
from_dark_file.theme_mode(),
from_light_file.theme_mode(),
"两份机器配置必须出同一个档位"
);
assert_eq!(from_dark_file.board_name(), "silver");
assert_eq!(from_light_file.board_name(), "silver");
// ② 反方向:截图参数给深色 ⇒ 即使文件写 light 也是 Dark/Ink
let shot_dark = Options {
screenshot: Some(PathBuf::from("unused.png")),
screenshot_theme: Some(SchemeFlavor::Dark),
..Options::default()
};
let (from_light_file_dark, d3) = with_mode("dark-a", light_file, shot_dark, SchemeFlavor::Dark);
assert_eq!(from_light_file_dark.theme_mode(), ThemeMode::Dark);
assert_eq!(from_light_file_dark.board_name(), "ink");
// ③ 反证:非截图通道下,同样的两份配置**确实**分出两个档(证明这条分支真的存在)
let (interactive_dark, d4) =
with_mode("plain-a", dark_file, Options::default(), SchemeFlavor::Dark);
let (interactive_light, d5) = with_mode(
"plain-b",
light_file,
Options::default(),
SchemeFlavor::Light,
);
assert_eq!(
interactive_dark.theme_mode(),
ThemeMode::Dark,
"产品路径要读文件"
);
assert_eq!(
interactive_light.theme_mode(),
ThemeMode::Light,
"产品路径要读文件"
);
for dir in [d1, d2, d3, d4, d5] {
cleanup(&dir);
}
}
+106 -12
View File
@@ -20,11 +20,15 @@
//! //!
//! | 场景 | 时钟 | 周期 | 谁触发 | //! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---| //! |---|---|---|---|
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | //! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] | //! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
//! //!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
//! 下一拍就退回心跳(见 `components::motion`)。
use crate::app::components::motion;
use crate::app::diag::diag;
use std::time::Instant; use std::time::Instant;
use iced::{window, Task}; use iced::{window, Task};
@@ -42,18 +46,48 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME; use super::CAPTURE_AT_FRAME;
mod account; mod account;
// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回)
mod adopt;
mod agent; mod agent;
mod appearance; mod appearance;
mod boot_env; pub(super) mod boot_env;
mod clock; mod clock;
mod content; mod content;
mod content_pack;
mod dialog;
mod dialog_fixture;
mod download; mod download;
mod instances;
mod motion_sync;
mod page_messages; mod page_messages;
mod settings; mod settings;
mod update_action;
/// 这一条键盘事件是不是 `Esc` 按下。
fn is_escape(event: &iced::keyboard::Event) -> bool {
matches!(
event,
iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}
)
}
impl App { impl App {
/// 一条消息进来,改状态并返回后续任务。 /// 一条消息进来,改状态并返回后续任务。
///
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
pub(super) fn update(&mut self, message: Message) -> Task<Message> { pub(super) fn update(&mut self, message: Message) -> Task<Message> {
let task = self.route_message(message);
self.sync_motion();
task
}
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
fn route_message(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::Boot => self.on_boot(), Message::Boot => self.on_boot(),
Message::WindowReady(id) => { Message::WindowReady(id) => {
@@ -72,7 +106,7 @@ impl App {
match hwnd { match hwnd {
Some(_) => self.apply_window_alpha("boot"), Some(_) => self.apply_window_alpha("boot"),
None => { None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None); self.log_appearance("boot", None);
} }
} }
@@ -86,7 +120,7 @@ impl App {
iced::theme::Mode::Dark => SchemeFlavor::Dark, iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light, iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
}; };
eprintln!("[theme] 系统主题 = {mode:?}"); diag!("[theme] 系统主题 = {mode:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。 // 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现 // 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。 // `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
@@ -116,7 +150,18 @@ impl App {
Task::none() Task::none()
} }
Message::Page(message) => self.on_page(message), Message::Page(message) => self.on_page(message),
Message::Agent(message) => self.on_agent(message), Message::Dialog(message) => dialog::on_dialog(self, message),
// M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭)
Message::Adopt(message) => self.on_adopt(message),
Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message {
if is_escape(event) && dialog::escape(self) {
return Task::none();
}
}
self.on_agent(message)
}
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => { Message::Select(nav) => {
self.state.navigate(route_of(nav)); self.state.navigate(route_of(nav));
@@ -144,7 +189,7 @@ impl App {
Message::Captured(screenshot) => self.on_captured(&screenshot), Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => { Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收) // gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
eprintln!("[gallery] {} pressed", message.id()); diag!("[gallery] {} pressed", message.id());
Task::none() Task::none()
} }
} }
@@ -157,12 +202,16 @@ impl App {
fn on_boot(&mut self) -> Task<Message> { fn on_boot(&mut self) -> Task<Message> {
let mut tasks = vec![window::latest().map(Message::WindowReady)]; let mut tasks = vec![window::latest().map(Message::WindowReady)];
// M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。
// 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。
self.configure_motion();
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。 // M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络; // 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面, // 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨 // 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
// 「没配置」与「配了但登录失败」。 // 「没配置」与「配了但登录失败」。
eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line()); diag!("{}", unlucky_cli::commands::msa_client_id_log_line());
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
@@ -173,6 +222,8 @@ impl App {
self.appearance.apply_env(); self.appearance.apply_env();
self.rebuild_ui(); self.rebuild_ui();
self.log_appearance("boot", None); self.log_appearance("boot", None);
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
boot_env::record_boot_log(self);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() { if self.options.wants_system_theme() {
@@ -191,7 +242,7 @@ impl App {
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope, // 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。 // 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = boot_env::env_start_route() { if let Some(route) = boot_env::env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}"); diag!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route); self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。 // 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true; self.auto_selected = true;
@@ -199,6 +250,10 @@ impl App {
self.ensure_content_loaded(); self.ensure_content_loaded();
} }
account::force_account_state(self); account::force_account_state(self);
// M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。
if let Some(mode) = dialog_fixture::apply(self) {
diag!("[boot] ULL_DIALOG_FIXTURE → {mode}");
}
self.auto_select(); self.auto_select();
self.view_dirty = true; self.view_dirty = true;
@@ -210,27 +265,66 @@ impl App {
Task::batch(tasks) Task::batch(tasks)
} }
/// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。
fn configure_motion(&mut self) {
let pin = boot_env::env_motion_pin();
let gpu = matches!(
super::backend::effective_kind(),
Some(crate::renderer::Kind::Wgpu)
);
let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some());
motion::configure(scale, pin);
// 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。
diag!(
"[motion] scale={:.2} pin={:?} gpu={} screenshot={}",
motion::scale().factor(),
pin,
gpu,
self.options.screenshot.is_some()
);
}
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。 /// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
/// ///
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> { fn on_heartbeat(&mut self) -> Task<Message> {
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
// 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把
// 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」
// 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。
motion::advance();
if !self.needs_frames() { if !self.needs_frames() {
let now = Instant::now(); let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK); let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
self.last_tick = now; self.last_tick = now;
self.advance(elapsed); self.advance(elapsed);
} }
// M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒
// 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。
self.motion_fixture_after_idle();
heartbeat() heartbeat()
} }
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。 /// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
/// [`CAPTURE_AT_FRAME`] 帧出图。
/// ///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。 /// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> { fn on_frame(&mut self) -> Task<Message> {
self.frames += 1; self.frames += 1;
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
motion::advance();
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
self.motion_fixture();
}
let now = Instant::now(); let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick); let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK { if elapsed >= TICK {
@@ -256,9 +350,9 @@ impl App {
}; };
match crate::shot::save(screenshot, &path) { match crate::shot::save(screenshot, &path) {
Ok(summary) => { Ok(summary) => {
eprintln!("{}", summary.describe()); diag!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。 // M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
eprintln!( diag!(
"[shot] screen={} theme={} renderer={} route={:?}", "[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(), self.options.screen.name(),
crate::shot::flavor_slug(&self.ui), crate::shot::flavor_slug(&self.ui),
@@ -266,7 +360,7 @@ impl App {
self.state.route() self.state.route()
); );
} }
Err(error) => eprintln!("[shot] 失败:{error}"), Err(error) => diag!("[shot] 失败:{error}"),
} }
iced::exit() iced::exit()
+25 -2
View File
@@ -95,7 +95,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request)); self.dispatch(AppCommand::AddAuthlibAccount(request));
} }
} }
Panel::Microsoft => return self.on_account(Account::StartBrowser), Panel::Microsoft => return self.on_account(Account::StartDevice),
Panel::None => {} Panel::None => {}
}, },
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -136,7 +136,8 @@ impl App {
match account.kind { match account.kind {
AccountKind::Microsoft => { AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft; self.account_form.panel = Panel::Microsoft;
self.dispatch(AppCommand::StartBrowserLogin); // M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
self.dispatch(AppCommand::StartDeviceLogin);
} }
AccountKind::Authlib => { AccountKind::Authlib => {
self.account_form.open(Track::Authlib); self.account_form.open(Track::Authlib);
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
app.state.device_login_started = app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600)); Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
} }
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
//
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
"msa-fallback" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
app.state.apply_device_login(
unlucky_presenter::dto::DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
)
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
);
}
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)` // 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => { "authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib; app.account_form.panel = crate::pages::account::Panel::Authlib;
@@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
"closed" => { "closed" => {
let _ = app.on_account(crate::pages::account::Message::Close); let _ = app.on_account(crate::pages::account::Message::Close);
} }
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
"msa-entry" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态) // 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => { "add" => {
app.account_form.section_open = true; app.account_form.section_open = true;
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
let _ = app.on_account(Account::StartDevice); let _ = app.on_account(Account::StartDevice);
match &app.account.device { match &app.account.device {
DevicePanel::Code { DevicePanel::Code {
code, remaining, .. code, countdown, ..
} => { } => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
} }
other => panic!("进行中状态必须可见,实际 {other:?}"), other => panic!("进行中状态必须可见,实际 {other:?}"),
} }
+358
View File
@@ -0,0 +1,358 @@
//! 「导入已有版本」向导的落点(M223 GUIADOPT)。
//!
//! ## 四段各自发什么、等什么
//!
//! | 用户动作 | 发什么 | 等什么回程 |
//! |---|---|---|
//! | 页头「导入」/ [`AdoptMessage::Open`] | `AdoptCommand::Scan` | `AdoptEvent::Scan` |
//! | 选目标 + 点「预检」/ [`AdoptMessage::Check`] | `AdoptCommand::Preview` | `Planned` / `Failed` |
//! | 点「确认导入」/ [`AdoptMessage::Confirm`] | `AdoptCommand::Confirm` | `Done` / `Failed` |
//! | 「返回」/ [`AdoptMessage::Back`] | `AdoptCommand::Scan`(重扫一次,顺手清掉预检/失败) | `Scan` |
//! | 「关闭」/ [`AdoptMessage::Close`] | `AdoptCommand::Dismiss` | `Cleared` |
//!
//! ## 三段纪律
//!
//! 1. **不在本地判「能不能导入」**:候选可不可用、落点在哪、有哪些问题全部来自 presenter 的回程
//! (`AdoptState`)。这里只把「用户点了什么」翻成命令;
//! 2. **确认只发用户填过的那份请求**(版本 + 名字 + 目标档),预检与确认**同一份**——
//! 于是「预检 A、写 B」这种错位不可能发生;
//! 3. **在等回程时把主按钮并掉**(`Form::busy`):连点两次「确认」不会写两条实例记录。
use iced::Task;
use unlucky_presenter::dto::{AdoptCommand, AdoptRequestView, AppCommand};
use crate::app::adopt::{AdoptMessage, Busy};
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 向导的一条消息。
pub(super) fn on_adopt(&mut self, message: AdoptMessage) -> Task<Message> {
match message {
AdoptMessage::Open => {
self.adopt_form.open();
self.dispatch_adopt(AdoptCommand::Scan);
}
AdoptMessage::Close => {
// 登记在飞时不吃「关闭」:core 那条线程已经开跑了,写了盘却不回报 Done
// 就会出现「实例已存在但界面什么都没说」——等它交卷(几十毫秒级)。
if self.adopt_form.busy == Busy::Importing {
return Task::none();
}
self.adopt_form.close();
self.dispatch_adopt(AdoptCommand::Dismiss);
}
AdoptMessage::Select(version_id) => {
self.adopt_form.selected = Some(version_id);
}
AdoptMessage::Name(name) => self.adopt_form.name = name,
AdoptMessage::Target(target) => {
// 登记在飞时不吃「换档」:它会派一条零写盘的预览把 `adopt_epoch` +1,
// 于是在飞的 Confirm 交卷时 Done 被代次丢掉——实例写进去了,界面什么都没说。
// 连本地档位也不改:界面上显示 A 档、盘上写的是 B 档,是最糟的一种不一致。
if self.adopt_form.busy == Busy::Importing {
return Task::none();
}
self.adopt_form.target = target;
// 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着)
self.adopt_form.checked = false;
// 共享档 / 版本目录档会写进**别人的目录**:一被点选就把后果原文拿出来
// (零写盘的预检,见 `Form::stage` 的收窄:这一次结果落在 Pick 段当提示读)。
if target.touches_foreign_dir() {
if let Some(request) = self.adopt_request() {
self.adopt_form.busy = Busy::Checking;
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
}
}
}
AdoptMessage::Check => {
if let Some(request) = self.adopt_request() {
self.adopt_form.checked = true;
self.adopt_form.busy = Busy::Checking;
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
}
}
AdoptMessage::Confirm => {
if let Some(request) = self.adopt_request() {
self.adopt_form.busy = Busy::Importing;
self.dispatch_adopt(AdoptCommand::Confirm(Box::new(request)));
}
}
AdoptMessage::Back => {
// 同 Close / Target:登记在飞时「返回」会把 `adopt_epoch` +1(它派 Scan),
// 在飞 Confirm 的 Done 就再也到不了界面。
if self.adopt_form.busy == Busy::Importing {
return Task::none();
}
// 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废,
// 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。
self.adopt_form.checked = false;
self.adopt_form.busy = Busy::Scanning;
self.dispatch_adopt(AdoptCommand::Scan);
}
}
Task::none()
}
/// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。
/// 出图通道的种子(`ULL_ADOPT_STATE`,与 `ULL_JAVA_STATE` / `ULL_AI_STATE` 同一族)。
///
/// **只在 `--screenshot` 下生效**:交互模式读这个环境变量没有意义(也没有人会设它)。
/// 不认识的名字一律忽略,退回正常启动路径(向导保持关闭)。
pub(in crate::app) fn hydrate_adopt_state(&mut self) {
if self.options.screenshot.is_none() {
return;
}
let Ok(raw) = std::env::var(crate::app::adopt::shot::ENV) else {
return;
};
if let Some((form, state)) = crate::app::adopt::shot::seed(&raw) {
self.adopt_form = form;
self.state.adopt = state;
}
}
pub(super) fn settle_adopt(&mut self) {
self.adopt_form.settled();
}
/// 当前表单能拼出的请求(没选版本就没有请求——按钮在那边也是禁用的)。
fn adopt_request(&self) -> Option<AdoptRequestView> {
let version_id = self.adopt_form.selected.clone()?;
let name = self.adopt_form.name.trim();
Some(AdoptRequestView {
version_id,
name: (!name.is_empty()).then(|| name.to_string()),
target: self.adopt_form.target,
})
}
fn dispatch_adopt(&mut self, command: AdoptCommand) {
self.dispatch(AppCommand::Adopt(Box::new(command)));
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AdoptCommand, AdoptEvent, AdoptTargetView, AppCommand, AppEvent, Backend,
};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::adopt::{AdoptMessage, Busy};
use crate::app::state::App;
use crate::cli::Options;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 记账后端:把 `dispatch` 收到的命令留在台账里(`MockBackend` 只认自己那几条,
/// 「有没有派出去」根本看不见——而 P2-1 的判据正是「不许派」)。
#[derive(Debug, Default)]
struct RecordingBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
}
impl Backend for RecordingBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 带台账的 App(返回台账句柄,测试直接查「派了什么」)。
fn recording() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(RecordingBackend {
dispatched: Rc::clone(&dispatched),
}),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// 台账里有没有派过某条 adopt 命令(只认命令种类,不看载荷内容)。
///
/// 写法是 `let ... else` + `matches!`:内层用 `match` 会被 clippy 判成
/// `match expression looks like matches! macro`(deny 级,CI 的 `-D warnings` 直接红)。
fn dispatched_adopt(ledger: &Rc<RefCell<Vec<AppCommand>>>, want: &str) -> bool {
ledger.borrow().iter().any(|command| {
let AppCommand::Adopt(command) = command else {
return false;
};
matches!(
(&**command, want),
(AdoptCommand::Scan, "scan")
| (AdoptCommand::Preview(_), "preview")
| (AdoptCommand::Confirm(_), "confirm")
| (AdoptCommand::Dismiss, "dismiss")
)
})
}
/// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。
///
/// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。
/// 抓不住:扫描真的返回了什么(那在 presenter 的 `host/adopt` 用例里)。
#[test]
fn opening_the_wizard_asks_for_a_scan() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
assert!(app.adopt_form.open);
assert_eq!(app.adopt_form.busy, Busy::Scanning);
assert_eq!(
app.adopt_form.target,
AdoptTargetView::InstanceDir,
"缺省不是 isolated"
);
}
/// 没选版本时「预检 / 确认」不派命令(否则会发出一条 `version_id` 为空的请求)。
///
/// 抓得住:`adopt_request()` 在没有选中项时仍拼出请求(空版本 id 打到 core 换一句
/// `adopt_version_not_found`,用户看到莫名其妙的失败)。
#[test]
fn no_request_without_a_selected_version() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Check);
assert_eq!(app.adopt_form.busy, Busy::Scanning, "Check 不该改等待态");
let _ = app.on_adopt(AdoptMessage::Confirm);
assert_eq!(app.adopt_form.busy, Busy::Scanning);
}
/// 选中 + 预检 = 一条 Preview 命令,且请求里的名字是**去过空白**的用户输入。
#[test]
fn check_sends_the_form_as_a_preview_request() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Name(" 生存 ".to_string()));
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
let _ = app.on_adopt(AdoptMessage::Check);
assert_eq!(app.adopt_form.busy, Busy::Checking);
// 请求本体不在 App 上留副本:能证明意图的只有等待态与目标档
assert_eq!(app.adopt_form.target, AdoptTargetView::Shared);
}
/// 回程到了就解除等待(`settle_adopt` 由 `pump` 调)。
#[test]
fn an_incoming_event_settles_the_wait() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
assert!(app.adopt_form.busy.waiting());
app.settle_adopt();
assert_eq!(app.adopt_form.busy, Busy::Idle);
// 事件真相进了 presenter 的状态(界面从它取词)
app.state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)),
));
assert!(app.state.adopt.scan.is_none());
}
/// 共享档一被点选就发一条**预检**(零写盘),但**不**置 `checked`——确认页要用户点「预检」。
///
/// 抓得住:`Target` 分支忘了发预检(后果原文永远不出现,契约 §5.2 失守)、
/// 或者顺手把 `checked` 也置上(点一下档位就跳到确认页)。
#[test]
fn choosing_a_foreign_target_previews_but_waits_for_the_user() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
assert_eq!(app.adopt_form.busy, Busy::Checking);
assert!(!app.adopt_form.checked, "确认页要等用户点「预检」");
// 缺省档不触发自动预检(它不会写进别人的目录)
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir));
assert!(!app.adopt_form.checked);
}
/// 登记在飞时「关闭」被忽略:core 已经在写了,关掉面板只会让 Done 无处可去。
///
/// 抓得住:Close 无条件清表单并 `Dismiss`(实例写成功了,界面却什么都没说)。
#[test]
fn closing_is_ignored_while_the_registration_is_in_flight() {
let mut app = headless();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Check);
app.adopt_form.busy = Busy::Importing;
let _ = app.on_adopt(AdoptMessage::Close);
assert!(app.adopt_form.open, "登记在飞时不许把面板关掉");
}
/// **P2-1**:登记在飞时,除了 Close,「返回」与「换目标档」也必须早退。
///
/// 三条都是同一条病:它们都会派一条新命令把 `adopt_epoch` +1,于是在飞 Confirm 的
/// `Done` 回来时被代次丢掉 —— 实例**写进去了**,界面却什么都没说(Close 那条早就守住了,
/// 唯独这两支漏了)。
///
/// 抓得住:`Back` 派了 `Scan`(旧 `Done` 被丢)、`Target` 派了 `Preview`(同上),
/// 以及「守卫写了但写在改本地状态之后」(表单被改成新档位,而盘上写的是旧档位)。
/// 抓不住:host 侧代次比较的真实时序(那在 presenter 的 `host/adopt` 那层)。
#[test]
fn back_and_target_are_ignored_while_the_registration_is_in_flight() {
let (mut app, ledger) = recording();
let _ = app.on_adopt(AdoptMessage::Open);
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir));
// 走到「确认」那一步:真的派一条 Confirm 出去
let _ = app.on_adopt(AdoptMessage::Check);
let _ = app.on_adopt(AdoptMessage::Confirm);
assert!(
dispatched_adopt(&ledger, "confirm"),
"前提:确认要真的派出去"
);
assert_eq!(app.adopt_form.busy, Busy::Importing);
ledger.borrow_mut().clear();
// 在飞期间点「返回」:不许派 Scan
let _ = app.on_adopt(AdoptMessage::Back);
assert!(
!dispatched_adopt(&ledger, "scan"),
"登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)"
);
assert_eq!(app.adopt_form.busy, Busy::Importing, "等待态不许被改掉");
// 在飞期间换目标档:不许派 Preview,也不许改本地档位
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
assert!(
!dispatched_adopt(&ledger, "preview"),
"登记在飞时换档不许派 Preview(会把 Done 的代次顶掉)"
);
assert_eq!(
app.adopt_form.target,
AdoptTargetView::InstanceDir,
"挡下来的点击不该改出界面与盘上不一致的档位"
);
assert_eq!(app.adopt_form.busy, Busy::Importing);
}
}
+6 -5
View File
@@ -29,6 +29,7 @@
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻 //! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。 //! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
use crate::app::diag::diag;
use iced::keyboard::{key, Key}; use iced::keyboard::{key, Key};
use iced::Task; use iced::Task;
@@ -141,10 +142,10 @@ impl App {
self.drain_agent(); self.drain_agent();
} }
SubmitOutcome::Failed(failure) => { SubmitOutcome::Failed(failure) => {
eprintln!("[agent] 提交失败({})", failure.code()); diag!("[agent] 提交失败({})", failure.code());
} }
SubmitOutcome::Rejected => { SubmitOutcome::Rejected => {
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住"); diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
} }
} }
} }
@@ -204,7 +205,7 @@ impl App {
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm { if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow; self.agent.focus = Focus::ConfirmAllow;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)"); diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm { } else if !pending && on_confirm {
self.agent.focus = Focus::Composer; self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false; self.agent.keyboard_mode = false;
@@ -279,7 +280,7 @@ impl App {
} }
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent { if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
eprintln!("[agent] rail=detached → Route::Agent"); diag!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent); self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。 // 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true; self.auto_selected = true;
@@ -371,7 +372,7 @@ impl App {
let _ = self.on_agent(message); let _ = self.on_agent(message);
} else { } else {
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。 // 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
eprintln!( diag!(
"[agent] Enter on focus={} handled by the composer", "[agent] Enter on focus={} handled by the composer",
self.agent.focus.name() self.agent.focus.name()
); );
+2 -1
View File
@@ -28,6 +28,7 @@
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 //! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
//! (M116 取消了 8/12/16 固定档)。 //! (M116 取消了 8/12/16 固定档)。
use crate::app::diag::diag;
use crate::platform; use crate::platform;
use crate::platform::win32::corner::{self, CornerApplied}; use crate::platform::win32::corner::{self, CornerApplied};
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome}; use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
@@ -73,7 +74,7 @@ impl App {
"frame=? layered=?".to_owned(), "frame=? layered=?".to_owned(),
), ),
}; };
eprintln!( diag!(
"{}", "{}",
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by) corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
); );
+149 -2
View File
@@ -1,8 +1,11 @@
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。 //! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。
//! //!
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线): //! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
//! **纯函数、无状态**,只是取值表长到了七条。 //! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组
//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处,
//! 帧泵自己不读环境变量。
use unlucky_design::motion::MotionScale;
use unlucky_presenter::router::Route; use unlucky_presenter::router::Route;
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。 /// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
@@ -24,3 +27,147 @@ pub(super) fn env_start_route() -> Option<Route> {
_ => None, _ => None,
} }
} }
/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。
///
/// ## 为什么档位在这里算
///
/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端
/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。
/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略
/// 只有一处,测试也不必摆环境变量。
///
/// | 条件 | 档位 | 为什么 |
/// |---|---|---|
/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) |
/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) |
/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 |
/// | 其余(`wgpu`) | `STANDARD` | —— |
/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) |
///
/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up),
/// 所以本函数只认上面这些来源。
pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale {
match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) {
Some("off") | Some("none") => return MotionScale::NONE,
Some("half") => return MotionScale::HALF,
Some("full") => return MotionScale::STANDARD,
_ => {}
}
if pinned {
// 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。
return MotionScale::STANDARD;
}
if screenshot {
return MotionScale::NONE;
}
if gpu {
MotionScale::STANDARD
} else {
MotionScale::HALF
}
}
/// `ULL_MOTION_AT=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
///
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
pub(super) fn env_motion_pin() -> Option<f64> {
std::env::var("ULL_MOTION_AT")
.ok()?
.trim()
.parse::<f64>()
.ok()
.filter(|ms| ms.is_finite() && *ms >= 0.0)
}
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
///
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
#[derive(Debug, Clone)]
pub(super) enum MotionFixture {
/// 跳到一个一级页(走「换页」那一档)。
Route(Route),
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
Detail,
}
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() {
"instances" => MotionFixture::Route(Route::Instances),
"download" => MotionFixture::Route(Route::Download),
"logs" => MotionFixture::Route(Route::Logs),
"settings" => MotionFixture::Route(Route::Settings),
"content" => MotionFixture::Route(Route::Content),
"detail" => MotionFixture::Detail,
_ => return None,
})
}
/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。
///
/// ## 为什么需要它(真机空闲取证)
///
/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话
/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与
/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路:
/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。
///
/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()`
/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。
pub(super) fn env_motion_fixture_at() -> Option<u32> {
std::env::var("ULL_MOTION_FIXTURE_AT")
.ok()?
.trim()
.parse::<u32>()
.ok()
.filter(|beats| *beats > 0)
}
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
///
/// ## 为什么这一拍只能「先记、后落」
///
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
///
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
///
/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走
/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」
/// 的进程也会留下 `launcher.log`。
pub(crate) fn record_boot_log(app: &super::super::state::App) {
use unlucky_core::launch::launcher_log;
launcher_log::record("boot", "app boot");
launcher_log::record(
"boot",
&format!(
"theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}",
app.ui().name(),
app.lang(),
app.state.route(),
app.width,
app.height,
app.has_screenshot(),
app.state.instances.len(),
),
);
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
// 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到
// stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。
launcher_log::record(
"boot",
&format!(
"backend request={} effective={} renderer_fallback={}",
app.options.backend.name(),
super::super::backend::effective_backend(app.options.backend).name(),
std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()),
),
);
}
+45 -4
View File
@@ -17,6 +17,7 @@
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干, //! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。 //! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
use crate::app::diag::diag;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use unlucky_presenter::dto::AppCommand; use unlucky_presenter::dto::AppCommand;
@@ -42,6 +43,16 @@ impl App {
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true; self.view_dirty = true;
} }
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
// 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
let font_status = crate::fonts::install::status();
if font_status != self.font_seen {
self.font_seen = font_status;
self.view_dirty = true;
}
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
self.hydrate_java_scan();
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s, // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -64,10 +75,14 @@ impl App {
let reloaded = let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some(); !self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded { if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观"); diag!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme(); self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance(); self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
} }
if self.view_dirty { if self.view_dirty {
@@ -78,8 +93,8 @@ impl App {
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)—— // 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!( diag!(
"[tick] n={} frames={} refreshes={} clock={}", "[tick] n={} frames={} refreshes={} clock={} motion={}",
self.ticks, self.ticks,
self.frames, self.frames,
self.refreshes, self.refreshes,
@@ -87,7 +102,10 @@ impl App {
"frames" "frames"
} else { } else {
"idle-1hz" "idle-1hz"
} },
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
// 也是「动画期间重绘成本」的记账口径,见报告)
crate::app::components::motion::samples()
); );
} }
} }
@@ -111,12 +129,35 @@ 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;
}
// M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除),
// 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。
let settled_adopt = matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_))
);
self.state.apply(event); self.state.apply(event);
if settled_adopt {
self.settle_adopt();
}
} }
self.view_dirty = true; self.view_dirty = true;
self.auto_select(); self.auto_select();
self.refresh_view(); self.refresh_view();
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
if clean_scan {
self.continue_import_after_clean_scan();
}
} }
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。 /// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
+43 -144
View File
@@ -16,9 +16,10 @@
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时 //! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use crate::app::diag::diag;
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView, AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
ImportRequestView, PackCommand, ExportRequestView,
}; };
use crate::pages::content::{Blocker, Content}; use crate::pages::content::{Blocker, Content};
@@ -77,7 +78,12 @@ impl App {
self.with_query(|query| query.loader = loader) self.with_query(|query| query.loader = loader)
} }
pages::content::Message::Select(id) => { pages::content::Message::Select(id) => {
self.content.selected = Some(id); // M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板)
self.content.selected = Some(id.clone());
self.state.content.request_detail(&id);
if let Some(request) = self.content_detail_request() {
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
}
self.view_dirty = true; self.view_dirty = true;
} }
pages::content::Message::SetTarget(id) => { pages::content::Message::SetTarget(id) => {
@@ -98,155 +104,26 @@ impl App {
self.dispatch(AppCommand::InstallContent(Box::new(request))); self.dispatch(AppCommand::InstallContent(Box::new(request)));
} }
} }
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
pages::content::Message::RetryFailed => {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if let Some(request) = model.retry_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M113: import/export —— 导入/导出区(细节在 `on_pack`) // M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message), pages::content::Message::Pack(message) => self.on_pack(message),
} }
Task::none() Task::none()
} }
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
overwrite,
})
}
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。 /// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
/// ///
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求** /// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。 /// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
fn pack_export_request(&self) -> Option<ExportRequestView> { pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?; let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export( Some(pages::content::request_export(
&self.content.pack, &self.content.pack,
@@ -299,6 +176,26 @@ impl App {
.is_some_and(|results| results.curseforge) .is_some_and(|results| results.curseforge)
} }
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
///
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref());
Some(ContentDetailRequest {
project: hit.id.clone(),
source: hit.source,
game_version: target.map(|target| target.mc_version.clone()),
loader: target.map(|target| target.loader),
})
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。 /// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
/// ///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`), /// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
@@ -312,10 +209,10 @@ impl App {
if !model.can_install() { if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做 // 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) { for reason in model.blocker_reasons(catalog) {
eprintln!("[content] 装入被挡下:{reason}"); diag!("[content] 装入被挡下:{reason}");
} }
if model.blockers.contains(&Blocker::NeedsPipeline) { if model.blockers.contains(&Blocker::NeedsPipeline) {
eprintln!("[content] 整合包装入未实现(M111)"); diag!("[content] 整合包装入未实现(M111)");
} }
return None; return None;
} }
@@ -334,6 +231,8 @@ impl App {
instance: target.id.clone(), instance: target.id.clone(),
game_version: target.mc_version.clone(), game_version: target.mc_version.clone(),
loader: target.loader, loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
}) })
} }
} }
@@ -0,0 +1,240 @@
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
//!
//! ## 为什么单独一个文件
//!
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
//!
//! ## 两条口径(与原来同一句)
//!
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
//! 状态,不进 presenter;
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
//!
//! ## M160 加的那条纪律
//!
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
use unlucky_presenter::dto::{
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
};
use crate::pages;
use super::*;
impl App {
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
if let Some(request) = self.pack_import_request() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
request,
)))));
}
}
PackMessage::PickPage(page) => {
self.content.pack.pick_page = page;
self.view_dirty = true;
}
PackMessage::TogglePick(path) => {
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
// (所以"一次都没点过"仍然等于 `KeepAll`)。
let choice = self.content.pack.pick_choice(&path);
let flipped = match choice {
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
};
self.content.pack.pick_choices.insert(path, flipped);
self.view_dirty = true;
}
PackMessage::PickKeepAll => {
self.content.pack.pick_overwrite_all = false;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::PickOverwriteAll => {
self.content.pack.pick_overwrite_all = true;
self.content.pack.pick_choices.clear();
self.view_dirty = true;
}
PackMessage::ConfirmImport => {
let decisions = self
.content
.pack
.decisions(self.state.pack.import.scan.as_ref());
if let Some(request) = self.pack_import_request_with(decisions) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::CancelImport => {
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
self.content.pack.pick_page = 0;
self.content.pack.pick_choices.clear();
self.content.pack.pick_overwrite_all = false;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
if let Some(request) =
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
{
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 预检干净(没有冲突)时直接往下导。
///
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
/// 结论也只会是"保留用户的"。
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
fn pack_import_request(&self) -> Option<ImportRequestView> {
self.pack_import_request_with(ConflictDecisionsView::default())
}
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
fn pack_import_request_with(
&self,
decisions: ConflictDecisionsView,
) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
decisions,
})
}
}
+138
View File
@@ -0,0 +1,138 @@
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `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 => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
//
// **认框不认人**(复审 r1 P1-1):只核销"当前这个框"登记的目标。哪怕某条关闭路径漏清,
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
let current = app.state.dialogs.current().map(|request| request.id);
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
app.close_dialog();
if let Some(instance) = app.selected_instance_id() {
app.state
.saves
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
dir: dir.clone(),
});
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
)));
}
app.refresh_view();
return Task::none();
}
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
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 => {
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
app.state.saves.close_confirm(current);
}
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 {
let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
return false;
};
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
app.state.saves.close_confirm(current);
app.close_dialog();
app.refresh_view();
true
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,262 @@
//! 主按钮 / Esc 的落点判据(M247,复审 r1 P1-1 / P2-1):**接线**层,不是 presenter 的状态机。
//! 一个 App + 一个只记账的后端,看"点了主按钮之后下发了什么"——「盘上的世界还在」在这里等价于
//! 「没有下发 `DeleteWorld`」(app 不写盘,唯一写入口是 `host::saves::dispatch`)。
//! 真实 `export()` 写没写 zip 由 `app/dialog/tests.rs` 的夹具覆盖,这里不造崩溃卡片。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SavesCommand, SavesListView, WorldView,
};
use unlucky_presenter::mock_data::mock_instances;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::{escape, on_dialog};
use crate::app::dialog::Message as DialogMessage;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
const DIR: &str = "Better World";
/// 只记账、按需喂事件的后端(与 `app/tests/presenter.rs` 的 recorder 同款)。
#[derive(Debug, Default)]
struct SpyBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
events: Rc<RefCell<Vec<AppEvent>>>,
}
impl Backend for SpyBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut *self.events.borrow_mut())
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 一个进到详情页、可喂事件、可记账的 App。
struct Harness {
app: App,
dispatched: Rc<RefCell<Vec<AppCommand>>>,
events: Rc<RefCell<Vec<AppEvent>>>,
}
impl Harness {
fn new() -> Self {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let events = Rc::new(RefCell::new(vec![AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
)]));
let backend = SpyBackend {
dispatched: Rc::clone(&dispatched),
events: Rc::clone(&events),
};
let mut harness = Self {
app: App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
),
dispatched,
events,
};
let _ = harness.app.update(Message::Boot);
assert_eq!(
harness.app.state.route(),
&Route::Detail("survival".to_string()),
"首屏该落在详情页(后面每一步都以它为前提交付目标)"
);
harness
}
/// 喂一条事件再抽干(`pump` 是唯一的 apply 入口,它顺带 `refresh_view`)。
fn feed(&mut self, event: AppEvent) {
self.events.borrow_mut().push(event);
self.app.pump();
self.app.refresh_view();
}
/// 点一次「删除 X」(页面消息的真实入口)。
fn request_delete(&mut self) {
let _ = self.app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::RequestDeleteWorld {
dir: DIR.to_string(),
},
)));
}
/// 有没有下发过 `SavesCommand::DeleteWorld`(= core 会不会去删盘上的世界)。
fn deleted(&self) -> bool {
self.dispatched.borrow().iter().any(|command| {
matches!(
command,
AppCommand::Saves(command)
if matches!(**command, SavesCommand::DeleteWorld { .. })
)
})
}
}
/// 一份 `saves/` 快照(`worlds` 决定"那个世界还在不在")。
fn snapshot(worlds: &[&str]) -> AppEvent {
AppEvent::Core(CoreEvent::Saves(Box::new(Ok(SavesListView {
instance: "survival".to_string(),
dir: "C:/mc/saves".to_string(),
exists: true,
worlds: worlds
.iter()
.map(|dir| WorldView {
dir: (*dir).to_string(),
icon: false,
modified_ms: None,
})
.collect(),
screenshots_dir: "C:/mc/screenshots".to_string(),
screenshots_exists: false,
screenshots: Vec::new(),
skipped: Vec::new(),
last_op: None,
}))))
}
/// **P1-1 事故链**:删存档 → `Esc` → 一个不相干的 `Confirm`(崩溃卡片的主按钮是「导出诊断包」)
/// → 点主按钮 ⇒ **没有** `DeleteWorld` 下发,框走它自己那条路(这里没有崩溃卡片 ⇒ 关框)。
#[test]
fn escaping_the_delete_dialog_disarms_it_for_the_next_one() {
let mut harness = Harness::new();
harness.request_delete();
assert!(
harness.app.state.dialogs.current().is_some(),
"确认框没弹出来"
);
assert!(harness.app.state.saves.confirm.is_some(), "目标没登记");
assert!(escape(&mut harness.app), "有框时 Esc 该被吃掉");
assert!(
harness.app.state.saves.confirm.is_none(),
"Esc 之后目标还留着 —— 下一个框的主按钮会把它兑现"
);
assert!(harness.app.state.dialogs.current().is_none());
// 不相干的框:崩溃卡片就是这个形态(`Confirm`,主按钮「导出诊断包」)。
let _ = harness.app.state.dialogs.push(
DialogSource::UserAction,
"启动失败",
"退出码 1",
DialogKind::Confirm {
primary: "导出诊断包".to_string(),
secondary: "关闭".to_string(),
},
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(
!harness.deleted(),
"Esc 之后那个目标被下一个框兑现了:世界被静默删除"
);
assert!(
harness.app.state.dialogs.current().is_none(),
"主按钮该走它自己那条路(这里没有崩溃卡片 ⇒ 关框)"
);
}
/// **纵深防御(P1-1 的第二层)**:万一某条路径只关了框、**漏清**目标,下一个不相干的框
/// 点主按钮也**拿不走**它 —— 因为 `take_confirm` 只有在"登记时那个框 == 当前框"时才交付。
///
/// 这条直接构造"漏清"状态(登记一个不存在的框 id),所以它**不依赖** `Esc` 那条路有没有清:
/// 去掉 id 校验,它立刻红。
#[test]
fn a_leaked_target_cannot_be_redeemed_by_another_dialog() {
let mut harness = Harness::new();
// 模拟事故形态:目标还挂着,但那个框早就不在了(登记 999 = 一个不存在的框)。
harness.app.state.saves.ask_delete(999, DIR);
let _ = harness.app.state.dialogs.push(
DialogSource::UserAction,
"启动失败",
"退出码 1",
DialogKind::Confirm {
primary: "导出诊断包".to_string(),
secondary: "关闭".to_string(),
},
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(
!harness.deleted(),
"漏清的目标被别的框兑现了:世界被静默删除(P1-1 的事故形态)"
);
assert!(
harness.app.state.saves.confirm.is_some(),
"别人的框不该顺手清掉我们的目标(清它的是关闭路径,不是主按钮)"
);
}
/// 反向:目标属于**当前**这个框时,主按钮仍然真的下发删除(没把功能关死)。
#[test]
fn the_owning_dialog_still_deletes() {
let mut harness = Harness::new();
harness.request_delete();
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(harness.deleted(), "自己的框点主按钮居然没删");
assert!(harness.app.state.saves.confirm.is_none(), "取走即清");
assert!(
harness.app.state.saves.pending.is_some(),
"该把那一行标成在飞(否则能连点)"
);
assert!(harness.app.state.dialogs.current().is_none(), "框该关掉");
}
/// **P2-1**:世界还在 ⇒ 一拍无关快照(备份回程 / 打开文件夹回程)之后,主按钮照样删。
#[test]
fn an_unrelated_snapshot_keeps_the_confirmation_redeemable() {
let mut harness = Harness::new();
harness.request_delete();
harness.feed(snapshot(&[DIR, "Other"]));
assert!(
harness.app.state.saves.confirm.is_some(),
"无关快照把目标吃掉了:主按钮会变成静默空操作"
);
assert!(
harness.app.state.dialogs.current().is_some(),
"框不该被收掉"
);
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
assert!(harness.deleted(), "快照之后主按钮点了没反应");
}
/// **P2-1 的另一半**:世界从快照里消失 ⇒ 框被**收起来**(不是留一个点了没反应的按钮),
/// 而且不下发删除(那个世界已经不存在了,下发只会换来一条 `WriteMissing` 噪音)。
#[test]
fn a_vanished_world_closes_its_confirmation() {
let mut harness = Harness::new();
harness.request_delete();
harness.feed(snapshot(&["Other"]));
assert!(
harness.app.state.saves.confirm.is_none(),
"作废的目标还留着"
);
assert!(
harness.app.state.dialogs.current().is_none(),
"框没被收起:主按钮会是一次静默空操作"
);
assert!(!harness.deleted(), "世界都不在了还下发删除");
}
@@ -0,0 +1,152 @@
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
//!
//! ## 为什么要有它
//!
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
//!
//! 取值:
//!
//! | 值 | 现场 |
//! |---|---|
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
//!
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
use std::path::{Path, PathBuf};
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_scan::{self, CrashSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use crate::app::state::App;
/// 现场根(`%TEMP%/ull-m187-fixture-<pid>`)。
fn root() -> PathBuf {
std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id()))
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, text);
}
/// 出图用的夹具(`None` = 没有开关,什么都不做)。
pub(super) fn apply(app: &mut App) -> Option<String> {
let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?;
let mode = mode.trim().to_lowercase();
if !app.has_screenshot() {
return None;
}
let root = root();
let _ = std::fs::remove_dir_all(&root);
let logs = root.join("logs");
write(
&logs.join("launcher.log"),
"2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\
2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\
2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n",
);
write(&logs.join("install.log"), "installed 1.20.4-fabric\n");
let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e";
let session_log = root
.join("sessions")
.join("game")
.join(format!("{session}.jsonl"));
write(
&session_log,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\
{\"kind\":\"exit\",\"payload\":{\"code\":1}}\n",
);
let tail: Vec<String> = vec![
"[main/INFO]: Loading Minecraft 1.20.4".to_string(),
"java.lang.RuntimeException: mixin injection failed".to_string(),
"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(),
];
let run_dir = root.join("instances").join("survival");
let latest = run_dir.join("logs").join("latest.log");
let version_json = root
.join("game")
.join("versions")
.join("1.20.4-fabric")
.join("1.20.4-fabric.json");
write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n");
let mut artifacts: Vec<ArtifactFact> = Vec::new();
if mode != "no-logs" {
write(
&run_dir
.join("crash-reports")
.join("crash-2026-11-08_15.30.19-client.txt"),
"---- Minecraft Crash Report ----\n\
Time: 2026-11-08 15:30:19\n\
Description: Initializing game\n\n\
java.lang.RuntimeException: mixin injection failed\n\
\tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\
\tat net.minecraft.client.main.Main.main(Main.java:51)\n",
);
write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n");
let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
artifacts = crash_scan::scan(&run_dir, since, 8)
.into_iter()
.map(|artifact| ArtifactFact {
source: artifact.source.as_str().to_string(),
path: artifact.path,
excerpt: artifact.excerpt,
})
.collect();
}
let report = CrashReport {
instance: "survival".to_string(),
name: "生存服的 1.20.4".to_string(),
version: "1.20.4-fabric".to_string(),
session: session.to_string(),
session_log: Some(session_log),
version_json: Some(version_json),
launcher_logs: Some(logs),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 7_400,
command: "javaw -Xmx4096m -Djava.library.path=<home>… ".to_string(),
java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(),
system: "os=windows version=10.0.26100 arch=x86_64".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
};
crash_report::publish(report);
let finished = LaunchFinished {
id: "survival".to_string(),
session: session.to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 7_400,
failure: None,
};
app.open_failure_dialog(&finished);
if mode == "export" {
// 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。
let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary);
}
Some(mode)
}
/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。
#[allow(dead_code)]
pub(super) fn crash_report_source() -> &'static str {
CrashSource::CrashReport.as_str()
}
+304
View File
@@ -0,0 +1,304 @@
//! 实例页「启动参数」区的消息落点(M157)。
//!
//! 三件事:
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
use unlucky_presenter::state::SavesOp;
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else {
return Task::none();
};
match message {
// M224:启停一个 mod。**不做乐观翻面**——先把那个开关标成「请求在飞」(置灰),
// 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。
//
// 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」,
// 与启动参数表单播没播种无关(守卫是给表单草稿用的,见下一条)。
InstanceMessage::SetModEnabled { file, enabled } => {
self.state.mods.mark_pending(&file);
self.dispatch(AppCommand::Mods(Box::new(ModsCommand::SetEnabled {
id: instance,
file,
enabled,
})));
return Task::none();
}
// M235:重新列一次存档(失败后的「重试」也走这条)。
//
// 与 `SetModEnabled` 一样放在 `bound_to` 守卫**之前**:存档区只认"当前选中实例",
// 与启动参数表单播没播种无关(那条守卫是给表单草稿用的)。
InstanceMessage::ReloadSaves => {
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(instance))));
return Task::none();
}
// M235:打开存档文件夹——拉起哪个目录由 host 定(`saves/` → `screenshots/` → 运行目录),
// 真正的 opener 在 `core::platform::opener`(平台差异不散到 app 这一层)。
InstanceMessage::OpenSavesFolder => {
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::OpenFolder {
id: instance,
})));
return Task::none();
}
// M247:行内重命名的三个动作。**输入过程不 dispatch**(只改 presenter 的草稿),
// 提交才发命令——与启动参数表单「编辑只改状态,保存才派命令」同一条纪律。
InstanceMessage::StartRenameWorld { dir } => {
self.state.saves.start_rename(&dir);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::RenameWorldChanged(text) => {
self.state.saves.edit_rename(text);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::CommitRenameWorld => {
let Some(draft) = self.state.saves.take_rename() else {
return Task::none();
};
// 空名字 / 没改 → 直接收起,不打后台:这两个不是"操作失败",是"用户改主意了"。
if draft.text.is_empty() || draft.text == draft.dir {
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
self.state.saves.mark_pending(SavesOp::Rename {
dir: draft.dir.clone(),
});
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::RenameWorld {
id: instance,
dir: draft.dir,
to: draft.text,
})));
return Task::none();
}
InstanceMessage::CancelRenameWorld => {
self.state.saves.cancel_rename();
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
InstanceMessage::RequestDeleteWorld { dir } => {
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
// **先压框、拿到它的 id,再登记目标**:目标绑在那个框上(复审 r1 P1-1)——
// 反过来(先登记再压框)会让"这一拍还没有框"的中间态挂在状态里,别的框的主按钮
// 就有机会把它兑掉。
let dialog = self.state.dialogs.push(
DialogSource::UserAction,
title,
body,
DialogKind::Confirm { primary, secondary },
);
self.state.saves.ask_delete(dialog, &dir);
self.view_dirty = true;
return Task::none();
}
// M247:备份——落点由 host 定(`<data>/exports/saves/`),回执里带完整路径。
InstanceMessage::BackupWorld { dir } => {
self.state
.saves
.mark_pending(SavesOp::Backup { dir: dir.clone() });
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::BackupWorld {
id: instance,
dir,
dest: None,
})));
return Task::none();
}
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text;
self.launch_form.dirty = true;
}
InstanceMessage::Game(text) => {
self.launch_form.game = text;
self.launch_form.dirty = true;
}
InstanceMessage::Memory(text) => {
self.launch_form.memory = text;
self.launch_form.dirty = true;
}
InstanceMessage::Java(text) => {
self.launch_form.java = text;
self.launch_form.dirty = true;
}
InstanceMessage::Backend(id) => {
self.launch_form.backend = id;
self.launch_form.dirty = true;
}
InstanceMessage::Reset => {
self.launch_form.reset();
}
InstanceMessage::Save => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch();
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
///
/// `pub(in crate::app)`(M247):删除确认框的主按钮在 `app/update/dialog.rs`,也要问同一个问题。
pub(in crate::app) fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::{InstanceMessage, LaunchForm};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn app_with_form() -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::mock::MockBackend::new()),
SettingsBridge::memory_only(),
);
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
app.state.navigate(unlucky_presenter::router::Route::Detail(
INSTANCE.to_string(),
));
app.launch_form = LaunchForm::from_view(
Some(INSTANCE),
&LaunchArgsView {
instance_id: INSTANCE.to_string(),
..LaunchArgsView::default()
},
);
app
}
/// 测试用的实例 id(mock 后端的实例快照里有它)。
const INSTANCE: &str = "survival";
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
#[test]
fn editing_marks_dirty_and_reset_waits_for_save() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
assert_eq!(app.launch_form.memory, "6144");
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
assert_eq!(app.launch_form.backend, "vulkan");
let _ = app.on_instance_message(InstanceMessage::Reset);
assert_eq!(app.launch_form.memory, "");
assert_eq!(app.launch_form.backend, "default");
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
}
/// **MODS 区的启停在表单守卫之前**(M224):表单还没播种也照样能启停。
///
/// 抓得住:把 `SetModEnabled` 放回 `bound_to` 守卫之后——那时首帧的详情页点了开关
/// 什么都不会发生(用户看到开关纹丝不动,也没有任何提示)。
#[test]
fn mod_toggles_work_before_the_launch_form_is_bound() {
let mut app = app_with_form();
// 页面已经打开(真路径上 `refresh_view` 的 `sync_mods` 会做这一步),但表单还没播种。
app.state.bind_mods(Some(INSTANCE));
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::SetModEnabled {
file: "sodium.jar".to_string(),
enabled: false,
});
assert!(
app.state.mods.is_pending("sodium.jar"),
"启停被表单守卫挡住了(开关点了没反应)"
);
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form();
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
assert_eq!(app.launch_form.memory, "");
assert!(!app.launch_form.dirty);
}
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
///
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
#[test]
fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save);
assert!(
app.launch_form.dirty,
"派发后仍要保留脏标记,等回程快照确认"
);
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
}
}
@@ -0,0 +1,166 @@
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
//!
//! ## 为什么这条不能在 `view` 里
//!
//! iced 0.14 的处理顺序是:
//!
//! ```text
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
//! ```
//!
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
//! **结构上**不可能暴露这条。
//!
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
//!
//! ## 标识从哪来
//!
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
use unlucky_presenter::router::Route;
use crate::app::components::motion;
use crate::pages;
use super::boot_env::MotionFixture;
use super::*;
impl App {
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
pub(in crate::app) fn sync_motion(&mut self) {
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
if self.window.is_none() && !self.has_screenshot() {
return;
}
let route = self.state.route();
let screen = screen_key(route);
let detail = match route {
Route::Detail(id) => Some(id.as_str()),
_ => None,
};
motion::observe(screen, detail);
}
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
pub(in crate::app) fn motion_fixture(&mut self) {
let Some(fixture) = super::boot_env::env_motion_fixture() else {
return;
};
let route = match (fixture, self.frames) {
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
(MotionFixture::Detail, 1) => Route::Instances,
(MotionFixture::Detail, 2) => {
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
Route::Detail(first)
}
(MotionFixture::Route(route), 1) => route,
_ => return,
};
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
///
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
/// 返回 `true` = 这一拍真的跳了页。
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
use std::sync::atomic::{AtomicU32, Ordering};
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
let Some(target) = boot_env::env_motion_fixture_at() else {
return false;
};
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
if beat != target {
return false;
}
let route = match boot_env::env_motion_fixture() {
Some(MotionFixture::Route(route)) => route,
Some(MotionFixture::Detail) => {
let Some(id) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return false;
};
Route::Detail(id)
}
None => Route::Download,
};
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
true
}
}
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
///
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<Screen>`,这里
/// 再折成 `Option<u64>` 给帧泵用。
fn screen_key(route: &Route) -> Option<u64> {
let screen = pages::Screen::of(route)?;
pages::Screen::ALL
.iter()
.position(|candidate| *candidate == screen)
.map(|index| index as u64 + 1)
}
#[cfg(test)]
mod tests {
use super::*;
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
#[test]
fn only_the_five_screens_get_a_key() {
let keys: Vec<Option<u64>> = [
Route::Instances,
Route::Download,
Route::Content,
Route::Settings,
Route::Logs,
Route::Detail("survival".to_string()),
Route::Accounts,
Route::Agent,
]
.iter()
.map(screen_key)
.collect();
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
assert_eq!(
keys[5],
Some(1),
"详情页归「实例」那一屏(与 Screen::of 同源)"
);
assert_eq!(keys[6], None, "账户页不归 pages 画");
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
}
}
@@ -21,6 +21,7 @@
use unlucky_i18n::{Lang, LangChoice}; use unlucky_i18n::{Lang, LangChoice};
use crate::app::diag::diag;
use crate::pages; use crate::pages;
use crate::ui::lang::LangState; use crate::ui::lang::LangState;
@@ -54,6 +55,8 @@ impl App {
} }
} }
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change), pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发 // M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -63,6 +66,9 @@ impl App {
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」, // 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。 // 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
pages::Message::Language(choice) => self.set_language(choice), pages::Message::Language(choice) => self.set_language(choice),
// M229:换主题档(Ink / Silver / 跟随系统)——换 `Ui` 的色板 + 落盘
// `appearance.theme_mode`。与语言行同一条「页面消息直接改本地 + 落盘」的路。
pages::Message::Theme(mode) => return self.set_theme_mode(mode),
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`) // M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
pages::Message::Content(message) => return self.on_content(message), pages::Message::Content(message) => return self.on_content(message),
// M95:摘要落剪贴板(文本由页面造,这里只搬) // M95:摘要落剪贴板(文本由页面造,这里只搬)
@@ -88,11 +94,32 @@ 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)
}
};
}
// M223:实例页页头「导入」——以前是占位动作(点了只打一行日志),现在是
// 「导入已有版本」向导的入口(M221 的 core 能力 + M223 的 presenter/GUI 接线)。
pages::Message::Action(pages::Action::Import) => {
return self.on_adopt(crate::app::adopt::AdoptMessage::Open);
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => { pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
} }
} }
@@ -123,9 +150,7 @@ impl App {
self.lang = LangState::of(choice); self.lang = LangState::of(choice);
self.lang.log(); self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) { if !self.bridge.persist_ui_language(choice.setting_value()) {
eprintln!( diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
} }
self.lang.log_cjk(); self.lang.log_cjk();
self.view_dirty = true; self.view_dirty = true;
@@ -1,17 +1,170 @@
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。 //! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
//! //!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。 //! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
//! //!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。 //! | 消息 | 做什么 |
//! |---|---|
//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) |
//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) |
//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` |
//!
//! ## 为什么「保存」不派 `AppCommand::SetSetting`
//!
//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会
//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路
//! (`SettingStore::set` + `save`),只是不经过那条通道。
//!
//! ## 为什么「测试」读文件而不是读草稿
//!
//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
use super::super::*; use super::super::*;
use crate::pages::settings::ai::{Probe, ProbeResult};
impl App { impl App {
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。 /// 助手分组的一条消息。
pub(super) fn on_settings_ai_about( pub(super) fn on_settings_ai_about(
&mut self, &mut self,
message: pages::settings::ai_about::Message, message: pages::settings::ai_about::Message,
) -> Task<Message> { ) -> Task<Message> {
match message {} use pages::settings::ai_about::Message;
match message {
Message::ToggleEnabled(enabled) => {
self.settings.ai_about.enabled = enabled;
self.touch();
}
Message::PickProvider(provider) => {
self.settings.ai_about.provider = provider;
self.touch();
}
Message::EditBaseUrl(value) => {
self.settings.ai_about.base_url = value;
self.touch();
}
Message::EditModel(value) => {
self.settings.ai_about.model = value;
self.touch();
}
Message::EditKey(secret) => {
self.settings.ai_about.key = secret;
self.touch();
}
Message::Reveal(reveal) => {
self.settings.ai_about.reveal = reveal;
self.view_dirty = true;
}
Message::Save => return self.save_ai_settings(),
Message::Test => return self.test_ai_connection(),
Message::ProbeDone(result) => {
self.settings.ai_about.probe = Probe::Done(*result);
self.view_dirty = true;
}
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch(&mut self) {
self.settings.ai_about.dirty = true;
self.settings.ai_about.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。
///
/// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。
/// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判),
/// 否则外部一改文件就把用户正在输入的内容冲掉。
pub(in crate::app) fn hydrate_ai_settings(&mut self) {
if self.options.screenshot.is_some() {
let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string());
if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) {
self.settings.ai_about = state;
}
return;
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
return;
};
let snapshot = pages::settings::ai::store::load(&path);
if self.settings.ai_about.hydrate(snapshot) {
self.view_dirty = true;
}
}
/// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。
fn save_ai_settings(&mut self) -> Task<Message> {
use pages::settings::ai::{store, SaveNotice};
if let Err(refusal) = self.settings.ai_about.validate() {
self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal));
self.view_dirty = true;
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.notice = Some(SaveNotice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let saved = store::save(&path, &self.settings.ai_about);
match saved {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.ai_about.dirty = false;
self.settings.ai_about.hydrate(snapshot);
self.settings.ai_about.key = pages::settings::ai::Secret::default();
self.settings.ai_about.notice = Some(SaveNotice::Saved);
}
Err(reason) => {
self.settings.ai_about.notice =
Some(pages::settings::ai::SaveNotice::Failed(reason));
}
}
self.view_dirty = true;
Task::none()
}
/// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。
fn test_ai_connection(&mut self) -> Task<Message> {
if !self.settings.ai_about.can_test() {
return Task::none();
}
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome(
unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured {
missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl,
},
0,
));
self.view_dirty = true;
return Task::none();
};
self.settings.ai_about.probe = Probe::Running;
self.view_dirty = true;
Task::perform(
async move {
let started = std::time::Instant::now();
let outcome = unlucky_cli::commands::agent::probe::probe_file(
&path,
unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT,
);
ProbeResult::from_outcome(outcome, started.elapsed().as_millis())
},
|result| {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::AiAbout(
pages::settings::ai_about::Message::ProbeDone(Box::new(result)),
),
))
},
)
} }
} }
@@ -1,18 +1,75 @@
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。 //! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。
//! //!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页 //! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事:
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。 //! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环
//! (`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> {
match message {} use pages::settings::download::Message;
match message {
Message::Set { key, value } => {
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
Message::Edit { key, value } => {
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
self.settings.download.edit(&key, value);
Task::none()
}
Message::Commit { key } => {
let value = self.pending_text(&key);
self.settings.download.clear(&key);
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
// `settings::set` 忽略(那条分支先认键、再解析值)。
Message::Action { key } => {
self.dispatch(AppCommand::SetSetting {
key,
value: String::new(),
});
Task::none()
}
}
}
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
///
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
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;
@@ -0,0 +1,332 @@
//! 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();
}
+335 -16
View File
@@ -1,18 +1,34 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。 //! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个 //! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。 //! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//! //!
//! 消息进来的通道有两条,都不经过共享文件: //! | 消息 | 做什么 |
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`; //! |---|---|
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))` //! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! (`app::Message::Page` 是既有通道,不用加新变体)。 //! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//! //!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线 //! ## 为什么「设为默认」不攒到「保存」里
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的 //!
//! `the_group_dispatch_has_no_catch_all_arm` 会红。 //! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
use super::super::*; use super::super::*;
use crate::app::diag::diag;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
/// M197 的两个落点(各自一个 `impl App` 块):真下载一个 JDK / 三个设置键。
mod downloads;
mod prefs;
impl App { impl App {
/// Java 分组的一条消息。 /// Java 分组的一条消息。
@@ -20,14 +36,317 @@ impl App {
&mut self, &mut self,
message: pages::settings::java::Message, message: pages::settings::java::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::java::Message;
match message { match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 Message::Scan => return self.start_java_scan(),
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 Message::CancelScan => {
#[cfg(test)] self.settings.java.scan.cancel();
pages::settings::java::Message::Ping => { self.hydrate_java_scan();
self.view_dirty = true;
Task::none()
} }
Message::AddDraft(value) => {
self.settings.java.add_draft = value;
self.settings.java.notice = None;
self.view_dirty = true;
}
Message::Add => self.add_java_manual(),
Message::SelectRuntime(binary) => self.select_java_default(&binary),
Message::EditMemory(value) => {
self.settings.java.memory = value;
self.touch_java();
}
Message::EditJvm(value) => {
self.settings.java.jvm = value;
self.touch_java();
}
Message::Save => self.save_java_settings(),
// M197:真下载一个 JDK + 三个设置键。落点各自一个 `impl App` 块(见 `mod` 声明)。
Message::Download(major) => return downloads::on_download(self, major),
Message::EditInstallRoot(value) => return prefs::on_edit_install_root(self, value),
Message::ToggleAutoInstall(on) => return prefs::on_toggle_auto_install(self, on),
Message::MoveVendor { index, up } => return prefs::on_move_vendor(self, index, up),
Message::ResetPriority => return prefs::on_reset_priority(self),
Message::SavePrefs => return prefs::on_save_prefs(self),
}
Task::none()
}
/// 三键写盘成功之后,请 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;
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
} }
} }
} }
@@ -0,0 +1,118 @@
//! 「下载一个 JDK」的 App 层落点(M197)。
//!
//! 三件事:
//!
//! | 函数 | 谁调 | 干什么 |
//! |---|---|---|
//! | [`App::start_java_download`] | `Download(major)` 消息 | 本地判据(目录/承接方/落点/是否已在跑)→ 起后台作业 |
//! | [`App::hydrate_java_downloads`] | 心跳那一拍(经 `hydrate_java_scan`) | 把作业快照灌成视图差量;**刚成功**时重扫 + 按 `java.auto_install` 决定要不要设为默认 |
//!
//! ## 为什么轮询挂在 `hydrate_java_scan` 上
//!
//! M175 的扫描已经是「后台线程 + 快照 + 心跳比对」的形状,`app/update/clock.rs` 每拍只调
//! `hydrate_java_scan()` 这一个入口。下载作业是同一族东西(后台线程 + 快照),所以复用那一拍,
//! `clock.rs` 一个字节都不用改——代价是**下载期间进度按 1 Hz 刷新**(帧时钟的判据在
//! `app/subscription.rs` 的 `needs_frames`,那是本 mission scope 之外的文件)。百分比一秒动一次
//! 对一次 100 MB 级的下载是够的;要紧的是它**会动**,而不是每秒动 60 次。
//!
//! ## 装完之后为什么要重扫
//!
//! 列表里那一行「已安装:…」来自**真扫盘的结论**(M175 的 `ScanSlot`),不是另存的安装记录。
//! 装完不重扫,界面上就会出现「下载成功」与「列表里没有它」并存——正是本仓最忌讳的那种自相矛盾。
use iced::Task;
use crate::app::message::Message;
use crate::app::state::App;
use crate::pages;
use unlucky_core::game::java::catalog;
use crate::pages::settings::java::{prefs_store, store};
impl App {
/// 下载并安装某个大版本(`Download(major)` 的落点)。
///
/// 四道本地判据,任何一道不过都在下载区顶上写清原因(点了没反应是禁止的):
/// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点;
/// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。
pub(super) fn start_java_download(&mut self, major: u32) {
use pages::settings::java::downloads::{gh_proxy_of_config, Refusal, Request};
self.settings.java.download_notice = None;
self.settings.java.notice = None;
self.view_dirty = true;
if catalog::find(major).is_none() {
self.settings.java.download_notice = Some(Refusal::UnknownMajor);
return;
}
if self.settings.java.downloads.busy() {
self.settings.java.download_notice = Some(Refusal::Busy);
return;
}
let Some(config) = store::config_of(&self.bridge) else {
self.settings.java.download_notice = Some(Refusal::NoStorage);
return;
};
let priority = self.settings.java.priority_vendors();
let domestic = self.settings.java.effective.domestic;
if catalog::vendors_for(major, &priority, domestic).is_empty() {
self.settings.java.download_notice = Some(Refusal::NoVendor);
return;
}
let saved_root = self.settings.java.prefs.saved_install_root.clone();
let Some(cache_root) = prefs_store::cache_root(&config) else {
self.settings.java.download_notice = Some(Refusal::NoStorage);
return;
};
let Some(install_root) = prefs_store::install_root(&config, &saved_root) else {
// 有设置文件但解析不出落点(standard 模式读不到系统目录):说这一句,
// 而不是含糊的「没有可用的设置文件」。
self.settings.java.download_notice = Some(Refusal::RootUnresolved);
return;
};
let started = self.settings.java.downloads.start(Request {
major,
cache_root,
install_root,
priority,
// `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认,
// `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。
gh_proxy: gh_proxy_of_config(&config),
});
if !started {
self.settings.java.download_notice = Some(Refusal::Busy);
}
}
/// 把作业快照灌成视图差量(心跳那一拍调)。返回是否变了。
pub(in crate::app) fn hydrate_java_downloads(&mut self) -> bool {
let report = self.settings.java.downloads.report();
if report == self.settings.java.download_seen {
return false;
}
let fresh = report
.success
.clone()
.filter(|success| self.settings.java.download_seen.success.as_ref() != Some(success));
self.settings.java.download_seen = report;
if let Some(success) = fresh {
// 新装上的一份:先重扫(列表要出现它),再按 `java.auto_install` 决定默认值。
let _ = self.start_java_scan();
if self.settings.java.prefs.saved_auto_install
&& self.settings.java.selected.trim().is_empty()
{
self.select_java_default(&success.binary);
}
}
self.view_dirty = true;
true
}
}
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
pub(super) fn on_download(app: &mut App, major: u32) -> Task<Message> {
app.start_java_download(major);
Task::none()
}
@@ -0,0 +1,126 @@
//! 三个设置键的 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,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless(); let mut app = headless();
app.view_dirty = false; app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings( let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping), pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
"4096".to_string(),
)),
))); )));
assert!( assert!(
@@ -265,3 +269,132 @@ 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;
@@ -0,0 +1,374 @@
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
//!
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
use super::bridged_app;
use crate::app::Message;
use crate::pages;
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
/// 一条 Java 分组消息 → 页面消息。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
///
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
/// 一个字节都没落盘、也没有任何提示。
///
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
/// JVM 回到空、`dirty` 变假)。
#[test]
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
let (mut app, path) = bridged_app("java-select");
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"6144".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
"C:/jdk-21/bin/java.exe".to_string(),
)));
assert_eq!(
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
"设为默认必须真的写进去"
);
assert_eq!(
app.settings.java.memory, "6144",
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert_eq!(
app.settings.java.jvm, "-XX:+UseG1GC",
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert!(
app.settings.java.dirty,
"内存 / JVM 参数还没保存,不许标成已保存"
);
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r3 P3-3:首屏自动扫必须有落脚点
/// **首屏自动扫一次盘**这件事必须在测试里有落脚点(r3 复审 P3-3)。
///
/// 现场:`hydrate_java_settings` 末尾那次 `if !cfg!(test) { auto_scan_java_if_due(...) }` 整行删掉、
/// 或把实参翻成 `true`(截图抑制档),全套测试仍是绿的——于是「设置页从不自动检测本机 Java」
/// 这个**用户实测的头号抱怨**回来时没人拦。
///
/// 为什么这条不是「再把判据测一遍」:判据(`auto_scan_due` 的四条)与取消语义已经有用例;这里要
/// 证明的是**首屏这条路真的调了它**。所以走的是生产入口(`construct.rs` 构造时与心跳都调
/// `hydrate_java_settings`)与生产作业(真扫盘),没有 `cfg!(test)` 分叉、也没有注入假作业。
///
/// 判据为什么同步可判、且与线程快慢无关:`ScanSlot::start_with` 在起线程**之前**就把 `running`
/// 置真(同一把锁里);万一扫描线程已经跑完,`finished` 会留着。所以「真的起过一次扫描」= 这两个
/// 位里至少有一个(空槽两者都是假)——不用等线程,也不看这台机器扫到了什么。
///
/// 变异证明:①删掉 `self.auto_scan_java_if_due(false);` 这一行 ⇒ 两位仍是假 ⇒ 红;
/// ②把它改成 `self.auto_scan_java_if_due(true);` ⇒ 被 `auto_scan_due` 的 `!screenshot` 挡掉 ⇒ 红。
#[test]
fn the_first_hydrate_starts_a_real_scan_by_itself() {
let (mut app, path) = bridged_app("java-autoscan");
// 构造那一刻已经自动扫过一次(同一个入口);这里要重新演一遍「首屏」,所以换一个空槽。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
app.hydrate_java_settings();
let report = app.settings.java.scan.report();
assert!(
report.running || report.finished,
"首屏 hydrate 没有自动发起扫描(快照是空的 {report:?}):设置页就不会自己检测本机 Java\
(r3 P3-3 的回归)"
);
// 收尾:取消这次真扫盘,别让它在后台继续探测本机 PATH 上的 java。
app.settings.java.scan.cancel();
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 一个会一直卡住的扫描作业(直到被取消)。
fn held_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<pages::settings::java::RuntimeRow> {
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Vec::new()
}
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
///
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
///
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
#[test]
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
HOLD.store(false, Ordering::SeqCst);
let (mut app, path) = bridged_app("java-cancel");
// 构造时已经自动扫过一次(首屏那条生产入口,见上一条用例);换成空槽,让本用例自己起。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
// cancelled=true / finished=false —— 正是复审指认的那个形状。
let slot = app.settings.java.scan.clone();
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
slot.cancel();
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!slot.report().running, "取消之后线程必须退出");
assert!(slot.report().cancelled);
assert!(!slot.report().finished);
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
app.hydrate_java_scan();
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
assert!(app.settings.java.scanning.is_none());
// 心跳那条路:走**生产入口**(`clock.rs` 每发现配置文件变了就调 `hydrate_java_settings`),
// 它末尾那次自动发起必须被「取消过」这一条挡住。
app.hydrate_java_settings();
assert!(
!slot.report().running,
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销):{:?}",
slot.report()
);
app.hydrate_java_scan();
assert!(
app.settings.java.scanning.is_none(),
"状态行不许回到「正在扫描」"
);
assert!(
app.settings.java.cancelled,
"取消标记要保持到下一次手动扫描为止"
);
HOLD.store(true, Ordering::SeqCst);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
///
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
/// `Not saved: IO 失败 @ …`。
///
/// M199 起页面侧已经撞不到这一档了(`java.extra_args` 超长先被 `State::validate` 具名拒掉,见下一条
/// 用例),所以这里直接钉 `write_notice` 这一层的映射:`Schema` 只能变成页面自己的 key。
///
/// 变异证明:把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 红;把它拼进
/// `Notice::Refused` 的 detail ⇒ 红(Debug 里会出现那句中文)。
#[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
use pages::settings::java::store::WriteError;
let notice = super::super::java::write_notice(WriteError::Schema(
"IO 失败 @ /tmp/launcher.json".to_string(),
));
assert_eq!(notice, pages::settings::java::Notice::WriteFailed);
let printed = format!("{notice:?}");
assert!(
!printed.contains("IO 失败"),
"core 的中文句子漏进了界面文案:{printed}"
);
}
/// 粘了 2000 字符的 JVM 参数:保存被**具名理由**拦下,而且文件一个字节都不动。
///
/// 现场(r3 复审 P3-4):`java.extra_args` 的 `max_len=1024` 只在 schema 那一档,页面侧不判长度
/// ⇒ 用户得到的是那句通用的「写不进去」,具体原因只在 stderr。本用例走的是 App 那条真保存路径。
///
/// 变异证明:删掉 `State::validate` 里的 `MAX_JVM_ARGS` 判据 ⇒ notice 变回
/// `Notice::WriteFailed`(schema 那一档),本条红。
#[test]
fn an_overlong_jvm_line_is_refused_with_its_own_reason() {
use pages::settings::java::Notice;
let (mut app, path) = bridged_app("java-jvm-long");
let too_long = "-D".to_string() + &"x".repeat(2000);
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
let _ = app.update(java(pages::settings::java::Message::Save));
let notice = app
.settings
.java
.notice
.clone()
.expect("保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("超长参数要给自己的原因(「太长」),不是那句通用的「写不进去」:{notice:?}");
};
assert_eq!(
reason.label(),
unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
"拒绝原因必须落到「太长」那条文案上(中英各一句,见 i18n 金表)"
);
assert!(!path.exists(), "被本地校验拒掉的保存不许碰磁盘");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- M197:三个设置键 + 真下载的落点
/// 三键改完点保存:**真落盘**、草稿被读回值收尾、不再脏,并且保存顺手重扫一次。
///
/// 变异证明:`save_java_prefs` 里不调 `prefs.adopt(snapshot)` ⇒ 草稿还是 `" D:/JDKs "`、
/// `dirty` 仍为真 ⇒ 本条红;不调 `start_java_scan` ⇒ `scan.report().running` 那条红。
#[test]
fn saving_the_three_keys_writes_them_and_reads_them_back() {
use pages::settings::java::prefs_store;
let (mut app, path) = bridged_app("java-prefs");
// 落点用这个临时数据根下的绝对路径:Windows / macOS / Linux 上都是绝对的,
// 断言于是与平台无关(写 "D:/JDKs" 在非 Windows 上是相对路径,会被解析到数据根下)。
let home = path.parent().unwrap().parent().unwrap().to_path_buf();
let jdks = home.join("JDKs").display().to_string();
let _ = app.update(java(pages::settings::java::Message::EditInstallRoot(
format!(" {jdks} "),
)));
let _ = app.update(java(pages::settings::java::Message::ToggleAutoInstall(
false,
)));
let _ = app.update(java(pages::settings::java::Message::MoveVendor {
index: 0,
up: false,
}));
assert!(app.settings.java.prefs.dirty(), "改过三键就该是脏的");
let _ = app.update(java(pages::settings::java::Message::SavePrefs));
assert_eq!(
app.settings.java.notice,
Some(pages::settings::java::Notice::PrefsSaved)
);
assert!(
!app.settings.java.prefs.dirty(),
"保存并把读回值当草稿之后不该还脏"
);
assert_eq!(
app.settings.java.prefs.install_root, jdks,
"草稿必须等于磁盘上那份(trim 过的),不是用户刚敲的原样"
);
assert!(
!app.settings.java.prefs.saved_auto_install,
"auto_install 要真变成关"
);
// 冷读:另起一次 load,确认三个键都在文件里。
let snapshot = prefs_store::load(&path);
assert_eq!(snapshot.install_root, jdks);
assert!(!snapshot.auto_install);
assert_eq!(snapshot.priority.len(), 4, "下移之后落盘的是整条链");
assert_ne!(snapshot.priority[0], "temurin", "顺序真的被改过");
assert!(
app.settings.java.scan.report().running,
"改了落点必须重扫一次,否则列表还停在旧目录里"
);
// 生效值也跟着换了层级(设了 installRoot ⇒ 落点来自设置)。
assert_eq!(
app.settings.java.effective.root, jdks,
"生效落点必须是刚保存的那一个"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// `java.auto_install` 开着且还没有默认 JDK:装完之后自动把新装的那份设为默认。
///
/// 变异证明:把 `hydrate_java_downloads` 里那段 `saved_auto_install && selected.is_empty()`
/// 的判据删掉 ⇒ 本条红(`selected` 还是空);把关掉那一档的断言反过来说明判据不是恒真。
#[test]
fn auto_install_adopts_the_fresh_jdk_when_no_default_is_set() {
use pages::settings::java::downloads::{Report, Success};
let (mut app, path) = bridged_app("java-adopt");
app.settings.java.prefs.saved_auto_install = true;
app.settings.java.selected = String::new();
app.settings.java.downloads.seed_report(Report {
job: None,
tries: Vec::new(),
failure: None,
success: Some(Success {
major: 21,
vendor: "Eclipse Temurin",
root: "D:/jdks/temurin/21".to_string(),
binary: "D:/jdks/temurin/21/bin/java.exe".to_string(),
source: Some("tuna".to_string()),
reused: false,
}),
});
assert!(app.hydrate_java_downloads(), "快照变了就要重建视图");
assert_eq!(
app.settings.java.selected, "D:/jdks/temurin/21/bin/java.exe",
"auto_install 开着 + 没有默认 JDK ⇒ 新装的那份自动成为默认"
);
let snapshot = pages::settings::java::store::load(&path);
assert_eq!(
snapshot.selected, "D:/jdks/temurin/21/bin/java.exe",
"而且真写进了 java.selected_path"
);
// 关掉 auto_install 时同样的成功快照**不**改默认。
let (mut app, path) = bridged_app("java-adopt-off");
app.settings.java.prefs.saved_auto_install = false;
app.settings.java.selected = String::new();
app.settings.java.downloads.seed_report(Report {
job: None,
tries: Vec::new(),
failure: None,
success: Some(Success {
major: 17,
vendor: "Eclipse Temurin",
root: "D:/jdks/temurin/17".to_string(),
binary: "D:/jdks/temurin/17/bin/java.exe".to_string(),
source: None,
reused: true,
}),
});
assert!(app.hydrate_java_downloads());
assert_eq!(app.settings.java.selected, "", "关掉就不许替用户改默认");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// 点一个目录里没有的大版本:立刻在下载区顶上写清原因,**不**起线程。
///
/// 变异证明:把 `start_java_download` 里 `catalog::find(major).is_none()` 那道判据删掉 ⇒
/// `download_notice` 为 `None`,本条红(点了却什么都不说是禁止的)。
#[test]
fn an_unknown_major_is_refused_on_the_spot() {
use pages::settings::java::downloads::Refusal;
let (mut app, path) = bridged_app("java-dl-refuse");
let _ = app.update(java(pages::settings::java::Message::Download(99)));
assert_eq!(
app.settings.java.download_notice,
Some(Refusal::UnknownMajor)
);
assert!(!app.settings.java.downloads.busy(), "不该起任何作业");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
@@ -0,0 +1,307 @@
//! 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();
}
@@ -0,0 +1,234 @@
//! 自更新动作的落点(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(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+92 -5
View File
@@ -21,12 +21,58 @@ use super::state::{log_first_frame, App};
use super::APP_TITLE; use super::APP_TITLE;
mod account_page; mod account_page;
mod instances; pub(super) mod instances;
mod logs; mod logs;
mod rail; mod rail;
mod update;
use instances::{detail_of, runtime_status, supporting_line}; use instances::{detail_of, runtime_status_row, supporting_line};
use logs::logs_model; use logs::logs_model;
use update::update_of;
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
///
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-ink` / `ull-silver`),
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
/// D30 §2.1 的「哪一行日志证明它是哪个组/主题」:出图通道专用的一行身份日志。
///
/// ## 为什么必须有
///
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-silver`。
///
/// ## 为什么只在变化时打
///
/// `App::view` 每帧都调用;每帧一行会把 `launcher.log` 刷满,反而没人能从中找到那一行。
/// 判据是(组序号,主题名)这一对——同一次截图进程里最多两行(首帧 + 主题切一次)。
///
/// ## 为什么是 `try_lock` 而不是 `lock`
///
/// 这里在 UI 线程的每帧路径上。`try_lock` 拿不到锁就**直接返回**(这一帧不打,下一帧还会再来,
/// 身份行只是晚一拍、不会丢),于是「界面帧永远不会被这把锁挡住」是**代码保证的**,不是靠
/// 「今天只有一个调用点、锁里只有一次比较」这种会随改动失效的前提。M204 r1 复审把原先那句
/// 「拿不到锁就不打」和当时用的阻塞 `lock()` 对上了:注释承诺的保证必须由代码提供。
///
/// **非截图通道一律不打**:交互模式下用户每点一次左列就多一行日志,没有价值。
fn note_screenshot_group(screenshot: bool, route: &Route, group: usize, theme: &str) {
if !screenshot {
return;
}
let Ok(mut last) = SHOT_GROUP_LOGGED.try_lock() else {
return;
};
let seen = (group, theme.to_string());
if last.as_ref() == Some(&seen) {
return;
}
*last = Some(seen);
drop(last);
crate::app::diag::diag!("[shot] settings route={route:?} group={group} theme={theme}");
}
impl App { impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。 /// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -43,6 +89,18 @@ 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 {
@@ -73,7 +131,8 @@ impl App {
}; };
let account = self.rail_account(); let account = self.rail_account();
match pages::Screen::of(self.state.route()) { // M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account( Some(_) => shell::view_with_account(
&self.ui, &self.ui,
shell::NavRail { shell::NavRail {
@@ -89,9 +148,13 @@ 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)
@@ -120,7 +183,21 @@ impl App {
self.options.force_state, self.options.force_state,
self.lang(), self.lang(),
), ),
} };
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
let tree = match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree,
};
// M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。
// 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。
super::adopt::layer(
&self.ui,
tree,
&self.adopt_form,
&self.state.adopt,
self.lang(),
)
} }
} }
@@ -136,6 +213,7 @@ impl App {
pub(super) fn model( pub(super) fn model(
state: &AppState, state: &AppState,
width: f32, width: f32,
height: f32,
forced: Option<crate::cli::ForcedState>, forced: Option<crate::cli::ForcedState>,
category: pages::Category, category: pages::Category,
draft: &pages::InstallDraft, draft: &pages::InstallDraft,
@@ -154,7 +232,7 @@ pub(super) fn model(
id: cell.instance.id.clone(), id: cell.instance.id.clone(),
name: cell.instance.name.clone(), name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog), supporting: supporting_line(cell.instance, catalog),
status: runtime_status(&cell.instance.runtime, catalog), status: runtime_status_row(&cell.instance.runtime, catalog),
}) })
.collect(); .collect();
@@ -170,12 +248,21 @@ 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),
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
mods: instances::mods_of(state, catalog),
saves: instances::saves_of(state, catalog),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
settings: state.settings_groups.clone(),
lang, lang,
forced: forced.map(|state| match state { forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。 // `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+128 -7
View File
@@ -18,10 +18,14 @@
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、 //! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。 //! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind}; use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome,
};
use unlucky_presenter::state::AppState;
use crate::pages::{Detail, Meta}; use crate::pages::{Detail, LaunchFacts, Meta, Mods, Saves};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。 /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -59,7 +63,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
} }
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 /// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> { pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime { match runtime {
InstanceRuntime::Idle => None, InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言 // 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
@@ -71,13 +75,103 @@ pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
InstanceRuntime::Running { pid } => { InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())])) Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
} }
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案 // 失败有**两种形状**,各说各的话(复审 P1-1):
InstanceRuntime::Failed { reason } => { // - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)])) // - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
// 绝不说成「启动游戏进程失败」。
//
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
}
}
/// 一条失败状态行。
///
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
match cause {
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(*stage, catalog)),
("detail", detail.as_str()),
],
),
LaunchFailureCause::Stage { stage, .. } => catalog.render(
Key::InstancesFailed,
&[("reason", failure_stage_label(*stage, catalog))],
),
LaunchFailureCause::Crashed { exit_code } => {
let reason = match exit_code {
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
};
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
LaunchFailureCause::SilentFailure { .. } => {
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
} }
} }
} }
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
///
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
other => runtime_status(other, catalog),
}
}
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
///
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
})
}
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
///
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
#[must_use]
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
let catalog = lang.catalog();
if let Some(failure) = &finished.failure {
return catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(failure.stage, catalog)),
("detail", failure.detail.as_str()),
],
);
}
let outcome = catalog.text(match finished.outcome {
LogOutcome::Ok => Key::LaunchOutcomeNormal,
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
});
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
catalog.render(
Key::LaunchToastEnded,
&[("outcome", outcome), ("seconds", seconds.as_str())],
)
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。 /// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str { fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage { catalog.text(match stage {
@@ -135,6 +229,17 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime, instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
), ),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
} }
} }
@@ -160,3 +265,19 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
_ => text.to_string(), _ => text.to_string(),
} }
} }
/// 本地 Mod 的展示模型(M224):presenter 的视图态 → 页面要画的东西。
///
/// 转换本体在 [`crate::pages::instances::mods`] 的 `from_state`(文案要用 `Texts`,那是页面层的
/// 取词入口)——这里只把"当前语言"给过去,与 [`detail_of`] 同一条接法。
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
crate::pages::mods_from_state(&state.mods, catalog)
}
/// 存档 / 截图的展示模型(M235):presenter 的视图态 → 页面要画的东西。
///
/// 与 [`mods_of`] 同一条接法:转换本体在 `pages::instances::saves::from_state`(文案要用
/// `Texts`),这里只把"当前语言"给过去。
pub(super) fn saves_of(state: &AppState, catalog: Catalog) -> Saves {
crate::pages::saves_from_state(&state.saves, catalog)
}
+6 -2
View File
@@ -74,7 +74,9 @@ fn crashed_only() -> AppState {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})); }));
state state
@@ -166,7 +168,9 @@ fn an_imported_crash_file_still_shows_its_findings() {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})); }));
state.apply_core(CoreEvent::DiagnosisReady(report( state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt", "file:crash-2026-11-02_19.40.15-client.txt",
+5 -15
View File
@@ -7,6 +7,9 @@ use unlucky_presenter::dto::{
}; };
use unlucky_presenter::mock_data::mock_instances; use unlucky_presenter::mock_data::mock_instances;
#[path = "tests/launch.rs"]
mod launch;
fn instance(runtime: InstanceRuntime) -> InstanceSummary { fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary { InstanceSummary {
id: "survival".to_string(), id: "survival".to_string(),
@@ -22,6 +25,7 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(), isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(), version_id: "1.20.4".to_string(),
version_present: true, version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
} }
} }
@@ -38,6 +42,7 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
model( model(
state, state,
1180.0, 1180.0,
760.0,
forced, forced,
pages::Category::All, pages::Category::All,
&draft, &draft,
@@ -121,21 +126,6 @@ fn a_busy_instance_offers_stop_instead_of_play() {
assert!(detail.can_stop, "启动中要能 Stop"); assert!(detail.can_stop, "启动中要能 Stop");
} }
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test] #[test]
fn timestamps_are_sliced_not_guessed() { fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog(); let en = Lang::En.catalog();
@@ -0,0 +1,149 @@
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
//!
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
use super::*;
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
#[test]
fn failed_instances_can_be_retried() {
// 进程**没起来**:说「哪一步 + 原文」。
let failed = instance(InstanceRuntime::Failed {
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
});
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
assert_eq!(
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process")
);
// 详情页给全文(阶段 + 原文)。
assert_eq!(
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process: os error 5")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
///
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
#[test]
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
let line = |cause: LaunchFailureCause, lang: Lang| {
let runtime = InstanceRuntime::Failed { cause };
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
(row, detail)
};
// ① 没起来:说阶段。
let (row, detail) = line(
LaunchFailureCause::Stage {
stage: LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
Lang::En,
);
assert_eq!(row, "Launch failed · Starting the game process");
assert_eq!(
detail,
"Launch failed · Starting the game process: os error 5"
);
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
assert!(
!detail.contains("Starting the game process"),
"进程起来了,不许说「启动游戏进程」:{detail}"
);
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
let (row, zh) = line(
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
Lang::ZhCn,
);
assert_eq!(row, "启动失败 · 退出码 0,没起来");
assert_eq!(zh, row);
assert!(row.contains("没起来"), "{row}");
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
assert_eq!(row, "Launch failed · The game crashed");
}
///
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
///
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
#[test]
fn the_launch_toast_speaks_the_interface_language() {
use unlucky_presenter::dto::{
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
};
let finished = |outcome, failure| LaunchFinished {
id: "survival".to_string(),
session: "20261031T120000-abcd12".to_string(),
outcome,
exit_code: Some(1),
uptime_ms: 12_500,
failure,
};
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
let ok = finished(LogOutcome::Ok, None);
let en = super::instances::launch_toast(&ok, Lang::En);
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
assert_eq!(en, "Finished normally · 12.5s");
assert_eq!(zh, "正常结束 · 12.5 秒");
assert_ne!(en, zh, "切语言时提示语要变");
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
let failed = finished(
LogOutcome::Crashed,
Some(LaunchFailureView::new(
LaunchFailureStage::InstanceConfig,
"permission denied (os error 5)",
)),
);
let en = super::instances::launch_toast(&failed, Lang::En);
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
assert!(
en.starts_with("Launch failed · Reading the instance settings"),
"{en}"
);
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
for text in [&en, &zh] {
assert!(
text.contains("permission denied (os error 5)"),
"原文(取证信息)不许丢:{text}"
);
}
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
assert!(
!zh.contains("Launch failed"),
"中文提示里混了英文前缀:{zh}"
);
}
/// 有没有中文字符(上面那条「不许混语言」的判据)。
fn is_cjk(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
+157
View File
@@ -0,0 +1,157 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
skipped: view.skipped.clone(),
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view);
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}
@@ -2,6 +2,9 @@
//! //!
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线; //! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。 //! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
//!
//! 禁用态的合成底([`Base`],M234)也从这里往下传:`Button::base(..)` → [`StyleSpec::base`] →
//! `disabled_button_colors` / `disabled_button_content`。装配侧没有第二个猜底色的地方。
use iced::widget::text::LineHeight; use iced::widget::text::LineHeight;
use iced::widget::{button, text, Row}; use iced::widget::{button, text, Row};
@@ -10,18 +13,17 @@ use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow};
use unlucky_design::color::Role; use unlucky_design::color::Role;
use unlucky_design::elevation::Elevation; use unlucky_design::elevation::Elevation;
use unlucky_design::shape::ShapeScale; use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons; use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::super::layout; use super::super::layout;
use super::{Geometry, Variant}; use super::{Button, Geometry, Variant};
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。 /// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub(super) struct StyleSpec { pub(crate) struct StyleSpec {
/// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。 /// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。
pub fill: Option<Role>, pub fill: Option<Role>,
/// 内容(文字 + 图标)角色。 /// 内容(文字 + 图标)角色。
@@ -34,6 +36,12 @@ pub(super) struct StyleSpec {
pub stroke: f32, pub stroke: f32,
/// 圆角折算用的边长(药丸 = 可见高)。 /// 圆角折算用的边长(药丸 = 可见高)。
pub radius_side: f32, pub radius_side: f32,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容都叠在它上面。
///
/// 由构造点给(卡片 = `surfaces::elevated::BASE`、菜单项 = `inputs::menu::PANEL_BASE`、
/// 直接摆在画布上 = [`Base::CANVAS`])。M229 之前这里恒为 `Role::Surface`,
/// 卡片里的禁用按钮因此把 10% 叠在画布色上(M223 的「形丢失」)。
pub base: Base,
/// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。 /// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
/// ///
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在 /// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
@@ -46,15 +54,11 @@ pub(super) struct StyleSpec {
pub(super) fn assemble<'a, M: Clone + 'a>( pub(super) fn assemble<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
variant: Variant, variant: Variant,
spec: super::Button<'a, M>, spec: Button<'a, M>,
) -> Element<'a, M> { ) -> Element<'a, M> {
let geometry = Geometry::of(ui, variant, spec.leading.is_some()); let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
let (fill, content, border) = variant.roles(); let (fill, content, _border) = variant.roles();
let forced = spec.state.or(if spec.on_press.is_none() { let forced = spec.forced();
Some(Interaction::Disabled)
} else {
None
});
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影) // 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
debug_assert!( debug_assert!(
@@ -68,14 +72,22 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color` // 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格 // 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。 // 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
let ink = content_color(ui, forced, content, fill, variant.disabled_content()); let ink = content_color(
ui,
spec.base,
forced,
content,
fill,
variant.disabled_content(),
);
// 标签排版跟尺寸档走:`ControlSize::label()` 已把「字号跟字级、字重固定 500」定死
// (XS/S = LabelLarge 14、M = BodyLarge 16、L = HeadlineSmall 24、XL = HeadlineLarge 32)。
let label_style = geometry.size.label();
let label = text(spec.label) let label = text(spec.label)
.size(ui.style(TypeRole::LabelLarge).size_px) .size(label_style.size_px)
.line_height(LineHeight::Absolute(Pixels( .line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
ui.style(TypeRole::LabelLarge).line_height_px, .font(ui.font_of(label_style))
)))
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
.color(ink); .color(ink);
let mut row = Row::new().align_y(Alignment::Center); let mut row = Row::new().align_y(Alignment::Center);
@@ -106,18 +118,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
.height(Length::Fill) .height(Length::Fill)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
let style = style_closure( let style = style_closure(ui, text_style_spec(ui, variant, &spec));
ui,
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
forced,
},
);
let element = button(content_box) let element = button(content_box)
.height(Length::Fixed(geometry.height)) .height(Length::Fixed(geometry.height))
@@ -130,6 +131,26 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
} }
} }
/// `Button` → 样式描述:**装配的唯一接线点**(`assemble` 与单测都走这里)。
///
/// 抽出来的理由与 [`super::icon_style_spec`] 同源:`button::Style` 是闭包、`Element` 是黑盒,
/// 两者都不可内省,所以「禁用态的底到底传了哪一个」只能在**结构体**上断言。
/// 写死一个 `StyleSpec` 再喂给 `style_closure` 的测试证明不了接线(M229 复审 P1-1 踩过)。
pub(super) fn text_style_spec<M>(ui: &Ui, variant: Variant, spec: &Button<'_, M>) -> StyleSpec {
let (fill, content, border) = variant.roles();
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
base: spec.base,
forced: spec.forced(),
}
}
/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。 /// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
/// ///
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon` /// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
@@ -139,19 +160,20 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。 /// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
pub(super) fn content_color( pub(super) fn content_color(
ui: &Ui, ui: &Ui,
base: Base,
forced: Option<Interaction>, forced: Option<Interaction>,
content: Role, content: Role,
fill: Option<Role>, fill: Option<Role>,
disabled_content: Role, disabled_content: Role,
) -> iced::Color { ) -> iced::Color {
match forced { match forced {
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated) // 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated),容器再叠在 `base` 上
Some(Interaction::Disabled) if fill.is_some() => { Some(Interaction::Disabled) if fill.is_some() => {
interaction::disabled_button_content(ui, disabled_content, true) interaction::disabled_button_content(ui, base, disabled_content, true)
} }
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton) // 无容器:38% 直接叠在 **base** 上(Text / Outlined / standard IconButton)
Some(Interaction::Disabled) => { Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, disabled_content, false) interaction::disabled_button_content(ui, base, disabled_content, false)
} }
_ => ui.color(content), _ => ui.color(content),
} }
@@ -168,28 +190,35 @@ pub(super) fn style_closure(
.forced .forced
.unwrap_or_else(|| Interaction::from_status(status)); .unwrap_or_else(|| Interaction::from_status(status));
let (background, text_color) = if state.is_disabled() { // 禁用那一对颜色**只求值一次**:背景与描边用的是同一对值(早先调了两次)
let (container, content) = interaction::disabled_button_colors(ui, Role::Surface); let disabled = state
// 透明容器在禁用态依然透明(官方只改内容透明度) .is_disabled()
(spec.fill.map(|_| container), content) .then(|| interaction::disabled_button_colors(ui, spec.base));
} else {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见) let (background, text_color) = match disabled {
let background = match spec.fill { Some((container, content)) => {
Some(role) => match state.layer() { // 透明容器在禁用态依然透明(官方只改内容透明度)
Some(_) => interaction::overlay_color(ui, state, spec.content, role) (spec.fill.map(|_| container), content)
.or(Some(ui.color(role))), }
None => Some(ui.color(role)), None => {
}, // 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
None => transparent_state_color(ui, state, spec.content), let background = match spec.fill {
}; Some(role) => match state.layer() {
(background, ui.color(spec.content)) Some(_) => interaction::overlay_color(ui, state, spec.content, role)
.or(Some(ui.color(role))),
None => Some(ui.color(role)),
},
None => transparent_state_color(ui, spec.base, state, spec.content),
};
(background, ui.color(spec.content))
}
}; };
let border_color = if state.is_disabled() { let border_color = match disabled {
interaction::disabled_button_colors(ui, Role::Surface).0 Some((container, _content)) => container,
} else { None => spec
spec.border .border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)) .map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)),
}; };
button::Style { button::Style {
@@ -217,14 +246,18 @@ pub(super) fn style_closure(
} }
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自 /// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。 /// 叠在**内容角色**上的状态层——把它们当作「叠在 `base`(脚下那一层)上」求值,得到不透明实色。
/// ///
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形, /// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
/// 三个变体共用一条口径,免得各自发挥。 /// 三个变体共用一条口径,免得各自发挥。
///
/// M234 复审 P2-3:`base` 之前写死 `Role::Surface`,卡片里的 Text/Outlined 按钮抬起时
/// 状态层叠在画布色上(与禁用支的口径不一致)。
pub(super) fn transparent_state_color( pub(super) fn transparent_state_color(
ui: &Ui, ui: &Ui,
base: Base,
state: Interaction, state: Interaction,
content: Role, content: Role,
) -> Option<iced::Color> { ) -> Option<iced::Color> {
interaction::overlay_color(ui, state, content, Role::Surface) interaction::overlay_color(ui, state, content, base.role())
} }
@@ -0,0 +1,227 @@
//! FAB 四态(规格卡 `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。
//!
//! ## 规格卡原值(`[spec]` = 图上的坐标/字号,几何全部来自 [`FabSize`] 的令牌访问器)
//!
//! | 档 | 尺寸 | 圆角 | 图标 | 标签 | 规格卡依据 |
//! |---|---|---|---|---|---|
//! | Small | 40×40 | 12(`Medium`) | 20 | — | `+` 徽章式小 FAB |
//! | Default | 56×56 | 16(`Large`) | 26 | — | M3 默认 FAB |
//! | Medium | 80×80 | 20(`LargeIncreased`) | 34 | — | 中大档 |
//! | Extended | 160×56(最小宽) | 16(`Large`) | 24 | 15 | `+ New`:起始内距 28、图标↔标签 8 |
//!
//! ## 配色:`PrimaryContainer` / `OnPrimaryContainer`
//!
//! 规格卡上四颗 FAB 都是**淡绿底 + 深绿字形**,M3 的对应角色正是 `primary-container` /
//! `on-primary-container`(`boards/ink.rs` 的 `[spec]` 行注释也这么标:`FAB 底` / `FAB 图标`)。
//! 所以这里不引入新角色,也不画阴影——规格卡上是纯色块,M3 的 FAB 高程由容器色表达。
//!
//! 色值不写在这份文档里:`tests/no_hardcoded_tokens.rs` 会扫 `src/**` 的十六进制色值,
//! 要核对数字请看 `boards/ink.rs` 的 `ANCHORS` 表(那是唯一真源)。
//!
//! ## 为什么单独一个文件
//!
//! 按钮家族(五式 × 五态 × 五档)已经在 `buttons/mod.rs` + `assemble.rs` 里贴着 400 行;FAB 的
//! 几何与按钮**不共享**(圆角不是半高、图标随档放大、扩展态有自己的内距),硬塞进去只会让两边
//! 的读取路径都变长。共享的只有 `interaction` 的状态层与图标烘焙工具。
use iced::widget::{button, container, row, text};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::inkcat::size::FabSize;
use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Base, Interaction};
use super::super::layout;
/// 扩展态右侧内距(px)。规格卡只量到起始 28(`FabSize::pad_start_px`),右侧没有量;
/// 取同值 **28** 让「图标 + 标签」在 160 宽里左右对称(`[估]`,与起始同源的唯一对称解)。
pub const EXTENDED_PAD_END: f32 = 28.0;
/// 一颗 FAB:图标必有,标签只有扩展态有;`on_press = None` 即禁用。
#[derive(Debug, Clone)]
pub struct Fab<'a, M> {
icon: IconKind,
label: Option<&'a str>,
size: FabSize,
on_press: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Fab<'a, M> {
/// 标准 FAB(默认档 = 56)。
pub fn new(icon: IconKind, on_press: M) -> Self {
Self {
icon,
label: None,
size: FabSize::Default,
on_press: Some(on_press),
state: None,
}
}
/// 扩展态(药丸 + 图标 + 标签)。`label` 文案由调用方给(组件不碰 i18n)。
pub fn extended(icon: IconKind, label: &'a str, on_press: M) -> Self {
Self {
label: Some(label),
size: FabSize::Extended,
..Self::new(icon, on_press)
}
}
/// 换档(Small / Default / Medium / Extended)。
pub fn size(mut self, size: FabSize) -> Self {
self.size = size;
self
}
/// 强制视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 禁用态的内容色:`OnPrimaryContainer` @38% 叠在 **FAB 自己的容器**(`PrimaryContainer`)上。
///
/// ## 为什么底是 FAB 自己的容器,不是画布(M234 复审 P2-2)
///
/// 五式按钮的禁用容器是「`OnSurface`@10% 叠在脚下那一层」——透明的那种甚至没有容器;所以它们的
/// 内容要叠 `0.10 容器 → 父底`。FAB 不一样:`Interaction::Disabled.layer()` 是 `None`,
/// 样式闭包走 `None => Some(ui.color(Role::PrimaryContainer))`,**禁用时背景仍是满档
/// `PrimaryContainer`**(官方 `FloatingActionButtonTokens` 也没有 DisabledContainerOpacity)。
/// 所以内容只能叠 `PrimaryContainer` 本身,`on_container = false`——那一层 10% 在 FAB 身上
/// 根本不存在,给了反而多算一层灰。
///
/// 修前传 `Base::CANVAS`:38% 的 `OnPrimaryContainer` 合在画布色上,出来是一块与 FAB 底无关的灰
/// (Silver 浅色实测是一块灰 `rgb(0.549,0.627,0.667)`),而不是「深色字压在淡蓝容器上」
/// 的应有结果(同板上正确值是 `rgb(0.329,0.561,0.737)`;数字见 artifacts/m234 的 README)。
///
/// `button()` 与样式闭包**共用这一个函数**:早先两处各写一遍,两次都传了同一个错值。
fn content_color(ui: &Ui, forced: Option<Interaction>) -> iced::Color {
match forced {
Some(Interaction::Disabled) => interaction::disabled_button_content(
ui,
Base::of(Role::PrimaryContainer),
Role::OnPrimaryContainer,
false,
),
_ => ui.color(Role::OnPrimaryContainer),
}
}
/// 画一颗 FAB。
pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
let size = spec.size;
let forced = spec.state.or(if spec.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
// 内容色(含禁用档)只走 `content_color` 一处
let ink = content_color(ui, forced);
let glyph = layout::fixed(
size.icon_px(),
size.icon_px(),
icons::view_at::<M>(spec.icon, ink, size.icon_px()),
);
let content: Element<'a, M> = match spec.label {
// 扩展态:图标 + 标签(起始内距 28、间距 8,规格卡实测值)
Some(label) => {
let label_style = TypeStyle {
weight: Weight::Medium,
..TypeRole::BodyMedium.style()
};
let label_px = size.label_px();
row![
glyph,
text(label)
.size(label_px)
.font(ui.font_of(TypeStyle {
size_px: label_px,
..label_style
}))
.color(ink),
]
.spacing(size.gap_px())
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: EXTENDED_PAD_END,
bottom: 0.0,
left: size.pad_start_px(),
})
.into()
}
None => container(glyph)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into(),
};
let shape = size.shape();
let radius = size.radius_px();
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let background = match state.layer() {
// 状态层叠在容器色上(与按钮族同一条 `overlay_color` 口径)
Some(_) => interaction::overlay_color(
ui,
state,
Role::OnPrimaryContainer,
Role::PrimaryContainer,
)
.or(Some(ui.color(Role::PrimaryContainer))),
None => Some(ui.color(Role::PrimaryContainer)),
};
let text_color = content_color(ui, Some(state));
button::Style {
background: background.map(Background::Color),
text_color,
border: Border {
radius: iced::border::Radius::new(radius),
..Border::default()
},
// 规格卡不画阴影;M3 的 FAB 高程靠容器色(rubric §7 的「L0/L1 不用阴影」同源)
shadow: Shadow::default(),
snap: true,
}
};
// 四档的宽都由令牌给(方态是边长、扩展态是**最小**宽 160)。
//
// ⚠️ iced 0.14 的 `Length` 只有 `Fill/FillPortion/Shrink/Fixed`,**没有 Min**(本仓 G15 / G17-d
// 已登记同一堵墙),所以「最小宽随标签撑开」在这里退化成**固定宽**:标签比 160 内距容得下的
// 更长时会被裁,短时也不会缩。登记为偏离(见 `FabSize::width_px` 的文档);真要 Min 语义得等
// iced 给 `Length::Min`,或在组件里先量文本再算宽——那要引字体度量,不在本轮。
let width = Length::Fixed(size.width_px());
let element = button(content)
.width(width)
.height(Length::Fixed(size.height_px()))
.padding(Padding::default())
.style(style);
debug_assert!(radius > 0.0, "FAB 圆角必须来自 ShapeScale({shape:?})");
debug_assert!(
matches!(size, FabSize::Extended) || size.pad_start_px() == 0.0,
"只有扩展态有起始内距"
);
match spec.on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,115 @@
//! FAB 的几何断言(四档尺寸/圆角/图标/内距,全部对着规格卡原值)。
//!
//! 抓得住:档位与规格卡错位(例如把 Default 写成 48)、圆角被写成半高、扩展态内距悄悄改成 M3 的 16。
//! 抓不住:图上好不好看(D30),以及 hover/pressed 的状态层(那走 `interaction` 的既有测试)。
//!
//! 配色只钉**一条**(禁用内容色,M234 复审 P2-2):FAB 没有禁用容器档,所以 38% 必须合在
//! 自己的 `PrimaryContainer` 上,而不是画布色。其余配色仍由 `interaction` 的测试覆盖。
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::inkcat::size::FabSize;
use unlucky_design::typography::Platform;
use super::super::super::interaction::Interaction;
use crate::theme::{to_iced, Families, Ui};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn the_four_states_are_the_spec_sizes() {
for (size, width, height, radius) in [
(FabSize::Small, 40.0, 40.0, 12.0),
(FabSize::Default, 56.0, 56.0, 16.0),
(FabSize::Medium, 80.0, 80.0, 20.0),
(FabSize::Extended, 160.0, 56.0, 16.0),
] {
assert_eq!(size.width_px(), width, "{size:?} 宽");
assert_eq!(size.height_px(), height, "{size:?} 高");
assert_eq!(size.radius_px(), radius, "{size:?} 圆角");
}
assert_eq!(FabSize::ALL.len(), 4);
}
#[test]
fn only_the_extended_state_carries_a_label_and_its_inset() {
for size in [FabSize::Small, FabSize::Default, FabSize::Medium] {
assert_eq!(size.label_px(), 0.0, "{size:?} 不该有标签");
assert_eq!(size.pad_start_px(), 0.0);
}
assert_eq!(FabSize::Extended.label_px(), 15.0);
assert_eq!(FabSize::Extended.pad_start_px(), 28.0);
assert_eq!(FabSize::Extended.gap_px(), 8.0);
}
/// 图标随档放大(20 / 26 / 34),扩展态回到 24(它与标签同行,跟着文字尺度走)。
#[test]
fn the_glyph_grows_with_the_smaller_states() {
assert!(FabSize::Small.icon_px() < FabSize::Default.icon_px());
assert!(FabSize::Default.icon_px() < FabSize::Medium.icon_px());
assert_eq!(FabSize::Extended.icon_px(), 24.0);
}
/// 扩展态右内距是常量(不是散在 `Padding` 里的字面量 28),且与起始内距对称。
#[test]
fn the_extended_pad_end_is_a_named_constant() {
use super::super::fab::EXTENDED_PAD_END;
use unlucky_design::inkcat::size::FabSize;
assert_eq!(EXTENDED_PAD_END, 28.0);
assert_eq!(
EXTENDED_PAD_END,
FabSize::Extended.pad_start_px(),
"左右对称:规格卡只量到起始 28,右侧取同值"
);
}
/// **M234 复审 P2-2 的判据**:禁用 FAB 的内容色必须合在**自己的容器**上。
///
/// 走的是真实现(`fab::content_color`,`button()` 与样式闭包共用它)。抓得住:底又改回画布
/// (那就是修前那块与 FAB 底无关的灰)、或有人把 `on_container` 打开(会多算一层
/// 10% 的 `OnSurface` 灰)。抓不住:容器本身该不该在禁用时保持满档 `PrimaryContainer`
/// (那是 M3 FAB token 的口径问题,不由本测试裁决)。
#[test]
fn disabled_fab_ink_composites_over_its_own_container() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = unlucky_design::color::BrandBoard::for_flavor(flavor).scheme();
// 期望值独立算:38% 的 OnPrimaryContainer 叠在满档 PrimaryContainer 上
let expected = to_iced(
scheme
.get(Role::OnPrimaryContainer)
.with_alpha(0.38)
.over(scheme.get(Role::PrimaryContainer)),
);
let actual = super::content_color(&ui, Some(Interaction::Disabled));
assert_eq!(
actual, expected,
"{flavor:?}:禁用 FAB 的内容色 = on-primary-container@38% 叠 PrimaryContainer"
);
// 反向:不许等于「叠画布」那一档(修前的错值)
let over_canvas = to_iced(
scheme
.get(Role::OnPrimaryContainer)
.with_alpha(0.38)
.over(scheme.get(Role::Surface)),
);
assert_ne!(actual, over_canvas, "{flavor:?}:又合到画布上了");
// 非禁用档不受影响
assert_eq!(
super::content_color(&ui, Some(Interaction::Enabled)),
ui.color(Role::OnPrimaryContainer)
);
}
}
@@ -25,6 +25,7 @@
use iced::Padding; use iced::Padding;
use unlucky_design::icons::IconSize; use unlucky_design::icons::IconSize;
use unlucky_design::inkcat::size::ControlSize;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use crate::theme::{Ui, STROKE_PX}; use crate::theme::{Ui, STROKE_PX};
@@ -44,29 +45,50 @@ pub struct Geometry {
pub gap: f32, pub gap: f32,
/// 描边宽度;`0` = 该式无描边。 /// 描边宽度;`0` = 该式无描边。
pub stroke: f32, pub stroke: f32,
/// 尺寸档(决定高、圆角折算边、内距与标签字级)。
pub size: ControlSize,
} }
impl Geometry { impl Geometry {
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。 /// 按变体 + 有没有前置图标求值(默认 **S 档 = 40 高**,产品页用的就是这一档)。
///
/// `#[cfg(test)]`:产品路径一律走 [`Geometry::sized`](`assemble` 里带上了尺寸档),
/// 这里只给单测一个「S 档」的便捷入口,留成产品 API 只会让 `dead_code` 响。
#[cfg(test)]
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self { pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
Self::sized(ui, variant, leading_icon, ControlSize::S)
}
/// 按尺寸档求值(规格卡 `BUTTONS / XS - XL`)。
///
/// 内距取 [`ControlSize::pad_x`](规格卡没写内距,那是 `[估]` 的「半高向下取 4 栅格、封顶 48」
/// 推导值);带前置图标时左侧再收 **4**(`Space::X1`)——药丸左半圆里图标要离弧远一点,
/// 与 M3 的「带图标左内距更小」是同一条观感规则。S 档因此是 **20 / 16 / 20**,
/// 而 M3 官方是 24 / 16 / 24:差 4,登记在 M229 报告里(规格卡没给内距,我们按它自己的
/// 半高规则推,而不是回到官方值——否则五档里只有 S 一种内距口径)。
pub fn sized(ui: &Ui, variant: Variant, leading_icon: bool, size: ControlSize) -> Self {
let (_, _, border) = variant.roles(); let (_, _, border) = variant.roles();
let pad = size.pad_x();
let start = if leading_icon { let start = if leading_icon {
ui.space(Space::X4) pad - ui.space(Space::X1)
} else { } else {
ui.space(Space::X6) pad
}; };
Self { Self {
height: ui.metrics().control_height, height: size.height_px(),
padding: Padding { padding: Padding {
top: 0.0, top: 0.0,
right: ui.space(Space::X6), right: pad,
bottom: 0.0, bottom: 0.0,
left: start, left: start,
}, },
// 图标盒固定 18(`IconSize::Button`):规格卡五档按钮的图标都没单独写字号,
// 档位只放大容器与标签,不放大图标——那是按钮,不是 FAB。
icon: IconSize::Button.px(), icon: IconSize::Button.px(),
gap: ui.space(Space::X2), gap: ui.space(Space::X2),
stroke: if border.is_some() { STROKE_PX } else { 0.0 }, stroke: if border.is_some() { STROKE_PX } else { 0.0 },
size,
} }
} }
} }
@@ -0,0 +1,68 @@
//! IconButton 的四式(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
//!
//! M234 纯搬移:本文件原来只是 `buttons/mod.rs` 的第 166~225 行(`mod.rs` 已顶到 399 行,
//! 加 `.base(..)` 与 `icon_on` 之前必须先腾地方)。搬移**零行为改动**,`IconVariant` 的文档、
//! 角色表、A1 出处一字未改;对外路径由 `mod.rs` 的 `pub use` 保持为 `buttons::IconVariant`。
use unlucky_design::color::Role;
/// IconButton 的**四式**(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
///
/// 规格卡画了四颗:实心 / 色调 / 描边 / 纯图标。12b §2.5 只有前三式(Standard / Filled / Tonal),
/// M229 按规格卡把 Standard 拆成 **Plain**(纯图标)与 **Outlined**(1px 描边圆圈),
/// `Standard` 这个名字保留为 `Plain` 的别名语义,避免既有调用点全改。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 纯图标(规格卡 `PLAIN`):容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Plain,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
/// 描边:透明容器 + 1px `Outline`(规格卡第三颗,M229 新增)。
Outlined,
}
impl IconVariant {
/// 旧名字的别名:`Standard` = [`IconVariant::Plain`](既有调用点不必改语义)。
#[allow(non_upper_case_globals)]
pub const Standard: IconVariant = IconVariant::Plain;
/// 四式全序(gallery 顺序 = 规格卡从左到右)。
pub const ALL: [IconVariant; 4] = [
IconVariant::Filled,
IconVariant::Tonal,
IconVariant::Outlined,
IconVariant::Plain,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Plain => "plain",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
IconVariant::Outlined => "outlined",
}
}
/// **禁用态图标色**:四式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Plain => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
IconVariant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
}
}
}
+113 -71
View File
@@ -12,7 +12,8 @@
//! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] | //! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] |
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — | //! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] | //! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] | //! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated = **`Elevation::Level1.surface_role()`**(= `SurfaceContainerLow`)/`Primary`;Outlined 透明 + 1dp `Outline` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
//! | 1.9b | 与 A1 的登记差异(产品口径,M248) | Outlined 描边 A1 是 `OutlineVariant`,本项目取 **`Outline`**:1dp 边界属于**非文字元素**(目标 3:1,`design/color/mod.rs` 的对比度纪律),`OutlineVariant` 是装饰分隔线的角色;且 `IconVariant::Outlined` 一直是 `Outline`,两族由此统一 | [`Variant::roles`] |
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] | //! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] | //! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 | //! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
@@ -47,18 +48,23 @@ use unlucky_design::color::Role;
use unlucky_design::component_map::Component; use unlucky_design::component_map::Component;
use unlucky_design::elevation::Elevation; use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_design::inkcat::size::ControlSize;
use unlucky_design::shape::ShapeScale; use unlucky_design::shape::ShapeScale;
use crate::icons; use crate::icons;
use crate::theme::Ui; use crate::theme::Ui;
use super::interaction::Interaction; use super::interaction::{Base, Interaction};
use super::layout; use super::layout;
mod assemble; mod assemble;
mod fab;
mod geometry; mod geometry;
mod icon_variant;
pub use fab::{fab, Fab};
pub use geometry::{Geometry, IconGeometry}; pub use geometry::{Geometry, IconGeometry};
pub use icon_variant::IconVariant;
use assemble::{assemble, style_closure, StyleSpec}; use assemble::{assemble, style_closure, StyleSpec};
@@ -72,9 +78,9 @@ pub enum Variant {
Filled, Filled,
/// 次强调(tonal):`SecondaryContainer`。 /// 次强调(tonal):`SecondaryContainer`。
Tonal, Tonal,
/// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。 /// 带高程:L1 的容器面(`Elevation::Level1.surface_role()` = `SurfaceContainerLow`)+ `Primary` 文本。
Elevated, Elevated,
/// 描边:透明容器 + 1dp `OutlineVariant`。 /// 描边:透明容器 + 1dp `Outline`(非文字元素角色;装饰分隔线才用 `OutlineVariant`)。
Outlined, Outlined,
/// 纯文本:最低权重。 /// 纯文本:最低权重。
Text, Text,
@@ -153,61 +159,17 @@ impl Variant {
Role::OnSecondaryContainer, Role::OnSecondaryContainer,
None, None,
), ),
Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None), // 容器面由**高程令牌**推导(L1 → `SurfaceContainerLow`):不许在组件里再抄一遍角色名,
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)), // 换层级口径只改 `elevation.rs` 一处(M248:P2 的落地方式是改来源,不是改色值)。
Variant::Elevated => (Some(self.elevation().surface_role()), Role::Primary, None),
// 1dp 边界取 `Outline`(非文字元素、目标 3:1);`OutlineVariant` 是装饰分隔线的角色,
// 拿它画边界在 Ink/Silver 上都到不了 3:1,也和 `IconVariant::Outlined` 不一致(M248:P3)。
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
Variant::Text => (None, Role::OnSurfaceVariant, None), Variant::Text => (None, Role::OnSurfaceVariant, None),
} }
} }
} }
/// IconButton 的三种容器变体(12b §2.5)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Standard,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
}
impl IconVariant {
/// 三式全序(gallery 顺序)。
pub const ALL: [IconVariant; 3] = [
IconVariant::Standard,
IconVariant::Filled,
IconVariant::Tonal,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Standard => "standard",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
}
}
/// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
}
}
}
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。 /// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
/// ///
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用, /// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
@@ -220,6 +182,10 @@ pub struct Button<'a, M> {
leading: Option<IconKind>, leading: Option<IconKind>,
trailing: Option<IconKind>, trailing: Option<IconKind>,
state: Option<Interaction>, state: Option<Interaction>,
/// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。
size: ControlSize,
/// **脚下那一层的底**(M234):禁用态的 10% 容器与 38% 内容叠在它上面。
base: Base,
} }
impl<'a, M> Button<'a, M> { impl<'a, M> Button<'a, M> {
@@ -231,6 +197,8 @@ impl<'a, M> Button<'a, M> {
leading: None, leading: None,
trailing: None, trailing: None,
state: None, state: None,
size: ControlSize::S,
base: Base::CANVAS,
} }
} }
@@ -242,9 +210,20 @@ impl<'a, M> Button<'a, M> {
leading: None, leading: None,
trailing: None, trailing: None,
state: None, state: None,
size: ControlSize::S,
base: Base::CANVAS,
} }
} }
/// 换尺寸档(`XS 32 / S 40 / M 56 / L 96 / XL 136`,圆角一律药丸)。
///
/// 产品页只用 **S**(= M3 `control_height` 40);M / L / XL 是规格卡的梯子档,给
/// 空态主行动与展示型入口留着,`--screen=gallery` 的矩阵会把五档一起拍出来。
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。 /// 前置图标(12b 1.4:带图标时左内边距收到 16)。
pub fn leading(mut self, kind: IconKind) -> Self { pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind); self.leading = Some(kind);
@@ -262,6 +241,28 @@ impl<'a, M> Button<'a, M> {
self.state = Some(state); self.state = Some(state);
self self
} }
/// **告诉按钮它现在踩在什么底上**(M234):禁用态的 10% 容器与 38% 内容都叠在这个底上。
///
/// 放进卡片/面板/浮层里**必须**给——不给就按 [`Base::CANVAS`] 合成,禁用态会与容器底
/// 脱节(M223 的「CR 1.05、形丢失」)。容器自己的 `BASE` 常量就是标准答案:
/// 卡片 = [`super::surfaces::elevated::BASE`]、菜单项 = `super::inputs::menu::PANEL_BASE`。
///
/// iced 没有环境上下文(样式闭包只拿得到 `&Theme`),所以这条信息只能显式传:
/// 这是「组件不猜宿主」的代价,也是 M229 那条「参数在、全传死值」的修法。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
/// 视觉态:强制的先,否则「没有消息就是禁用」。
pub(super) fn forced(&self) -> Option<Interaction> {
self.state.or(if self.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
})
}
} }
/// Filled 按钮(实心 `Primary`)。 /// Filled 按钮(实心 `Primary`)。
@@ -274,12 +275,12 @@ pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a
assemble(ui, Variant::Tonal, button) assemble(ui, Variant::Tonal, button)
} }
/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。 /// Elevated 按钮(L1 的容器面 + `Primary` 文本,用色调差表达高程、不画阴影)。
pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> { pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Elevated, button) assemble(ui, Variant::Elevated, button)
} }
/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。 /// Outlined 按钮(透明 + 1dp `Outline`)。
pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> { pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Outlined, button) assemble(ui, Variant::Outlined, button)
} }
@@ -289,6 +290,42 @@ pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a,
assemble(ui, Variant::Text, button) assemble(ui, Variant::Text, button)
} }
/// 图标按钮的描边宽度:**有描边角色就有那一圈,没有就是 0**。
pub(crate) fn icon_stroke(variant: IconVariant) -> f32 {
if variant.roles().2.is_some() {
crate::theme::STROKE_PX
} else {
0.0
}
}
/// 图标按钮的**样式描述**(从 `icon()` 抽出来,好让它能被单测直接钉住)。
///
/// 为什么抽:`Style` 闭包不可内省,`icon()` 返回 `Element` 也看不进去——复审把
/// `StyleSpec.stroke` 写死成 `0.0` 时,`icon_stroke()` 的纯函数测试照样绿(它测的是函数,
/// 不是**接线**)。现在「角色 → 填充/内容/描边」这件事在 `icon_style_spec` 里一次成型,
/// 单测断言的就是 `icon()` 真正塞进 `style_closure` 的那个结构体。
pub(crate) fn icon_style_spec(
variant: IconVariant,
side: f32,
forced: Option<Interaction>,
base: Base,
) -> StyleSpec {
let (fill, content, border) = variant.roles();
StyleSpec {
fill,
content,
border,
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
shape: Component::IconButton.spec().shape,
// M229 复审 P1-1:这里曾硬编码 `0.0`,于是 `Outlined` 与 `Plain` 逐像素相同。
stroke: icon_stroke(variant),
radius_side: side,
base,
forced,
}
}
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。 /// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
/// ///
/// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`, /// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`,
@@ -297,6 +334,8 @@ pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a,
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。 /// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
/// ///
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。 /// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
///
/// 这是 **画布版**(禁用底 = [`Base::CANVAS`]):放进卡片/面板里请用 [`icon_on`] 给真实父底。
pub fn icon<'a, M: Clone + 'a>( pub fn icon<'a, M: Clone + 'a>(
ui: &'a Ui, ui: &'a Ui,
variant: IconVariant, variant: IconVariant,
@@ -304,8 +343,22 @@ pub fn icon<'a, M: Clone + 'a>(
on_press: Option<M>, on_press: Option<M>,
state: Option<Interaction>, state: Option<Interaction>,
) -> Element<'a, M> { ) -> Element<'a, M> {
icon_on(ui, variant, kind, on_press, state, Base::CANVAS)
}
/// 带**父底**的 IconButton(M234):禁用态的 10% 容器与 38% 内容叠在 `base` 上。
#[allow(clippy::too_many_arguments)]
pub fn icon_on<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: IconVariant,
kind: IconKind,
on_press: Option<M>,
state: Option<Interaction>,
base: impl Into<Base>,
) -> Element<'a, M> {
let base = base.into();
let geometry = IconGeometry::of(ui); let geometry = IconGeometry::of(ui);
let (fill, content, border) = variant.roles(); let (fill, content, _) = variant.roles();
let forced = state.or(if on_press.is_some() { let forced = state.or(if on_press.is_some() {
None None
} else { } else {
@@ -317,23 +370,12 @@ pub fn icon<'a, M: Clone + 'a>(
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中, // 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
// 所以「居中」这件事必须由容器自己写死,别指望 button。 // 所以「居中」这件事必须由容器自己写死,别指望 button。
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明 // 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content()); let glyph =
assemble::content_color(ui, base, forced, content, fill, variant.disabled_content());
let icon_box = let icon_box =
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph)); layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
let style = style_closure( let style = style_closure(ui, icon_style_spec(variant, geometry.side, forced, base));
ui,
StyleSpec {
fill,
content,
border,
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
shape: Component::IconButton.spec().shape,
stroke: 0.0,
radius_side: geometry.side,
forced,
},
);
let element = button(icon_box) let element = button(icon_box)
.width(Length::Fixed(geometry.side)) .width(Length::Fixed(geometry.side))
+19 -10
View File
@@ -11,7 +11,7 @@ use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use super::super::interaction::{self, Interaction}; use super::super::interaction::{self, Base, Interaction};
use super::assemble::StyleSpec; use super::assemble::StyleSpec;
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant}; use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
use crate::theme::{Families, Ui, STROKE_PX}; use crate::theme::{Families, Ui, STROKE_PX};
@@ -69,16 +69,20 @@ fn geometry_matches_the_reference_values() {
20.0, 20.0,
"12b 1.2 full 圆角 = 高/2" "12b 1.2 full 圆角 = 高/2"
); );
assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24"); // M229:内距取规格卡的 `ControlSize::pad_x`(S 档 = 20),不再是 12b 的 24。
assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24"); assert_eq!(geometry.padding.left, 20.0, "S 档 pad_x 20");
assert_eq!(geometry.padding.right, 20.0, "S 档 pad_x 20");
assert_eq!(geometry.padding.top, 0.0); assert_eq!(geometry.padding.top, 0.0);
assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8"); assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8");
assert_eq!(geometry.gap, ui.space(Space::X2)); assert_eq!(geometry.gap, ui.space(Space::X2));
assert_eq!(geometry.stroke, 0.0, "实心按钮无描边"); assert_eq!(geometry.stroke, 0.0, "实心按钮无描边");
let with_icon = Geometry::of(&ui, Variant::Filled, true); let with_icon = Geometry::of(&ui, Variant::Filled, true);
assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16"); assert_eq!(
assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动"); with_icon.padding.left, 16.0,
"带图标左侧再收 4(20 − 4):与 12b 1.4 的 16 恰好同值"
);
assert_eq!(with_icon.padding.right, 20.0, "带图标右内边距不动");
let outlined = Geometry::of(&ui, Variant::Outlined, false); let outlined = Geometry::of(&ui, Variant::Outlined, false);
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px"); assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
@@ -118,6 +122,10 @@ fn icon_button_geometry_matches_the_reference() {
); );
} }
mod base;
mod icon;
mod roles;
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。 /// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
/// ///
/// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为 /// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为
@@ -181,8 +189,9 @@ fn five_variants_have_distinct_colour_roles() {
( (
None, None,
unlucky_design::color::Role::OnSurfaceVariant, unlucky_design::color::Role::OnSurfaceVariant,
Some(unlucky_design::color::Role::OutlineVariant) Some(unlucky_design::color::Role::Outline)
) ),
"描边取 Outline(M248:非文字元素角色;OutlineVariant 是装饰分隔线的)"
); );
assert_eq!( assert_eq!(
Variant::Text.roles(), Variant::Text.roles(),
@@ -249,6 +258,7 @@ fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
content, content,
border, border,
shape: variant.shape(), shape: variant.shape(),
base: Base::CANVAS,
stroke: Geometry::of(&light(), variant, false).stroke, stroke: Geometry::of(&light(), variant, false).stroke,
radius_side: Geometry::of(&light(), variant, false).height, radius_side: Geometry::of(&light(), variant, false).height,
forced, forced,
@@ -267,7 +277,7 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角"); assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角");
assert_eq!( assert_eq!(
idle.border.color, idle.border.color,
ui.color(unlucky_design::color::Role::OutlineVariant) ui.color(unlucky_design::color::Role::Outline)
); );
assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影"); assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影");
@@ -277,8 +287,7 @@ fn outlined_style_draws_hairline_border_and_full_radius() {
assert_ne!(hovered.background, idle.background); assert_ne!(hovered.background, idle.background);
let disabled = style(&ui.theme(), button::Status::Disabled); let disabled = style(&ui.theme(), button::Status::Disabled);
let (container, content) = let (container, content) = interaction::disabled_button_colors(&ui, Base::CANVAS);
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%"); assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
assert_eq!( assert_eq!(
disabled.border.color, container, disabled.border.color, container,
@@ -0,0 +1,75 @@
//! 禁用态**合成底**的接线判据(M234;从 `buttons/tests.rs` 拆出,守 400 行硬线)。
//!
//! 判据走真装配路径(`Button::base` → `text_style_spec` → `style_closure`),
//! 不是「自己拼一个 `StyleSpec` 再喂进去」——后者证明不了接线(M229 复审 P1-1 的教训)。
use iced::widget::button;
use iced::Background;
use unlucky_design::color::Role;
use unlucky_design::color::SchemeFlavor;
use super::super::Button;
use super::{light, style_closure, Variant};
use crate::app::components::interaction::Base;
use crate::app::components::surfaces;
use crate::theme::to_iced;
/// **M234 的接线判据**:禁用按钮的容器色必须在**它被告诉的那层底**上合成。
///
/// 走的是**真装配路径**:`Button::base(..)` → `text_style_spec`(接线)→ `style_closure`
/// (求值)。所以这条红了只有两种原因:① `assemble` 没把 `spec.base` 传下去;
/// ② `disabled_button_colors` 内部又写死了画布色。
///
/// 抓得住:卡片/面板里的禁用按钮叠错底(M223 的「CR 1.05、形丢失」就是这么来的)。
/// 抓不住:调用点**忘了**调 `.base(..)`(编译期不报错,默认 `Base::CANVAS`)——那一半由
/// `gallery` 的像素 probe(`ULL_GALLERY_SECTION=disabledbase`)与卡片场景守。
#[test]
fn a_disabled_button_composites_over_the_base_it_is_given() {
use unlucky_design::color::BrandBoard;
let ui = light();
let card = surfaces::elevated::BASE;
let spec = super::super::assemble::text_style_spec(
&ui,
Variant::Filled,
&Button::<u8>::disabled("卡片里"),
);
assert_eq!(
spec.base,
Base::CANVAS,
"不调 `.base(..)` 时是画布底(默认值,语义写在 `Base::CANVAS` 的文档里)"
);
let card_spec = super::super::assemble::text_style_spec(
&ui,
Variant::Filled,
&Button::<u8>::disabled("卡片里").base(card),
);
assert_eq!(
card_spec.base, card,
"接线:`Button::base` 必须原样进 `StyleSpec`"
);
let card_style = style_closure(&ui, card_spec)(&ui.theme(), button::Status::Disabled);
let canvas_style = style_closure(&ui, spec)(&ui.theme(), button::Status::Disabled);
// 期望值独立算(写死 0.10 + 直接取色板角色),不调被测入口。
let scheme = BrandBoard::for_flavor(SchemeFlavor::Light).scheme();
let expected_card = to_iced(
scheme
.get(Role::OnSurface)
.with_alpha(0.10)
.over(scheme.get(Role::SurfaceContainer)),
);
assert_eq!(
card_style.background,
Some(Background::Color(expected_card)),
"卡片里的禁用 Filled 按钮容器 = on-surface@10% 叠 **SurfaceContainer**"
);
assert_ne!(
card_style.background, canvas_style.background,
"卡片底与画布底的合成结果必须不同(叠在画布上就是 M234 要修的那条)"
);
}
@@ -0,0 +1,53 @@
//! 图标按钮的单测(M229 复审 P1-1 从 `buttons/tests.rs` 拆出,守 400 行硬线)。
//!
//! 拆出来的只有「四式图标按钮的描边」这一组:`icon()` 曾经把 `StyleSpec.stroke` 写死 `0.0`,
//! 于是 `Outlined` 与 `Plain` 逐像素相同——判据在这里,渲染级的那一半在 `gallery/probe/rows.rs`。
use super::*;
/// **M229 复审 P1-1 的回归门**:四种图标按钮里**只有 `Outlined` 有描边**。
///
/// 抓得住:`icon()` 里把 `stroke` 写死成 0(那样 Outlined 与 Plain 逐像素相同——正是复审量到的现场)。
/// 抓不住:描边颜色对不对(那是 `IconVariant::roles` 的事,上面已有变体表测试)。
///
/// 渲染级的第二道在 `components/gallery/probe.rs`(`buttons` 段的 sheet 上数 Outlined 行的环像素、
/// 并断言 Plain 行没有环),这一条是纯函数的、不依赖出图。
#[test]
fn only_the_outlined_icon_variant_draws_a_ring() {
use super::super::{icon_stroke, icon_style_spec};
assert_eq!(
icon_stroke(IconVariant::Outlined),
crate::theme::STROKE_PX,
"Outlined 必须画 1px 环(否则与 Plain 逐像素相同)"
);
assert_eq!(
icon_stroke(IconVariant::Outlined),
1.0,
"全站描边 1px(rubric G7)"
);
for plain in [IconVariant::Plain, IconVariant::Filled, IconVariant::Tonal] {
assert_eq!(icon_stroke(plain), 0.0, "{plain:?} 不该有描边");
}
// 与角色表同源:有描边角色 ⇔ 有环。
for variant in IconVariant::ALL {
assert_eq!(
icon_stroke(variant) > 0.0,
variant.roles().2.is_some(),
"{variant:?} 的描边与角色表不一致"
);
// **接线断言**:`icon()` 真正塞进 `style_closure` 的那个结构体(`icon_style_spec`)。
// 只测 `icon_stroke()` 抓不住「调用点写死 0」——第一版就是这样漏的(变异测试证明过)。
let spec = icon_style_spec(variant, 40.0, None, Base::CANVAS);
assert_eq!(
spec.stroke,
icon_stroke(variant),
"{variant:?}:icon() 用的描边宽度与角色表不一致(调用点被写死了?)"
);
assert_eq!(
spec.border.is_some(),
icon_stroke(variant) > 0.0,
"{variant:?}:描边角色与线宽必须同生同灭"
);
}
}
@@ -0,0 +1,190 @@
//! 角色语义 + 「可主题化」断言(M248:Elevated 底 / Outlined 描边)。
//!
//! **判据不是「等于某个 hex」**:品牌色板本来就会被换(D38 的取色方案会整表接管 49 个角色),
//! 钉死色值等于把「配色可自定义」这条路焊上。所以这里钉两件互相补位的事:
//!
//! 1. [`role_table_is_named_roles_not_colours`]:`Variant::roles()` 吐的是**角色名**;Elevated 的
//! 容器还必须由**高程令牌**推导(`Elevation::Level1.surface_role()`),不许在组件里手抄一遍;
//! 2. [`styles_follow_the_scheme_across_boards_and_custom_accents`]:同一变体在 Ink / Silver /
//! 自定义种子方案(`Scheme::from_seed`)三套方案下,`button::Style` 里解出的颜色**分别等于
//! 各自方案的该角色色值**,且三者互不相同。把「取角色」改成「取死值(`Role::Surface`)」
//! 或「写死某套方案」都会被这条打红。
//!
//! 抓不住什么:
//! - **令牌表里两个角色同值**时,换角色的变异只有第 1 条会红;
//! - **像素**:角色写对了、图上却是别的颜色(样式闭包被别处覆盖)——那是 `gallery/probe` 与出图的事。
use iced::widget::button;
use iced::Background;
use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor};
use unlucky_design::elevation::Elevation;
use unlucky_design::typography::Platform;
use super::super::{style_closure, IconVariant, Variant};
use super::spec_of;
use crate::theme::{Ui, STROKE_PX};
/// 三套配色来源:两块出厂品牌板 + 一套 `Scheme::from_seed` 自定义种子方案。
///
/// 第三套是关键:品牌板是固化数据,**换不了**;只有「同一份组件代码吃进另一套方案就换色」
/// 才叫可自定义(D38 的取色链就是这么接管 49 个角色的)。种子用 design 里的
/// [`DEFAULT_SEED`](取色链默认的天蓝)——它是令牌,不是本文件写下的色值字面量
/// (`tests/no_hardcoded_tokens.rs` 拦 `#RRGGBB`,这条门禁是对的)。
fn themes() -> Vec<(&'static str, Ui)> {
vec![
(
"ink",
Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES),
),
(
"silver",
Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES),
),
(
"custom",
Ui::from_scheme(
Scheme::from_seed(SchemeFlavor::Dark, unlucky_design::color::DEFAULT_SEED),
super::FAMILIES,
),
),
]
}
#[test]
fn role_table_is_named_roles_not_colours() {
// ---- P2:Elevated 的容器是**高程令牌的推论**,不是一个手抄的角色名 ----------
assert_eq!(Variant::Elevated.elevation(), Elevation::Level1, "L1");
assert_eq!(
Variant::Elevated.roles().0,
Some(Elevation::Level1.surface_role()),
"Elevated 的底必须由高程令牌推导(L1 → surface_role = SurfaceContainerLow),\
组件里不许再写一遍这个角色名"
);
assert_ne!(
Elevation::Level1.surface_role(),
Role::Surface,
"L1 的容器面必须比画布高一档(否则 Elevated 与画布同色)"
);
// ---- P3:1dp 边界取 `Outline`;`OutlineVariant` 是装饰分隔线的角色 ----------
assert_eq!(
Variant::Outlined.roles(),
(None, Role::OnSurfaceVariant, Some(Role::Outline)),
"Outlined 的 1dp 描边取 Outline(`design/color/mod.rs` 的对比度纪律:\
Outline 是「非文字元素」角色、目标 3:1;OutlineVariant 是纯装饰分隔线)"
);
// 同族一致性:图标按钮那一式一直是 Outline,此前文字按钮是 OutlineVariant ⇒ 全站两套描边
assert_eq!(
IconVariant::Outlined.roles().2,
Some(Role::Outline),
"图标按钮 Outlined 与文字按钮 Outlined 必须是同一个描边角色"
);
}
/// 非文字元素的对比度下限 3:1(WCAG 1.4.11;`design/color/mod.rs` 的对比度纪律同一口径)。
///
/// 这条是 P3 的**理由**而不只是判据:`OutlineVariant` 在深/浅两块板上都到不了 3:1,
/// 拿它画按钮边界等于「看得见形状、量不出边界」。别把下限写死成某个角色的实测值——
/// 换品牌板或换取色方案时,比例是**算出来的**,不是抄出来的。
#[test]
fn outlined_boundary_meets_the_non_text_contrast_bar() {
for (name, ui) in themes() {
let scheme = ui.scheme();
let boundary = scheme.contrast(Role::Outline, Role::Surface);
assert!(
boundary >= 3.0,
"{name}: Outline/Surface = {boundary:.2},非文字元素下限是 3:1"
);
// 报告用:装饰角色差多少(`--nocapture` 可见),不改判据
eprintln!(
"[{name}] Outline/Surface={boundary:.2} · OutlineVariant/Surface={:.2}",
scheme.contrast(Role::OutlineVariant, Role::Surface)
);
}
}
#[test]
fn styles_follow_the_scheme_across_boards_and_custom_accents() {
let elevated = spec_of(Variant::Elevated, Some(super::Interaction::Enabled));
let outlined = spec_of(Variant::Outlined, Some(super::Interaction::Enabled));
let mut elevated_fills = Vec::new();
let mut outlined_strokes = Vec::new();
for (name, ui) in themes() {
let style = style_closure(&ui, elevated);
let idle = style(&ui.theme(), button::Status::Active);
let expected = ui.color(Elevation::Level1.surface_role());
assert_eq!(
idle.background,
Some(Background::Color(expected)),
"{name}: Elevated 的底 = 本方案的 L1 容器面"
);
assert_ne!(
idle.background,
Some(Background::Color(ui.color(Role::Surface))),
"{name}: Elevated 退化成画布色了(取死值 Surface 的变异会打红这条)"
);
assert_eq!(
idle.text_color,
ui.color(Role::Primary),
"{name}: Elevated 标签 = Primary"
);
let style = style_closure(&ui, outlined);
let idle = style(&ui.theme(), button::Status::Active);
assert_eq!(
idle.border.color,
ui.color(Role::Outline),
"{name}: Outlined 描边 = Outline"
);
assert_eq!(idle.border.width, STROKE_PX, "{name}: 描边 1dp");
assert_eq!(
idle.text_color,
ui.color(Role::OnSurfaceVariant),
"{name}: Outlined 标签 = OnSurfaceVariant"
);
elevated_fills.push(expected);
outlined_strokes.push(idle.border.color);
}
// 三套方案两两不同 ⇒「跟着配色来源走」这件事有实据(不是三套恰好同色)
for (index, (name, _)) in themes().iter().enumerate() {
for other in index + 1..elevated_fills.len() {
assert_ne!(
elevated_fills[index], elevated_fills[other],
"{name} 与第 {other} 套方案的 Elevated 底同色,可主题化没有实据"
);
assert_ne!(
outlined_strokes[index], outlined_strokes[other],
"{name} 与第 {other} 套方案的 Outlined 描边同色"
);
}
}
}
/// 基线条:Ink(深)与 Silver(浅)两块**出厂**板必须给出不同的 Elevated 底与 Outlined 描边。
///
/// 与上一条分开写:上一条的三套方案里有一套是自定义的,这条只钉出厂外观——
/// 哪怕有人把 `themes()` 改成只跑自定义方案,出厂这块的回归也还在。
#[test]
fn ink_and_silver_resolve_the_same_roles_to_different_colours() {
let ink = Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES);
let silver = Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES);
for role in [
Elevation::Level1.surface_role(),
Role::Outline,
Role::OnSurfaceVariant,
] {
assert_ne!(
ink.color(role),
silver.color(role),
"{role:?} 在 Ink 与 Silver 上同色,说明它没跟着色板走"
);
}
// 浅色板的 L1 容器必须比画布暗、深色板必须比画布亮(层级方向不能反)
assert!(silver.color(Role::Surface).r > silver.color(Role::SurfaceContainerLow).r);
assert!(ink.color(Role::Surface).r < ink.color(Role::SurfaceContainerLow).r);
}
Loaded 100 of 916 files, more files were not shown because too many files have changed in this diff. Show more