Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d517f28140 |
No files matched your search
@@ -132,59 +132,7 @@ jobs:
|
||||
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
|
||||
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
|
||||
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
|
||||
#
|
||||
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
|
||||
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
|
||||
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
|
||||
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
|
||||
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
|
||||
# 事各自对准一种「看不见」:
|
||||
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
|
||||
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
|
||||
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
|
||||
# 注解是匿名侧唯一能取到的失败正文;
|
||||
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
|
||||
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
|
||||
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
log="$RUNNER_TEMP/cargo-test.log"
|
||||
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
|
||||
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
|
||||
# 不是 tee 的码)。
|
||||
set +e
|
||||
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
|
||||
code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "cargo exit=$code"
|
||||
if [ "$code" -ne 0 ]; then
|
||||
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
|
||||
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
|
||||
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
|
||||
fi
|
||||
exit "$code"
|
||||
- name: cargo test --workspace --all-targets(windows,失败升注解)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
|
||||
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
|
||||
ULL_XDEV_TEST_WAIVE: "1"
|
||||
run: |
|
||||
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
|
||||
# 不会把它改掉,所以它是 cargo 自己的码。
|
||||
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
|
||||
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
|
||||
$code = $LASTEXITCODE
|
||||
Write-Host "cargo exit=$code"
|
||||
if ($code -ne 0) {
|
||||
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
|
||||
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
|
||||
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
|
||||
Select-Object -First 40 |
|
||||
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
|
||||
}
|
||||
exit $code
|
||||
- run: cargo test --workspace --all-targets
|
||||
|
||||
build:
|
||||
name: build release (${{ matrix.os }})
|
||||
@@ -292,33 +240,3 @@ jobs:
|
||||
name: runtime-smoke-screenshot-macos
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
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
|
||||
@@ -1,9 +1,5 @@
|
||||
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):
|
||||
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
|
||||
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
|
||||
@@ -58,11 +54,7 @@ jobs:
|
||||
run: |
|
||||
set -uo pipefail
|
||||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||||
# 与下方 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
|
||||
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
|
||||
echo "$tree"
|
||||
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
|
||||
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
|
||||
@@ -117,9 +109,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "$tree"
|
||||
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
|
||||
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
|
||||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
|
||||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
|
||||
if [ -n "$hits" ]; then
|
||||
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
|
||||
printf '%s\n' "$hits" | sed 's/^/ - /'
|
||||
@@ -328,10 +318,9 @@ jobs:
|
||||
}
|
||||
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
|
||||
# 所以这个预筛不会漏)。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
|
||||
if scan "$f"; then printf '%s\n' "$f"; fi
|
||||
done | LC_ALL=C sort || true)
|
||||
done | LC_ALL=C sort)
|
||||
echo "期望(恰好这一个):$EXPECTED"
|
||||
echo "实际扫到的集合:"
|
||||
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
name: Release / build(可复用)
|
||||
|
||||
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
|
||||
# M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
|
||||
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
|
||||
# `matrix.rustflags` → `inputs.rustflags`。
|
||||
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
|
||||
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
|
||||
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
|
||||
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
|
||||
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
|
||||
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
|
||||
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
|
||||
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
|
||||
# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slot:
|
||||
description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)"
|
||||
type: string
|
||||
required: true
|
||||
os:
|
||||
description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)"
|
||||
type: string
|
||||
required: true
|
||||
build_dir:
|
||||
description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)"
|
||||
type: string
|
||||
required: true
|
||||
rustflags:
|
||||
description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
# (M149)微软登录用的 Azure 公共客户端 id(公共客户端 id 随二进制一起分发,不按高密级处理,但**不进 git
|
||||
# 文件**:真值放仓库 Secret,用户在仓库设置里录入)。`required: false` 是刻意的——调用方没传(本地、
|
||||
# 没有 runner 的镜像站、以及 Secret 尚未录入时)求值为空串,`client_id.rs` 把空白当「没配」,
|
||||
# 于是非官方构建不烘 id,官方构建才烘。**调用方必须显式传**(`secrets: inherit` 或按名传);
|
||||
# 没传时这里是空,不会报错。
|
||||
secrets:
|
||||
ULL_MSA_CLIENT_ID:
|
||||
description: "Azure 公共客户端 id,烘进发布二进制(构建期 option_env!);留空 = 不烘"
|
||||
required: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ inputs.slot }})
|
||||
runs-on: ${{ inputs.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
|
||||
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
|
||||
# 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
|
||||
# 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
|
||||
# `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
|
||||
# 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
|
||||
# M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
|
||||
# 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
|
||||
# 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
|
||||
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
|
||||
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
|
||||
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
|
||||
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
|
||||
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
|
||||
if: inputs.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
|
||||
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
|
||||
shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
|
||||
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
[ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
|
||||
# ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
|
||||
for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
|
||||
Microsoft.VisualStudio.Component.Windows11SDK.26100 \
|
||||
Microsoft.VisualStudio.Component.Windows11SDK.22621; do
|
||||
hit=""
|
||||
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
|
||||
else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
|
||||
done
|
||||
# ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
|
||||
for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
|
||||
hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
|
||||
say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
|
||||
done
|
||||
for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
|
||||
[ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
|
||||
for ver in "$root"/*/; do
|
||||
libs=( "$ver"um/arm64/*.lib )
|
||||
say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
|
||||
done
|
||||
done
|
||||
# ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
|
||||
for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
|
||||
hit=""
|
||||
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
|
||||
say notice "link.exe 候选 $p → ${hit:-未命中}"
|
||||
done
|
||||
echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
|
||||
|
||||
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
|
||||
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
|
||||
# (M149)`ULL_MSA_CLIENT_ID` 经 `option_env!` 烘进二进制(`crates/core/src/auth/microsoft/client_id.rs`):
|
||||
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
|
||||
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
|
||||
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
|
||||
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
|
||||
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
|
||||
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
|
||||
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
|
||||
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
|
||||
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
|
||||
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
|
||||
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
|
||||
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ inputs.rustflags }}
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
SLOT: ${{ inputs.slot }}
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
|
||||
if [ "$SLOT" = "windows-arm64" ]; then
|
||||
rustup target add aarch64-pc-windows-msvc
|
||||
rustup target list --installed
|
||||
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
if [ -x "$vswhere" ]; then
|
||||
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的
|
||||
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
|
||||
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
|
||||
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
|
||||
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
|
||||
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
|
||||
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
|
||||
mkdir -p "$tmp"
|
||||
vcvars=""
|
||||
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
|
||||
# 只赌一种就等于再写一个「静默退化成 warning」。
|
||||
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
|
||||
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$vcvars" ] && break
|
||||
done
|
||||
dump=""
|
||||
if [ -n "$vcvars" ]; then
|
||||
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
|
||||
{ printf '@echo off\r\n'
|
||||
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
|
||||
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
|
||||
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
|
||||
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
|
||||
fi
|
||||
for key in LIB INCLUDE; do
|
||||
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
|
||||
if [ -n "$value" ]; then
|
||||
export "$key=$value"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
|
||||
echo "env $key=${value:0:90}…(${#value} 字符)"
|
||||
else
|
||||
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
|
||||
fi
|
||||
done
|
||||
found=""
|
||||
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
|
||||
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
|
||||
[ -n "$found" ] && break
|
||||
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$hit" ] && found=${hit//\\//}
|
||||
done
|
||||
if [ -n "$found" ]; then
|
||||
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
|
||||
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
|
||||
echo "linker: $found"
|
||||
else
|
||||
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
|
||||
fi
|
||||
else
|
||||
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
|
||||
fi
|
||||
fi
|
||||
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
|
||||
# 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们
|
||||
# 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。
|
||||
if [ "$SLOT" = "windows-arm64" ]; then
|
||||
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
|
||||
fi
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
|
||||
EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
|
||||
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
|
||||
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
|
||||
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
|
||||
fi
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
|
||||
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
|
||||
for p in "${pats[@]}"; do
|
||||
for pat in "${p//\//\\}" "$p"; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
|
||||
find_pat=()
|
||||
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
|
||||
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio" \
|
||||
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
|
||||
fi
|
||||
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
ls -l "$EXE"
|
||||
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
|
||||
fi
|
||||
|
||||
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明,
|
||||
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
|
||||
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
|
||||
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
|
||||
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
|
||||
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
|
||||
if: inputs.slot == 'linux-x64'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends xvfb xauth \
|
||||
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
|
||||
out="$RUNNER_TEMP/linux-smoke"
|
||||
mkdir -p "$out/data"
|
||||
log="$out/launcher.log"
|
||||
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
|
||||
set +e
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
|
||||
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
|
||||
code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "launcher exit=$code"
|
||||
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
|
||||
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
|
||||
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
|
||||
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
|
||||
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
|
||||
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
|
||||
ls -l "$out"
|
||||
exit "$code"
|
||||
|
||||
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
|
||||
if: always() && inputs.slot == 'linux-x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x64-first-frame
|
||||
path: ${{ runner.temp }}/linux-smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ inputs.slot }}
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
|
||||
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||
ls -l dist
|
||||
|
||||
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for archive in dist/*.tar.gz; do
|
||||
[ -e "$archive" ] || continue
|
||||
listing=$(tar -tzf "$archive")
|
||||
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||
if ! grep -q "/$required$" <<<"$listing"; then
|
||||
echo "::error file=$archive::$archive 缺少 $required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||
done
|
||||
if ls dist/*.exe >/dev/null 2>&1; then
|
||||
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ inputs.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
|
||||
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
|
||||
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
|
||||
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
|
||||
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
|
||||
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
|
||||
- name: arm64 失败收尾注解(红不挡发布)
|
||||
if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
|
||||
shell: bash
|
||||
env:
|
||||
SLOT: ${{ inputs.slot }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$SLOT" = "linux-arm64" ]; then
|
||||
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
|
||||
else
|
||||
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
|
||||
fi
|
||||
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
|
||||
@@ -1,70 +0,0 @@
|
||||
name: Release guard
|
||||
|
||||
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
|
||||
#
|
||||
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
|
||||
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
|
||||
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
|
||||
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
|
||||
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
|
||||
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
|
||||
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
|
||||
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
|
||||
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
|
||||
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
|
||||
#
|
||||
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
|
||||
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
|
||||
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
|
||||
#
|
||||
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
|
||||
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
|
||||
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
|
||||
# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 /
|
||||
# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。
|
||||
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
|
||||
#
|
||||
# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/
|
||||
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
|
||||
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
|
||||
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
|
||||
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
|
||||
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
|
||||
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
|
||||
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
|
||||
# 该红的红、该绿的绿。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-semantics:
|
||||
name: 发布标记语义守门(push tag → 稳定版)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 标记表自检(push tag ⇒ 稳定版)
|
||||
run: python3 packaging/release_marker.py --self-test
|
||||
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
|
||||
run: python3 packaging/check_release_semantics.py
|
||||
- name: 变异自测(守门抓不抓得住回归)
|
||||
run: python3 packaging/check_release_semantics.py --self-mutation
|
||||
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
|
||||
run: python3 packaging/check_publish_slots.py --self-test
|
||||
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
|
||||
run: python3 packaging/check_publish_slots.py
|
||||
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
|
||||
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
|
||||
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
|
||||
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
|
||||
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
|
||||
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
|
||||
run: python3 packaging/check_test_source_off.py --self-test
|
||||
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
|
||||
run: python3 packaging/check_test_source_off.py
|
||||
@@ -1,182 +0,0 @@
|
||||
name: Release / publish(可复用)
|
||||
|
||||
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
|
||||
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
|
||||
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
|
||||
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
|
||||
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
|
||||
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
|
||||
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
|
||||
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
|
||||
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
|
||||
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
|
||||
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
|
||||
# `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
|
||||
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
|
||||
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
|
||||
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
|
||||
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
|
||||
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
|
||||
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
|
||||
# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的
|
||||
# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PUBLISH_TOKEN:
|
||||
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
|
||||
required: true
|
||||
|
||||
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
|
||||
# **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
|
||||
# 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
|
||||
# `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
|
||||
# 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
|
||||
# **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧,
|
||||
# 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。
|
||||
# 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」,
|
||||
# 不是静默丢弃(这条是任务里点名要验的反方向)。
|
||||
# `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。
|
||||
# **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS
|
||||
# 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。
|
||||
# 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml
|
||||
# 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。
|
||||
# macOS 两档仍未验证,默认连构建都不跑(M139)。
|
||||
PUBLISH_SLOTS: "windows-x64 linux-x64"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
|
||||
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
|
||||
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
|
||||
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
|
||||
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
|
||||
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
|
||||
|
||||
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
|
||||
keep=" "
|
||||
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
|
||||
for f in dist/*; do
|
||||
base=${f##*/}
|
||||
[ "$base" = "SHA256SUMS.txt" ] && continue
|
||||
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
|
||||
case "$keep" in
|
||||
*" $platform_arch "*)
|
||||
echo "publish: $base"
|
||||
;;
|
||||
*)
|
||||
echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
|
||||
rm -f "$f"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
- name: 重算全量 SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
|
||||
# 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
|
||||
# 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
|
||||
cd dist
|
||||
rm -f SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
|
||||
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
|
||||
python3 packaging/make_manifest.py --self-test
|
||||
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
|
||||
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
|
||||
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||
# 后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||
python3 packaging/make_manifest.py \
|
||||
--dist dist \
|
||||
--version "$TAG" \
|
||||
--channel stable \
|
||||
--asset-base-url "$RELEASE_BASE/$TAG" \
|
||||
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
- name: 生成 Release 说明(支持矩阵模板)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os, pathlib
|
||||
version = os.environ["TAG"].lstrip("v")
|
||||
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
|
||||
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
|
||||
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
|
||||
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
|
||||
print(body[:400])
|
||||
PY
|
||||
|
||||
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
PRERELEASE: ${{ github.event.inputs.prerelease }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
|
||||
# ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
|
||||
# 三种触发各自创建什么(那张表是唯一真相源):
|
||||
# push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
|
||||
# dispatch prerelease=true → create / edit 都带预发布标记
|
||||
# dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
|
||||
# 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
|
||||
# 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
|
||||
# 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
|
||||
# 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
|
||||
# 进程替换里的失败会被 `set -e` 吞掉。
|
||||
marks=/tmp/release-marks.sh
|
||||
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
|
||||
source "$marks"
|
||||
echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
||||
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
|
||||
else
|
||||
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
|
||||
fi
|
||||
+324
-76
@@ -1,33 +1,13 @@
|
||||
name: Release
|
||||
|
||||
# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
|
||||
# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
|
||||
# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
|
||||
# 不碰标记)。
|
||||
# **构建 ≠ 发布**:只有 `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::` 明说未发布。
|
||||
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 +
|
||||
# SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。
|
||||
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试
|
||||
# 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都
|
||||
# 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
|
||||
# 契约唯一真相源 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 与
|
||||
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
|
||||
#
|
||||
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
|
||||
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
|
||||
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
|
||||
# cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
|
||||
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
|
||||
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
|
||||
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
|
||||
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -36,17 +16,13 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
all_slots:
|
||||
description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
|
||||
description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows"
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease:
|
||||
# 默认 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 时才生效)"
|
||||
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
|
||||
type: boolean
|
||||
default: false
|
||||
default: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -63,7 +39,7 @@ jobs:
|
||||
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
|
||||
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
|
||||
plan:
|
||||
name: 选槽位(默认四个:Windows 两档 + Linux 两档)
|
||||
name: 选槽位(默认两个 Windows)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
@@ -83,25 +59,16 @@ jobs:
|
||||
SLOTS = [
|
||||
# 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"),
|
||||
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
|
||||
# **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
|
||||
# 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
|
||||
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
|
||||
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
|
||||
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费;GitHub 文档的 Standard runners 也列
|
||||
# `windows-11-vs2026-arm`)。M140 前是在 windows-latest 上交叉,**CI 从未成功过一次**(v0.2.1 首跑红在
|
||||
# 工具链体检,M141/M142 在此修)。原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
|
||||
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
|
||||
rustflags="-C target-feature=+crt-static", mode="ci"),
|
||||
# ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。
|
||||
# Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。
|
||||
# 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里:
|
||||
# `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见
|
||||
# `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。
|
||||
# ↓ 四档**未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
|
||||
# (将来验证 Linux/macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
|
||||
# 同步 README §1/§9 与 release-notes.md。Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移。
|
||||
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="probe"),
|
||||
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="probe"),
|
||||
# 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-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
|
||||
@@ -111,42 +78,323 @@ jobs:
|
||||
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
|
||||
PY
|
||||
|
||||
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
|
||||
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
|
||||
build:
|
||||
name: build (${{ matrix.slot }})
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
slot: ${{ matrix.slot }}
|
||||
os: ${{ matrix.os }}
|
||||
build_dir: ${{ matrix.build_dir }}
|
||||
rustflags: ${{ matrix.rustflags }}
|
||||
secrets:
|
||||
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
|
||||
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白
|
||||
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static`
|
||||
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要
|
||||
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。
|
||||
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e——
|
||||
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出),
|
||||
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测
|
||||
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就
|
||||
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。
|
||||
- name: Windows ARM64 工具链体检(红不挡发布,只取证)
|
||||
if: matrix.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
|
||||
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
|
||||
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
|
||||
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
|
||||
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
|
||||
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
|
||||
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
|
||||
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
|
||||
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
|
||||
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
|
||||
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
|
||||
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
|
||||
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
|
||||
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
|
||||
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
|
||||
scan
|
||||
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
|
||||
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
|
||||
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
|
||||
v=""
|
||||
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
|
||||
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
|
||||
done
|
||||
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
|
||||
v=""
|
||||
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
|
||||
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
|
||||
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
|
||||
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
|
||||
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
|
||||
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
|
||||
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
|
||||
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
|
||||
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
|
||||
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
|
||||
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
|
||||
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
|
||||
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
|
||||
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
|
||||
scan
|
||||
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
|
||||
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
|
||||
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
|
||||
exit 1
|
||||
fi
|
||||
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
|
||||
|
||||
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
|
||||
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
|
||||
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
|
||||
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
|
||||
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
|
||||
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
|
||||
fi
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
|
||||
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
|
||||
for p in "${pats[@]}"; do
|
||||
for pat in "${p//\//\\}" "$p"; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
|
||||
find_pat=()
|
||||
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
|
||||
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio" \
|
||||
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
|
||||
fi
|
||||
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
ls -l "$EXE"
|
||||
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
|
||||
fi
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ matrix.slot }}
|
||||
BUILD_DIR: ${{ matrix.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
|
||||
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||
ls -l dist
|
||||
|
||||
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for archive in dist/*.tar.gz; do
|
||||
[ -e "$archive" ] || continue
|
||||
listing=$(tar -tzf "$archive")
|
||||
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||
if ! grep -q "/$required$" <<<"$listing"; then
|
||||
echo "::error file=$archive::$archive 缺少 $required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||
done
|
||||
if ls dist/*.exe >/dev/null 2>&1; then
|
||||
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
|
||||
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
|
||||
- name: arm64 失败收尾注解(红不挡发布)
|
||||
if: failure() && matrix.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: build
|
||||
# `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
|
||||
# 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
|
||||
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
|
||||
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
|
||||
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
|
||||
# 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
|
||||
if: always() && !cancelled() && github.ref_type == 'tag'
|
||||
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/release-publish.yml
|
||||
secrets:
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
|
||||
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
env:
|
||||
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
|
||||
# **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着
|
||||
# arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据),
|
||||
# **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步
|
||||
# README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。
|
||||
PUBLISH_SLOTS: "windows-x64"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
|
||||
keep=" "
|
||||
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
|
||||
for f in dist/*; do
|
||||
base=${f##*/}
|
||||
[ "$base" = "SHA256SUMS.txt" ] && continue
|
||||
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
|
||||
case "$keep" in
|
||||
*" $platform_arch "*)
|
||||
echo "publish: $base"
|
||||
;;
|
||||
*)
|
||||
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)"
|
||||
rm -f "$f"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个
|
||||
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。
|
||||
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。
|
||||
for slot in $PUBLISH_SLOTS; do
|
||||
stem=${slot//-/_}
|
||||
for prefix in unlucky-launcher ull; do
|
||||
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
|
||||
echo "::error::发布白名单缺产物:$prefix / $slot"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
|
||||
cd dist
|
||||
rm -f SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: 生成 Release 说明(支持矩阵模板)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os, pathlib
|
||||
version = os.environ["TAG"].lstrip("v")
|
||||
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
|
||||
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
|
||||
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
|
||||
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
|
||||
print(body[:400])
|
||||
PY
|
||||
|
||||
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
PRERELEASE: ${{ inputs.prerelease }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
|
||||
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push
|
||||
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。
|
||||
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。
|
||||
create_mark=()
|
||||
edit_mark=()
|
||||
case "${PRERELEASE:-}" in
|
||||
true)
|
||||
create_mark=(--prerelease)
|
||||
edit_mark=(--prerelease)
|
||||
;;
|
||||
false)
|
||||
edit_mark=(--prerelease=false)
|
||||
;;
|
||||
"")
|
||||
create_mark=(--prerelease)
|
||||
;;
|
||||
*)
|
||||
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
|
||||
else
|
||||
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
|
||||
fi
|
||||
Generated
+8
-9
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-agent"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures-util",
|
||||
@@ -4997,13 +4997,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-app-iced"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"iced",
|
||||
"image",
|
||||
"log",
|
||||
"unlucky-cli",
|
||||
"unlucky-core",
|
||||
"unlucky-design",
|
||||
"unlucky-i18n",
|
||||
"unlucky-presenter",
|
||||
@@ -5011,7 +5010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-cli"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5022,7 +5021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-core"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -5041,15 +5040,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-design"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-i18n"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-palette-extract"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"unlucky-design",
|
||||
@@ -5057,7 +5056,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-presenter"
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ members = [
|
||||
exclude = ["crates/app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.3"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
authors = ["AskaEth <i@yeij.top>"]
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
|
||||
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||
>
|
||||
> **Version in this README: `0.2.3`** — `unlucky-launcher --version` prints the same number.
|
||||
|
||||
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
|
||||
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
|
||||
@@ -45,14 +43,6 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
|
||||
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
|
||||
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
|
||||
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
|
||||
(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
|
||||
@@ -66,17 +56,8 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
|
||||
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
|
||||
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 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,
|
||||
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
|
||||
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
|
||||
@@ -102,17 +83,16 @@ Three tracks on one page:
|
||||
- **Third-party community auth servers** (authlib-injector style).
|
||||
- **Local offline profiles**, for isolated testing.
|
||||
|
||||
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public
|
||||
client id they need (a public client id, not a credential — it ships inside the binary), so there is
|
||||
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the
|
||||
variable is read at startup, so restart the launcher after changing it. Building from source bakes in
|
||||
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id
|
||||
anywhere stops before any network call, and its alert says exactly what is missing: the title
|
||||
"Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with
|
||||
it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that
|
||||
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus
|
||||
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local
|
||||
profiles need no id.
|
||||
**Microsoft sign-in needs an Azure public client id, and no release binary carries one.** The id is
|
||||
not in this repo and CI injects none, so set `ULL_MSA_CLIENT_ID=<id>` before launching, or build with
|
||||
it (`ULL_MSA_CLIENT_ID=<id> cargo build`, which bakes it in via `option_env!`). Without one, sign-in
|
||||
stops before any network call and the alert says exactly what is missing: the title "Microsoft
|
||||
sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then
|
||||
restart the launcher — a running process does not pick up a new variable), and
|
||||
`Code: msa_not_configured`. It offers no Retry button, because retrying in that same process cannot
|
||||
help. Every other sign-in failure keeps the older shape: one category line plus its own `Code:`
|
||||
line, with the full reason only in the launcher log. Community auth servers and local profiles need
|
||||
no id.
|
||||
|
||||
### Look and language
|
||||
|
||||
@@ -179,76 +159,41 @@ The dispatch rule is deliberately narrow, so nothing that used to work changed m
|
||||
that its typo took effect. The subcommand table lives in exactly one place
|
||||
(`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`,
|
||||
`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.
|
||||
`diag`, `config`, `java`, `agent`, `version`, `help`.
|
||||
|
||||
### Not done yet
|
||||
|
||||
This list is the reason to believe the rest. None of it is hidden behind marketing copy.
|
||||
|
||||
- **No installer.** You download a file and run it — there is no setup step, and updates replace that
|
||||
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.
|
||||
- **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a
|
||||
newer version for you, and there is no update source decided yet.
|
||||
- **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
|
||||
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
|
||||
edit them rather than on a Save.
|
||||
- **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow
|
||||
builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself
|
||||
was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its
|
||||
theme/backend lines — and that same check is now a per-slot CI gate on every release
|
||||
(`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop
|
||||
distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified.
|
||||
Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window
|
||||
transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are
|
||||
delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app.
|
||||
`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.
|
||||
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
|
||||
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
|
||||
been downloaded and run end to end. Treat them as untested output, not as "supported because the
|
||||
matrix lists them".
|
||||
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why
|
||||
the build exists — 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.
|
||||
- **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.
|
||||
- **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
|
||||
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.
|
||||
- **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.
|
||||
- **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
|
||||
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
|
||||
[`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).
|
||||
- **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is
|
||||
unverified there.
|
||||
|
||||
## Download and run
|
||||
|
||||
@@ -260,28 +205,20 @@ Grab the file for your platform from
|
||||
| Slot | Asset | Shape |
|
||||
|------|-------|-------|
|
||||
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
|
||||
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack |
|
||||
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **built, not published**, see below |
|
||||
| `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 — **not built** by the release workflow |
|
||||
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow |
|
||||
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
||||
| `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 |
|
||||
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same |
|
||||
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
|
||||
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
|
||||
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
||||
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
|
||||
|
||||
**This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots
|
||||
named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it
|
||||
writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard
|
||||
(`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are
|
||||
missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that
|
||||
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.
|
||||
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is
|
||||
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
|
||||
The row stays because the architecture is still supported and the slot still builds (unpublished) in
|
||||
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
|
||||
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
|
||||
only downloadable Windows asset.
|
||||
|
||||
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
|
||||
@@ -289,18 +226,6 @@ documents are attached to the release next to it — downloading the Windows bin
|
||||
licence. Download links never carry implementation detail in the name, and the binary inside an
|
||||
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
|
||||
`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
|
||||
@@ -318,7 +243,7 @@ upstream cargo. On Linux, X11 is the preferred path today and Wayland is the lon
|
||||
Capability differences are feature gates with runtime probing, never a compile-time "this platform
|
||||
gets less".
|
||||
|
||||
First run with a Microsoft account? Nothing to configure — official builds carry the client id; see
|
||||
First run with a Microsoft account? Set `ULL_MSA_CLIENT_ID` first — see
|
||||
[Accounts](#accounts). Nothing else needs configuring.
|
||||
|
||||
## Build from source
|
||||
@@ -341,9 +266,6 @@ cargo deny check licenses bans sources # dependency licences (re
|
||||
|
||||
What the build produces:
|
||||
|
||||
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
|
||||
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
|
||||
— see [Accounts](#accounts).
|
||||
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
|
||||
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
|
||||
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
|
||||
@@ -367,8 +289,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|
||||
|----------|---------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
|
||||
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
|
||||
| `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) |
|
||||
| `.github/workflows/release-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 |
|
||||
| `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
|
||||
|
||||
## Architecture and module map
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
|
||||
|
||||
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
|
||||
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
|
||||
|
||||
> 给主线的改动清单、兼容性说明与合并指南见
|
||||
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
|
||||
|
||||
## 已验证
|
||||
|
||||
| 项 | 状态 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
|
||||
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
|
||||
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
|
||||
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
|
||||
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
|
||||
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
|
||||
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
|
||||
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
|
||||
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
完整配方(工具链、patchelf、linker、门禁)见
|
||||
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
|
||||
|
||||
```sh
|
||||
termux-x11 :0 -ac &
|
||||
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
|
||||
```
|
||||
|
||||
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
|
||||
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
|
||||
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
|
||||
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
|
||||
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
|
||||
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
|
||||
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
|
||||
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
|
||||
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
|
||||
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
|
||||
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
|
||||
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
|
||||
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
|
||||
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
|
||||
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
|
||||
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
|
||||
PC 的 Linux CI 不受影响。
|
||||
|
||||
## 后续
|
||||
|
||||
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
|
||||
2. 剪贴板/IME 在 termux-x11 下的行为验证
|
||||
3. narrator/flite 与音频设备(当前 missing 但不阻断)
|
||||
+31
-92
@@ -4,8 +4,6 @@
|
||||
|
||||
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
|
||||
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||
>
|
||||
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
|
||||
|
||||
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
|
||||
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
|
||||
@@ -35,11 +33,6 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
|
||||
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
|
||||
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
|
||||
每次运行留一份 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`、
|
||||
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
|
||||
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
|
||||
@@ -51,15 +44,8 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
|
||||
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
|
||||
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
|
||||
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
|
||||
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
|
||||
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
|
||||
写,导入报告事后仍逐条列出全部冲突。
|
||||
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
|
||||
以及一键**装入这个实例**。
|
||||
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
|
||||
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
|
||||
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
|
||||
绝不说成「没有匹配的内容」。
|
||||
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
|
||||
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
|
||||
禁用态,并把不匹配写清楚。没有一条是静默失败的。
|
||||
@@ -82,13 +68,12 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
|
||||
- **第三方社区认证服**(authlib-injector 一类)。
|
||||
- **本地离线档案**,用于隔离测试。
|
||||
|
||||
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是
|
||||
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值:
|
||||
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`,
|
||||
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题
|
||||
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带
|
||||
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是
|
||||
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
|
||||
**微软登录需要一个 Azure 公共客户端 id,而发布版二进制里没有。** 这个 id 不在仓库里,CI 也不注入,所以
|
||||
启动前设 `ULL_MSA_CLIENT_ID=<id>`(或构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`,它通过
|
||||
`option_env!` 烧进二进制)。没有它时点登录**不会联网**,弹窗直接说清是这一项没配:标题「微软登录未配置」,
|
||||
正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后要**重启启动器**——已在运行的进程读不到新设的环境
|
||||
变量),并带 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它
|
||||
登录失败仍是老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
|
||||
|
||||
### 外观与语言
|
||||
|
||||
@@ -143,59 +128,35 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
|
||||
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
|
||||
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
|
||||
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
|
||||
`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`);发布
|
||||
构建里**不存在**这条路径。
|
||||
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、
|
||||
`version`、`help`。
|
||||
|
||||
### 还没做
|
||||
|
||||
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
|
||||
|
||||
- **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
|
||||
(见[自更新与稳定通道](#自更新与稳定通道))。
|
||||
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
|
||||
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
|
||||
观察要等 `0.2.3` 发出去之后。
|
||||
- **没有安装包,也没有自动更新通道。** 下个文件直接跑,没有任何东西替你检查新版本,更新源也还没定。
|
||||
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
|
||||
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
|
||||
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
|
||||
- **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编
|
||||
`linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染
|
||||
后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里
|
||||
**这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。
|
||||
「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到
|
||||
(`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`
|
||||
能编这两档取证,但它们从不发布。
|
||||
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
|
||||
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
|
||||
「矩阵里有就算支持」。
|
||||
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
|
||||
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
|
||||
Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动 `workflow_dispatch`
|
||||
的探针、带 `continue-on-error`,不是门禁。
|
||||
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
|
||||
真正拿到的高度。
|
||||
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
|
||||
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
|
||||
`静默失败` 就是它。
|
||||
- **启动器内的助手背后没有模型**(见上)。MCP server 有。
|
||||
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
|
||||
文件是被静默覆盖的。
|
||||
- **内容库的安装进度是阶段级**,不是字节级。
|
||||
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
|
||||
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
|
||||
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。
|
||||
|
||||
## 下载与运行
|
||||
|
||||
@@ -206,37 +167,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
| 槽位 | 产物 | 形态 |
|
||||
|------|------|------|
|
||||
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
|
||||
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 |
|
||||
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 |
|
||||
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) |
|
||||
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** |
|
||||
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** |
|
||||
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
||||
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
|
||||
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 |
|
||||
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 |
|
||||
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 |
|
||||
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 |
|
||||
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
||||
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
|
||||
|
||||
**本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
|
||||
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
|
||||
**不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`):
|
||||
名单里点名了却没产物 ⇒ 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` 连显示都不需要。
|
||||
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
|
||||
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
|
||||
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
|
||||
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
|
||||
|
||||
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
|
||||
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
|
||||
@@ -255,7 +197,7 @@ Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。W
|
||||
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
|
||||
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
|
||||
|
||||
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。
|
||||
第一次用微软账户?先设 `ULL_MSA_CLIENT_ID`——见[账户](#账户)。其它都不需要配置。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
@@ -276,8 +218,6 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|
||||
|
||||
编出来的 bin:
|
||||
|
||||
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
|
||||
cargo build`);官方发布产物已内置,见[账户](#账户)。
|
||||
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
|
||||
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
|
||||
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
|
||||
@@ -297,8 +237,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|
||||
|----------|------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
|
||||
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
||||
| `.github/workflows/release.yml` | tag `v*` → 构建默认四档(`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(发布 `windows-x64` 与 `linux-x64`;`linux-x64` 另有一条同槽位的 Xvfb 首帧门禁;`-f all_slots=true` 才整表六槽位取证) |
|
||||
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
|
||||
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
|
||||
|
||||
## 架构与模块地图
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# Termux 适配:方案、改动清单与合并指南
|
||||
|
||||
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
|
||||
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
|
||||
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
|
||||
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
|
||||
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
|
||||
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
|
||||
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
|
||||
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
|
||||
|
||||
## 方案
|
||||
|
||||
### 1. 平台识别
|
||||
|
||||
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
|
||||
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
|
||||
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
|
||||
|
||||
### 2. JDK:发现 + 安装
|
||||
|
||||
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
|
||||
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
|
||||
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
|
||||
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
|
||||
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
|
||||
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
|
||||
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
|
||||
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
|
||||
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
|
||||
|
||||
### 3. 字体
|
||||
|
||||
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
|
||||
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
|
||||
|
||||
### 4. 窗口(横屏优先)
|
||||
|
||||
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
|
||||
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
|
||||
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
|
||||
|
||||
### 5. GPU(启动器)
|
||||
|
||||
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
|
||||
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
|
||||
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
|
||||
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
|
||||
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
|
||||
|
||||
### 6. Minecraft 运行链
|
||||
|
||||
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
|
||||
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
|
||||
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
|
||||
下载兜底到阿里云 Maven(`install/libraries.rs`)。
|
||||
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
|
||||
加载不了 glibc natives。
|
||||
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
|
||||
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
|
||||
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 | 平台隔离 |
|
||||
| --- | --- | --- |
|
||||
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
|
||||
| `core/platform/mod.rs` | 导出新模块 | — |
|
||||
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
|
||||
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
|
||||
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
|
||||
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
|
||||
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
|
||||
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
|
||||
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
|
||||
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
|
||||
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
|
||||
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
|
||||
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
|
||||
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
|
||||
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
|
||||
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
|
||||
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
|
||||
|
||||
## 行为变化与兼容性
|
||||
|
||||
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
|
||||
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
|
||||
默认回退序**不含** termux,PC 行为不变。
|
||||
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
|
||||
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
|
||||
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
|
||||
|
||||
## 合并指南
|
||||
|
||||
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
|
||||
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
|
||||
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
|
||||
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
|
||||
`crates/app-iced/Cargo.toml`)。
|
||||
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
|
||||
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
|
||||
3. 本机已知、**不影响 PC CI** 的三条失败:
|
||||
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
|
||||
- 同测试:长路径被脱敏成 `<opaque>`
|
||||
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
|
||||
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
|
||||
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
|
||||
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
|
||||
所以在 Termux 上跑门禁看不出来):
|
||||
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
|
||||
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
|
||||
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
|
||||
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
|
||||
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
|
||||
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
|
||||
行为不变、用例原样留在原处。
|
||||
|
||||
## 验证矩阵(本机)
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
|
||||
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
|
||||
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
|
||||
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
|
||||
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
|
||||
|
||||
## 后续路线
|
||||
|
||||
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
|
||||
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
|
||||
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
|
||||
4. narrator/flite 与音频设备(当前缺失但不阻断)。
|
||||
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
|
||||
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
|
||||
@@ -13,7 +13,7 @@ publish.workspace = true
|
||||
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
|
||||
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
|
||||
[dependencies]
|
||||
unlucky-core = { path = "../core", version = "0.2.3" }
|
||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
||||
|
||||
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
||||
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
||||
|
||||
@@ -45,7 +45,7 @@ pub use chat::{
|
||||
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
|
||||
};
|
||||
pub use exec::{ToolExecOutcome, ToolExecutor};
|
||||
pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
|
||||
pub use provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
|
||||
/// 一轮对话最多几轮工具调用。
|
||||
pub const DEFAULT_MAX_STEPS: usize = 8;
|
||||
|
||||
@@ -14,11 +14,6 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[path = "provider/failure.rs"]
|
||||
mod failure;
|
||||
|
||||
pub use failure::ProviderFailure;
|
||||
|
||||
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
|
||||
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
|
||||
/// Ollama 默认端点(research/02 §5.2)。
|
||||
@@ -59,35 +54,6 @@ impl ProviderKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
|
||||
///
|
||||
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
|
||||
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
|
||||
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
|
||||
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
|
||||
/// 未知值给 `None`(由调用方回落默认,不猜)。
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Option<Self> {
|
||||
match value.trim() {
|
||||
"openai_compatible" => Some(Self::OpenAiCompatible),
|
||||
"anthropic" => Some(Self::Anthropic),
|
||||
"ollama" => Some(Self::Ollama),
|
||||
"disabled" => Some(Self::Disabled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
|
||||
#[must_use]
|
||||
pub fn setting_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "openai_compatible",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::Ollama => "ollama",
|
||||
Self::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页展示名。
|
||||
#[must_use]
|
||||
pub fn display_name(self) -> &'static str {
|
||||
@@ -208,22 +174,18 @@ impl ProviderConfig {
|
||||
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
|
||||
pub fn validate(&self) -> Result<(), ProviderError> {
|
||||
if !self.is_enabled() {
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
return Err(ProviderError::new(
|
||||
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
|
||||
));
|
||||
}
|
||||
if self.model.trim().is_empty() {
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
"没有配置模型名(model)",
|
||||
));
|
||||
return Err(ProviderError::new("没有配置模型名(model)"));
|
||||
}
|
||||
if self.resolved_base_url().trim().is_empty() {
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
format!("{} 需要填写端点(base_url)", self.kind.display_name()),
|
||||
));
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要填写端点(base_url)",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
}
|
||||
if self.kind.requires_api_key()
|
||||
&& self
|
||||
@@ -231,10 +193,10 @@ impl ProviderConfig {
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
{
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
format!("{} 需要 API key", self.kind.display_name()),
|
||||
));
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要 API key",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -277,38 +239,20 @@ impl fmt::Debug for ProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
|
||||
/// `ErrorReport` 翻译,见 [`failure`])。
|
||||
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderError {
|
||||
kind: ProviderFailure,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProviderError {
|
||||
/// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
|
||||
/// 从可读文本构造。
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ProviderFailure::Other,
|
||||
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 {
|
||||
@@ -320,5 +264,77 @@ impl fmt::Display for ProviderError {
|
||||
impl std::error::Error for ProviderError {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "provider/tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_defaults_match_research() {
|
||||
assert_eq!(
|
||||
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
|
||||
LLAMACPP_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::ollama("llama3").resolved_base_url(),
|
||||
OLLAMA_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
|
||||
ANTHROPIC_DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_model_base_url_and_key() {
|
||||
assert!(!ProviderConfig::disabled().is_enabled());
|
||||
assert!(ProviderConfig::disabled().validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
|
||||
// OpenAI 兼容必须显式给端点
|
||||
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ProviderConfig::anthropic("claude").validate().is_err());
|
||||
assert!(ProviderConfig::anthropic("claude")
|
||||
.with_api_key("sk-ant")
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_debug_never_leaks_key() {
|
||||
let config =
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.with_api_key("sk-super-secret");
|
||||
let text = config.redacted_debug();
|
||||
assert!(!text.contains("sk-super-secret"), "{text}");
|
||||
assert!(text.contains(crate::redact::MASK));
|
||||
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("sk-super-secret"), "{debug}");
|
||||
assert!(debug.contains(crate::redact::MASK), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_base_url_falls_back_to_default() {
|
||||
let mut config = ProviderConfig::ollama("llama3");
|
||||
config.base_url = String::new();
|
||||
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_states_serialize_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
|
||||
"\"open_ai_compatible\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
|
||||
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
|
||||
//!
|
||||
//! ## 为什么不是字符串匹配
|
||||
//!
|
||||
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
|
||||
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
|
||||
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
|
||||
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
|
||||
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
|
||||
//! 一度偶发红)。
|
||||
//!
|
||||
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
|
||||
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
|
||||
//!
|
||||
//! ## 六档口径
|
||||
//!
|
||||
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
|
||||
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
|
||||
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
|
||||
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
|
||||
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
|
||||
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
|
||||
|
||||
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderFailure {
|
||||
/// 配置不完整:**没有发出任何请求**。
|
||||
NotConfigured,
|
||||
/// 鉴权失败(401 / 403 / 凭据被拒)。
|
||||
Auth,
|
||||
/// 模型不存在(404 / `model_not_found`)。
|
||||
ModelNotFound,
|
||||
/// 端点不可达(传输层失败,没拿到任何回应)。
|
||||
Unreachable,
|
||||
/// 超时(调用方时限或传输层超时)。
|
||||
Timeout,
|
||||
/// 其它(协议 / 解析 / 5xx / 未知)。
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ProviderFailure {
|
||||
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotConfigured => "not_configured",
|
||||
Self::Auth => "auth",
|
||||
Self::ModelNotFound => "model_not_found",
|
||||
Self::Unreachable => "unreachable",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
|
||||
#[must_use]
|
||||
pub const fn sent_a_request(self) -> bool {
|
||||
!matches!(self, Self::NotConfigured)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
|
||||
//!
|
||||
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
|
||||
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_defaults_match_research() {
|
||||
assert_eq!(
|
||||
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
|
||||
LLAMACPP_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::ollama("llama3").resolved_base_url(),
|
||||
OLLAMA_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
|
||||
ANTHROPIC_DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_vocabulary_round_trips() {
|
||||
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
|
||||
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
|
||||
// agent 侧读出来是 None(M174 的根因)。
|
||||
for kind in [
|
||||
ProviderKind::OpenAiCompatible,
|
||||
ProviderKind::Anthropic,
|
||||
ProviderKind::Ollama,
|
||||
ProviderKind::Disabled,
|
||||
] {
|
||||
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
|
||||
}
|
||||
assert_eq!(
|
||||
ProviderKind::from_setting(" ollama "),
|
||||
Some(ProviderKind::Ollama)
|
||||
);
|
||||
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
|
||||
assert_eq!(ProviderKind::from_setting(""), None);
|
||||
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
|
||||
assert_eq!(
|
||||
unlucky_core::setting::keys::AI_PROVIDERS,
|
||||
[
|
||||
ProviderKind::OpenAiCompatible.setting_value(),
|
||||
ProviderKind::Anthropic.setting_value(),
|
||||
ProviderKind::Ollama.setting_value(),
|
||||
ProviderKind::Disabled.setting_value(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_model_base_url_and_key() {
|
||||
assert!(!ProviderConfig::disabled().is_enabled());
|
||||
assert!(ProviderConfig::disabled().validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
|
||||
// OpenAI 兼容必须显式给端点
|
||||
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ProviderConfig::anthropic("claude").validate().is_err());
|
||||
assert!(ProviderConfig::anthropic("claude")
|
||||
.with_api_key("sk-ant")
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_debug_never_leaks_key() {
|
||||
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.with_api_key("sk-super-secret");
|
||||
let text = config.redacted_debug();
|
||||
assert!(!text.contains("sk-super-secret"), "{text}");
|
||||
assert!(text.contains(crate::redact::MASK));
|
||||
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("sk-super-secret"), "{debug}");
|
||||
assert!(debug.contains(crate::redact::MASK), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_base_url_falls_back_to_default() {
|
||||
let mut config = ProviderConfig::ollama("llama3");
|
||||
config.base_url = String::new();
|
||||
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_states_serialize_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
|
||||
"\"open_ai_compatible\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
|
||||
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ use std::sync::Arc;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
|
||||
use rig_core::error::{ErrorKind, ProviderError as RigError};
|
||||
use rig_core::error::ProviderError as RigError;
|
||||
use rig_core::message::{
|
||||
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
|
||||
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
|
||||
@@ -64,7 +64,7 @@ use super::chat::{
|
||||
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
|
||||
ProviderFuture,
|
||||
};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
|
||||
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
|
||||
pub const RIG_VERSION_PIN: &str = "=0.45.0";
|
||||
@@ -275,63 +275,9 @@ fn to_reply(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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`] |
|
||||
/// rig 错误 → 本方错误(文案面向用户)。
|
||||
fn map_error(error: RigError) -> ProviderError {
|
||||
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
|
||||
ProviderError::new(error.to_string())
|
||||
}
|
||||
|
||||
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
|
||||
@@ -341,5 +287,82 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rig/tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
|
||||
super::super::chat::ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_is_rejected_before_touching_the_network() {
|
||||
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
|
||||
panic!("禁用态应在构造阶段被拒");
|
||||
};
|
||||
assert!(err.to_string().contains("禁用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_without_key_is_rejected() {
|
||||
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
|
||||
assert!(RigProvider::new(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_states_build_offline() {
|
||||
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
|
||||
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_maps_messages_and_tools() {
|
||||
let request = ChatRequest {
|
||||
model: "mock".to_string(),
|
||||
messages: vec![
|
||||
ChatMessage::system("你是助手"),
|
||||
ChatMessage::user("把主题换成浅色"),
|
||||
ChatMessage::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![tool("call_1", "set_config")],
|
||||
},
|
||||
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
|
||||
],
|
||||
tools: vec![crate::registry::ToolDefinition {
|
||||
name: "set_config".to_string(),
|
||||
description: "改配置".to_string(),
|
||||
input_schema: serde_json::json!({ "type": "object" }),
|
||||
}],
|
||||
temperature: Some(0.5),
|
||||
max_tokens: Some(512),
|
||||
};
|
||||
let built = build_request(&request).expect("应能构造");
|
||||
assert_eq!(built.chat_history.len(), 4);
|
||||
assert_eq!(built.tools.len(), 1);
|
||||
assert_eq!(built.tools[0].name.as_str(), "set_config");
|
||||
assert_eq!(built.temperature, Some(0.5));
|
||||
assert_eq!(built.max_tokens, Some(512));
|
||||
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
|
||||
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
|
||||
assert!(json.contains("toolresult"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_a_readable_error() {
|
||||
let request = ChatRequest {
|
||||
model: "m".to_string(),
|
||||
messages: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let err = build_request(&request).unwrap_err();
|
||||
assert!(err.to_string().contains("对话历史为空"));
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
|
||||
//!
|
||||
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
|
||||
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
|
||||
super::super::chat::ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_is_rejected_before_touching_the_network() {
|
||||
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
|
||||
panic!("禁用态应在构造阶段被拒");
|
||||
};
|
||||
assert!(err.to_string().contains("禁用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_without_key_is_rejected() {
|
||||
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
|
||||
assert!(
|
||||
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_states_build_offline() {
|
||||
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
|
||||
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_maps_messages_and_tools() {
|
||||
let request = ChatRequest {
|
||||
model: "mock".to_string(),
|
||||
messages: vec![
|
||||
ChatMessage::system("你是助手"),
|
||||
ChatMessage::user("把主题换成浅色"),
|
||||
ChatMessage::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![tool("call_1", "set_config")],
|
||||
},
|
||||
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
|
||||
],
|
||||
tools: vec![crate::registry::ToolDefinition {
|
||||
name: "set_config".to_string(),
|
||||
description: "改配置".to_string(),
|
||||
input_schema: serde_json::json!({ "type": "object" }),
|
||||
}],
|
||||
temperature: Some(0.5),
|
||||
max_tokens: Some(512),
|
||||
};
|
||||
let built = build_request(&request).expect("应能构造");
|
||||
assert_eq!(built.chat_history.len(), 4);
|
||||
assert_eq!(built.tools.len(), 1);
|
||||
assert_eq!(built.tools[0].name.as_str(), "set_config");
|
||||
assert_eq!(built.temperature, Some(0.5));
|
||||
assert_eq!(built.max_tokens, Some(512));
|
||||
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
|
||||
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
|
||||
assert!(json.contains("toolresult"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_a_readable_error() {
|
||||
let request = ChatRequest {
|
||||
model: "m".to_string(),
|
||||
messages: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let err = build_request(&request).unwrap_err();
|
||||
assert!(err.to_string().contains("对话历史为空"));
|
||||
}
|
||||
@@ -22,15 +22,12 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||
unlucky-design = { path = "../design", version = "0.2.3" }
|
||||
|
||||
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
|
||||
unlucky-core = { path = "../core", version = "0.2.3" }
|
||||
unlucky-design = { path = "../design", version = "0.2.1" }
|
||||
|
||||
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
|
||||
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
|
||||
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
|
||||
unlucky-presenter = { path = "../presenter", version = "0.2.3" }
|
||||
unlucky-presenter = { path = "../presenter", version = "0.2.1" }
|
||||
|
||||
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
||||
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
||||
@@ -60,11 +57,11 @@ image = { version = "0.25", default-features = false, features = [
|
||||
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
||||
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
|
||||
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.2.3" }
|
||||
unlucky-cli = { path = "../cli", version = "0.2.1" }
|
||||
|
||||
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
|
||||
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
|
||||
unlucky-i18n = { path = "../i18n", version = "0.2.3" }
|
||||
unlucky-i18n = { path = "../i18n", version = "0.2.1" }
|
||||
|
||||
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
||||
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
||||
@@ -90,13 +87,6 @@ iced = { version = "=0.14.0", default-features = false, features = [
|
||||
default = ["gpu"]
|
||||
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
||||
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]
|
||||
workspace = true
|
||||
@@ -30,8 +30,6 @@ use super::{MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod diagnostics;
|
||||
mod fallback;
|
||||
mod termux_gpu;
|
||||
mod termux_window;
|
||||
|
||||
use fallback::{on_failure, respawn, OnFailure};
|
||||
|
||||
@@ -41,8 +39,6 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let boot_started = Instant::now();
|
||||
|
||||
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
||||
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
|
||||
termux_gpu::enable();
|
||||
let boot = boot_probe(&options);
|
||||
let decision = boot.decision;
|
||||
diagnostics::report_boot(&options, &boot.probe, &decision);
|
||||
@@ -135,20 +131,10 @@ fn launch(
|
||||
|
||||
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
|
||||
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
|
||||
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
|
||||
// 失败就退回 1180×760 的验收尺寸。
|
||||
let (default_size, default_source) = termux_window::default_size(
|
||||
let (size, size_source) = acceptance::window_size(
|
||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||
(MIN_SIZE.width, MIN_SIZE.height),
|
||||
)
|
||||
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
|
||||
let (size, size_source) =
|
||||
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
|
||||
let size_source = if size_source == "default" {
|
||||
default_source
|
||||
} else {
|
||||
size_source
|
||||
};
|
||||
);
|
||||
let settings = diagnostics::window_settings(size);
|
||||
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
||||
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
||||
|
||||
@@ -35,11 +35,6 @@ pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
|
||||
size: iced::Size::new(size.0, size.1),
|
||||
min_size: Some(MIN_SIZE),
|
||||
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
|
||||
//
|
||||
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
|
||||
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
|
||||
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
|
||||
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
|
||||
//!
|
||||
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
|
||||
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
|
||||
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
|
||||
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
|
||||
//!
|
||||
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
|
||||
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
|
||||
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
|
||||
|
||||
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
|
||||
pub(super) fn enable() {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return;
|
||||
}
|
||||
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|
||||
|| std::env::var_os("VK_DRIVER_FILES").is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(prefix) = termux::prefix(&ProcessEnv) else {
|
||||
return;
|
||||
};
|
||||
let icd = icd_path(&prefix);
|
||||
if icd.is_file() {
|
||||
std::env::set_var("VK_ICD_FILENAMES", &icd);
|
||||
std::env::set_var("VK_DRIVER_FILES", &icd);
|
||||
}
|
||||
}
|
||||
|
||||
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
|
||||
fn icd_path(prefix: &Path) -> PathBuf {
|
||||
prefix.join(ICD_REL)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::icd_path;
|
||||
|
||||
#[test]
|
||||
fn icd_path_is_under_the_glibc_prefix() {
|
||||
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
|
||||
assert_eq!(
|
||||
path,
|
||||
std::path::PathBuf::from(
|
||||
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
|
||||
//!
|
||||
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
|
||||
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
|
||||
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
|
||||
//!
|
||||
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
/// 屏幕四周留白(像素)。
|
||||
const MARGIN: f32 = 32.0;
|
||||
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
|
||||
const MAX_SCALE: f32 = 2.0;
|
||||
/// `xdpyinfo` 里尺寸那一行的前缀。
|
||||
const DIMENSIONS_PREFIX: &str = "dimensions:";
|
||||
|
||||
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
|
||||
///
|
||||
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
|
||||
/// 这里不复制常量。
|
||||
#[must_use]
|
||||
pub(super) fn default_size(
|
||||
default: (f32, f32),
|
||||
min: (f32, f32),
|
||||
) -> Option<((f32, f32), &'static str)> {
|
||||
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
|
||||
return None;
|
||||
}
|
||||
let screen = x_screen_size()?;
|
||||
Some((fit_screen(default, min, screen), "termux-screen"))
|
||||
}
|
||||
|
||||
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
|
||||
#[must_use]
|
||||
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
|
||||
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
|
||||
return default;
|
||||
}
|
||||
let available = (
|
||||
(screen.0 - MARGIN * 2.0).max(1.0),
|
||||
(screen.1 - MARGIN * 2.0).max(1.0),
|
||||
);
|
||||
let scale = (available.0 / default.0)
|
||||
.min(available.1 / default.1)
|
||||
.min(MAX_SCALE);
|
||||
let width = (default.0 * scale).max(min.0).min(screen.0);
|
||||
let height = (default.1 * scale).max(min.1).min(screen.1);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
|
||||
fn x_screen_size() -> Option<(f32, f32)> {
|
||||
let output = Command::new("xdpyinfo").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
|
||||
let value = line.split_whitespace().nth(1)?;
|
||||
let (width, height) = value.split_once('x')?;
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fit_screen;
|
||||
|
||||
const DEFAULT: (f32, f32) = (1180.0, 760.0);
|
||||
const MIN: (f32, f32) = (880.0, 560.0);
|
||||
|
||||
fn close(actual: (f32, f32), expected: (f32, f32)) {
|
||||
assert!(
|
||||
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
|
||||
"expected {expected:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_screen_scales_up_keeping_aspect() {
|
||||
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
|
||||
close(
|
||||
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
|
||||
(1875.58, 1208.0),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_screen_still_fits_width() {
|
||||
// 1272×2066:宽度是瓶颈 → 1208×778。
|
||||
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_screen_is_capped_at_two_times() {
|
||||
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_screen_never_goes_below_min_or_overflow() {
|
||||
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
|
||||
assert_eq!(width, 800.0, "宽度受屏幕限制");
|
||||
assert_eq!(height, 560.0, "高度不小于最小尺寸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_screen_falls_back_to_default() {
|
||||
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -130,12 +130,6 @@ pub struct App {
|
||||
pub(super) refreshes: u64,
|
||||
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
|
||||
pub(super) auto_selected: bool,
|
||||
/// 上一次看到的**中文字体下载状态**(M168)。
|
||||
///
|
||||
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
|
||||
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
|
||||
/// 而空闲态不会为了它每拍白重建一次。
|
||||
pub(super) font_seen: crate::fonts::install::CjkInstall,
|
||||
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
|
||||
pub(super) height: f32,
|
||||
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
|
||||
@@ -174,9 +168,6 @@ pub struct App {
|
||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||
pub(super) install_cancelled_seen: u64,
|
||||
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||
pub(super) launch_ends_seen: u64,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
@@ -187,12 +178,6 @@ pub struct App {
|
||||
pub(super) account_form: pages::account::Form,
|
||||
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
|
||||
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,
|
||||
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
|
||||
pub(super) avatars: pages::account::avatar::Cache,
|
||||
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
|
||||
|
||||
@@ -86,14 +86,9 @@ impl App {
|
||||
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
|
||||
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
|
||||
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(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
height,
|
||||
Some(pages::Screen::Instances.nav()),
|
||||
);
|
||||
view.lang = lang.lang();
|
||||
@@ -118,9 +113,6 @@ impl App {
|
||||
frames: 0,
|
||||
refreshes: 0,
|
||||
auto_selected: false,
|
||||
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
|
||||
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
|
||||
font_seen: crate::fonts::install::status(),
|
||||
height,
|
||||
appearance,
|
||||
appearance_prefs,
|
||||
@@ -132,9 +124,7 @@ impl App {
|
||||
agent_host: super::presenter::AgentHost::from_env(),
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
launch_form: pages::LaunchForm::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
content: pages::content::PageState::default(),
|
||||
account_form: pages::account::Form::new(),
|
||||
account: pages::account::Page::build(
|
||||
@@ -149,10 +139,6 @@ impl App {
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
||||
app.hydrate_ai_settings();
|
||||
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
|
||||
app.hydrate_java_settings();
|
||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
@@ -37,23 +34,6 @@ impl App {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
@@ -66,42 +46,16 @@ impl App {
|
||||
self.view = super::super::view::model(
|
||||
&self.state,
|
||||
width,
|
||||
self.height,
|
||||
self.options.force_state,
|
||||
category,
|
||||
&draft,
|
||||
self.lang(),
|
||||
&self.content,
|
||||
);
|
||||
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
self.sync_launch_form();
|
||||
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
|
||||
// 皮肤解码走缓存,所以重复重建不重解图)。
|
||||
self.rebuild_account_page(width);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
|
||||
///
|
||||
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
|
||||
/// 免得「A 的草稿被当成 B 的初值」。
|
||||
fn sync_launch_form(&mut self) {
|
||||
let Route::Detail(id) = self.state.route() else {
|
||||
self.launch_form.bound = None;
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.state
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
{
|
||||
Some(instance) => {
|
||||
let view = instance.launch.clone();
|
||||
self.launch_form.sync(&view);
|
||||
}
|
||||
None => self.launch_form.bound = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,6 @@ impl App {
|
||||
self.has_screenshot()
|
||||
|| !self.state.toasts.is_empty()
|
||||
|| !self.state.running().is_empty()
|
||||
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|
||||
|| self.settings.java.scanning.is_some()
|
||||
|| self
|
||||
.watch_until
|
||||
.is_some_and(|deadline| Instant::now() < deadline)
|
||||
|
||||
@@ -12,8 +12,6 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
#[cfg(test)]
|
||||
mod appearance;
|
||||
#[cfg(test)]
|
||||
mod instances_save_loop;
|
||||
#[cfg(test)]
|
||||
mod presenter;
|
||||
|
||||
use super::message::Message;
|
||||
|
||||
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
|
||||
assert_eq!(app.view.screen, Screen::Settings);
|
||||
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
|
||||
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
|
||||
drop(_tree);
|
||||
|
||||
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
|
||||
app.appearance.apply(Change::SelectGroup(0));
|
||||
assert_eq!(app.appearance.selected_group, 0);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
|
||||
}
|
||||
|
||||
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
|
||||
//!
|
||||
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
|
||||
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
|
||||
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
|
||||
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
|
||||
//!
|
||||
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
|
||||
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
|
||||
LoaderInfo, LoaderKind,
|
||||
};
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::toast::{Toast, ToastKind};
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::{App, TICK};
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
|
||||
#[derive(Debug, Default)]
|
||||
struct FailingSaveBackend {
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
queued: Vec<AppEvent>,
|
||||
}
|
||||
|
||||
impl Backend for FailingSaveBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.queued)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatched.borrow_mut().push(command.clone());
|
||||
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
|
||||
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
|
||||
self.queued.push(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Error,
|
||||
text: "内存超出范围".to_string(),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
|
||||
fn snapshot() -> Vec<InstanceSummary> {
|
||||
vec![InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView {
|
||||
instance_id: "survival".to_string(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let dispatched = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = FailingSaveBackend {
|
||||
dispatched: Rc::clone(&dispatched),
|
||||
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
|
||||
};
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
(app, dispatched)
|
||||
}
|
||||
|
||||
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
|
||||
///
|
||||
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
|
||||
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
|
||||
/// 下面的前置断言会红)。
|
||||
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
|
||||
#[test]
|
||||
fn failed_save_keeps_the_draft_through_the_whole_loop() {
|
||||
let (mut app, dispatched) = app_with_snapshot();
|
||||
|
||||
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
app.advance(TICK);
|
||||
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
|
||||
assert!(!app.launch_form.dirty);
|
||||
|
||||
// 用户把内存改成非法值(host 会拒),然后点保存。
|
||||
let _ = app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::Memory("4g".to_string()),
|
||||
)));
|
||||
assert_eq!(app.launch_form.memory, "4g");
|
||||
let _ = app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::Save,
|
||||
)));
|
||||
assert!(
|
||||
dispatched
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
|
||||
"保存要把命令派发出去"
|
||||
);
|
||||
|
||||
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
|
||||
app.advance(TICK);
|
||||
|
||||
assert_eq!(
|
||||
app.launch_form.memory, "4g",
|
||||
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
|
||||
);
|
||||
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
|
||||
}
|
||||
@@ -47,12 +47,9 @@ mod appearance;
|
||||
mod boot_env;
|
||||
mod clock;
|
||||
mod content;
|
||||
mod content_pack;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
mod update_action;
|
||||
|
||||
impl App {
|
||||
/// 一条消息进来,改状态并返回后续任务。
|
||||
|
||||
@@ -95,7 +95,7 @@ impl App {
|
||||
self.dispatch(AppCommand::AddAuthlibAccount(request));
|
||||
}
|
||||
}
|
||||
Panel::Microsoft => return self.on_account(Account::StartDevice),
|
||||
Panel::Microsoft => return self.on_account(Account::StartBrowser),
|
||||
Panel::None => {}
|
||||
},
|
||||
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
|
||||
@@ -136,8 +136,7 @@ impl App {
|
||||
match account.kind {
|
||||
AccountKind::Microsoft => {
|
||||
self.account_form.panel = Panel::Microsoft;
|
||||
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
|
||||
self.dispatch(AppCommand::StartDeviceLogin);
|
||||
self.dispatch(AppCommand::StartBrowserLogin);
|
||||
}
|
||||
AccountKind::Authlib => {
|
||||
self.account_form.open(Track::Authlib);
|
||||
@@ -174,23 +173,6 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
app.state.device_login_started =
|
||||
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)`
|
||||
"authlib-failed" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Authlib;
|
||||
@@ -216,11 +198,6 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
"closed" => {
|
||||
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" => {
|
||||
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);
|
||||
match &app.account.device {
|
||||
DevicePanel::Code {
|
||||
code, countdown, ..
|
||||
code, remaining, ..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
|
||||
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
|
||||
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
|
||||
}
|
||||
other => panic!("进行中状态必须可见,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -42,16 +42,6 @@ impl App {
|
||||
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
|
||||
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,
|
||||
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
|
||||
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
|
||||
@@ -78,10 +68,6 @@ impl App {
|
||||
self.apply_config_theme();
|
||||
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
|
||||
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 {
|
||||
@@ -125,26 +111,12 @@ impl App {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut clean_scan = false;
|
||||
for event in events {
|
||||
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
|
||||
if matches!(
|
||||
&event,
|
||||
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
|
||||
pack
|
||||
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
|
||||
) {
|
||||
clean_scan = true;
|
||||
}
|
||||
self.state.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
self.auto_select();
|
||||
self.refresh_view();
|
||||
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
|
||||
if clean_scan {
|
||||
self.continue_import_after_clean_scan();
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
|
||||
ExportRequestView,
|
||||
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
|
||||
ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
@@ -77,12 +77,7 @@ impl App {
|
||||
self.with_query(|query| query.loader = loader)
|
||||
}
|
||||
pages::content::Message::Select(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.content.selected = Some(id);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::SetTarget(id) => {
|
||||
@@ -109,11 +104,149 @@ impl App {
|
||||
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」必须真的进请求**
|
||||
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
|
||||
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
let instance = self.content.pack.instance.clone()?;
|
||||
Some(pages::content::request_export(
|
||||
&self.content.pack,
|
||||
@@ -166,26 +299,6 @@ impl App {
|
||||
.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_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
|
||||
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
|
||||
//!
|
||||
//! ## 两条口径(与原来同一句)
|
||||
//!
|
||||
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
|
||||
//! 状态,不进 presenter;
|
||||
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
|
||||
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
//!
|
||||
//! ## M160 加的那条纪律
|
||||
//!
|
||||
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
|
||||
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
|
||||
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 导入/导出区的消息(M113)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||||
use pages::content::PackMessage;
|
||||
match message {
|
||||
PackMessage::Dropped(path) => {
|
||||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||||
self.content.pack.draft = path.display().to_string();
|
||||
self.content.pack.hover = false;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Hover(hover) => {
|
||||
self.content.pack.hover = hover;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PathChanged(path) => {
|
||||
self.content.pack.draft = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstance(instance) => {
|
||||
self.content.pack.instance = instance;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstanceName(name) => {
|
||||
self.content.pack.instance_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetFormat(format) => {
|
||||
self.content.pack.format = format;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportName(name) => {
|
||||
self.content.pack.export_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportVersion(version) => {
|
||||
self.content.pack.export_version = version;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetOutput(path) => {
|
||||
self.content.pack.output = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ToggleConflict(path) => {
|
||||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::OpenExport => {
|
||||
self.content.pack.export_open = true;
|
||||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||||
if self.content.pack.export_name.trim().is_empty() {
|
||||
self.content.pack.export_name =
|
||||
self.content.pack.instance.clone().unwrap_or_default();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import => {
|
||||
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
if let Some(request) = self.pack_import_request() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::PickPage(page) => {
|
||||
self.content.pack.pick_page = page;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::TogglePick(path) => {
|
||||
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
|
||||
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
|
||||
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
|
||||
// (所以"一次都没点过"仍然等于 `KeepAll`)。
|
||||
let choice = self.content.pack.pick_choice(&path);
|
||||
let flipped = match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
|
||||
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
|
||||
};
|
||||
self.content.pack.pick_choices.insert(path, flipped);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickKeepAll => {
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickOverwriteAll => {
|
||||
self.content.pack.pick_overwrite_all = true;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ConfirmImport => {
|
||||
let decisions = self
|
||||
.content
|
||||
.pack
|
||||
.decisions(self.state.pack.import.scan.as_ref());
|
||||
if let Some(request) = self.pack_import_request_with(decisions) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::CancelImport => {
|
||||
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
|
||||
if let Some(request) =
|
||||
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
|
||||
{
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Cancel => {
|
||||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||||
self.dispatch(AppCommand::CancelInstall);
|
||||
}
|
||||
PackMessage::Precheck => {
|
||||
if let Some(request) = self.pack_export_request() {
|
||||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||||
self.state.pack.set_export_request(request.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Confirm => {
|
||||
if let Some(request) = self.state.pack.export.request.clone() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检干净(没有冲突)时直接往下导。
|
||||
///
|
||||
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
|
||||
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
|
||||
/// 结论也只会是"保留用户的"。
|
||||
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
|
||||
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
|
||||
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
|
||||
fn pack_import_request(&self) -> Option<ImportRequestView> {
|
||||
self.pack_import_request_with(ConflictDecisionsView::default())
|
||||
}
|
||||
|
||||
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
|
||||
fn pack_import_request_with(
|
||||
&self,
|
||||
decisions: ConflictDecisionsView,
|
||||
) -> Option<ImportRequestView> {
|
||||
let draft = self.content.pack.draft.trim();
|
||||
if draft.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self.content.pack.instance_name.trim();
|
||||
Some(ImportRequestView {
|
||||
pack: std::path::PathBuf::from(draft),
|
||||
instance: self.content.pack.instance.clone(),
|
||||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||||
decisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! 实例页「启动参数」区的消息落点(M157)。
|
||||
//!
|
||||
//! 三件事:
|
||||
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
|
||||
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
|
||||
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
|
||||
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
|
||||
//!
|
||||
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
|
||||
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 启动参数区的一条消息。
|
||||
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
|
||||
let Some(instance) = self.selected_instance_id() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.launch_form.bound_to(&instance) {
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match message {
|
||||
InstanceMessage::Jvm(text) => {
|
||||
self.launch_form.jvm = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Game(text) => {
|
||||
self.launch_form.game = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Memory(text) => {
|
||||
self.launch_form.memory = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Java(text) => {
|
||||
self.launch_form.java = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Backend(id) => {
|
||||
self.launch_form.backend = id;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Reset => {
|
||||
self.launch_form.reset();
|
||||
}
|
||||
InstanceMessage::Save => {
|
||||
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
|
||||
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
|
||||
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
|
||||
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
|
||||
let patch: LaunchArgsPatch = self.launch_form.patch();
|
||||
self.dispatch(AppCommand::set_launch_args(&instance, patch));
|
||||
}
|
||||
}
|
||||
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
|
||||
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
|
||||
fn selected_instance_id(&self) -> Option<String> {
|
||||
match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::LaunchArgsView;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages::{InstanceMessage, LaunchForm};
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn app_with_form() -> App {
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(unlucky_presenter::mock::MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
|
||||
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
|
||||
app.state.navigate(unlucky_presenter::router::Route::Detail(
|
||||
INSTANCE.to_string(),
|
||||
));
|
||||
app.launch_form = LaunchForm::from_view(
|
||||
Some(INSTANCE),
|
||||
&LaunchArgsView {
|
||||
instance_id: INSTANCE.to_string(),
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
/// 测试用的实例 id(mock 后端的实例快照里有它)。
|
||||
const INSTANCE: &str = "survival";
|
||||
|
||||
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
|
||||
#[test]
|
||||
fn editing_marks_dirty_and_reset_waits_for_save() {
|
||||
let mut app = app_with_form();
|
||||
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
|
||||
assert_eq!(app.launch_form.memory, "6144");
|
||||
assert!(app.launch_form.dirty);
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
|
||||
assert_eq!(app.launch_form.backend, "vulkan");
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::Reset);
|
||||
assert_eq!(app.launch_form.memory, "");
|
||||
assert_eq!(app.launch_form.backend, "default");
|
||||
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
|
||||
}
|
||||
|
||||
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
|
||||
#[test]
|
||||
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
|
||||
let mut app = app_with_form();
|
||||
app.launch_form = LaunchForm::from_view(
|
||||
Some("other"),
|
||||
&LaunchArgsView {
|
||||
instance_id: "other".to_string(),
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
);
|
||||
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
|
||||
assert_eq!(app.launch_form.memory, "");
|
||||
assert!(!app.launch_form.dirty);
|
||||
}
|
||||
|
||||
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
|
||||
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
|
||||
///
|
||||
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
|
||||
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
|
||||
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
|
||||
#[test]
|
||||
fn save_dispatch_keeps_the_dirty_flag() {
|
||||
let mut app = app_with_form();
|
||||
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
|
||||
assert!(app.launch_form.dirty);
|
||||
let _ = app.on_instance_message(InstanceMessage::Save);
|
||||
assert!(
|
||||
app.launch_form.dirty,
|
||||
"派发后仍要保留脏标记,等回程快照确认"
|
||||
);
|
||||
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
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::Appearance(change) => self.on_appearance(change),
|
||||
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
|
||||
@@ -90,8 +88,6 @@ impl App {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
|
||||
pages::Message::Instances(message) => return self.on_instance_message(message),
|
||||
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
|
||||
pages::Message::Account(message) => return self.on_account(message),
|
||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||
|
||||
@@ -1,170 +1,17 @@
|
||||
//! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
|
||||
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
|
||||
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
|
||||
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
|
||||
//!
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `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`)。用户在输入框里改了但没
|
||||
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
|
||||
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::ai::{Probe, ProbeResult};
|
||||
|
||||
impl App {
|
||||
/// 助手分组的一条消息。
|
||||
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
|
||||
pub(super) fn on_settings_ai_about(
|
||||
&mut self,
|
||||
message: pages::settings::ai_about::Message,
|
||||
) -> Task<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)),
|
||||
),
|
||||
))
|
||||
},
|
||||
)
|
||||
match message {}
|
||||
}
|
||||
}
|
||||
@@ -1,291 +1,33 @@
|
||||
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
|
||||
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
|
||||
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
|
||||
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
|
||||
//!
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
|
||||
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
|
||||
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
|
||||
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
|
||||
//! 消息进来的通道有两条,都不经过共享文件:
|
||||
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
|
||||
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
|
||||
//! (`app::Message::Page` 是既有通道,不用加新变体)。
|
||||
//!
|
||||
//! ## 为什么「设为默认」不攒到「保存」里
|
||||
//!
|
||||
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
|
||||
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
|
||||
//! 界面还标着旧的默认值,只会以为没生效。
|
||||
//!
|
||||
//! ## 版本门槛(「为什么选它」那句话的数据)
|
||||
//!
|
||||
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
|
||||
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
|
||||
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
|
||||
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
|
||||
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
|
||||
impl App {
|
||||
/// Java 分组的一条消息。
|
||||
pub(super) fn on_settings_java(
|
||||
&mut self,
|
||||
message: pages::settings::java::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::java::Message;
|
||||
|
||||
match message {
|
||||
Message::Scan => return self.start_java_scan(),
|
||||
Message::CancelScan => {
|
||||
self.settings.java.scan.cancel();
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
Message::AddDraft(value) => {
|
||||
self.settings.java.add_draft = value;
|
||||
self.settings.java.notice = None;
|
||||
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
|
||||
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
|
||||
#[cfg(test)]
|
||||
pages::settings::java::Message::Ping => {
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
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(),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||
fn touch_java(&mut self) {
|
||||
self.settings.java.dirty = true;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
|
||||
///
|
||||
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
|
||||
///
|
||||
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
|
||||
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
|
||||
///
|
||||
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
|
||||
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
|
||||
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
|
||||
pub(in crate::app) fn hydrate_java_settings(&mut self) {
|
||||
let seeded = self.options.screenshot.is_some();
|
||||
let mut live = false;
|
||||
if seeded {
|
||||
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
|
||||
if raw.trim() == "live" {
|
||||
live = true;
|
||||
} else {
|
||||
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
|
||||
self.settings.java = state;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
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);
|
||||
let scan_changed = self.hydrate_java_scan();
|
||||
self.settings.java.context = self.java_context();
|
||||
if changed || scan_changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
|
||||
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
|
||||
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
|
||||
if !cfg!(test) {
|
||||
self.auto_scan_java_if_due(seeded && !live);
|
||||
}
|
||||
}
|
||||
|
||||
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
|
||||
///
|
||||
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
|
||||
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
|
||||
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
|
||||
}
|
||||
|
||||
/// 把后台扫描的快照灌进状态。返回是否变了。
|
||||
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
let changed = self.settings.java.apply_scan(&report);
|
||||
if changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
|
||||
fn start_java_scan(&mut self) -> Task<Message> {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
let manual = pages::settings::java::store::load_manual(&config)
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
let roots = pages::settings::java::store::managed_roots(&config);
|
||||
self.settings.java.notice = None;
|
||||
if self.settings.java.scan.start(roots, manual, true) {
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
|
||||
fn add_java_manual(&mut self) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
let draft = self.settings.java.add_draft.clone();
|
||||
match pages::settings::java::store::add_manual(&config, &draft) {
|
||||
Ok((binary, row)) => {
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
if !self
|
||||
.settings
|
||||
.java
|
||||
.runtimes
|
||||
.iter()
|
||||
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
|
||||
{
|
||||
self.settings.java.runtimes.push(row);
|
||||
self.settings
|
||||
.java
|
||||
.runtimes
|
||||
.sort_by_key(|runtime| runtime.major);
|
||||
}
|
||||
self.settings.java.add_draft = String::new();
|
||||
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
|
||||
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
|
||||
self.settings.java.scanned = true;
|
||||
self.settings.java.notice = Some(Notice::Added(binary));
|
||||
}
|
||||
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
|
||||
fn select_java_default(&mut self, binary: &str) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::set_selected(&config, binary) {
|
||||
Ok(snapshot) => {
|
||||
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
|
||||
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
|
||||
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
|
||||
fn save_java_settings(&mut self) {
|
||||
let state = &self.settings.java;
|
||||
if let Err(refusal) = state.validate() {
|
||||
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::save(&config, &self.settings.java) {
|
||||
Ok(snapshot) => {
|
||||
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||
self.settings.java.dirty = false;
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::Saved);
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
|
||||
///
|
||||
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
|
||||
/// (页面就不画门槛那一行,也不假装有要求)。
|
||||
fn java_context(&self) -> Context {
|
||||
let tiles = self.state.tiles();
|
||||
let routed = match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => {
|
||||
tiles.iter().find(|cell| &cell.instance.id == id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(cell) = routed.or_else(|| tiles.first()) else {
|
||||
return Context::default();
|
||||
};
|
||||
let instance = cell.instance;
|
||||
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
|
||||
Context {
|
||||
required: Some(Requirement {
|
||||
instance: instance.name.clone(),
|
||||
version: instance.mc_version.clone(),
|
||||
minimum: requirement.minimum,
|
||||
maximum: requirement.maximum,
|
||||
}),
|
||||
layer: Some(match instance.launch.memory_mb {
|
||||
Some(mb) => Layer::Instance {
|
||||
instance: instance.name.clone(),
|
||||
mb,
|
||||
},
|
||||
None => Layer::Global,
|
||||
}),
|
||||
effective_memory_mb: Some(instance.launch.memory_effective_mb),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写盘失败 → 回执:三档都走 i18n key。
|
||||
///
|
||||
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
|
||||
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
|
||||
/// 界面上给一句页面自己的「写不进去」。
|
||||
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) => {
|
||||
eprintln!("[java] 设置写盘失败:{detail}");
|
||||
Notice::WriteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,12 +256,8 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
let mut app = headless();
|
||||
app.view_dirty = false;
|
||||
|
||||
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
|
||||
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
|
||||
let _ = app.update(Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
|
||||
"4096".to_string(),
|
||||
)),
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
|
||||
)));
|
||||
|
||||
assert!(
|
||||
@@ -269,128 +265,3 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
"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;
|
||||
@@ -1,163 +0,0 @@
|
||||
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
|
||||
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
|
||||
|
||||
use super::bridged_app;
|
||||
use crate::app::Message;
|
||||
use crate::pages;
|
||||
|
||||
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
|
||||
|
||||
/// 一条 Java 分组消息 → 页面消息。
|
||||
fn java(message: pages::settings::java::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
|
||||
///
|
||||
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
|
||||
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
|
||||
/// 一个字节都没落盘、也没有任何提示。
|
||||
///
|
||||
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
|
||||
/// JVM 回到空、`dirty` 变假)。
|
||||
#[test]
|
||||
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
|
||||
let (mut app, path) = bridged_app("java-select");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::EditMemory(
|
||||
"6144".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(
|
||||
"-XX:+UseG1GC".to_string(),
|
||||
)));
|
||||
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
|
||||
"C:/jdk-21/bin/java.exe".to_string(),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
|
||||
"设为默认必须真的写进去"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.memory, "6144",
|
||||
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.jvm, "-XX:+UseG1GC",
|
||||
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.dirty,
|
||||
"内存 / JVM 参数还没保存,不许标成已保存"
|
||||
);
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 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");
|
||||
|
||||
// 真起一次扫描(注入卡住的作业),再取消,等线程退出: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());
|
||||
|
||||
let started = app.auto_scan_java_if_due(false);
|
||||
assert!(
|
||||
!started,
|
||||
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
|
||||
);
|
||||
assert!(
|
||||
!slot.report().running,
|
||||
"不许起第二个扫描线程:{:?}",
|
||||
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 失败 @ …`。
|
||||
///
|
||||
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。
|
||||
#[test]
|
||||
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
|
||||
let (mut app, path) = bridged_app("java-schema");
|
||||
|
||||
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度,
|
||||
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。
|
||||
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));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.notice,
|
||||
Some(pages::settings::java::Notice::WriteFailed),
|
||||
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
|
||||
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
|
||||
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
|
||||
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
|
||||
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
|
||||
|
||||
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> {
|
||||
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
||||
///
|
||||
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
||||
/// 文件、并把取证进程拖成死循环。
|
||||
fn restart_now(&mut self) -> Task<Message> {
|
||||
if self.options.screenshot.is_some() {
|
||||
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
|
||||
return Task::none();
|
||||
}
|
||||
match std::env::current_exe() {
|
||||
Ok(exe) => {
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
match std::process::Command::new(&exe).args(&args).spawn() {
|
||||
Ok(child) => eprintln!(
|
||||
"[update] 已拉起新进程 pid={}:{}",
|
||||
child.id(),
|
||||
exe.display()
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("[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(),
|
||||
"重启不是一条后端指令(不该派发任何命令)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,12 @@ use super::state::{log_first_frame, App};
|
||||
use super::APP_TITLE;
|
||||
|
||||
mod account_page;
|
||||
pub(super) mod instances;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
mod update;
|
||||
|
||||
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||
use instances::{detail_of, runtime_status, supporting_line};
|
||||
use logs::logs_model;
|
||||
use update::update_of;
|
||||
|
||||
impl App {
|
||||
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
|
||||
@@ -94,8 +92,6 @@ impl App {
|
||||
},
|
||||
// M106:设置页 General 组要画「当前档位 + 生效语言」。
|
||||
self.language(),
|
||||
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
|
||||
&self.launch_form,
|
||||
)
|
||||
.map(Message::Page),
|
||||
agent::docked(&self.ui, session, agent_catalog, self.width)
|
||||
@@ -140,7 +136,6 @@ impl App {
|
||||
pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
height: f32,
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
draft: &pages::InstallDraft,
|
||||
@@ -159,7 +154,7 @@ pub(super) fn model(
|
||||
id: cell.instance.id.clone(),
|
||||
name: cell.instance.name.clone(),
|
||||
supporting: supporting_line(cell.instance, catalog),
|
||||
status: runtime_status_row(&cell.instance.runtime, catalog),
|
||||
status: runtime_status(&cell.instance.runtime, catalog),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -175,15 +170,12 @@ pub(super) fn model(
|
||||
pages::Model {
|
||||
screen,
|
||||
window_width: width,
|
||||
window_height: height,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
lang,
|
||||
forced: forced.map(|state| match state {
|
||||
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
|
||||
|
||||
@@ -18,13 +18,10 @@
|
||||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
use crate::pages::{Detail, Meta};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
@@ -62,7 +59,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
|
||||
}
|
||||
|
||||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||||
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Idle => None,
|
||||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||||
@@ -74,103 +71,13 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
|
||||
InstanceRuntime::Running { pid } => {
|
||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||
}
|
||||
// 失败有**两种形状**,各说各的话(复审 P1-1):
|
||||
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
|
||||
// - `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())])
|
||||
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
|
||||
InstanceRuntime::Failed { reason } => {
|
||||
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行用的运行状态:**单行**(`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()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||||
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
||||
catalog.text(match stage {
|
||||
@@ -228,17 +135,6 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
|
||||
instance.runtime,
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +74,7 @@ fn crashed_only() -> AppState {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
}));
|
||||
|
||||
state
|
||||
@@ -168,9 +166,7 @@ fn an_imported_crash_file_still_shows_its_findings() {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
}));
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(
|
||||
"file:crash-2026-11-02_19.40.15-client.txt",
|
||||
|
||||
@@ -7,9 +7,6 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
#[path = "tests/launch.rs"]
|
||||
mod launch;
|
||||
|
||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
@@ -25,7 +22,6 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: unlucky_presenter::dto::LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +38,6 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
|
||||
model(
|
||||
state,
|
||||
1180.0,
|
||||
760.0,
|
||||
forced,
|
||||
pages::Category::All,
|
||||
&draft,
|
||||
@@ -126,6 +121,21 @@ fn a_busy_instance_offers_stop_instead_of_play() {
|
||||
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]
|
||||
fn timestamps_are_sliced_not_guessed() {
|
||||
let en = Lang::En.catalog();
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
|
||||
//!
|
||||
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
|
||||
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
// 进程**没起来**:说「哪一步 + 原文」。
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
|
||||
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
});
|
||||
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process")
|
||||
);
|
||||
// 详情页给全文(阶段 + 原文)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process: os error 5")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
|
||||
///
|
||||
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
|
||||
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
|
||||
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
|
||||
#[test]
|
||||
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
|
||||
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
|
||||
|
||||
let line = |cause: LaunchFailureCause, lang: Lang| {
|
||||
let runtime = InstanceRuntime::Failed { cause };
|
||||
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
|
||||
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
|
||||
(row, detail)
|
||||
};
|
||||
|
||||
// ① 没起来:说阶段。
|
||||
let (row, detail) = line(
|
||||
LaunchFailureCause::Stage {
|
||||
stage: LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
Lang::En,
|
||||
);
|
||||
assert_eq!(row, "Launch failed · Starting the game process");
|
||||
assert_eq!(
|
||||
detail,
|
||||
"Launch failed · Starting the game process: os error 5"
|
||||
);
|
||||
|
||||
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
|
||||
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
|
||||
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
|
||||
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
|
||||
assert!(
|
||||
!detail.contains("Starting the game process"),
|
||||
"进程起来了,不许说「启动游戏进程」:{detail}"
|
||||
);
|
||||
|
||||
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
|
||||
let (row, zh) = line(
|
||||
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
Lang::ZhCn,
|
||||
);
|
||||
assert_eq!(row, "启动失败 · 退出码 0,没起来");
|
||||
assert_eq!(zh, row);
|
||||
assert!(row.contains("没起来"), "{row}");
|
||||
|
||||
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
|
||||
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · The game crashed");
|
||||
}
|
||||
|
||||
///
|
||||
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
|
||||
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
|
||||
///
|
||||
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
|
||||
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
|
||||
#[test]
|
||||
fn the_launch_toast_speaks_the_interface_language() {
|
||||
use unlucky_presenter::dto::{
|
||||
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
|
||||
};
|
||||
|
||||
let finished = |outcome, failure| LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 12_500,
|
||||
failure,
|
||||
};
|
||||
|
||||
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
|
||||
let ok = finished(LogOutcome::Ok, None);
|
||||
let en = super::instances::launch_toast(&ok, Lang::En);
|
||||
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
|
||||
assert_eq!(en, "Finished normally · 12.5s");
|
||||
assert_eq!(zh, "正常结束 · 12.5 秒");
|
||||
assert_ne!(en, zh, "切语言时提示语要变");
|
||||
|
||||
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
|
||||
let failed = finished(
|
||||
LogOutcome::Crashed,
|
||||
Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::InstanceConfig,
|
||||
"permission denied (os error 5)",
|
||||
)),
|
||||
);
|
||||
let en = super::instances::launch_toast(&failed, Lang::En);
|
||||
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
|
||||
assert!(
|
||||
en.starts_with("Launch failed · Reading the instance settings"),
|
||||
"{en}"
|
||||
);
|
||||
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
|
||||
for text in [&en, &zh] {
|
||||
assert!(
|
||||
text.contains("permission denied (os error 5)"),
|
||||
"原文(取证信息)不许丢:{text}"
|
||||
);
|
||||
}
|
||||
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
|
||||
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
|
||||
assert!(
|
||||
!zh.contains("Launch failed"),
|
||||
"中文提示里混了英文前缀:{zh}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有没有中文字符(上面那条「不许混语言」的判据)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
|
||||
//!
|
||||
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
|
||||
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
|
||||
//!
|
||||
//! | DTO | 页面视图 |
|
||||
//! |---|---|
|
||||
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
|
||||
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
|
||||
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
|
||||
|
||||
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
|
||||
|
||||
use crate::pages::{Update, UpdateStatus};
|
||||
|
||||
/// 快照 → 视图模型。
|
||||
pub(super) fn update_of(view: &UpdateView) -> Update {
|
||||
Update {
|
||||
current_version: view.current_version.clone(),
|
||||
channel: view.channel.clone(),
|
||||
auto_check: view.auto_check,
|
||||
status: status_of(&view.outcome),
|
||||
checked_at: view.checked_at.clone(),
|
||||
applying: view.applying,
|
||||
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
|
||||
skipped: view.skipped.clone(),
|
||||
silent: view.silent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
|
||||
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
|
||||
match outcome {
|
||||
UpdateOutcome::Idle => UpdateStatus::Idle,
|
||||
UpdateOutcome::Checking => UpdateStatus::Checking,
|
||||
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
|
||||
latest: latest.clone(),
|
||||
},
|
||||
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
|
||||
version: candidate.version.clone(),
|
||||
size: candidate.size,
|
||||
notes: candidate.notes.clone(),
|
||||
forced: candidate.forced,
|
||||
},
|
||||
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
|
||||
version: version.clone(),
|
||||
},
|
||||
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
|
||||
reason: failure.message.clone(),
|
||||
retryable: failure.retryable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
|
||||
|
||||
fn view(outcome: UpdateOutcome) -> UpdateView {
|
||||
let mut view = UpdateView::new("0.2.2", "stable", true);
|
||||
view.outcome = outcome;
|
||||
view
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_is_never_mapped_to_up_to_date() {
|
||||
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"取更新清单失败:连接被重置",
|
||||
true,
|
||||
))));
|
||||
assert!(
|
||||
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
|
||||
"失败必须原样带原因,实际 {:?}",
|
||||
mapped.status
|
||||
);
|
||||
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
|
||||
|
||||
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
|
||||
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"连不上",
|
||||
true,
|
||||
)));
|
||||
silent.silent = true;
|
||||
let mapped = update_of(&silent);
|
||||
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
|
||||
assert!(mapped.banner().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_outcomes_stay_distinct() {
|
||||
let available = update_of(&view(UpdateOutcome::Available(Box::new(
|
||||
unlucky_presenter::dto::UpdateCandidateView {
|
||||
version: "0.2.3".to_string(),
|
||||
size: 21_000_000,
|
||||
notes: "修掉启动崩溃".to_string(),
|
||||
forced: false,
|
||||
},
|
||||
))));
|
||||
assert!(available.available().is_some());
|
||||
assert!(matches!(
|
||||
available.banner(),
|
||||
Some(crate::pages::Banner::Available { .. })
|
||||
));
|
||||
|
||||
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.2".to_string()),
|
||||
}));
|
||||
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
|
||||
assert!(up_to_date.banner().is_none());
|
||||
|
||||
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
|
||||
version: "0.2.3".to_string(),
|
||||
}));
|
||||
assert!(matches!(
|
||||
missing.status,
|
||||
UpdateStatus::MissingArtifact { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
|
||||
let mut view = view(UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.3".to_string()),
|
||||
});
|
||||
view.ready = Some(ReadyUpdate {
|
||||
version: "0.2.3".to_string(),
|
||||
path: "C:/ULL/unlucky-launcher.exe".to_string(),
|
||||
backup: None,
|
||||
});
|
||||
let mapped = update_of(&view);
|
||||
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
|
||||
assert!(matches!(
|
||||
mapped.banner(),
|
||||
Some(crate::pages::Banner::Ready { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
|
||||
let mut view = view(UpdateOutcome::Available(Box::new(
|
||||
unlucky_presenter::dto::UpdateCandidateView {
|
||||
version: "0.2.3".to_string(),
|
||||
size: 1,
|
||||
notes: String::new(),
|
||||
forced: false,
|
||||
},
|
||||
)));
|
||||
view.skipped = Some("0.2.3".to_string());
|
||||
let mapped = update_of(&view);
|
||||
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
|
||||
assert!(mapped.available().is_some(), "候选要留着给设置页");
|
||||
assert!(mapped.banner().is_none());
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,6 @@ impl Dispatch {
|
||||
|
||||
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
|
||||
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
|
||||
// 只有 `RawWindowHandle::Win32` 那条臂用到它,其余平台只剩 `_ => None`
|
||||
// ⇒ 无条件导入在非 Windows 上是 `unused_imports`(M147)。
|
||||
#[cfg(target_os = "windows")]
|
||||
use window::raw_window_handle::RawWindowHandle;
|
||||
|
||||
let handle = handle.window_handle().ok()?;
|
||||
@@ -102,21 +99,11 @@ mod tests {
|
||||
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
|
||||
let invalid = dispatch(1, Some(0.5));
|
||||
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
|
||||
// 「做不到」的语义随平台不同(M147 实证:这条断言原先按 Windows 写死,Ubuntu 上
|
||||
// 622 passed / 1 failed):Windows 上 FFI 真被调用并失败 ⇒ `Failed`;非 Windows 走
|
||||
// `transparency.rs` 的空实现 ⇒ `Unsupported`。两边都不许表现成 `Applied` / `Opaque`。
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(
|
||||
matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
|
||||
"{:?}",
|
||||
invalid.alpha
|
||||
);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
invalid.alpha,
|
||||
AlphaOutcome::Unsupported,
|
||||
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
|
||||
);
|
||||
}
|
||||
|
||||
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
|
||||
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
||||
//! divider.rs ← COMPBASE(15 §22)
|
||||
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
|
||||
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||
@@ -53,7 +52,6 @@ pub mod inputs;
|
||||
pub mod interaction;
|
||||
pub mod layout;
|
||||
pub mod navigation;
|
||||
pub mod scroll;
|
||||
pub mod selection;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
//! 滚动条**并排**在内容右侧,不压在内容上(M173)。
|
||||
//!
|
||||
//! ## 根因(读 iced 0.14 的源码,不是猜测)
|
||||
//!
|
||||
//! `scrollable` 的竖直滚动条是**浮层**,而且内容区不扣任何横向内边距:
|
||||
//!
|
||||
//! - 内容拿到的约束仍是整个视口宽——`iced_widget-0.14.2/src/scrollable.rs:437` 那条
|
||||
//! `layout::padded(limits, self.width, self.height, Padding::ZERO, …)`:`Scrollbar::spacing`
|
||||
//! 默认 `None`,右内边距**恒 0**;
|
||||
//! - 轨道按视口右边缘定位——同文件 `scrollable.rs:1970-1980`
|
||||
//! `x = bounds.x + bounds.width - total_scrollbar_width`,`total_scrollbar_width =
|
||||
//! width.max(scroller_width) + 2 * margin`。
|
||||
//!
|
||||
//! 于是轨道(本仓 6 + 2×2 = **10dp**)整条压在内容最右边那 10dp 上。屏幕上的观感就是
|
||||
//! 「滚动条骑在卡片右边缘」。
|
||||
//!
|
||||
//! ## 为什么不用 iced 自己的 `Scrollbar::spacing(Some(..))`
|
||||
//!
|
||||
//! 它**只在滑块可见时**才预留(`scrollable.rs:478-500`:`if state.is_scrollbar_visible
|
||||
//! { padding } else { 0.0 }`)。长页面 ⇄ 短页面切换时内容宽度会跳 10dp,卡片会横移——
|
||||
//! 那是另一个缺陷(`M173` 的判据要求「有无滚动条时内容宽度一致」)。
|
||||
//!
|
||||
//! ## 本模块的做法:恒定槽位
|
||||
//!
|
||||
//! 内容外面包一层「右侧恒留 `slot`」的容器,再交给 `scrollable`:
|
||||
//!
|
||||
//! ```text
|
||||
//! 视口 0 ───────────────────────── 视口宽 − slot │ slot │
|
||||
//! │ 内容(可滚) │ 轨道 │
|
||||
//! ```
|
||||
//!
|
||||
//! `内容可用宽度 = 视口宽 − slot` 与滑块在不在**无关**(因为内边距来自本模块,不来自 iced 的
|
||||
//! 可见性判断)。`slot = rail_width + 2 * margin` 与 iced 的 `total_scrollbar_width` 同口径,
|
||||
//! 轨道整个落在槽里,滑块再内缩 `margin`,所以内容与滑块之间永远隔着 `margin`。
|
||||
//!
|
||||
//! 逻辑像素(dp):高 DPI 下 iced 按缩放因子整体放大,槽与轨道同比例,二者不会重新重叠。
|
||||
//!
|
||||
//! ## 谁该用
|
||||
//!
|
||||
//! 所有「内容宽度吃满视口」的竖直滚动区。设置页(`pages/settings/frame.rs`)是第一个用户;
|
||||
//! 实例页列表列 / 详情列、日志面板、内容库正文、版本管理侧栏目前仍是 `scrollable(...)` 裸调,
|
||||
//! 同一处重叠在那些页面上照样存在(M173 的 finding 里有清单)。
|
||||
|
||||
use iced::widget::{container, scrollable};
|
||||
use iced::{Element, Length, Padding, Theme};
|
||||
|
||||
/// 滚动条轨占的横向槽宽(dp)= 轨宽 + 两侧留白。
|
||||
///
|
||||
/// 与 iced 的 `total_scrollbar_width`(`width.max(scroller_width) + 2 * margin`)同一个式子:
|
||||
/// 参数里的 `rail_width` 传的是 `Scrollbar::width` 与 `scroller_width` 的同一个值(本仓两者相等)。
|
||||
pub fn rail_slot(rail_width: f32, margin: f32) -> f32 {
|
||||
rail_width + 2.0 * margin
|
||||
}
|
||||
|
||||
/// 内容可用宽度(dp)= 视口宽 − 槽宽。
|
||||
///
|
||||
/// 这条式子是本模块的**判据**:`components/scroll.rs` 的测试用 iced 的布局引擎真量一遍,
|
||||
/// `pages/settings/tests/scroll_fit.rs` 在产品那条 `frame::scroll` 上再量一遍。
|
||||
/// 负数按 0 处理——视口比槽还窄时(产品窗口不会发生)宁可内容零宽,也不给 iced 送负宽。
|
||||
#[cfg(test)]
|
||||
pub fn content_width(viewport: f32, slot: f32) -> f32 {
|
||||
(viewport - slot).max(0.0)
|
||||
}
|
||||
|
||||
/// 竖直滚动区:内容右侧恒留 [`rail_slot`] 的槽,滚动条画在槽里。
|
||||
///
|
||||
/// `style` 就是 `scrollable::style` 回调(配色由调用方按令牌给),本模块只管几何。
|
||||
pub fn vertical<'a, Message, Renderer>(
|
||||
rail_width: f32,
|
||||
margin: f32,
|
||||
style: impl Fn(&Theme, scrollable::Status) -> scrollable::Style + 'a,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Renderer: iced::advanced::text::Renderer + 'a,
|
||||
{
|
||||
scrollable(beside(rail_slot(rail_width, margin), content))
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(rail_width)
|
||||
.scroller_width(rail_width)
|
||||
.margin(margin),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(style)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 内容 + 右侧槽位。容器宽度仍是 `Fill`(= 视口宽),扣掉的是**内容的**可用宽度。
|
||||
fn beside<'a, Message, Renderer>(
|
||||
slot: f32,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Renderer: iced::advanced::Renderer + 'a,
|
||||
{
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding {
|
||||
right: slot,
|
||||
..Padding::ZERO
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! ## 为什么这里的守卫是**真的布局**而不是源码扫描
|
||||
//!
|
||||
//! iced 的控件树支持在测试里跑一遍布局:`iced_core` 在 debug 构建下给 `()` 实现了全套
|
||||
//! `Renderer` trait(`iced_core-0.14.0/src/renderer/null.rs`),所以 `Element<_, _, Theme, ()>`
|
||||
//! 能被 `Widget::layout` 真正量出尺寸——**不需要开窗、不需要 GPU**。
|
||||
//!
|
||||
//! 于是「内容可用宽度 = 视口宽 − 槽宽」是一条数值断言,而不是「看图觉得对」;把
|
||||
//! [`beside`] 的 `padding` 去掉,下面的 `content_width` 立刻变成 434 而断言红。
|
||||
//!
|
||||
//! ⚠️ 诚实边界:`()` 的 Renderer 实现带 `#[cfg(debug_assertions)]`,所以这些测试在
|
||||
//! `cargo test --release` 下编不过(本仓 CI/本地一律 dev profile 跑测试)。
|
||||
|
||||
use iced::advanced::layout::Limits;
|
||||
use iced::advanced::widget::Tree;
|
||||
use iced::widget::{container, scrollable, space};
|
||||
use iced::{Rectangle, Size};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 本仓的轨道参数(真源是 `pages/frame/pieces.rs` 的两个常量,这里按「用户取值」复核)。
|
||||
const RAIL: f32 = 6.0;
|
||||
const MARGIN: f32 = 2.0;
|
||||
/// 设置页表单列在 1180 宽窗口下的列宽(`measure_cap_px` = 424)。
|
||||
const VIEWPORT: f32 = 424.0;
|
||||
/// 视口高(1180×760 减去页头/页边距后的量级;只要求「长页 > 视口」「短页 < 视口」)。
|
||||
const VIEWPORT_HEIGHT: f32 = 600.0;
|
||||
/// 长页面(一定出滑块)与短页面(一定不出滑块)的内容高。
|
||||
const LONG: f32 = 4000.0;
|
||||
const SHORT: f32 = 120.0;
|
||||
|
||||
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
|
||||
///
|
||||
/// 节点树:`scrollable → [槽容器(Fill 宽,右内边距 = slot) → [内容]]`,见
|
||||
/// `iced_core/src/layout.rs:201`(`padded` 的节点恒为「自己 + 一个子节点」)。
|
||||
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
|
||||
let filler: Element<'_, (), Theme, ()> = container(space())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(content_height))
|
||||
.into();
|
||||
|
||||
let mut element: Element<'_, (), Theme, ()> =
|
||||
vertical::<(), ()>(RAIL, MARGIN, scrollable::default, filler);
|
||||
let mut tree = Tree::new(element.as_widget());
|
||||
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&Limits::new(Size::ZERO, Size::new(VIEWPORT, VIEWPORT_HEIGHT)),
|
||||
);
|
||||
|
||||
let gutter = node.children()[0].bounds();
|
||||
let content = node.children()[0].children()[0].bounds();
|
||||
|
||||
(gutter, content)
|
||||
}
|
||||
|
||||
/// **本 mission 的核心判据**:内容可用宽度 = 视口宽 − 滚动条槽,且与有没有滑块无关。
|
||||
#[test]
|
||||
fn the_content_width_leaves_the_rail_its_own_slot() {
|
||||
let slot = rail_slot(RAIL, MARGIN);
|
||||
assert_eq!(slot, RAIL + 2.0 * MARGIN, "槽宽口径 = 轨宽 + 两侧留白");
|
||||
|
||||
let (_, long) = laid_out(LONG);
|
||||
let (_, short) = laid_out(SHORT);
|
||||
|
||||
assert_eq!(
|
||||
long.width,
|
||||
content_width(VIEWPORT, slot),
|
||||
"长页面(出滑块)的内容宽度必须是「视口宽 − 槽宽」"
|
||||
);
|
||||
assert_eq!(
|
||||
short.width,
|
||||
content_width(VIEWPORT, slot),
|
||||
"短页面(不出滑块)的内容宽度必须**一样**:否则滑块一出现内容就横跳"
|
||||
);
|
||||
assert_eq!(VIEWPORT - long.width, slot, "右侧扣掉的正好是槽宽");
|
||||
|
||||
// 滑块落在槽的中间:内容右缘 → margin → 滑块 → margin → 视口右缘。
|
||||
// 轨道按 iced 的算法定位(`scrollable.rs:1970-2005`:槽宽 = 轨宽 + 2×margin,滑块再内缩 margin),
|
||||
// 所以「内容右缘 + margin」就是滑块的左缘。
|
||||
let content_right = long.x + long.width;
|
||||
let pill_left = content_right + MARGIN;
|
||||
let pill_right = pill_left + RAIL;
|
||||
assert_eq!(
|
||||
pill_right + MARGIN,
|
||||
VIEWPORT,
|
||||
"滑块必须整个落在槽内(两侧各留 margin),否则「并排」变成「压着」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 槽容器自己仍是满宽(`Fill`),否则页面为它预留的 424 会被再吃掉一截。
|
||||
#[test]
|
||||
fn the_slot_wrapper_keeps_the_full_viewport_width() {
|
||||
let (gutter, _) = laid_out(LONG);
|
||||
assert_eq!(gutter.width, VIEWPORT);
|
||||
}
|
||||
|
||||
/// 左边界与宽度在长/短两态下**逐项相同**(M173 明写的「两张对照图 + 数值」里的数值部分)。
|
||||
#[test]
|
||||
fn long_and_short_pages_share_the_same_left_edge_and_width() {
|
||||
let (_, long) = laid_out(LONG);
|
||||
let (_, short) = laid_out(SHORT);
|
||||
|
||||
assert_eq!(long.x, short.x, "左边界必须一致");
|
||||
assert_eq!(long.width, short.width, "宽度必须一致");
|
||||
assert_eq!(short.x, 0.0, "槽只占右侧,左侧内边距为 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_width_never_goes_negative() {
|
||||
assert_eq!(content_width(RAIL, rail_slot(RAIL, MARGIN)), 0.0);
|
||||
assert_eq!(content_width(0.0, rail_slot(RAIL, MARGIN)), 0.0);
|
||||
assert_eq!(content_width(VIEWPORT, 0.0), VIEWPORT);
|
||||
}
|
||||
}
|
||||
@@ -56,22 +56,6 @@ impl<'a, M> Chip<'a, M> {
|
||||
Self::new(label, ChipVariant::Input, false, on_press)
|
||||
}
|
||||
|
||||
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
|
||||
///
|
||||
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**:
|
||||
/// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。
|
||||
pub fn badge(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
variant: ChipVariant::Filter,
|
||||
selected: true,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
|
||||
///
|
||||
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
|
||||
|
||||
@@ -18,31 +18,18 @@
|
||||
|
||||
//! ## 不做什么
|
||||
//!
|
||||
//! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,
|
||||
//! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。
|
||||
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
|
||||
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
|
||||
//!
|
||||
//! ## 中文那一半(M92 / D28 后半 / M168 实装下载)
|
||||
//! ## 中文那一半(M92 / D28 后半)
|
||||
//!
|
||||
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
|
||||
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
|
||||
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
|
||||
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
|
||||
//! cosmic-text 的全局状态,不是本模块能管的事)。
|
||||
//!
|
||||
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
|
||||
//!
|
||||
//! ```text
|
||||
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
|
||||
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
|
||||
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
|
||||
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
|
||||
//! ```
|
||||
//!
|
||||
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
|
||||
|
||||
pub mod cdn;
|
||||
mod cjk;
|
||||
pub mod install;
|
||||
|
||||
use cjk::CjkReport;
|
||||
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
|
||||
@@ -152,64 +139,15 @@ pub fn resolve() -> Resolved {
|
||||
resolve_with(None)
|
||||
}
|
||||
|
||||
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
|
||||
///
|
||||
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
|
||||
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
|
||||
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
|
||||
fn register_termux_fonts() {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| {
|
||||
if !termux::is_termux(&ProcessEnv) {
|
||||
return;
|
||||
}
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"/system/fonts/NotoSansCJK-Regular.ttc",
|
||||
"/system/fonts/NotoSansCJKSC-Regular.otf",
|
||||
"/system/fonts/DroidSansFallback.ttf",
|
||||
];
|
||||
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
|
||||
return;
|
||||
};
|
||||
let db = font_system.raw().db_mut();
|
||||
for path in CANDIDATES {
|
||||
if db.load_font_file(path).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||
///
|
||||
/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是
|
||||
/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入,
|
||||
/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。
|
||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
register_termux_fonts();
|
||||
resolve_with_faces(setting, system_faces().as_deref())
|
||||
}
|
||||
|
||||
/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。
|
||||
///
|
||||
/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。
|
||||
///
|
||||
/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`)
|
||||
/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发,
|
||||
/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。
|
||||
/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
|
||||
///
|
||||
/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。
|
||||
pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved {
|
||||
let platform = Platform::current();
|
||||
let faces = system_faces();
|
||||
|
||||
let is_installed = |family: &str| -> bool {
|
||||
faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family))
|
||||
faces
|
||||
.as_ref()
|
||||
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
|
||||
};
|
||||
|
||||
let families = [
|
||||
@@ -233,11 +171,11 @@ pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Reso
|
||||
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
|
||||
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
|
||||
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces));
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
|
||||
|
||||
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
|
||||
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
|
||||
let cjk_report = cjk::probe_with(setting, faces);
|
||||
let cjk_report = cjk::probe_with(setting, faces.as_deref());
|
||||
|
||||
Resolved {
|
||||
platform,
|
||||
@@ -247,34 +185,20 @@ pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Reso
|
||||
}
|
||||
}
|
||||
|
||||
/// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里
|
||||
/// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值**
|
||||
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
///
|
||||
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
|
||||
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
|
||||
/// 那就是白块。
|
||||
///
|
||||
/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源
|
||||
/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。
|
||||
/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器
|
||||
/// 会白下一次 17 MiB。
|
||||
///
|
||||
/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经
|
||||
/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`**
|
||||
/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳
|
||||
/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。
|
||||
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`,
|
||||
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。
|
||||
pub fn cjk_for(setting: Option<&str>) -> CjkReport {
|
||||
let report = cjk::probe_with(setting, system_faces().as_deref());
|
||||
install::after_probe(report.strategy);
|
||||
report
|
||||
cjk::probe_with(setting, system_faces().as_deref())
|
||||
}
|
||||
|
||||
/// 一条系统字面(族名 + 字重)。
|
||||
///
|
||||
/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一
|
||||
/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Face {
|
||||
struct Face {
|
||||
family: String,
|
||||
weight: u16,
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
|
||||
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
|
||||
//! core 的 FontSpec(值)
|
||||
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
|
||||
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
|
||||
//! ```
|
||||
//!
|
||||
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
|
||||
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
|
||||
//! 单测)。
|
||||
//!
|
||||
//! ## 「不联网」的三条路
|
||||
//!
|
||||
//! | 情形 | 谁拦住 |
|
||||
//! |---|---|
|
||||
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
|
||||
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
|
||||
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
|
||||
//!
|
||||
//! ## 设置改写只换 URL,不换哈希
|
||||
//!
|
||||
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
|
||||
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Source};
|
||||
|
||||
use crate::platform::data;
|
||||
|
||||
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdnFetcher {
|
||||
cache_root: PathBuf,
|
||||
spec: FontSpec,
|
||||
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
|
||||
///
|
||||
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
|
||||
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
|
||||
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
|
||||
last_from_cache: Cell<Option<bool>>,
|
||||
}
|
||||
|
||||
impl CdnFetcher {
|
||||
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
|
||||
///
|
||||
/// # Errors
|
||||
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
|
||||
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
|
||||
Ok(Self {
|
||||
cache_root,
|
||||
spec: spec_of(font)?,
|
||||
last_from_cache: Cell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
|
||||
///
|
||||
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
|
||||
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
|
||||
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
|
||||
#[must_use]
|
||||
pub fn has_cached(&self) -> bool {
|
||||
core_font::cache_path(&self.cache_root, &self.spec).is_file()
|
||||
}
|
||||
|
||||
/// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。
|
||||
///
|
||||
/// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。
|
||||
#[must_use]
|
||||
pub fn was_cached(&self) -> Option<bool> {
|
||||
self.last_from_cache.get()
|
||||
}
|
||||
|
||||
/// 缓存文件落点(诊断日志用)。
|
||||
#[must_use]
|
||||
pub fn cache_file(&self) -> PathBuf {
|
||||
core_font::cache_path(&self.cache_root, &self.spec)
|
||||
}
|
||||
}
|
||||
|
||||
impl unlucky_i18n::font::Fetcher for CdnFetcher {
|
||||
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
|
||||
self.last_from_cache
|
||||
.set(outcome.as_ref().ok().map(|fetch| fetch.from_cache));
|
||||
outcome.map(|fetch| fetch.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
|
||||
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = font.source;
|
||||
|
||||
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
|
||||
if let Some(override_url) = data::font_cdn_override() {
|
||||
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
|
||||
candidates.insert(0, override_url);
|
||||
}
|
||||
|
||||
FontSpec::new(candidates, sha256, bytes)
|
||||
.map_err(|error| FetchError::Integrity(error.to_string()))
|
||||
}
|
||||
|
||||
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
|
||||
fn translate(error: FontFetchError) -> FetchError {
|
||||
match error {
|
||||
FontFetchError::Network(message) => FetchError::Network(message),
|
||||
FontFetchError::Integrity(message) => FetchError::Integrity(message),
|
||||
FontFetchError::Disk(message) => FetchError::Disk(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_i18n::font::NOTO_SANS_SC;
|
||||
|
||||
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
|
||||
///
|
||||
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
|
||||
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
|
||||
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
|
||||
#[test]
|
||||
fn the_pin_survives_the_trip_to_core() {
|
||||
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = NOTO_SANS_SC.source;
|
||||
|
||||
assert!(
|
||||
spec.urls.len() >= urls.len(),
|
||||
"候选不该变少:{:?}",
|
||||
spec.urls
|
||||
);
|
||||
assert_eq!(spec.sha256, sha256);
|
||||
assert_eq!(spec.bytes, bytes);
|
||||
assert_eq!(
|
||||
spec.urls[spec.urls.len() - urls.len()],
|
||||
urls[0],
|
||||
"内置首选不许被挤掉"
|
||||
);
|
||||
}
|
||||
|
||||
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
|
||||
#[test]
|
||||
fn an_empty_cache_root_is_not_a_hit() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
|
||||
assert!(!fetcher.has_cached());
|
||||
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
|
||||
assert!(fetcher
|
||||
.cache_file()
|
||||
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,8 @@
|
||||
//!
|
||||
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
|
||||
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
|
||||
//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
|
||||
//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
|
||||
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
|
||||
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`);
|
||||
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。
|
||||
//!
|
||||
//! ## `ui.language` 的接线(M106)
|
||||
//!
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n::font::decide(判据) → Strategy
|
||||
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
|
||||
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
|
||||
//! fonts::install::register(注册) → iced 的字体系统里多一个族
|
||||
//! ```
|
||||
//!
|
||||
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
|
||||
//!
|
||||
//! | 入口 | 时机 | 谁来 |
|
||||
//! |---|---|---|
|
||||
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
|
||||
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
|
||||
//!
|
||||
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
|
||||
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
|
||||
//!
|
||||
//! ## 为什么下载在后台线程而不是启动那一拍
|
||||
//!
|
||||
//! 一份 16.9 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
|
||||
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
|
||||
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
|
||||
//!
|
||||
//! ## 失败**不是**致命错误
|
||||
//!
|
||||
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
|
||||
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
|
||||
//! (「下载失败不崩」)。
|
||||
//!
|
||||
//! ## 状态是一份进程级快照
|
||||
//!
|
||||
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
|
||||
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
|
||||
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
|
||||
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
|
||||
//!
|
||||
//! ## 截图通道
|
||||
//!
|
||||
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
|
||||
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
|
||||
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
|
||||
|
||||
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FetchClass {
|
||||
/// 网络层。
|
||||
Network,
|
||||
/// 校验层(哈希 / 魔数 / 大小上限)。
|
||||
Integrity,
|
||||
/// 磁盘层。
|
||||
Disk,
|
||||
}
|
||||
|
||||
impl FetchClass {
|
||||
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Network => "network",
|
||||
Self::Integrity => "integrity",
|
||||
Self::Disk => "disk",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&FetchError> for FetchClass {
|
||||
fn from(error: &FetchError) -> Self {
|
||||
match error {
|
||||
FetchError::Network(_) => Self::Network,
|
||||
FetchError::Integrity(_) => Self::Integrity,
|
||||
FetchError::Disk(_) => Self::Disk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CjkInstall {
|
||||
/// 还没判定(启动前 / 判据没跑过)。
|
||||
Unknown,
|
||||
/// 英文环境:不需要中文字体(D28 的第一条)。
|
||||
NotNeeded,
|
||||
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
|
||||
System {
|
||||
/// 命中的族名。
|
||||
family: String,
|
||||
},
|
||||
/// 探测不出(拿不到系统字体库)——**不下载**。
|
||||
Unprobeable,
|
||||
/// 正在下载。
|
||||
Downloading {
|
||||
/// 字节数(pin 里的期望值;用来写「16.9 MB」这种可核对的量)。
|
||||
bytes: u64,
|
||||
},
|
||||
/// 已就绪(下载完成并注册进字体系统)。
|
||||
Ready {
|
||||
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
|
||||
family: String,
|
||||
/// 是否零网络命中缓存。
|
||||
from_cache: bool,
|
||||
},
|
||||
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
|
||||
Failed {
|
||||
/// 类别。
|
||||
class: FetchClass,
|
||||
/// 人话原因(日志/诊断用;UI 只按类别选词)。
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CjkInstall {
|
||||
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
|
||||
#[must_use]
|
||||
pub const fn is_downloading(&self) -> bool {
|
||||
matches!(self, Self::Downloading { .. })
|
||||
}
|
||||
|
||||
/// 一行日志片段(`cjk-install=…`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::Unknown => "cjk-install=unknown".to_owned(),
|
||||
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
|
||||
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
|
||||
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
|
||||
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
|
||||
Self::Ready { family, from_cache } => {
|
||||
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
|
||||
}
|
||||
Self::Failed { class, message } => {
|
||||
format!("cjk-install=failed({},{message})", class.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程级状态(一份)。
|
||||
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
|
||||
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
|
||||
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
|
||||
}
|
||||
|
||||
/// 当前状态(设置页那一行、心跳、日志都读它)。
|
||||
#[must_use]
|
||||
pub fn status() -> CjkInstall {
|
||||
if let Some(forced) = capture_override() {
|
||||
return forced;
|
||||
}
|
||||
slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
|
||||
fn capture_override() -> Option<CjkInstall> {
|
||||
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
|
||||
FORCED
|
||||
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
|
||||
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
|
||||
|
||||
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
|
||||
#[must_use]
|
||||
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
|
||||
let value = raw.trim();
|
||||
Some(match value {
|
||||
"not-needed" => CjkInstall::NotNeeded,
|
||||
"unprobeable" => CjkInstall::Unprobeable,
|
||||
"system" => CjkInstall::System {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
},
|
||||
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
"ready" => CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
"failed" | "failed:network" => CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
"failed:integrity" => CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
"failed:disk" => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
|
||||
///
|
||||
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
|
||||
pub fn ensure_for(
|
||||
strategy: Strategy,
|
||||
fetcher: &dyn Fetcher,
|
||||
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
|
||||
) -> CjkInstall {
|
||||
match font::ensure(strategy, fetcher) {
|
||||
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
|
||||
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
|
||||
family: family.to_owned(),
|
||||
},
|
||||
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
|
||||
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
|
||||
Ok(family) => CjkInstall::Ready {
|
||||
family,
|
||||
from_cache: false,
|
||||
},
|
||||
Err(message) => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message,
|
||||
},
|
||||
},
|
||||
Err(error) => CjkInstall::Failed {
|
||||
class: FetchClass::from(&error),
|
||||
message: error.message().to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
|
||||
///
|
||||
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
|
||||
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
|
||||
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
/// 什么都不做(已经在下载 / 已就绪)。
|
||||
Skip,
|
||||
/// 只把状态改成这个(零网络)。
|
||||
Apply(CjkInstall),
|
||||
/// 真的发起一次下载。
|
||||
Start(&'static font::CjkFont),
|
||||
}
|
||||
|
||||
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
|
||||
///
|
||||
/// | 当前状态 | 策略 | 这一步 |
|
||||
/// |---|---|---|
|
||||
/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) |
|
||||
/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) |
|
||||
/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) |
|
||||
/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) |
|
||||
///
|
||||
/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②)
|
||||
///
|
||||
/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」
|
||||
/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**:
|
||||
/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在
|
||||
/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是
|
||||
/// 常见情形。
|
||||
///
|
||||
/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底)
|
||||
///
|
||||
/// | 档 | panic 语义 | 线程 panic 之后 |
|
||||
/// |---|---|---|
|
||||
/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) |
|
||||
/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 |
|
||||
///
|
||||
/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`),
|
||||
/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的
|
||||
/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。
|
||||
///
|
||||
/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外
|
||||
/// 产生网络流量并可能在出图过程中注册新字体。
|
||||
#[must_use]
|
||||
pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step {
|
||||
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
|
||||
return Step::Skip;
|
||||
}
|
||||
match strategy {
|
||||
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
|
||||
Strategy::System { family } => Step::Apply(CjkInstall::System {
|
||||
family: family.to_owned(),
|
||||
}),
|
||||
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
|
||||
// 出图通道需要下载 ⇒ **什么都不做**(也不改状态):写「不需要下载」是谎话,
|
||||
// 写「正在下载」也是谎话(并没有在下载)。
|
||||
Strategy::NeedsDownload(_) if offline => Step::Skip,
|
||||
Strategy::NeedsDownload(font) => Step::Start(font),
|
||||
}
|
||||
}
|
||||
|
||||
/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。
|
||||
#[must_use]
|
||||
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
|
||||
plan_with_offline(strategy, current, offline())
|
||||
}
|
||||
|
||||
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
|
||||
///
|
||||
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB);
|
||||
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
|
||||
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网;
|
||||
/// * 出图通道([`set_offline`])→ 永不发起。
|
||||
pub fn after_probe(strategy: Strategy) -> CjkInstall {
|
||||
match plan(strategy, &status()) {
|
||||
Step::Skip => status(),
|
||||
Step::Apply(state) => {
|
||||
update(state.clone());
|
||||
state
|
||||
}
|
||||
Step::Start(font) => {
|
||||
worker::start(font);
|
||||
status()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。
|
||||
///
|
||||
/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。
|
||||
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体——
|
||||
/// 这既产生流量,也让「同一份代码出同一张图」不再成立。
|
||||
pub fn set_offline(offline: bool) {
|
||||
OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 当前是否处于 [`set_offline`] 的状态。
|
||||
#[must_use]
|
||||
pub fn offline() -> bool {
|
||||
OFFLINE.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 进程级联网开关(默认允许)。
|
||||
static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
|
||||
pub(super) fn update(state: CjkInstall) {
|
||||
let previous = {
|
||||
let mut guard = slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
std::mem::replace(&mut *guard, state.clone())
|
||||
};
|
||||
if previous != state {
|
||||
eprintln!("[font] {}", state.describe());
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
|
||||
mod worker;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install/tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,369 +0,0 @@
|
||||
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
|
||||
//!
|
||||
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
|
||||
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
|
||||
struct FakeFetcher {
|
||||
calls: RefCell<usize>,
|
||||
seen: RefCell<Option<&'static str>>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(0),
|
||||
seen: RefCell::new(None),
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
*self.calls.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
*self.calls.borrow_mut() += 1;
|
||||
*self.seen.borrow_mut() = Some(font.family);
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
|
||||
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
|
||||
|
||||
/// 假注册器:记收到的字节数,返回一个族名。
|
||||
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
|
||||
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
|
||||
let recorder = std::rc::Rc::clone(&seen);
|
||||
let register = move |bytes: Vec<u8>| {
|
||||
recorder.borrow_mut().push(bytes.len());
|
||||
Ok("Noto Sans SC".to_owned())
|
||||
};
|
||||
(seen, register)
|
||||
}
|
||||
|
||||
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
|
||||
///
|
||||
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
|
||||
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
|
||||
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
|
||||
/// `CdnFetcher` 的缓存判定承担)。
|
||||
#[test]
|
||||
fn the_three_idle_strategies_never_touch_the_fetcher() {
|
||||
let (registered, register) = recorder();
|
||||
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::NotNeeded, &idle, ®ister),
|
||||
CjkInstall::NotNeeded
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::System { family: "SimSun" }, &idle, ®ister),
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::Unprobeable, &idle, ®ister),
|
||||
CjkInstall::Unprobeable
|
||||
);
|
||||
assert_eq!(
|
||||
idle.calls(),
|
||||
0,
|
||||
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
|
||||
);
|
||||
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
|
||||
|
||||
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
|
||||
let working = FakeFetcher::new(Ok(vec![9; 4096]));
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, ®ister),
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: false
|
||||
}
|
||||
);
|
||||
assert_eq!(working.calls(), 1);
|
||||
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
|
||||
assert_eq!(*registered.borrow(), vec![4096]);
|
||||
}
|
||||
|
||||
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
|
||||
///
|
||||
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
|
||||
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
|
||||
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
|
||||
#[test]
|
||||
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
|
||||
let (registered, register) = recorder();
|
||||
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
|
||||
|
||||
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, ®ister);
|
||||
assert_eq!(
|
||||
state,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "sha256 不符".to_owned()
|
||||
}
|
||||
);
|
||||
assert!(registered.borrow().is_empty(), "校验失败不许注册");
|
||||
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
|
||||
}
|
||||
|
||||
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
|
||||
#[test]
|
||||
fn every_failure_class_survives_the_trip() {
|
||||
let (_, register) = recorder();
|
||||
for (error, class) in [
|
||||
(FetchError::Network("超时".to_owned()), FetchClass::Network),
|
||||
(
|
||||
FetchError::Integrity("哈希不符".to_owned()),
|
||||
FetchClass::Integrity,
|
||||
),
|
||||
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
|
||||
] {
|
||||
let fetcher = FakeFetcher::new(Err(error));
|
||||
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, ®ister);
|
||||
match state {
|
||||
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
|
||||
other => panic!("应当是 Failed:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
|
||||
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
|
||||
let state = ensure_for(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&fetcher,
|
||||
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
|
||||
);
|
||||
assert!(matches!(
|
||||
state,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
|
||||
///
|
||||
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
|
||||
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
|
||||
///
|
||||
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
|
||||
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
|
||||
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
|
||||
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
|
||||
///
|
||||
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
|
||||
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
|
||||
#[test]
|
||||
fn a_started_or_finished_install_is_not_restarted() {
|
||||
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
|
||||
let ready = CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
};
|
||||
|
||||
for current in [downloading, ready] {
|
||||
assert_eq!(
|
||||
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, false),
|
||||
Step::Skip,
|
||||
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
|
||||
);
|
||||
}
|
||||
|
||||
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
|
||||
for current in [
|
||||
CjkInstall::Unknown,
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "超时".to_owned(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, false),
|
||||
Step::Start(&NOTO_SANS_SC),
|
||||
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
|
||||
///
|
||||
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
|
||||
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
|
||||
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
|
||||
///
|
||||
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
|
||||
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
|
||||
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
|
||||
#[test]
|
||||
fn the_idle_strategies_only_apply_state() {
|
||||
assert_eq!(
|
||||
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
|
||||
Step::Apply(CjkInstall::NotNeeded)
|
||||
);
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::System { family: "SimSun" },
|
||||
&CjkInstall::Unknown,
|
||||
false
|
||||
),
|
||||
Step::Apply(CjkInstall::System {
|
||||
family: "SimSun".to_owned()
|
||||
})
|
||||
);
|
||||
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
|
||||
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
|
||||
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
|
||||
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
|
||||
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
|
||||
// 不存在停在 Downloading 的状态。
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::Unprobeable,
|
||||
&CjkInstall::Downloading { bytes: 1 },
|
||||
false
|
||||
),
|
||||
Step::Skip,
|
||||
"在飞的下载不许被别的策略改写状态"
|
||||
);
|
||||
}
|
||||
|
||||
/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。
|
||||
///
|
||||
/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)——
|
||||
/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。
|
||||
/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。
|
||||
#[test]
|
||||
fn every_strategy_skips_while_a_download_is_in_flight() {
|
||||
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
|
||||
for strategy in [
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::Unprobeable,
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_with_offline(strategy, &downloading, false),
|
||||
Step::Skip,
|
||||
"{strategy:?}:下载中不许改状态、不许起第二条线程"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。
|
||||
///
|
||||
/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示
|
||||
/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。
|
||||
/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。
|
||||
#[test]
|
||||
fn the_screenshot_channel_never_starts_a_download() {
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&CjkInstall::Unknown,
|
||||
true
|
||||
),
|
||||
Step::Skip,
|
||||
"出图通道不许联网"
|
||||
);
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&CjkInstall::Unknown,
|
||||
false
|
||||
),
|
||||
Step::Start(&NOTO_SANS_SC),
|
||||
"正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)"
|
||||
);
|
||||
// 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。
|
||||
for strategy in [
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::Unprobeable,
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_with_offline(strategy, &CjkInstall::Unknown, false),
|
||||
plan_with_offline(strategy, &CjkInstall::Unknown, true),
|
||||
"{strategy:?}:不联网的策略与通道无关"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
|
||||
///
|
||||
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
|
||||
#[test]
|
||||
fn the_capture_switch_parses_every_state() {
|
||||
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
|
||||
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
|
||||
assert!(matches!(
|
||||
parse_capture("system"),
|
||||
Some(CjkInstall::System { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_capture("downloading"),
|
||||
Some(CjkInstall::Downloading { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_capture("ready"),
|
||||
Some(CjkInstall::Ready {
|
||||
from_cache: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
for (raw, class) in [
|
||||
("failed", FetchClass::Network),
|
||||
("failed:network", FetchClass::Network),
|
||||
("failed:integrity", FetchClass::Integrity),
|
||||
("failed:disk", FetchClass::Disk),
|
||||
] {
|
||||
match parse_capture(raw) {
|
||||
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
|
||||
other => panic!("{raw} 应解析成 Failed:{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(parse_capture("bogus"), None);
|
||||
assert_eq!(parse_capture(""), None);
|
||||
}
|
||||
|
||||
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
|
||||
#[test]
|
||||
fn every_state_describes_itself_on_one_line() {
|
||||
let states = [
|
||||
CjkInstall::Unknown,
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned(),
|
||||
},
|
||||
CjkInstall::Unprobeable,
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: false,
|
||||
},
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "sha256 不符".to_owned(),
|
||||
},
|
||||
];
|
||||
for state in states {
|
||||
let line = state.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk-install="), "{line}");
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
|
||||
//!
|
||||
//! ## 为什么拆出来
|
||||
//!
|
||||
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
|
||||
//!
|
||||
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
|
||||
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
|
||||
//!
|
||||
//! ## 为什么下载在后台线程
|
||||
//!
|
||||
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
|
||||
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
|
||||
//!
|
||||
//! ## 线程里做了什么(顺序即判据)
|
||||
//!
|
||||
//! ```text
|
||||
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限)
|
||||
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
|
||||
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
|
||||
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
|
||||
//! ```
|
||||
//!
|
||||
//! ## panic 这件事分两档说(复审 r3 P2-1)
|
||||
//!
|
||||
//! | 档 | panic 语义 | 线程 panic 之后 |
|
||||
//! |---|---|---|
|
||||
//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 |
|
||||
//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` |
|
||||
//!
|
||||
//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径
|
||||
//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`,
|
||||
//! 那属于发布 profile 的决定,不在本 mission)。
|
||||
//!
|
||||
//! ## 诚实边界
|
||||
//!
|
||||
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
|
||||
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
|
||||
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为
|
||||
//! (那要真跑一个 `--release` 的 panic 用例)。
|
||||
|
||||
use unlucky_i18n::font::{self, Strategy};
|
||||
|
||||
use super::{ensure_for, update, CjkInstall, FetchClass};
|
||||
use crate::fonts::cdn::CdnFetcher;
|
||||
use crate::platform::data;
|
||||
|
||||
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
|
||||
///
|
||||
/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是
|
||||
/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。
|
||||
pub(super) fn start(font: &'static font::CjkFont) {
|
||||
let font::Source::Pinned { bytes, .. } = font.source;
|
||||
let Some(cache_root) = data::cache_root() else {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "数据根解析不出来,字体无处可落".to_owned(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let fetcher = match CdnFetcher::new(font, cache_root) {
|
||||
Ok(fetcher) => fetcher,
|
||||
Err(error) => {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::from(&error),
|
||||
message: error.message().to_owned(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cache_file = fetcher.cache_file();
|
||||
update(CjkInstall::Downloading { bytes });
|
||||
eprintln!(
|
||||
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
|
||||
font.family,
|
||||
cache_file.display(),
|
||||
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
|
||||
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
|
||||
fetcher.has_cached()
|
||||
);
|
||||
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("ull-cjk-font".to_owned())
|
||||
.spawn(move || run(font, fetcher));
|
||||
|
||||
if let Err(error) = spawned {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: format!("起不了字体下载线程({error})"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。
|
||||
fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) {
|
||||
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
ensure_for(
|
||||
Strategy::NeedsDownload(font),
|
||||
&fetcher,
|
||||
®ister_into_font_system,
|
||||
)
|
||||
})) {
|
||||
Ok(state) => state,
|
||||
Err(_) => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(),
|
||||
},
|
||||
};
|
||||
|
||||
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
|
||||
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
|
||||
let from_cache = fetcher.was_cached().unwrap_or(false);
|
||||
let state = match state {
|
||||
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
|
||||
other => other,
|
||||
};
|
||||
|
||||
match &state {
|
||||
CjkInstall::Ready { family, from_cache } => eprintln!(
|
||||
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} \
|
||||
(中文字形现在由它兜底,界面不需要重启)"
|
||||
),
|
||||
CjkInstall::Failed { class, message } => eprintln!(
|
||||
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
|
||||
class.as_str()
|
||||
),
|
||||
other => eprintln!("[font] cjk-download {}", other.describe()),
|
||||
}
|
||||
update(state);
|
||||
}
|
||||
|
||||
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
|
||||
///
|
||||
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
|
||||
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
|
||||
///
|
||||
/// # Errors
|
||||
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
|
||||
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
|
||||
let lock = iced::advanced::graphics::text::font_system();
|
||||
let mut font_system = lock
|
||||
.write()
|
||||
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
|
||||
|
||||
font_system.load_font(std::borrow::Cow::Owned(bytes));
|
||||
|
||||
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
|
||||
// 那个借用活过守卫(E0597)。
|
||||
let families: Vec<String> = font_system
|
||||
.raw()
|
||||
.db()
|
||||
.faces()
|
||||
.flat_map(|face| face.families.iter())
|
||||
.map(|(family, _)| family.clone())
|
||||
.collect();
|
||||
|
||||
families
|
||||
.iter()
|
||||
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
|
||||
}
|
||||
@@ -173,42 +173,3 @@ fn type_roles_pick_the_display_stack_for_big_text() {
|
||||
);
|
||||
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
|
||||
}
|
||||
|
||||
/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。
|
||||
///
|
||||
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
|
||||
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
|
||||
///
|
||||
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
|
||||
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
|
||||
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
|
||||
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
|
||||
#[test]
|
||||
fn resolving_fonts_never_starts_a_download() {
|
||||
use unlucky_i18n::font::Strategy;
|
||||
|
||||
use crate::fonts::install;
|
||||
|
||||
// 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。
|
||||
let latin: Vec<Face> = ["Segoe UI", "Consolas"]
|
||||
.iter()
|
||||
.map(|family| Face {
|
||||
family: (*family).to_owned(),
|
||||
weight: 400,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let before = install::status();
|
||||
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
|
||||
let after = install::status();
|
||||
|
||||
assert_eq!(
|
||||
resolved.cjk.strategy,
|
||||
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
|
||||
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
|
||||
);
|
||||
assert!(
|
||||
!after.is_downloading(),
|
||||
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
|
||||
);
|
||||
}
|
||||
@@ -38,9 +38,6 @@ pub mod widgets;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "account/tests_msa.rs"]
|
||||
mod tests_msa;
|
||||
#[cfg(test)]
|
||||
#[path = "account/tests_text.rs"]
|
||||
mod tests_text;
|
||||
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
//!
|
||||
//! ## 为什么不是「一个转圈」
|
||||
//!
|
||||
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
|
||||
//!
|
||||
//! ## 文案一律**借用模型里的串**(M178)
|
||||
//!
|
||||
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
|
||||
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
|
||||
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
|
||||
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
|
||||
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
|
||||
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Alignment, Element};
|
||||
@@ -19,7 +13,7 @@ use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
|
||||
use unlucky_presenter::dto::LoginFailure;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::pages::frame;
|
||||
@@ -42,40 +36,34 @@ pub fn panel<'a>(
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
countdown,
|
||||
remaining,
|
||||
expired,
|
||||
note,
|
||||
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
|
||||
} => device_code(ui, code, link, *remaining, *expired, catalog),
|
||||
DevicePanel::Browser {
|
||||
link,
|
||||
countdown,
|
||||
remaining,
|
||||
expired,
|
||||
} => browser(ui, link, countdown, *expired, catalog),
|
||||
DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
|
||||
} => browser(ui, link, *remaining, *expired, catalog),
|
||||
DevicePanel::Failed { failure } => failed(ui, *failure, catalog),
|
||||
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
|
||||
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
|
||||
/// 没登记时在预检处直接回退设备码)。
|
||||
///
|
||||
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
|
||||
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
|
||||
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
|
||||
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = column![row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceSignIn),
|
||||
Account::StartDevice.into_page(),
|
||||
catalog.text(Key::AccountDeviceBrowser),
|
||||
Account::StartBrowser.into_page(),
|
||||
)
|
||||
),
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceBrowser),
|
||||
Account::StartBrowser.into_page(),
|
||||
catalog.text(Key::AccountDeviceUseCode),
|
||||
Account::StartDevice.into_page(),
|
||||
)
|
||||
),
|
||||
buttons::text(
|
||||
@@ -88,14 +76,7 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::AccountDeviceFallbackHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
.spacing(ui.space(Space::X3));
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
@@ -124,14 +105,13 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
|
||||
/// 设备码等待态:**码 + 链接 + 倒计时**。
|
||||
fn device_code<'a>(
|
||||
ui: &'a Ui,
|
||||
code: &'a str,
|
||||
link: &'a str,
|
||||
countdown: &'a str,
|
||||
remaining: Option<u64>,
|
||||
expired: bool,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut actions = row![
|
||||
@@ -170,7 +150,9 @@ fn device_code<'a>(
|
||||
),
|
||||
));
|
||||
|
||||
let mut body = column![
|
||||
let countdown = countdown_line(ui, remaining, expired, catalog);
|
||||
|
||||
let body = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
@@ -192,7 +174,7 @@ fn device_code<'a>(
|
||||
link,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
countdown_line(ui, countdown, expired),
|
||||
countdown,
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
@@ -200,21 +182,17 @@ fn device_code<'a>(
|
||||
catalog.text(Key::AccountDeviceHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X3));
|
||||
// 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
|
||||
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
|
||||
if let Some(text) = note_text(ui, note, catalog) {
|
||||
body = body.push(text);
|
||||
}
|
||||
frame::card(ui, body.push(actions))
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
|
||||
fn browser<'a>(
|
||||
ui: &'a Ui,
|
||||
link: &'a str,
|
||||
countdown: &'a str,
|
||||
remaining: Option<u64>,
|
||||
expired: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
@@ -259,19 +237,37 @@ fn browser<'a>(
|
||||
link,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
countdown_line(ui, countdown, expired),
|
||||
countdown_line(ui, remaining, expired, catalog),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X3));
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
|
||||
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
|
||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
|
||||
fn countdown_line<'a>(
|
||||
ui: &'a Ui,
|
||||
remaining: Option<u64>,
|
||||
expired: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
if expired {
|
||||
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
|
||||
return frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
true,
|
||||
catalog.text(Key::AccountDeviceExpired),
|
||||
ui.color(Role::Error),
|
||||
);
|
||||
}
|
||||
frame::text_role(
|
||||
let text = remaining.map_or_else(String::new, |secs| {
|
||||
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
|
||||
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
|
||||
});
|
||||
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
@@ -280,45 +276,16 @@ fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, P
|
||||
)
|
||||
}
|
||||
|
||||
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
|
||||
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
|
||||
catalog.text(super::model::note_key(note))
|
||||
}
|
||||
|
||||
/// 回退说明那一行(`None` = 没有这回事)。
|
||||
fn note_text<'a>(
|
||||
ui: &'a Ui,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Option<Element<'a, PageMessage>> {
|
||||
let note = note?;
|
||||
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
|
||||
Some(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
browser_note_text(note, catalog),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
}
|
||||
|
||||
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
||||
///
|
||||
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
|
||||
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
|
||||
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
||||
fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
failure: LoginFailure,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut body = catalog.text(failure_body_key(failure)).to_string();
|
||||
if let Some(note) = note {
|
||||
body.push_str("\n\n");
|
||||
body.push_str(browser_note_text(note, catalog));
|
||||
}
|
||||
/// 三点修订叠在这里:
|
||||
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
|
||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
|
||||
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
|
||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。
|
||||
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = catalog.text(failure_body_key(failure)).to_string();
|
||||
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
||||
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
||||
if !matches!(failure, LoginFailure::MsaNotConfigured) {
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
use iced::widget::image;
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
|
||||
};
|
||||
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::avatar::{Avatar, Cache};
|
||||
@@ -166,18 +164,6 @@ impl Form {
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
|
||||
///
|
||||
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
|
||||
#[must_use]
|
||||
pub const fn note_key(note: BrowserLoginNote) -> Key {
|
||||
match note {
|
||||
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
|
||||
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
|
||||
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行 / 当前账户卡片用的数据。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Row {
|
||||
@@ -267,8 +253,7 @@ impl Page {
|
||||
.map(|account| row_of(account, avatars, lang))
|
||||
.collect();
|
||||
let active = rows.iter().position(|row| row.active);
|
||||
let remaining = state.device_code_remaining();
|
||||
let device = device_panel(&state.device_login, remaining, lang.catalog());
|
||||
let device = device_panel(&state.device_login, state.device_code_remaining());
|
||||
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
|
||||
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
|
||||
let panel = if matches!(device, DevicePanel::Hidden) {
|
||||
|
||||
@@ -10,10 +10,7 @@
|
||||
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
|
||||
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
|
||||
|
||||
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -28,23 +25,17 @@ pub enum DevicePanel {
|
||||
code: String,
|
||||
/// 验证链接
|
||||
link: String,
|
||||
/// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
|
||||
///
|
||||
/// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
|
||||
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
|
||||
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
|
||||
countdown: String,
|
||||
/// 是否已过期(决定颜色与「重试」按钮)
|
||||
/// 剩余秒数
|
||||
remaining: Option<u64>,
|
||||
/// 是否已过期
|
||||
expired: bool,
|
||||
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
|
||||
note: Option<BrowserLoginNote>,
|
||||
},
|
||||
/// 授权码流:等浏览器回调
|
||||
Browser {
|
||||
/// 授权链接(可复制)
|
||||
link: String,
|
||||
/// 倒计时那行已渲染好的文案(同上)
|
||||
countdown: String,
|
||||
/// 剩余秒数
|
||||
remaining: Option<u64>,
|
||||
/// 是否已过期
|
||||
expired: bool,
|
||||
},
|
||||
@@ -52,8 +43,6 @@ pub enum DevicePanel {
|
||||
Failed {
|
||||
/// 原因码(页面据此选 i18n)
|
||||
failure: LoginFailure,
|
||||
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
|
||||
note: Option<BrowserLoginNote>,
|
||||
},
|
||||
/// 成功
|
||||
Succeeded {
|
||||
@@ -87,13 +76,8 @@ pub enum AuthlibPanel {
|
||||
}
|
||||
|
||||
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
|
||||
pub(super) fn device_panel(
|
||||
view: &DeviceLoginView,
|
||||
remaining: Option<u64>,
|
||||
catalog: Catalog,
|
||||
) -> DevicePanel {
|
||||
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel {
|
||||
let expired = remaining == Some(0);
|
||||
let countdown = countdown_text(remaining, expired, catalog);
|
||||
match view.phase {
|
||||
DeviceLoginPhase::Idle => DevicePanel::Hidden,
|
||||
DeviceLoginPhase::Requesting => DevicePanel::Requesting,
|
||||
@@ -101,22 +85,20 @@ pub(super) fn device_panel(
|
||||
if view.user_code.trim().is_empty() {
|
||||
DevicePanel::Browser {
|
||||
link: view.verification_uri.clone(),
|
||||
countdown,
|
||||
remaining,
|
||||
expired,
|
||||
}
|
||||
} else {
|
||||
DevicePanel::Code {
|
||||
code: view.user_code.clone(),
|
||||
link: view.qr_link.clone(),
|
||||
countdown,
|
||||
remaining,
|
||||
expired,
|
||||
note: view.browser_note,
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceLoginPhase::Failed => DevicePanel::Failed {
|
||||
failure: view.failure.unwrap_or(LoginFailure::Other),
|
||||
note: view.browser_note,
|
||||
},
|
||||
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
|
||||
name: view.account_name.clone(),
|
||||
@@ -124,19 +106,6 @@ pub(super) fn device_panel(
|
||||
}
|
||||
}
|
||||
|
||||
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
|
||||
///
|
||||
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
|
||||
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
|
||||
if expired {
|
||||
return catalog.text(Key::AccountDeviceExpired).to_string();
|
||||
}
|
||||
remaining.map_or_else(String::new, |secs| {
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
|
||||
})
|
||||
}
|
||||
|
||||
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
|
||||
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
|
||||
match view.phase {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build(state: &AppState, form: &Form) -> Page {
|
||||
fn build(state: &AppState, form: &Form) -> Page {
|
||||
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
|
||||
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
|
||||
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
|
||||
@@ -212,15 +212,12 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
|
||||
match &page.device {
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
countdown,
|
||||
remaining,
|
||||
expired,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH");
|
||||
assert!(
|
||||
countdown.contains("15:00"),
|
||||
"倒计时要画成 mm:ss:{countdown}"
|
||||
);
|
||||
assert_eq!(*remaining, Some(900));
|
||||
assert!(!expired);
|
||||
}
|
||||
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
|
||||
@@ -235,10 +232,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
|
||||
let page = build(&state, &Form::new());
|
||||
match &page.device {
|
||||
DevicePanel::Code {
|
||||
countdown, expired, ..
|
||||
remaining, expired, ..
|
||||
} => {
|
||||
assert_eq!(*remaining, Some(0));
|
||||
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
|
||||
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
|
||||
}
|
||||
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
|
||||
//!
|
||||
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
|
||||
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
|
||||
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::avatar::Cache;
|
||||
use super::login::browser_note_text;
|
||||
use super::model::{DevicePanel, Form, Page};
|
||||
use super::tests::build;
|
||||
use super::tests_text::{has_cjk, on_screen};
|
||||
|
||||
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
|
||||
fn build_zh(state: &AppState, form: &Form) -> Page {
|
||||
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
|
||||
}
|
||||
|
||||
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
|
||||
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
|
||||
///
|
||||
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
|
||||
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
|
||||
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
|
||||
#[test]
|
||||
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView {
|
||||
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
|
||||
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
|
||||
application."
|
||||
.to_string(),
|
||||
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
|
||||
}
|
||||
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
|
||||
)));
|
||||
let page = build(&state, &Form::new());
|
||||
|
||||
// 1. 面板态带着说明码(不是只剩一个用户码)
|
||||
match &page.device {
|
||||
DevicePanel::Code { note, code, .. } => {
|
||||
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
|
||||
assert_eq!(code, "ABCD-EFGH");
|
||||
}
|
||||
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
|
||||
let text = on_screen(&page);
|
||||
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
|
||||
assert!(
|
||||
text.contains("http://localhost/auth"),
|
||||
"提示里要给出可照抄的重定向 URI:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.to_lowercase().contains("redirect uri"),
|
||||
"提示里要点名「重定向 URI」这一项:{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("invalid_request"),
|
||||
"原始报文不许上屏:{text}"
|
||||
);
|
||||
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
|
||||
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
|
||||
|
||||
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
|
||||
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
|
||||
let zh = build_zh(&state, &Form::new());
|
||||
let zh_text = on_screen(&zh);
|
||||
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
|
||||
let note_text = browser_note_text(
|
||||
BrowserLoginNote::RedirectNotRegistered,
|
||||
Lang::ZhCn.catalog(),
|
||||
);
|
||||
assert!(
|
||||
note_text.contains("http://localhost/auth"),
|
||||
"中文文案也要给出可照抄的重定向 URI:{note_text}"
|
||||
);
|
||||
assert!(note_text.contains("重定向 URI"), "{note_text}");
|
||||
assert!(note_text.contains("设备码"), "{note_text}");
|
||||
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
|
||||
assert!(
|
||||
!zh_text.contains(forbidden),
|
||||
"中文界面混进了原始报文:{zh_text}"
|
||||
);
|
||||
assert!(
|
||||
!note_text.contains(forbidden),
|
||||
"词条混进了原始报文:{note_text}"
|
||||
);
|
||||
}
|
||||
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
|
||||
assert_eq!(
|
||||
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
|
||||
"Could not confirm that browser sign-in is usable, so it switched to a device code."
|
||||
);
|
||||
}
|
||||
|
||||
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
|
||||
#[test]
|
||||
fn a_failed_device_login_keeps_the_fallback_note() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView::failed_as(
|
||||
LoginFailure::Unreachable,
|
||||
"连不上 login.microsoftonline.com",
|
||||
)
|
||||
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
|
||||
)));
|
||||
let page = build(&state, &Form::new());
|
||||
match &page.device {
|
||||
DevicePanel::Failed { failure, note } => {
|
||||
assert_eq!(*failure, LoginFailure::Unreachable);
|
||||
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
|
||||
}
|
||||
other => panic!("应是失败面板,实际 {other:?}"),
|
||||
}
|
||||
let text = on_screen(&page);
|
||||
assert!(!has_cjk(&text), "{text}");
|
||||
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::AccountNoticeCode;
|
||||
|
||||
use super::login::{browser_note_text, failure_body_key, failure_title_key};
|
||||
use super::login::{failure_body_key, failure_title_key};
|
||||
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
|
||||
|
||||
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
|
||||
@@ -60,33 +60,23 @@ pub(super) fn on_screen(page: &Page) -> String {
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
countdown,
|
||||
note,
|
||||
..
|
||||
remaining,
|
||||
expired,
|
||||
} => {
|
||||
out.push_str(code);
|
||||
out.push_str(link);
|
||||
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
|
||||
out.push_str(countdown);
|
||||
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
|
||||
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
|
||||
if let Some(note) = note {
|
||||
out.push_str(browser_note_text(*note, catalog));
|
||||
if *expired {
|
||||
out.push_str(catalog.text(Key::AccountDeviceExpired));
|
||||
} else if let Some(secs) = remaining {
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
|
||||
}
|
||||
}
|
||||
DevicePanel::Browser {
|
||||
link, countdown, ..
|
||||
} => {
|
||||
out.push_str(link);
|
||||
out.push_str(countdown);
|
||||
}
|
||||
DevicePanel::Failed { failure, note } => {
|
||||
DevicePanel::Browser { link, .. } => out.push_str(link),
|
||||
DevicePanel::Failed { failure } => {
|
||||
out.push_str(catalog.text(failure_title_key(*failure)));
|
||||
out.push_str(catalog.text(failure_body_key(*failure)));
|
||||
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
|
||||
if let Some(note) = note {
|
||||
out.push_str(browser_note_text(*note, catalog));
|
||||
}
|
||||
}
|
||||
DevicePanel::Succeeded { name } => {
|
||||
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
|
||||
//! (偏离登记在 [`super`] 的偏离表 #7)。
|
||||
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
|
||||
@@ -65,7 +63,7 @@ pub const TILE_SCALE_STEP: f32 = 1.15;
|
||||
/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。
|
||||
pub const MAX_TILE_SCALE: f32 = 1024.0;
|
||||
|
||||
/// 解码失败的原因(人话在 [`LoadError::note`])。
|
||||
/// 解码失败的原因(人话在 [`LoadError::describe`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoadError {
|
||||
/// 读文件失败:不存在 / 没权限 / 被占用。
|
||||
@@ -81,76 +79,17 @@ pub enum LoadError {
|
||||
},
|
||||
}
|
||||
|
||||
/// 一条**可翻译**诊断:`key` + 已经格式化好的参数(模板里只有 `{name}`,见 [`Key`])。
|
||||
///
|
||||
/// 为什么不是 `String`:文案表在金表里(M180 `I18NAPPEAR`),本模块只给「哪条 key + 哪些值」。
|
||||
/// 数字在这一层就格式化好(`{:.2}` / `{:?}` 这些格式说明词表不支持)。
|
||||
///
|
||||
/// 为什么 `types` 是 `String` 而不是 `&'static str`:`Io(reason)` / `Undecodable(reason)` 的原因来自
|
||||
/// 磁盘与解码库(运行时字符串),只能持有。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
key: Key,
|
||||
args: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// 组装一条(参数名必须与译文里的 `{name}` 逐字相同)。
|
||||
fn new(key: Key, args: Vec<(&'static str, String)>) -> Self {
|
||||
Self { key, args }
|
||||
}
|
||||
|
||||
/// 这条诊断是哪条文案。
|
||||
#[must_use]
|
||||
pub fn key(&self) -> Key {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// 参数(名 → 已成形的值)。
|
||||
#[must_use]
|
||||
pub fn args(&self) -> Vec<(&str, &str)> {
|
||||
self.args
|
||||
.iter()
|
||||
.map(|(name, value)| (*name, value.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按语言取整句(`{name}` 全替换;缺参时占位符原样留着,见 [`unlucky_i18n::catalog::Catalog::render`])。
|
||||
#[must_use]
|
||||
pub fn render(&self, lang: Lang) -> String {
|
||||
lang.catalog().render(self.key, &self.args())
|
||||
}
|
||||
|
||||
/// 英文基线里有没有这个子串。
|
||||
///
|
||||
/// 给**跨 mission 的既有断言**用(`app/tests/appearance.rs` 查「诊断里有没有说出原因」),
|
||||
/// 那条断言不在本 mission 的 scope 里、不能改成按 key 断言。UI 一律走 [`Self::render`]。
|
||||
#[must_use]
|
||||
pub fn contains(&self, needle: &str) -> bool {
|
||||
self.render(Lang::En).contains(needle)
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadError {
|
||||
/// 可翻译诊断(设置页提示行与日志共用同一条 key)。
|
||||
/// 可读诊断(设置页提示行与日志共用同一句)。
|
||||
#[must_use]
|
||||
pub fn note(&self) -> Note {
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::Io(reason) => Note::new(
|
||||
Key::SettingsAppearanceBgReadFailed,
|
||||
vec![("reason", reason.clone())],
|
||||
Self::Io(reason) => format!("Background image could not be read: {reason}"),
|
||||
Self::Undecodable(reason) => format!(
|
||||
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)"
|
||||
),
|
||||
Self::Undecodable(reason) => Note::new(
|
||||
Key::SettingsAppearanceBgDecodeFailed,
|
||||
vec![("reason", reason.clone())],
|
||||
),
|
||||
Self::TooLarge { width, height } => Note::new(
|
||||
Key::SettingsAppearanceBgTooLarge,
|
||||
vec![
|
||||
("width", width.to_string()),
|
||||
("height", height.to_string()),
|
||||
("limit", MAX_SOURCE_EDGE.to_string()),
|
||||
],
|
||||
Self::TooLarge { width, height } => format!(
|
||||
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -254,7 +193,7 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
|
||||
/// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是
|
||||
/// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。
|
||||
#[must_use]
|
||||
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<Note> {
|
||||
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
|
||||
if !matches!(fill, BgFill::Tile) {
|
||||
return None;
|
||||
}
|
||||
@@ -262,13 +201,9 @@ pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<Note> {
|
||||
if width.min(height) >= SMALL_TILE_EDGE {
|
||||
return None;
|
||||
}
|
||||
Some(Note::new(
|
||||
Key::SettingsAppearanceBgSmallTile,
|
||||
vec![
|
||||
("width", image.source.0.to_string()),
|
||||
("height", image.source.1.to_string()),
|
||||
("max", MAX_TILES.to_string()),
|
||||
],
|
||||
Some(format!(
|
||||
"Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).",
|
||||
image.source.0, image.source.1
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
|
||||
use std::path::{Path, PathBuf};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
fn temp_file(label: &str, name: &str) -> PathBuf {
|
||||
@@ -68,28 +66,19 @@ fn a_real_jpeg_decodes_too() {
|
||||
#[test]
|
||||
fn a_missing_or_broken_file_reports_a_readable_reason() {
|
||||
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
|
||||
// 都必须是 Err(不许静默回默认色),且 note() 给出**哪条 key + 哪些参数**(句子在词表里)。
|
||||
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。
|
||||
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
|
||||
let missing = temp_file("missing", "bg.png");
|
||||
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
|
||||
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
|
||||
let note = error.note();
|
||||
assert_eq!(note.key(), Key::SettingsAppearanceBgReadFailed);
|
||||
assert!(
|
||||
note.contains("could not be read"),
|
||||
"英文基线要说出「读不出来」:{}",
|
||||
note.render(Lang::En)
|
||||
);
|
||||
assert!(error
|
||||
.describe()
|
||||
.starts_with("Background image could not be read"));
|
||||
|
||||
let broken = temp_file("broken", "bg.png");
|
||||
std::fs::write(&broken, b"this is not an image").expect("写坏文件");
|
||||
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
|
||||
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
|
||||
assert_eq!(
|
||||
error.note().key(),
|
||||
Key::SettingsAppearanceBgDecodeFailed,
|
||||
"解不开与读不出来必须是两条 key(措辞与修法都不同)"
|
||||
);
|
||||
cleanup(&broken);
|
||||
|
||||
let empty = load(" ").expect_err("空路径必须报错");
|
||||
@@ -179,21 +168,10 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
|
||||
),
|
||||
"实际 {error:?}"
|
||||
);
|
||||
let note = error.note();
|
||||
assert_eq!(note.key(), Key::SettingsAppearanceBgTooLarge);
|
||||
assert!(
|
||||
note.contains("too large"),
|
||||
"英文基线要说出「太大」:{}",
|
||||
note.render(Lang::En)
|
||||
);
|
||||
let args = note.args();
|
||||
assert!(
|
||||
args.contains(&("width", "9000")) && args.contains(&("height", "4")),
|
||||
"宽高要作为参数传进去(不是写进译文):{args:?}"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&("limit", "8192")),
|
||||
"上限走占位符而不是写进译文:{args:?}"
|
||||
error.describe().contains("too large"),
|
||||
"提示要人话:{}",
|
||||
error.describe()
|
||||
);
|
||||
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
|
||||
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
|
||||
@@ -209,21 +187,8 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
|
||||
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
|
||||
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
|
||||
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
|
||||
assert_eq!(note.key(), Key::SettingsAppearanceBgSmallTile);
|
||||
let args = note.args();
|
||||
assert!(
|
||||
args.contains(&("width", "32")) && args.contains(&("height", "32")),
|
||||
"尺寸作为参数:{args:?}"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&("max", "64")),
|
||||
"块数上限走占位符(`MAX_TILES` 改了不该动译文):{args:?}"
|
||||
);
|
||||
assert!(
|
||||
note.render(Lang::En).contains("scaled up"),
|
||||
"英文基线要说出「会被放大」:{}",
|
||||
note.render(Lang::En)
|
||||
);
|
||||
assert!(note.contains("32×32"), "{note}");
|
||||
assert!(note.contains("scaled up"), "{note}");
|
||||
|
||||
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
|
||||
assert!(small_tile_note(&small, BgFill::Cover).is_none());
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
|
||||
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
|
||||
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
|
||||
//! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 |
|
||||
//! | `section` | 设置页「外观」分组(真控件) | 无 |
|
||||
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
|
||||
//!
|
||||
//! ## 偏离登记(D30 要求逐条写清)
|
||||
@@ -65,14 +65,10 @@ pub mod background;
|
||||
|
||||
mod palette;
|
||||
mod picture;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod probe;
|
||||
mod section;
|
||||
mod state;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。
|
||||
/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。
|
||||
///
|
||||
/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。
|
||||
/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开
|
||||
@@ -168,3 +164,56 @@ impl Change {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Change, APPEARANCE_GROUP};
|
||||
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
|
||||
|
||||
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
|
||||
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
|
||||
#[test]
|
||||
fn every_change_names_its_own_schema_key() {
|
||||
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
|
||||
let cases = [
|
||||
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
|
||||
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
|
||||
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
|
||||
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
|
||||
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
|
||||
(Change::BgDim(0.5), keys::BG_DIM),
|
||||
(
|
||||
Change::PaletteSource(PaletteSource::Wallpaper),
|
||||
keys::PALETTE_SOURCE,
|
||||
),
|
||||
(
|
||||
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
|
||||
keys::WINDOW_CORNER,
|
||||
),
|
||||
];
|
||||
for (change, key) in cases {
|
||||
assert_eq!(change.key(), Some(key), "{change:?}");
|
||||
assert!(change.value().is_some());
|
||||
}
|
||||
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
|
||||
assert_eq!(Change::SelectGroup(2).value(), None);
|
||||
}
|
||||
|
||||
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
|
||||
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
|
||||
#[test]
|
||||
fn persisted_values_use_the_schema_vocabulary() {
|
||||
assert_eq!(
|
||||
Change::BgFill(BgFill::Contain).value().as_deref(),
|
||||
Some("contain")
|
||||
);
|
||||
assert_eq!(
|
||||
Change::PaletteSource(PaletteSource::BackgroundImage)
|
||||
.value()
|
||||
.as_deref(),
|
||||
Some("background-image")
|
||||
);
|
||||
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
|
||||
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
//!
|
||||
//! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])——
|
||||
//! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」);
|
||||
//! 2. 把 `Provenance::hint_key()` 翻成一条词表 key(M180:句子在 `catalog/settings/appearance.rs`,
|
||||
//! 这里只给 key——中文界面下这句提示也是中文)。
|
||||
//! 2. 把 `Provenance::hint_key()` 翻成一句可读提示(键 → 文案是 UI 层的活,i18n 之后再统一)。
|
||||
//!
|
||||
//! ## 为什么不在这里做对比度
|
||||
//!
|
||||
@@ -20,7 +19,6 @@
|
||||
|
||||
use unlucky_design::color::{Scheme, SchemeFlavor};
|
||||
use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_presenter::prefs::PaletteSource;
|
||||
|
||||
use super::background::Image;
|
||||
@@ -64,9 +62,9 @@ impl Resolved {
|
||||
self.provenance.level
|
||||
}
|
||||
|
||||
/// 提示 key(`None` = 一级成功、没什么可解释的)。
|
||||
/// 可读提示(`None` = 一级成功、没什么可解释的)。
|
||||
#[must_use]
|
||||
pub fn note(&self) -> Option<Key> {
|
||||
pub fn note(&self) -> Option<&'static str> {
|
||||
note(&self.provenance)
|
||||
}
|
||||
}
|
||||
@@ -99,55 +97,47 @@ pub fn resolve(
|
||||
Resolved { scheme, provenance }
|
||||
}
|
||||
|
||||
/// `Provenance::hint_key()` → 一条词表 key(M180:句子在 `catalog/settings/appearance.rs`)。
|
||||
/// `Provenance::hint_key()` → 一句可读提示(英文基线;i18n mission 之后走词表)。
|
||||
///
|
||||
/// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里;
|
||||
/// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。
|
||||
#[must_use]
|
||||
pub fn note(provenance: &Provenance) -> Option<Key> {
|
||||
pub fn note(provenance: &Provenance) -> Option<&'static str> {
|
||||
Some(match provenance.hint_key()? {
|
||||
"palette.hint.background.undecodable" => Key::SettingsAppearancePaletteBgUndecodable,
|
||||
"palette.hint.background.too-small" => Key::SettingsAppearancePaletteBgTooSmall,
|
||||
"palette.hint.background.bad-dimensions" => Key::SettingsAppearancePaletteBgBadDimensions,
|
||||
"palette.hint.background.undecodable" => {
|
||||
"Background image could not be read; colours fall back."
|
||||
}
|
||||
"palette.hint.background.too-small" => {
|
||||
"Background image is too small to sample; colours fall back."
|
||||
}
|
||||
"palette.hint.background.bad-dimensions" => {
|
||||
"Background image data looks inconsistent; colours fall back."
|
||||
}
|
||||
"palette.hint.background.no-opaque-pixels" => {
|
||||
Key::SettingsAppearancePaletteBgNoOpaquePixels
|
||||
"Background image is almost fully transparent; colours fall back."
|
||||
}
|
||||
"palette.hint.background.low-chroma" => Key::SettingsAppearancePaletteBgLowChroma,
|
||||
"palette.hint.background.contrast" => Key::SettingsAppearancePaletteBgContrast,
|
||||
"palette.hint.wallpaper.undecodable" => Key::SettingsAppearancePaletteWallpaperUndecodable,
|
||||
"palette.hint.wallpaper.low-chroma" => Key::SettingsAppearancePaletteWallpaperLowChroma,
|
||||
"palette.hint.wallpaper.too-small" => Key::SettingsAppearancePaletteWallpaperTooSmall,
|
||||
"palette.hint.wallpaper.bad-dimensions" => {
|
||||
Key::SettingsAppearancePaletteWallpaperBadDimensions
|
||||
"palette.hint.background.low-chroma" => {
|
||||
"Background image is too monochrome to tint from; colours fall back."
|
||||
}
|
||||
"palette.hint.background.contrast" => {
|
||||
"Background image colours would be unreadable; colours fall back."
|
||||
}
|
||||
"palette.hint.wallpaper.undecodable" => "Desktop wallpaper could not be read.",
|
||||
"palette.hint.wallpaper.low-chroma" => "Desktop wallpaper is too monochrome to tint from.",
|
||||
"palette.hint.wallpaper.too-small" => "Desktop wallpaper is too small to sample.",
|
||||
"palette.hint.wallpaper.bad-dimensions" => "Desktop wallpaper data looks inconsistent.",
|
||||
"palette.hint.wallpaper.no-opaque-pixels" => {
|
||||
Key::SettingsAppearancePaletteWallpaperNoOpaquePixels
|
||||
"Desktop wallpaper is almost fully transparent."
|
||||
}
|
||||
"palette.hint.wallpaper.contrast" => Key::SettingsAppearancePaletteWallpaperContrast,
|
||||
"palette.hint.preset.contrast" => Key::SettingsAppearancePalettePresetContrast,
|
||||
"palette.hint.adjusted" => Key::SettingsAppearancePaletteAdjusted,
|
||||
"palette.hint.wallpaper.contrast" => {
|
||||
"Desktop wallpaper colours would be unreadable; colours fall back."
|
||||
}
|
||||
"palette.hint.preset.contrast" => "The built-in card needed a small contrast correction.",
|
||||
"palette.hint.adjusted" => "A few colours were nudged for readability.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生效层级**码值** → 已有的那三条档位 key(M180 复审 P2-2:中文界面里不许留机器码)。
|
||||
///
|
||||
/// 码值的全部来源只有两处,取值集合相同:`Level::code()`(`background-image` / `wallpaper` /
|
||||
/// `preset`)与 `PaletteSource::as_str()`(同三个词)。所以三条 key 就够,**不新增平行词表**——
|
||||
/// 卡片底部「配色来源」用它们表示**用户选的**档,这一行用它们表示**回落之后真正生效**的档。
|
||||
///
|
||||
/// 未识别的码值落到 `background-image` 那条(宁可显示一个偏保守的档名,也不许把机器码漏进
|
||||
/// 中文界面——这条兜底本身由 `tests` 里的 `every_level_code_maps_to_its_own_sentence` 钉住)。
|
||||
/// `state.rs` 已贴 400 行线,所以这个映射住在本文件。
|
||||
#[must_use]
|
||||
pub fn level_key(code: &str) -> Key {
|
||||
match code {
|
||||
"preset" => Key::SettingsAppearancePalettePreset,
|
||||
"wallpaper" => Key::SettingsAppearancePaletteWallpaper,
|
||||
_ => Key::SettingsAppearancePaletteBackground,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{note, resolve, Source};
|
||||
@@ -155,7 +145,6 @@ mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::palette::wcag::check;
|
||||
use unlucky_design::palette::{Level, Outcome, Skip};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_presenter::prefs::PaletteSource;
|
||||
|
||||
/// 高彩度的图(纯红):取色一定成功。
|
||||
@@ -204,7 +193,7 @@ mod tests {
|
||||
assert!(resolved.provenance.did_fall_back());
|
||||
assert_eq!(
|
||||
resolved.note(),
|
||||
Some(Key::SettingsAppearancePaletteBgUndecodable)
|
||||
Some("Background image could not be read; colours fall back.")
|
||||
);
|
||||
assert!(check(&resolved.scheme).is_clean());
|
||||
}
|
||||
@@ -226,11 +215,7 @@ mod tests {
|
||||
}
|
||||
other => panic!("灰图必须被判不合格,实际 {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
resolved.note(),
|
||||
Some(Key::SettingsAppearancePaletteBgLowChroma),
|
||||
"灰图的回落原因要指名「彩度不足」"
|
||||
);
|
||||
assert!(resolved.note().unwrap().contains("monochrome"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -291,40 +276,4 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// **M180 复审 P2-2 的守门**:生效层级的**每一个码值**都必须映射到它自己那条中文句子。
|
||||
///
|
||||
/// 抓得住:① 三个码值被映射到同一条 key(界面上分不出到底生效的是哪一级);
|
||||
/// ② 某条映射被删掉 / 写错(`preset` 落到 background 那条 ⇒ 中文界面说「取自背景图」);
|
||||
/// ③ 有人再加一级却忘了扩映射。
|
||||
/// 抓不住:译文本身的措辞(那是 `catalog/settings/appearance.rs` 的译文守卫的事)。
|
||||
///
|
||||
/// 变异证明:把 `"preset"` 那行删掉(落进兜底)⇒ 本条红,且 `assert_ne!` 直接点名码值。
|
||||
///
|
||||
/// 为什么这条必须有:`the_appearance_card_speaks_chinese` 断的是**整句**非 ASCII,
|
||||
/// 而「配色:preset」里有汉字 ⇒ 整句非 ASCII ⇒ 那个守卫放过它。这个口子只能在这里堵。
|
||||
#[test]
|
||||
fn every_level_code_maps_to_its_own_sentence() {
|
||||
use super::level_key;
|
||||
|
||||
let codes = ["background-image", "wallpaper", "preset"];
|
||||
let keys: Vec<Key> = codes.iter().copied().map(level_key).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
Key::SettingsAppearancePaletteBackground,
|
||||
Key::SettingsAppearancePaletteWallpaper,
|
||||
Key::SettingsAppearancePalettePreset,
|
||||
],
|
||||
"码值与档位 key 必须一一对应"
|
||||
);
|
||||
|
||||
// 三条中文句子互不相同(否则「映射对了」但界面上仍分不出层级)。
|
||||
let zh = unlucky_i18n::Lang::ZhCn.catalog();
|
||||
let mut texts: Vec<&str> = keys.iter().map(|key| zh.text(*key)).collect();
|
||||
let total = texts.len();
|
||||
texts.sort_unstable();
|
||||
texts.dedup();
|
||||
assert_eq!(texts.len(), total, "三条档位译文有重复:{texts:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。
|
||||
//!
|
||||
//! ## 为什么需要这一层
|
||||
//!
|
||||
//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的
|
||||
//! 公开接口:
|
||||
//!
|
||||
//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree`
|
||||
//! (不是子控件),没有 `as_any` 之类的向下转型口;
|
||||
//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器**
|
||||
//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。
|
||||
//!
|
||||
//! 所以取证点退到「字进树之前」那一层:`section::t` / `section::tr` 是本分组**唯一**两个取词口,
|
||||
//! 它们把每条译文顺手记一份(M180 起分成两份:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的
|
||||
//! 整句),测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
|
||||
//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。
|
||||
//!
|
||||
//! ## 为什么是 thread-local
|
||||
//!
|
||||
//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台,
|
||||
//! 也不需要加锁或串行化。
|
||||
//!
|
||||
//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use unlucky_i18n::catalog::Key;
|
||||
|
||||
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序(`t()` 取的 `&'static str`)。
|
||||
thread_local! {
|
||||
// clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }`
|
||||
// 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号):
|
||||
// `thread_local!{ static X: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) }; }`
|
||||
// —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static TEXTS: RefCell<Vec<(Key, &'static str)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
// 自上次 `take_rendered` 以来记录下的 `(key, 整句)`:`tr()` 那条带占位符的路径(M180 加)。
|
||||
//
|
||||
// 为什么单独一份而不是把上面那份改成 `String`:M179 的守卫
|
||||
// (`pages/settings/tests/appearance_section.rs::the_appearance_group_speaks_chinese_when_the_interface_does`)
|
||||
// 断言的是 `&'static str`,那个文件不在 M180 的 scope 里、不能跟着改签名。两份记录各自的
|
||||
// `take*` 也各自清空,互不干扰。
|
||||
thread_local! {
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static RENDERED: RefCell<Vec<(Key, String)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// 记一条(由 [`super::section::t`] 调用;不对外)。
|
||||
pub(crate) fn record(key: Key, text: &'static str) {
|
||||
TEXTS.with(|texts| texts.borrow_mut().push((key, text)));
|
||||
}
|
||||
|
||||
/// 记一条**替换完占位符**的整句(由 [`super::section::tr`] 调用;不对外)。
|
||||
pub(crate) fn record_rendered(key: Key, text: String) {
|
||||
RENDERED.with(|texts| texts.borrow_mut().push((key, text)));
|
||||
}
|
||||
|
||||
/// 取走并清空本线程记录下的取词(测试用)。
|
||||
pub(crate) fn take() -> Vec<(Key, &'static str)> {
|
||||
TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
|
||||
}
|
||||
|
||||
/// 取走并清空本线程记录下的整句(测试用)。
|
||||
pub(crate) fn take_rendered() -> Vec<(Key, String)> {
|
||||
RENDERED.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
|
||||
//!
|
||||
//! ## 控件与键的对应
|
||||
//!
|
||||
//! | 控件 | 键 | 改动消息 |
|
||||
//! |---|---|---|
|
||||
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
|
||||
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
|
||||
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
|
||||
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
|
||||
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
|
||||
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
|
||||
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
|
||||
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
|
||||
//!
|
||||
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
|
||||
//!
|
||||
//! ## 为什么「值」与「生效值」分开显示
|
||||
//!
|
||||
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
|
||||
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
|
||||
|
||||
use iced::widget::{button, column, container, row, space};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner};
|
||||
|
||||
use crate::app::components::inputs::{slider, text_field, Field, Slider};
|
||||
use crate::pages::{frame, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::alpha::{
|
||||
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
|
||||
WINDOW_ALPHA_FLOOR,
|
||||
};
|
||||
use super::background::{self, BG_DIM_MAX};
|
||||
use super::state::State;
|
||||
use super::Change;
|
||||
|
||||
/// 「外观」分组整块(一张卡)。
|
||||
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
|
||||
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
|
||||
|
||||
// ① 整窗透明度
|
||||
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
|
||||
rows = rows.push(slider_row(
|
||||
ui,
|
||||
"Window transparency",
|
||||
window_percent,
|
||||
format!(
|
||||
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
|
||||
WINDOW_ALPHA_FLOOR
|
||||
),
|
||||
state.window_opacity < 1.0,
|
||||
slider(
|
||||
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
|
||||
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
|
||||
let corner = state.window_corner();
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCorner),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSystem),
|
||||
corner == WindowCorner::System,
|
||||
Change::Corner(WindowCorner::System),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSquare),
|
||||
corner == WindowCorner::Square,
|
||||
Change::Corner(WindowCorner::Square),
|
||||
),
|
||||
],
|
||||
));
|
||||
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
|
||||
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
|
||||
if corner == WindowCorner::System && state.window_opacity < 1.0 {
|
||||
rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended)));
|
||||
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
|
||||
rows = rows.push(gap);
|
||||
}
|
||||
let tone = ui.color(Role::OnSurfaceVariant);
|
||||
rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone));
|
||||
|
||||
// ③ 面板透明度(CPU 档强制 1.0)
|
||||
let forced = state.panel_is_forced();
|
||||
let panel_percent = if forced {
|
||||
format!("100% (forced from {:.2})", state.panel_alpha)
|
||||
} else {
|
||||
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
|
||||
};
|
||||
let panel_hint = if forced {
|
||||
"Software renderer: panels stay fully opaque (window transparency still works)."
|
||||
} else {
|
||||
"Only the panel/card layer. Floor 0.40; independent of window transparency."
|
||||
};
|
||||
let panel_control = if forced {
|
||||
slider(Slider::new(ui, panel_fraction(1.0)))
|
||||
} else {
|
||||
slider(
|
||||
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
|
||||
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
)
|
||||
};
|
||||
rows = rows.push(slider_row(
|
||||
ui,
|
||||
"Panel transparency",
|
||||
panel_percent,
|
||||
panel_hint.to_owned(),
|
||||
!forced,
|
||||
panel_control,
|
||||
));
|
||||
|
||||
// ④ 背景图开关
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
"Background image",
|
||||
vec![
|
||||
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
|
||||
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑤ 路径(文本框:受控,输入即生效)
|
||||
let path_field: Element<'a, Message> = text_field(
|
||||
ui,
|
||||
Field::new(&state.bg_path)
|
||||
.label("Image path")
|
||||
.placeholder("D:/Pictures/background.png"),
|
||||
|value| Message::Appearance(Change::BgImage(value)),
|
||||
);
|
||||
rows = rows.push(
|
||||
column![label(ui, "Image path"), path_field]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
|
||||
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
|
||||
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
|
||||
for note in state.diagnostics() {
|
||||
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
|
||||
}
|
||||
if let Some(note) = state.backend_note() {
|
||||
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(note) = state
|
||||
.image()
|
||||
.and_then(|image| background::small_tile_note(image, state.bg_fill))
|
||||
{
|
||||
rows = rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(note) = state.palette_note() {
|
||||
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
let resolved = format!("Colours: {}", state.palette_level());
|
||||
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
|
||||
|
||||
// ⑦ 填充
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
"Fill",
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
"Cover",
|
||||
state.bg_fill == BgFill::Cover,
|
||||
Change::BgFill(BgFill::Cover),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Contain",
|
||||
state.bg_fill == BgFill::Contain,
|
||||
Change::BgFill(BgFill::Contain),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Tile",
|
||||
state.bg_fill == BgFill::Tile,
|
||||
Change::BgFill(BgFill::Tile),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑧ 暗化
|
||||
rows = rows.push(slider_row(
|
||||
ui,
|
||||
"Dim",
|
||||
format!("{:.0}%", state.bg_dim * 100.0),
|
||||
format!(
|
||||
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
|
||||
),
|
||||
state.bg_dim > 0.0,
|
||||
slider(
|
||||
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
|
||||
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
|
||||
),
|
||||
));
|
||||
|
||||
// ⑨ 配色来源
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
"Colour source",
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
"Background",
|
||||
state.palette_source == PaletteSource::BackgroundImage,
|
||||
Change::PaletteSource(PaletteSource::BackgroundImage),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Wallpaper",
|
||||
state.palette_source == PaletteSource::Wallpaper,
|
||||
Change::PaletteSource(PaletteSource::Wallpaper),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Preset",
|
||||
state.palette_source == PaletteSource::Preset,
|
||||
Change::PaletteSource(PaletteSource::Preset),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
card(ui, rows)
|
||||
}
|
||||
|
||||
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
|
||||
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let background = ui.layer(MaterialLayer::Card);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
border: ui.outlined(spec.shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 行与控件
|
||||
|
||||
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
content,
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
|
||||
///
|
||||
/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission,
|
||||
/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**,
|
||||
/// 接线时只换这一行,不用回来重写控件。
|
||||
fn t(key: Key) -> &'static str {
|
||||
unlucky_i18n::Lang::En.catalog().text(key)
|
||||
}
|
||||
|
||||
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
|
||||
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
|
||||
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
|
||||
}
|
||||
|
||||
/// 一行「标签 + 值 + 滑块 + 说明」。
|
||||
fn slider_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
value: String,
|
||||
hint: String,
|
||||
enabled: bool,
|
||||
control: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let value_color = if enabled {
|
||||
ui.color(Role::OnSurface)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let head = row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
head,
|
||||
control,
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行「标签 + 一排互斥按钮」。
|
||||
fn choice_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
choices: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
row(choices)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
|
||||
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
selected,
|
||||
text,
|
||||
content_color,
|
||||
))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Some(Background::Color(ui.state_over(
|
||||
unlucky_design::color::StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Appearance(change))
|
||||
.into()
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。
|
||||
//!
|
||||
//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3:
|
||||
//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
use crate::app::components::inputs::{slider, text_field, Field, Slider};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::background::{self, BG_DIM_MAX};
|
||||
use super::super::palette;
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{choice, choice_row, fixed2, label, note_line, slider_row, t, tr, tr_note};
|
||||
|
||||
/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。
|
||||
pub(super) fn push<'a>(
|
||||
ui: &'a Ui,
|
||||
state: &'a State,
|
||||
lang: Lang,
|
||||
rows: &mut Vec<Element<'a, Message>>,
|
||||
) {
|
||||
// ④ 背景图开关
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceBackgroundImage, lang),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceOff, lang),
|
||||
!state.bg_enabled,
|
||||
Change::BgEnabled(false),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceOn, lang),
|
||||
state.bg_enabled,
|
||||
Change::BgEnabled(true),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑤ 路径(文本框:受控,输入即生效)
|
||||
let path_field: Element<'a, Message> = text_field(
|
||||
ui,
|
||||
Field::new(&state.bg_path)
|
||||
.label(t(Key::SettingsAppearanceImagePath, lang))
|
||||
.placeholder("D:/Pictures/background.png"),
|
||||
|value| Message::Appearance(Change::BgImage(value)),
|
||||
);
|
||||
rows.push(
|
||||
column![
|
||||
label(ui, t(Key::SettingsAppearanceImagePath, lang)),
|
||||
path_field
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
);
|
||||
|
||||
// ⑥ 诊断(读盘失败 / 配色回落)——**不许静默**,且必须在**首屏可见处**
|
||||
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
|
||||
//
|
||||
// CPU 档的「面板/玻璃被强制」那句**不在这里**:它是张卡级状态,挂在被禁用的面板滑块旁边
|
||||
// (`panel.rs` 的 `PanelOpacityForcedHint`),一屏只出现一次(M180 复审 P2-1)。
|
||||
for note in state.diagnostics() {
|
||||
let text = tr_note(lang, note);
|
||||
rows.push(note_line(ui, &text, ui.color(Role::Error)));
|
||||
}
|
||||
if let Some(note) = state
|
||||
.image()
|
||||
.and_then(|image| background::small_tile_note(image, state.bg_fill))
|
||||
{
|
||||
let text = tr_note(lang, ¬e);
|
||||
rows.push(note_line(ui, &text, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(key) = state.palette_note() {
|
||||
rows.push(note_line(
|
||||
ui,
|
||||
t(key, lang),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
// 生效的配色层级:显示的是**回落之后真正生效**的那一级(与底部「配色来源」里用户选的那一档
|
||||
// 不一定相同)。码值 → 已有的三条档位 key(不新增平行词表),所以中文界面也读得懂。
|
||||
let level = tr(
|
||||
lang,
|
||||
Key::SettingsAppearancePaletteLevel,
|
||||
&[("level", t(palette::level_key(state.palette_level()), lang))],
|
||||
);
|
||||
rows.push(note_line(ui, &level, ui.color(Role::OnSurfaceVariant)));
|
||||
|
||||
// ⑦ 填充
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceBgFill, lang),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceBgFillCover, lang),
|
||||
state.bg_fill == BgFill::Cover,
|
||||
Change::BgFill(BgFill::Cover),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceBgFillContain, lang),
|
||||
state.bg_fill == BgFill::Contain,
|
||||
Change::BgFill(BgFill::Contain),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceBgFillTile, lang),
|
||||
state.bg_fill == BgFill::Tile,
|
||||
Change::BgFill(BgFill::Tile),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑧ 暗化
|
||||
rows.push(slider_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceBgDim, lang),
|
||||
format!("{:.0}%", state.bg_dim * 100.0),
|
||||
tr(
|
||||
lang,
|
||||
Key::SettingsAppearanceBgDimHint,
|
||||
&[("max", &fixed2(BG_DIM_MAX))],
|
||||
),
|
||||
state.bg_dim > 0.0,
|
||||
slider(
|
||||
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
|
||||
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。
|
||||
|
||||
use iced::Element;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::PaletteSource;
|
||||
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{choice, choice_row, t};
|
||||
|
||||
/// 把 ⑨ 配色来源推进行 `rows`。
|
||||
pub(super) fn push<'a>(
|
||||
ui: &'a Ui,
|
||||
state: &'a State,
|
||||
lang: Lang,
|
||||
rows: &mut Vec<Element<'a, Message>>,
|
||||
) {
|
||||
// ⑨ 配色来源
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearancePaletteSource, lang),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearancePaletteBackground, lang),
|
||||
state.palette_source == PaletteSource::BackgroundImage,
|
||||
Change::PaletteSource(PaletteSource::BackgroundImage),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearancePaletteWallpaper, lang),
|
||||
state.palette_source == PaletteSource::Wallpaper,
|
||||
Change::PaletteSource(PaletteSource::Wallpaper),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearancePalettePreset, lang),
|
||||
state.palette_source == PaletteSource::Preset,
|
||||
Change::PaletteSource(PaletteSource::Preset),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
|
||||
//!
|
||||
//! ## 控件与键的对应
|
||||
//!
|
||||
//! | 控件 | 键 | 改动消息 |
|
||||
//! |---|---|---|
|
||||
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
|
||||
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
|
||||
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
|
||||
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
|
||||
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
|
||||
//! | 填充(铺满 / 完整显示 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
|
||||
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
|
||||
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
|
||||
//!
|
||||
//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线)
|
||||
//!
|
||||
//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`),
|
||||
//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它:
|
||||
//!
|
||||
//! | 文件 | 是什么 |
|
||||
//! |---|---|
|
||||
//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 两个取词口 [`t`] / [`tr`] |
|
||||
//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) |
|
||||
//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) |
|
||||
//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 |
|
||||
//! | [`colour_source`] | ⑨ 配色来源(三档) |
|
||||
//!
|
||||
//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/
|
||||
//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动):
|
||||
//!
|
||||
//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec<Element>`);
|
||||
//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线);
|
||||
//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块);
|
||||
//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`);
|
||||
//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。
|
||||
//!
|
||||
//! ## 语言(M179 接过管子,M180 补齐文案)
|
||||
//!
|
||||
//! [`t`] / [`tr`] 是**唯一**两个取词口(一个拿 `&'static str`、一个拿带占位符的 owned 句子),
|
||||
//! `lang` 从 `settings::view` 的 `Language.effective` → `Group.body` → [`section`] 一路递进来。
|
||||
//! M179 只把管子接通(当时本组 27 条文案里真正本地化的只有圆角那 5 条);M180 `I18NAPPEAR` 把
|
||||
//! 剩下 22 条字面量与 `state.rs` / `background.rs` / `palette.rs` 的运行时诊断全部搬进
|
||||
//! `catalog/settings/appearance.rs`,于是中文界面上这一页的**卡片文案**没有一句是英文。
|
||||
//!
|
||||
//! 两处例外(M180 复审 L-1 记下的口径):① 写进 `{reason}` 的**原因串**里,来自本仓库自写的
|
||||
//! 两条(`"no path configured"`、`"unknown image format …"`)与 OS / 解码库给出的文本仍是英文——
|
||||
//! 它们是不可翻译的第三方或诊断原文;② `Windows 11` / `macOS` / `Linux` / `PNG` / `JPEG` / `BMP`
|
||||
//! 这些专名。除此之外这一页不出现英文。
|
||||
//!
|
||||
//! ## 为什么「值」与「生效值」分开显示
|
||||
//!
|
||||
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
|
||||
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
|
||||
|
||||
use iced::widget::{button, column, container, row};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use crate::pages::{frame, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::state::State;
|
||||
use super::Change;
|
||||
|
||||
mod bg_image;
|
||||
mod colour_source;
|
||||
mod panel;
|
||||
mod window;
|
||||
|
||||
/// 「外观」分组整块(一张卡)。
|
||||
///
|
||||
/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按
|
||||
/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。
|
||||
pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
|
||||
let mut rows: Vec<Element<'a, Message>> = Vec::new();
|
||||
|
||||
window::push(ui, state, lang, &mut rows);
|
||||
panel::push(ui, state, lang, &mut rows);
|
||||
bg_image::push(ui, state, lang, &mut rows);
|
||||
colour_source::push(ui, state, lang, &mut rows);
|
||||
|
||||
card(
|
||||
ui,
|
||||
column(rows)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
|
||||
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let background = ui.layer(MaterialLayer::Card);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
border: ui.outlined(spec.shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 行与控件
|
||||
//
|
||||
// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。
|
||||
|
||||
pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
content,
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
|
||||
///
|
||||
/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录
|
||||
/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块
|
||||
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M180 `I18NAPPEAR` 已把这一页剩余的 22 处
|
||||
/// 字面量与全部运行时诊断搬进词表,见 `catalog/settings/appearance.rs` 的模块文档。
|
||||
pub(super) fn t(key: Key, lang: Lang) -> &'static str {
|
||||
let text = lang.catalog().text(key);
|
||||
// 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的
|
||||
// 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。
|
||||
#[cfg(test)]
|
||||
super::probe::record(key, text);
|
||||
text
|
||||
}
|
||||
|
||||
/// 带占位符的句子(`{name}` 替换后的 owned 版本)。
|
||||
///
|
||||
/// 为什么不直接 `lang.catalog().render(...)`:这条路径也要进 [`t`] 那份取证记录
|
||||
/// (`probe::record_rendered`),否则「中文界面下这一页每一句都不是纯 ASCII」的守卫会漏掉所有
|
||||
/// 带数字的说明句。数值参数由调用方先格式化好(词表只认 `{name}`,不认 `{:.2}`)。
|
||||
pub(super) fn tr(lang: Lang, key: Key, args: &[(&str, &str)]) -> String {
|
||||
let text = lang.catalog().render(key, args);
|
||||
#[cfg(test)]
|
||||
super::probe::record_rendered(key, text.clone());
|
||||
text
|
||||
}
|
||||
|
||||
/// 两位小数(`{:.2}` 这种格式说明由 Rust 侧做,词表里只留 `{name}`)。
|
||||
pub(super) fn fixed2(value: f32) -> String {
|
||||
format!("{value:.2}")
|
||||
}
|
||||
|
||||
/// 一条 [`Note`](key + 已格式化的参数)按语言取整句;与 [`tr`] 共用取证口。
|
||||
pub(super) fn tr_note(lang: Lang, note: &super::background::Note) -> String {
|
||||
tr(lang, note.key(), ¬e.args())
|
||||
}
|
||||
|
||||
pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
|
||||
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
|
||||
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
|
||||
}
|
||||
|
||||
/// 一行「标签 + 值 + 滑块 + 说明」。
|
||||
pub(super) fn slider_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
value: String,
|
||||
hint: String,
|
||||
enabled: bool,
|
||||
control: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let value_color = if enabled {
|
||||
ui.color(Role::OnSurface)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let head = row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
head,
|
||||
control,
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行「标签 + 一排互斥按钮」。
|
||||
pub(super) fn choice_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
choices: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
row(choices)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
|
||||
pub(super) fn choice<'a>(
|
||||
ui: &'a Ui,
|
||||
text: &'a str,
|
||||
selected: bool,
|
||||
change: Change,
|
||||
) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
selected,
|
||||
text,
|
||||
content_color,
|
||||
))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Some(Background::Color(ui.state_over(
|
||||
unlucky_design::color::StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Appearance(change))
|
||||
.into()
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//! 外观分组第 ③ 段:**面板透明度**滑块(下限 0.4;CPU 档强制 1.0 并禁用滑块)。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use crate::app::components::inputs::{slider, Slider};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::alpha::{panel_alpha_from_fraction, panel_fraction, PANEL_ALPHA_FLOOR};
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{fixed2, slider_row, t, tr};
|
||||
|
||||
/// 把 ③ 面板透明度推进 `rows`。
|
||||
pub(super) fn push<'a>(
|
||||
ui: &'a Ui,
|
||||
state: &'a State,
|
||||
lang: Lang,
|
||||
rows: &mut Vec<Element<'a, Message>>,
|
||||
) {
|
||||
// ③ 面板透明度(CPU 档强制 1.0)
|
||||
let forced = state.panel_is_forced();
|
||||
let panel_percent = if forced {
|
||||
// 数值占位:`{requested}` 是请求值(词表不认 `{:.2}`,所以在这里先格式化成两位小数)。
|
||||
tr(
|
||||
lang,
|
||||
Key::SettingsAppearancePanelOpacityForcedValue,
|
||||
&[("requested", &fixed2(state.panel_alpha))],
|
||||
)
|
||||
} else {
|
||||
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
|
||||
};
|
||||
let panel_hint = if forced {
|
||||
t(Key::SettingsAppearancePanelOpacityForcedHint, lang).to_owned()
|
||||
} else {
|
||||
tr(
|
||||
lang,
|
||||
Key::SettingsAppearancePanelOpacityHint,
|
||||
&[("floor", &fixed2(PANEL_ALPHA_FLOOR))],
|
||||
)
|
||||
};
|
||||
let panel_control = if forced {
|
||||
slider(Slider::new(ui, panel_fraction(1.0)))
|
||||
} else {
|
||||
slider(
|
||||
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
|
||||
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
)
|
||||
};
|
||||
rows.push(slider_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearancePanelOpacity, lang),
|
||||
panel_percent,
|
||||
panel_hint,
|
||||
!forced,
|
||||
panel_control,
|
||||
));
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! 外观分组第 ① ② 段:**整窗**两件——整窗透明度滑块、窗口圆角(含 notice 与说明段)。
|
||||
//!
|
||||
//! 这两段是唯一**一个控件牵动另一个控件**的地方:圆角「跟随系统」时把整窗透明度调低会让圆角失效
|
||||
//! (`DwmExtendFrameIntoClientArea` 的世界里没有半透明边框),所以只有这一对放在同一个文件里——
|
||||
//! notice 的判据(`corner == System && window_opacity < 1.0`)跨这两段。
|
||||
|
||||
use iced::widget::space;
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::WindowCorner;
|
||||
|
||||
use crate::app::components::inputs::{slider, Slider};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::alpha::{window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR};
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{choice, choice_row, fixed2, note_line, slider_row, t, tr};
|
||||
|
||||
/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。
|
||||
pub(super) fn push<'a>(
|
||||
ui: &'a Ui,
|
||||
state: &'a State,
|
||||
lang: Lang,
|
||||
rows: &mut Vec<Element<'a, Message>>,
|
||||
) {
|
||||
// ① 整窗透明度
|
||||
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
|
||||
rows.push(slider_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceWindowOpacity, lang),
|
||||
window_percent,
|
||||
tr(
|
||||
lang,
|
||||
Key::SettingsAppearanceWindowOpacityHint,
|
||||
&[("floor", &fixed2(WINDOW_ALPHA_FLOOR))],
|
||||
),
|
||||
state.window_opacity < 1.0,
|
||||
slider(
|
||||
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
|
||||
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
|
||||
let corner = state.window_corner();
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCorner, lang),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSystem, lang),
|
||||
corner == WindowCorner::System,
|
||||
Change::Corner(WindowCorner::System),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSquare, lang),
|
||||
corner == WindowCorner::Square,
|
||||
Change::Corner(WindowCorner::Square),
|
||||
),
|
||||
],
|
||||
));
|
||||
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
|
||||
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
|
||||
if corner == WindowCorner::System && state.window_opacity < 1.0 {
|
||||
rows.push(super::super::notice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSuspended, lang),
|
||||
));
|
||||
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
|
||||
rows.push(gap);
|
||||
}
|
||||
let tone = ui.color(Role::OnSurfaceVariant);
|
||||
rows.push(note_line(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerHint, lang),
|
||||
tone,
|
||||
));
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
//! 变成一条**可读诊断**而不是一个 `unwrap`。
|
||||
|
||||
use unlucky_design::color::{Scheme, SchemeFlavor};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_presenter::prefs::{
|
||||
AppearanceBase, AppearancePrefs, BgFill, PaletteSource, WindowCorner,
|
||||
};
|
||||
@@ -25,7 +24,7 @@ use unlucky_presenter::prefs::{
|
||||
use crate::Backend;
|
||||
|
||||
use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque};
|
||||
use super::background::{self, Image, Note};
|
||||
use super::background::{self, Image};
|
||||
use super::palette::{self, Resolved, Source};
|
||||
|
||||
/// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。
|
||||
@@ -68,8 +67,8 @@ pub struct State {
|
||||
pub selected_group: usize,
|
||||
/// 已解码的背景图(`None` = 没设 / 读失败)。
|
||||
image: Option<Image>,
|
||||
/// 读盘/解码失败的**可读诊断**(按发生顺序追加;`Note` = key + 参数,句子在词表里)。
|
||||
diagnostics: Vec<Note>,
|
||||
/// 读盘/解码失败的**可读诊断**(按发生顺序追加)。
|
||||
diagnostics: Vec<String>,
|
||||
/// 上一次的配色解析结果。
|
||||
resolved: Option<Resolved>,
|
||||
/// 当前深浅(配色解析的输入)。
|
||||
@@ -197,15 +196,24 @@ impl State {
|
||||
Some(&resolved.scheme)
|
||||
}
|
||||
|
||||
/// 读盘/解码失败的诊断(设置页逐条显示;一条 = 一条 key + 它的参数)。
|
||||
/// 读盘/解码失败的诊断(设置页逐条显示)。
|
||||
#[must_use]
|
||||
pub fn diagnostics(&self) -> &[Note] {
|
||||
pub fn diagnostics(&self) -> &[String] {
|
||||
&self.diagnostics
|
||||
}
|
||||
|
||||
/// 配色来源的提示 key(`None` = 一级成功)。
|
||||
/// CPU 档降级的说明(`None` = 没有降级)。
|
||||
#[must_use]
|
||||
pub fn palette_note(&self) -> Option<Key> {
|
||||
pub fn backend_note(&self) -> Option<&'static str> {
|
||||
self.panel_is_forced().then_some(
|
||||
"Software renderer: panel transparency and glass stay fully opaque (window \
|
||||
transparency still works).",
|
||||
)
|
||||
}
|
||||
|
||||
/// 配色来源的提示(`None` = 一级成功)。
|
||||
#[must_use]
|
||||
pub fn palette_note(&self) -> Option<&'static str> {
|
||||
self.resolved.as_ref()?.note()
|
||||
}
|
||||
|
||||
@@ -354,7 +362,7 @@ impl State {
|
||||
}
|
||||
match background::load(trimmed) {
|
||||
Ok(image) => self.image = Some(image),
|
||||
Err(error) => self.diagnostics.push(error.note()),
|
||||
Err(error) => self.diagnostics.push(error.describe()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
//! `pages::appearance` 的单测:`Change` 的落盘映射(M93)+ **中文界面文案的取证守卫**(M180)。
|
||||
//!
|
||||
//! 为什么是独立文件:`mod.rs` 已经 220 行上下,而 M180 要在模块里加三条取证用例。搬移口径与
|
||||
//! `background/tests.rs` 一致——既有两条用例**一字未动**,只是从 `mod.rs` 的行内 `mod tests` 挪到这里。
|
||||
//!
|
||||
//! ## M180 的两条取证守卫,它们「抓得住什么」
|
||||
//!
|
||||
//! iced 0.14 没有可遍历控件树的公开接口(见 [`super::probe`] 的模块文档),所以取证点只能是
|
||||
//! 取词口 `section::t` / `section::tr`。于是这一对守卫分工如下:
|
||||
//!
|
||||
//! | 守卫 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`the_appearance_card_speaks_chinese`] | 中文界面下取到的每一条文案都是纯 ASCII(= 有人把某条 key 的英文写回中文表 / 中文表里放英文原句) | 硬编码字面量(它压根不过取词口) |
|
||||
//! | [`every_appearance_card_key_is_drawn`] | 某一处控件被改回**写死英文**:那条 key 不再被取,集合里就少一个元素 | 新加一条字面量(新 key 还没有,集合不看它) |
|
||||
//!
|
||||
//! 两条合起来才等于「中文界面下外观卡片里不再出现英文字面量」——单看任一条都有一个真会发生的
|
||||
//! 漏网口,所以这里刻意写两条。
|
||||
|
||||
use super::{Change, APPEARANCE_GROUP};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
|
||||
|
||||
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
|
||||
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
|
||||
#[test]
|
||||
fn every_change_names_its_own_schema_key() {
|
||||
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
|
||||
let cases = [
|
||||
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
|
||||
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
|
||||
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
|
||||
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
|
||||
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
|
||||
(Change::BgDim(0.5), keys::BG_DIM),
|
||||
(
|
||||
Change::PaletteSource(PaletteSource::Wallpaper),
|
||||
keys::PALETTE_SOURCE,
|
||||
),
|
||||
(
|
||||
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
|
||||
keys::WINDOW_CORNER,
|
||||
),
|
||||
];
|
||||
for (change, key) in cases {
|
||||
assert_eq!(change.key(), Some(key), "{change:?}");
|
||||
assert!(change.value().is_some());
|
||||
}
|
||||
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
|
||||
assert_eq!(Change::SelectGroup(2).value(), None);
|
||||
}
|
||||
|
||||
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
|
||||
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
|
||||
#[test]
|
||||
fn persisted_values_use_the_schema_vocabulary() {
|
||||
assert_eq!(
|
||||
Change::BgFill(BgFill::Contain).value().as_deref(),
|
||||
Some("contain")
|
||||
);
|
||||
assert_eq!(
|
||||
Change::PaletteSource(PaletteSource::BackgroundImage)
|
||||
.value()
|
||||
.as_deref(),
|
||||
Some("background-image")
|
||||
);
|
||||
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
|
||||
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 文案(M180 I18NAPPEAR)
|
||||
|
||||
/// 画一帧外观卡片,取走这一帧从词表取到的**全部**字:`(key, 文案)`。
|
||||
///
|
||||
/// 三档状态各画一帧:
|
||||
/// - [`Case::Factory`]:auto 档、没设背景图——覆盖常驻的标签 / 选项 / 说明;
|
||||
/// - [`Case::Cpu`]:CPU 档——覆盖「强制 100%」那一行与软件渲染说明(这两条只在降级态出现);
|
||||
/// - [`Case::Dim`]:整窗 α 调低——覆盖「圆角暂时不生效」那条 notice。
|
||||
///
|
||||
/// 两条取词路径都要收:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的整句(后者不进前者的记录)。
|
||||
fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
|
||||
|
||||
use super::probe;
|
||||
use crate::cli::Backend;
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
let (backend, alpha) = match case {
|
||||
Case::Factory => (Backend::Auto, 1.0),
|
||||
Case::Cpu => (Backend::Cpu, 1.0),
|
||||
Case::Dim => (Backend::Gpu, 0.5),
|
||||
};
|
||||
let mut state = super::State::new(
|
||||
&AppearancePrefs::factory(),
|
||||
&AppearanceBase::factory(),
|
||||
backend,
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
if alpha < 1.0 {
|
||||
state.apply(Change::WindowOpacity(alpha));
|
||||
}
|
||||
|
||||
let ui = Ui::new(SchemeFlavor::Light, FAMILIES);
|
||||
// 前面几条用例可能留了记录:本线程的取证每次都从清空开始。
|
||||
probe::take();
|
||||
probe::take_rendered();
|
||||
|
||||
let _tree = super::section(&ui, &state, lang);
|
||||
|
||||
let mut texts: Vec<(Key, String)> = probe::take()
|
||||
.into_iter()
|
||||
.map(|(key, text)| (key, text.to_owned()))
|
||||
.collect();
|
||||
texts.extend(probe::take_rendered());
|
||||
|
||||
// 复审 L-2:`{name}` 换完还留着花括号 ⇒ 某处参数名与模板不一致(`("floor", …)` 写成
|
||||
// `("flor", …)` 时句子会原样显示「下限 {floor}」,而「整句非 ASCII」的守卫看不出来)。
|
||||
// 三档状态合起来覆盖本页全部 `tr()` 调用,所以这条断言是全覆盖的。
|
||||
for (key, text) in &texts {
|
||||
assert!(
|
||||
!text.contains('{'),
|
||||
"{case:?}:{} 渲染后还留着占位符「{text}」——`tr()` 的参数名与模板对不上",
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// 画哪一档状态。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Case {
|
||||
/// 工厂态(GPU / auto)。
|
||||
Factory,
|
||||
/// CPU 降级态(面板 α 与玻璃被强制)。
|
||||
Cpu,
|
||||
/// 整窗 α 调低(圆角暂时不生效)。
|
||||
Dim,
|
||||
}
|
||||
|
||||
/// 这一页**常驻**的 key(三档状态合起来必须一个不缺地取到)。
|
||||
///
|
||||
/// 为什么是手写清单而不是「`Key::ALL` 里所有 `SettingsAppearance*`」:诊断一族(读盘失败 / 配色回落 /
|
||||
/// 小图平铺)取决于运行时状态,单测里要让它们出现得造磁盘上的坏图;那些由
|
||||
/// `background/tests.rs` 与 `palette.rs` 的单测按 key 逐条钉(见各自的用例注释)。
|
||||
const CARD_KEYS: &[Key] = &[
|
||||
Key::SettingsAppearanceCorner,
|
||||
Key::SettingsAppearanceCornerSystem,
|
||||
Key::SettingsAppearanceCornerSquare,
|
||||
Key::SettingsAppearanceCornerHint,
|
||||
Key::SettingsAppearanceCornerSuspended,
|
||||
Key::SettingsAppearanceWindowOpacity,
|
||||
Key::SettingsAppearanceWindowOpacityHint,
|
||||
Key::SettingsAppearancePanelOpacity,
|
||||
Key::SettingsAppearancePanelOpacityHint,
|
||||
Key::SettingsAppearancePanelOpacityForcedValue,
|
||||
Key::SettingsAppearancePanelOpacityForcedHint,
|
||||
Key::SettingsAppearanceBackgroundImage,
|
||||
Key::SettingsAppearanceOff,
|
||||
Key::SettingsAppearanceOn,
|
||||
Key::SettingsAppearanceImagePath,
|
||||
Key::SettingsAppearancePaletteLevel,
|
||||
Key::SettingsAppearanceBgFill,
|
||||
Key::SettingsAppearanceBgFillCover,
|
||||
Key::SettingsAppearanceBgFillContain,
|
||||
Key::SettingsAppearanceBgFillTile,
|
||||
Key::SettingsAppearanceBgDim,
|
||||
Key::SettingsAppearanceBgDimHint,
|
||||
Key::SettingsAppearancePaletteSource,
|
||||
Key::SettingsAppearancePaletteBackground,
|
||||
Key::SettingsAppearancePaletteWallpaper,
|
||||
Key::SettingsAppearancePalettePreset,
|
||||
];
|
||||
|
||||
/// **M180 守门 ①**:中文界面下,外观卡片里取到的每一条文案都不是纯 ASCII。
|
||||
///
|
||||
/// 抓得住:某条 key 的中文译文被换成英文原句(中文表里出现纯 ASCII 的一句);中文译文写成
|
||||
/// `PNG` / `Preset` 这种专名;`tr()` 那条路径把数字拼进英文模板(比如 `Colours: preset`)。
|
||||
/// 抓不住:硬编码字面量——它不过取词口,由 [`every_appearance_card_key_is_drawn`] 与
|
||||
/// `catalog/settings/appearance.rs` 的译文守卫合力守。
|
||||
///
|
||||
/// 变异证明:把 `SettingsAppearanceOff` 的中文改成 `"Off"` ⇒ 本条立刻红。
|
||||
#[test]
|
||||
fn the_appearance_card_speaks_chinese() {
|
||||
// 白名单:技术专名允许是纯 ASCII(现在一条都没有——`%` / 数字不在文案里,它们是参数)。
|
||||
const ASCII_OK: &[&str] = &[];
|
||||
|
||||
for case in [Case::Factory, Case::Cpu, Case::Dim] {
|
||||
let pairs = appearance_texts(Lang::ZhCn, case);
|
||||
assert!(
|
||||
pairs.len() >= 20,
|
||||
"{case:?}:取词口只记到 {} 条文案,渲染路径没走到底(语言管子断了?)",
|
||||
pairs.len()
|
||||
);
|
||||
for (key, text) in &pairs {
|
||||
if ASCII_OK.contains(&text.as_str()) {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
!text.is_ascii(),
|
||||
"{case:?}:中文界面下外观卡片里出现了纯 ASCII 的文案「{text}」({})\
|
||||
——写死英文,或中文表里放了英文原句",
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **M180 守门 ②(漏项)**:这一页每一处控件都必须从词表取词。
|
||||
///
|
||||
/// 抓得住:把某处 `t(Key::X, lang)` / `tr(lang, Key::X, …)` 改回写死的英文字面量——那条 key 不再
|
||||
/// 出现在取词记录里,集合比较当场红(这正是 M172 口径「22 处硬编码」的复发形态)。
|
||||
/// 抓不住:新加一条写死英文(它没有 key,也就没有「少了哪条」)。
|
||||
///
|
||||
/// 变异证明:把 `bg_image.rs` 里 `t(Key::SettingsAppearanceOff, lang)` 换回 `"Off"` ⇒ 本条红。
|
||||
#[test]
|
||||
fn every_appearance_card_key_is_drawn() {
|
||||
let mut seen: Vec<Key> = Vec::new();
|
||||
for case in [Case::Factory, Case::Cpu, Case::Dim] {
|
||||
seen.extend(
|
||||
appearance_texts(Lang::ZhCn, case)
|
||||
.into_iter()
|
||||
.map(|(key, _)| key),
|
||||
);
|
||||
}
|
||||
|
||||
let missing: Vec<&str> = CARD_KEYS
|
||||
.iter()
|
||||
.filter(|key| !seen.contains(key))
|
||||
.map(|key| key.name())
|
||||
.collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"这些 key 一帧都没取到(控件被改回写死英文,或整段没画):{missing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 英文界面下不许出现汉字(否则「含汉字」可能来自写死的中文,而不是词表)。
|
||||
#[test]
|
||||
fn the_appearance_card_keeps_its_english_baseline() {
|
||||
for case in [Case::Factory, Case::Cpu, Case::Dim] {
|
||||
let texts = appearance_texts(Lang::En, case);
|
||||
let with_han: Vec<&(Key, String)> = texts
|
||||
.iter()
|
||||
.filter(|(_, text)| text.chars().any(is_han))
|
||||
.collect();
|
||||
assert!(
|
||||
with_han.is_empty(),
|
||||
"{case:?}:英文界面下外观卡片出现了汉字:{with_han:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。
|
||||
fn is_han(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -81,9 +81,8 @@ pub fn view<'a>(
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: Lang,
|
||||
window_height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
view::view(ui, metrics, model, lang, window_height)
|
||||
view::view(ui, metrics, model, lang)
|
||||
}
|
||||
|
||||
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
|
||||
|
||||
@@ -11,16 +11,14 @@
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
|
||||
//!
|
||||
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use super::model::{
|
||||
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
|
||||
};
|
||||
use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||||
@@ -149,45 +147,9 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
let targets = targets();
|
||||
let target = Some("survival".to_string());
|
||||
let empty = state == "empty";
|
||||
// M162:「未联网」那一屏(与空态分开画)
|
||||
let offline = state == "offline";
|
||||
// M162:详情通道的版本列表(真实路径来自 presenter 的 `ContentDetail`)
|
||||
let demo_versions = if empty || offline {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![
|
||||
VersionRow {
|
||||
number: "mc1.20.1-0.5.11".to_string(),
|
||||
kind: "release".to_string(),
|
||||
file_name: Some("sodium-fabric-0.5.11.jar".to_string()),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", "mc1.20.1-0.5.11"),
|
||||
("kind", "Release"),
|
||||
("date", "2025-06-01"),
|
||||
],
|
||||
),
|
||||
},
|
||||
VersionRow {
|
||||
number: "mc1.20.1-0.5.10".to_string(),
|
||||
kind: "beta".to_string(),
|
||||
file_name: Some("sodium-fabric-0.5.10.jar".to_string()),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", "mc1.20.1-0.5.10"),
|
||||
("kind", "Beta"),
|
||||
("date", "2025-05-20"),
|
||||
],
|
||||
),
|
||||
},
|
||||
]
|
||||
};
|
||||
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
|
||||
let installing = state == "install";
|
||||
// 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络)
|
||||
let list = if empty || offline {
|
||||
let list = if empty {
|
||||
Vec::new()
|
||||
} else {
|
||||
hits(kind, catalog, state != "incompatible")
|
||||
@@ -234,13 +196,7 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
},
|
||||
hits: list,
|
||||
selected,
|
||||
status: if offline {
|
||||
Status::Offline("connect timed out after 10s".to_string())
|
||||
} else if empty {
|
||||
Status::Empty
|
||||
} else {
|
||||
Status::Ready
|
||||
},
|
||||
status: if empty { Status::Empty } else { Status::Ready },
|
||||
total: if empty { 0 } else { 1_284 },
|
||||
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
|
||||
has_more: !empty,
|
||||
@@ -255,8 +211,6 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
None
|
||||
},
|
||||
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
|
||||
available_versions: demo_versions,
|
||||
versions_note: None,
|
||||
draft: if empty {
|
||||
"quarkonium".to_string()
|
||||
} else {
|
||||
|
||||
@@ -74,53 +74,6 @@ impl Tier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主列在**矮窗口**下切成「整列一个滚动条」的门槛(M161)。
|
||||
///
|
||||
/// ## 为什么需要这条门槛
|
||||
///
|
||||
/// 主列自上而下是:搜索条(48)+ 筛选四排(4×32 + 3×8 = 152)+ 计数头(20)+ 结果列表 + 「加载更多」(40)
|
||||
/// 再加两条 `Space::X2` 间距。列表自己没有最小高度,于是窗口一矮,**列表视口只剩几十像素**:
|
||||
/// 一行 `ListItem` 是 60/76 高,被切成半行——两行文字(overline + 标题)看起来像「折行挤压」。
|
||||
///
|
||||
/// ## 明确的两档行为(不是到处加滚动条)
|
||||
///
|
||||
/// | 正文可用高 | 版面 |
|
||||
/// |---|---|
|
||||
/// | ≥ [`MIN_BODY_HEIGHT`] | 现状:**列表自己滚**(筛选条常驻,滚的时候不跑掉) |
|
||||
/// | < [`MIN_BODY_HEIGHT`] | 主列整列变成**一个**纵向滚动区:列表按自然高度铺开,一行都不压 |
|
||||
///
|
||||
/// 阈值 = 固定件(48 + 152 + 20 + 40 + 间距 ≈ 276)+ 至少三整行(3×60 = 180)= 456,
|
||||
/// 取 **460**(往上抹到 4 的倍数,给 divider / 取整留余量)。数值改了就改这里的算式与
|
||||
/// `layout/tests` 的等式用例。
|
||||
pub const MIN_BODY_HEIGHT: f32 = 460.0;
|
||||
|
||||
/// 正文高度的两档行为。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BodyMode {
|
||||
/// 高够:列表自己滚(筛选条常驻)
|
||||
Split,
|
||||
/// 矮窗口:主列整列一个滚动区,列表按自然高度铺开
|
||||
ScrollWhole,
|
||||
}
|
||||
|
||||
impl BodyMode {
|
||||
/// 判据(纯函数,测得到)。
|
||||
#[must_use]
|
||||
pub fn of(body_height: f32) -> Self {
|
||||
if body_height >= MIN_BODY_HEIGHT {
|
||||
Self::Split
|
||||
} else {
|
||||
Self::ScrollWhole
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表要不要自己滚。
|
||||
#[must_use]
|
||||
pub const fn list_scrolls(self) -> bool {
|
||||
matches!(self, Self::Split)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
|
||||
pub fn wide_min() -> f32 {
|
||||
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
||||
@@ -239,101 +192,3 @@ mod tests {
|
||||
assert!(!Tier::Narrow.rail_has_labels());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod body_mode_tests {
|
||||
use super::*;
|
||||
|
||||
/// 门槛是**算出来的**:固定件(搜索 48 + 筛选 152 + 计数 20 + 加载更多 40 + 两条 8 间距)
|
||||
/// 加三整行列表(3 × 60)。谁把 460 改成拍脑袋的数,这条会红。
|
||||
#[test]
|
||||
fn the_minimum_body_height_is_fixed_parts_plus_three_rows() {
|
||||
let fixed = 48.0 + 152.0 + 20.0 + 40.0 + 2.0 * Space::X2.dp();
|
||||
let three_rows = 3.0 * 60.0;
|
||||
assert!(
|
||||
MIN_BODY_HEIGHT >= fixed + three_rows,
|
||||
"门槛 {MIN_BODY_HEIGHT} 小于「固定件 {fixed} + 三行 {three_rows}」——矮窗口还是会切半行"
|
||||
);
|
||||
assert!(
|
||||
MIN_BODY_HEIGHT <= fixed + three_rows + 16.0,
|
||||
"门槛留了太多余量:{} 会把还放得下的窗口也推进「整列滚动」档",
|
||||
MIN_BODY_HEIGHT - (fixed + three_rows)
|
||||
);
|
||||
}
|
||||
|
||||
/// 两档在门槛处切开,且只有 `Split` 让列表自己滚。
|
||||
#[test]
|
||||
fn the_body_mode_flips_at_the_threshold() {
|
||||
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT), BodyMode::Split);
|
||||
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT - 0.5), BodyMode::ScrollWhole);
|
||||
assert!(BodyMode::of(900.0).list_scrolls());
|
||||
assert!(!BodyMode::of(300.0).list_scrolls());
|
||||
}
|
||||
|
||||
/// 最小窗口(880×560)落在**矮**档,验收到 760 不换版面——**正文可用高按真实外壳算**。
|
||||
///
|
||||
/// 复审 P2-2:早先这里是 `560 − 56 − 88 − 32` 反推的(三个数全错:真实是 titlebar 48 /
|
||||
/// 两行页头 84 / Expanded 页边距 2×24 = chrome 180),结论碰巧对、但抓不住 metrics 漂移。
|
||||
/// 现在直接调 [`crate::pages::frame::body_height`],chrome 从 token 现取。
|
||||
#[test]
|
||||
fn the_minimum_window_size_uses_the_scrolling_body() {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::pages::frame;
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
let ui = Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
);
|
||||
// 880 宽 = 窗口最小尺寸(`app::MIN_SIZE.width`),属 Expanded。
|
||||
let metrics = frame_metrics(PaneKit::Single, 880.0);
|
||||
let chrome =
|
||||
ui.metrics().titlebar_height + ui.metrics().header_height(true) + 2.0 * metrics.gutter;
|
||||
|
||||
// 真值:48 + 84 + 48 = 180。这条会随 token 改而红——那时请重看一眼两档版面。
|
||||
assert_eq!(chrome, 180.0, "外壳 chrome 与 design token 不一致");
|
||||
|
||||
// 最小窗口 560 高:正文只剩 380 < 460 ⇒ 整列滚动(本条债的真实场景)。
|
||||
let short = frame::body_height(&ui, metrics, 560.0, true);
|
||||
assert_eq!(short, 560.0 - chrome);
|
||||
assert_eq!(
|
||||
BodyMode::of(short),
|
||||
BodyMode::ScrollWhole,
|
||||
"正文只剩 {short}"
|
||||
);
|
||||
|
||||
// 验收尺寸 760 高:580 ≥ 460 ⇒ 列表自己滚(不换版面)。
|
||||
let tall = frame::body_height(&ui, metrics, 760.0, true);
|
||||
assert_eq!(BodyMode::of(tall), BodyMode::Split);
|
||||
|
||||
// 把门槛和**真实 chrome** 绑死:窗口高 = chrome + MIN 时必须还是 Split,
|
||||
// 少 1px 就翻成整列滚动。chrome 涨到 100/页边距 40 这类漂移会让这条立刻红
|
||||
// (而不是像旧用例那样拿反推数字自说自话)。
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT,
|
||||
true
|
||||
)),
|
||||
BodyMode::Split
|
||||
);
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT - 1.0,
|
||||
true
|
||||
)),
|
||||
BodyMode::ScrollWhole
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_i18n::Catalog;
|
||||
|
||||
use crate::pages::download::Download;
|
||||
|
||||
@@ -35,7 +35,7 @@ mod items;
|
||||
|
||||
pub(crate) use items::HitFacts;
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
|
||||
|
||||
/// 内容库页要画的东西。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -75,10 +75,6 @@ pub struct Content {
|
||||
pub draft: String,
|
||||
/// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检
|
||||
pub pack: super::packui::Pack,
|
||||
/// 选中项目的可用版本(M162:详情通道;空 = 还没拉到 / 站点没给)
|
||||
pub available_versions: Vec<VersionRow>,
|
||||
/// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说)
|
||||
pub versions_note: Option<String>,
|
||||
}
|
||||
|
||||
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
|
||||
@@ -160,9 +156,6 @@ impl Content {
|
||||
catalog,
|
||||
);
|
||||
|
||||
let (available_versions, versions_note) =
|
||||
detail_view(content, page.selected.as_deref(), catalog);
|
||||
|
||||
Self {
|
||||
kind: page.kind,
|
||||
query: ContentQueryView {
|
||||
@@ -190,8 +183,6 @@ impl Content {
|
||||
versions,
|
||||
draft: page.draft.clone(),
|
||||
pack,
|
||||
available_versions,
|
||||
versions_note,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +213,8 @@ impl Content {
|
||||
|
||||
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
|
||||
fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
|
||||
if let Some(failure) = &content.failed {
|
||||
// M162:没联网单独一态(空列表不能冒充「没有结果」)
|
||||
return if failure.is_offline() {
|
||||
Status::Offline(failure.detail.clone())
|
||||
} else {
|
||||
Status::Failed(failure.detail.clone())
|
||||
};
|
||||
if let Some(reason) = &content.failed {
|
||||
return Status::Failed(reason.clone());
|
||||
}
|
||||
if content.pending || content.results.is_none() {
|
||||
return Status::Loading;
|
||||
@@ -251,57 +237,6 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
|
||||
.and_then(|progress| crate::pages::download::install_panel_model(progress, catalog))
|
||||
}
|
||||
|
||||
/// 选中项目的版本列表 + 一句说明(详情通道;迟到结果按项目 id 丢)。
|
||||
///
|
||||
/// 三条优先级:**有结果就画结果** → 在飞就画「正在取」 → 失败才画原因。选中项变了而结果是
|
||||
/// 上一个项目的,一律当「没有」(`detail.project != selected`)——否则会画着 A 的版本列表
|
||||
/// 却高亮着 B。
|
||||
fn detail_view(
|
||||
content: &ContentState,
|
||||
selected: Option<&str>,
|
||||
catalog: Catalog,
|
||||
) -> (Vec<VersionRow>, Option<String>) {
|
||||
let Some(selected) = selected else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
if let Some(detail) = content.detail.as_ref().filter(|d| d.project == selected) {
|
||||
let rows = detail
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| VersionRow {
|
||||
number: version.number.clone(),
|
||||
kind: version.kind.clone(),
|
||||
file_name: version.file_name.clone(),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", version.number.as_str()),
|
||||
("kind", capitalize(&version.kind).as_str()),
|
||||
("date", version.published.as_str()),
|
||||
],
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
return (rows, None);
|
||||
}
|
||||
if content.detail_pending {
|
||||
return (
|
||||
Vec::new(),
|
||||
Some(catalog.text(Key::ContentDetailFetching).to_string()),
|
||||
);
|
||||
}
|
||||
match &content.detail_failed {
|
||||
Some(failure) => (
|
||||
Vec::new(),
|
||||
Some(catalog.render(
|
||||
Key::ContentDetailFetchFailed,
|
||||
&[("reason", failure.detail.as_str())],
|
||||
)),
|
||||
),
|
||||
None => (Vec::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
|
||||
///
|
||||
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
|
||||
|
||||
@@ -45,34 +45,13 @@ pub enum Status {
|
||||
Empty,
|
||||
/// 失败(带一句人读原因)
|
||||
Failed(String),
|
||||
/// 未联网(M162):**不是**「没有匹配的内容」,也不是普通失败——单独一屏,
|
||||
/// 说清「要么联网要么没结果」这件事不可能靠空列表表达。
|
||||
Offline(String),
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// 失败了吗(视图用它决定画错误面板还是列表)。
|
||||
pub const fn failed(&self) -> bool {
|
||||
matches!(self, Self::Failed(_) | Self::Offline(_))
|
||||
matches!(self, Self::Failed(_))
|
||||
}
|
||||
|
||||
/// 未联网吗(视图据此画离线态而不是通用失败态)。
|
||||
pub const fn offline(&self) -> bool {
|
||||
matches!(self, Self::Offline(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本列表的一行(M162:详情通道拉回来的 `ContentVersionView`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionRow {
|
||||
/// 版本号(`mc1.20.1-0.5.11`)
|
||||
pub number: String,
|
||||
/// 版本类型(`release` / `beta` / `alpha`,站点原文)
|
||||
pub kind: String,
|
||||
/// 文件名(`None` = 站点没给)
|
||||
pub file_name: Option<String>,
|
||||
/// 已经求好文案的一行(`版本号 · 类型 · 日期`)
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use super::*;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, ContentQuery,
|
||||
ContentResults, ContentSource, ContentVersionView, InstanceRuntime, LoaderKind,
|
||||
ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
|
||||
LoaderKind,
|
||||
};
|
||||
|
||||
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
|
||||
@@ -200,72 +200,10 @@ fn the_four_states_are_derived_from_three_fields() {
|
||||
#[test]
|
||||
fn a_failure_outranks_the_loading_state() {
|
||||
let mut state = state_with(None, true);
|
||||
state.content.failed = Some(ContentFailure::new(ContentFailureKind::Server, "站点 503"));
|
||||
state.content.failed = Some("网络不通".to_string());
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(model.status, Status::Failed("站点 503".to_string()));
|
||||
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
|
||||
assert!(model.status.failed());
|
||||
assert!(!model.status.offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_network_is_its_own_state_not_an_empty_list() {
|
||||
// M162:无网必须画「未联网」,不能与「没有匹配的内容」共用一个空态。
|
||||
let mut state = state_with(None, false);
|
||||
state.content.results = Some(results(Vec::new(), 0));
|
||||
state.content.failed = Some(ContentFailure::offline("connect timed out"));
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(
|
||||
model.status,
|
||||
Status::Offline("connect timed out".to_string())
|
||||
);
|
||||
assert!(model.status.offline());
|
||||
assert!(model.status.failed());
|
||||
assert_ne!(model.status, Status::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_come_from_the_detail_channel_of_the_selected_project() {
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
let mut page = PageState {
|
||||
selected: Some("p1".to_string()),
|
||||
..PageState::default()
|
||||
};
|
||||
// 详情还没到:说明「正在取」,不是「一个版本都没有」
|
||||
state.content.request_detail("p1");
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert!(model.available_versions.is_empty());
|
||||
assert!(model.versions_note.is_some());
|
||||
|
||||
state.content.apply_detail(
|
||||
"p1",
|
||||
ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
description: "fast".to_string(),
|
||||
body: String::new(),
|
||||
versions: vec![ContentVersionView {
|
||||
id: "v1".to_string(),
|
||||
name: "Sodium 0.6.0".to_string(),
|
||||
number: "mc1.20.1-0.6.0".to_string(),
|
||||
kind: "release".to_string(),
|
||||
game_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
published: "2026-02-01T00:00:00Z".to_string(),
|
||||
file_name: Some("sodium.jar".to_string()),
|
||||
file_size: Some(2048),
|
||||
}],
|
||||
},
|
||||
);
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert_eq!(model.available_versions.len(), 1);
|
||||
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
|
||||
assert!(model.available_versions[0].line.contains("mc1.20.1-0.6.0"));
|
||||
assert!(model.versions_note.is_none());
|
||||
|
||||
// 换了选中项:上一个项目的版本列表不许画在这个项目下面
|
||||
page.selected = Some("p2".to_string());
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert!(model.available_versions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -285,7 +223,6 @@ fn can_install_needs_a_selection_and_no_blockers() {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
version_present: true,
|
||||
launch: unlucky_presenter::dto::LaunchArgsView::default(),
|
||||
}];
|
||||
let catalog = Lang::En.catalog();
|
||||
let page = PageState {
|
||||
|
||||
@@ -28,22 +28,14 @@
|
||||
//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) |
|
||||
//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) |
|
||||
//!
|
||||
//! ## M160 新增的一屏
|
||||
//!
|
||||
//! `model/pick.rs` + `view/pick.rs` 是**导入前的冲突清单**:分页(每页 `PAGE_SIZE` 条)、
|
||||
//! 三个入口、计数摘要、确认/取消。它出现的前提是 core 的预检给出了至少一条冲突——
|
||||
//! 没有冲突时页面连这一屏都不画(直接往下导)。
|
||||
//!
|
||||
//! ## 本版明确的边界(报告里同样登记)
|
||||
//!
|
||||
//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、
|
||||
//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是
|
||||
//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。
|
||||
//! - **冲突是逐文件决策了(M160)**:点「导入」先跑一次**只读预检**(core 的 `scan_pack`),
|
||||
//! 没有冲突就直接导(不打扰用户);有冲突就画那一屏清单——分页([`model::PAGE_SIZE`] 条一页)、
|
||||
//! 「全部保留 / 全部覆盖 / 逐项」三个入口、计数摘要、确认与取消。**缺省永远是保留**:
|
||||
//! 没点过的文件一个都不会被包内内容替换。仍然不支持的是「两者都留(改名)」——那要改落盘
|
||||
//! 布局,不在本轮。
|
||||
//! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 /
|
||||
//! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」,
|
||||
//! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。
|
||||
|
||||
mod demo;
|
||||
mod message;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `pick` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//!
|
||||
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
|
||||
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
|
||||
@@ -20,8 +20,8 @@ use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
||||
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
|
||||
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView,
|
||||
InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
@@ -68,42 +68,13 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
|
||||
match state {
|
||||
"importing" => import.progress = Some(progress()),
|
||||
// M160:导入前的冲突清单(分页 + 三个入口 + 计数摘要)
|
||||
"pick" => {
|
||||
import.scan = Some(scan(312));
|
||||
// 停在第 2 页,且已经逐项点过两条「覆盖」——图上能同时看见分页与逐项状态
|
||||
page.pick_page = 1;
|
||||
// 两条落到**第 2 页**的路径上:图上能同时看见「保留」与「覆盖」两种行状态
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-041.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-044.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
}
|
||||
"partial" => import.report = Some(report(ImportStatusView::Partial, false)),
|
||||
"conflict" => {
|
||||
import.report = Some(report(ImportStatusView::Partial, true));
|
||||
page.expanded = Some("config/sodium-options.json".to_string());
|
||||
}
|
||||
// 致命态(M161 分成三档出图:预检没过 / 已回滚 / 回滚没做完)。
|
||||
// `failed` 保留旧名字(= 已回滚),另外两个是新档位。
|
||||
"failed" | "failed_rolled_back" => {
|
||||
let mut fatal = report(ImportStatusView::Failed, false);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "import_integrity".to_string(),
|
||||
label: "Integrity check".to_string(),
|
||||
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
|
||||
hint: Some("Re-download the pack from its source".to_string()),
|
||||
});
|
||||
fatal.rolled_back = true;
|
||||
fatal.failure_stage = Some(ImportFailureStageView::RolledBack);
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 预检没过:**一个字节都没写**(与上一条只差 `failure_stage`,图上要能一眼分辨)
|
||||
"failed_preflight" => {
|
||||
// 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野)
|
||||
"failed" => {
|
||||
let mut fatal = report(ImportStatusView::Failed, false);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
@@ -113,21 +84,6 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
});
|
||||
fatal.files.clear();
|
||||
fatal.bytes_written = 0;
|
||||
fatal.rolled_back = true;
|
||||
fatal.failure_stage = Some(ImportFailureStageView::NotStarted);
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 回滚没做完:目标目录可能与导入前不同
|
||||
"failed_incomplete" => {
|
||||
let mut fatal = report(ImportStatusView::Failed, false);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "import_integrity".to_string(),
|
||||
label: "Integrity check".to_string(),
|
||||
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
|
||||
hint: None,
|
||||
});
|
||||
fatal.rolled_back = false;
|
||||
fatal.failure_stage = Some(ImportFailureStageView::RollbackIncomplete);
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
|
||||
@@ -211,7 +167,6 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
|
||||
registered: Some("Survival".to_string()),
|
||||
bytes_written: 3_400_000,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
if conflicts {
|
||||
report.conflicts = vec![
|
||||
@@ -239,59 +194,6 @@ fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条待决冲突(M160 预览清单的一行)。
|
||||
fn pending(index: usize) -> unlucky_presenter::dto::ImportConflictView {
|
||||
// 前几条用真实形态的路径,其余按序号生成:截图里既要有可读的真实感,也要有"很多条"的证据。
|
||||
let path = match index {
|
||||
0 => "config/sodium-options.json".to_string(),
|
||||
1 => "mods/create-1.2.3.jar".to_string(),
|
||||
2 => "config/jei/jei-client.ini".to_string(),
|
||||
3 => "options.txt".to_string(),
|
||||
_ => format!("config/generated/module-{index:03}.toml"),
|
||||
};
|
||||
unlucky_presenter::dto::ImportConflictView {
|
||||
path,
|
||||
source: if index.is_multiple_of(3) {
|
||||
unlucky_presenter::dto::ConflictSourceView::ClientOverrides
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictSourceView::Overrides
|
||||
},
|
||||
reason: if index.is_multiple_of(2) {
|
||||
unlucky_presenter::dto::ConflictReasonView::PreExisting
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictReasonView::UserModified
|
||||
},
|
||||
disabled: false,
|
||||
pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(),
|
||||
pack_size: 3_800 + (index as u64 * 37) % 900,
|
||||
disk_sha1: "c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string(),
|
||||
disk_size: 4_100 + (index as u64 * 53) % 1_200,
|
||||
base_sha1: (!index.is_multiple_of(2))
|
||||
.then(|| "3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检结果(`count` 条冲突;图上靠分页只画当前这一页)。
|
||||
fn scan(count: usize) -> unlucky_presenter::dto::ImportScanView {
|
||||
let conflicts: Vec<unlucky_presenter::dto::ImportConflictView> =
|
||||
(0..count).map(pending).collect();
|
||||
let lost_bytes = conflicts.iter().map(|each| each.disk_size).sum();
|
||||
let incoming_bytes = conflicts.iter().map(|each| each.pack_size).sum();
|
||||
unlucky_presenter::dto::ImportScanView {
|
||||
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
conflicts,
|
||||
unknown_remote: 4,
|
||||
lost_bytes,
|
||||
incoming_bytes,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出预检:12 内联 / 1 跳过(包不完整)。
|
||||
fn preview() -> ExportPreviewView {
|
||||
let mut files = vec![ExportFileView {
|
||||
|
||||
@@ -30,22 +30,10 @@ pub enum Message {
|
||||
Cancel,
|
||||
/// 关掉结果面板(报告 / 致命错误都走这一条)
|
||||
Dismiss,
|
||||
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档)
|
||||
/// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
|
||||
Overwrite,
|
||||
/// 展开/收起某一条冲突的三方明细
|
||||
ToggleConflict(String),
|
||||
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
|
||||
PickPage(usize),
|
||||
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
|
||||
TogglePick(String),
|
||||
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
|
||||
PickKeepAll,
|
||||
/// 整批档:全部覆盖
|
||||
PickOverwriteAll,
|
||||
/// 按当前选择开始导入
|
||||
ConfirmImport,
|
||||
/// 取消这次导入(不写盘,回到入口态)
|
||||
CancelImport,
|
||||
/// 换目标实例(`None` = 按包名新建)
|
||||
SetInstance(Option<InstanceId>),
|
||||
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
|
||||
|
||||
@@ -23,28 +23,18 @@
|
||||
//! ## 容忍 / 致命在模型层就分开
|
||||
//!
|
||||
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
|
||||
//! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
//!
|
||||
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
|
||||
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
|
||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
|
||||
mod export;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||
pub use pick::{Pick, PickRow};
|
||||
pub use progress::Running;
|
||||
pub use report::{ConflictRow, FatalKind, Report, Row};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
pub use report::{ConflictRow, Report, Row};
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
|
||||
PackFormatView, ProbeView,
|
||||
};
|
||||
use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
|
||||
@@ -71,12 +61,6 @@ pub struct PackPageState {
|
||||
pub export_open: bool,
|
||||
/// 展开的那条冲突路径
|
||||
pub expanded: Option<String>,
|
||||
/// 冲突清单翻到第几页(0 起;M160)
|
||||
pub pick_page: usize,
|
||||
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
|
||||
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
|
||||
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
|
||||
pub pick_overwrite_all: bool,
|
||||
}
|
||||
|
||||
impl Default for PackPageState {
|
||||
@@ -94,65 +78,10 @@ impl Default for PackPageState {
|
||||
output: String::new(),
|
||||
export_open: false,
|
||||
expanded: None,
|
||||
pick_page: 0,
|
||||
pick_choices: BTreeMap::new(),
|
||||
pick_overwrite_all: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPageState {
|
||||
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
|
||||
#[must_use]
|
||||
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
|
||||
if let Some(choice) = self.pick_choices.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 把这一屏的选择变成发给宿主的那份决策。
|
||||
///
|
||||
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
|
||||
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
|
||||
/// 全点一遍),而"没点过"永远落在保留上。
|
||||
#[must_use]
|
||||
pub fn decisions(
|
||||
&self,
|
||||
scan: Option<&unlucky_presenter::dto::ImportScanView>,
|
||||
) -> ConflictDecisionsView {
|
||||
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
|
||||
let default = if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
};
|
||||
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
|
||||
let items: Vec<ConflictDecisionView> = self
|
||||
.pick_choices
|
||||
.iter()
|
||||
.filter(|(_, choice)| **choice != default)
|
||||
.map(|(path, choice)| ConflictDecisionView {
|
||||
path: path.clone(),
|
||||
choice: *choice,
|
||||
})
|
||||
.collect();
|
||||
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
|
||||
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
|
||||
if items.is_empty() || empty {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
|
||||
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
|
||||
};
|
||||
}
|
||||
ConflictDecisionsView::per_item(default, items)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个实例候选(导入目标与导出源共用)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackTarget {
|
||||
@@ -203,8 +132,6 @@ pub struct Pack {
|
||||
pub cancelled_note: &'static str,
|
||||
/// 导入结果
|
||||
pub report: Option<Report>,
|
||||
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
|
||||
pub pick: Option<Pick>,
|
||||
/// 导出块
|
||||
pub export: Export,
|
||||
}
|
||||
@@ -254,15 +181,6 @@ impl Pack {
|
||||
.report
|
||||
.as_ref()
|
||||
.map(|report| report::report_of(report, page, catalog)),
|
||||
pick: import.scan.as_ref().and_then(|scan| {
|
||||
pick::pick_of(
|
||||
scan,
|
||||
page.pick_page,
|
||||
&page.pick_choices,
|
||||
page.pick_overwrite_all,
|
||||
catalog,
|
||||
)
|
||||
}),
|
||||
export: export::export_of(export, page, candidates, catalog),
|
||||
}
|
||||
}
|
||||
@@ -272,7 +190,6 @@ impl Pack {
|
||||
self.running.is_none()
|
||||
&& self.probe.is_none()
|
||||
&& self.report.is_none()
|
||||
&& self.pick.is_none()
|
||||
&& !self.cancelled
|
||||
&& !self.export.open
|
||||
&& self.draft.is_empty()
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
//! 导入**冲突清单**的视图模型(M160):分页、计数、逐项处置。
|
||||
//!
|
||||
//! # 为什么是分页而不是"一次画完"
|
||||
//!
|
||||
//! 冲突清单可能有几千条(一个用户改过很多配置的老实例)。`iced` 的每一行都是真实 widget,
|
||||
//! 一次画几千行会同时拖慢布局与滚动,而用户真正要看的永远只有"这一屏"。所以模型里只出
|
||||
//! **当前这一页**([`PAGE_SIZE`] 条),页面上的「上一页 / 下一页」改的是
|
||||
//! [`PackPageState::pick_page`]——数据本身留在 presenter 的 `ImportScanView` 里,页面不复制。
|
||||
//!
|
||||
//! # 三个入口如何体现在计数上
|
||||
//!
|
||||
//! | 用户点了 | 决策表 | 计数 |
|
||||
//! |---|---|---|
|
||||
//! | 全部保留 | [`ConflictDecisionsView::KeepAll`] | 覆盖 0 |
|
||||
//! | 全部覆盖 | [`ConflictDecisionsView::OverwriteAll`] | 覆盖 = 全部 |
|
||||
//! | 逐项 | `PerItem`(只列"覆盖"的那些,其余按保留) | 覆盖 = 点过的条数 |
|
||||
//!
|
||||
//! 页面显示的两个数直接来自 [`PackPageState::decisions`]、[`PackPageState::pick_choice`] 的计算,
|
||||
//! 与发给 host 的那份决策**同源**——不会出现"界面说覆盖 3 个、实际覆盖 0 个"。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
human_bytes, ConflictChoiceView, ConflictReasonView, ConflictSourceView, ImportConflictView,
|
||||
ImportScanView,
|
||||
};
|
||||
|
||||
/// 一页画多少行(一次画几千行是性能陷阱;40 行在一屏内可读且不滚动太多)。
|
||||
pub const PAGE_SIZE: usize = 40;
|
||||
|
||||
/// 冲突清单块(导入前那一屏)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pick {
|
||||
/// 标题(`导入前:有 12 个文件已存在且内容不同`)
|
||||
pub title: String,
|
||||
/// 计数摘要(`保留 9 · 覆盖 3 · 覆盖将丢弃 2.4 MB`)
|
||||
pub summary: String,
|
||||
/// 缺省说明("没改动的一律按保留")
|
||||
pub default_note: &'static str,
|
||||
/// 判不了的远端条目(`Some` 才画那一行)
|
||||
pub unknown_note: Option<String>,
|
||||
/// **当前页**的行(最多 [`PAGE_SIZE`] 条)
|
||||
pub rows: Vec<PickRow>,
|
||||
/// 当前页(0 起)
|
||||
pub page: usize,
|
||||
/// 总页数(至少 1)
|
||||
pub pages: usize,
|
||||
/// 区间说明(`第 1-40 项,共 312 项`)
|
||||
pub range: String,
|
||||
/// 保留几条
|
||||
pub keep_count: usize,
|
||||
/// 覆盖几条
|
||||
pub overwrite_count: usize,
|
||||
/// 能不能翻页
|
||||
pub can_prev: bool,
|
||||
/// 能不能翻页
|
||||
pub can_next: bool,
|
||||
/// 三个入口的文案
|
||||
pub keep_all: &'static str,
|
||||
/// 三个入口的文案
|
||||
pub overwrite_all: &'static str,
|
||||
/// 逐项按钮(保留)的文案
|
||||
pub row_keep: &'static str,
|
||||
/// 逐项按钮(覆盖)的文案
|
||||
pub row_overwrite: &'static str,
|
||||
/// 翻页文案
|
||||
pub prev: &'static str,
|
||||
/// 翻页文案
|
||||
pub next: &'static str,
|
||||
/// 确认导入
|
||||
pub confirm: String,
|
||||
/// 取消导入
|
||||
pub cancel: &'static str,
|
||||
/// 「全部保留」这一档当前是否生效(整批档切到保留时高亮)
|
||||
pub keep_all_active: bool,
|
||||
/// 「全部覆盖」这一档当前是否生效
|
||||
pub overwrite_all_active: bool,
|
||||
}
|
||||
|
||||
/// 清单里的一行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickRow {
|
||||
/// 实例内路径
|
||||
pub path: String,
|
||||
/// 来源(`包内 overrides/` / `清单 files[]`)
|
||||
pub source: String,
|
||||
/// 成因(`本来就在` / `你改过`)
|
||||
pub reason: String,
|
||||
/// 两侧体积(`你的:2.1 MB · 包内:1.8 MB`)
|
||||
pub sizes: String,
|
||||
/// 当前处置([`ConflictChoiceView::KeepUser`] = 保留)
|
||||
pub choice: ConflictChoiceView,
|
||||
/// 这一行按钮上写的字(点了就变成另一侧)
|
||||
pub toggle: &'static str,
|
||||
}
|
||||
|
||||
impl PickRow {
|
||||
/// 这一行是"覆盖"吗。
|
||||
#[must_use]
|
||||
pub const fn overwrites(&self) -> bool {
|
||||
matches!(self.choice, ConflictChoiceView::Overwrite)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从预检结果 + 页面选择造出这一块(`None` = 没有待决冲突,页面不该画它)。
|
||||
#[must_use]
|
||||
pub fn pick_of(
|
||||
scan: &ImportScanView,
|
||||
page_number: usize,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
catalog: Catalog,
|
||||
) -> Option<Pick> {
|
||||
if scan.conflicts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let total = scan.conflicts.len();
|
||||
let pages = total.div_ceil(PAGE_SIZE).max(1);
|
||||
let page = page_number.min(pages - 1);
|
||||
let from = page * PAGE_SIZE;
|
||||
let to = (from + PAGE_SIZE).min(total);
|
||||
|
||||
let rows: Vec<PickRow> = scan.conflicts[from..to]
|
||||
.iter()
|
||||
.map(|conflict| row_of(conflict, per_item, batch_overwrite, catalog))
|
||||
.collect();
|
||||
|
||||
let overwrite_count = scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| {
|
||||
effective(conflict.path.as_str(), per_item, batch_overwrite)
|
||||
== ConflictChoiceView::Overwrite
|
||||
})
|
||||
.count();
|
||||
let keep_count = total - overwrite_count;
|
||||
// 覆盖会丢掉多少:只算"你真选了覆盖"的那些,避免用最坏数字吓人。
|
||||
let lost = scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| {
|
||||
effective(conflict.path.as_str(), per_item, batch_overwrite)
|
||||
== ConflictChoiceView::Overwrite
|
||||
})
|
||||
.map(|conflict| conflict.disk_size)
|
||||
.fold(0_u64, u64::saturating_add);
|
||||
|
||||
Some(Pick {
|
||||
title: catalog.render(Key::PackPickTitle, &[("count", &total.to_string())]),
|
||||
summary: catalog.render(
|
||||
Key::PackPickSummary,
|
||||
&[
|
||||
("keep", &keep_count.to_string()),
|
||||
("overwrite", &overwrite_count.to_string()),
|
||||
("bytes", &human_bytes(lost)),
|
||||
],
|
||||
),
|
||||
default_note: catalog.text(Key::PackPickDefaultNote),
|
||||
unknown_note: (scan.unknown_remote > 0).then(|| {
|
||||
catalog.render(
|
||||
Key::PackPickUnknownRemote,
|
||||
&[("count", &scan.unknown_remote.to_string())],
|
||||
)
|
||||
}),
|
||||
rows,
|
||||
page,
|
||||
pages,
|
||||
range: catalog.render(
|
||||
Key::PackPickPage,
|
||||
&[
|
||||
("from", &(from + 1).to_string()),
|
||||
("to", &to.to_string()),
|
||||
("total", &total.to_string()),
|
||||
],
|
||||
),
|
||||
keep_count,
|
||||
overwrite_count,
|
||||
can_prev: page > 0,
|
||||
can_next: page + 1 < pages,
|
||||
keep_all: catalog.text(Key::PackPickKeepAll),
|
||||
overwrite_all: catalog.text(Key::PackPickOverwriteAll),
|
||||
row_keep: catalog.text(Key::PackPickRowKeep),
|
||||
row_overwrite: catalog.text(Key::PackPickRowOverwrite),
|
||||
prev: catalog.text(Key::PackPickPrev),
|
||||
next: catalog.text(Key::PackPickNext),
|
||||
confirm: catalog.render(Key::PackPickConfirm, &[]),
|
||||
cancel: catalog.text(Key::PackPickCancel),
|
||||
keep_all_active: !batch_overwrite,
|
||||
overwrite_all_active: batch_overwrite,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一条冲突的**有效**处置:逐项优先,其次整批档,缺省保留(与 core 侧同一条规则)。
|
||||
fn effective(
|
||||
path: &str,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
) -> ConflictChoiceView {
|
||||
if let Some(choice) = per_item.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if batch_overwrite {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
fn row_of(
|
||||
conflict: &ImportConflictView,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
catalog: Catalog,
|
||||
) -> PickRow {
|
||||
let choice = effective(conflict.path.as_str(), per_item, batch_overwrite);
|
||||
PickRow {
|
||||
path: conflict.path.clone(),
|
||||
source: source_label(conflict.source, catalog).to_owned(),
|
||||
reason: reason_label(conflict.reason, catalog).to_owned(),
|
||||
sizes: format!(
|
||||
"{} · {}",
|
||||
catalog.render(
|
||||
Key::PackPickDisk,
|
||||
&[("size", &human_bytes(conflict.disk_size))]
|
||||
),
|
||||
catalog.render(
|
||||
Key::PackPickIncoming,
|
||||
&[("size", &human_bytes(conflict.pack_size))]
|
||||
)
|
||||
),
|
||||
choice,
|
||||
// 按钮写"点了会变成什么":当前保留 ⇒ 按钮是「覆盖」。
|
||||
toggle: if matches!(choice, ConflictChoiceView::Overwrite) {
|
||||
catalog.text(Key::PackPickRowKeep)
|
||||
} else {
|
||||
catalog.text(Key::PackPickRowOverwrite)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 来源的人读串。
|
||||
fn source_label(source: ConflictSourceView, catalog: Catalog) -> &'static str {
|
||||
match source {
|
||||
ConflictSourceView::Overrides => catalog.text(Key::PackPickSourceOverrides),
|
||||
ConflictSourceView::ClientOverrides => catalog.text(Key::PackPickSourceClientOverrides),
|
||||
ConflictSourceView::CurseForge | ConflictSourceView::Modrinth => {
|
||||
catalog.text(Key::PackPickSourceManifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 成因的人读串(复用导入报告那三句,不另造一套说法)。
|
||||
fn reason_label(reason: ConflictReasonView, catalog: Catalog) -> &'static str {
|
||||
match reason {
|
||||
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
|
||||
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
|
||||
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,6 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
|
||||
bytes_total: 0,
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
|
||||
failure_kind: None,
|
||||
message: progress.current.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
|
||||
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView,
|
||||
ImportStatusView, MissingFileView, MissingReasonView,
|
||||
};
|
||||
|
||||
use super::PackPageState;
|
||||
@@ -48,40 +48,10 @@ pub struct ConflictRow {
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
/// 致命失败块(**两种措辞,不共用**)。
|
||||
///
|
||||
/// 为什么不是一句「失败」:`NotStarted`(预检就没过,一个字节都没写)与 `RolledBack`
|
||||
/// (写进去了,又整包回滚干净)对用户是不同的消息——前者「去修包再试」,后者「刚才动过你的目录,
|
||||
/// 已经复原」。M161 之前两者共用一句「已回滚 —— 目标目录零残留」,预检失败时那句话是假的。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FatalKind {
|
||||
/// 预检/装配阶段失败:目标目录**一个字节都没写**
|
||||
NotStarted,
|
||||
/// 写过目标目录,回滚**完整**完成
|
||||
RolledBack,
|
||||
/// 写过,但回滚有失败项(目标目录可能与导入前不同)
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl FatalKind {
|
||||
/// 图标(与 [`Fatal::title`] 各说一遍:颜色/图标关掉时标题仍然分得清)。
|
||||
#[must_use]
|
||||
pub const fn icon(self) -> unlucky_design::icons::IconKind {
|
||||
match self {
|
||||
// 预检没过 = 「还没开始」,用警示而不是「已出错」:目录是干净的
|
||||
Self::NotStarted => unlucky_design::icons::IconKind::Warn,
|
||||
Self::RolledBack => unlucky_design::icons::IconKind::Restore,
|
||||
Self::RollbackIncomplete => unlucky_design::icons::IconKind::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败块。
|
||||
/// 致命失败块(**已整包回滚**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fatal {
|
||||
/// 三种阶段之一(决定标题/图标/底色)
|
||||
pub kind: FatalKind,
|
||||
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
|
||||
/// 「已回滚 —— 目标目录零残留」
|
||||
pub title: String,
|
||||
/// 大类名(`路径穿越` / `清单损坏` …)
|
||||
pub label: String,
|
||||
@@ -154,7 +124,7 @@ pub(super) fn report_of(
|
||||
|
||||
Report {
|
||||
status: report.status,
|
||||
title: status_title(report, catalog).to_owned(),
|
||||
title: status_title(report.status, catalog).to_owned(),
|
||||
subtitle: subtitle(report, catalog),
|
||||
written_line: catalog.render(
|
||||
Key::PackReportWritten,
|
||||
@@ -163,10 +133,7 @@ pub(super) fn report_of(
|
||||
("bytes", &human(report)),
|
||||
],
|
||||
),
|
||||
fatal: report
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|error| fatal_of(error, report.failure_stage, catalog)),
|
||||
fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)),
|
||||
missing_title,
|
||||
missing: report
|
||||
.missing
|
||||
@@ -210,22 +177,11 @@ fn human(report: &ImportReportView) -> String {
|
||||
unlucky_presenter::dto::human_bytes(report.bytes_written)
|
||||
}
|
||||
|
||||
/// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。
|
||||
fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
|
||||
match report.status {
|
||||
fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str {
|
||||
match status {
|
||||
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
||||
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
|
||||
ImportStatusView::Failed => match report.failure_stage {
|
||||
Some(ImportFailureStageView::NotStarted) => {
|
||||
catalog.text(Key::PackStatusFailedNotStarted)
|
||||
}
|
||||
Some(ImportFailureStageView::RollbackIncomplete) => {
|
||||
catalog.text(Key::PackStatusFailedIncomplete)
|
||||
}
|
||||
// `None` 不该出现(失败必有阶段);退回「已回滚」这一句是旧行为,
|
||||
// 只有拿不到阶段时才会走到(例如未来的旧版对端)。
|
||||
_ => catalog.text(Key::PackStatusFailed),
|
||||
},
|
||||
ImportStatusView::Failed => catalog.text(Key::PackStatusFailed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,24 +203,9 @@ fn subtitle(report: &ImportReportView, catalog: Catalog) -> String {
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn fatal_of(
|
||||
error: &ImportErrorView,
|
||||
stage: Option<ImportFailureStageView>,
|
||||
catalog: Catalog,
|
||||
) -> Fatal {
|
||||
let kind = match stage {
|
||||
Some(ImportFailureStageView::NotStarted) => FatalKind::NotStarted,
|
||||
Some(ImportFailureStageView::RollbackIncomplete) => FatalKind::RollbackIncomplete,
|
||||
_ => FatalKind::RolledBack,
|
||||
};
|
||||
let title = match kind {
|
||||
FatalKind::NotStarted => catalog.text(Key::PackFatalTitleNotStarted),
|
||||
FatalKind::RolledBack => catalog.text(Key::PackFatalTitle),
|
||||
FatalKind::RollbackIncomplete => catalog.text(Key::PackFatalTitleIncomplete),
|
||||
};
|
||||
fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal {
|
||||
Fatal {
|
||||
kind,
|
||||
title: title.to_owned(),
|
||||
title: catalog.text(Key::PackFatalTitle).to_owned(),
|
||||
label: error.label.clone(),
|
||||
message: error.message.clone(),
|
||||
code: error.code.clone(),
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod pick;
|
||||
mod stage;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
@@ -227,7 +224,6 @@ fn report_keeps_tolerable_and_fatal_apart() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
let block = report_of(&report, &PackPageState::default(), catalog());
|
||||
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
||||
@@ -264,7 +260,6 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||
let page = PackPageState {
|
||||
@@ -362,7 +357,6 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
|
||||
let new_instance = PackPageState::default();
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
//! 冲突清单(M160)的视图模型守门:分页、计数、三个入口 → 决策表。
|
||||
//!
|
||||
//! 这里钉的是**不画**的那部分——一屏只出 `PAGE_SIZE` 行、翻页只换数据、计数与最终决策同源。
|
||||
//! 画法本身靠出图(`ULL_CONTENT_PACK=pick`)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::super::pick::{pick_of, PAGE_SIZE};
|
||||
use super::super::PackPageState;
|
||||
use super::catalog;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictReasonView, ConflictSourceView,
|
||||
ImportConflictView, ImportScanView,
|
||||
};
|
||||
|
||||
/// `count` 条冲突的预检结果。
|
||||
fn scan(count: usize) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: std::path::PathBuf::from("instance"),
|
||||
format: None,
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
conflicts: (0..count)
|
||||
.map(|index| ImportConflictView {
|
||||
path: format!("config/row-{index:03}.toml"),
|
||||
source: ConflictSourceView::Overrides,
|
||||
reason: ConflictReasonView::PreExisting,
|
||||
disabled: false,
|
||||
pack_sha1: "aa".repeat(20),
|
||||
pack_size: 1_000,
|
||||
disk_sha1: "bb".repeat(20),
|
||||
disk_size: 2_000,
|
||||
base_sha1: None,
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: 0,
|
||||
lost_bytes: 0,
|
||||
incoming_bytes: 0,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pick(
|
||||
scan: &ImportScanView,
|
||||
page: usize,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch: bool,
|
||||
) -> super::super::pick::Pick {
|
||||
pick_of(scan, page, per_item, batch, catalog()).expect("有冲突就该画这一块")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_current_page_is_drawn() {
|
||||
let scan = scan(312);
|
||||
let first = pick(&scan, 0, &BTreeMap::new(), false);
|
||||
assert_eq!(first.rows.len(), PAGE_SIZE, "一屏只许画一页的行");
|
||||
assert_eq!(first.pages, 8, "312 条 / 40 条一页 = 8 页");
|
||||
assert_eq!(first.page, 0);
|
||||
assert!(first.can_next, "还有下一页");
|
||||
assert!(!first.can_prev, "第一页没有上一页");
|
||||
assert_eq!(first.range, "Items 1-40 of 312");
|
||||
assert_eq!(first.rows[0].path, "config/row-000.toml");
|
||||
|
||||
let second = pick(&scan, 1, &BTreeMap::new(), false);
|
||||
assert_eq!(second.page, 1);
|
||||
assert_eq!(
|
||||
second.rows[0].path,
|
||||
format!("config/row-{PAGE_SIZE:03}.toml")
|
||||
);
|
||||
assert_eq!(second.range, "Items 41-80 of 312");
|
||||
assert!(second.can_prev && second.can_next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_page_is_short_and_the_page_number_is_clamped() {
|
||||
let scan = scan(312);
|
||||
let last = pick(&scan, 7, &BTreeMap::new(), false);
|
||||
assert_eq!(last.rows.len(), 312 - 7 * PAGE_SIZE, "最后一页只剩尾巴");
|
||||
assert_eq!(last.range, "Items 281-312 of 312");
|
||||
assert!(!last.can_next);
|
||||
|
||||
// 页码越界(例如用户先在 300 条的清单上翻到第 6 页,再重扫一份 10 条的)⇒ 夹到最后一页。
|
||||
let clamped = pick(&scan, 99, &BTreeMap::new(), false);
|
||||
assert_eq!(clamped.page, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_counts_what_the_user_actually_picked() {
|
||||
let scan = scan(10);
|
||||
let keep_all = pick(&scan, 0, &BTreeMap::new(), false);
|
||||
assert_eq!(keep_all.keep_count, 10);
|
||||
assert_eq!(keep_all.overwrite_count, 0);
|
||||
assert!(keep_all.summary.contains("Keep 10"), "{}", keep_all.summary);
|
||||
assert!(keep_all.keep_all_active && !keep_all.overwrite_all_active);
|
||||
|
||||
let mut per_item = BTreeMap::new();
|
||||
per_item.insert(
|
||||
"config/row-001.toml".to_string(),
|
||||
ConflictChoiceView::Overwrite,
|
||||
);
|
||||
per_item.insert(
|
||||
"config/row-004.toml".to_string(),
|
||||
ConflictChoiceView::Overwrite,
|
||||
);
|
||||
let mixed = pick(&scan, 0, &per_item, false);
|
||||
assert_eq!(mixed.overwrite_count, 2);
|
||||
assert_eq!(mixed.keep_count, 8);
|
||||
// 丢弃量只算"真选了覆盖"的那两条(各 2 000 字节)。
|
||||
assert!(
|
||||
mixed.summary.contains("Overwrite 2") || mixed.summary.contains("overwrite 2"),
|
||||
"{}",
|
||||
mixed.summary
|
||||
);
|
||||
assert_eq!(
|
||||
mixed.rows[1].choice,
|
||||
ConflictChoiceView::Overwrite,
|
||||
"点过的那一行要显示成覆盖"
|
||||
);
|
||||
assert_eq!(mixed.rows[0].choice, ConflictChoiceView::KeepUser);
|
||||
|
||||
let all = pick(&scan, 0, &BTreeMap::new(), true);
|
||||
assert_eq!(all.overwrite_count, 10);
|
||||
assert!(all.overwrite_all_active && !all.keep_all_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_conflict_means_no_block() {
|
||||
let scan = scan(0);
|
||||
assert!(
|
||||
pick_of(&scan, 0, &BTreeMap::new(), false, catalog()).is_none(),
|
||||
"没有冲突就不该画这一屏(不打扰用户)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_default_to_keep_all() {
|
||||
let page = PackPageState::default();
|
||||
assert_eq!(page.decisions(None), ConflictDecisionsView::KeepAll);
|
||||
// 逐项点一个"覆盖" ⇒ 逐项表里只有它,缺省档仍是保留。
|
||||
let mut page = PackPageState::default();
|
||||
page.pick_choices
|
||||
.insert("mods/a.jar".to_string(), ConflictChoiceView::Overwrite);
|
||||
let decisions = page.decisions(Some(&scan(3)));
|
||||
assert_eq!(decisions.default_choice(), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(decisions.overwrite_count(), 1);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_overwrite_plus_a_kept_file_is_expressible() {
|
||||
// 全部覆盖之后单独放回一条:缺省档是覆盖、逐项里那条是保留。
|
||||
let mut page = PackPageState {
|
||||
pick_overwrite_all: true,
|
||||
..PackPageState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page.decisions(Some(&scan(3))),
|
||||
ConflictDecisionsView::OverwriteAll,
|
||||
"只点整批覆盖时不需要逐项表"
|
||||
);
|
||||
page.pick_choices
|
||||
.insert("mods/pin.jar".to_string(), ConflictChoiceView::KeepUser);
|
||||
let decisions = page.decisions(Some(&scan(3)));
|
||||
assert_eq!(decisions.default_choice(), ConflictChoiceView::Overwrite);
|
||||
assert_eq!(decisions.keep_count(), 1);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_flips_the_row_and_clearing_the_batch_drops_per_item_choices() {
|
||||
let mut page = PackPageState::default();
|
||||
let path = "mods/a.jar";
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
|
||||
page.pick_choices
|
||||
.insert(path.to_string(), ConflictChoiceView::Overwrite);
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::Overwrite);
|
||||
|
||||
// 整批覆盖之后,那条逐项"保留"仍然优先于整批档。
|
||||
page.pick_overwrite_all = true;
|
||||
page.pick_choices
|
||||
.insert(path.to_string(), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(
|
||||
page.pick_choice("mods/other.jar"),
|
||||
ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//! M161:导入失败的**两种阶段**不许共用一句话。
|
||||
//!
|
||||
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
|
||||
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
|
||||
|
||||
use unlucky_presenter::dto::ImportFailureStageView;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// **「预检没过」与「已回滚」必须各说各的话**(M161 收的债)。
|
||||
///
|
||||
/// 此前两者共用 `rolled_back: true`,于是预检失败(一个字节都没写)也被写成
|
||||
/// 「已回滚 —— 目标目录零残留」——一个没发生的回滚。现在标题、图标、底色三处都分开,
|
||||
/// 且 `NotStarted` 的标题**不许**出现「回滚 / rolled back」这类词(那才是真正的谎)。
|
||||
#[test]
|
||||
fn the_two_failure_stages_never_share_one_sentence() {
|
||||
fn failed(stage: Option<ImportFailureStageView>) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Broken".to_string()),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target: PathBuf::from("C:/tmp"),
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: Some(ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
label: "path traversal".to_string(),
|
||||
message: "../etc/passwd".to_string(),
|
||||
hint: None,
|
||||
}),
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: stage,
|
||||
}
|
||||
}
|
||||
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
let not_started = report_of(
|
||||
&failed(Some(ImportFailureStageView::NotStarted)),
|
||||
&PackPageState::default(),
|
||||
zh,
|
||||
);
|
||||
let rolled_back = report_of(
|
||||
&failed(Some(ImportFailureStageView::RolledBack)),
|
||||
&PackPageState::default(),
|
||||
zh,
|
||||
);
|
||||
let incomplete = report_of(
|
||||
&failed(Some(ImportFailureStageView::RollbackIncomplete)),
|
||||
&PackPageState::default(),
|
||||
zh,
|
||||
);
|
||||
|
||||
// 三种标题互不相同(三态压力:谁把它们折叠回同一句,这里就红)。
|
||||
let titles = [
|
||||
not_started.title.as_str(),
|
||||
rolled_back.title.as_str(),
|
||||
incomplete.title.as_str(),
|
||||
];
|
||||
assert_eq!(
|
||||
titles
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len(),
|
||||
3,
|
||||
"三种失败阶段的标题撞了:{titles:?}"
|
||||
);
|
||||
|
||||
// 「预检没过」那一句里不许出现「回滚」(那是没发生的事)。
|
||||
let fatal = not_started.fatal.expect("致命块");
|
||||
assert_eq!(
|
||||
fatal.kind,
|
||||
crate::pages::content::packui::model::FatalKind::NotStarted
|
||||
);
|
||||
assert!(
|
||||
!fatal.title.contains("回滚"),
|
||||
"预检失败不该说「回滚」:{}",
|
||||
fatal.title
|
||||
);
|
||||
assert!(!not_started.title.contains("回滚"), "{}", not_started.title);
|
||||
// 已回滚那一句必须说清「回滚」这件事。
|
||||
assert!(
|
||||
rolled_back.title.contains("回滚"),
|
||||
"已回滚那句要说明回滚:{}",
|
||||
rolled_back.title
|
||||
);
|
||||
// 图标分开:预检没过不是「错误」字形(目录没动过),回滚不完整才是。
|
||||
assert_ne!(
|
||||
crate::pages::content::packui::model::FatalKind::NotStarted.icon(),
|
||||
crate::pages::content::packui::model::FatalKind::RolledBack.icon(),
|
||||
"两种阶段的图标不许一样"
|
||||
);
|
||||
|
||||
// 成功/部分成功没有「失败阶段」可言。
|
||||
let mut ok = failed(None);
|
||||
ok.status = ImportStatusView::Success;
|
||||
ok.error = None;
|
||||
assert!(report_of(&ok, &PackPageState::default(), zh)
|
||||
.fatal
|
||||
.is_none());
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use unlucky_i18n::Lang;
|
||||
use super::demo::pack_in;
|
||||
use super::model::Pack;
|
||||
|
||||
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
|
||||
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
|
||||
/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
|
||||
const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
|
||||
|
||||
#[test]
|
||||
fn every_screenshot_state_fills_something() {
|
||||
@@ -28,33 +28,6 @@ fn every_screenshot_state_fills_something() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_state_shows_a_paginated_conflict_list() {
|
||||
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
|
||||
let (model, page) = pack_in("pick", Lang::En);
|
||||
let pick = model.pick.expect("pick 态必须有冲突清单");
|
||||
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
|
||||
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
|
||||
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
|
||||
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
|
||||
assert!(pick.can_prev && pick.can_next);
|
||||
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
|
||||
assert!(
|
||||
pick.keep_all_active && !pick.overwrite_all_active,
|
||||
"整批档停在缺省那一档(全部保留)"
|
||||
);
|
||||
assert_eq!(
|
||||
pick.rows[0].choice,
|
||||
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
|
||||
"没点过的那一行必须是保留"
|
||||
);
|
||||
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
|
||||
assert_eq!(
|
||||
page.pick_choice("config/generated/module-041.toml"),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_shows_the_shared_install_panel() {
|
||||
let (model, _page) = pack_in("importing", Lang::En);
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
|
||||
mod export;
|
||||
mod parts;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
@@ -65,7 +64,6 @@ use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use self::export::export_block;
|
||||
use self::pick::pick_block;
|
||||
use self::progress::{probe_block, running_block};
|
||||
use self::report::report_block;
|
||||
use super::model::Pack;
|
||||
@@ -90,9 +88,6 @@ pub(crate) fn section<'a>(
|
||||
// 事件,任何时候把文件拖进来都算数。
|
||||
if model.export.open {
|
||||
block = block.push(export_block(ui, catalog, &model.export));
|
||||
} else if let Some(pick) = &model.pick {
|
||||
// 冲突清单优先于一切:用户此刻要做的决定就是"哪几个文件覆盖"
|
||||
block = block.push(pick_block(ui, catalog, pick));
|
||||
} else if let Some(running) = &model.running {
|
||||
block = block.push(running_block(ui, catalog, running));
|
||||
} else if let Some(report) = &model.report {
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
//! 冲突清单那一屏的画法(M160):**分页** + 三个入口 + 计数摘要 + 确认/取消。
|
||||
//!
|
||||
//! ## 版面
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌ 导入前:有 312 个文件已存在且内容不同 ───────────────┐
|
||||
//! │ 保留 309 · 覆盖 3 · 覆盖将丢弃 2.4 MB │
|
||||
//! │ 没有改动的一律按「保留」:… │
|
||||
//! │ [全部保留我的] [全部覆盖] │
|
||||
//! ├ 第 1-40 项,共 312 项 [上一页] [下一页] │
|
||||
//! │ config/sodium-options.json 包内 overrides/ · 本来就在 │
|
||||
//! │ 你的:4.1 KB · 包内:3.8 KB [覆盖]│
|
||||
//! │ …(**只画当前这一页**) │
|
||||
//! ├ [按这些选择导入] [取消导入] │
|
||||
//! └─────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## 三处「不误导」
|
||||
//!
|
||||
//! 1. **缺省说明写在按钮下面**:用户不必猜"没点过的那 309 条会怎样";
|
||||
//! 2. **计数摘要里的丢弃量只算真选了覆盖的**:用最坏数字吓人不算如实;
|
||||
//! 3. **按钮写的是"点了会变成什么"**(当前保留 ⇒ 按钮写「覆盖」),所以点一下就知道后果。
|
||||
//!
|
||||
//! ## 为什么分页而不是滚动
|
||||
//!
|
||||
//! 几千条冲突一次画完 = 几千个 widget:布局与滚动都卡。分页把 widget 数量钉在
|
||||
//! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::Catalog;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::{Pick, PickRow};
|
||||
use super::super::PackMessage;
|
||||
use super::intent;
|
||||
|
||||
/// 处置按钮槽的宽度(px):取最宽状态「⟳ Overwrite」的实际占用再留余量,
|
||||
/// 保证「Keep」与「Overwrite」两种状态的右边缘落在同一条线上。
|
||||
const TOGGLE_SLOT: f32 = 132.0;
|
||||
|
||||
/// 整块。
|
||||
pub(super) fn pick_block<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
pick: &'a Pick,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut block = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleSmall,
|
||||
true,
|
||||
pick.title.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
// 计数摘要:这是"我要丢掉多少"的唯一一处数字,用强调色
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
pick.summary.as_str(),
|
||||
ui.color(Role::Primary),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
pick.default_note,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(note) = &pick.unknown_note {
|
||||
block = block.push(
|
||||
row![
|
||||
frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
note.as_str(),
|
||||
ui.color(Role::Tertiary),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
// 三个入口:整批保留 / 整批覆盖(逐项就是下面每行那个按钮)
|
||||
block = block.push(
|
||||
row![
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
pick.keep_all,
|
||||
pick.keep_all_active,
|
||||
intent(PackMessage::PickKeepAll),
|
||||
),
|
||||
),
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
pick.overwrite_all,
|
||||
pick.overwrite_all_active,
|
||||
intent(PackMessage::PickOverwriteAll),
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
|
||||
// 分页头(当前页之外的行一律不画)
|
||||
block = block.push(bounded(
|
||||
column(
|
||||
std::iter::once(pager(ui, catalog, pick))
|
||||
.chain(pick.rows.iter().map(|item| pick_row(ui, item)))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
375.0,
|
||||
));
|
||||
|
||||
// 列表与动作行之间留出与其它区块一致的呼吸(复审 r1 视觉 2):滚到底时最后一行会紧贴
|
||||
// 动作行,靠这层 padding 把两者分开(直接调列 spacing 会把行间距也一起拉开)。
|
||||
block = block.push(
|
||||
container(
|
||||
row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport))
|
||||
.leading(IconKind::Check),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.cancel, intent(PackMessage::CancelImport)),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding(Padding {
|
||||
top: ui.space(Space::X2),
|
||||
..Padding::ZERO
|
||||
}),
|
||||
);
|
||||
|
||||
container(block)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.space(Space::X3))
|
||||
.style(move |_theme| container::Style {
|
||||
border: Border {
|
||||
color: ui.color(Role::Outline),
|
||||
width: 1.0,
|
||||
radius: ui.radius_dp(ShapeScale::Large),
|
||||
},
|
||||
background: Some(Background::Color(ui.color(Role::Surface))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 分页头:区间 + 上一页 / 下一页。
|
||||
fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageMessage> {
|
||||
let previous = if pick.can_prev {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
pick.prev,
|
||||
intent(PackMessage::PickPage(pick.page.saturating_sub(1))),
|
||||
)
|
||||
.leading(IconKind::ChevronDown),
|
||||
)
|
||||
} else {
|
||||
// 到边了就把按钮指回当前页(点了不动的死按钮比"没有按钮"更让人困惑)
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.prev, intent(PackMessage::PickPage(pick.page))),
|
||||
)
|
||||
};
|
||||
let next = if pick.can_next {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.next, intent(PackMessage::PickPage(pick.page + 1)))
|
||||
.leading(IconKind::ChevronDown),
|
||||
)
|
||||
} else {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.next, intent(PackMessage::PickPage(pick.page))),
|
||||
)
|
||||
};
|
||||
// 分页行与行内控件共用同一条右锚点(复审 r1 视觉 3):区间文案靠左,两个按钮用
|
||||
// `horizontal_space` 顶到右边,于是「Previous · Next」的右边缘与下面每一行的处置槽对齐。
|
||||
// 两个按钮进一个**右对齐的槽**,槽的右边缘就是行的右边缘 —— 于是它们的右端与下面每行的
|
||||
// 处置槽落在同一条线上(按钮自身的内边距也一起对齐,不会差那十几像素)。
|
||||
let buttons_slot = container(
|
||||
row![previous, next]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fixed(TOGGLE_SLOT * 2.0))
|
||||
.align_x(Alignment::End);
|
||||
row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
pick.range.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
iced::widget::Space::new().width(Length::Fill),
|
||||
buttons_slot,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一条冲突:**第一行**路径 + 处置按钮,**第二行**来源 · 成因 · 两侧体积。
|
||||
///
|
||||
/// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱,
|
||||
/// 出图实测每行折成四五段——扫读成本比两行高得多。
|
||||
fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
|
||||
// 两种取值都是**同一种几何**(等高的 pill),只是填充不同:
|
||||
// · 当前=覆盖 ⇒ **tonal 填充**(这一行会被盖掉,是"待反悔"的那一档)
|
||||
// · 当前=保留 ⇒ **描边**(缺省档;点了才会覆盖)
|
||||
// 两种 pill 再各自右对齐进同宽度的槽,于是**右边缘落在同一条线**(复审 r1 视觉 1 的必修项)——
|
||||
// 之前一边是描边 pill、一边是无容器的文字按钮,宽窄与右端都不一致。
|
||||
let toggle = if item.overwrites() {
|
||||
buttons::tonal(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
)
|
||||
.leading(IconKind::Refresh),
|
||||
)
|
||||
} else {
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
)
|
||||
.leading(IconKind::Refresh),
|
||||
)
|
||||
};
|
||||
// 路径那半列必须自己吃掉剩余宽度(`Length::Fill`),否则处置槽会紧跟文字、离右边缘一大截,
|
||||
// 与分页行的右锚点对不上(出图实测:行内按钮停在 x≈680、分页按钮在 x≈780)。
|
||||
let path_slot = container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.path.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill);
|
||||
// 处置按钮自己那一格:定宽 + 右对齐 ⇒ 两种取值的 pill 右边缘落在同一条线上。
|
||||
// 右侧再留 8dp:滚动条就贴在内容右边缘(出图实测 x≈791),pill 紧贴着它显得挤。
|
||||
let toggle_slot = container(toggle)
|
||||
.width(Length::Fixed(TOGGLE_SLOT))
|
||||
.align_x(Alignment::End)
|
||||
.padding(Padding {
|
||||
right: ui.space(Space::X2),
|
||||
..Padding::ZERO
|
||||
});
|
||||
let head = row![path_slot, toggle_slot]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
let detail = frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
format!("{} · {} · {}", item.source, item.reason, item.sizes),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
|
||||
column![head, detail]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 有界容器:内容多时自己滚(与结果块的 `bounded` 同一条口径,但这里限高不同)。
|
||||
fn bounded<'a>(
|
||||
content: impl Into<Element<'a, PageMessage>>,
|
||||
height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
iced::widget::scrollable(container(content).width(Length::Fill))
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
Loaded 100 of 454 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user