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

No files matched your search

+1 -49
View File
@@ -132,55 +132,7 @@ jobs:
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。 # 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条 # 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。 # `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
# - run: cargo test --workspace --all-targets
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
# 事各自对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
# 注解是匿名侧唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
log="$RUNNER_TEMP/cargo-test.log"
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
# 不是 tee 的码)。
set +e
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "cargo exit=$code"
if [ "$code" -ne 0 ]; then
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
fi
exit "$code"
- name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows'
shell: pwsh
run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE
Write-Host "cargo exit=$code"
if ($code -ne 0) {
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
build: build:
name: build release (${{ matrix.os }}) name: build release (${{ matrix.os }})
+3 -14
View File
@@ -1,9 +1,5 @@
name: Guards name: Guards
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): # 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 # 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层 # 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
@@ -58,11 +54,7 @@ jobs:
run: | run: |
set -uo pipefail set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。 tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
exit 1
fi
echo "$tree" echo "$tree"
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
@@ -117,9 +109,7 @@ jobs:
exit 1 exit 1
fi fi
echo "$tree" echo "$tree"
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头): hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
if [ -n "$hits" ]; then if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:" echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /' printf '%s\n' "$hits" | sed 's/^/ - /'
@@ -328,10 +318,9 @@ jobs:
} }
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开, # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。 # 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort || true) done | LC_ALL=C sort)
echo "期望(恰好这一个):$EXPECTED" echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:" echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
-247
View File
@@ -1,247 +0,0 @@
name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# 本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,唯一的改动是四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。其余 `name` / `if` / `env` / `run` / `uses` 与拆分前逐行相同。
# 矩阵**仍由调用方** `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)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: inputs.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 起全原生)。
# (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`。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
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: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: 打包(产物 + 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)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
- name: arm64 失败收尾注解(红不挡发布)
if: failure() && inputs.slot == 'windows-arm64'
shell: bash
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
-40
View File
@@ -1,40 +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)。
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
-169
View File
@@ -1,169 +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` 的授予。
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
# ① `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)──
# **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: 生成更新清单 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
+309 -40
View File
@@ -1,22 +1,13 @@
name: Release name: Release
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 + # 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生 `windows-latest` / `windows-11-arm`)→ 单文件产物 +
# SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带 `--prerelease`,否则 # SHA256SUMS → artifact + GitHub Release。pre-release 默认开;同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。
# `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、不碰标记)。
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试 # **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M142 起只有 `windows-x64`**(arm64 连试
# 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都 # 四轮未成,仍留矩阵里取证、红了不挡 x64 发布;它修好后把键加回那一行即可)。四档 Linux/macOS 从未验证,默认连构建都
# 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。 # 不跑(M139:省配额、不留红叉);整表六槽位取证走 `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与 # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
#
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build /
# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on: on:
push: push:
@@ -29,13 +20,9 @@ on:
type: boolean type: boolean
default: false default: false
prerelease: prerelease:
# 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上—— description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
type: boolean type: boolean
default: false default: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -91,41 +78,323 @@ jobs:
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr) print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
PY PY
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
build: build:
name: build (${{ matrix.slot }}) name: build (${{ matrix.slot }})
needs: plan needs: plan
strategy: strategy:
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。 runs-on: ${{ matrix.os }}
permissions: steps:
contents: read - uses: actions/checkout@v4
uses: ./.github/workflows/release-build.yml
with: - name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
slot: ${{ matrix.slot }} if: runner.os == 'Linux'
os: ${{ matrix.os }} shell: bash
build_dir: ${{ matrix.build_dir }} run: |
rustflags: ${{ matrix.rustflags }} sudo apt-get update
secrets: sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。 libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白 - uses: dtolnay/rust-toolchain@stable
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。 - uses: Swatinem/rust-cache@v2
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
# (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: release:
name: GitHub Release name: GitHub Release
needs: build needs: build
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) # `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
# 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 # 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag' if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
uses: ./.github/workflows/release-publish.yml env:
secrets: # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 # **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据),
# **红了不挡发布**。**可逆**: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
View File
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -4997,13 +4997,12 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
"log", "log",
"unlucky-cli", "unlucky-cli",
"unlucky-core",
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
@@ -5011,7 +5010,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5022,7 +5021,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
@@ -5041,15 +5040,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.3" version = "0.2.1"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.3" version = "0.2.1"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5057,7 +5056,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.3" version = "0.2.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.3" version = "0.2.1"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
+34 -87
View File
@@ -4,8 +4,6 @@
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and > **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases> > release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **Version in this README: `0.2.3`** — `unlucky-launcher --version` prints the same number.
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no (Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
@@ -45,14 +43,6 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and - **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
killed as trees, not as one PID. killed as trees, not as one PID.
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
and every field falls back on its own: instance → global setting → factory default, so an instance
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
the backend row only appears for game versions that have it. The CLI and the GUI read the same
resolved values (`crates/core/src/game/launch_overrides.rs`).
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals - **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the (exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
@@ -66,17 +56,8 @@ gap: the reason text is still raw Chinese straight out of the launch layer (see
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`). import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
- **Import conflicts are decided per file** — before anything is written, the scan lists every
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
that do not conflict are never rewritten, and the report still names every conflict afterwards.
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version, - **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
loader and sort filters, paged results, and one-click *install into this instance*. loader and sort filters, paged results, and one-click *install into this instance*.
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
the target instance (project type, game version, loader); project details and version lists follow,
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
time. Being offline is its own state — "not connected" — never "no matching content".
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the - **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
library (packs go through the import pipeline above); CurseForge's *install* is disabled because library (packs go through the import pipeline above); CurseForge's *install* is disabled because
its file endpoint is not wired; and an item whose game version or loader does not match the target its file endpoint is not wired; and an item whose game version or loader does not match the target
@@ -102,17 +83,16 @@ Three tracks on one page:
- **Third-party community auth servers** (authlib-injector style). - **Third-party community auth servers** (authlib-injector style).
- **Local offline profiles**, for isolated testing. - **Local offline profiles**, for isolated testing.
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public **Microsoft sign-in needs an Azure public client id, and no release binary carries one.** The id is
client id they need (a public client id, not a credential — it ships inside the binary), so there is not in this repo and CI injects none, so set `ULL_MSA_CLIENT_ID=<id>` before launching, or build with
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the it (`ULL_MSA_CLIENT_ID=<id> cargo build`, which bakes it in via `option_env!`). Without one, sign-in
variable is read at startup, so restart the launcher after changing it. Building from source bakes in stops before any network call and the alert says exactly what is missing: the title "Microsoft
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then
anywhere stops before any network call, and its alert says exactly what is missing: the title restart the launcher — a running process does not pick up a new variable), and
"Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with `Code: msa_not_configured`. It offers no Retry button, because retrying in that same process cannot
it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that help. Every other sign-in failure keeps the older shape: one category line plus its own `Code:`
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus line, with the full reason only in the launcher log. Community auth servers and local profiles need
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local no id.
profiles need no id.
### Look and language ### Look and language
@@ -179,52 +159,23 @@ The dispatch rule is deliberately narrow, so nothing that used to work changed m
that its typo took effect. The subcommand table lives in exactly one place that its typo took effect. The subcommand table lives in exactly one place
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help` (`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`, lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
`diag`, `config`, `java`, `update`, `agent`, `version`, `help`. `diag`, `config`, `java`, `agent`, `version`, `help`.
### Updates, and the stable channel
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
`ull update check` / `ull update apply` both build the same `core::update::Job`
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
*renamed* but not overwritten, which is why the swap is a rename.
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
no artifact for this platform / the check itself failed (offline, broken manifest, channel
mismatch). A failed check is never reported as "up to date".
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
- **Nothing installs itself behind your back.** The check at startup can be turned off
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
### Not done yet ### Not done yet
This list is the reason to believe the rest. None of it is hidden behind marketing copy. This list is the reason to believe the rest. None of it is hidden behind marketing copy.
- **No installer.** You download a file and run it — there is no setup step, and updates replace that - **No installer and no auto-update channel.** You download a file and run it. Nothing checks for a
file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)). newer version for you, and there is no update source decided yet.
- **The update path has only been exercised against a local directory source.** The whole chain
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
The first observation on the live stable channel happens after `0.2.3` ships.
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*, - **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
*Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset *Open folder*, *Delete*. On the logs page: *Open folder*. In the settings frame: *Save*, *Reset
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save. edit them rather than on a Save.
- **Linux and macOS artifacts are unverified, and this release does not even build them.** The - **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots `linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
sit in the CI matrix but the default release run skips them, and they have never been downloaded and been downloaded and run end to end. Treat them as untested output, not as "supported because the
run end to end. `gh workflow run release.yml -f all_slots=true` builds them for evidence. Treat them matrix lists 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 - **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 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 them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
@@ -237,11 +188,12 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
translate — which is exactly the `静默失败` you can see in the English screenshot above. translate — which is exactly the `静默失败` you can see in the English screenshot above.
- **The in-app assistant has no model behind it** (see above). The MCP server does. - **The in-app assistant has no model behind it** (see above). The MCP server does.
- **Pack import has two global policies**, keep-user or overwrite, not a per-file choice; the import
report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install path. - **Install progress is stage-level**, not byte-level, on the content-library install path.
- **No native file chooser.** Import takes drag-and-drop or a pasted path. - **No native file chooser.** Import takes drag-and-drop or a pasted path.
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is - **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes: unverified there.
[`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
## Download and run ## Download and run
@@ -254,20 +206,19 @@ Grab the file for your platform from
|------|-------|-------| |------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run | | `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below | | `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 — **not yet published** | | `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 — **not yet published** | | `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not yet published** | | `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not yet published** | | `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset | | every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
**This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named `windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is
in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable The row stays because the architecture is still supported and the slot still builds (unpublished) in
asset. Of those rows `windows-arm64` is the one CI still builds — natively, on GitHub's the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
`windows-11-arm` runner, for evidence — but that slot's toolchain check has not gone green yet, so its `.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
`.exe` stays out of the release. The row stays because the architecture is still supported; it rejoins only downloadable Windows asset.
the download list once the build is green.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -292,7 +243,7 @@ upstream cargo. On Linux, X11 is the preferred path today and Wayland is the lon
Capability differences are feature gates with runtime probing, never a compile-time "this platform Capability differences are feature gates with runtime probing, never a compile-time "this platform
gets less". gets less".
First run with a Microsoft account? Nothing to configure — official builds carry the client id; see First run with a Microsoft account? Set `ULL_MSA_CLIENT_ID` first — see
[Accounts](#accounts). Nothing else needs configuring. [Accounts](#accounts). Nothing else needs configuring.
## Build from source ## Build from source
@@ -315,9 +266,6 @@ cargo deny check licenses bans sources # dependency licences (re
What the build produces: What the build produces:
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
— see [Accounts](#accounts).
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed - `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
@@ -341,8 +289,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|----------|---------| |----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → build both Windows slots (`windows-x64` + `windows-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` only; `-f all_slots=true` builds all six slots for evidence) | | `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
## Architecture and module map ## Architecture and module map
-59
View File
@@ -1,59 +0,0 @@
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
> 给主线的改动清单、兼容性说明与合并指南见
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
## 已验证
| 项 | 状态 | 证据 |
| --- | --- | --- |
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
## 快速开始
完整配方(工具链、patchelf、linker、门禁)见
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
```sh
termux-x11 :0 -ac &
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
```
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
## 已知边界
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
PC 的 Linux CI 不受影响。
## 后续
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
2. 剪贴板/IME 在 termux-x11 下的行为验证
3. narrator/flite 与音频设备(当前 missing 但不阻断)
+25 -64
View File
@@ -4,8 +4,6 @@
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里: > **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases> > <https://github.com/AskaEth/Unlucky-Launcher/releases>
>
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是 Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器 **一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
@@ -35,11 +33,6 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。 与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位, - **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。 每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、 - **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在 `crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。 日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
@@ -51,15 +44,8 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或 - **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的 CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。 导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
写,导入报告事后仍逐条列出全部冲突。
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果, - **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
以及一键**装入这个实例**。 以及一键**装入这个实例**。
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
绝不说成「没有匹配的内容」。
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线); - **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是 CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
禁用态,并把不匹配写清楚。没有一条是静默失败的。 禁用态,并把不匹配写清楚。没有一条是静默失败的。
@@ -82,13 +68,12 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **第三方社区认证服**(authlib-injector 一类)。 - **第三方社区认证服**(authlib-injector 一类)。
- **本地离线档案**,用于隔离测试。 - **本地离线档案**,用于隔离测试。
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是 **微软登录需要一个 Azure 公共客户端 id,而发布版二进制里没有。** 这个 id 不在仓库里,CI 也不注入,所以
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值: 启动前设 `ULL_MSA_CLIENT_ID=<id>`(或构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`,它通过
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`, `option_env!` 烧进二进制)。没有它时点登录**不会联网**,弹窗直接说清是这一项没配:标题「微软登录未配置」,
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题 正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后要**重启启动器**——已在运行的进程读不到新设的环境
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带 变量),并带 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是 登录失败仍是老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
### 外观与语言 ### 外观与语言
@@ -143,41 +128,19 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示 **未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义 `unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、 (`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、 `install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`agent`、
`agent`、`version`、`help`。 `version`、`help`。
### 自更新与稳定通道
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
渠道不符)。失败绝不会被说成「已是最新」。
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
构建里**不存在**这条路径。
### 还没做 ### 还没做
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。 这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
- **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西 - **没有安装包,也没有自动更新通道。** 下个文件直接跑,没有任何东西替你检查新版本,更新源也还没定。
(见[自更新与稳定通道](#自更新与稳定通道))。
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
观察要等 `0.2.3` 发出去之后。
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 - **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
- **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、 - **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
`macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过; `macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
`gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是
「矩阵里有就算支持」。 「矩阵里有就算支持」。
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置 - **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉 (macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
@@ -189,9 +152,11 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的 (`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
`静默失败` 就是它。 `静默失败` 就是它。
- **启动器内的助手背后没有模型**(见上)。MCP server 有。 - **启动器内的助手背后没有模型**(见上)。MCP server 有。
- **整合包导入只有两份全局策略**:保留用户侧或覆盖,不是逐文件选择;导入报告会逐条列出冲突,所以没有
文件是被静默覆盖的。
- **内容库的安装进度是阶段级**,不是字节级。 - **内容库的安装进度是阶段级**,不是字节级。
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。 - **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。
## 下载与运行 ## 下载与运行
@@ -203,18 +168,17 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|------|------|------| |------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 | | `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** | | `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** | | `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** | | `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** | | `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 |
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
**本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位 `windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标 ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
**暂未发布** 的行是命名规则,不是本版可下载的资产。这几行里 `windows-arm64` 是 CI 唯一真跑的那档:M140 起在 Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
GitHub 原生 `windows-11-arm` runner 上编来取证,但该槽位的工具链体检还没绿,所以它的 `.exe` 不进 Release; 它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
行不删是因为架构仍要支持,绿了就把键加回 `PUBLISH_SLOTS`。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -233,7 +197,7 @@ Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。W
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异 (MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。 一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。 第一次用微软账户?先设 `ULL_MSA_CLIENT_ID`——见[账户](#账户)。其它都不需要配置。
## 从源码构建 ## 从源码构建
@@ -254,8 +218,6 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
编出来的 bin: 编出来的 bin:
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
cargo build`);官方发布产物已内置,见[账户](#账户)。
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo - `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成 的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。 `unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
@@ -275,8 +237,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|----------|------| |----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) | | `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
## 架构与模块地图 ## 架构与模块地图
-144
View File
@@ -1,144 +0,0 @@
# Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## TL;DR
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
## 方案
### 1. 平台识别
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
### 2. JDK:发现 + 安装
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
### 3. 字体
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
### 4. 窗口(横屏优先)
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
### 5. GPU(启动器)
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
### 6. Minecraft 运行链
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
下载兜底到阿里云 Maven(`install/libraries.rs`)。
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
加载不了 glibc natives。
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
## 改动清单
| 文件 | 改动 | 平台隔离 |
| --- | --- | --- |
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
| `core/platform/mod.rs` | 导出新模块 | — |
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
## 行为变化与兼容性
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
默认回退序**不含** termux,PC 行为不变。
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
## 合并指南
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
`crates/app-iced/Cargo.toml`)。
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
3. 本机已知、**不影响 PC CI** 的三条失败:
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
- 同测试:长路径被脱敏成 `<opaque>`
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
所以在 Termux 上跑门禁看不出来):
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
行为不变、用例原样留在原处。
## 验证矩阵(本机)
| 项 | 结果 |
| --- | --- |
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
## 后续路线
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
4. narrator/flite 与音频设备(当前缺失但不阻断)。
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
## 回滚策略
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.3" } unlucky-core = { path = "../core", version = "0.2.1" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+4 -7
View File
@@ -22,15 +22,12 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.3" } unlucky-design = { path = "../design", version = "0.2.1" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.3" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.3" } unlucky-presenter = { path = "../presenter", version = "0.2.1" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -60,11 +57,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli → # 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.3" } unlucky-cli = { path = "../cli", version = "0.2.1" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.3" } unlucky-i18n = { path = "../i18n", version = "0.2.1" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
+2 -16
View File
@@ -30,8 +30,6 @@ use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics; mod diagnostics;
mod fallback; mod fallback;
mod termux_gpu;
mod termux_window;
use fallback::{on_failure, respawn, OnFailure}; use fallback::{on_failure, respawn, OnFailure};
@@ -41,8 +39,6 @@ pub(crate) fn run(options: Options) -> iced::Result {
let boot_started = Instant::now(); let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打) // ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
termux_gpu::enable();
let boot = boot_probe(&options); let boot = boot_probe(&options);
let decision = boot.decision; let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision); diagnostics::report_boot(&options, &boot.probe, &decision);
@@ -135,20 +131,10 @@ fn launch(
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳** // M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。 // ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先), let (size, size_source) = acceptance::window_size(
// 失败就退回 1180×760 的验收尺寸。
let (default_size, default_source) = termux_window::default_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height), (WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height), (MIN_SIZE.width, MIN_SIZE.height),
) );
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
let (size, size_source) =
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
let size_source = if size_source == "default" {
default_source
} else {
size_source
};
let settings = diagnostics::window_settings(size); let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2) // 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source); diagnostics::report_window(&settings, options.scale_factor, size_source);
@@ -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);
}
}
-9
View File
@@ -168,9 +168,6 @@ pub struct App {
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」, /// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。 /// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64, pub(super) install_cancelled_seen: u64,
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
/// ///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -181,12 +178,6 @@ pub struct App {
pub(super) account_form: pages::account::Form, pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。 /// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page, pub(super) account: pages::account::Page,
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
///
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache, pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
@@ -89,7 +89,6 @@ impl App {
let mut view = pages::Model::new( let mut view = pages::Model::new(
pages::Screen::Instances, pages::Screen::Instances,
width, width,
height,
Some(pages::Screen::Instances.nav()), Some(pages::Screen::Instances.nav()),
); );
view.lang = lang.lang(); view.lang = lang.lang();
@@ -125,9 +124,7 @@ impl App {
agent_host: super::presenter::AgentHost::from_env(), agent_host: super::presenter::AgentHost::from_env(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(), download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0,
content: pages::content::PageState::default(), content: pages::content::PageState::default(),
account_form: pages::account::Form::new(), account_form: pages::account::Form::new(),
account: pages::account::Page::build( account: pages::account::Page::build(
-46
View File
@@ -6,11 +6,8 @@
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::view;
use crate::pages; use crate::pages;
use unlucky_presenter::router::Route;
use super::App; use super::App;
impl App { impl App {
@@ -37,23 +34,6 @@ impl App {
self.state.toasts.push(ToastKind::Info, text); self.state.toasts.push(ToastKind::Info, text);
} }
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
if self.state.launch_ends != self.launch_ends_seen {
self.launch_ends_seen = self.state.launch_ends;
if let Some(finished) = self.state.last_launch_end.as_deref() {
let text = view::instances::launch_toast(finished, self.lang());
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
&& finished.failure.is_none()
{
ToastKind::Success
} else {
ToastKind::Error
};
self.state.toasts.push(kind, text);
}
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category; let category = self.view.download.category;
@@ -66,42 +46,16 @@ impl App {
self.view = super::super::view::model( self.view = super::super::view::model(
&self.state, &self.state,
width, width,
self.height,
self.options.force_state, self.options.force_state,
category, category,
&draft, &draft,
self.lang(), self.lang(),
&self.content, &self.content,
); );
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
self.sync_launch_form();
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。 // 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width); self.rebuild_account_page(width);
self.view_dirty = false; self.view_dirty = false;
self.refreshes += 1; self.refreshes += 1;
} }
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
/// 免得「A 的草稿被当成 B 的初值」。
fn sync_launch_form(&mut self) {
let Route::Detail(id) = self.state.route() else {
self.launch_form.bound = None;
return;
};
match self
.state
.instances
.iter()
.find(|instance| &instance.id == id)
{
Some(instance) => {
let view = instance.launch.clone();
self.launch_form.sync(&view);
}
None => self.launch_form.bound = None,
}
}
} }
-2
View File
@@ -12,8 +12,6 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)] #[cfg(test)]
mod appearance; mod appearance;
#[cfg(test)] #[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter; mod presenter;
use super::message::Message; use super::message::Message;
+2 -2
View File
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
assert_eq!(app.view.screen, Screen::Settings); assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP); assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
drop(_tree); drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。 // 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0)); app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0); assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form); let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
} }
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。 /// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
@@ -1,145 +0,0 @@
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
//!
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
//!
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
LoaderInfo, LoaderKind,
};
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::message::Message;
use crate::app::state::{App, TICK};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
#[derive(Debug, Default)]
struct FailingSaveBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
}
impl Backend for FailingSaveBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.queued)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command.clone());
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
self.queued.push(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Error,
text: "内存超出范围".to_string(),
remaining_ms: 0,
}));
}
}
}
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
fn snapshot() -> Vec<InstanceSummary> {
vec![InstanceSummary {
id: "survival".to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
launch: LaunchArgsView {
instance_id: "survival".to_string(),
memory_mb: Some(4096),
memory_effective_mb: 4096,
..LaunchArgsView::default()
},
}]
}
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched = Rc::new(RefCell::new(Vec::new()));
let backend = FailingSaveBackend {
dispatched: Rc::clone(&dispatched),
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
};
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, dispatched)
}
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
///
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
/// 下面的前置断言会红)。
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
#[test]
fn failed_save_keeps_the_draft_through_the_whole_loop() {
let (mut app, dispatched) = app_with_snapshot();
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
app.advance(TICK);
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
assert!(!app.launch_form.dirty);
// 用户把内存改成非法值(host 会拒),然后点保存。
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Memory("4g".to_string()),
)));
assert_eq!(app.launch_form.memory, "4g");
let _ = app.update(Message::Page(pages::Message::Instances(
pages::InstanceMessage::Save,
)));
assert!(
dispatched
.borrow()
.iter()
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
"保存要把命令派发出去"
);
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
app.advance(TICK);
assert_eq!(
app.launch_form.memory, "4g",
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
);
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
}
-3
View File
@@ -47,12 +47,9 @@ mod appearance;
mod boot_env; mod boot_env;
mod clock; mod clock;
mod content; mod content;
mod content_pack;
mod download; mod download;
mod instances;
mod page_messages; mod page_messages;
mod settings; mod settings;
mod update_action;
impl App { impl App {
/// 一条消息进来,改状态并返回后续任务。 /// 一条消息进来,改状态并返回后续任务。
-14
View File
@@ -111,26 +111,12 @@ impl App {
if events.is_empty() { if events.is_empty() {
return; return;
} }
let mut clean_scan = false;
for event in events { for event in events {
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
if matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
pack
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
) {
clean_scan = true;
}
self.state.apply(event); self.state.apply(event);
} }
self.view_dirty = true; self.view_dirty = true;
self.auto_select(); self.auto_select();
self.refresh_view(); self.refresh_view();
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
if clean_scan {
self.continue_import_after_clean_scan();
}
} }
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。 /// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
+142 -29
View File
@@ -17,8 +17,8 @@
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource, AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
ExportRequestView, ImportRequestView, PackCommand,
}; };
use crate::pages::content::{Blocker, Content}; use crate::pages::content::{Blocker, Content};
@@ -77,12 +77,7 @@ impl App {
self.with_query(|query| query.loader = loader) self.with_query(|query| query.loader = loader)
} }
pages::content::Message::Select(id) => { pages::content::Message::Select(id) => {
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板) self.content.selected = Some(id);
self.content.selected = Some(id.clone());
self.state.content.request_detail(&id);
if let Some(request) = self.content_detail_request() {
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
}
self.view_dirty = true; self.view_dirty = true;
} }
pages::content::Message::SetTarget(id) => { pages::content::Message::SetTarget(id) => {
@@ -109,11 +104,149 @@ impl App {
Task::none() Task::none()
} }
/// 导入/导出区的消息(M113)。
///
/// 两条口径:
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
PackMessage::Dropped(path) => {
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
self.content.pack.draft = path.display().to_string();
self.content.pack.hover = false;
self.view_dirty = true;
}
PackMessage::Hover(hover) => {
self.content.pack.hover = hover;
self.view_dirty = true;
}
PackMessage::PathChanged(path) => {
self.content.pack.draft = path;
self.view_dirty = true;
}
PackMessage::SetInstance(instance) => {
self.content.pack.instance = instance;
self.view_dirty = true;
}
PackMessage::SetInstanceName(name) => {
self.content.pack.instance_name = name;
self.view_dirty = true;
}
PackMessage::SetFormat(format) => {
self.content.pack.format = format;
self.view_dirty = true;
}
PackMessage::SetExportName(name) => {
self.content.pack.export_name = name;
self.view_dirty = true;
}
PackMessage::SetExportVersion(version) => {
self.content.pack.export_version = version;
self.view_dirty = true;
}
PackMessage::SetOutput(path) => {
self.content.pack.output = path;
self.view_dirty = true;
}
PackMessage::ToggleConflict(path) => {
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
{
None
} else {
Some(path)
};
self.view_dirty = true;
}
PackMessage::OpenExport => {
self.content.pack.export_open = true;
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
if self.content.pack.export_name.trim().is_empty() {
self.content.pack.export_name =
self.content.pack.instance.clone().unwrap_or_default();
}
self.view_dirty = true;
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
let draft = self.content.pack.draft.trim();
if draft.is_empty() {
return None;
}
let name = self.content.pack.instance_name.trim();
Some(ImportRequestView {
pack: std::path::PathBuf::from(draft),
instance: self.content.pack.instance.clone(),
instance_name: (!name.is_empty()).then(|| name.to_string()),
overwrite,
})
}
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。 /// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
/// ///
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求** /// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。 /// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> { fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?; let instance = self.content.pack.instance.clone()?;
Some(pages::content::request_export( Some(pages::content::request_export(
&self.content.pack, &self.content.pack,
@@ -166,26 +299,6 @@ impl App {
.is_some_and(|results| results.curseforge) .is_some_and(|results| results.curseforge)
} }
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
///
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref());
Some(ContentDetailRequest {
project: hit.id.clone(),
source: hit.source,
game_version: target.map(|target| target.mc_version.clone()),
loader: target.map(|target| target.loader),
})
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。 /// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
/// ///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`), /// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
@@ -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,
})
}
}
-174
View File
@@ -1,174 +0,0 @@
//! 实例页「启动参数」区的消息落点(M157)。
//!
//! 三件事:
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
use crate::pages::InstanceMessage;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 启动参数区的一条消息。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else {
return Task::none();
};
if !self.launch_form.bound_to(&instance) {
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
return Task::none();
}
match message {
InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text;
self.launch_form.dirty = true;
}
InstanceMessage::Game(text) => {
self.launch_form.game = text;
self.launch_form.dirty = true;
}
InstanceMessage::Memory(text) => {
self.launch_form.memory = text;
self.launch_form.dirty = true;
}
InstanceMessage::Java(text) => {
self.launch_form.java = text;
self.launch_form.dirty = true;
}
InstanceMessage::Backend(id) => {
self.launch_form.backend = id;
self.launch_form.dirty = true;
}
InstanceMessage::Reset => {
self.launch_form.reset();
}
InstanceMessage::Save => {
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
let patch: LaunchArgsPatch = self.launch_form.patch();
self.dispatch(AppCommand::set_launch_args(&instance, patch));
}
}
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
Task::none()
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::{InstanceMessage, LaunchForm};
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn app_with_form() -> App {
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::mock::MockBackend::new()),
SettingsBridge::memory_only(),
);
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
app.state.navigate(unlucky_presenter::router::Route::Detail(
INSTANCE.to_string(),
));
app.launch_form = LaunchForm::from_view(
Some(INSTANCE),
&LaunchArgsView {
instance_id: INSTANCE.to_string(),
..LaunchArgsView::default()
},
);
app
}
/// 测试用的实例 id(mock 后端的实例快照里有它)。
const INSTANCE: &str = "survival";
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
#[test]
fn editing_marks_dirty_and_reset_waits_for_save() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
assert_eq!(app.launch_form.memory, "6144");
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
assert_eq!(app.launch_form.backend, "vulkan");
let _ = app.on_instance_message(InstanceMessage::Reset);
assert_eq!(app.launch_form.memory, "");
assert_eq!(app.launch_form.backend, "default");
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
}
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
#[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form();
app.launch_form = LaunchForm::from_view(
Some("other"),
&LaunchArgsView {
instance_id: "other".to_string(),
..LaunchArgsView::default()
},
);
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
assert_eq!(app.launch_form.memory, "");
assert!(!app.launch_form.dirty);
}
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
///
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
#[test]
fn save_dispatch_keeps_the_dirty_flag() {
let mut app = app_with_form();
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
assert!(app.launch_form.dirty);
let _ = app.on_instance_message(InstanceMessage::Save);
assert!(
app.launch_form.dirty,
"派发后仍要保留脏标记,等回程快照确认"
);
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
}
}
@@ -54,8 +54,6 @@ impl App {
} }
} }
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change), pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发 // M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -90,8 +88,6 @@ impl App {
self.view_dirty = true; self.view_dirty = true;
self.refresh_view(); self.refresh_view();
} }
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
pages::Message::Instances(message) => return self.on_instance_message(message),
// M114:账户页(三轨登录 / 列表动作 / 表单输入) // M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message), pages::Message::Account(message) => return self.on_account(message),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
@@ -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(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+3 -11
View File
@@ -21,14 +21,12 @@ use super::state::{log_first_frame, App};
use super::APP_TITLE; use super::APP_TITLE;
mod account_page; mod account_page;
pub(super) mod instances; mod instances;
mod logs; mod logs;
mod rail; mod rail;
mod update;
use instances::{detail_of, runtime_status_row, supporting_line}; use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model; use logs::logs_model;
use update::update_of;
impl App { impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。 /// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -94,8 +92,6 @@ impl App {
}, },
// M106:设置页 General 组要画「当前档位 + 生效语言」。 // M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(), self.language(),
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
&self.launch_form,
) )
.map(Message::Page), .map(Message::Page),
agent::docked(&self.ui, session, agent_catalog, self.width) agent::docked(&self.ui, session, agent_catalog, self.width)
@@ -140,7 +136,6 @@ impl App {
pub(super) fn model( pub(super) fn model(
state: &AppState, state: &AppState,
width: f32, width: f32,
height: f32,
forced: Option<crate::cli::ForcedState>, forced: Option<crate::cli::ForcedState>,
category: pages::Category, category: pages::Category,
draft: &pages::InstallDraft, draft: &pages::InstallDraft,
@@ -159,7 +154,7 @@ pub(super) fn model(
id: cell.instance.id.clone(), id: cell.instance.id.clone(),
name: cell.instance.name.clone(), name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog), supporting: supporting_line(cell.instance, catalog),
status: runtime_status_row(&cell.instance.runtime, catalog), status: runtime_status(&cell.instance.runtime, catalog),
}) })
.collect(); .collect();
@@ -175,15 +170,12 @@ pub(super) fn model(
pages::Model { pages::Model {
screen, screen,
window_width: width, window_width: width,
window_height: height,
active: route.nav(), active: route.nav(),
instances: pages::Instances { rows, detail }, instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()), content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang), logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
lang, lang,
forced: forced.map(|state| match state { forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。 // `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+7 -111
View File
@@ -18,13 +18,10 @@
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、 //! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。 //! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome,
};
use crate::pages::{Detail, LaunchFacts, Meta}; use crate::pages::{Detail, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。 /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -62,7 +59,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
} }
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 /// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> { pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime { match runtime {
InstanceRuntime::Idle => None, InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言 // 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
@@ -74,103 +71,13 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
InstanceRuntime::Running { pid } => { InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())])) Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
} }
// 失败有**两种形状**,各说各的话(复审 P1-1): // `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息); InstanceRuntime::Failed { reason } => {
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码), Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
// 绝不说成「启动游戏进程失败」。
//
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
}
}
/// 一条失败状态行。
///
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
match cause {
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(*stage, catalog)),
("detail", detail.as_str()),
],
),
LaunchFailureCause::Stage { stage, .. } => catalog.render(
Key::InstancesFailed,
&[("reason", failure_stage_label(*stage, catalog))],
),
LaunchFailureCause::Crashed { exit_code } => {
let reason = match exit_code {
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
};
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
}
LaunchFailureCause::SilentFailure { .. } => {
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
} }
} }
} }
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
///
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
other => runtime_status(other, catalog),
}
}
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
///
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
})
}
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
///
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
#[must_use]
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
let catalog = lang.catalog();
if let Some(failure) = &finished.failure {
return catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(failure.stage, catalog)),
("detail", failure.detail.as_str()),
],
);
}
let outcome = catalog.text(match finished.outcome {
LogOutcome::Ok => Key::LaunchOutcomeNormal,
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
});
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
catalog.render(
Key::LaunchToastEnded,
&[("outcome", outcome), ("seconds", seconds.as_str())],
)
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。 /// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str { fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage { catalog.text(match stage {
@@ -228,17 +135,6 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
instance.runtime, instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
), ),
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
launch: LaunchFacts {
allowed: instance.launch.graphics_backend_allowed,
mc_version: instance.launch.mc_version.clone(),
min_version: instance.launch.graphics_backend_min_version.clone(),
memory_effective_mb: instance.launch.memory_effective_mb,
java_effective: instance.launch.java_effective.clone(),
backend_effective: instance.launch.graphics_backend_effective.clone(),
options: instance.launch.graphics_backend_options.clone(),
},
} }
} }
+2 -6
View File
@@ -74,9 +74,7 @@ fn crashed_only() -> AppState {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
exit_code: Some(1), summary: "崩溃(exit 1)".to_string(),
uptime_ms: 3_000,
failure: None,
})); }));
state state
@@ -168,9 +166,7 @@ fn an_imported_crash_file_still_shows_its_findings() {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
exit_code: Some(1), summary: "崩溃(exit 1)".to_string(),
uptime_ms: 3_000,
failure: None,
})); }));
state.apply_core(CoreEvent::DiagnosisReady(report( state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt", "file:crash-2026-11-02_19.40.15-client.txt",
+15 -5
View File
@@ -7,9 +7,6 @@ use unlucky_presenter::dto::{
}; };
use unlucky_presenter::mock_data::mock_instances; use unlucky_presenter::mock_data::mock_instances;
#[path = "tests/launch.rs"]
mod launch;
fn instance(runtime: InstanceRuntime) -> InstanceSummary { fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary { InstanceSummary {
id: "survival".to_string(), id: "survival".to_string(),
@@ -25,7 +22,6 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
isolation: "isolated".to_string(), isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(), version_id: "1.20.4".to_string(),
version_present: true, version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
} }
} }
@@ -42,7 +38,6 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
model( model(
state, state,
1180.0, 1180.0,
760.0,
forced, forced,
pages::Category::All, pages::Category::All,
&draft, &draft,
@@ -126,6 +121,21 @@ fn a_busy_instance_offers_stop_instead_of_play() {
assert!(detail.can_stop, "启动中要能 Stop"); assert!(detail.can_stop, "启动中要能 Stop");
} }
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test] #[test]
fn timestamps_are_sliced_not_guessed() { fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog(); let en = Lang::En.catalog();
@@ -1,149 +0,0 @@
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
//!
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
use super::*;
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
#[test]
fn failed_instances_can_be_retried() {
// 进程**没起来**:说「哪一步 + 原文」。
let failed = instance(InstanceRuntime::Failed {
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
});
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
assert_eq!(
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process")
);
// 详情页给全文(阶段 + 原文)。
assert_eq!(
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process: os error 5")
);
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
///
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
#[test]
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
let line = |cause: LaunchFailureCause, lang: Lang| {
let runtime = InstanceRuntime::Failed { cause };
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
(row, detail)
};
// ① 没起来:说阶段。
let (row, detail) = line(
LaunchFailureCause::Stage {
stage: LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
Lang::En,
);
assert_eq!(row, "Launch failed · Starting the game process");
assert_eq!(
detail,
"Launch failed · Starting the game process: os error 5"
);
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
assert!(
!detail.contains("Starting the game process"),
"进程起来了,不许说「启动游戏进程」:{detail}"
);
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
let (row, zh) = line(
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
Lang::ZhCn,
);
assert_eq!(row, "启动失败 · 退出码 0,没起来");
assert_eq!(zh, row);
assert!(row.contains("没起来"), "{row}");
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
assert_eq!(row, "Launch failed · The game crashed");
}
///
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
///
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
#[test]
fn the_launch_toast_speaks_the_interface_language() {
use unlucky_presenter::dto::{
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
};
let finished = |outcome, failure| LaunchFinished {
id: "survival".to_string(),
session: "20261031T120000-abcd12".to_string(),
outcome,
exit_code: Some(1),
uptime_ms: 12_500,
failure,
};
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
let ok = finished(LogOutcome::Ok, None);
let en = super::instances::launch_toast(&ok, Lang::En);
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
assert_eq!(en, "Finished normally · 12.5s");
assert_eq!(zh, "正常结束 · 12.5 秒");
assert_ne!(en, zh, "切语言时提示语要变");
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
let failed = finished(
LogOutcome::Crashed,
Some(LaunchFailureView::new(
LaunchFailureStage::InstanceConfig,
"permission denied (os error 5)",
)),
);
let en = super::instances::launch_toast(&failed, Lang::En);
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
assert!(
en.starts_with("Launch failed · Reading the instance settings"),
"{en}"
);
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
for text in [&en, &zh] {
assert!(
text.contains("permission denied (os error 5)"),
"原文(取证信息)不许丢:{text}"
);
}
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
assert!(
!zh.contains("Launch failed"),
"中文提示里混了英文前缀:{zh}"
);
}
/// 有没有中文字符(上面那条「不许混语言」的判据)。
fn is_cjk(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
-157
View File
@@ -1,157 +0,0 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
skipped: view.skipped.clone(),
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view);
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}
-13
View File
@@ -52,9 +52,6 @@ impl Dispatch {
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。 /// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> { pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
// 只有 `RawWindowHandle::Win32` 那条臂用到它,其余平台只剩 `_ => None`
// ⇒ 无条件导入在非 Windows 上是 `unused_imports`(M147)。
#[cfg(target_os = "windows")]
use window::raw_window_handle::RawWindowHandle; use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?; let handle = handle.window_handle().ok()?;
@@ -102,21 +99,11 @@ mod tests {
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。 // 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5)); let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED"); assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
// 「做不到」的语义随平台不同(M147 实证:这条断言原先按 Windows 写死,Ubuntu 上
// 622 passed / 1 failed):Windows 上 FFI 真被调用并失败 ⇒ `Failed`;非 Windows 走
// `transparency.rs` 的空实现 ⇒ `Unsupported`。两边都不许表现成 `Applied` / `Opaque`。
#[cfg(target_os = "windows")]
assert!( assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }), matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}", "{:?}",
invalid.alpha invalid.alpha
); );
#[cfg(not(target_os = "windows"))]
assert_eq!(
invalid.alpha,
AlphaOutcome::Unsupported,
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
);
} }
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`), /// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
-35
View File
@@ -139,43 +139,8 @@ pub fn resolve() -> Resolved {
resolve_with(None) resolve_with(None)
} }
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
///
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
fn register_termux_fonts() {
use std::sync::OnceLock;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| {
if !termux::is_termux(&ProcessEnv) {
return;
}
const CANDIDATES: &[&str] = &[
"/system/fonts/NotoSansCJK-Regular.ttc",
"/system/fonts/NotoSansCJKSC-Regular.otf",
"/system/fonts/DroidSansFallback.ttf",
];
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
return;
};
let db = font_system.raw().db_mut();
for path in CANDIDATES {
if db.load_font_file(path).is_ok() {
return;
}
}
});
}
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
pub fn resolve_with(setting: Option<&str>) -> Resolved { pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts();
let platform = Platform::current(); let platform = Platform::current();
let faces = system_faces(); let faces = system_faces();
+1 -2
View File
@@ -81,9 +81,8 @@ pub fn view<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
model: &'a Content, model: &'a Content,
lang: Lang, lang: Lang,
window_height: f32,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
view::view(ui, metrics, model, lang, window_height) view::view(ui, metrics, model, lang)
} }
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。 /// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
+5 -51
View File
@@ -11,16 +11,14 @@
//! | 变量 | 取值 | 作用 | //! | 变量 | 取值 | 作用 |
//! |---|---|---| //! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | //! | `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()` 根本不会被调用。 //! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{ use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
};
use crate::pages::download::Download; use crate::pages::download::Download;
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
@@ -149,45 +147,9 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
let targets = targets(); let targets = targets();
let target = Some("survival".to_string()); let target = Some("survival".to_string());
let empty = state == "empty"; 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 installing = state == "install";
// 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络) let list = if empty {
let list = if empty || offline {
Vec::new() Vec::new()
} else { } else {
hits(kind, catalog, state != "incompatible") hits(kind, catalog, state != "incompatible")
@@ -234,13 +196,7 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
}, },
hits: list, hits: list,
selected, selected,
status: if offline { status: if empty { Status::Empty } else { Status::Ready },
Status::Offline("connect timed out after 10s".to_string())
} else if empty {
Status::Empty
} else {
Status::Ready
},
total: if empty { 0 } else { 1_284 }, total: if empty { 0 } else { 1_284 },
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」 // 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
has_more: !empty, has_more: !empty,
@@ -255,8 +211,6 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
None None
}, },
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
available_versions: demo_versions,
versions_note: None,
draft: if empty { draft: if empty {
"quarkonium".to_string() "quarkonium".to_string()
} else { } else {
-145
View File
@@ -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` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。 /// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
pub fn wide_min() -> f32 { pub fn wide_min() -> f32 {
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
@@ -239,101 +192,3 @@ mod tests {
assert!(!Tier::Narrow.rail_has_labels()); 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
);
}
}
+4 -69
View File
@@ -27,7 +27,7 @@ use unlucky_presenter::dto::{
}; };
use unlucky_presenter::state::AppState; use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::Catalog;
use crate::pages::download::Download; use crate::pages::download::Download;
@@ -35,7 +35,7 @@ mod items;
pub(crate) use items::HitFacts; pub(crate) use items::HitFacts;
pub(crate) use items::{capitalize, thousands}; 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)] #[derive(Debug, Clone, PartialEq)]
@@ -75,10 +75,6 @@ pub struct Content {
pub draft: String, pub draft: String,
/// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检 /// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检
pub pack: super::packui::Pack, pub pack: super::packui::Pack,
/// 选中项目的可用版本(M162:详情通道;空 = 还没拉到 / 站点没给)
pub available_versions: Vec<VersionRow>,
/// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说)
pub versions_note: Option<String>,
} }
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。 /// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
@@ -160,9 +156,6 @@ impl Content {
catalog, catalog,
); );
let (available_versions, versions_note) =
detail_view(content, page.selected.as_deref(), catalog);
Self { Self {
kind: page.kind, kind: page.kind,
query: ContentQueryView { query: ContentQueryView {
@@ -190,8 +183,6 @@ impl Content {
versions, versions,
draft: page.draft.clone(), draft: page.draft.clone(),
pack, pack,
available_versions,
versions_note,
} }
} }
@@ -222,13 +213,8 @@ impl Content {
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。 /// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
fn status_of(content: &ContentState, hits: &[Hit]) -> Status { fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
if let Some(failure) = &content.failed { if let Some(reason) = &content.failed {
// M162:没联网单独一态(空列表不能冒充「没有结果」) return Status::Failed(reason.clone());
return if failure.is_offline() {
Status::Offline(failure.detail.clone())
} else {
Status::Failed(failure.detail.clone())
};
} }
if content.pending || content.results.is_none() { if content.pending || content.results.is_none() {
return Status::Loading; 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)) .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 节)。 /// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
/// ///
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。 /// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
@@ -45,34 +45,13 @@ pub enum Status {
Empty, Empty,
/// 失败(带一句人读原因) /// 失败(带一句人读原因)
Failed(String), Failed(String),
/// 未联网(M162):**不是**「没有匹配的内容」,也不是普通失败——单独一屏,
/// 说清「要么联网要么没结果」这件事不可能靠空列表表达。
Offline(String),
} }
impl Status { impl Status {
/// 失败了吗(视图用它决定画错误面板还是列表)。 /// 失败了吗(视图用它决定画错误面板还是列表)。
pub const fn failed(&self) -> bool { 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 的文本控件借用内容)。 /// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
@@ -6,8 +6,8 @@
use super::*; use super::*;
use unlucky_i18n::Lang; use unlucky_i18n::Lang;
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, ContentQuery, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
ContentResults, ContentSource, ContentVersionView, InstanceRuntime, LoaderKind, LoaderKind,
}; };
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit { fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
@@ -200,72 +200,10 @@ fn the_four_states_are_derived_from_three_fields() {
#[test] #[test]
fn a_failure_outranks_the_loading_state() { fn a_failure_outranks_the_loading_state() {
let mut state = state_with(None, true); 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()); 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.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] #[test]
@@ -285,7 +223,6 @@ fn can_install_needs_a_selection_and_no_blockers() {
isolation: "isolated".to_string(), isolation: "isolated".to_string(),
version_id: "1.20.1".to_string(), version_id: "1.20.1".to_string(),
version_present: true, version_present: true,
launch: unlucky_presenter::dto::LaunchArgsView::default(),
}]; }];
let catalog = Lang::En.catalog(); let catalog = Lang::En.catalog();
let page = PageState { let page = PageState {
+3 -11
View File
@@ -28,22 +28,14 @@
//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) | //! | [`view`] | 画法(进度**整块**复用下载页的安装面板) |
//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) | //! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) |
//! //!
//! ## M160 新增的一屏
//!
//! `model/pick.rs` + `view/pick.rs` 是**导入前的冲突清单**:分页(每页 `PAGE_SIZE` 条)、
//! 三个入口、计数摘要、确认/取消。它出现的前提是 core 的预检给出了至少一条冲突——
//! 没有冲突时页面连这一屏都不画(直接往下导)。
//!
//! ## 本版明确的边界(报告里同样登记) //! ## 本版明确的边界(报告里同样登记)
//! //!
//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、 //! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、
//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是 //! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是
//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。 //! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。
//! - **冲突是逐文件决策了(M160)**:点「导入」先跑一次**只读预检**(core 的 `scan_pack`), //! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 /
//! 没有冲突就直接导(不打扰用户);有冲突就画那一屏清单——分页([`model::PAGE_SIZE`] 条一页)、 //! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」,
//! 「全部保留 / 全部覆盖 / 逐项」三个入口、计数摘要、确认与取消。**缺省永远是保留**: //! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。
//! 没点过的文件一个都不会被包内内容替换。仍然不支持的是「两者都留(改名)」——那要改落盘
//! 布局,不在本轮。
mod demo; mod demo;
mod message; 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`), //! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行, //! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
@@ -20,8 +20,8 @@ use unlucky_i18n::Lang;
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView, ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView,
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
}; };
use crate::pages::content::Target; use crate::pages::content::Target;
@@ -68,42 +68,13 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
match state { match state {
"importing" => import.progress = Some(progress()), "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)), "partial" => import.report = Some(report(ImportStatusView::Partial, false)),
"conflict" => { "conflict" => {
import.report = Some(report(ImportStatusView::Partial, true)); import.report = Some(report(ImportStatusView::Partial, true));
page.expanded = Some("config/sodium-options.json".to_string()); page.expanded = Some("config/sodium-options.json".to_string());
} }
// 致命态(M161 分成三档出图:预检没过 / 已回滚 / 回滚没做完)。 // 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野)
// `failed` 保留旧名字(= 已回滚),另外两个是新档位。 "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" => {
let mut fatal = report(ImportStatusView::Failed, false); let mut fatal = report(ImportStatusView::Failed, false);
fatal.error = Some(ImportErrorView { fatal.error = Some(ImportErrorView {
code: "pack_traversal".to_string(), code: "pack_traversal".to_string(),
@@ -113,21 +84,6 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
}); });
fatal.files.clear(); fatal.files.clear();
fatal.bytes_written = 0; 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); import.report = Some(fatal);
} }
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上 // 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
@@ -211,7 +167,6 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
registered: Some("Survival".to_string()), registered: Some("Survival".to_string()),
bytes_written: 3_400_000, bytes_written: 3_400_000,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
if conflicts { if conflicts {
report.conflicts = vec![ 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 跳过(包不完整)。 /// 导出预检:12 内联 / 1 跳过(包不完整)。
fn preview() -> ExportPreviewView { fn preview() -> ExportPreviewView {
let mut files = vec![ExportFileView { let mut files = vec![ExportFileView {
@@ -30,22 +30,10 @@ pub enum Message {
Cancel, Cancel,
/// 关掉结果面板(报告 / 致命错误都走这一条) /// 关掉结果面板(报告 / 致命错误都走这一条)
Dismiss, Dismiss,
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档) /// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
Overwrite, Overwrite,
/// 展开/收起某一条冲突的三方明细 /// 展开/收起某一条冲突的三方明细
ToggleConflict(String), ToggleConflict(String),
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
PickPage(usize),
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
TogglePick(String),
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
PickKeepAll,
/// 整批档:全部覆盖
PickOverwriteAll,
/// 按当前选择开始导入
ConfirmImport,
/// 取消这次导入(不写盘,回到入口态)
CancelImport,
/// 换目标实例(`None` = 按包名新建) /// 换目标实例(`None` = 按包名新建)
SetInstance(Option<InstanceId>), SetInstance(Option<InstanceId>),
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字) /// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
@@ -23,28 +23,18 @@
//! ## 容忍 / 致命在模型层就分开 //! ## 容忍 / 致命在模型层就分开
//! //!
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`] //! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
//! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。 //! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
//!
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
mod export; mod export;
mod pick;
mod progress; mod progress;
mod report; mod report;
pub use export::{format_label, request_of as request_export, Export, Preview}; pub use export::{format_label, request_of as request_export, Export, Preview};
pub use pick::{Pick, PickRow};
pub use progress::Running; pub use progress::Running;
pub use report::{ConflictRow, FatalKind, Report, Row}; pub use report::{ConflictRow, Report, Row};
use std::collections::BTreeMap;
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
PackFormatView, ProbeView,
};
use crate::pages::content::Target; use crate::pages::content::Target;
@@ -71,12 +61,6 @@ pub struct PackPageState {
pub export_open: bool, pub export_open: bool,
/// 展开的那条冲突路径 /// 展开的那条冲突路径
pub expanded: Option<String>, 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 { impl Default for PackPageState {
@@ -94,65 +78,10 @@ impl Default for PackPageState {
output: String::new(), output: String::new(),
export_open: false, export_open: false,
expanded: None, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackTarget { pub struct PackTarget {
@@ -203,8 +132,6 @@ pub struct Pack {
pub cancelled_note: &'static str, pub cancelled_note: &'static str,
/// 导入结果 /// 导入结果
pub report: Option<Report>, pub report: Option<Report>,
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
pub pick: Option<Pick>,
/// 导出块 /// 导出块
pub export: Export, pub export: Export,
} }
@@ -254,15 +181,6 @@ impl Pack {
.report .report
.as_ref() .as_ref()
.map(|report| report::report_of(report, page, catalog)), .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), export: export::export_of(export, page, candidates, catalog),
} }
} }
@@ -272,7 +190,6 @@ impl Pack {
self.running.is_none() self.running.is_none()
&& self.probe.is_none() && self.probe.is_none()
&& self.report.is_none() && self.report.is_none()
&& self.pick.is_none()
&& !self.cancelled && !self.cancelled
&& !self.export.open && !self.export.open
&& self.draft.is_empty() && 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, bytes_total: 0,
speed_bps: 0, speed_bps: 0,
eta_secs: 0, eta_secs: 0,
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
failure_kind: None,
message: progress.current.clone().unwrap_or_default(), message: progress.current.clone().unwrap_or_default(),
} }
} }
@@ -14,8 +14,8 @@
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView,
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView, ImportStatusView, MissingFileView, MissingReasonView,
}; };
use super::PackPageState; use super::PackPageState;
@@ -48,40 +48,10 @@ pub struct ConflictRow {
pub expanded: bool, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fatal { pub struct Fatal {
/// 三种阶段之一(决定标题/图标/底色) /// 「已回滚 —— 目标目录零残留」
pub kind: FatalKind,
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
pub title: String, pub title: String,
/// 大类名(`路径穿越` / `清单损坏` …) /// 大类名(`路径穿越` / `清单损坏` …)
pub label: String, pub label: String,
@@ -154,7 +124,7 @@ pub(super) fn report_of(
Report { Report {
status: report.status, status: report.status,
title: status_title(report, catalog).to_owned(), title: status_title(report.status, catalog).to_owned(),
subtitle: subtitle(report, catalog), subtitle: subtitle(report, catalog),
written_line: catalog.render( written_line: catalog.render(
Key::PackReportWritten, Key::PackReportWritten,
@@ -163,10 +133,7 @@ pub(super) fn report_of(
("bytes", &human(report)), ("bytes", &human(report)),
], ],
), ),
fatal: report fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)),
.error
.as_ref()
.map(|error| fatal_of(error, report.failure_stage, catalog)),
missing_title, missing_title,
missing: report missing: report
.missing .missing
@@ -210,22 +177,11 @@ fn human(report: &ImportReportView) -> String {
unlucky_presenter::dto::human_bytes(report.bytes_written) unlucky_presenter::dto::human_bytes(report.bytes_written)
} }
/// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。 fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str {
fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str { match status {
match report.status {
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess), ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial), ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
ImportStatusView::Failed => match report.failure_stage { ImportStatusView::Failed => catalog.text(Key::PackStatusFailed),
Some(ImportFailureStageView::NotStarted) => {
catalog.text(Key::PackStatusFailedNotStarted)
}
Some(ImportFailureStageView::RollbackIncomplete) => {
catalog.text(Key::PackStatusFailedIncomplete)
}
// `None` 不该出现(失败必有阶段);退回「已回滚」这一句是旧行为,
// 只有拿不到阶段时才会走到(例如未来的旧版对端)。
_ => catalog.text(Key::PackStatusFailed),
},
} }
} }
@@ -247,24 +203,9 @@ fn subtitle(report: &ImportReportView, catalog: Catalog) -> String {
parts.join(" · ") parts.join(" · ")
} }
fn fatal_of( fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal {
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),
};
Fatal { Fatal {
kind, title: catalog.text(Key::PackFatalTitle).to_owned(),
title: title.to_owned(),
label: error.label.clone(), label: error.label.clone(),
message: error.message.clone(), message: error.message.clone(),
code: error.code.clone(), code: error.code.clone(),
@@ -3,9 +3,6 @@
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、 //! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。 //! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
mod pick;
mod stage;
use std::path::PathBuf; use std::path::PathBuf;
use unlucky_i18n::Lang; use unlucky_i18n::Lang;
@@ -227,7 +224,6 @@ fn report_keeps_tolerable_and_fatal_apart() {
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
let block = report_of(&report, &PackPageState::default(), catalog()); let block = report_of(&report, &PackPageState::default(), catalog());
assert!(block.fatal.is_some(), "致命块必须单独存在"); assert!(block.fatal.is_some(), "致命块必须单独存在");
@@ -264,7 +260,6 @@ fn conflicts_default_to_keeping_the_user_copy() {
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例 // 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
let page = PackPageState { let page = PackPageState {
@@ -362,7 +357,6 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
let new_instance = PackPageState::default(); 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::demo::pack_in;
use super::model::Pack; use super::model::Pack;
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。 /// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"]; const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
#[test] #[test]
fn every_screenshot_state_fills_something() { 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] #[test]
fn importing_shows_the_shared_install_panel() { fn importing_shows_the_shared_install_panel() {
let (model, _page) = pack_in("importing", Lang::En); let (model, _page) = pack_in("importing", Lang::En);
@@ -41,7 +41,6 @@
mod export; mod export;
mod parts; mod parts;
mod pick;
mod progress; mod progress;
mod report; mod report;
@@ -65,7 +64,6 @@ use crate::pages::Message as PageMessage;
use crate::theme::Ui; use crate::theme::Ui;
use self::export::export_block; use self::export::export_block;
use self::pick::pick_block;
use self::progress::{probe_block, running_block}; use self::progress::{probe_block, running_block};
use self::report::report_block; use self::report::report_block;
use super::model::Pack; use super::model::Pack;
@@ -90,9 +88,6 @@ pub(crate) fn section<'a>(
// 事件,任何时候把文件拖进来都算数。 // 事件,任何时候把文件拖进来都算数。
if model.export.open { if model.export.open {
block = block.push(export_block(ui, catalog, &model.export)); 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 { } else if let Some(running) = &model.running {
block = block.push(running_block(ui, catalog, running)); block = block.push(running_block(ui, catalog, running));
} else if let Some(report) = &model.report { } 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()
}
@@ -15,7 +15,7 @@ use crate::pages::frame;
use crate::pages::Message as PageMessage; use crate::pages::Message as PageMessage;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::model::{ConflictRow, FatalKind, Report}; use super::super::model::{ConflictRow, Report};
use super::super::PackMessage; use super::super::PackMessage;
use super::intent; use super::intent;
use super::parts::{bounded, marker_box, rows_block, tolerable_title}; use super::parts::{bounded, marker_box, rows_block, tolerable_title};
@@ -62,46 +62,34 @@ pub(super) fn report_block<'a>(
.width(Length::Fill); .width(Length::Fill);
if let Some(fatal) = &report.fatal { if let Some(fatal) = &report.fatal {
// 三种阶段各自的底色/图标:预检没过用 `SecondaryContainer`(**不是错的颜色**——
// 目标目录一个字节都没写,没什么可修的残留),已回滚用 `ErrorContainer`,
// 回滚不完整留 `ErrorContainer`(它才是真的要对一眼目录的状态)。
let (role, on_role) = match fatal.kind {
FatalKind::NotStarted => (Role::SecondaryContainer, Role::OnSecondaryContainer),
_ => (Role::ErrorContainer, Role::OnErrorContainer),
};
block = block.push(marker_box( block = block.push(marker_box(
ui, ui,
role, Role::ErrorContainer,
row![ column![
frame::glyph(fatal.kind.icon(), ui.color(on_role), frame::ICON_PX), frame::text_role(
column![ ui,
frame::text_role( TypeRole::LabelLarge,
ui, true,
TypeRole::LabelLarge, fatal.title.as_str(),
true, ui.color(Role::OnErrorContainer),
fatal.title.as_str(), ),
ui.color(on_role), frame::text_role_owned(
), ui,
frame::text_role_owned( TypeRole::BodyMedium,
ui, false,
TypeRole::BodyMedium, format!("{} · {}", fatal.label, fatal.code),
false, ui.color(Role::OnErrorContainer),
format!("{} · {}", fatal.label, fatal.code), ),
ui.color(on_role), frame::text_role(
), ui,
frame::text_role( TypeRole::BodySmall,
ui, false,
TypeRole::BodySmall, fatal.message.as_str(),
false, ui.color(Role::OnErrorContainer),
fatal.message.as_str(), ),
ui.color(on_role),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
] ]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X1))
.align_y(Alignment::Start), .width(Length::Fill),
)); ));
if let Some(hint) = &fatal.hint { if let Some(hint) = &fatal.hint {
block = block.push( block = block.push(
+135 -37
View File
@@ -21,12 +21,13 @@ use unlucky_presenter::dto::ContentSort;
use unlucky_presenter::router::Route; use unlucky_presenter::router::Route;
use crate::app::components::buttons::{self, Button}; use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::{empty_state, EmptyState};
use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::selection::{chip, Chip}; use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui; use crate::theme::Ui;
use super::layout::Rails; use super::layout::Rails;
use super::model::{Content, Hit}; use super::model::{Content, Hit, Status};
mod detail; mod detail;
mod filters; mod filters;
@@ -97,18 +98,12 @@ pub(super) fn search<'a>(
.into() .into()
} }
/// 结果列表。 /// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
///
/// 两档(M161,判据同 [`super::layout::BodyMode`]):
///
/// * `mode.list_scrolls()`:列表**自己滚**(筛选条常驻,高窗口的常态);
/// * 否则:行按自然高度铺开,滚动交给外层主列——矮窗口里一行 `ListItem` 不许被压成半行。
pub(super) fn list<'a>( pub(super) fn list<'a>(
ui: &'a Ui, ui: &'a Ui,
catalog: Catalog, catalog: Catalog,
model: &'a Content, model: &'a Content,
rails: Rails, rails: Rails,
mode: super::layout::BodyMode,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let rows = column( let rows = column(
model model
@@ -122,32 +117,20 @@ pub(super) fn list<'a>(
.width(Length::Fill); .width(Length::Fill);
let _ = catalog; let _ = catalog;
let inner: Element<'a, PageMessage> = if mode.list_scrolls() { container(
container( scrollable(rows)
scrollable(rows) .direction(scrollable::Direction::Vertical(
.direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new()
scrollable::Scrollbar::new() .width(frame::SCROLLBAR_WIDTH)
.width(frame::SCROLLBAR_WIDTH) .scroller_width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH) .margin(frame::SCROLLBAR_MARGIN),
.margin(frame::SCROLLBAR_MARGIN), ))
)) .height(Length::Fill)
.height(Length::Fill) .style(frame::scroll_style(ui)),
.style(frame::scroll_style(ui)), )
) .width(Length::Fixed(rails.list))
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} else {
// 自然高度(不给 `Fill`):外层主列的那个滚动条负责滚。
rows.into()
};
container(inner)
.width(Length::Fixed(rails.list))
.height(if mode.list_scrolls() {
Length::Fill
} else {
Length::Shrink
})
.into()
} }
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> { fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
@@ -208,6 +191,111 @@ pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element
) )
} }
pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(
column![
frame::text_role_centered(
ui,
TypeRole::TitleMedium,
true,
catalog.text(Key::ContentLoading),
ui.color(Role::OnSurface)
),
frame::text_role_centered(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentLoadingHint),
ui.color(Role::OnSurfaceVariant)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(empty_state(
ui,
EmptyState::new(
IconKind::Search,
catalog.text(Key::ContentEmptyTitle),
catalog.text(Key::ContentEmptyBody),
)
.action(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry),
),
))
}
/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。
pub(super) fn failed<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentFailedTitle),
ui.color(Role::Error)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentFailedBody),
ui.color(Role::OnSurfaceVariant)
),
buttons::filled(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
fn centered<'a>(content: impl Into<Element<'a, PageMessage>>) -> Element<'a, PageMessage> {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标, /// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标,
/// 已在报告里登记为偏离)。 /// 已在报告里登记为偏离)。
pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind { pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind {
@@ -235,6 +323,16 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
} }
} }
mod status; /// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
pub(super) fn status_body<'a>(
pub(crate) use status::status_body; ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
match &model.status {
Status::Loading => Some(loading(ui, catalog)),
Status::Empty => Some(empty(ui, catalog)),
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
Status::Ready => None,
}
}
@@ -93,39 +93,6 @@ pub(crate) fn detail<'a>(
let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3)); let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3));
// M162:可用版本(详情通道拉的;最多画 5 行——列表页负责「多」,这里只负责「有哪些」)
if model.available_versions.is_empty() {
if let Some(note) = model.versions_note.as_deref() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
note,
ui.color(Role::OnSurfaceVariant),
));
}
} else {
let count = model.available_versions.len().to_string();
let mut versions = column![frame::text_role_owned(
ui,
TypeRole::LabelMedium,
true,
catalog.render(Key::ContentDetailVersionCount, &[("count", count.as_str())]),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X1));
for version in model.available_versions.iter().take(5) {
versions = versions.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
version.line.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
body = body.push(versions);
}
// 目标实例:一行 chip(选中的那个高亮) // 目标实例:一行 chip(选中的那个高亮)
if model.targets.is_empty() { if model.targets.is_empty() {
body = body.push(frame::text_role( body = body.push(frame::text_role(
@@ -23,11 +23,7 @@ use super::{intent, sort_label};
use crate::pages::frame; use crate::pages::frame;
use crate::pages::Message as PageMessage; use crate::pages::Message as PageMessage;
/// 来源 + 排序 + 版本 + 加载器四组 chip(**每组一行**,组与组之间纵向排)。 /// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。
///
/// 组内是一行 `row`([`wrapped_row`]):**不做自动换行**——屏幕太窄时 chip 会被裁掉,
/// 而不是折到第二行(`row` 没有 wrap 能力)。这是既定行为,别把这条注释读成「会自动换行」;
/// 宽度由列表列下限 424([`super::layout::compact_min`])兜住,正常窗口下不会撞到。
pub(crate) fn filters<'a>( pub(crate) fn filters<'a>(
ui: &'a Ui, ui: &'a Ui,
catalog: Catalog, catalog: Catalog,
@@ -1,200 +0,0 @@
//! 内容库页的**状态层**:加载 / 空 / 失败 / 未联网 / 状态分派。
//!
//! 从 `parts.rs` 拆出(M161 合入后那份越过了 400 行;状态层本来就是「一屏只有一个」的
//! 独立东西,拆开后 `parts.rs` 只剩筛选条与列表)。四态各一支,判据在
//! `model::Status`,这里只负责画。
//!
//! **未联网(M162)与失败是两屏**:前者说「要么联网、要么真的没有」,后者说「拉取失败了」。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::{empty_state, EmptyState};
use crate::theme::Ui;
use super::super::model::{Content, Status};
use super::intent;
use crate::pages::content::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
pub(crate) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(
column![
frame::text_role_centered(
ui,
TypeRole::TitleMedium,
true,
catalog.text(Key::ContentLoading),
ui.color(Role::OnSurface)
),
frame::text_role_centered(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentLoadingHint),
ui.color(Role::OnSurfaceVariant)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。
pub(crate) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(empty_state(
ui,
EmptyState::new(
IconKind::Search,
catalog.text(Key::ContentEmptyTitle),
catalog.text(Key::ContentEmptyBody),
)
.action(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry),
),
))
}
/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。
pub(crate) fn failed<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentFailedTitle),
ui.color(Role::Error)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentFailedBody),
ui.color(Role::OnSurfaceVariant)
),
buttons::filled(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
fn centered<'a>(content: impl Into<Element<'a, PageMessage>>) -> Element<'a, PageMessage> {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。
pub(crate) fn offline<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(
IconKind::Warn,
ui.color(Role::OnSurfaceVariant),
frame::ICON_PX
),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentOfflineTitle),
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentOfflineBody),
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh),
),
]
.spacing(ui.space(Space::X2))
.align_x(Alignment::Center),
)
}
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
pub(crate) fn status_body<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
match &model.status {
Status::Loading => Some(loading(ui, catalog)),
Status::Empty => Some(empty(ui, catalog)),
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
// M162:未联网不是「没有结果」,也不是普通失败——单独一屏(标题/正文/图标都不同)
Status::Offline(reason) => Some(offline(ui, catalog, reason.as_str())),
Status::Ready => None,
}
}
+9 -42
View File
@@ -49,17 +49,12 @@ pub(super) fn view<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
model: &'a Content, model: &'a Content,
lang: unlucky_i18n::Lang, lang: unlucky_i18n::Lang,
window_height: f32,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let catalog = lang.catalog(); let catalog = lang.catalog();
// 正文可用高 → 版面两档(`Split`:列表自己滚;`ScrollWhole`:主列整列一个滚动条)。
// 门槛与判据在 [`content_layout`] 里,那里有用例钉住「不算拍脑袋的数」。
let body_height = frame::body_height(ui, metrics, window_height, true);
let mode = content_layout::BodyMode::of(body_height);
let header = Header::new(catalog.text(Key::ContentTitle)) let header = Header::new(catalog.text(Key::ContentTitle))
.subtitle(catalog.text(Key::ContentSubtitle)) .subtitle(catalog.text(Key::ContentSubtitle))
.action(parts::section_switcher(ui, catalog, true)); .action(parts::section_switcher(ui, catalog, true));
frame::page(ui, metrics, header, body(ui, metrics, catalog, model, mode)) frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
} }
fn body<'a>( fn body<'a>(
@@ -67,7 +62,6 @@ fn body<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
catalog: Catalog, catalog: Catalog,
model: &'a Content, model: &'a Content,
mode: content_layout::BodyMode,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let rails = content_layout::resolve(metrics.available); let rails = content_layout::resolve(metrics.available);
@@ -81,14 +75,14 @@ fn body<'a>(
let main = column![ let main = column![
kind_rail(ui, catalog, model, rails), kind_rail(ui, catalog, model, rails),
divider::full(ui), divider::full(ui),
main_column(ui, catalog, model, rails, mode) main_column(ui, catalog, model, rails)
] ]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill); .height(Length::Fill);
columns = row![main].width(Length::Fill).height(Length::Fill); columns = row![main].width(Length::Fill).height(Length::Fill);
} else { } else {
columns = columns.push(main_column(ui, catalog, model, rails, mode)); columns = columns.push(main_column(ui, catalog, model, rails));
if rails.tier.has_detail() { if rails.tier.has_detail() {
columns = columns.push(detail_column(ui, catalog, model, rails)); columns = columns.push(detail_column(ui, catalog, model, rails));
} }
@@ -157,19 +151,11 @@ fn kind_rail<'a>(
} }
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。 /// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
///
/// `mode` 决定**谁滚**(M161,判据在 `content_layout::BodyMode`):
///
/// * `Split`:列表自己滚(筛选条常驻,高窗口的常态);
/// * `ScrollWhole`:矮窗口——主列(含搜索条与筛选)整列进**一个**纵向滚动区,列表按自然高度铺开。
/// 这样一行 `ListItem` 不会被压成半行(那看起来就是「折行挤压」),也不会出现「列表里一个滚动条、
/// 外层又一个」的双滚动。
fn main_column<'a>( fn main_column<'a>(
ui: &'a Ui, ui: &'a Ui,
catalog: Catalog, catalog: Catalog,
model: &'a Content, model: &'a Content,
rails: Rails, rails: Rails,
mode: content_layout::BodyMode,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排 // 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
// 溢出 32px(详情列被切掉一半,复审截图上正是这条) // 溢出 32px(详情列被切掉一半,复审截图上正是这条)
@@ -192,15 +178,10 @@ fn main_column<'a>(
} else { } else {
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0, // **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。 // 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
let mut list = column![parts::list(ui, catalog, model, rails, mode)] let mut list = column![parts::list(ui, catalog, model, rails)]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.width(Length::Fill); .width(Length::Fill)
// 列表高度:`Split` 吃掉剩余高度(自己滚);`ScrollWhole` 按内容铺开(外层滚)。 .height(Length::Fill);
list = if mode.list_scrolls() {
list.height(Length::Fill)
} else {
list.height(Length::Shrink)
};
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高) // 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
if !rails.tier.has_detail() { if !rails.tier.has_detail() {
list = list list = list
@@ -210,11 +191,10 @@ fn main_column<'a>(
list.push(parts::more(ui, catalog, model)).into() list.push(parts::more(ui, catalog, model)).into()
}; };
// `ScrollWhole` 档:主列整列一个滚动区(列表按自然高度)。**只有这一处**加滚动条,
// 不在列表里再套一个(双滚动是「到处加滚动条糊」的典型症状)。
let mut column = column![] let mut column = column![]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.width(Length::Fixed(rails.list)); .width(Length::Fixed(rails.list))
.height(Length::Fill);
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。 // M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。 // 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
@@ -224,20 +204,7 @@ fn main_column<'a>(
.push(divider::full(ui)); .push(divider::full(ui));
} }
column = column.push(head).push(body); column.push(head).push(body).into()
if mode.list_scrolls() {
return column.height(Length::Fill).into();
}
iced::widget::scrollable(column.height(Length::Shrink))
.direction(iced::widget::scrollable::Direction::Vertical(
iced::widget::scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
} }
/// 详情列(只在 `Wide` / `Narrow` 存在)。 /// 详情列(只在 `Wide` / `Narrow` 存在)。
@@ -295,8 +295,6 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
speed_bps: if failed { 0 } else { 1_258_291 }, speed_bps: if failed { 0 } else { 1_258_291 },
eta_secs: if failed { 0 } else { 75 }, eta_secs: if failed { 0 } else { 75 },
message: String::new(), message: String::new(),
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
failure_kind: None,
}, },
catalog, catalog,
) )
-13
View File
@@ -114,19 +114,6 @@ pub fn page<'a>(
.into() .into()
} }
/// 页面正文的**可用高度**(窗口高 − 自绘标题栏 − 页头带 − 上下页边距)。
///
/// M161:内容库要靠它决定「矮窗口换版面」。三个被减掉的量都取自 [`page`]/[`header_bar`]
/// 用的**同一个**来源(`Ui::metrics` 与 [`FrameMetrics::gutter`]),不另抄字面量——
/// 谁改了页头高,这里跟着变。
#[must_use]
pub fn body_height(ui: &Ui, metrics: FrameMetrics, window_height: f32, has_subtitle: bool) -> f32 {
let chrome = ui.metrics().titlebar_height
+ ui.metrics().header_height(has_subtitle)
+ 2.0 * metrics.gutter;
(window_height - chrome).max(0.0)
}
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> { fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
let spec = Component::TopAppBar.spec(); let spec = Component::TopAppBar.spec();
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant // 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
-46
View File
@@ -99,49 +99,3 @@ fn two_pane_body_is_narrower_than_the_available_width() {
assert!(wide.column < wide.available); assert!(wide.column < wide.available);
assert!(wide.secondary.is_some()); assert!(wide.secondary.is_some());
} }
/// **`body_height` 用真的 `Ui::metrics` + `FrameMetrics` 算**(M161 复审 P2-2:这条函数此前
/// 一条测试都没有,而④的「最小窗口」用例是拿 56/88/32 反推的——三个数全错)。
///
/// 抓得住:①`titlebar_height` / `header_height` / `gutter` 任何一个被改(chrome 变了,
/// 下面的等式立刻红);②把两行页头当单行算(`has_subtitle` 被忽略);③把页边距只减一遍。
#[test]
fn body_height_subtracts_the_real_shell_chrome() {
let ui = ui();
let metrics = frame_metrics(PaneKit::Single, 880.0);
let metrics_wide = frame_metrics(PaneKit::Single, 1180.0);
let chrome = |two_line: bool| {
ui.metrics().titlebar_height + ui.metrics().header_height(two_line) + 2.0 * metrics.gutter
};
// 真值来自 token:自绘标题栏 48 + 两行页头 84 + 上下页边距 2×24 = 180。
assert_eq!(
chrome(true),
48.0 + 84.0 + 2.0 * 24.0,
"chrome 与 design token 不一致(titlebar_height / page_header_tall_height / Expanded gutter)"
);
assert_eq!(body_height(&ui, metrics, 560.0, true), 560.0 - chrome(true));
// 单行页头少 28(84 − 56 = `page_header_tall_height` − `page_header_height`)。
assert_eq!(
body_height(&ui, metrics, 560.0, false),
560.0 - chrome(false)
);
assert!(
body_height(&ui, metrics, 560.0, true) < body_height(&ui, metrics, 560.0, false),
"带副标题的页头更高,正文可用高必须更小"
);
// 窄窗(Compact < 600)页边距更小 ⇒ 同样窗口高下正文更高。
let compact = frame_metrics(PaneKit::Single, 560.0);
assert_eq!(compact.class, crate::pages::layout::WidthClass::Compact);
assert!(
compact.gutter < metrics_wide.gutter,
"Compact 档的页边距应小于 Expanded(16 < 24)"
);
assert!(
body_height(&ui, compact, 560.0, true) > body_height(&ui, metrics_wide, 560.0, true),
"页边距变小 ⇒ 正文可用高变大"
);
// 窗口比外壳还矮时不返回负数(不许把版面算成 NaN/负值)。
assert_eq!(body_height(&ui, metrics, 100.0, true), 0.0);
}
+1 -5
View File
@@ -62,7 +62,6 @@ use crate::renderer;
use crate::shot; use crate::shot;
use crate::theme::Ui; use crate::theme::Ui;
use super::instances;
use super::{app_with_appearance, demo_model, Model}; use super::{app_with_appearance, demo_model, Model};
mod app; mod app;
@@ -119,8 +118,6 @@ struct Harness {
model: Model, model: Model,
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。 /// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
appearance: Option<appearance::State>, appearance: Option<appearance::State>,
/// 实例页「启动参数」区的表单(M157):harness 没有 `App`,自己持有演示表单。
form: instances::LaunchForm,
window: Option<window::Id>, window: Option<window::Id>,
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读, /// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。 /// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
@@ -311,8 +308,7 @@ impl Harness {
} }
fn view(&self) -> Element<'_, Message> { fn view(&self) -> Element<'_, Message> {
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref(), &self.form) app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
.map(Message::Page)
} }
} }
+1 -10
View File
@@ -106,14 +106,6 @@ pub fn run(request: Request) -> Result<(), String> {
request.flavor, request.flavor,
); );
state.apply_env(); state.apply_env();
// M159:`ULL_SETTINGS_GROUP=<下标>` 在 harness 里也生效(产品路径走
// `app/state/construct.rs` 的同一条判据)。没有它就只能拍默认的「General」组,
// 而设置页有五组——「更新」组的验收图正是靠它拍出来的。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(request.screenshot.is_some())
{
state.selected_group = index;
}
eprintln!( eprintln!(
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \ "[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
配色来源={:?} 选中分组={}", 配色来源={:?} 选中分组={}",
@@ -140,7 +132,7 @@ pub fn run(request: Request) -> Result<(), String> {
let written = Arc::clone(&written); let written = Arc::clone(&written);
iced::application( iced::application(
move || { move || {
let mut model = demo_model(boot.screen, boot.width, boot.height); let mut model = demo_model(boot.screen, boot.width);
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot` // 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced), // 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。 // 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
@@ -152,7 +144,6 @@ pub fn run(request: Request) -> Result<(), String> {
ui: Ui::new(boot.flavor, families), ui: Ui::new(boot.flavor, families),
model, model,
appearance: appearance.clone(), appearance: appearance.clone(),
form: crate::pages::demo_launch_form(),
request: boot.clone(), request: boot.clone(),
window: None, window: None,
hwnd: None, hwnd: None,
+4 -12
View File
@@ -48,8 +48,6 @@
mod data; mod data;
mod demo; mod demo;
mod detail; mod detail;
mod launch_args;
mod state;
mod text; mod text;
#[cfg(test)] #[cfg(test)]
@@ -57,8 +55,7 @@ mod tests;
#[cfg(test)] #[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED}; pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(crate) use demo::{demo, demo_form}; pub(super) use demo::demo;
pub use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts; pub(super) use text::Texts;
use iced::widget::{column, container, row, scrollable}; use iced::widget::{column, container, row, scrollable};
@@ -100,12 +97,7 @@ pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
/// ///
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口, /// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。 /// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
pub fn view<'a>( pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Model,
form: &'a LaunchForm,
) -> Element<'a, Message> {
let block = &model.instances; let block = &model.instances;
let forced = model.forced; let forced = model.forced;
let texts = Texts::of(model.lang.catalog()); let texts = Texts::of(model.lang.catalog());
@@ -131,12 +123,12 @@ pub fn view<'a>(
metrics, metrics,
list_column(ui, block, forced, texts), list_column(ui, block, forced, texts),
match &block.detail { match &block.detail {
Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts), Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
None => detail::detail_empty(ui, texts), None => detail::detail_empty(ui, texts),
}, },
), ),
_ => match &block.detail { _ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts), Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
None => frame::body(metrics, detail::detail_empty(ui, texts)), None => frame::body(metrics, detail::detail_empty(ui, texts)),
}, },
}; };
+1 -62
View File
@@ -4,11 +4,9 @@
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。 //! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::LaunchArgsView;
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row}; use super::super::model::{Detail, Instances, Meta, Row};
use super::data::{DEMO, NOTE, SELECTED}; use super::data::{DEMO, NOTE, SELECTED};
use super::state::LaunchForm;
/// 演示路径的目录:**恒英文**。 /// 演示路径的目录:**恒英文**。
/// ///
@@ -53,7 +51,6 @@ pub(crate) fn demo() -> Instances {
status: None, status: None,
can_launch: true, can_launch: true,
can_stop: false, can_stop: false,
launch: launch_facts(),
}; };
Instances { Instances {
@@ -68,61 +65,3 @@ fn meta(label: &str, value: &str) -> Meta {
value: value.to_string(), value: value.to_string(),
} }
} }
/// 启动参数区的演示事实(M157 的 D30 出图夹具)。
///
/// `ULL_LAUNCH_ARGS_FIXTURE=gated` 出「版本不够、三态灰化」那一屏;缺省(`open`)是
/// 「26.2 实例 + Vulkan 可选」的正常态。**这是出图夹具,不是产品逻辑**:产品路径的门槛
/// 来自 presenter 快照(core 的 `graphics_backend_allowed`),与这个环境变量无关。
pub(crate) fn launch_facts() -> LaunchFacts {
let gated = gated_fixture();
LaunchFacts {
allowed: !gated,
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
min_version: "26.2-snapshot-2".to_string(),
memory_effective_mb: if gated { 2048 } else { 4096 },
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。
///
/// 返回**拥有**的表单而不是 `&'static`:出图/单测各自持有一份即可(一帧借用一次),
/// 而 `static` 会让所有出图路径共享同一份可编辑状态(两个参数的控件现在都是单行
/// `field::text_field` + `String`,不再是早先的多行编辑器)。
pub(crate) fn demo_form() -> LaunchForm {
LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view())
}
fn demo_view() -> LaunchArgsView {
let gated = gated_fixture();
LaunchArgsView {
instance_id: DEMO[SELECTED].name.to_string(),
jvm_args: "-XX:+UseG1GC".to_string(),
game_args: "--fullscreen".to_string(),
memory_mb: (!gated).then_some(4096),
memory_effective_mb: if gated { 2048 } else { 4096 },
java_path: String::new(),
java_effective: "C:/Java/temurin-21/bin/java.exe".to_string(),
graphics_backend: if gated { "default" } else { "vulkan" }.to_string(),
graphics_backend_effective: if gated { "default" } else { "vulkan" }.to_string(),
graphics_backend_allowed: !gated,
mc_version: if gated { "1.20.4" } else { "26.2-snapshot-2" }.to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
fn gated_fixture() -> bool {
std::env::var("ULL_LAUNCH_ARGS_FIXTURE")
.map(|value| value.trim().eq_ignore_ascii_case("gated"))
.unwrap_or(false)
}
+5 -39
View File
@@ -4,8 +4,8 @@
//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。 //! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。
//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。 //! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。
use iced::widget::{column, container, row, scrollable}; use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length, Padding}; use iced::{Alignment, Element, Length};
use unlucky_design::color::Role; use unlucky_design::color::Role;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
@@ -23,7 +23,6 @@ use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail; use super::super::model::Detail;
use super::super::{Action, Message}; use super::super::{Action, Message};
use super::forced_state; use super::forced_state;
use super::state::LaunchForm;
use super::Texts; use super::Texts;
// --------------------------------------------------------------- 详情列 // --------------------------------------------------------------- 详情列
@@ -32,7 +31,6 @@ pub(super) fn detail_column<'a>(
ui: &'a Ui, ui: &'a Ui,
metrics: FrameMetrics, metrics: FrameMetrics,
detail: &'a Detail, detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>, forced: Option<Interaction>,
texts: Texts, texts: Texts,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -45,23 +43,15 @@ pub(super) fn detail_column<'a>(
.width(Length::Fill); .width(Length::Fill);
body = push_text(ui, metrics, body, detail); body = push_text(ui, metrics, body, detail);
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)。 // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
// frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
// M157 r2(D30 复审):详情列的内容(正文段 + 启动参数卡 + 动作行)在 1180×1000 下**超出一屏**,
// 而这里原来没有滚动区 ⇒ Max memory 以下的控件被静默裁掉、够不着。现在整列套一个滚动区
// (与左列列表同一套滚动条样式与宽度),被裁内容可达;滚动条是唯一入口,没有第二份状态。
let content = body.push(action_row(ui, forced, texts));
frame::left_aligned(metrics.column, scroller(ui, content))
} }
pub(super) fn narrow_detail<'a>( pub(super) fn narrow_detail<'a>(
ui: &'a Ui, ui: &'a Ui,
metrics: FrameMetrics, metrics: FrameMetrics,
detail: &'a Detail, detail: &'a Detail,
form: &'a LaunchForm,
forced: Option<Interaction>, forced: Option<Interaction>,
texts: Texts, texts: Texts,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -84,7 +74,6 @@ pub(super) fn narrow_detail<'a>(
.width(Length::Fill); .width(Length::Fill);
body = push_text(ui, metrics, body, detail); body = push_text(ui, metrics, body, detail);
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role( body = body.push(frame::text_role(
ui, ui,
@@ -94,30 +83,7 @@ pub(super) fn narrow_detail<'a>(
ui.color(Role::OnSurfaceVariant), ui.color(Role::OnSurfaceVariant),
)); ));
frame::body( frame::body(metrics, body.push(action_row(ui, forced, texts)))
metrics,
scroller(ui, body.push(action_row(ui, forced, texts))),
)
}
/// 详情列的滚动区(M157 r2):与左列列表同一套滚动条几何(宽 6 / 内缩 2 / 同一 `style`)。
///
/// 右侧留 `Space::X2` 内缩:滚动条是**覆盖**在内容上的,不留这条缝时卡片的 1dp 描边会被
/// 滚动条压住(D30 复审里「卡片右边框缺一截」就是它)。
fn scroller<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let content = container(content)
.width(Length::Fill)
.padding(Padding::default().right(ui.space(Space::X2)));
scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
} }
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 /// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
@@ -1,233 +0,0 @@
//! 实例详情页的「启动参数」区(M157)。
//!
//! | 控件 | 语义 | 消息 |
//! |---|---|---|
//! | JVM 参数(多行) | 实例层,与全局 `java.extra_args` **叠加**(实例段最高优先级) | [`Message::Jvm`] |
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时只灰化后两档**(`default` 始终可点)+ 提示 | [`Message::Backend`] |
//!
//! 两条判据刻意不在这里重算:
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
//! presenter 的 host 已经算好)——UI 不复制版本号比较;
//! 2. **生效值**(内存/Java/后端)同样来自 facts,表单里画的是实例层原文。
//!
//! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。
use iced::widget::{column, row};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::model::LaunchFacts;
use super::super::Message as PageMessage;
use super::state::{LaunchForm, Message};
use super::Texts;
/// 页内消息 → 页面消息(本文件的控件树统一画 `pages::Message`)。
fn page(message: Message) -> PageMessage {
PageMessage::Instances(message)
}
/// 「启动参数」区(在详情列的正文段之后、动作行之前)。
pub(super) fn section<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
form: &'a LaunchForm,
texts: Texts,
) -> Element<'a, PageMessage> {
// 段间距 X2(8dp):卡内 5 个字段 + 三态 + 动作行在 424 宽下已经很长,
// 用 X3 会让详情列更长一屏;行内间距仍是 X1(4dp),读起来不挤。
let mut body = column![frame_title(ui, texts.launch_title())]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
body = body.push(args_field(
ui,
texts.launch_jvm(),
texts.launch_jvm_hint(),
form.jvm.as_str(),
|value| page(Message::Jvm(value)),
));
body = body.push(args_field(
ui,
texts.launch_game(),
texts.launch_game_hint(),
form.game.as_str(),
|value| page(Message::Game(value)),
));
body = body.push(text_field_with_hint(
ui,
texts.launch_memory(),
form.memory.as_str(),
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_memory_hint(facts.memory_effective_mb),
ui.color(Role::OnSurfaceVariant),
),
|value| page(Message::Memory(value)),
));
body = body.push(text_field_with_hint(
ui,
texts.launch_java(),
form.java.as_str(),
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_java_hint().to_string(),
ui.color(Role::OnSurfaceVariant),
),
|value| page(Message::Java(value)),
));
body = body.push(backend_row(ui, facts, form, texts));
body = body.push(actions_row(ui, texts));
// 与相邻信息卡**同一套**卡壳(`frame::card`:`card_padding` 21dp + OutlinedCard 的圆角/描边)。
super::super::frame::card(ui, body)
}
/// 卡片标题:**与信息卡的标签同款**(`LabelMedium` 不加粗、`OnSurfaceVariant`),
/// 复审 ③⑤ 抓的就是这里(原来用了 `emphasized = true`,比同屏其它标签重一档)。
fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> {
super::super::frame::text_role(
ui,
TypeRole::LabelMedium,
false,
title,
ui.color(Role::OnSurfaceVariant),
)
}
/// 一段参数输入 + 一行提示(单行受控输入;参数按空白切分,与 core 的 `tokenize` 同口径)。
fn args_field<'a>(
ui: &'a Ui,
label: &'a str,
hint: &'a str,
value: &'a str,
on_input: impl Fn(String) -> PageMessage + 'a,
) -> Element<'a, PageMessage> {
column![
text_field(ui, Field::new(value).label(label), on_input),
super::super::frame::text_role(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 单行输入 + 一行提示(提示是**拥有的**字符串,所以不能走 `Field::supporting` 的借用口径)。
fn text_field_with_hint<'a>(
ui: &'a Ui,
label: &'a str,
value: &'a str,
hint: Element<'a, PageMessage>,
on_input: impl Fn(String) -> PageMessage + 'a,
) -> Element<'a, PageMessage> {
column![
text_field(ui, Field::new(value).label(label), on_input),
hint,
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 三态后端的「不下发」档(`GraphicsBackend::Default` 的稳定 id)。
///
/// UI 侧不依赖 core(分层:UI → presenter → core),所以在页面里落一个常量;
/// 词表本身仍来自 presenter 快照([`LaunchFacts::options`]),这里只认「哪一个是安全档」。
const BACKEND_DEFAULT: &str = "default";
/// 某一档 chip 在门限不满足时**是否仍可点**。
///
/// 抽成纯函数是为了让它可断言(`Element` 没有内省接口,画出来的禁用态测不到;
/// 见 `instances/tests/launch_args.rs`)。
pub(crate) fn backend_chip_enabled(allowed: bool, id: &str) -> bool {
allowed || id == BACKEND_DEFAULT
}
/// 三态后端:**版本不够时只灰掉 `opengl` / `vulkan`**,`default` 始终可点。
///
/// 为什么 `default` 不许一起灰:它是「不下发 `--graphicsBackend`」的安全档,写它永远不会被
/// core 的版本门限拒绝。一起灰掉的话,用户手改实例文件、或在 CLI 里给旧版本实例写下
/// `graphicsBackend: vulkan` 之后,UI 上就**回不到 `default`**——唯一的出路是「恢复跟随全局」,
/// 而那会连带清空 JVM / 游戏参数 / 内存 / Java 四项(M157 复审 P3)。
fn backend_row<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
form: &'a LaunchForm,
texts: Texts,
) -> Element<'a, PageMessage> {
let allowed = facts.allowed;
let mut chips = row![].spacing(ui.space(Space::X2));
for id in &facts.options {
let label = texts.backend_label(id).unwrap_or(id.as_str());
let selected = form.backend == *id;
let spec = Chip::filter(label, selected, page(Message::Backend(id.clone())));
let spec = if backend_chip_enabled(allowed, id) {
spec
} else {
spec.disabled()
};
chips = chips.push(chip(ui, spec));
}
let mut meta = column![
super::super::frame::text_role(
ui,
TypeRole::BodySmall,
false,
texts.launch_backend(),
ui.color(Role::OnSurface),
),
chips,
super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_effective(&facts.backend_effective),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !allowed {
// 灰化 + 明确提示:既不说「已生效」,也不静默吞掉用户的选择。
meta = meta.push(super::super::frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
texts.launch_backend_gated(&facts.mc_version, &facts.min_version),
ui.color(Role::Error),
));
}
meta.into()
}
fn actions_row<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, PageMessage> {
row![
buttons::filled(ui, Button::new(texts.launch_save(), page(Message::Save))),
buttons::text(ui, Button::new(texts.launch_reset(), page(Message::Reset))),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -1,291 +0,0 @@
//! 实例页的**页面状态**(M157):启动参数区的表单。
//!
//! ## 为什么状态在 app、页面只借一帧
//!
//! iced 的 `view` 不能持有跨帧状态,而启动参数区是可编辑的(两个多行框 + 两行单行框 +
//! 三态 chips)。所以表单状态的所有者是 `App`(`app/state.rs` 的 `launch_form` 字段),
//! 页面层只在这一帧借用;这与设置页「组状态归 `App`」是同一条纪律(`pages/settings/state.rs`)。
//!
//! ## 与快照的关系(唯一的重播种点)
//!
//! [`LaunchForm::sync`] 由 `App::refresh_view` 调用:
//!
//! | 情况 | 行为 |
//! |---|---|
//! | 换了一个实例 | **重播种**(用户没写完的草稿不能串到别的实例上) |
//! | 同一个实例、没改过 | 重播种(保存/外部改了配置后跟着刷新) |
//! | 同一个实例、改过(`dirty`) | **不覆盖**(每来一条事件就把输入抹掉是不可接受的) |
//!
//! 「留空 = 跟随下一层」由提交时把空串原样交给 host 表达(`Some("")` = 清空),
//! 表单自己不做任何回落计算——回落只发生在 core。
use unlucky_presenter::dto::{LaunchArgsPatch, LaunchArgsView};
/// 启动参数表单(五项 + 脏标记 + 绑定的实例 id)。
#[derive(Debug)]
pub struct LaunchForm {
/// 这份草稿属于哪个实例(`None` = 还没播种)。
pub bound: Option<String>,
/// 实例级 JVM 参数(空白分隔的文本)。
pub jvm: String,
/// 实例级游戏参数(空白分隔的文本)。
pub game: String,
/// 最大堆(文本形态,空 = 跟随全局)。
pub memory: String,
/// Java 可执行文件(空 = 跟随全局 / 自动挑)。
pub java: String,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: String,
/// 用户改过、还没保存。
pub dirty: bool,
}
impl Default for LaunchForm {
fn default() -> Self {
Self::from_view(None, &LaunchArgsView::default())
}
}
impl LaunchForm {
/// 用一份快照播种(`bound` 为 `None` = 还没绑定实例)。
#[must_use]
pub fn from_view(bound: Option<&str>, view: &LaunchArgsView) -> Self {
Self {
bound: bound.map(str::to_string),
jvm: view.jvm_args.clone(),
game: view.game_args.clone(),
memory: view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(),
java: view.java_path.clone(),
backend: view.graphics_backend.clone(),
dirty: false,
}
}
/// 这份草稿是否正对着该实例。
#[must_use]
pub fn bound_to(&self, id: &str) -> bool {
self.bound.as_deref() == Some(id)
}
/// 重播种(见模块文档的三档)。
///
/// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件
/// (内存写了 `4g`、实例文件写不进去),那时 pump 会带着**旧快照**重建视图——如果这里无条件
/// 覆盖,用户刚敲的东西就没了(M157 r3 复审 P2-2)。判据是「快照已经等于表单」:
/// 相等 ⇒ 这次保存落了盘,清脏并重播种;不相等 ⇒ 保留草稿等用户改。
pub fn sync(&mut self, view: &LaunchArgsView) {
let same_instance = self.bound_to(&view.instance_id);
if same_instance && self.dirty {
if self.matches(view) {
self.dirty = false;
*self = Self::from_view(Some(&view.instance_id), view);
}
return;
}
*self = Self::from_view(Some(&view.instance_id), view);
}
/// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。
///
/// **内存按数值比**:host 会把 `"04096"` / `"+4096"` 归一成 `4096` 落盘,回程快照给的是
/// `Some(4096)`——按文本比会永远不等,于是表单一直脏、也不再跟随外部改动(r4 复审 P3-b)。
/// 解析失败(用户正在敲半截)才退回文本口径。
/// 其余字段直接比原文(JVM/游戏参数由 core 的 `tokenize` 消费,不做归一)。
#[must_use]
pub fn matches(&self, view: &LaunchArgsView) -> bool {
self.jvm.trim() == view.jvm_args.trim()
&& self.game.trim() == view.game_args.trim()
&& self.memory_matches(view.memory_mb)
&& self.java.trim() == view.java_path.trim()
&& self.backend == view.graphics_backend
}
/// 内存字段是否等于快照里的值(数值优先,解析失败退文本)。
fn memory_matches(&self, persisted: Option<u32>) -> bool {
let typed = self.memory.trim();
match (typed.parse::<u32>(), persisted) {
(Ok(typed), Some(persisted)) => typed == persisted,
// 空输入 = 跟随全局:快照那边也应当是「没有实例覆盖」。
(Err(_), None) => typed.is_empty(),
_ => false,
}
}
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
#[must_use]
pub fn patch(&self) -> LaunchArgsPatch {
LaunchArgsPatch {
jvm_args: Some(self.jvm.clone()),
game_args: Some(self.game.clone()),
memory_mb: Some(self.memory.clone()),
java_path: Some(self.java.clone()),
graphics_backend: Some(self.backend.clone()),
}
}
/// 「恢复跟随全局」:五项清空,脏标记置位(等用户点保存)。
pub fn reset(&mut self) {
self.jvm.clear();
self.game.clear();
self.memory.clear();
self.java.clear();
self.backend = "default".to_string();
self.dirty = true;
}
}
/// 启动参数区的消息(`pages::Message::Instances` 的载荷)。
///
/// 只表达**页面意图**:编辑写进表单,保存/恢复才需要 app 层理解(前者 dispatch 一条
/// `AppCommand::SetInstanceLaunchArgs`,后者只是清表单)。
#[derive(Debug, Clone)]
pub enum Message {
/// 编辑 JVM 参数。
Jvm(String),
/// 编辑游戏参数。
Game(String),
/// 编辑最大内存(单行文本)。
Memory(String),
/// 编辑 Java 路径(单行文本)。
Java(String),
/// 选图形后端(三态 chips)。
Backend(String),
/// 保存(派发命令)。
Save,
/// 恢复跟随全局(清表单,仍需保存)。
Reset,
}
#[cfg(test)]
mod tests {
use super::*;
fn view(instance: &str, jvm: &str, memory: Option<u32>, backend: &str) -> LaunchArgsView {
LaunchArgsView {
instance_id: instance.to_string(),
jvm_args: jvm.to_string(),
game_args: String::new(),
memory_mb: memory,
memory_effective_mb: memory.unwrap_or(2048),
java_path: String::new(),
java_effective: String::new(),
graphics_backend: backend.to_string(),
graphics_backend_effective: backend.to_string(),
graphics_backend_allowed: true,
mc_version: "26.2".to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: vec![
"default".to_string(),
"opengl".to_string(),
"vulkan".to_string(),
],
}
}
/// 换实例必重播种;同实例的脏草稿不被事件冲掉。
#[test]
fn sync_reseeds_per_instance_but_keeps_dirty_edits() {
let mut form = LaunchForm::default();
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.jvm, "-Xss4m");
assert_eq!(form.memory, "4096");
assert!(!form.dirty);
// 用户改动 → 脏 → 同实例的快照刷新不许覆盖。
form.memory = "8192".to_string();
form.dirty = true;
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.memory, "8192", "脏草稿被事件冲掉了");
// 换实例 → 重播种(草稿不串台)。
form.sync(&view("b", "", None, "default"));
assert_eq!(form.memory, "");
assert!(!form.dirty);
assert!(form.bound_to("b"));
}
/// **保存失败不许吃掉草稿**(M157 r3 复审 P2-2):脏表单 + 旧快照回来 ⇒ 原样保留。
///
/// 抓得住:`sync` 在「同实例 + 脏」时无条件重播种(用户敲的 `4g` 被磁盘上的 4096 覆盖)。
/// 抓不住:真实事件时序(那在 `app/update/instances.rs` 的用例里按消息顺序模拟)。
#[test]
fn failed_save_keeps_the_draft() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
// 用户把内存改成非法值(host 会拒),此时表单是脏的。
form.memory = "4g".to_string();
form.jvm = "-Xmx3g".to_string();
form.dirty = true;
// 回程事件带来的仍是**旧**快照(保存失败 ⇒ 盘上没变)。
form.sync(&view("a", "", Some(4096), "default"));
assert_eq!(form.memory, "4g", "失败后的重播把用户正在改的内存吃掉了");
assert_eq!(form.jvm, "-Xmx3g");
assert!(form.dirty, "草稿还在,脏标记也不能掉");
}
/// **保存成功要清脏**:回程快照等于表单 ⇒ 这一步算落盘确认,之后可以跟着外部变化重播种。
#[test]
fn confirmed_save_clears_dirty_and_reseeds() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = "8192".to_string();
form.backend = "vulkan".to_string();
form.dirty = true;
form.sync(&view("a", "", Some(8192), "vulkan"));
assert!(!form.dirty, "盘上已经是这份值了,脏标记该清");
assert_eq!(form.memory, "8192");
// 之后外部改了配置(比如 CLI 写入)⇒ 干净的表格跟着更新。
form.sync(&view("a", "-Xss4m", Some(2048), "opengl"));
assert_eq!(form.memory, "2048");
assert_eq!(form.jvm, "-Xss4m");
}
/// **内存按数值比**(r4 复审 P3-b):`04096` / `+4096` 落盘后回程是 `4096`,必须判等。
///
/// 抓得住:`matches` 退回文本比较——那样这两类输入会永远「没保存成功」,表单一直脏、
/// 之后外部改配置也不再重播种。
#[test]
fn memory_is_compared_by_value_not_by_text() {
for typed in ["04096", "+4096", " 4096 "] {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = typed.to_string();
form.dirty = true;
assert!(
form.matches(&view("a", "", Some(4096), "default")),
"`{typed}` 应判等"
);
form.sync(&view("a", "", Some(4096), "default"));
assert!(!form.dirty, "`{typed}` 保存确认后要清脏");
}
// 数值不等就是不等于;半截输入(解析失败)也不许判等。
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = "8".to_string();
assert!(!form.matches(&view("a", "", Some(4096), "default")));
form.memory = "4g".to_string();
assert!(!form.matches(&view("a", "", Some(4096), "default")));
// 清空 = 跟随全局:快照也得是「没有实例覆盖」。
form.memory.clear();
assert!(form.matches(&view("a", "", None, "default")));
}
/// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。
#[test]
fn patch_always_covers_all_five_fields() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", None, "default"));
form.memory = "6144".to_string();
form.backend = "opengl".to_string();
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some("6144"));
assert_eq!(patch.graphics_backend.as_deref(), Some("opengl"));
form.reset();
assert!(form.dirty);
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some(""));
assert_eq!(patch.graphics_backend.as_deref(), Some("default"));
}
}
@@ -4,9 +4,6 @@ use super::*;
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。 /// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
mod i18n; mod i18n;
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
mod launch_args;
use super::super::layout::source; use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -28,18 +28,14 @@ use super::super::Texts;
/// ///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的; /// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。 /// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"]; const ALLOWED: &[&str] = &["Java", "pid"];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 /// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 6] = [ const PAGE_SOURCES: [(&str, &str); 5] = [
("instances.rs", include_str!("../../instances.rs")), ("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")), ("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")), ("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.rs")), ("instances/text.rs", include_str!("../text.rs")),
(
"instances/launch_args.rs",
include_str!("../launch_args.rs"),
),
( (
"app/view/instances.rs", "app/view/instances.rs",
include_str!("../../../app/view/instances.rs"), include_str!("../../../app/view/instances.rs"),
@@ -61,42 +57,13 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 24] = [ const ALLOWED_LITERALS: [&str; 6] = [
"{date} {time} UTC", "{date} {time} UTC",
"isolated", "isolated",
"shared", "shared",
"pid", "pid",
// M161:失败行由 `LaunchStageFailed` / `InstancesFailed` / `InstancesCrashed` 渲染,
// 占位符名是 `{stage}` / `{detail}` / `{reason}` / `{code}`(占位符名不是文案);
// `{outcome}` / `{seconds}` 是收尾 Toast 的。
"stage",
"detail",
"reason", "reason",
"code",
"outcome",
"seconds",
"Instances", "Instances",
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
// `backend_label` 只认这三档,其余原样上屏。
"default",
"opengl",
"vulkan",
// M157:`Catalog::render` 的占位符名(`{version}` / `{value}` / `{default}`),不是文案。
"version",
"value",
// M157 r4:门槛文案的占位符名(版本号由 DTO 给,目录里不写死)。
"min",
// M157:`instances/demo.rs` 的**出图夹具**(用户数据的形状,不是界面文案):
// 一个示例 JVM 参数、两个示例 MC 版本、一个示例 Java 路径,以及夹具开关的环境变量名与取值。
"-XX:+UseG1GC",
// M157 r2:示例游戏参数(同上,是夹具不是文案)。
"--fullscreen",
"26.2-snapshot-2",
"1.20.4",
"C:/Java/temurin-21/bin/java.exe",
"ULL_LAUNCH_ARGS_FIXTURE",
// M161:示例「被门槛挡下」的取值(夹具)。
"gated",
]; ];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -228,23 +195,11 @@ fn the_running_line_keeps_its_number_and_separator() {
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}"); assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
} }
// 失败那一行同理(M161:`InstancesFailed` 已被 `LaunchStageFailed` 取代——「哪一步」也要翻, // 失败那一行同理:core 给的原因原文原样保留,只翻前半句
// 只有 `detail` 那段原文原样保留) let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
let args = [("stage", "Starting the game process"), ("detail", "exit 1")]; let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
let failed_en = Catalog::of(Lang::En).render(Key::LaunchStageFailed, &args); assert_eq!(failed_en, "Launch failed · exit 1");
let failed_zh = Catalog::of(Lang::ZhCn).render( assert_eq!(failed_zh, "启动失败 · exit 1");
Key::LaunchStageFailed,
&[("stage", "启动游戏进程"), ("detail", "exit 1")],
);
assert_eq!(
failed_en,
"Launch failed · Starting the game process: exit 1"
);
assert_eq!(failed_zh, "启动失败 · 启动游戏进程:exit 1");
for text in [&failed_en, &failed_zh] {
assert!(text.contains("exit 1"), "原文丢了:{text}");
}
assert_ne!(failed_en, failed_zh, "切换语言时前半句要变");
} }
/// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。 /// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。
@@ -293,13 +248,9 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
"text.rs 没有引用本族的 key" "text.rs 没有引用本族的 key"
); );
assert!( assert!(
PAGE_SOURCES[5].1.contains("Key::InstancesStage"), PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
"阶段标签没有走目录" "阶段标签没有走目录"
); );
assert!(
PAGE_SOURCES[4].1.contains("texts.launch_title()"),
"启动参数区没有走目录"
);
} }
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。 /// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
@@ -1,55 +0,0 @@
//! 「启动参数」区(M157)的**门限**判据:三态 chips 在版本不够时谁可点。
//!
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口,画出来的「禁用态」在测试里
//! 拿不到(`Chip::disabled` 只是把 `on_press` 清成 `None`)。所以把判据抽成
//! [`backend_chip_enabled`] 并在这里钉住——页面那一行必须调它(否则这条绿着也没意义,
//! 守门形状与 `instances/tests.rs` 的源码断言同一取舍)。
use super::super::launch_args::backend_chip_enabled;
use crate::pages::LaunchFacts;
/// 门限满足:三档全可点。
#[test]
fn every_backend_chip_is_clickable_when_the_gate_is_open() {
for id in ["default", "opengl", "vulkan"] {
assert!(backend_chip_enabled(true, id), "门限满足时 {id} 必须可点");
}
}
/// 门限不满足:只有 `default` 可点(它是「不下发」的安全档)。
///
/// 抓得住:把 `default` 一起灰掉(M157 复审 P3 的原状)——那时旧版本实例上写了
/// `graphicsBackend: vulkan` 的用户在 UI 里回不到 `default`,只能「恢复跟随全局」
/// (连带清空 JVM / 游戏参数 / 内存 / Java 四项)。
#[test]
fn the_default_chip_stays_clickable_when_the_gate_is_closed() {
assert!(
backend_chip_enabled(false, "default"),
"门限不满足时 default 也必须可点:它是唯一永远合法的档"
);
for id in ["opengl", "vulkan"] {
assert!(
!backend_chip_enabled(false, id),
"门限不满足时 {id} 必须灰掉(下发会被 core 拒绝)"
);
}
}
/// 词表与判据不许脱节:`default` 必须真在这份快照的 options 里,否则「安全档」无从点起。
#[test]
fn the_gate_rule_covers_the_demo_option_table() {
let facts = LaunchFacts {
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
..LaunchFacts::default()
};
assert!(
facts
.options
.iter()
.any(|id| backend_chip_enabled(false, id)),
"门限关闭时至少有一档可点(否则三态整体锁死)"
);
}
@@ -85,72 +85,6 @@ impl Texts {
self.text(Key::InstancesNarrowNote) self.text(Key::InstancesNarrowNote)
} }
// ------------------------------------------------------------- 启动参数(M157)
pub(super) fn launch_title(self) -> &'static str {
self.text(Key::InstancesLaunchTitle)
}
pub(super) fn launch_jvm(self) -> &'static str {
self.text(Key::InstancesLaunchJvm)
}
pub(super) fn launch_jvm_hint(self) -> &'static str {
self.text(Key::InstancesLaunchJvmHint)
}
pub(super) fn launch_game(self) -> &'static str {
self.text(Key::InstancesLaunchGame)
}
pub(super) fn launch_game_hint(self) -> &'static str {
self.text(Key::InstancesLaunchGameHint)
}
pub(super) fn launch_memory(self) -> &'static str {
self.text(Key::InstancesLaunchMemory)
}
/// 「留空 = 跟随全局默认({default} MiB)」,`default` 是生效值(三级回落之后)。
pub(super) fn launch_memory_hint(self, default_mb: u32) -> String {
self.0.render(
Key::InstancesLaunchMemoryHint,
&[("default", &default_mb.to_string())],
)
}
pub(super) fn launch_java(self) -> &'static str {
self.text(Key::InstancesLaunchJava)
}
pub(super) fn launch_java_hint(self) -> &'static str {
self.text(Key::InstancesLaunchJavaHint)
}
pub(super) fn launch_backend(self) -> &'static str {
self.text(Key::InstancesLaunchBackend)
}
/// 「需游戏版本 {min} 及以上(本实例 {version})」。
///
/// `min` 来自 DTO(`LaunchFacts::min_version` ← core 的 `GRAPHICS_BACKEND_MIN_VERSION`),
/// **不在目录里硬编码版本号**——否则 core 一改门槛,文案会继续骗人(r4 复审 P3-c)。
pub(super) fn launch_backend_gated(self, version: &str, min: &str) -> String {
self.0.render(
Key::InstancesLaunchBackendGated,
&[("version", version), ("min", min)],
)
}
/// 「生效:vulkan」。
pub(super) fn launch_effective(self, value: &str) -> String {
self.0
.render(Key::InstancesLaunchEffective, &[("value", value)])
}
/// 三态后端的显示名(稳定 id → 文案);**未知 id 返回 `None`**(调用方原样上屏,不猜)。
pub(super) fn backend_label(self, id: &str) -> Option<&'static str> {
Some(self.text(match id {
"default" => Key::InstancesBackendDefault,
"opengl" => Key::InstancesBackendOpengl,
"vulkan" => Key::InstancesBackendVulkan,
_ => return None,
}))
}
pub(super) fn launch_save(self) -> &'static str {
self.text(Key::InstancesLaunchSave)
}
pub(super) fn launch_reset(self) -> &'static str {
self.text(Key::InstancesLaunchReset)
}
fn text(self, key: Key) -> &'static str { fn text(self, key: Key) -> &'static str {
self.0.text(key) self.0.text(key)
} }
-24
View File
@@ -91,36 +91,12 @@ pub enum Message {
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动 /// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。 /// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
Content(content::Message), Content(content::Message),
/// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。
Instances(super::instances::InstanceMessage),
/// 自更新动作(**M159**):提示条与设置页「更新」区共用一支。
///
/// 为什么是顶层变体而不是又一个 `GroupMessage`:更新动作是**跨页的一次性意图**
/// (检查 / 下载安装 / 忽略 / 立即重启),不是某一组的内部状态;设置页的「更新」组也不持有
/// 任何字段(快照在 presenter 里)。与 M106 的 [`Self::Language`] 同一取舍,偏离已登记在
/// `pages/settings/update.rs` 的模块文档里。
Update(UpdateAction),
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。 /// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
/// ///
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。 /// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
Account(crate::pages::account::Message), Account(crate::pages::account::Message),
} }
/// 自更新的用户动作(M159)。落点是 `app/update/update_action.rs`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// 立即检查(手动;会清掉「忽略这个版本」)
CheckNow,
/// 下载 → 校验 → 替换(提示条与设置页共用一个动作)
Apply,
/// 忽略这个版本(本次会话不再弹提示条;设置页仍可手动装)
Dismiss,
/// 立即重启(新版本已就位,重启后生效)
RestartNow,
/// 开关「启动时检查更新」
SetAutoCheck(bool),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action { pub enum Action {
+13 -55
View File
@@ -108,7 +108,6 @@ pub mod frame;
pub mod harness; pub mod harness;
pub mod layout; pub mod layout;
pub mod model; pub mod model;
pub mod update;
pub mod account; pub mod account;
pub mod content; pub mod content;
@@ -127,21 +126,17 @@ pub(crate) mod settings;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{InstanceMessage, LaunchForm};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message, UpdateAction}; pub use message::{Action, Message};
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row}; pub use model::{Detail, Instances, Meta, Model, Row};
pub use screen::Screen; pub use screen::Screen;
pub use settings::Language; pub use settings::Language;
pub use update::{Banner, Update, UpdateStatus};
use iced::widget::{column, container, row, space}; use iced::widget::{column, container, row, space};
use iced::{Element, Length}; use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer; use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use crate::theme::Ui; use crate::theme::Ui;
@@ -155,11 +150,7 @@ use frame::filled;
/// ///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用: /// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。 /// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>( pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render( render(
ui, ui,
model, model,
@@ -168,7 +159,6 @@ pub fn page<'a>(
groups: settings::demo_groups(), groups: settings::demo_groups(),
}, },
Language::default(), Language::default(),
form,
) )
} }
@@ -184,7 +174,6 @@ pub fn page_with_appearance<'a>(
ui: &'a Ui, ui: &'a Ui,
model: &'a Model, model: &'a Model,
appearance: &'a appearance::State, appearance: &'a appearance::State,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
render( render(
ui, ui,
@@ -194,7 +183,6 @@ pub fn page_with_appearance<'a>(
groups: settings::demo_groups(), groups: settings::demo_groups(),
}, },
Language::default(), Language::default(),
form,
) )
} }
@@ -211,9 +199,8 @@ pub fn page_with_settings<'a>(
model: &'a Model, model: &'a Model,
states: settings::States<'a>, states: settings::States<'a>,
language: Language, language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
render(ui, model, states, language, launch_form) render(ui, model, states, language)
} }
fn render<'a>( fn render<'a>(
@@ -221,35 +208,17 @@ fn render<'a>(
model: &'a Model, model: &'a Model,
states: settings::States<'a>, states: settings::States<'a>,
language: Language, language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width); let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
let page = match model.screen { match model.screen {
Screen::Instances => instances::view(ui, metrics, model, launch_form), Screen::Instances => instances::view(ui, metrics, model),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。 // M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang), Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang` // M109:内容库页同样跟 `model.lang`
// M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁), Screen::Content => content::view(ui, metrics, &model.content, model.lang),
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。 Screen::Settings => settings::view(ui, metrics, states, language),
Screen::Content => {
content::view(ui, metrics, &model.content, model.lang, model.window_height)
}
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
};
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
} }
} }
@@ -258,12 +227,8 @@ fn render<'a>(
/// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给 /// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的 /// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。 /// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
pub fn app<'a>( pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
ui: &'a Ui, app_with_appearance(ui, model, None)
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
app_with_appearance(ui, model, None, form)
} }
/// 同 [`app`],但可以带上**外观运行期状态**。 /// 同 [`app`],但可以带上**外观运行期状态**。
@@ -276,7 +241,6 @@ pub fn app_with_appearance<'a>(
ui: &'a Ui, ui: &'a Ui,
model: &'a Model, model: &'a Model,
appearance: Option<&'a appearance::State>, appearance: Option<&'a appearance::State>,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。 // harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
let body = render( let body = render(
@@ -287,7 +251,6 @@ pub fn app_with_appearance<'a>(
groups: settings::demo_groups(), groups: settings::demo_groups(),
}, },
Language::default(), Language::default(),
form,
); );
let shell = row![nav::rail(ui, model.active), body] let shell = row![nav::rail(ui, model.active), body]
@@ -308,19 +271,14 @@ pub fn app_with_appearance<'a>(
/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。 /// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。
/// ///
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。 /// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
/// pub fn demo_model(screen: Screen, window_width: f32) -> Model {
/// 高度也是模型的一部分(M161):内容库在矮窗口下要换版面,出图时 `--height` 必须 let mut model = Model::new(screen, window_width, Some(screen.nav()));
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
pub fn demo_model(screen: Screen, window_width: f32, window_height: f32) -> Model {
let mut model = Model::new(screen, window_width, window_height, Some(screen.nav()));
match screen { match screen {
Screen::Instances => model.instances = instances::demo(), Screen::Instances => model.instances = instances::demo(),
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()), Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang), Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
Screen::Logs => model.logs = logs::demo(model.lang), Screen::Logs => model.logs = logs::demo(model.lang),
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。 Screen::Settings => {}
// 只给这一页:其它页的基线图(尤其实例页的启动参数区)不该因为更新提示条而整体下移。
Screen::Settings => model.update = update::demo(),
} }
model model
} }
+1 -40
View File
@@ -21,7 +21,6 @@ use crate::app::components::interaction::Interaction;
use super::content::Content; use super::content::Content;
use super::logs::Logs; use super::logs::Logs;
use super::update::Update;
use super::{Download, Screen}; use super::{Download, Screen};
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。 /// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
@@ -65,30 +64,6 @@ pub struct Detail {
pub can_launch: bool, pub can_launch: bool,
/// 「Stop」可用 /// 「Stop」可用
pub can_stop: bool, pub can_stop: bool,
/// 启动参数区的只读事实(M157)
pub launch: LaunchFacts,
}
/// 启动参数区(M157)的**只读事实**:门槛、生效值、三态词表。
///
/// 可编辑的值不在这里——它们在页面状态 [`super::instances::LaunchForm`](由 `App` 持有)。
/// 这样「每来一条事件就重建视图模型」不会把用户正在输入的内容冲掉。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LaunchFacts {
/// 这个版本能不能下发 `--graphicsBackend`(core 的版本门槛判定)。
pub allowed: bool,
/// 判定依据的游戏版本(提示文案里给出)。
pub mc_version: String,
/// 门槛的最低版本(`26.2-snapshot-2`;提示与测试共用一份来源)。
pub min_version: String,
/// 生效最大堆(三级回落之后)。
pub memory_effective_mb: u32,
/// 生效 Java(空 = 自动挑)。
pub java_effective: String,
/// 生效图形后端。
pub backend_effective: String,
/// 三态词表(`default` / `opengl` / `vulkan`)。
pub options: Vec<String>,
} }
/// 实例页要画的东西。 /// 实例页要画的东西。
@@ -107,11 +82,6 @@ pub struct Model {
pub screen: Screen, pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位判据就是它) /// 窗口宽度(**含外壳**:布局档位判据就是它)
pub window_width: f32, pub window_width: f32,
/// 窗口高度(**含外壳**)。
///
/// 只有「矮窗口要换版面」这一类判据用它(M161:内容库在低高度下不能把列表压扁)——
/// 宽度档位仍只看 [`Self::window_width`]。
pub window_height: f32,
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页) /// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
pub active: Option<NavSection>, pub active: Option<NavSection>,
/// 实例页的数据(其它三页还是占位视觉,不读) /// 实例页的数据(其它三页还是占位视觉,不读)
@@ -122,8 +92,6 @@ pub struct Model {
pub logs: Logs, pub logs: Logs,
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。 /// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
pub content: Content, pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。 /// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
/// ///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`), /// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -139,23 +107,16 @@ pub struct Model {
impl Model { impl Model {
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。 /// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
pub fn new( pub fn new(screen: Screen, window_width: f32, active: Option<NavSection>) -> Self {
screen: Screen,
window_width: f32,
window_height: f32,
active: Option<NavSection>,
) -> Self {
Self { Self {
screen, screen,
window_width, window_width,
window_height,
active, active,
instances: Instances::default(), instances: Instances::default(),
download: Download::loading(), download: Download::loading(),
logs: Logs::default(), logs: Logs::default(),
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据) // 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En), content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
lang: Lang::En, lang: Lang::En,
forced: None, forced: None,
} }
+4 -31
View File
@@ -50,12 +50,6 @@
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、 //! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。 //! 别的分组文件或 `pages/mod.rs`。
//! //!
//! **M159 的第五组(更新)是另一条形状**:它不持有组状态、也不走 `GroupMessage`,而是读
//! `settings::view` 的 `update` 形参(presenter 快照),由 [`UPDATE_GROUP`] 按下标分发——与 M106 的
//! 语言行同一取舍(那两样都不是「某一组的内部状态」)。所以加它只动了 ① `pages/settings/update.rs`、
//! ② 本文件的 [`GROUPS`] / [`GROUP_LABELS`] / `render` 的形参、③ `pages/mod.rs` 与 `pages/model.rs`
//! (模型多一格 `update`),**没有**碰 `state::Groups` 与 `message::GroupMessage`。
//!
//! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`) //! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`)
//! //!
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上 //! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
@@ -141,7 +135,6 @@ pub(crate) mod java;
pub(crate) mod language_appearance; pub(crate) mod language_appearance;
mod message; mod message;
mod state; mod state;
pub(crate) mod update;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@@ -161,7 +154,6 @@ use unlucky_i18n::{Lang, LangChoice};
use crate::theme::Ui; use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout}; use super::layout::{FrameMetrics, PageLayout};
use super::update::Update;
use super::Message; use super::Message;
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。 /// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
@@ -215,14 +207,11 @@ struct Group {
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。 /// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
/// ///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。 /// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 5] = [ const GROUPS: [Group; 4] = [
general::GROUP, general::GROUP,
appearance::GROUP, appearance::GROUP,
java::GROUP, java::GROUP,
download::GROUP, download::GROUP,
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
update::GROUP,
]; ];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 /// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
@@ -241,21 +230,13 @@ const GENERAL_GROUP: usize = 0;
/// ///
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— /// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。 /// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [
unlucky_i18n::Key::SettingsGroupGeneral, unlucky_i18n::Key::SettingsGroupGeneral,
unlucky_i18n::Key::SettingsGroupAppearance, unlucky_i18n::Key::SettingsGroupAppearance,
unlucky_i18n::Key::SettingsGroupJava, unlucky_i18n::Key::SettingsGroupJava,
unlucky_i18n::Key::SettingsGroupDownloads, unlucky_i18n::Key::SettingsGroupDownloads,
// M159
unlucky_i18n::Key::SettingsGroupUpdate,
]; ];
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
///
/// 与 [`GENERAL_GROUP`] 同一种做法:这一组要画的是 **presenter 快照**(不在 [`States`] 里),
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 整页。 /// 整页。
/// ///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -265,7 +246,6 @@ pub fn view<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
states: States<'a>, states: States<'a>,
language: Language, language: Language,
update: &'a Update,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。 // 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = states let selected = states
@@ -276,12 +256,9 @@ pub fn view<'a>(
PageLayout::SupportingPane => frame::panes( PageLayout::SupportingPane => frame::panes(
metrics, metrics,
frame::group_nav(ui, selected, language.effective), frame::group_nav(ui, selected, language.effective),
frame::left_aligned( frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)),
metrics.column,
form(ui, Some(selected), states, language, update),
),
), ),
_ => frame::single_column(metrics, form(ui, None, states, language, update)), _ => frame::single_column(metrics, form(ui, None, states, language)),
}; };
frame::page(ui, metrics, frame::header(ui, language.effective), body) frame::page(ui, metrics, frame::header(ui, language.effective), body)
@@ -300,7 +277,6 @@ fn form<'a>(
only: Option<usize>, only: Option<usize>,
states: States<'a>, states: States<'a>,
language: Language, language: Language,
update: &'a Update,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let catalog = language.effective.catalog(); let catalog = language.effective.catalog();
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill); let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
@@ -318,9 +294,6 @@ fn form<'a>(
// 无关(harness 那条路径也画得出真控件)。 // 无关(harness 那条路径也画得出真控件)。
let card = if index == GENERAL_GROUP { let card = if index == GENERAL_GROUP {
general::body(ui, language) general::body(ui, language)
} else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective)
} else { } else {
group group
.body .body
@@ -40,7 +40,6 @@ mod appearance_section;
mod download_section; mod download_section;
mod general_section; mod general_section;
mod java_section; mod java_section;
mod update_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH}; use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*; use super::*;
@@ -129,10 +128,7 @@ fn the_appearance_group_is_where_the_module_says_it_is() {
#[test] #[test]
fn the_split_kept_the_left_column_order() { fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect(); let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!( assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
names,
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
);
} }
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。 /// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
@@ -197,6 +193,6 @@ fn the_settings_module_does_not_name_any_group_state() {
fn every_line_can_be_wired_alone() { fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事—— // 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。 // ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组"); assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内"); assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
} }
@@ -1,198 +0,0 @@
//! 设置页「更新」分组(M159)的断言:注册表位置、四条状态各有各的句子。
//!
//! 为什么只测「词表 + 判据」而不测控件:iced 的 `Element` 没有内省接口(`tests/mod.rs` 顶部那条
//! 写过理由),上屏文本只能从**取词的那一个入口**取。这里守的是「四种结论的句子不是同一句」
//! ——正是 mission 里「检查失败不等于已是最新」那条硬要求的最小可测形态。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::update::{GROUP as UPDATE_GROUP_META, UPDATE_GROUP};
use super::super::{GROUPS, SELECTED};
use crate::pages::update::UpdateStatus;
use crate::pages::{Screen, Update};
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效。
#[test]
fn the_update_group_sits_at_the_end_of_the_registry() {
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组");
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
assert!(SELECTED < GROUPS.len());
}
/// 四种结论的句子**互不相同**,且「失败」那一句必须把原因带出来。
///
/// 抓得住:把「检查失败」写成「已是最新」(那是 mission 明令禁止的撒谎:断网时会说「你是最新」)。
/// 抓不住:句子翻得顺不顺、颜色对不对(那是人读与截图的事)。
#[test]
fn the_four_outcomes_read_differently() {
let catalog = Lang::ZhCn.catalog();
let up_to_date = catalog.render(Key::SettingsUpdateUpToDate, &[("version", "0.2.2")]);
let available = catalog.render(
Key::SettingsUpdateAvailable,
&[("version", "0.2.3"), ("size", "21.0 MB")],
);
let missing = catalog.render(Key::SettingsUpdateMissingArtifact, &[("version", "0.2.3")]);
let failed = catalog.render(
Key::SettingsUpdateFailed,
&[("reason", "取更新清单失败:连接被重置")],
);
let all = [&up_to_date, &available, &missing, &failed];
for (index, left) in all.iter().enumerate() {
for right in all.iter().skip(index + 1) {
assert_ne!(left, right, "两条结论说的是同一句话:{left} / {right}");
}
}
assert!(
failed.contains("连接被重置"),
"失败那一句必须带出原因,实际:{failed}"
);
assert!(
!failed.contains("0.2.2"),
"失败那一句不该冒出当前版本号(读起来像「已是最新」):{failed}"
);
}
/// 页面视图模型与 presenter 的判据同序:就位 > 进行中 > 失败(仅主动查) > 有新版本。
///
/// 两处各算一次(presenter 判据 + 页面视图),顺序必须一致,否则同一份状态在提示条与设置页上
/// 会各说一套。这里只钉页面这一侧的顺序;presenter 那一侧在 `presenter` 的 `dto::update` 单测里。
#[test]
fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
let mut update = Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Failed {
reason: "连不上".to_string(),
retryable: true,
},
checked_at: String::new(),
applying: true,
ready: None,
skipped: None,
silent: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Applying)
));
update.applying = false;
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Failed { .. })
));
update.status = UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1024,
notes: String::new(),
forced: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Available { .. })
));
update.ready = Some("0.2.3".to_string());
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
#[test]
fn the_demo_model_carries_an_update_snapshot() {
let model = crate::pages::demo_model(Screen::Settings, 1180.0, 760.0);
assert_eq!(model.update, crate::pages::update::demo());
assert!(
model.update.available().is_some(),
"演示快照要带一个可装的版本"
);
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
assert_eq!(
crate::pages::demo_model(Screen::Instances, 1180.0, 760.0).update,
Update::default()
);
let catalog: Catalog = Lang::En.catalog();
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
}
/// **忙碌时按钮禁用,且禁用态与状态行同源**(M161 收的债:以前点了没反应)。
///
/// 三条判据:
/// ① `Checking` / `applying` 时 `can_check()` 与 `can_apply()` 都是 false(按钮画成置灰);
/// ② 这两态下设置页的状态行**必须**是「正在检查…」/「正在安装…」——否则用户看到一颗灰按钮
/// 却没有任何解释;
/// ③ 结论回来(失败也算)之后恢回可点:超时/网络失败不许把按钮永久钉死。
#[test]
fn the_buttons_are_disabled_exactly_while_something_is_running() {
use super::super::update::status_line;
let catalog = Catalog::of(Lang::ZhCn);
let available = UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1024,
notes: String::new(),
forced: false,
};
// 静息:有候选 ⇒ 两颗按钮都能点。
let idle = Update {
status: available.clone(),
..Update::default()
};
assert!(!idle.busy());
assert!(idle.can_check(), "静息时「立即检查」可点");
assert!(idle.can_apply(), "有候选且不忙 ⇒ 「下载并安装」可点");
// 正在查:两颗都置灰,状态行说「正在检查」。
let checking = Update {
status: UpdateStatus::Checking,
..idle.clone()
};
assert!(checking.busy(), "Checking 就是忙碌");
assert!(!checking.can_check(), "查的时候不许再点「立即检查」");
assert!(!checking.can_apply(), "查的时候不许装(presenter 也拒)");
assert_eq!(
status_line(catalog, &checking).0,
catalog.text(Key::SettingsUpdateChecking),
"灰按钮旁边必须有「正在检查…」这句话"
);
// 正在装:`applying` 也算忙碌(此时 status 可能仍是 Available)。
let applying = Update {
applying: true,
..idle.clone()
};
assert!(applying.busy());
assert!(!applying.can_apply(), "装的时候安装键置灰,不是消失");
assert!(!applying.can_check(), "装的时候也不许再来一条检查");
// 状态行在这一档也必须说「正在安装…」(复审 P2 附注:此前只有截图兜着)。
assert_eq!(
super::super::update::status_text(catalog, &applying),
(catalog.text(Key::SettingsUpdateApplying).to_string(), false),
"applying 档的状态行必须是「正在安装…」且不是错误色"
);
// 同一档的两句不许撞(「正在安装」≠「正在检查」)。
assert_ne!(
catalog.text(Key::SettingsUpdateApplying),
catalog.text(Key::SettingsUpdateChecking)
);
// 结论回来(这里是最坏的结论:失败)⇒ 恢回可点。
let failed = Update {
status: UpdateStatus::Failed {
reason: "连不上".to_string(),
retryable: true,
},
..Update::default()
};
assert!(!failed.busy(), "失败 = 这一轮结束了");
assert!(failed.can_check(), "失败之后必须能重试");
assert!(!failed.can_apply(), "没有候选时安装键不可点");
}
@@ -1,293 +0,0 @@
//! 设置页「更新」分组(M159):当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次结果。
//!
//! ## 为什么这一组不走 `Group.body`
//!
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`,而 `States` 只有「外观状态 + 各组
//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种
//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给
//! [`body`],`GROUP.body` 仍是 `None`。
//!
//! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 /
//! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。
//! 偏离已登记在本文件与 mission 报告里(与 M106 的 `Message::Language` 同一取舍:这两个都不是
//! 「某一组的内部状态」,折进 `GroupMessage` 只会多一层没人用的转运)。
//!
//! ## 四条状态行必须分开
//!
//! 「已是最新」「有新版本」「没有本平台产物」「检查失败」是四种不同的结论(mission 硬要求:
//! 失败不许说成最新)。第四种一定要把原因摆出来(连不上 / 清单坏了 / 渠道不符),
//! 否则用户只会看到一句「检查失败」而无从下手。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::text_role_owned;
use crate::pages::update::{Update, UpdateStatus};
use crate::pages::Message;
use crate::pages::UpdateAction;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::Group;
/// 「更新」分组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
pub(super) const UPDATE_GROUP: usize = 4;
/// 更新分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。
pub(super) const GROUP: Group = Group {
name: "Updates",
icon: IconKind::Download,
// 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 [`body`]。
rows: &[
("Current version", "0.2.2"),
("Channel", "Stable"),
("Check for updates on startup", "On"),
],
body: None,
};
/// 渲染整组正文。
pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateCurrent),
update.current_version.clone(),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateChannel),
channel_label(catalog, &update.channel),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update));
stacked = stacked.push(divider(ui));
stacked = stacked.push(status_block(ui, catalog, update));
card(ui, stacked)
}
/// 渠道显示名:`stable` 走 key,其它渠道原样上屏(D50 现在只有 stable,但开 beta/alpha 时
/// 页面不该跟着改——渠道是数据,不是写死的字面量)。
fn channel_label(catalog: Catalog, channel: &str) -> String {
match channel {
"stable" => catalog.text(Key::SettingsUpdateChannelStable).to_string(),
other => other.to_string(),
}
}
/// 「标签 + 值」一行(与 `frame::setting_row` 同一套排版,但值要在调用处现算)。
fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
iced::widget::container(text_role_owned(
ui,
TypeRole::BodyMedium,
false,
label.to_string(),
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 自动检查开关 + 「立即检查」按钮。
///
/// **忙碌时「立即检查」置灰**(M161):此前它永远可点,而 presenter 的 `begin_check` 在
/// inflight 时静默 `return None`——用户点了没反应,还以为启动器卡了。禁用态与状态行同源
/// (同一个 [`Update::busy`]):按钮灰着的时候,下面那句状态行一定是「正在检查…」/「正在安装…」。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element<'a, Message> {
let check_now = catalog.text(Key::SettingsUpdateCheckNow);
let check = if update.can_check() {
buttons::text(
ui,
Button::new(check_now, Message::Update(UpdateAction::CheckNow)),
)
} else {
buttons::text(ui, Button::disabled(check_now))
};
row![
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsUpdateAutoCheck),
update.auto_check,
Message::Update(UpdateAction::SetAutoCheck(!update.auto_check)),
),
),
check,
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(iced::Padding::from([0.0_f32, 0.0]))
.into()
}
/// 上次检查时间 + 结论 + (有候选时)下载按钮 + 说明摘要 + 一句校验提示。
fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element<'a, Message> {
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
let checked = if update.checked_at.trim().is_empty() {
catalog.text(Key::SettingsUpdateNever).to_string()
} else {
update.checked_at.clone()
};
block = block.push(info_row(
ui,
catalog.text(Key::SettingsUpdateLastChecked),
checked,
));
// 下载/校验/替换期间先说这一句:设置页的按钮在这一刻也点不动(见下面 `applying` 分支),
// 用户得知道「它正在干活」而不是「点了没反应」。
let (text, error) = status_text(catalog, update);
let color = ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
});
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available() {
// 有候选就**永远画出**这一颗按钮:忙碌时置灰(`can_apply` = false),而不是整颗消失。
// 消失会让人以为「装到一半的功能没了」;灰着 + 状态行那句「正在安装…」才说得清。
let apply_label = catalog.text(Key::SettingsUpdateApply);
let apply = if update.can_apply() {
buttons::filled(
ui,
Button::new(apply_label, Message::Update(UpdateAction::Apply)),
)
} else {
buttons::filled(ui, Button::disabled(apply_label))
};
let mut actions = row![apply]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if forced {
actions = actions.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateForced).to_string(),
ui.color(Role::Error),
));
}
block = block.push(actions);
if !notes.trim().is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateNotes, &[("notes", notes.trim())]),
ui.color(Role::OnSurfaceVariant),
));
}
}
if let Some(version) = &update.ready {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateReady, &[("version", version)]),
ui.color(Role::OnSurface),
));
block = block.push(buttons::tonal(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
));
}
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateHint).to_string(),
ui.color(Role::OnSurfaceVariant),
));
block.into()
}
/// 状态行此刻那句话(**唯一**产地:`applying` 与四态都在这里分派)。
///
/// 拆成函数是为了可断言:`applying` 这一档此前只有截图兜着(复审 P2 附注),
/// `Element` 没有内省接口,而上屏文本只能从**取词的那一个入口**取。
pub(super) fn status_text(catalog: Catalog, update: &Update) -> (String, bool) {
if update.applying {
// 「正在下载/校验/替换」不是错误色(它只是在干活)。
return (catalog.text(Key::SettingsUpdateApplying).to_string(), false);
}
status_line(catalog, update)
}
/// 一条状态行(文案 + 是不是「错」那一类)。
///
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
/// `pub(super)` 只为让 `settings/tests` 能断言「忙碌时状态行与按钮禁用态同源」。
pub(super) fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
let (key, args, error) = match &update.status {
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
UpdateStatus::UpToDate { latest } => {
let version = latest
.clone()
.unwrap_or_else(|| update.current_version.clone());
(
Key::SettingsUpdateUpToDate,
vec![("version", version)],
false,
)
}
UpdateStatus::Available { version, size, .. } => (
Key::SettingsUpdateAvailable,
vec![("version", version.clone()), ("size", human_bytes(*size))],
false,
),
UpdateStatus::MissingArtifact { version } => (
Key::SettingsUpdateMissingArtifact,
vec![("version", version.clone())],
true,
),
UpdateStatus::Failed { reason, .. } => (
Key::SettingsUpdateFailed,
vec![("reason", reason.clone())],
true,
),
};
let args: Vec<(&str, &str)> = args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect();
(catalog.render(key, &args), error)
}
+6 -8
View File
@@ -54,11 +54,10 @@ fn screens_declare_the_layout_their_page_needs() {
#[test] #[test]
fn every_screen_builds_at_the_three_comparison_widths() { fn every_screen_builds_at_the_three_comparison_widths() {
let ui = ui(); let ui = ui();
let form = demo_launch_form();
for width in [1180.0, 900.0, 700.0] { for width in [1180.0, 900.0, 700.0] {
for screen in Screen::ALL { for screen in Screen::ALL {
let model = demo_model(screen, width, 760.0); let model = demo_model(screen, width);
let _element: Element<'_, Message> = app(&ui, &model, &form); let _element: Element<'_, Message> = app(&ui, &model);
} }
} }
} }
@@ -67,10 +66,9 @@ fn every_screen_builds_at_the_three_comparison_widths() {
#[test] #[test]
fn an_empty_snapshot_still_builds() { fn an_empty_snapshot_still_builds() {
let ui = ui(); let ui = ui();
let form = demo_launch_form();
for screen in Screen::ALL { for screen in Screen::ALL {
let model = Model::new(screen, 1180.0, 760.0, Some(screen.nav())); let model = Model::new(screen, 1180.0, Some(screen.nav()));
let _element: Element<'_, Message> = app(&ui, &model, &form); let _element: Element<'_, Message> = app(&ui, &model);
} }
} }
@@ -368,7 +366,7 @@ fn every_rail_item_points_at_a_route_that_highlights_it() {
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。 /// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
#[test] #[test]
fn the_demo_model_carries_the_m84_baseline() { fn the_demo_model_carries_the_m84_baseline() {
let model = demo_model(Screen::Instances, 1180.0, 760.0); let model = demo_model(Screen::Instances, 1180.0);
assert_eq!(model.instances.rows.len(), 12); assert_eq!(model.instances.rows.len(), 12);
let detail = model.instances.detail.as_ref().expect("演示数据有选中项"); let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
assert_eq!(detail.meta.len(), 6, "元信息 6 格"); assert_eq!(detail.meta.len(), 6, "元信息 6 格");
@@ -377,7 +375,7 @@ fn the_demo_model_carries_the_m84_baseline() {
// 其余三页不带实例数据(占位视觉不读它) // 其余三页不带实例数据(占位视觉不读它)
for screen in [Screen::Download, Screen::Settings, Screen::Logs] { for screen in [Screen::Download, Screen::Settings, Screen::Logs] {
let model = demo_model(screen, 1180.0, 760.0); let model = demo_model(screen, 1180.0);
assert!( assert!(
model.instances.rows.is_empty(), model.instances.rows.is_empty(),
"{} 不该带实例数据", "{} 不该带实例数据",
-368
View File
@@ -1,368 +0,0 @@
//! 自更新(M159)的**页面视图模型**:只放「要画什么」。
//!
//! 与 `presenter::dto::UpdateView` 的分工和本目录其它页一致(见 `pages/model.rs` 模块文档):
//! DTO 是 IPC 契约(含 core 语义字段),这里是纯展示字段,于是页面可以用演示数据喂同一个渲染函数。
//! 转换点只有一个:`app/view/update.rs`。
//!
//! ## 为什么状态是「一个有载荷的枚举」
//!
//! 「检查失败」与「已是最新」必须是两个变体(mission 硬要求):把失败折叠成
//! `UpToDate` 会让界面在断网时说「已是最新」,那是撒谎。`MissingArtifact`(有新版但没本平台产物)
//! 同理——它既不是「有新版本可装」,也不是「已是最新」。
/// 一次检查的结论(页面视图)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum UpdateStatus {
/// 还没查过
#[default]
Idle,
/// 正在查
Checking,
/// 已是最新
UpToDate {
/// 该渠道内的最高版本(`None` = 还没有任何发布)
latest: Option<String>,
},
/// 有可装的更新
Available {
/// 新版本号
version: String,
/// 包体字节数
size: u64,
/// 更新说明(可能为空)
notes: String,
/// 是否强制
forced: bool,
},
/// 有更高版本,但没有本平台的产物
MissingArtifact {
/// 那个版本
version: String,
},
/// 检查/应用失败(**不是**已是最新)
Failed {
/// 人话原因
reason: String,
/// 换个时间再试是否可能变好
retryable: bool,
},
}
/// 顶部提示条此刻的形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Banner<'a> {
/// 有新版本
Available {
/// 新版本号
version: &'a str,
/// 包体字节数
size: u64,
/// 说明摘要
notes: &'a str,
},
/// 正在下载/校验
Applying,
/// 已就位,等重启
Ready {
/// 新版本号
version: &'a str,
},
/// 失败(只在用户主动查之后出现)
Failed {
/// 人话原因
reason: &'a str,
/// 是否可重试
retryable: bool,
},
}
/// 更新页 / 提示条的全部展示状态。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Update {
/// 当前版本
pub current_version: String,
/// 渠道(`stable`)
pub channel: String,
/// 启动时是否静默检查
pub auto_check: bool,
/// 检查结论
pub status: UpdateStatus,
/// 上次检查时间(空串 = 没查过)
pub checked_at: String,
/// 正在下载/校验/替换
pub applying: bool,
/// 已就位的新版本号(`None` = 没有)
pub ready: Option<String>,
/// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
///
/// 只压住**这一个**版本:0.2.3 被忽略后同一次运行里冒出 0.2.4 仍然提示
/// (文案是「忽略这个版本」,不是「别再问我」)。
pub skipped: Option<String>,
/// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条
pub silent: bool,
}
mod busy;
impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
#[must_use]
pub fn available(&self) -> Option<(&str, u64, &str, bool)> {
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
forced,
} => Some((version, *size, notes, *forced)),
_ => None,
}
}
/// 提示条此刻要显示什么(`None` = 不显示)。
///
/// 优先级与 presenter 侧同一条(就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)),
/// 这里再算一次是因为页面层拿不到 DTO 的那份判据——两处判据的顺序必须一致,
/// `pages/tests.rs` 有对拍用例守着。
#[must_use]
pub fn banner(&self) -> Option<Banner<'_>> {
if let Some(version) = &self.ready {
return Some(Banner::Ready { version });
}
if self.applying {
return Some(Banner::Applying);
}
if let UpdateStatus::Failed { reason, retryable } = &self.status {
return (!self.silent).then_some(Banner::Failed {
reason,
retryable: *retryable,
});
}
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
..
} if self.skipped.as_deref() != Some(version.as_str()) => Some(Banner::Available {
version,
size: *size,
notes,
}),
_ => None,
}
}
}
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
///
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
///
/// 取证开关 `ULL_UPDATE_STATE`(M161,沿用 `ULL_CONTENT_*` 那套范式):不设 = 缺省那一态;
/// `checking` / `applying` 用来出「按钮置灰 + 状态行说正在干活」的那两张图,
/// `failed` / `up_to_date` 用来出另外两条结论。
#[must_use]
pub fn demo() -> Update {
let mut snapshot = demo_available();
match std::env::var("ULL_UPDATE_STATE")
.unwrap_or_default()
.as_str()
{
"checking" => snapshot.status = UpdateStatus::Checking,
"applying" => snapshot.applying = true,
"failed" => {
snapshot.status = UpdateStatus::Failed {
reason: "Could not fetch the release manifest: connection reset".to_string(),
retryable: true,
}
}
"up_to_date" => {
snapshot.status = UpdateStatus::UpToDate {
latest: Some("0.2.2".to_string()),
}
}
_ => {}
}
snapshot
}
/// 缺省那一态(可装的新版本)。
fn demo_available() -> Update {
Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 21_500_000,
notes: "修掉启动崩溃;实例页加载更快".to_string(),
forced: false,
},
checked_at: "11-08 12:00".to_string(),
applying: false,
ready: None,
skipped: None,
silent: false,
}
}
/// 「有新版本」那条提示条的正文:**体积与更新说明摘要都要给**(mission 明列的三件之二)。
///
/// 拆成函数是为了可断言(`Element` 没有内省接口):条数与措辞由 `tests` 的两条用例钉住。
#[must_use]
pub fn available_body(catalog: Catalog, size: u64, notes: &str) -> String {
let size = human_bytes(size);
let body = catalog.render(Key::UpdateBannerBodyAvailable, &[("size", &size)]);
if notes.trim().is_empty() {
body
} else {
format!("{body} · {}", notes.trim())
}
}
// --------------------------------------------------------------- 提示条
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::pages::frame::text_role_owned;
use crate::pages::message::UpdateAction;
use crate::pages::Message;
use crate::theme::Ui;
/// 顶部提示条(每一页都在页头之上;`None` = 现在没有要说的)。
///
/// 形态三种:有新版本(可下载 / 可忽略)、正在下载、已就位(可立即重启)。**失败不在这里**
/// ——静默检查失败是常态,弹一条红条等于每次开机先骂网络一次;失败原因在设置页「更新」区看。
pub fn banner<'a>(
ui: &'a Ui,
update: &'a Update,
catalog: Catalog,
) -> Option<Element<'a, Message>> {
let kind = update.banner()?;
// 三种形态各自的底色:有新版 = 次级容器(可操作、非错误);进行中 = 同色但只读;
// 就位 = 主容器(「已经好了,重启即生效」是正反馈)。
let (title, body, actions, role): (String, String, Vec<Element<'a, Message>>, Role) = match kind
{
Banner::Available {
version,
size,
notes,
} => {
let mut actions = vec![
buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerInstall),
Message::Update(UpdateAction::Apply),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerDismiss),
Message::Update(UpdateAction::Dismiss),
),
),
];
let _ = &mut actions;
(
catalog.render(Key::UpdateBannerTitleAvailable, &[("version", version)]),
available_body(catalog, size, notes),
actions,
Role::SecondaryContainer,
)
}
Banner::Applying => (
catalog.text(Key::UpdateBannerApplying).to_string(),
String::new(),
Vec::new(),
Role::SecondaryContainer,
),
Banner::Ready { version } => (
catalog.text(Key::UpdateBannerTitleReady).to_string(),
catalog.render(Key::UpdateBannerBodyReady, &[("version", version)]),
vec![buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
)],
Role::PrimaryContainer,
),
Banner::Failed { reason, .. } => (
catalog.text(Key::UpdateBannerTitleFailed).to_string(),
reason.to_string(),
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)],
Role::ErrorContainer,
),
};
let on_container = match role {
Role::PrimaryContainer => Role::OnPrimaryContainer,
Role::ErrorContainer => Role::OnErrorContainer,
_ => Role::OnSecondaryContainer,
};
let mut text_column = column![text_role_owned(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(on_container),
)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !body.is_empty() {
text_column = text_column.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(on_container),
));
}
let mut line = row![text_column]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
for action in actions {
line = line.push(action);
}
let padding = Padding::from([ui.space(Space::X3), ui.space(Space::X4)]);
Some(
container(line)
.width(Length::Fill)
.padding(padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(role))),
border: Border {
radius: ui.radius(ShapeScale::Medium, 0.0),
..Border::default()
},
..container::Style::default()
})
.into(),
)
}
#[cfg(test)]
#[path = "update/tests.rs"]
mod tests;
-43
View File
@@ -1,43 +0,0 @@
//! 「这一条更新动作此刻能不能点」(M161:把「按钮亮着、点了没反应」收掉)。
//!
//! 判据只有一条:有没有检查/下载正在跑。三处必须同源——
//!
//! | 处 | 读的是 |
//! |---|---|
//! | presenter `Runtime::inflight()` | `begin_check` / `begin_apply` 置位,`adopt` 清位 |
//! | 设置页两颗按钮 | [`Update::can_check`] / [`Update::can_apply`](本文件) |
//! | 设置页状态行 | 「正在检查…」/「正在安装…」(同一份 `busy()` 判据) |
//! | app 的消息入口 | `App::update_busy`(`app/update/update_action.rs`)——最后一道闸 |
//!
//! 为什么拆分到本文件:`pages/update.rs` 贴着 400 行硬线(AGENTS §3-1),
//! 而这一组判据是「独立的一块问题」(能不能点),独立成文件后更好找。
use super::{Update, UpdateStatus};
impl Update {
/// 有一条检查/下载正在跑。
///
/// 两个字段都要看:`status == Checking` 是「查版本」,`applying` 是「下载/校验/替换」。
/// presenter 侧同一个判据是 `Runtime::inflight()`(`begin_check` / `begin_apply` 置位,
/// `adopt` 清位),页面这层是它的**只读投影**——按钮的禁用态必须与它同源,
/// 否则会出现「按钮亮着、点了没反应」。
#[must_use]
pub fn busy(&self) -> bool {
self.applying || matches!(self.status, UpdateStatus::Checking)
}
/// 「立即检查」此刻能点吗。
///
/// 忙碌时**禁用而不是消失**:入口消失会让人以为功能没了,禁用 + 状态行那句
/// 「正在检查…」才说得清「它在干活,等一下」。
#[must_use]
pub fn can_check(&self) -> bool {
!self.busy()
}
/// 「下载并安装」此刻能点吗(要有候选,且不忙)。
#[must_use]
pub fn can_apply(&self) -> bool {
self.available().is_some() && !self.busy()
}
}
-56
View File
@@ -1,56 +0,0 @@
//! 自更新视图模型的单测(从 `pages/update.rs` 拆出,那边贴着 400 行硬线)。
use super::*;
use unlucky_i18n::Lang;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
for lang in [Lang::En, Lang::ZhCn] {
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
}
// 发布没写说明时只留体积,不留一个空尾巴。
let bare = available_body(Lang::En.catalog(), 1024, " ");
assert!(bare.contains("1.0 KB"), "{bare}");
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
}
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
#[test]
fn the_banner_priority_matches_the_presenter() {
let mut update = Update {
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
..Update::default()
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.skipped = Some("0.2.3".to_string());
assert!(update.banner().is_none(), "忽略之后不再打扰");
// 忽略的是**那一个版本**:同一次运行里冒出 0.2.4 仍然提示(与 presenter 同一语义)。
update.status = UpdateStatus::Available {
version: "0.2.4".to_string(),
size: 1,
notes: String::new(),
forced: false,
};
assert!(
matches!(update.banner(), Some(Banner::Available { version, .. }) if version == "0.2.4"),
"0.2.3 被忽略不该压住 0.2.4"
);
update.skipped = None;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
@@ -18,10 +18,6 @@
//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事 //! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事
//! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。 //! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。
// M147:本模块七项(三个属性号 + 四个读函数)只在 Windows 上有使用者——唯一调用方 `probe::run_one`
// 的 Windows 版被门着,Linux + `-D warnings` 下就是 dead_code 错。用模块级 `cfg_attr` 而不是
// 逐项 `cfg`:`cfg` 会把这些项连同本文件那几条与平台无关的单测一起门掉(逐项 `cfg_attr` 多 7 行,顶破红线)。
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
/// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。 /// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。
pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34; pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34;
/// `DWMWCP_DONOTROUND`:显式直角。 /// `DWMWCP_DONOTROUND`:显式直角。
@@ -124,7 +120,7 @@ pub fn set_preference(hwnd: isize, preference: u32) -> CornerOutcome {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
let _ = (hwnd, preference); let _ = (hwnd, preference);
CornerOutcome::UnsupportedPlatform return CornerOutcome::UnsupportedPlatform;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -19,9 +19,6 @@
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多 //! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
//! (实测:同 argv 开/关穿透,hash 完全相同)。 //! (实测:同 argv 开/关穿透,hash 完全相同)。
// 唯一使用者是下面的 `click_through`(它自己按平台门禁),非 Windows 上这个导入没人用
// ⇒ `unused_imports` 在 `-D warnings` 下升成错(M147)。
#[cfg(target_os = "windows")]
use super::raw_ex_style; use super::raw_ex_style;
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。 /// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
@@ -39,14 +39,9 @@
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use super::alpha::{apply_window_alpha, ensure_layered}; use super::alpha::{apply_window_alpha, ensure_layered};
// `CornerOutcome` 与平台无关(`ProbeRow` 的字段类型、非 Windows 的 `empty_row` 都用它),
// 其余九个符号只服务于 Windows 的 `run_one` —— 分开写,否则非 Windows 上整条导入无人使用
// (`unused_imports`,M147)。
use super::dwm::CornerOutcome;
#[cfg(target_os = "windows")]
use super::dwm::{ use super::dwm::{
corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness, corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness,
window_rect, window_style, DWMWCP_DONOTROUND, DWMWCP_ROUND, window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND,
}; };
/// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。 /// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。
+2 -2
View File
@@ -22,10 +22,10 @@ path = "src/lib.rs"
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, # 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 # 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.3" } unlucky-core = { path = "../core", version = "0.2.1" }
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, # agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
# core 侧五个端口在 src/commands/agent/ports/ 装配)。 # core 侧五个端口在 src/commands/agent/ports/ 装配)。
unlucky-agent = { path = "../agent", version = "0.2.3" } unlucky-agent = { path = "../agent", version = "0.2.1" }
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 # `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
+9 -51
View File
@@ -14,9 +14,6 @@ pub const fn general() -> &'static str {
命令: 命令:
instances list 列出实例与已安装版本 instances list 列出实例与已安装版本
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
[--backend default|opengl|vulkan] [--clear]
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run] install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
下载并安装版本本体(--loader fabric|quilt|neoforge|forge 下载并安装版本本体(--loader fabric|quilt|neoforge|forge
时再叠一层加载器)→ 登记实例;--dry-run 只出计划 时再叠一层加载器)→ 登记实例;--dry-run 只出计划
@@ -37,13 +34,11 @@ pub const fn general() -> &'static str {
config set <key> <value> 写配置(走 setting schema 校验) config set <key> <value> 写配置(走 setting schema 校验)
java list 列出可用 Java(含每个 JDK 的实际落点) java list 列出可用 Java(含每个 JDK 的实际落点)
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家) --vendor temurin|oracle|microsoft|zulu 钉死一家)
java vendors 列出各发行版的镜像/支持版本/当前优先级 java vendors 列出四家发行版的镜像/支持版本/当前优先级
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
agent config [--port N] 打印 Claude Code / Cursor 的配置样例 agent config [--port N] 打印 Claude Code / Cursor 的配置样例
update check 查一次更新(只读:不动任何文件;失败退出码 1)
update apply [--dest <path>] 下载 → SHA256 校验 → 替换(缺省替换本进程 exe)
version 版本 + 系统摘要(同 --version) version 版本 + 系统摘要(同 --version)
help [命令] 本帮助 help [命令] 本帮助
@@ -74,24 +69,7 @@ pub fn topic(name: &str) -> Option<&'static str> {
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串 version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的 注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器 启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。 痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。",
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
[--backend default|opengl|vulkan] [--clear]
读/写实例配置页「启动参数」区(M157)。不带取写选项时只读:
--jvm <text> 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**,
实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段)
--game <text> 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数)
--memory <MB> 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb
--java <path> 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑
--backend <id> default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发,
老版本会被版本门槛拦下(启动时给诊断告警,不静默)
--clear 先把五项清空再应用其它选项(= 全部回「跟随全局」)
每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。
值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen`
会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。
--json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}",
), ),
"pack" => Some( "pack" => Some(
"\ "\
@@ -113,24 +91,6 @@ pub fn topic(name: &str) -> Option<&'static str> {
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。 退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code; --json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
data.stages 列出实际走过的阶段。", data.stages 列出实际走过的阶段。",
),
"update" => Some(
"%prog% update check
%prog% update apply [--dest <path>]
自更新(D50:GitHub Releases 固定清单地址,先只发 stable)。
check 查一次更新,只读:报告当前版本、渠道、有没有新版(版本号/体积/说明摘要)。
四种结论分开报,不折叠:up_to_date / available / missing_artifact(有新版但没本平台
产物,属发布侧漏项)/ 失败(退出码 1,code=update_check_failed)。
apply 先复查一次(清单只有几 KB),有新版才下载 → 长度 + SHA256 双检 → 原子替换。
校验不符或替换被占用时**不会**留下暂存文件、也不会破坏现有 exe(自动回滚)。
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
), ),
"install" => Some( "install" => Some(
"\ "\
@@ -173,10 +133,10 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 **分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
凭据永不打印:list 只输出名称/轨道/UUID。 凭据永不打印:list 只输出名称/轨道/UUID。
add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。 add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。
微软登录所需配置已内置在官方构建里,装完即可用;仍可用环境变量覆盖(优先于内置值): 它需要一个 Azure 公共客户端 id —— 登录链路是完整的,缺的只是这一项配置;id 不进仓库:
ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(优先于内置值) ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(环境变量优先)
ULL_MSA_CLIENT_ID=<id> cargo build 自己从源码构建时注入,二进制自带 ULL_MSA_CLIENT_ID=<id> cargo build 构建时注入,二进制自带
两处都没有时(只有自建且不自带 id 才会这样)命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。 两处都没有时命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
), ),
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), "sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
@@ -197,7 +157,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java %prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录) 并打印每个 JDK 的实际落点(managed 项带目录)
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 %prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序 %prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/ portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
@@ -208,9 +168,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms) 发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序 → zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。 (只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链; 每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。",
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
--json 的 chain 块与人读的「降级:」行。",
), ),
"agent" => Some( "agent" => Some(
"\ "\
+1 -2
View File
@@ -13,8 +13,7 @@ pub mod program;
pub use model::{ pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs, AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
}; };
pub use parse::parse; pub use parse::parse;
-49
View File
@@ -26,8 +26,6 @@ pub enum Command {
Help(Option<String>), Help(Option<String>),
/// `instances list`。 /// `instances list`。
InstancesList, InstancesList,
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--loader X] [--name N]`。 /// `install <version> [--loader X] [--name N]`。
Install(InstallArgs), Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。 /// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
@@ -69,10 +67,6 @@ pub enum Command {
}, },
/// `agent serve`:对外 MCP server(长驻)。 /// `agent serve`:对外 MCP server(长驻)。
AgentServe(AgentServeArgs), AgentServe(AgentServeArgs),
/// `update check`:查一次更新,不动任何文件。
UpdateCheck(UpdateCheckArgs),
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
UpdateApply(UpdateApplyArgs),
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。 /// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
AgentConfig { AgentConfig {
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。 /// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
@@ -80,17 +74,6 @@ pub enum Command {
}, },
} }
/// `update check` 的参数(现在没有选项,留一个结构体以免将来加选项时改签名)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateCheckArgs {}
/// `update apply` 的参数。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateApplyArgs {
/// 替换目标(缺省 = 本进程的 exe;取证/自定义安装位置时才给)。
pub dest: Option<PathBuf>,
}
/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。 /// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentServeArgs { pub struct AgentServeArgs {
@@ -202,38 +185,6 @@ pub struct DiagArgs {
pub tail: usize, pub tail: usize,
} }
/// `instances launch-args` 的参数(M157:实例级启动参数)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceLaunchArgs {
/// 实例 id 或实例名。
pub instance: String,
/// 实例级 JVM 参数(空白分隔,支持引号)。
pub jvm_args: Option<String>,
/// 实例级游戏参数。
pub game_args: Option<String>,
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
pub memory_mb: Option<String>,
/// 实例级 Java 可执行文件。
pub java: Option<String>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: Option<String>,
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
pub clear: bool,
}
impl InstanceLaunchArgs {
/// 这次调用是不是纯读取(没有任何写入口)。
#[must_use]
pub fn is_read_only(&self) -> bool {
!self.clear
&& self.jvm_args.is_none()
&& self.game_args.is_none()
&& self.memory_mb.is_none()
&& self.java.is_none()
&& self.backend.is_none()
}
}
/// `config get` 的参数。 /// `config get` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigGetArgs { pub struct ConfigGetArgs {
+15 -4
View File
@@ -9,8 +9,6 @@ use crate::error::CliError;
mod accounts; mod accounts;
mod agent; mod agent;
mod instances;
mod update;
use super::model::{ use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
@@ -83,7 +81,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
match name.as_str() { match name.as_str() {
"version" => Ok(Command::Version), "version" => Ok(Command::Version),
"help" => Ok(Command::Help(tail.first().cloned())), "help" => Ok(Command::Help(tail.first().cloned())),
"instances" => instances::parse_instances(tail), "instances" => parse_instances(tail),
"install" => parse_install(tail, globals), "install" => parse_install(tail, globals),
"pack" => parse_pack(tail), "pack" => parse_pack(tail),
"launch" => parse_launch(tail), "launch" => parse_launch(tail),
@@ -94,7 +92,6 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"java" => parse_java(tail), "java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail), "accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail), "agent" => agent::parse_agent(tail),
"update" => update::parse_update(tail),
other => { other => {
Err(CliError::usage(format!("未知命令 `{other}`")) Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表")) .with_hint("%prog% --help 查看命令列表"))
@@ -102,6 +99,20 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
} }
} }
fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> { fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?; let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
let version = require_single_positional(&parsed, "install <version>")?; let version = require_single_positional(&parsed, "install <version>")?;
-45
View File
@@ -1,45 +0,0 @@
//! `instances` 的两个子命令解析(`list` / `launch-args`)。
//!
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行);
//! 纯搬移,语义未变。
use super::super::model::InstanceLaunchArgs;
use super::*;
pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some("launch-args") => {
return parse_instances_launch_args(tail.get(1..).unwrap_or_default())
}
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list / launch-args,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
///
/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。
fn parse_instances_launch_args(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["jvm", "game", "memory", "java", "backend"],
&["clear"],
)?;
let instance = require_single_positional(&parsed, "instances launch-args <instance>")?;
Ok(Command::InstancesLaunchArgs(InstanceLaunchArgs {
instance,
jvm_args: parsed.value("jvm"),
game_args: parsed.value("game"),
memory_mb: parsed.value("memory"),
java: parsed.value("java"),
backend: parsed.value("backend"),
clear: parsed.has("clear"),
}))
}
-46
View File
@@ -1,46 +0,0 @@
//! `ull update` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。
//!
//! 语法:
//! - `ull update check`
//! - `ull update apply [--dest <path>]`
//!
//! 两条命令都只是「造一个 `core::update::Job` → 调 `check` / `apply` → 排版输出」,
//! 判定(版本比较、渠道准入、清单校验)与替换(双检 + 原子改名)全在 core。
//! `--dest` 钉替换目标,缺省是本进程的 exe(取证 / 自定义安装位置时才给)。
//!
//! ## 为什么单独一个文件
//!
//! `parse.rs` 在加进这两条之后是 406 行,撞仓库 400 行硬线(`guards.yml` 的 `file-size`
//! 作业按 `git ls-files '*.rs'` + `wc -l > 400` 判红)。拆法与 `parse/{accounts,agent,
//! instances}.rs` 一致:一个子命令族一个文件,主文件只留分发。
use std::path::PathBuf;
use super::{ensure_no_extra, ensure_no_positionals, parse_flags};
use crate::cli::model::{Command, UpdateApplyArgs, UpdateCheckArgs};
use crate::error::CliError;
/// 解析 `update ...`。
pub(super) fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
+21 -55
View File
@@ -32,10 +32,6 @@ use crate::output::Report;
/// 敏感值在输出里的占位。 /// 敏感值在输出里的占位。
const MASK: &str = "<redacted>"; const MASK: &str = "<redacted>";
mod prefs_migration;
use prefs_migration::migrate_update_prefs;
/// `ull config get [<key>] [--show-secret]`。 /// `ull config get [<key>] [--show-secret]`。
/// ///
/// # Errors /// # Errors
@@ -50,9 +46,6 @@ pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, Cl
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。 /// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> { fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
// 先跑一次遗留偏好迁移(幂等;没有旧文件时只是一次 stat)。必须在 `load` **之前**:
// 迁移写的正是这份 `launcher.json`,先读后迁会让这一条输出还是旧值。
let migration = migrate_update_prefs(ctx);
let path = ctx.setting_path_for(key); let path = ctx.setting_path_for(key);
let store = SettingStore::load(&path, launcher_schema()); let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone(); let schema = store.doc().schema().clone();
@@ -72,7 +65,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
value.clone() value.clone()
}; };
let problems = render_problems(&store); let problems = render_problems(&store);
let mut data = json!({ let data = json!({
"key": key, "key": key,
"value": shown, "value": shown,
"redacted": hidden, "redacted": hidden,
@@ -90,12 +83,6 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
} }
)]; )];
human.push(format!("来自 {}", path.display())); human.push(format!("来自 {}", path.display()));
if let Some(note) = migration {
human.push(note.human);
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
for problem in &problems { for problem in &problems {
human.push(format!("问题:{problem}")); human.push(format!("问题:{problem}"));
} }
@@ -105,7 +92,6 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。 /// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> { fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
let migration = migrate_update_prefs(ctx);
let path = ctx.launcher_config(); let path = ctx.launcher_config();
let store = SettingStore::load(&path, launcher_schema()); let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone(); let schema = store.doc().schema().clone();
@@ -154,33 +140,27 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
ctx.data.game_home.display() ctx.data.game_home.display()
)); ));
human.extend(text.lines().map(str::to_string)); human.extend(text.lines().map(str::to_string));
if let Some(note) = &migration {
human.push(note.human.clone());
}
for problem in &problems { for problem in &problems {
human.push(format!("问题:{problem}")); human.push(format!("问题:{problem}"));
} }
if masked { if masked {
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string()); human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
} }
let mut data = json!({ Ok(Report::new(
"path": path.display().to_string(), json!({
"core_path": core_path.as_ref().map(|core| core.display().to_string()), "path": path.display().to_string(),
"config": document, "core_path": core_path.as_ref().map(|core| core.display().to_string()),
"redacted": masked, "config": document,
"problems": problems, "redacted": masked,
"data_root": { "problems": problems,
"mode": ctx.data.mode.as_str(), "data_root": {
"launcher_home": ctx.data.launcher_home.display().to_string(), "mode": ctx.data.mode.as_str(),
"game_home": ctx.data.game_home.display().to_string(), "launcher_home": ctx.data.launcher_home.display().to_string(),
}, "game_home": ctx.data.game_home.display().to_string(),
}); },
if let Some(note) = migration { }),
if let Some(object) = data.as_object_mut() { human,
object.insert("migration".to_string(), note.data); ))
}
}
Ok(Report::new(data, human))
} }
/// `ull config set <key> <value>`。 /// `ull config set <key> <value>`。
@@ -191,8 +171,6 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
/// ///
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。 /// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> { pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
// 同一份遗留偏好先迁进来:否则用户「set 一个、get 另一个」会看到两份真相。
let migration = migrate_update_prefs(ctx);
let path = ctx.setting_path_for(key); let path = ctx.setting_path_for(key);
let mut store = SettingStore::load(&path, launcher_schema()); let mut store = SettingStore::load(&path, launcher_schema());
let sensitive = is_sensitive(store.doc().schema(), key); let sensitive = is_sensitive(store.doc().schema(), key);
@@ -217,7 +195,7 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
})?; })?;
match &outcome { match &outcome {
SaveOutcome::Saved(saved) => { SaveOutcome::Saved(saved) => {
let mut data = json!({ let data = json!({
"key": key, "key": key,
"old": old, "old": old,
"new": value, "new": value,
@@ -231,25 +209,13 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
), ),
format!("已保存:{}", saved.display()), format!("已保存:{}", saved.display()),
]; ];
if let Some(note) = migration {
human.push(note.human);
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
human.extend(source_note(ctx, key)); human.extend(source_note(ctx, key));
Ok(Report::new(data, human)) Ok(Report::new(data, human))
} }
SaveOutcome::Skipped(SkipReason::NotDirty) => { SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
let mut human = vec![format!("{key} 已经是该值,未写盘")]; json!({ "key": key, "new": value, "saved": null }),
if let Some(note) = &migration { vec![format!("{key} 已经是该值,未写盘")],
human.push(note.human.clone()); )),
}
Ok(Report::new(
json!({ "key": key, "new": value, "saved": null }),
human,
))
}
SaveOutcome::Skipped(reason) => { SaveOutcome::Skipped(reason) => {
let error = CliError::user( let error = CliError::user(
"config_read_only", "config_read_only",
@@ -1,70 +0,0 @@
//! M159 的遗留更新偏好 → 主配置(`update.auto_check`)的迁移(M161)。
//!
//! 从 `commands/config.rs` 拆出:那边贴着 400 行硬线,而这块是**独立的一件事**
//! (遗留文件的桥接),与 get/set 的排版逻辑无关。
use serde_json::{json, Value};
use unlucky_core::setting::update_prefs::{self, Migration};
use crate::context::Ctx;
/// M159 的遗留更新偏好 → 主配置的迁移结果(只在**有话说**时返回)。
pub(super) struct PrefsNote {
/// 人读一行(`config get/set` 的人类可读输出里追加)
pub(super) human: String,
/// 同一件事的机器可读形状(`--json` 信封里追加)
pub(super) data: Value,
}
/// 把 `<数据根>/cache/update/prefs.json` 迁进 `launcher.json`(M161)。
///
/// 为什么 `config` 这个**读命令**也会写盘:那份遗留文件对 CLI 本来就不可见——用户在手头没 GUI
/// 的机器上 `config get update.auto_check`,旧实现永远回默认 `true`,而他的开关可能是关的。
/// 迁移是**幂等**的(源指纹闸门 + 旧文件改名 `.migrated`),有收据,且在写不进去时什么都不动
/// (见 [`unlucky_core::setting::update_prefs`])。没有旧文件时这里只是一次 `stat`。
pub(super) fn migrate_update_prefs(ctx: &Ctx) -> Option<PrefsNote> {
let layout = ctx.layout();
let paths = update_prefs::Targets {
launcher: &ctx.launcher_config(),
legacy: &layout.cache().join("update").join("prefs.json"),
receipt: &layout.config().join(update_prefs::RECEIPT_FILE),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0);
let migration = update_prefs::migrate(paths, now);
let (human, detail) = match &migration {
Migration::Nothing => return None,
Migration::Migrated { auto_check, .. } => (
format!(
"已把旧更新偏好迁入主配置:update.auto_check = {auto_check}(收据 {})",
paths.receipt.display()
),
format!("update.auto_check = {auto_check} 已迁移"),
),
Migration::NewValueWins { auto_check, .. } => (
format!(
"旧更新偏好文件已退役(主配置里已有 update.auto_check = {auto_check},新值胜出;收据 {})",
paths.receipt.display()
),
"旧文件已退役,主配置的值未改动".to_string(),
),
Migration::Unreadable { path, reason } => (
format!("旧更新偏好读不出来({}):{reason}", path.display()),
"旧文件坏,未迁移".to_string(),
),
Migration::Refused { reason } => (
format!("旧更新偏好未迁移:{reason}"),
"主配置不可写,未迁移".to_string(),
),
};
Some(PrefsNote {
human,
data: json!({
"id": migration.as_str(),
"receipt": migration.receipt().map(|path| path.display().to_string()),
"detail": detail,
}),
})
}
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more