Compare commits
57
Commits
termux-glibc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d5fe8aa73 | ||
|
|
c319f9dbfd | ||
|
|
47ed270293 | ||
|
|
ecd24f0608 | ||
|
|
b394316df4 | ||
|
|
81278f55af | ||
|
|
0241f793b2 | ||
|
|
79b796fd58 | ||
|
|
6804b0ad1d | ||
|
|
03741ecd2d | ||
|
|
b2d9f3927f | ||
|
|
d568cc6f37 | ||
|
|
4defc41e8d | ||
|
|
a4f096eea3 | ||
|
|
04ac90911e | ||
|
|
1c8f6aa1b3 | ||
|
|
a80362915a | ||
|
|
ee67965b9b | ||
|
|
211e07b470 | ||
|
|
c76a1437e6 | ||
|
|
e2021645d7 | ||
|
|
d68523b02a | ||
|
|
3b3430a93e | ||
|
|
cb3ae8556b | ||
|
|
88ac9d5aca | ||
|
|
4dbeb07220 | ||
|
|
c82e39d0eb | ||
|
|
161aec52f4 | ||
|
|
41019d9cae | ||
|
|
fe472e36a5 | ||
|
|
394faad611 | ||
|
|
23c2abebc2 | ||
|
|
0e7ae44923 | ||
|
|
7eed6c6261 | ||
|
|
02ed09ae49 | ||
|
|
173a18b842 | ||
|
|
bbb316ca08 | ||
|
|
c434dbe335 | ||
|
|
7b971dc75d | ||
|
|
48d1ee6616 | ||
|
|
1e0fc44fed | ||
|
|
4582a025db | ||
|
|
e090c67297 | ||
|
|
ac2e163eb8 | ||
|
|
aa42496561 | ||
|
|
4bb1591aca | ||
|
|
be280c16a3 | ||
|
|
d728ed836f | ||
|
|
37de00b07e | ||
|
|
06fe12d811 | ||
|
|
c38fbe4068 | ||
|
|
f001b93942 | ||
|
|
afdbc9fd51 | ||
|
|
7946e5b804 | ||
|
|
5c96227881 | ||
|
|
f9607c1246 | ||
|
|
e3ee20bdcd |
No files matched your search
@@ -1,5 +1,9 @@
|
||||
name: Guards
|
||||
|
||||
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
|
||||
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
|
||||
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
|
||||
|
||||
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
|
||||
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
|
||||
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
|
||||
@@ -54,7 +58,11 @@ jobs:
|
||||
run: |
|
||||
set -uo pipefail
|
||||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||||
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
|
||||
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
|
||||
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
|
||||
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
|
||||
exit 1
|
||||
fi
|
||||
echo "$tree"
|
||||
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
|
||||
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
|
||||
@@ -109,7 +117,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "$tree"
|
||||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
|
||||
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
|
||||
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
|
||||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
|
||||
printf '%s\n' "$hits" | sed 's/^/ - /'
|
||||
@@ -318,9 +328,10 @@ jobs:
|
||||
}
|
||||
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
|
||||
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
|
||||
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
|
||||
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
||||
if scan "$f"; then printf '%s\n' "$f"; fi
|
||||
done | LC_ALL=C sort)
|
||||
done | LC_ALL=C sort || true)
|
||||
echo "期望(恰好这一个):$EXPECTED"
|
||||
echo "实际扫到的集合:"
|
||||
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
||||
|
||||
@@ -94,6 +94,27 @@ jobs:
|
||||
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:
|
||||
@@ -141,7 +162,12 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
|
||||
# D51 定案(2026-11-06,方案甲):GitHub 的 `releases/latest/download/*` 只指向**非
|
||||
# prerelease** 的最新 Release。稳定版发布时显式 dispatch `prerelease=false`(latest 随之生效,
|
||||
# 客户端地址不变);push tag 默认仍 prerelease(视为预发布观察,不进稳定通道)。
|
||||
# 发版 checklist 第六件 = 确认 prerelease 标记与通道一致;v0.2.2 起首个适用。
|
||||
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
|
||||
|
||||
Generated
+9
-8
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-agent"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures-util",
|
||||
@@ -4997,12 +4997,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-app-iced"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"iced",
|
||||
"image",
|
||||
"log",
|
||||
"unlucky-cli",
|
||||
"unlucky-core",
|
||||
"unlucky-design",
|
||||
"unlucky-i18n",
|
||||
"unlucky-presenter",
|
||||
@@ -5010,7 +5011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-cli"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5021,7 +5022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-core"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -5040,15 +5041,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-design"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-i18n"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-palette-extract"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"unlucky-design",
|
||||
@@ -5056,7 +5057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-presenter"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ members = [
|
||||
exclude = ["crates/app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
authors = ["AskaEth <i@yeij.top>"]
|
||||
|
||||
@@ -173,10 +173,11 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
|
||||
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
|
||||
management from the GUI is the instance-wizard work still outstanding, and settings change as you
|
||||
edit them rather than on a Save.
|
||||
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
|
||||
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
|
||||
been downloaded and run end to end. Treat them as untested output, not as "supported because the
|
||||
matrix lists them".
|
||||
- **Linux and macOS artifacts are unverified, and this release does not even build them.** The
|
||||
reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots
|
||||
sit in the CI matrix but the default release run skips them, and they have never been downloaded and
|
||||
run end to end. `gh workflow run release.yml -f all_slots=true` builds them for evidence. Treat them
|
||||
as untested output, not as "supported because the matrix lists them".
|
||||
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why
|
||||
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts
|
||||
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
|
||||
@@ -193,8 +194,9 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
|
||||
report lists every conflict so nothing is overwritten silently.
|
||||
- **Install progress is stage-level**, not byte-level, on the content-library install path.
|
||||
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
|
||||
- **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is
|
||||
unverified there.
|
||||
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
|
||||
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
|
||||
[`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
|
||||
|
||||
## Download and run
|
||||
|
||||
@@ -207,19 +209,20 @@ Grab the file for your platform from
|
||||
|------|-------|-------|
|
||||
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
|
||||
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
|
||||
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory |
|
||||
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same |
|
||||
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
|
||||
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
|
||||
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
||||
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory — **not yet published** |
|
||||
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **not yet published** |
|
||||
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not yet published** |
|
||||
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not yet published** |
|
||||
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
||||
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
|
||||
|
||||
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is
|
||||
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
|
||||
The row stays because the architecture is still supported and the slot still builds (unpublished) in
|
||||
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
|
||||
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
|
||||
only downloadable Windows asset.
|
||||
**This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named
|
||||
in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before
|
||||
it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable
|
||||
asset. Of those rows `windows-arm64` is the one CI still builds — natively, on GitHub's
|
||||
`windows-11-arm` runner, for evidence — but that slot's toolchain check has not gone green yet, so its
|
||||
`.exe` stays out of the release. The row stays because the architecture is still supported; it rejoins
|
||||
the download list once the build is green.
|
||||
|
||||
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
|
||||
@@ -293,7 +296,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|
||||
|----------|---------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
|
||||
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
|
||||
| `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
|
||||
| `.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) |
|
||||
|
||||
## Architecture and module map
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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 但不阻断)
|
||||
+15
-13
@@ -140,8 +140,9 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
|
||||
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
|
||||
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
|
||||
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
|
||||
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
|
||||
- **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、
|
||||
`macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过;
|
||||
`gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是
|
||||
「矩阵里有就算支持」。
|
||||
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
|
||||
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
|
||||
@@ -157,7 +158,7 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
文件是被静默覆盖的。
|
||||
- **内容库的安装进度是阶段级**,不是字节级。
|
||||
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
|
||||
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。
|
||||
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
|
||||
|
||||
## 下载与运行
|
||||
|
||||
@@ -169,17 +170,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
|------|------|------|
|
||||
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
|
||||
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
|
||||
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 |
|
||||
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 |
|
||||
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 |
|
||||
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 |
|
||||
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
||||
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** |
|
||||
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** |
|
||||
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** |
|
||||
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** |
|
||||
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
||||
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
|
||||
|
||||
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
|
||||
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
|
||||
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
|
||||
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
|
||||
**本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
|
||||
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
|
||||
**暂未发布** 的行是命名规则,不是本版可下载的资产。这几行里 `windows-arm64` 是 CI 唯一真跑的那档:M140 起在
|
||||
GitHub 原生 `windows-11-arm` runner 上编来取证,但该槽位的工具链体检还没绿,所以它的 `.exe` 不进 Release;
|
||||
行不删是因为架构仍要支持,绿了就把键加回 `PUBLISH_SLOTS`。
|
||||
|
||||
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
|
||||
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
|
||||
@@ -240,7 +242,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|
||||
|----------|------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
|
||||
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
||||
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
|
||||
| `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) |
|
||||
|
||||
## 架构与模块地图
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Termux 适配:方案、改动清单与合并指南
|
||||
|
||||
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
|
||||
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
|
||||
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
|
||||
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
|
||||
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
|
||||
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
|
||||
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
|
||||
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
|
||||
|
||||
## 方案
|
||||
|
||||
### 1. 平台识别
|
||||
|
||||
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
|
||||
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
|
||||
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
|
||||
|
||||
### 2. JDK:发现 + 安装
|
||||
|
||||
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
|
||||
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
|
||||
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
|
||||
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
|
||||
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
|
||||
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
|
||||
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
|
||||
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
|
||||
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
|
||||
|
||||
### 3. 字体
|
||||
|
||||
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
|
||||
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
|
||||
|
||||
### 4. 窗口(横屏优先)
|
||||
|
||||
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
|
||||
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
|
||||
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
|
||||
|
||||
### 5. GPU(启动器)
|
||||
|
||||
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
|
||||
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
|
||||
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
|
||||
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
|
||||
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
|
||||
|
||||
### 6. Minecraft 运行链
|
||||
|
||||
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
|
||||
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
|
||||
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
|
||||
下载兜底到阿里云 Maven(`install/libraries.rs`)。
|
||||
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
|
||||
加载不了 glibc natives。
|
||||
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
|
||||
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
|
||||
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 | 平台隔离 |
|
||||
| --- | --- | --- |
|
||||
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
|
||||
| `core/platform/mod.rs` | 导出新模块 | — |
|
||||
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
|
||||
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
|
||||
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
|
||||
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
|
||||
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
|
||||
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
|
||||
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
|
||||
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
|
||||
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
|
||||
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
|
||||
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
|
||||
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
|
||||
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
|
||||
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
|
||||
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
|
||||
|
||||
## 行为变化与兼容性
|
||||
|
||||
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
|
||||
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
|
||||
默认回退序**不含** termux,PC 行为不变。
|
||||
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
|
||||
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
|
||||
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
|
||||
|
||||
## 合并指南
|
||||
|
||||
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
|
||||
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
|
||||
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
|
||||
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
|
||||
`crates/app-iced/Cargo.toml`)。
|
||||
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
|
||||
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
|
||||
3. 本机已知、**不影响 PC CI** 的三条失败:
|
||||
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
|
||||
- 同测试:长路径被脱敏成 `<opaque>`
|
||||
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
|
||||
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
|
||||
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
|
||||
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
|
||||
所以在 Termux 上跑门禁看不出来):
|
||||
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
|
||||
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
|
||||
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
|
||||
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
|
||||
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
|
||||
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
|
||||
行为不变、用例原样留在原处。
|
||||
|
||||
## 验证矩阵(本机)
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
|
||||
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
|
||||
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
|
||||
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
|
||||
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
|
||||
|
||||
## 后续路线
|
||||
|
||||
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
|
||||
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
|
||||
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
|
||||
4. narrator/flite 与音频设备(当前缺失但不阻断)。
|
||||
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
|
||||
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
|
||||
@@ -13,7 +13,7 @@ publish.workspace = true
|
||||
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
|
||||
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
|
||||
[dependencies]
|
||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
||||
unlucky-core = { path = "../core", version = "0.2.2" }
|
||||
|
||||
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
||||
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
||||
|
||||
@@ -22,12 +22,15 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||
unlucky-design = { path = "../design", version = "0.2.1" }
|
||||
unlucky-design = { path = "../design", version = "0.2.2" }
|
||||
|
||||
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
|
||||
unlucky-core = { path = "../core", version = "0.2.2" }
|
||||
|
||||
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
|
||||
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
|
||||
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
|
||||
unlucky-presenter = { path = "../presenter", version = "0.2.1" }
|
||||
unlucky-presenter = { path = "../presenter", version = "0.2.2" }
|
||||
|
||||
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
||||
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
||||
@@ -57,11 +60,11 @@ image = { version = "0.25", default-features = false, features = [
|
||||
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
||||
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
|
||||
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.2.1" }
|
||||
unlucky-cli = { path = "../cli", version = "0.2.2" }
|
||||
|
||||
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
|
||||
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
|
||||
unlucky-i18n = { path = "../i18n", version = "0.2.1" }
|
||||
unlucky-i18n = { path = "../i18n", version = "0.2.2" }
|
||||
|
||||
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
||||
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
||||
|
||||
@@ -30,6 +30,8 @@ use super::{MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod diagnostics;
|
||||
mod fallback;
|
||||
mod termux_gpu;
|
||||
mod termux_window;
|
||||
|
||||
use fallback::{on_failure, respawn, OnFailure};
|
||||
|
||||
@@ -39,6 +41,8 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let boot_started = Instant::now();
|
||||
|
||||
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
||||
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
|
||||
termux_gpu::enable();
|
||||
let boot = boot_probe(&options);
|
||||
let decision = boot.decision;
|
||||
diagnostics::report_boot(&options, &boot.probe, &decision);
|
||||
@@ -131,10 +135,20 @@ fn launch(
|
||||
|
||||
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
|
||||
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
|
||||
let (size, size_source) = acceptance::window_size(
|
||||
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
|
||||
// 失败就退回 1180×760 的验收尺寸。
|
||||
let (default_size, default_source) = termux_window::default_size(
|
||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||
(MIN_SIZE.width, MIN_SIZE.height),
|
||||
);
|
||||
)
|
||||
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
|
||||
let (size, size_source) =
|
||||
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
|
||||
let size_source = if size_source == "default" {
|
||||
default_source
|
||||
} else {
|
||||
size_source
|
||||
};
|
||||
let settings = diagnostics::window_settings(size);
|
||||
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
||||
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,9 @@ pub struct App {
|
||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||
pub(super) install_cancelled_seen: u64,
|
||||
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||
pub(super) launch_ends_seen: u64,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
@@ -178,6 +181,12 @@ pub struct App {
|
||||
pub(super) account_form: pages::account::Form,
|
||||
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
|
||||
pub(super) account: pages::account::Page,
|
||||
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
|
||||
///
|
||||
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
|
||||
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
|
||||
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
pub(super) launch_form: pages::LaunchForm,
|
||||
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
|
||||
pub(super) avatars: pages::account::avatar::Cache,
|
||||
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
|
||||
|
||||
@@ -89,6 +89,7 @@ impl App {
|
||||
let mut view = pages::Model::new(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
height,
|
||||
Some(pages::Screen::Instances.nav()),
|
||||
);
|
||||
view.lang = lang.lang();
|
||||
@@ -124,7 +125,9 @@ impl App {
|
||||
agent_host: super::presenter::AgentHost::from_env(),
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
launch_form: pages::LaunchForm::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
content: pages::content::PageState::default(),
|
||||
account_form: pages::account::Form::new(),
|
||||
account: pages::account::Page::build(
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
@@ -34,6 +37,23 @@ impl App {
|
||||
self.state.toasts.push(ToastKind::Info, text);
|
||||
}
|
||||
|
||||
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
|
||||
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
|
||||
if self.state.launch_ends != self.launch_ends_seen {
|
||||
self.launch_ends_seen = self.state.launch_ends;
|
||||
if let Some(finished) = self.state.last_launch_end.as_deref() {
|
||||
let text = view::instances::launch_toast(finished, self.lang());
|
||||
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
|
||||
&& finished.failure.is_none()
|
||||
{
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
};
|
||||
self.state.toasts.push(kind, text);
|
||||
}
|
||||
}
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
@@ -46,16 +66,42 @@ impl App {
|
||||
self.view = super::super::view::model(
|
||||
&self.state,
|
||||
width,
|
||||
self.height,
|
||||
self.options.force_state,
|
||||
category,
|
||||
&draft,
|
||||
self.lang(),
|
||||
&self.content,
|
||||
);
|
||||
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
self.sync_launch_form();
|
||||
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
|
||||
// 皮肤解码走缓存,所以重复重建不重解图)。
|
||||
self.rebuild_account_page(width);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
|
||||
///
|
||||
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
|
||||
/// 免得「A 的草稿被当成 B 的初值」。
|
||||
fn sync_launch_form(&mut self) {
|
||||
let Route::Detail(id) = self.state.route() else {
|
||||
self.launch_form.bound = None;
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.state
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
{
|
||||
Some(instance) => {
|
||||
let view = instance.launch.clone();
|
||||
self.launch_form.sync(&view);
|
||||
}
|
||||
None => self.launch_form.bound = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
#[cfg(test)]
|
||||
mod appearance;
|
||||
#[cfg(test)]
|
||||
mod instances_save_loop;
|
||||
#[cfg(test)]
|
||||
mod presenter;
|
||||
|
||||
use super::message::Message;
|
||||
|
||||
@@ -202,13 +202,13 @@ fn the_settings_page_renders_the_real_appearance_group() {
|
||||
assert_eq!(app.view.screen, Screen::Settings);
|
||||
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
|
||||
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
|
||||
drop(_tree);
|
||||
|
||||
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
|
||||
app.appearance.apply(Change::SelectGroup(0));
|
||||
assert_eq!(app.appearance.selected_group, 0);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
|
||||
}
|
||||
|
||||
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
|
||||
//!
|
||||
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
|
||||
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
|
||||
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
|
||||
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
|
||||
//!
|
||||
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
|
||||
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
|
||||
LoaderInfo, LoaderKind,
|
||||
};
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::toast::{Toast, ToastKind};
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::{App, TICK};
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
|
||||
#[derive(Debug, Default)]
|
||||
struct FailingSaveBackend {
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
queued: Vec<AppEvent>,
|
||||
}
|
||||
|
||||
impl Backend for FailingSaveBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.queued)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatched.borrow_mut().push(command.clone());
|
||||
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
|
||||
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
|
||||
self.queued.push(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Error,
|
||||
text: "内存超出范围".to_string(),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
|
||||
fn snapshot() -> Vec<InstanceSummary> {
|
||||
vec![InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView {
|
||||
instance_id: "survival".to_string(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let dispatched = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = FailingSaveBackend {
|
||||
dispatched: Rc::clone(&dispatched),
|
||||
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
|
||||
};
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
(app, dispatched)
|
||||
}
|
||||
|
||||
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
|
||||
///
|
||||
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
|
||||
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
|
||||
/// 下面的前置断言会红)。
|
||||
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
|
||||
#[test]
|
||||
fn failed_save_keeps_the_draft_through_the_whole_loop() {
|
||||
let (mut app, dispatched) = app_with_snapshot();
|
||||
|
||||
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
app.advance(TICK);
|
||||
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
|
||||
assert!(!app.launch_form.dirty);
|
||||
|
||||
// 用户把内存改成非法值(host 会拒),然后点保存。
|
||||
let _ = app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::Memory("4g".to_string()),
|
||||
)));
|
||||
assert_eq!(app.launch_form.memory, "4g");
|
||||
let _ = app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::Save,
|
||||
)));
|
||||
assert!(
|
||||
dispatched
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
|
||||
"保存要把命令派发出去"
|
||||
);
|
||||
|
||||
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
|
||||
app.advance(TICK);
|
||||
|
||||
assert_eq!(
|
||||
app.launch_form.memory, "4g",
|
||||
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
|
||||
);
|
||||
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
|
||||
}
|
||||
@@ -47,9 +47,12 @@ mod appearance;
|
||||
mod boot_env;
|
||||
mod clock;
|
||||
mod content;
|
||||
mod content_pack;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
mod update_action;
|
||||
|
||||
impl App {
|
||||
/// 一条消息进来,改状态并返回后续任务。
|
||||
|
||||
@@ -111,12 +111,26 @@ impl App {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut clean_scan = false;
|
||||
for event in events {
|
||||
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
|
||||
if matches!(
|
||||
&event,
|
||||
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
|
||||
pack
|
||||
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
|
||||
) {
|
||||
clean_scan = true;
|
||||
}
|
||||
self.state.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
self.auto_select();
|
||||
self.refresh_view();
|
||||
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
|
||||
if clean_scan {
|
||||
self.continue_import_after_clean_scan();
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
|
||||
ImportRequestView, PackCommand,
|
||||
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
|
||||
ExportRequestView,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
@@ -77,7 +77,12 @@ impl App {
|
||||
self.with_query(|query| query.loader = loader)
|
||||
}
|
||||
pages::content::Message::Select(id) => {
|
||||
self.content.selected = Some(id);
|
||||
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板)
|
||||
self.content.selected = Some(id.clone());
|
||||
self.state.content.request_detail(&id);
|
||||
if let Some(request) = self.content_detail_request() {
|
||||
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::SetTarget(id) => {
|
||||
@@ -104,149 +109,11 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 导入/导出区的消息(M113)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||||
use pages::content::PackMessage;
|
||||
match message {
|
||||
PackMessage::Dropped(path) => {
|
||||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||||
self.content.pack.draft = path.display().to_string();
|
||||
self.content.pack.hover = false;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Hover(hover) => {
|
||||
self.content.pack.hover = hover;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PathChanged(path) => {
|
||||
self.content.pack.draft = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstance(instance) => {
|
||||
self.content.pack.instance = instance;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstanceName(name) => {
|
||||
self.content.pack.instance_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetFormat(format) => {
|
||||
self.content.pack.format = format;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportName(name) => {
|
||||
self.content.pack.export_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportVersion(version) => {
|
||||
self.content.pack.export_version = version;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetOutput(path) => {
|
||||
self.content.pack.output = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ToggleConflict(path) => {
|
||||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::OpenExport => {
|
||||
self.content.pack.export_open = true;
|
||||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||||
if self.content.pack.export_name.trim().is_empty() {
|
||||
self.content.pack.export_name =
|
||||
self.content.pack.instance.clone().unwrap_or_default();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import => {
|
||||
if let Some(request) = self.pack_import_request(false) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
|
||||
if let Some(request) = self.pack_import_request(true) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Cancel => {
|
||||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||||
self.dispatch(AppCommand::CancelInstall);
|
||||
}
|
||||
PackMessage::Precheck => {
|
||||
if let Some(request) = self.pack_export_request() {
|
||||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||||
self.state.pack.set_export_request(request.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Confirm => {
|
||||
if let Some(request) = self.state.pack.export.request.clone() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
|
||||
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
|
||||
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
|
||||
let draft = self.content.pack.draft.trim();
|
||||
if draft.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self.content.pack.instance_name.trim();
|
||||
Some(ImportRequestView {
|
||||
pack: std::path::PathBuf::from(draft),
|
||||
instance: self.content.pack.instance.clone(),
|
||||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||||
overwrite,
|
||||
})
|
||||
}
|
||||
|
||||
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
|
||||
///
|
||||
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
|
||||
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
|
||||
fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
let instance = self.content.pack.instance.clone()?;
|
||||
Some(pages::content::request_export(
|
||||
&self.content.pack,
|
||||
@@ -299,6 +166,26 @@ impl App {
|
||||
.is_some_and(|results| results.curseforge)
|
||||
}
|
||||
|
||||
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
|
||||
///
|
||||
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
|
||||
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
|
||||
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
let hit = model.selected_hit()?;
|
||||
let target = model
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| Some(&target.id) == model.target.as_ref());
|
||||
Some(ContentDetailRequest {
|
||||
project: hit.id.clone(),
|
||||
source: hit.source,
|
||||
game_version: target.map(|target| target.mc_version.clone()),
|
||||
loader: target.map(|target| target.loader),
|
||||
})
|
||||
}
|
||||
|
||||
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
|
||||
///
|
||||
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
|
||||
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
|
||||
//!
|
||||
//! ## 两条口径(与原来同一句)
|
||||
//!
|
||||
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
|
||||
//! 状态,不进 presenter;
|
||||
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
|
||||
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
//!
|
||||
//! ## M160 加的那条纪律
|
||||
//!
|
||||
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
|
||||
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
|
||||
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 导入/导出区的消息(M113)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||||
use pages::content::PackMessage;
|
||||
match message {
|
||||
PackMessage::Dropped(path) => {
|
||||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||||
self.content.pack.draft = path.display().to_string();
|
||||
self.content.pack.hover = false;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Hover(hover) => {
|
||||
self.content.pack.hover = hover;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PathChanged(path) => {
|
||||
self.content.pack.draft = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstance(instance) => {
|
||||
self.content.pack.instance = instance;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstanceName(name) => {
|
||||
self.content.pack.instance_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetFormat(format) => {
|
||||
self.content.pack.format = format;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportName(name) => {
|
||||
self.content.pack.export_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportVersion(version) => {
|
||||
self.content.pack.export_version = version;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetOutput(path) => {
|
||||
self.content.pack.output = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ToggleConflict(path) => {
|
||||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::OpenExport => {
|
||||
self.content.pack.export_open = true;
|
||||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||||
if self.content.pack.export_name.trim().is_empty() {
|
||||
self.content.pack.export_name =
|
||||
self.content.pack.instance.clone().unwrap_or_default();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import => {
|
||||
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
if let Some(request) = self.pack_import_request() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::PickPage(page) => {
|
||||
self.content.pack.pick_page = page;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::TogglePick(path) => {
|
||||
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
|
||||
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
|
||||
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
|
||||
// (所以"一次都没点过"仍然等于 `KeepAll`)。
|
||||
let choice = self.content.pack.pick_choice(&path);
|
||||
let flipped = match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
|
||||
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
|
||||
};
|
||||
self.content.pack.pick_choices.insert(path, flipped);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickKeepAll => {
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickOverwriteAll => {
|
||||
self.content.pack.pick_overwrite_all = true;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ConfirmImport => {
|
||||
let decisions = self
|
||||
.content
|
||||
.pack
|
||||
.decisions(self.state.pack.import.scan.as_ref());
|
||||
if let Some(request) = self.pack_import_request_with(decisions) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::CancelImport => {
|
||||
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
|
||||
if let Some(request) =
|
||||
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
|
||||
{
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Cancel => {
|
||||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||||
self.dispatch(AppCommand::CancelInstall);
|
||||
}
|
||||
PackMessage::Precheck => {
|
||||
if let Some(request) = self.pack_export_request() {
|
||||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||||
self.state.pack.set_export_request(request.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Confirm => {
|
||||
if let Some(request) = self.state.pack.export.request.clone() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检干净(没有冲突)时直接往下导。
|
||||
///
|
||||
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
|
||||
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
|
||||
/// 结论也只会是"保留用户的"。
|
||||
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
|
||||
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
|
||||
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
|
||||
fn pack_import_request(&self) -> Option<ImportRequestView> {
|
||||
self.pack_import_request_with(ConflictDecisionsView::default())
|
||||
}
|
||||
|
||||
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
|
||||
fn pack_import_request_with(
|
||||
&self,
|
||||
decisions: ConflictDecisionsView,
|
||||
) -> Option<ImportRequestView> {
|
||||
let draft = self.content.pack.draft.trim();
|
||||
if draft.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self.content.pack.instance_name.trim();
|
||||
Some(ImportRequestView {
|
||||
pack: std::path::PathBuf::from(draft),
|
||||
instance: self.content.pack.instance.clone(),
|
||||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||||
decisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 实例页「启动参数」区的消息落点(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,6 +54,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
|
||||
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
|
||||
pages::Message::Update(action) => return self.on_update_action(action),
|
||||
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
|
||||
pages::Message::Appearance(change) => self.on_appearance(change),
|
||||
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
|
||||
@@ -88,6 +90,8 @@ impl App {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
|
||||
pages::Message::Instances(message) => return self.on_instance_message(message),
|
||||
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
|
||||
pages::Message::Account(message) => return self.on_account(message),
|
||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
|
||||
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
|
||||
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
|
||||
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
|
||||
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::pages::UpdateAction;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 一条更新动作。
|
||||
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
|
||||
match action {
|
||||
UpdateAction::CheckNow => {
|
||||
if self.update_busy() {
|
||||
return self.refuse_busy("立即检查");
|
||||
}
|
||||
self.dispatch(AppCommand::CheckUpdate)
|
||||
}
|
||||
UpdateAction::Apply => {
|
||||
if self.update_busy() {
|
||||
return self.refuse_busy("下载并安装");
|
||||
}
|
||||
self.dispatch(AppCommand::ApplyUpdate)
|
||||
}
|
||||
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
|
||||
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
|
||||
UpdateAction::RestartNow => return self.restart_now(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 有没有一条检查/下载正在跑。
|
||||
///
|
||||
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
|
||||
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
|
||||
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
|
||||
///
|
||||
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
|
||||
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
|
||||
fn update_busy(&self) -> bool {
|
||||
let view = &self.state.update;
|
||||
view.applying || view.outcome.is_checking()
|
||||
}
|
||||
|
||||
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
|
||||
fn refuse_busy(&self, what: &str) -> Task<Message> {
|
||||
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
||||
///
|
||||
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
||||
/// 文件、并把取证进程拖成死循环。
|
||||
fn restart_now(&mut self) -> Task<Message> {
|
||||
if self.options.screenshot.is_some() {
|
||||
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
|
||||
return Task::none();
|
||||
}
|
||||
match std::env::current_exe() {
|
||||
Ok(exe) => {
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
match std::process::Command::new(&exe).args(&args).spawn() {
|
||||
Ok(child) => eprintln!(
|
||||
"[update] 已拉起新进程 pid={}:{}",
|
||||
child.id(),
|
||||
exe.display()
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("[update] 取 current_exe 失败:{error}");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
iced::exit()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages::UpdateAction;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
|
||||
struct Recorder {
|
||||
seen: Arc<Mutex<Vec<AppCommand>>>,
|
||||
}
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.seen
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.push(command);
|
||||
}
|
||||
|
||||
fn tick(&mut self, _elapsed: std::time::Duration) {}
|
||||
}
|
||||
|
||||
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder {
|
||||
seen: Arc::clone(&seen),
|
||||
}),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
(app, seen)
|
||||
}
|
||||
|
||||
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
|
||||
seen.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("应当派发过一条命令")
|
||||
}
|
||||
|
||||
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
|
||||
#[test]
|
||||
fn every_update_action_reaches_the_backend() {
|
||||
let (mut app, seen) = app();
|
||||
|
||||
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert_eq!(last(&seen), AppCommand::CheckUpdate);
|
||||
|
||||
let _ = app.on_update_action(UpdateAction::Apply);
|
||||
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
|
||||
|
||||
let _ = app.on_update_action(UpdateAction::Dismiss);
|
||||
assert_eq!(last(&seen), AppCommand::DismissUpdate);
|
||||
|
||||
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
|
||||
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
|
||||
}
|
||||
|
||||
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
|
||||
///
|
||||
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
|
||||
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
|
||||
#[test]
|
||||
fn pressing_while_busy_dispatches_nothing() {
|
||||
let (mut app, seen) = app();
|
||||
|
||||
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
|
||||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert!(
|
||||
seen.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_empty(),
|
||||
"正在检查时再按「立即检查」不许派发第二条命令"
|
||||
);
|
||||
|
||||
// 检查回来(失败也算回来):不忙了,能再点。
|
||||
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
|
||||
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
|
||||
);
|
||||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
|
||||
|
||||
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
|
||||
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
|
||||
app.state.update.applying = true;
|
||||
let _ = app.on_update_action(UpdateAction::Apply);
|
||||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert_eq!(
|
||||
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
|
||||
before,
|
||||
"applying 期间不许派发任何新的检查/安装"
|
||||
);
|
||||
|
||||
// 应用结束:恢复可点。
|
||||
app.state.update.applying = false;
|
||||
let _ = app.on_update_action(UpdateAction::Apply);
|
||||
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
|
||||
}
|
||||
|
||||
/// 截图通道不重启:取证进程不许把自己重启成死循环。
|
||||
#[test]
|
||||
fn the_screenshot_channel_never_restarts() {
|
||||
let (mut app, seen) = app();
|
||||
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
|
||||
let _ = app.on_update_action(UpdateAction::RestartNow);
|
||||
assert!(
|
||||
seen.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_empty(),
|
||||
"重启不是一条后端指令(不该派发任何命令)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,14 @@ use super::state::{log_first_frame, App};
|
||||
use super::APP_TITLE;
|
||||
|
||||
mod account_page;
|
||||
mod instances;
|
||||
pub(super) mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
mod update;
|
||||
|
||||
use instances::{detail_of, runtime_status, supporting_line};
|
||||
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||
use logs::logs_model;
|
||||
use update::update_of;
|
||||
|
||||
impl App {
|
||||
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
|
||||
@@ -92,6 +94,8 @@ impl App {
|
||||
},
|
||||
// M106:设置页 General 组要画「当前档位 + 生效语言」。
|
||||
self.language(),
|
||||
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
|
||||
&self.launch_form,
|
||||
)
|
||||
.map(Message::Page),
|
||||
agent::docked(&self.ui, session, agent_catalog, self.width)
|
||||
@@ -136,6 +140,7 @@ impl App {
|
||||
pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
height: f32,
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
draft: &pages::InstallDraft,
|
||||
@@ -154,7 +159,7 @@ pub(super) fn model(
|
||||
id: cell.instance.id.clone(),
|
||||
name: cell.instance.name.clone(),
|
||||
supporting: supporting_line(cell.instance, catalog),
|
||||
status: runtime_status(&cell.instance.runtime, catalog),
|
||||
status: runtime_status_row(&cell.instance.runtime, catalog),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -170,12 +175,15 @@ pub(super) fn model(
|
||||
pages::Model {
|
||||
screen,
|
||||
window_width: width,
|
||||
window_height: height,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
lang,
|
||||
forced: forced.map(|state| match state {
|
||||
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
|
||||
use crate::pages::{Detail, Meta};
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
@@ -59,7 +62,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
|
||||
}
|
||||
|
||||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||||
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Idle => None,
|
||||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||||
@@ -71,11 +74,101 @@ pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
|
||||
InstanceRuntime::Running { pid } => {
|
||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||
}
|
||||
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
|
||||
InstanceRuntime::Failed { reason } => {
|
||||
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
|
||||
// 失败有**两种形状**,各说各的话(复审 P1-1):
|
||||
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
|
||||
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
|
||||
// 绝不说成「启动游戏进程失败」。
|
||||
//
|
||||
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
|
||||
// (单行、不许折行,见 D-M79-5)。
|
||||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条失败状态行。
|
||||
///
|
||||
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
|
||||
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
|
||||
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
|
||||
match cause {
|
||||
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
|
||||
Key::LaunchStageFailed,
|
||||
&[
|
||||
("stage", failure_stage_label(*stage, catalog)),
|
||||
("detail", detail.as_str()),
|
||||
],
|
||||
),
|
||||
LaunchFailureCause::Stage { stage, .. } => catalog.render(
|
||||
Key::InstancesFailed,
|
||||
&[("reason", failure_stage_label(*stage, catalog))],
|
||||
),
|
||||
LaunchFailureCause::Crashed { exit_code } => {
|
||||
let reason = match exit_code {
|
||||
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
|
||||
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
|
||||
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
|
||||
};
|
||||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||||
}
|
||||
LaunchFailureCause::SilentFailure { .. } => {
|
||||
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
|
||||
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
|
||||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行用的运行状态:**单行**(`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()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||||
@@ -135,6 +228,17 @@ pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail
|
||||
instance.runtime,
|
||||
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
|
||||
),
|
||||
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
|
||||
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
|
||||
launch: LaunchFacts {
|
||||
allowed: instance.launch.graphics_backend_allowed,
|
||||
mc_version: instance.launch.mc_version.clone(),
|
||||
min_version: instance.launch.graphics_backend_min_version.clone(),
|
||||
memory_effective_mb: instance.launch.memory_effective_mb,
|
||||
java_effective: instance.launch.java_effective.clone(),
|
||||
backend_effective: instance.launch.graphics_backend_effective.clone(),
|
||||
options: instance.launch.graphics_backend_options.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ fn crashed_only() -> AppState {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
}));
|
||||
|
||||
state
|
||||
@@ -166,7 +168,9 @@ fn an_imported_crash_file_still_shows_its_findings() {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
}));
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(
|
||||
"file:crash-2026-11-02_19.40.15-client.txt",
|
||||
|
||||
@@ -7,6 +7,9 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
#[path = "tests/launch.rs"]
|
||||
mod launch;
|
||||
|
||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
@@ -22,6 +25,7 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: unlucky_presenter::dto::LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +42,7 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
|
||||
model(
|
||||
state,
|
||||
1180.0,
|
||||
760.0,
|
||||
forced,
|
||||
pages::Category::All,
|
||||
&draft,
|
||||
@@ -121,21 +126,6 @@ fn a_busy_instance_offers_stop_instead_of_play() {
|
||||
assert!(detail.can_stop, "启动中要能 Stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
reason: "exit 1".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · exit 1")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_sliced_not_guessed() {
|
||||
let en = Lang::En.catalog();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
|
||||
//!
|
||||
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
|
||||
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
// 进程**没起来**:说「哪一步 + 原文」。
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
|
||||
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
});
|
||||
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process")
|
||||
);
|
||||
// 详情页给全文(阶段 + 原文)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process: os error 5")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
|
||||
///
|
||||
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
|
||||
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
|
||||
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
|
||||
#[test]
|
||||
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
|
||||
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
|
||||
|
||||
let line = |cause: LaunchFailureCause, lang: Lang| {
|
||||
let runtime = InstanceRuntime::Failed { cause };
|
||||
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
|
||||
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
|
||||
(row, detail)
|
||||
};
|
||||
|
||||
// ① 没起来:说阶段。
|
||||
let (row, detail) = line(
|
||||
LaunchFailureCause::Stage {
|
||||
stage: LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
Lang::En,
|
||||
);
|
||||
assert_eq!(row, "Launch failed · Starting the game process");
|
||||
assert_eq!(
|
||||
detail,
|
||||
"Launch failed · Starting the game process: os error 5"
|
||||
);
|
||||
|
||||
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
|
||||
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
|
||||
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
|
||||
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
|
||||
assert!(
|
||||
!detail.contains("Starting the game process"),
|
||||
"进程起来了,不许说「启动游戏进程」:{detail}"
|
||||
);
|
||||
|
||||
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
|
||||
let (row, zh) = line(
|
||||
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
Lang::ZhCn,
|
||||
);
|
||||
assert_eq!(row, "启动失败 · 退出码 0,没起来");
|
||||
assert_eq!(zh, row);
|
||||
assert!(row.contains("没起来"), "{row}");
|
||||
|
||||
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
|
||||
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · The game crashed");
|
||||
}
|
||||
|
||||
///
|
||||
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
|
||||
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
|
||||
///
|
||||
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
|
||||
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
|
||||
#[test]
|
||||
fn the_launch_toast_speaks_the_interface_language() {
|
||||
use unlucky_presenter::dto::{
|
||||
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
|
||||
};
|
||||
|
||||
let finished = |outcome, failure| LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 12_500,
|
||||
failure,
|
||||
};
|
||||
|
||||
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
|
||||
let ok = finished(LogOutcome::Ok, None);
|
||||
let en = super::instances::launch_toast(&ok, Lang::En);
|
||||
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
|
||||
assert_eq!(en, "Finished normally · 12.5s");
|
||||
assert_eq!(zh, "正常结束 · 12.5 秒");
|
||||
assert_ne!(en, zh, "切语言时提示语要变");
|
||||
|
||||
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
|
||||
let failed = finished(
|
||||
LogOutcome::Crashed,
|
||||
Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::InstanceConfig,
|
||||
"permission denied (os error 5)",
|
||||
)),
|
||||
);
|
||||
let en = super::instances::launch_toast(&failed, Lang::En);
|
||||
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
|
||||
assert!(
|
||||
en.starts_with("Launch failed · Reading the instance settings"),
|
||||
"{en}"
|
||||
);
|
||||
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
|
||||
for text in [&en, &zh] {
|
||||
assert!(
|
||||
text.contains("permission denied (os error 5)"),
|
||||
"原文(取证信息)不许丢:{text}"
|
||||
);
|
||||
}
|
||||
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
|
||||
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
|
||||
assert!(
|
||||
!zh.contains("Launch failed"),
|
||||
"中文提示里混了英文前缀:{zh}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有没有中文字符(上面那条「不许混语言」的判据)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
|
||||
//!
|
||||
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
|
||||
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
|
||||
//!
|
||||
//! | DTO | 页面视图 |
|
||||
//! |---|---|
|
||||
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
|
||||
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
|
||||
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
|
||||
|
||||
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
|
||||
|
||||
use crate::pages::{Update, UpdateStatus};
|
||||
|
||||
/// 快照 → 视图模型。
|
||||
pub(super) fn update_of(view: &UpdateView) -> Update {
|
||||
Update {
|
||||
current_version: view.current_version.clone(),
|
||||
channel: view.channel.clone(),
|
||||
auto_check: view.auto_check,
|
||||
status: status_of(&view.outcome),
|
||||
checked_at: view.checked_at.clone(),
|
||||
applying: view.applying,
|
||||
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
|
||||
skipped: view.skipped.clone(),
|
||||
silent: view.silent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
|
||||
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
|
||||
match outcome {
|
||||
UpdateOutcome::Idle => UpdateStatus::Idle,
|
||||
UpdateOutcome::Checking => UpdateStatus::Checking,
|
||||
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
|
||||
latest: latest.clone(),
|
||||
},
|
||||
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
|
||||
version: candidate.version.clone(),
|
||||
size: candidate.size,
|
||||
notes: candidate.notes.clone(),
|
||||
forced: candidate.forced,
|
||||
},
|
||||
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
|
||||
version: version.clone(),
|
||||
},
|
||||
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
|
||||
reason: failure.message.clone(),
|
||||
retryable: failure.retryable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
|
||||
|
||||
fn view(outcome: UpdateOutcome) -> UpdateView {
|
||||
let mut view = UpdateView::new("0.2.2", "stable", true);
|
||||
view.outcome = outcome;
|
||||
view
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_is_never_mapped_to_up_to_date() {
|
||||
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"取更新清单失败:连接被重置",
|
||||
true,
|
||||
))));
|
||||
assert!(
|
||||
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
|
||||
"失败必须原样带原因,实际 {:?}",
|
||||
mapped.status
|
||||
);
|
||||
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
|
||||
|
||||
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
|
||||
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"连不上",
|
||||
true,
|
||||
)));
|
||||
silent.silent = true;
|
||||
let mapped = update_of(&silent);
|
||||
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
|
||||
assert!(mapped.banner().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_outcomes_stay_distinct() {
|
||||
let available = update_of(&view(UpdateOutcome::Available(Box::new(
|
||||
unlucky_presenter::dto::UpdateCandidateView {
|
||||
version: "0.2.3".to_string(),
|
||||
size: 21_000_000,
|
||||
notes: "修掉启动崩溃".to_string(),
|
||||
forced: false,
|
||||
},
|
||||
))));
|
||||
assert!(available.available().is_some());
|
||||
assert!(matches!(
|
||||
available.banner(),
|
||||
Some(crate::pages::Banner::Available { .. })
|
||||
));
|
||||
|
||||
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.2".to_string()),
|
||||
}));
|
||||
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
|
||||
assert!(up_to_date.banner().is_none());
|
||||
|
||||
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
|
||||
version: "0.2.3".to_string(),
|
||||
}));
|
||||
assert!(matches!(
|
||||
missing.status,
|
||||
UpdateStatus::MissingArtifact { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
|
||||
let mut view = view(UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.3".to_string()),
|
||||
});
|
||||
view.ready = Some(ReadyUpdate {
|
||||
version: "0.2.3".to_string(),
|
||||
path: "C:/ULL/unlucky-launcher.exe".to_string(),
|
||||
backup: None,
|
||||
});
|
||||
let mapped = update_of(&view);
|
||||
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
|
||||
assert!(matches!(
|
||||
mapped.banner(),
|
||||
Some(crate::pages::Banner::Ready { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
|
||||
let mut view = view(UpdateOutcome::Available(Box::new(
|
||||
unlucky_presenter::dto::UpdateCandidateView {
|
||||
version: "0.2.3".to_string(),
|
||||
size: 1,
|
||||
notes: String::new(),
|
||||
forced: false,
|
||||
},
|
||||
)));
|
||||
view.skipped = Some("0.2.3".to_string());
|
||||
let mapped = update_of(&view);
|
||||
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
|
||||
assert!(mapped.available().is_some(), "候选要留着给设置页");
|
||||
assert!(mapped.banner().is_none());
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,43 @@ pub fn resolve() -> Resolved {
|
||||
resolve_with(None)
|
||||
}
|
||||
|
||||
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
|
||||
///
|
||||
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
|
||||
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
|
||||
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
|
||||
fn register_termux_fonts() {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| {
|
||||
if !termux::is_termux(&ProcessEnv) {
|
||||
return;
|
||||
}
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"/system/fonts/NotoSansCJK-Regular.ttc",
|
||||
"/system/fonts/NotoSansCJKSC-Regular.otf",
|
||||
"/system/fonts/DroidSansFallback.ttf",
|
||||
];
|
||||
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
|
||||
return;
|
||||
};
|
||||
let db = font_system.raw().db_mut();
|
||||
for path in CANDIDATES {
|
||||
if db.load_font_file(path).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
register_termux_fonts();
|
||||
|
||||
let platform = Platform::current();
|
||||
let faces = system_faces();
|
||||
|
||||
|
||||
@@ -81,8 +81,9 @@ pub fn view<'a>(
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: Lang,
|
||||
window_height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
view::view(ui, metrics, model, lang)
|
||||
view::view(ui, metrics, model, lang, window_height)
|
||||
}
|
||||
|
||||
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) |
|
||||
//!
|
||||
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
|
||||
use super::model::{
|
||||
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
|
||||
};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||||
@@ -147,9 +149,45 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
let targets = targets();
|
||||
let target = Some("survival".to_string());
|
||||
let empty = state == "empty";
|
||||
// M162:「未联网」那一屏(与空态分开画)
|
||||
let offline = state == "offline";
|
||||
// M162:详情通道的版本列表(真实路径来自 presenter 的 `ContentDetail`)
|
||||
let demo_versions = if empty || offline {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![
|
||||
VersionRow {
|
||||
number: "mc1.20.1-0.5.11".to_string(),
|
||||
kind: "release".to_string(),
|
||||
file_name: Some("sodium-fabric-0.5.11.jar".to_string()),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", "mc1.20.1-0.5.11"),
|
||||
("kind", "Release"),
|
||||
("date", "2025-06-01"),
|
||||
],
|
||||
),
|
||||
},
|
||||
VersionRow {
|
||||
number: "mc1.20.1-0.5.10".to_string(),
|
||||
kind: "beta".to_string(),
|
||||
file_name: Some("sodium-fabric-0.5.10.jar".to_string()),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", "mc1.20.1-0.5.10"),
|
||||
("kind", "Beta"),
|
||||
("date", "2025-05-20"),
|
||||
],
|
||||
),
|
||||
},
|
||||
]
|
||||
};
|
||||
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
|
||||
let installing = state == "install";
|
||||
let list = if empty {
|
||||
// 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络)
|
||||
let list = if empty || offline {
|
||||
Vec::new()
|
||||
} else {
|
||||
hits(kind, catalog, state != "incompatible")
|
||||
@@ -196,7 +234,13 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
},
|
||||
hits: list,
|
||||
selected,
|
||||
status: if empty { Status::Empty } else { Status::Ready },
|
||||
status: if offline {
|
||||
Status::Offline("connect timed out after 10s".to_string())
|
||||
} else if empty {
|
||||
Status::Empty
|
||||
} else {
|
||||
Status::Ready
|
||||
},
|
||||
total: if empty { 0 } else { 1_284 },
|
||||
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
|
||||
has_more: !empty,
|
||||
@@ -211,6 +255,8 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
None
|
||||
},
|
||||
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
|
||||
available_versions: demo_versions,
|
||||
versions_note: None,
|
||||
draft: if empty {
|
||||
"quarkonium".to_string()
|
||||
} else {
|
||||
|
||||
@@ -74,6 +74,53 @@ impl Tier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主列在**矮窗口**下切成「整列一个滚动条」的门槛(M161)。
|
||||
///
|
||||
/// ## 为什么需要这条门槛
|
||||
///
|
||||
/// 主列自上而下是:搜索条(48)+ 筛选四排(4×32 + 3×8 = 152)+ 计数头(20)+ 结果列表 + 「加载更多」(40)
|
||||
/// 再加两条 `Space::X2` 间距。列表自己没有最小高度,于是窗口一矮,**列表视口只剩几十像素**:
|
||||
/// 一行 `ListItem` 是 60/76 高,被切成半行——两行文字(overline + 标题)看起来像「折行挤压」。
|
||||
///
|
||||
/// ## 明确的两档行为(不是到处加滚动条)
|
||||
///
|
||||
/// | 正文可用高 | 版面 |
|
||||
/// |---|---|
|
||||
/// | ≥ [`MIN_BODY_HEIGHT`] | 现状:**列表自己滚**(筛选条常驻,滚的时候不跑掉) |
|
||||
/// | < [`MIN_BODY_HEIGHT`] | 主列整列变成**一个**纵向滚动区:列表按自然高度铺开,一行都不压 |
|
||||
///
|
||||
/// 阈值 = 固定件(48 + 152 + 20 + 40 + 间距 ≈ 276)+ 至少三整行(3×60 = 180)= 456,
|
||||
/// 取 **460**(往上抹到 4 的倍数,给 divider / 取整留余量)。数值改了就改这里的算式与
|
||||
/// `layout/tests` 的等式用例。
|
||||
pub const MIN_BODY_HEIGHT: f32 = 460.0;
|
||||
|
||||
/// 正文高度的两档行为。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BodyMode {
|
||||
/// 高够:列表自己滚(筛选条常驻)
|
||||
Split,
|
||||
/// 矮窗口:主列整列一个滚动区,列表按自然高度铺开
|
||||
ScrollWhole,
|
||||
}
|
||||
|
||||
impl BodyMode {
|
||||
/// 判据(纯函数,测得到)。
|
||||
#[must_use]
|
||||
pub fn of(body_height: f32) -> Self {
|
||||
if body_height >= MIN_BODY_HEIGHT {
|
||||
Self::Split
|
||||
} else {
|
||||
Self::ScrollWhole
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表要不要自己滚。
|
||||
#[must_use]
|
||||
pub const fn list_scrolls(self) -> bool {
|
||||
matches!(self, Self::Split)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
|
||||
pub fn wide_min() -> f32 {
|
||||
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
||||
@@ -192,3 +239,101 @@ mod tests {
|
||||
assert!(!Tier::Narrow.rail_has_labels());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod body_mode_tests {
|
||||
use super::*;
|
||||
|
||||
/// 门槛是**算出来的**:固定件(搜索 48 + 筛选 152 + 计数 20 + 加载更多 40 + 两条 8 间距)
|
||||
/// 加三整行列表(3 × 60)。谁把 460 改成拍脑袋的数,这条会红。
|
||||
#[test]
|
||||
fn the_minimum_body_height_is_fixed_parts_plus_three_rows() {
|
||||
let fixed = 48.0 + 152.0 + 20.0 + 40.0 + 2.0 * Space::X2.dp();
|
||||
let three_rows = 3.0 * 60.0;
|
||||
assert!(
|
||||
MIN_BODY_HEIGHT >= fixed + three_rows,
|
||||
"门槛 {MIN_BODY_HEIGHT} 小于「固定件 {fixed} + 三行 {three_rows}」——矮窗口还是会切半行"
|
||||
);
|
||||
assert!(
|
||||
MIN_BODY_HEIGHT <= fixed + three_rows + 16.0,
|
||||
"门槛留了太多余量:{} 会把还放得下的窗口也推进「整列滚动」档",
|
||||
MIN_BODY_HEIGHT - (fixed + three_rows)
|
||||
);
|
||||
}
|
||||
|
||||
/// 两档在门槛处切开,且只有 `Split` 让列表自己滚。
|
||||
#[test]
|
||||
fn the_body_mode_flips_at_the_threshold() {
|
||||
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT), BodyMode::Split);
|
||||
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT - 0.5), BodyMode::ScrollWhole);
|
||||
assert!(BodyMode::of(900.0).list_scrolls());
|
||||
assert!(!BodyMode::of(300.0).list_scrolls());
|
||||
}
|
||||
|
||||
/// 最小窗口(880×560)落在**矮**档,验收到 760 不换版面——**正文可用高按真实外壳算**。
|
||||
///
|
||||
/// 复审 P2-2:早先这里是 `560 − 56 − 88 − 32` 反推的(三个数全错:真实是 titlebar 48 /
|
||||
/// 两行页头 84 / Expanded 页边距 2×24 = chrome 180),结论碰巧对、但抓不住 metrics 漂移。
|
||||
/// 现在直接调 [`crate::pages::frame::body_height`],chrome 从 token 现取。
|
||||
#[test]
|
||||
fn the_minimum_window_size_uses_the_scrolling_body() {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::pages::frame;
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
let ui = Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
);
|
||||
// 880 宽 = 窗口最小尺寸(`app::MIN_SIZE.width`),属 Expanded。
|
||||
let metrics = frame_metrics(PaneKit::Single, 880.0);
|
||||
let chrome =
|
||||
ui.metrics().titlebar_height + ui.metrics().header_height(true) + 2.0 * metrics.gutter;
|
||||
|
||||
// 真值:48 + 84 + 48 = 180。这条会随 token 改而红——那时请重看一眼两档版面。
|
||||
assert_eq!(chrome, 180.0, "外壳 chrome 与 design token 不一致");
|
||||
|
||||
// 最小窗口 560 高:正文只剩 380 < 460 ⇒ 整列滚动(本条债的真实场景)。
|
||||
let short = frame::body_height(&ui, metrics, 560.0, true);
|
||||
assert_eq!(short, 560.0 - chrome);
|
||||
assert_eq!(
|
||||
BodyMode::of(short),
|
||||
BodyMode::ScrollWhole,
|
||||
"正文只剩 {short}"
|
||||
);
|
||||
|
||||
// 验收尺寸 760 高:580 ≥ 460 ⇒ 列表自己滚(不换版面)。
|
||||
let tall = frame::body_height(&ui, metrics, 760.0, true);
|
||||
assert_eq!(BodyMode::of(tall), BodyMode::Split);
|
||||
|
||||
// 把门槛和**真实 chrome** 绑死:窗口高 = chrome + MIN 时必须还是 Split,
|
||||
// 少 1px 就翻成整列滚动。chrome 涨到 100/页边距 40 这类漂移会让这条立刻红
|
||||
// (而不是像旧用例那样拿反推数字自说自话)。
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT,
|
||||
true
|
||||
)),
|
||||
BodyMode::Split
|
||||
);
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT - 1.0,
|
||||
true
|
||||
)),
|
||||
BodyMode::ScrollWhole
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Catalog;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::pages::download::Download;
|
||||
|
||||
@@ -35,7 +35,7 @@ mod items;
|
||||
|
||||
pub(crate) use items::HitFacts;
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
|
||||
|
||||
/// 内容库页要画的东西。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -75,6 +75,10 @@ pub struct Content {
|
||||
pub draft: String,
|
||||
/// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检
|
||||
pub pack: super::packui::Pack,
|
||||
/// 选中项目的可用版本(M162:详情通道;空 = 还没拉到 / 站点没给)
|
||||
pub available_versions: Vec<VersionRow>,
|
||||
/// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说)
|
||||
pub versions_note: Option<String>,
|
||||
}
|
||||
|
||||
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
|
||||
@@ -156,6 +160,9 @@ impl Content {
|
||||
catalog,
|
||||
);
|
||||
|
||||
let (available_versions, versions_note) =
|
||||
detail_view(content, page.selected.as_deref(), catalog);
|
||||
|
||||
Self {
|
||||
kind: page.kind,
|
||||
query: ContentQueryView {
|
||||
@@ -183,6 +190,8 @@ impl Content {
|
||||
versions,
|
||||
draft: page.draft.clone(),
|
||||
pack,
|
||||
available_versions,
|
||||
versions_note,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +222,13 @@ impl Content {
|
||||
|
||||
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
|
||||
fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
|
||||
if let Some(reason) = &content.failed {
|
||||
return Status::Failed(reason.clone());
|
||||
if let Some(failure) = &content.failed {
|
||||
// M162:没联网单独一态(空列表不能冒充「没有结果」)
|
||||
return if failure.is_offline() {
|
||||
Status::Offline(failure.detail.clone())
|
||||
} else {
|
||||
Status::Failed(failure.detail.clone())
|
||||
};
|
||||
}
|
||||
if content.pending || content.results.is_none() {
|
||||
return Status::Loading;
|
||||
@@ -237,6 +251,57 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
|
||||
.and_then(|progress| crate::pages::download::install_panel_model(progress, catalog))
|
||||
}
|
||||
|
||||
/// 选中项目的版本列表 + 一句说明(详情通道;迟到结果按项目 id 丢)。
|
||||
///
|
||||
/// 三条优先级:**有结果就画结果** → 在飞就画「正在取」 → 失败才画原因。选中项变了而结果是
|
||||
/// 上一个项目的,一律当「没有」(`detail.project != selected`)——否则会画着 A 的版本列表
|
||||
/// 却高亮着 B。
|
||||
fn detail_view(
|
||||
content: &ContentState,
|
||||
selected: Option<&str>,
|
||||
catalog: Catalog,
|
||||
) -> (Vec<VersionRow>, Option<String>) {
|
||||
let Some(selected) = selected else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
if let Some(detail) = content.detail.as_ref().filter(|d| d.project == selected) {
|
||||
let rows = detail
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| VersionRow {
|
||||
number: version.number.clone(),
|
||||
kind: version.kind.clone(),
|
||||
file_name: version.file_name.clone(),
|
||||
line: catalog.render(
|
||||
Key::ContentDetailVersionLine,
|
||||
&[
|
||||
("number", version.number.as_str()),
|
||||
("kind", capitalize(&version.kind).as_str()),
|
||||
("date", version.published.as_str()),
|
||||
],
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
return (rows, None);
|
||||
}
|
||||
if content.detail_pending {
|
||||
return (
|
||||
Vec::new(),
|
||||
Some(catalog.text(Key::ContentDetailFetching).to_string()),
|
||||
);
|
||||
}
|
||||
match &content.detail_failed {
|
||||
Some(failure) => (
|
||||
Vec::new(),
|
||||
Some(catalog.render(
|
||||
Key::ContentDetailFetchFailed,
|
||||
&[("reason", failure.detail.as_str())],
|
||||
)),
|
||||
),
|
||||
None => (Vec::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
|
||||
///
|
||||
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
|
||||
|
||||
@@ -45,13 +45,34 @@ pub enum Status {
|
||||
Empty,
|
||||
/// 失败(带一句人读原因)
|
||||
Failed(String),
|
||||
/// 未联网(M162):**不是**「没有匹配的内容」,也不是普通失败——单独一屏,
|
||||
/// 说清「要么联网要么没结果」这件事不可能靠空列表表达。
|
||||
Offline(String),
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// 失败了吗(视图用它决定画错误面板还是列表)。
|
||||
pub const fn failed(&self) -> bool {
|
||||
matches!(self, Self::Failed(_))
|
||||
matches!(self, Self::Failed(_) | Self::Offline(_))
|
||||
}
|
||||
|
||||
/// 未联网吗(视图据此画离线态而不是通用失败态)。
|
||||
pub const fn offline(&self) -> bool {
|
||||
matches!(self, Self::Offline(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本列表的一行(M162:详情通道拉回来的 `ContentVersionView`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionRow {
|
||||
/// 版本号(`mc1.20.1-0.5.11`)
|
||||
pub number: String,
|
||||
/// 版本类型(`release` / `beta` / `alpha`,站点原文)
|
||||
pub kind: String,
|
||||
/// 文件名(`None` = 站点没给)
|
||||
pub file_name: Option<String>,
|
||||
/// 已经求好文案的一行(`版本号 · 类型 · 日期`)
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use super::*;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
|
||||
LoaderKind,
|
||||
ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, ContentQuery,
|
||||
ContentResults, ContentSource, ContentVersionView, InstanceRuntime, LoaderKind,
|
||||
};
|
||||
|
||||
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
|
||||
@@ -200,10 +200,72 @@ fn the_four_states_are_derived_from_three_fields() {
|
||||
#[test]
|
||||
fn a_failure_outranks_the_loading_state() {
|
||||
let mut state = state_with(None, true);
|
||||
state.content.failed = Some("网络不通".to_string());
|
||||
state.content.failed = Some(ContentFailure::new(ContentFailureKind::Server, "站点 503"));
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
|
||||
assert_eq!(model.status, Status::Failed("站点 503".to_string()));
|
||||
assert!(model.status.failed());
|
||||
assert!(!model.status.offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_network_is_its_own_state_not_an_empty_list() {
|
||||
// M162:无网必须画「未联网」,不能与「没有匹配的内容」共用一个空态。
|
||||
let mut state = state_with(None, false);
|
||||
state.content.results = Some(results(Vec::new(), 0));
|
||||
state.content.failed = Some(ContentFailure::offline("connect timed out"));
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert_eq!(
|
||||
model.status,
|
||||
Status::Offline("connect timed out".to_string())
|
||||
);
|
||||
assert!(model.status.offline());
|
||||
assert!(model.status.failed());
|
||||
assert_ne!(model.status, Status::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_come_from_the_detail_channel_of_the_selected_project() {
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
let mut page = PageState {
|
||||
selected: Some("p1".to_string()),
|
||||
..PageState::default()
|
||||
};
|
||||
// 详情还没到:说明「正在取」,不是「一个版本都没有」
|
||||
state.content.request_detail("p1");
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert!(model.available_versions.is_empty());
|
||||
assert!(model.versions_note.is_some());
|
||||
|
||||
state.content.apply_detail(
|
||||
"p1",
|
||||
ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
description: "fast".to_string(),
|
||||
body: String::new(),
|
||||
versions: vec![ContentVersionView {
|
||||
id: "v1".to_string(),
|
||||
name: "Sodium 0.6.0".to_string(),
|
||||
number: "mc1.20.1-0.6.0".to_string(),
|
||||
kind: "release".to_string(),
|
||||
game_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
published: "2026-02-01T00:00:00Z".to_string(),
|
||||
file_name: Some("sodium.jar".to_string()),
|
||||
file_size: Some(2048),
|
||||
}],
|
||||
},
|
||||
);
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert_eq!(model.available_versions.len(), 1);
|
||||
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
|
||||
assert!(model.available_versions[0].line.contains("mc1.20.1-0.6.0"));
|
||||
assert!(model.versions_note.is_none());
|
||||
|
||||
// 换了选中项:上一个项目的版本列表不许画在这个项目下面
|
||||
page.selected = Some("p2".to_string());
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert!(model.available_versions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -223,6 +285,7 @@ fn can_install_needs_a_selection_and_no_blockers() {
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
version_present: true,
|
||||
launch: unlucky_presenter::dto::LaunchArgsView::default(),
|
||||
}];
|
||||
let catalog = Lang::En.catalog();
|
||||
let page = PageState {
|
||||
|
||||
@@ -28,14 +28,22 @@
|
||||
//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) |
|
||||
//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) |
|
||||
//!
|
||||
//! ## M160 新增的一屏
|
||||
//!
|
||||
//! `model/pick.rs` + `view/pick.rs` 是**导入前的冲突清单**:分页(每页 `PAGE_SIZE` 条)、
|
||||
//! 三个入口、计数摘要、确认/取消。它出现的前提是 core 的预检给出了至少一条冲突——
|
||||
//! 没有冲突时页面连这一屏都不画(直接往下导)。
|
||||
//!
|
||||
//! ## 本版明确的边界(报告里同样登记)
|
||||
//!
|
||||
//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、
|
||||
//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是
|
||||
//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。
|
||||
//! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 /
|
||||
//! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」,
|
||||
//! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。
|
||||
//! - **冲突是逐文件决策了(M160)**:点「导入」先跑一次**只读预检**(core 的 `scan_pack`),
|
||||
//! 没有冲突就直接导(不打扰用户);有冲突就画那一屏清单——分页([`model::PAGE_SIZE`] 条一页)、
|
||||
//! 「全部保留 / 全部覆盖 / 逐项」三个入口、计数摘要、确认与取消。**缺省永远是保留**:
|
||||
//! 没点过的文件一个都不会被包内内容替换。仍然不支持的是「两者都留(改名)」——那要改落盘
|
||||
//! 布局,不在本轮。
|
||||
|
||||
mod demo;
|
||||
mod message;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `pick` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//!
|
||||
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
|
||||
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
|
||||
@@ -20,8 +20,8 @@ use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
||||
ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView,
|
||||
InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
|
||||
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
@@ -68,13 +68,42 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
|
||||
match state {
|
||||
"importing" => import.progress = Some(progress()),
|
||||
// M160:导入前的冲突清单(分页 + 三个入口 + 计数摘要)
|
||||
"pick" => {
|
||||
import.scan = Some(scan(312));
|
||||
// 停在第 2 页,且已经逐项点过两条「覆盖」——图上能同时看见分页与逐项状态
|
||||
page.pick_page = 1;
|
||||
// 两条落到**第 2 页**的路径上:图上能同时看见「保留」与「覆盖」两种行状态
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-041.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-044.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
}
|
||||
"partial" => import.report = Some(report(ImportStatusView::Partial, false)),
|
||||
"conflict" => {
|
||||
import.report = Some(report(ImportStatusView::Partial, true));
|
||||
page.expanded = Some("config/sodium-options.json".to_string());
|
||||
}
|
||||
// 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野)
|
||||
"failed" => {
|
||||
// 致命态(M161 分成三档出图:预检没过 / 已回滚 / 回滚没做完)。
|
||||
// `failed` 保留旧名字(= 已回滚),另外两个是新档位。
|
||||
"failed" | "failed_rolled_back" => {
|
||||
let mut fatal = report(ImportStatusView::Failed, false);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "import_integrity".to_string(),
|
||||
label: "Integrity check".to_string(),
|
||||
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
|
||||
hint: Some("Re-download the pack from its source".to_string()),
|
||||
});
|
||||
fatal.rolled_back = true;
|
||||
fatal.failure_stage = Some(ImportFailureStageView::RolledBack);
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 预检没过:**一个字节都没写**(与上一条只差 `failure_stage`,图上要能一眼分辨)
|
||||
"failed_preflight" => {
|
||||
let mut fatal = report(ImportStatusView::Failed, false);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
@@ -84,6 +113,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
});
|
||||
fatal.files.clear();
|
||||
fatal.bytes_written = 0;
|
||||
fatal.rolled_back = true;
|
||||
fatal.failure_stage = Some(ImportFailureStageView::NotStarted);
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 回滚没做完:目标目录可能与导入前不同
|
||||
"failed_incomplete" => {
|
||||
let mut fatal = report(ImportStatusView::Failed, false);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "import_integrity".to_string(),
|
||||
label: "Integrity check".to_string(),
|
||||
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
|
||||
hint: None,
|
||||
});
|
||||
fatal.rolled_back = false;
|
||||
fatal.failure_stage = Some(ImportFailureStageView::RollbackIncomplete);
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
|
||||
@@ -167,6 +211,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
|
||||
registered: Some("Survival".to_string()),
|
||||
bytes_written: 3_400_000,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
if conflicts {
|
||||
report.conflicts = vec![
|
||||
@@ -194,6 +239,59 @@ fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条待决冲突(M160 预览清单的一行)。
|
||||
fn pending(index: usize) -> unlucky_presenter::dto::ImportConflictView {
|
||||
// 前几条用真实形态的路径,其余按序号生成:截图里既要有可读的真实感,也要有"很多条"的证据。
|
||||
let path = match index {
|
||||
0 => "config/sodium-options.json".to_string(),
|
||||
1 => "mods/create-1.2.3.jar".to_string(),
|
||||
2 => "config/jei/jei-client.ini".to_string(),
|
||||
3 => "options.txt".to_string(),
|
||||
_ => format!("config/generated/module-{index:03}.toml"),
|
||||
};
|
||||
unlucky_presenter::dto::ImportConflictView {
|
||||
path,
|
||||
source: if index.is_multiple_of(3) {
|
||||
unlucky_presenter::dto::ConflictSourceView::ClientOverrides
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictSourceView::Overrides
|
||||
},
|
||||
reason: if index.is_multiple_of(2) {
|
||||
unlucky_presenter::dto::ConflictReasonView::PreExisting
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictReasonView::UserModified
|
||||
},
|
||||
disabled: false,
|
||||
pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(),
|
||||
pack_size: 3_800 + (index as u64 * 37) % 900,
|
||||
disk_sha1: "c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string(),
|
||||
disk_size: 4_100 + (index as u64 * 53) % 1_200,
|
||||
base_sha1: (!index.is_multiple_of(2))
|
||||
.then(|| "3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检结果(`count` 条冲突;图上靠分页只画当前这一页)。
|
||||
fn scan(count: usize) -> unlucky_presenter::dto::ImportScanView {
|
||||
let conflicts: Vec<unlucky_presenter::dto::ImportConflictView> =
|
||||
(0..count).map(pending).collect();
|
||||
let lost_bytes = conflicts.iter().map(|each| each.disk_size).sum();
|
||||
let incoming_bytes = conflicts.iter().map(|each| each.pack_size).sum();
|
||||
unlucky_presenter::dto::ImportScanView {
|
||||
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
conflicts,
|
||||
unknown_remote: 4,
|
||||
lost_bytes,
|
||||
incoming_bytes,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出预检:12 内联 / 1 跳过(包不完整)。
|
||||
fn preview() -> ExportPreviewView {
|
||||
let mut files = vec![ExportFileView {
|
||||
|
||||
@@ -30,10 +30,22 @@ pub enum Message {
|
||||
Cancel,
|
||||
/// 关掉结果面板(报告 / 致命错误都走这一条)
|
||||
Dismiss,
|
||||
/// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
|
||||
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档)
|
||||
Overwrite,
|
||||
/// 展开/收起某一条冲突的三方明细
|
||||
ToggleConflict(String),
|
||||
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
|
||||
PickPage(usize),
|
||||
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
|
||||
TogglePick(String),
|
||||
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
|
||||
PickKeepAll,
|
||||
/// 整批档:全部覆盖
|
||||
PickOverwriteAll,
|
||||
/// 按当前选择开始导入
|
||||
ConfirmImport,
|
||||
/// 取消这次导入(不写盘,回到入口态)
|
||||
CancelImport,
|
||||
/// 换目标实例(`None` = 按包名新建)
|
||||
SetInstance(Option<InstanceId>),
|
||||
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
|
||||
|
||||
@@ -23,18 +23,28 @@
|
||||
//! ## 容忍 / 致命在模型层就分开
|
||||
//!
|
||||
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
|
||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
//! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
//!
|
||||
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
|
||||
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
|
||||
|
||||
mod export;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||
pub use pick::{Pick, PickRow};
|
||||
pub use progress::Running;
|
||||
pub use report::{ConflictRow, Report, Row};
|
||||
pub use report::{ConflictRow, FatalKind, Report, Row};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
|
||||
PackFormatView, ProbeView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
|
||||
@@ -61,6 +71,12 @@ pub struct PackPageState {
|
||||
pub export_open: bool,
|
||||
/// 展开的那条冲突路径
|
||||
pub expanded: Option<String>,
|
||||
/// 冲突清单翻到第几页(0 起;M160)
|
||||
pub pick_page: usize,
|
||||
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
|
||||
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
|
||||
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
|
||||
pub pick_overwrite_all: bool,
|
||||
}
|
||||
|
||||
impl Default for PackPageState {
|
||||
@@ -78,10 +94,65 @@ impl Default for PackPageState {
|
||||
output: String::new(),
|
||||
export_open: false,
|
||||
expanded: None,
|
||||
pick_page: 0,
|
||||
pick_choices: BTreeMap::new(),
|
||||
pick_overwrite_all: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPageState {
|
||||
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
|
||||
#[must_use]
|
||||
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
|
||||
if let Some(choice) = self.pick_choices.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 把这一屏的选择变成发给宿主的那份决策。
|
||||
///
|
||||
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
|
||||
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
|
||||
/// 全点一遍),而"没点过"永远落在保留上。
|
||||
#[must_use]
|
||||
pub fn decisions(
|
||||
&self,
|
||||
scan: Option<&unlucky_presenter::dto::ImportScanView>,
|
||||
) -> ConflictDecisionsView {
|
||||
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
|
||||
let default = if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
};
|
||||
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
|
||||
let items: Vec<ConflictDecisionView> = self
|
||||
.pick_choices
|
||||
.iter()
|
||||
.filter(|(_, choice)| **choice != default)
|
||||
.map(|(path, choice)| ConflictDecisionView {
|
||||
path: path.clone(),
|
||||
choice: *choice,
|
||||
})
|
||||
.collect();
|
||||
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
|
||||
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
|
||||
if items.is_empty() || empty {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
|
||||
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
|
||||
};
|
||||
}
|
||||
ConflictDecisionsView::per_item(default, items)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个实例候选(导入目标与导出源共用)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackTarget {
|
||||
@@ -132,6 +203,8 @@ pub struct Pack {
|
||||
pub cancelled_note: &'static str,
|
||||
/// 导入结果
|
||||
pub report: Option<Report>,
|
||||
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
|
||||
pub pick: Option<Pick>,
|
||||
/// 导出块
|
||||
pub export: Export,
|
||||
}
|
||||
@@ -181,6 +254,15 @@ impl Pack {
|
||||
.report
|
||||
.as_ref()
|
||||
.map(|report| report::report_of(report, page, catalog)),
|
||||
pick: import.scan.as_ref().and_then(|scan| {
|
||||
pick::pick_of(
|
||||
scan,
|
||||
page.pick_page,
|
||||
&page.pick_choices,
|
||||
page.pick_overwrite_all,
|
||||
catalog,
|
||||
)
|
||||
}),
|
||||
export: export::export_of(export, page, candidates, catalog),
|
||||
}
|
||||
}
|
||||
@@ -190,6 +272,7 @@ impl Pack {
|
||||
self.running.is_none()
|
||||
&& self.probe.is_none()
|
||||
&& self.report.is_none()
|
||||
&& self.pick.is_none()
|
||||
&& !self.cancelled
|
||||
&& !self.export.open
|
||||
&& self.draft.is_empty()
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 导入**冲突清单**的视图模型(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,6 +98,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
|
||||
bytes_total: 0,
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
|
||||
failure_kind: None,
|
||||
message: progress.current.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView,
|
||||
ImportStatusView, MissingFileView, MissingReasonView,
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
|
||||
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
|
||||
};
|
||||
|
||||
use super::PackPageState;
|
||||
@@ -48,10 +48,40 @@ pub struct ConflictRow {
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
/// 致命失败块(**已整包回滚**)。
|
||||
/// 致命失败块(**两种措辞,不共用**)。
|
||||
///
|
||||
/// 为什么不是一句「失败」:`NotStarted`(预检就没过,一个字节都没写)与 `RolledBack`
|
||||
/// (写进去了,又整包回滚干净)对用户是不同的消息——前者「去修包再试」,后者「刚才动过你的目录,
|
||||
/// 已经复原」。M161 之前两者共用一句「已回滚 —— 目标目录零残留」,预检失败时那句话是假的。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FatalKind {
|
||||
/// 预检/装配阶段失败:目标目录**一个字节都没写**
|
||||
NotStarted,
|
||||
/// 写过目标目录,回滚**完整**完成
|
||||
RolledBack,
|
||||
/// 写过,但回滚有失败项(目标目录可能与导入前不同)
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl FatalKind {
|
||||
/// 图标(与 [`Fatal::title`] 各说一遍:颜色/图标关掉时标题仍然分得清)。
|
||||
#[must_use]
|
||||
pub const fn icon(self) -> unlucky_design::icons::IconKind {
|
||||
match self {
|
||||
// 预检没过 = 「还没开始」,用警示而不是「已出错」:目录是干净的
|
||||
Self::NotStarted => unlucky_design::icons::IconKind::Warn,
|
||||
Self::RolledBack => unlucky_design::icons::IconKind::Restore,
|
||||
Self::RollbackIncomplete => unlucky_design::icons::IconKind::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败块。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fatal {
|
||||
/// 「已回滚 —— 目标目录零残留」
|
||||
/// 三种阶段之一(决定标题/图标/底色)
|
||||
pub kind: FatalKind,
|
||||
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
|
||||
pub title: String,
|
||||
/// 大类名(`路径穿越` / `清单损坏` …)
|
||||
pub label: String,
|
||||
@@ -124,7 +154,7 @@ pub(super) fn report_of(
|
||||
|
||||
Report {
|
||||
status: report.status,
|
||||
title: status_title(report.status, catalog).to_owned(),
|
||||
title: status_title(report, catalog).to_owned(),
|
||||
subtitle: subtitle(report, catalog),
|
||||
written_line: catalog.render(
|
||||
Key::PackReportWritten,
|
||||
@@ -133,7 +163,10 @@ pub(super) fn report_of(
|
||||
("bytes", &human(report)),
|
||||
],
|
||||
),
|
||||
fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)),
|
||||
fatal: report
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|error| fatal_of(error, report.failure_stage, catalog)),
|
||||
missing_title,
|
||||
missing: report
|
||||
.missing
|
||||
@@ -177,11 +210,22 @@ fn human(report: &ImportReportView) -> String {
|
||||
unlucky_presenter::dto::human_bytes(report.bytes_written)
|
||||
}
|
||||
|
||||
fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str {
|
||||
match status {
|
||||
/// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。
|
||||
fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
|
||||
match report.status {
|
||||
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
||||
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
|
||||
ImportStatusView::Failed => catalog.text(Key::PackStatusFailed),
|
||||
ImportStatusView::Failed => match report.failure_stage {
|
||||
Some(ImportFailureStageView::NotStarted) => {
|
||||
catalog.text(Key::PackStatusFailedNotStarted)
|
||||
}
|
||||
Some(ImportFailureStageView::RollbackIncomplete) => {
|
||||
catalog.text(Key::PackStatusFailedIncomplete)
|
||||
}
|
||||
// `None` 不该出现(失败必有阶段);退回「已回滚」这一句是旧行为,
|
||||
// 只有拿不到阶段时才会走到(例如未来的旧版对端)。
|
||||
_ => catalog.text(Key::PackStatusFailed),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +247,24 @@ fn subtitle(report: &ImportReportView, catalog: Catalog) -> String {
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal {
|
||||
fn fatal_of(
|
||||
error: &ImportErrorView,
|
||||
stage: Option<ImportFailureStageView>,
|
||||
catalog: Catalog,
|
||||
) -> Fatal {
|
||||
let kind = match stage {
|
||||
Some(ImportFailureStageView::NotStarted) => FatalKind::NotStarted,
|
||||
Some(ImportFailureStageView::RollbackIncomplete) => FatalKind::RollbackIncomplete,
|
||||
_ => FatalKind::RolledBack,
|
||||
};
|
||||
let title = match kind {
|
||||
FatalKind::NotStarted => catalog.text(Key::PackFatalTitleNotStarted),
|
||||
FatalKind::RolledBack => catalog.text(Key::PackFatalTitle),
|
||||
FatalKind::RollbackIncomplete => catalog.text(Key::PackFatalTitleIncomplete),
|
||||
};
|
||||
Fatal {
|
||||
title: catalog.text(Key::PackFatalTitle).to_owned(),
|
||||
kind,
|
||||
title: title.to_owned(),
|
||||
label: error.label.clone(),
|
||||
message: error.message.clone(),
|
||||
code: error.code.clone(),
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod pick;
|
||||
mod stage;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
@@ -224,6 +227,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
let block = report_of(&report, &PackPageState::default(), catalog());
|
||||
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
||||
@@ -260,6 +264,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||
let page = PackPageState {
|
||||
@@ -357,6 +362,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
|
||||
let new_instance = PackPageState::default();
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 冲突清单(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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! M161:导入失败的**两种阶段**不许共用一句话。
|
||||
//!
|
||||
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
|
||||
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
|
||||
|
||||
use unlucky_presenter::dto::ImportFailureStageView;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// **「预检没过」与「已回滚」必须各说各的话**(M161 收的债)。
|
||||
///
|
||||
/// 此前两者共用 `rolled_back: true`,于是预检失败(一个字节都没写)也被写成
|
||||
/// 「已回滚 —— 目标目录零残留」——一个没发生的回滚。现在标题、图标、底色三处都分开,
|
||||
/// 且 `NotStarted` 的标题**不许**出现「回滚 / rolled back」这类词(那才是真正的谎)。
|
||||
#[test]
|
||||
fn the_two_failure_stages_never_share_one_sentence() {
|
||||
fn failed(stage: Option<ImportFailureStageView>) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Broken".to_string()),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target: PathBuf::from("C:/tmp"),
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: Some(ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
label: "path traversal".to_string(),
|
||||
message: "../etc/passwd".to_string(),
|
||||
hint: None,
|
||||
}),
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: stage,
|
||||
}
|
||||
}
|
||||
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
let not_started = report_of(
|
||||
&failed(Some(ImportFailureStageView::NotStarted)),
|
||||
&PackPageState::default(),
|
||||
zh,
|
||||
);
|
||||
let rolled_back = report_of(
|
||||
&failed(Some(ImportFailureStageView::RolledBack)),
|
||||
&PackPageState::default(),
|
||||
zh,
|
||||
);
|
||||
let incomplete = report_of(
|
||||
&failed(Some(ImportFailureStageView::RollbackIncomplete)),
|
||||
&PackPageState::default(),
|
||||
zh,
|
||||
);
|
||||
|
||||
// 三种标题互不相同(三态压力:谁把它们折叠回同一句,这里就红)。
|
||||
let titles = [
|
||||
not_started.title.as_str(),
|
||||
rolled_back.title.as_str(),
|
||||
incomplete.title.as_str(),
|
||||
];
|
||||
assert_eq!(
|
||||
titles
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len(),
|
||||
3,
|
||||
"三种失败阶段的标题撞了:{titles:?}"
|
||||
);
|
||||
|
||||
// 「预检没过」那一句里不许出现「回滚」(那是没发生的事)。
|
||||
let fatal = not_started.fatal.expect("致命块");
|
||||
assert_eq!(
|
||||
fatal.kind,
|
||||
crate::pages::content::packui::model::FatalKind::NotStarted
|
||||
);
|
||||
assert!(
|
||||
!fatal.title.contains("回滚"),
|
||||
"预检失败不该说「回滚」:{}",
|
||||
fatal.title
|
||||
);
|
||||
assert!(!not_started.title.contains("回滚"), "{}", not_started.title);
|
||||
// 已回滚那一句必须说清「回滚」这件事。
|
||||
assert!(
|
||||
rolled_back.title.contains("回滚"),
|
||||
"已回滚那句要说明回滚:{}",
|
||||
rolled_back.title
|
||||
);
|
||||
// 图标分开:预检没过不是「错误」字形(目录没动过),回滚不完整才是。
|
||||
assert_ne!(
|
||||
crate::pages::content::packui::model::FatalKind::NotStarted.icon(),
|
||||
crate::pages::content::packui::model::FatalKind::RolledBack.icon(),
|
||||
"两种阶段的图标不许一样"
|
||||
);
|
||||
|
||||
// 成功/部分成功没有「失败阶段」可言。
|
||||
let mut ok = failed(None);
|
||||
ok.status = ImportStatusView::Success;
|
||||
ok.error = None;
|
||||
assert!(report_of(&ok, &PackPageState::default(), zh)
|
||||
.fatal
|
||||
.is_none());
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use unlucky_i18n::Lang;
|
||||
use super::demo::pack_in;
|
||||
use super::model::Pack;
|
||||
|
||||
/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
|
||||
const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
|
||||
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
|
||||
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
|
||||
|
||||
#[test]
|
||||
fn every_screenshot_state_fills_something() {
|
||||
@@ -28,6 +28,33 @@ fn every_screenshot_state_fills_something() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_state_shows_a_paginated_conflict_list() {
|
||||
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
|
||||
let (model, page) = pack_in("pick", Lang::En);
|
||||
let pick = model.pick.expect("pick 态必须有冲突清单");
|
||||
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
|
||||
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
|
||||
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
|
||||
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
|
||||
assert!(pick.can_prev && pick.can_next);
|
||||
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
|
||||
assert!(
|
||||
pick.keep_all_active && !pick.overwrite_all_active,
|
||||
"整批档停在缺省那一档(全部保留)"
|
||||
);
|
||||
assert_eq!(
|
||||
pick.rows[0].choice,
|
||||
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
|
||||
"没点过的那一行必须是保留"
|
||||
);
|
||||
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
|
||||
assert_eq!(
|
||||
page.pick_choice("config/generated/module-041.toml"),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_shows_the_shared_install_panel() {
|
||||
let (model, _page) = pack_in("importing", Lang::En);
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
mod export;
|
||||
mod parts;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
@@ -64,6 +65,7 @@ use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use self::export::export_block;
|
||||
use self::pick::pick_block;
|
||||
use self::progress::{probe_block, running_block};
|
||||
use self::report::report_block;
|
||||
use super::model::Pack;
|
||||
@@ -88,6 +90,9 @@ pub(crate) fn section<'a>(
|
||||
// 事件,任何时候把文件拖进来都算数。
|
||||
if model.export.open {
|
||||
block = block.push(export_block(ui, catalog, &model.export));
|
||||
} else if let Some(pick) = &model.pick {
|
||||
// 冲突清单优先于一切:用户此刻要做的决定就是"哪几个文件覆盖"
|
||||
block = block.push(pick_block(ui, catalog, pick));
|
||||
} else if let Some(running) = &model.running {
|
||||
block = block.push(running_block(ui, catalog, running));
|
||||
} else if let Some(report) = &model.report {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! 冲突清单那一屏的画法(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::theme::Ui;
|
||||
|
||||
use super::super::model::{ConflictRow, Report};
|
||||
use super::super::model::{ConflictRow, FatalKind, Report};
|
||||
use super::super::PackMessage;
|
||||
use super::intent;
|
||||
use super::parts::{bounded, marker_box, rows_block, tolerable_title};
|
||||
@@ -62,34 +62,46 @@ pub(super) fn report_block<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
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(
|
||||
ui,
|
||||
Role::ErrorContainer,
|
||||
role,
|
||||
row![
|
||||
frame::glyph(fatal.kind.icon(), ui.color(on_role), frame::ICON_PX),
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
true,
|
||||
fatal.title.as_str(),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
ui.color(on_role),
|
||||
),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
format!("{} · {}", fatal.label, fatal.code),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
ui.color(on_role),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
fatal.message.as_str(),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
ui.color(on_role),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Start),
|
||||
));
|
||||
if let Some(hint) = &fatal.hint {
|
||||
block = block.push(
|
||||
|
||||
@@ -21,13 +21,12 @@ use unlucky_presenter::dto::ContentSort;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
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::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::layout::Rails;
|
||||
use super::model::{Content, Hit, Status};
|
||||
use super::model::{Content, Hit};
|
||||
|
||||
mod detail;
|
||||
mod filters;
|
||||
@@ -98,12 +97,18 @@ pub(super) fn search<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
|
||||
/// 结果列表。
|
||||
///
|
||||
/// 两档(M161,判据同 [`super::layout::BodyMode`]):
|
||||
///
|
||||
/// * `mode.list_scrolls()`:列表**自己滚**(筛选条常驻,高窗口的常态);
|
||||
/// * 否则:行按自然高度铺开,滚动交给外层主列——矮窗口里一行 `ListItem` 不许被压成半行。
|
||||
pub(super) fn list<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
mode: super::layout::BodyMode,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let rows = column(
|
||||
model
|
||||
@@ -117,6 +122,7 @@ pub(super) fn list<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
let _ = catalog;
|
||||
let inner: Element<'a, PageMessage> = if mode.list_scrolls() {
|
||||
container(
|
||||
scrollable(rows)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
@@ -128,9 +134,20 @@ pub(super) fn list<'a>(
|
||||
.height(Length::Fill)
|
||||
.style(frame::scroll_style(ui)),
|
||||
)
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill)
|
||||
.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> {
|
||||
@@ -191,111 +208,6 @@ 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/资源包/光影」四枚专用图标,
|
||||
/// 已在报告里登记为偏离)。
|
||||
pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind {
|
||||
@@ -323,16 +235,6 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
|
||||
pub(super) 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())),
|
||||
Status::Ready => None,
|
||||
}
|
||||
}
|
||||
mod status;
|
||||
|
||||
pub(crate) use status::status_body;
|
||||
@@ -93,6 +93,39 @@ pub(crate) fn detail<'a>(
|
||||
|
||||
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(选中的那个高亮)
|
||||
if model.targets.is_empty() {
|
||||
body = body.push(frame::text_role(
|
||||
|
||||
@@ -23,7 +23,11 @@ use super::{intent, sort_label};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。
|
||||
/// 来源 + 排序 + 版本 + 加载器四组 chip(**每组一行**,组与组之间纵向排)。
|
||||
///
|
||||
/// 组内是一行 `row`([`wrapped_row`]):**不做自动换行**——屏幕太窄时 chip 会被裁掉,
|
||||
/// 而不是折到第二行(`row` 没有 wrap 能力)。这是既定行为,别把这条注释读成「会自动换行」;
|
||||
/// 宽度由列表列下限 424([`super::layout::compact_min`])兜住,正常窗口下不会撞到。
|
||||
pub(crate) fn filters<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! 内容库页的**状态层**:加载 / 空 / 失败 / 未联网 / 状态分派。
|
||||
//!
|
||||
//! 从 `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,
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,17 @@ pub(super) fn view<'a>(
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: unlucky_i18n::Lang,
|
||||
window_height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
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))
|
||||
.subtitle(catalog.text(Key::ContentSubtitle))
|
||||
.action(parts::section_switcher(ui, catalog, true));
|
||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
|
||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model, mode))
|
||||
}
|
||||
|
||||
fn body<'a>(
|
||||
@@ -62,6 +67,7 @@ fn body<'a>(
|
||||
metrics: FrameMetrics,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
mode: content_layout::BodyMode,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let rails = content_layout::resolve(metrics.available);
|
||||
|
||||
@@ -75,14 +81,14 @@ fn body<'a>(
|
||||
let main = column![
|
||||
kind_rail(ui, catalog, model, rails),
|
||||
divider::full(ui),
|
||||
main_column(ui, catalog, model, rails)
|
||||
main_column(ui, catalog, model, rails, mode)
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
columns = row![main].width(Length::Fill).height(Length::Fill);
|
||||
} else {
|
||||
columns = columns.push(main_column(ui, catalog, model, rails));
|
||||
columns = columns.push(main_column(ui, catalog, model, rails, mode));
|
||||
if rails.tier.has_detail() {
|
||||
columns = columns.push(detail_column(ui, catalog, model, rails));
|
||||
}
|
||||
@@ -151,11 +157,19 @@ fn kind_rail<'a>(
|
||||
}
|
||||
|
||||
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
|
||||
///
|
||||
/// `mode` 决定**谁滚**(M161,判据在 `content_layout::BodyMode`):
|
||||
///
|
||||
/// * `Split`:列表自己滚(筛选条常驻,高窗口的常态);
|
||||
/// * `ScrollWhole`:矮窗口——主列(含搜索条与筛选)整列进**一个**纵向滚动区,列表按自然高度铺开。
|
||||
/// 这样一行 `ListItem` 不会被压成半行(那看起来就是「折行挤压」),也不会出现「列表里一个滚动条、
|
||||
/// 外层又一个」的双滚动。
|
||||
fn main_column<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
mode: content_layout::BodyMode,
|
||||
) -> Element<'a, PageMessage> {
|
||||
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
|
||||
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
|
||||
@@ -178,10 +192,15 @@ fn main_column<'a>(
|
||||
} else {
|
||||
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
|
||||
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
|
||||
let mut list = column![parts::list(ui, catalog, model, rails)]
|
||||
let mut list = column![parts::list(ui, catalog, model, rails, mode)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
.width(Length::Fill);
|
||||
// 列表高度:`Split` 吃掉剩余高度(自己滚);`ScrollWhole` 按内容铺开(外层滚)。
|
||||
list = if mode.list_scrolls() {
|
||||
list.height(Length::Fill)
|
||||
} else {
|
||||
list.height(Length::Shrink)
|
||||
};
|
||||
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
||||
if !rails.tier.has_detail() {
|
||||
list = list
|
||||
@@ -191,10 +210,11 @@ fn main_column<'a>(
|
||||
list.push(parts::more(ui, catalog, model)).into()
|
||||
};
|
||||
|
||||
// `ScrollWhole` 档:主列整列一个滚动区(列表按自然高度)。**只有这一处**加滚动条,
|
||||
// 不在列表里再套一个(双滚动是「到处加滚动条糊」的典型症状)。
|
||||
let mut column = column![]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill);
|
||||
.width(Length::Fixed(rails.list));
|
||||
|
||||
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
|
||||
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
|
||||
@@ -204,7 +224,20 @@ fn main_column<'a>(
|
||||
.push(divider::full(ui));
|
||||
}
|
||||
|
||||
column.push(head).push(body).into()
|
||||
column = column.push(head).push(body);
|
||||
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` 存在)。
|
||||
|
||||
@@ -295,6 +295,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
|
||||
speed_bps: if failed { 0 } else { 1_258_291 },
|
||||
eta_secs: if failed { 0 } else { 75 },
|
||||
message: String::new(),
|
||||
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
|
||||
failure_kind: None,
|
||||
},
|
||||
catalog,
|
||||
)
|
||||
|
||||
@@ -114,6 +114,19 @@ pub fn page<'a>(
|
||||
.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> {
|
||||
let spec = Component::TopAppBar.spec();
|
||||
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
|
||||
|
||||
@@ -99,3 +99,49 @@ fn two_pane_body_is_narrower_than_the_available_width() {
|
||||
assert!(wide.column < wide.available);
|
||||
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);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ use crate::renderer;
|
||||
use crate::shot;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::instances;
|
||||
use super::{app_with_appearance, demo_model, Model};
|
||||
|
||||
mod app;
|
||||
@@ -118,6 +119,8 @@ struct Harness {
|
||||
model: Model,
|
||||
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
|
||||
appearance: Option<appearance::State>,
|
||||
/// 实例页「启动参数」区的表单(M157):harness 没有 `App`,自己持有演示表单。
|
||||
form: instances::LaunchForm,
|
||||
window: Option<window::Id>,
|
||||
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
|
||||
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
|
||||
@@ -308,7 +311,8 @@ impl Harness {
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
|
||||
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref(), &self.form)
|
||||
.map(Message::Page)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,14 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
request.flavor,
|
||||
);
|
||||
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!(
|
||||
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
|
||||
配色来源={:?} 选中分组={}",
|
||||
@@ -132,7 +140,7 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
let written = Arc::clone(&written);
|
||||
iced::application(
|
||||
move || {
|
||||
let mut model = demo_model(boot.screen, boot.width);
|
||||
let mut model = demo_model(boot.screen, boot.width, boot.height);
|
||||
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
|
||||
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
|
||||
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
|
||||
@@ -144,6 +152,7 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
ui: Ui::new(boot.flavor, families),
|
||||
model,
|
||||
appearance: appearance.clone(),
|
||||
form: crate::pages::demo_launch_form(),
|
||||
request: boot.clone(),
|
||||
window: None,
|
||||
hwnd: None,
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
mod data;
|
||||
mod demo;
|
||||
mod detail;
|
||||
mod launch_args;
|
||||
mod state;
|
||||
mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -55,7 +57,8 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use data::{DEMO, NOTE, SELECTED};
|
||||
pub(super) use demo::demo;
|
||||
pub(crate) use demo::{demo, demo_form};
|
||||
pub use state::{LaunchForm, Message as InstanceMessage};
|
||||
pub(super) use text::Texts;
|
||||
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
@@ -97,7 +100,12 @@ pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
|
||||
///
|
||||
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
|
||||
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Model,
|
||||
form: &'a LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
let block = &model.instances;
|
||||
let forced = model.forced;
|
||||
let texts = Texts::of(model.lang.catalog());
|
||||
@@ -123,12 +131,12 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
|
||||
metrics,
|
||||
list_column(ui, block, forced, texts),
|
||||
match &block.detail {
|
||||
Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
|
||||
Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts),
|
||||
None => detail::detail_empty(ui, texts),
|
||||
},
|
||||
),
|
||||
_ => match &block.detail {
|
||||
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
|
||||
Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts),
|
||||
None => frame::body(metrics, detail::detail_empty(ui, texts)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::LaunchArgsView;
|
||||
|
||||
use super::super::model::{Detail, Instances, Meta, Row};
|
||||
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row};
|
||||
use super::data::{DEMO, NOTE, SELECTED};
|
||||
use super::state::LaunchForm;
|
||||
|
||||
/// 演示路径的目录:**恒英文**。
|
||||
///
|
||||
@@ -51,6 +53,7 @@ pub(crate) fn demo() -> Instances {
|
||||
status: None,
|
||||
can_launch: true,
|
||||
can_stop: false,
|
||||
launch: launch_facts(),
|
||||
};
|
||||
|
||||
Instances {
|
||||
@@ -65,3 +68,61 @@ fn meta(label: &str, value: &str) -> Meta {
|
||||
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)
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。
|
||||
//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
@@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics};
|
||||
use super::super::model::Detail;
|
||||
use super::super::{Action, Message};
|
||||
use super::forced_state;
|
||||
use super::state::LaunchForm;
|
||||
use super::Texts;
|
||||
|
||||
// --------------------------------------------------------------- 详情列
|
||||
@@ -31,6 +32,7 @@ pub(super) fn detail_column<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -43,15 +45,23 @@ pub(super) fn detail_column<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
|
||||
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
|
||||
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)。
|
||||
//
|
||||
// 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>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -74,6 +84,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
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(
|
||||
ui,
|
||||
@@ -83,7 +94,30 @@ pub(super) fn narrow_detail<'a>(
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
|
||||
frame::body(metrics, body.push(action_row(ui, forced, texts)))
|
||||
frame::body(
|
||||
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()
|
||||
}
|
||||
|
||||
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 实例详情页的「启动参数」区(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()
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! 实例页的**页面状态**(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,6 +4,9 @@ use super::*;
|
||||
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
|
||||
mod i18n;
|
||||
|
||||
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
|
||||
mod launch_args;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
|
||||
@@ -28,14 +28,18 @@ use super::super::Texts;
|
||||
///
|
||||
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
|
||||
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
|
||||
const ALLOWED: &[&str] = &["Java", "pid"];
|
||||
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"];
|
||||
|
||||
/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 5] = [
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 6] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
("instances/text.rs", include_str!("../text.rs")),
|
||||
(
|
||||
"instances/launch_args.rs",
|
||||
include_str!("../launch_args.rs"),
|
||||
),
|
||||
(
|
||||
"app/view/instances.rs",
|
||||
include_str!("../../../app/view/instances.rs"),
|
||||
@@ -57,13 +61,42 @@ const PAGE_SOURCES: [(&str, &str); 5] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 6] = [
|
||||
const ALLOWED_LITERALS: [&str; 24] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
"pid",
|
||||
// M161:失败行由 `LaunchStageFailed` / `InstancesFailed` / `InstancesCrashed` 渲染,
|
||||
// 占位符名是 `{stage}` / `{detail}` / `{reason}` / `{code}`(占位符名不是文案);
|
||||
// `{outcome}` / `{seconds}` 是收尾 Toast 的。
|
||||
"stage",
|
||||
"detail",
|
||||
"reason",
|
||||
"code",
|
||||
"outcome",
|
||||
"seconds",
|
||||
"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",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
@@ -195,11 +228,23 @@ fn the_running_line_keeps_its_number_and_separator() {
|
||||
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
|
||||
}
|
||||
|
||||
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句
|
||||
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
||||
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
||||
assert_eq!(failed_en, "Launch failed · exit 1");
|
||||
assert_eq!(failed_zh, "启动失败 · exit 1");
|
||||
// 失败那一行同理(M161:`InstancesFailed` 已被 `LaunchStageFailed` 取代——「哪一步」也要翻,
|
||||
// 只有 `detail` 那段原文原样保留)
|
||||
let args = [("stage", "Starting the game process"), ("detail", "exit 1")];
|
||||
let failed_en = Catalog::of(Lang::En).render(Key::LaunchStageFailed, &args);
|
||||
let failed_zh = Catalog::of(Lang::ZhCn).render(
|
||||
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`] 里。
|
||||
@@ -248,9 +293,13 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
|
||||
"text.rs 没有引用本族的 key"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
|
||||
PAGE_SOURCES[5].1.contains("Key::InstancesStage"),
|
||||
"阶段标签没有走目录"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[4].1.contains("texts.launch_title()"),
|
||||
"启动参数区没有走目录"
|
||||
);
|
||||
}
|
||||
|
||||
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 「启动参数」区(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,6 +85,72 @@ impl Texts {
|
||||
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 {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
@@ -91,12 +91,36 @@ pub enum Message {
|
||||
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
|
||||
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
|
||||
Content(content::Message),
|
||||
/// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。
|
||||
Instances(super::instances::InstanceMessage),
|
||||
/// 自更新动作(**M159**):提示条与设置页「更新」区共用一支。
|
||||
///
|
||||
/// 为什么是顶层变体而不是又一个 `GroupMessage`:更新动作是**跨页的一次性意图**
|
||||
/// (检查 / 下载安装 / 忽略 / 立即重启),不是某一组的内部状态;设置页的「更新」组也不持有
|
||||
/// 任何字段(快照在 presenter 里)。与 M106 的 [`Self::Language`] 同一取舍,偏离已登记在
|
||||
/// `pages/settings/update.rs` 的模块文档里。
|
||||
Update(UpdateAction),
|
||||
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
|
||||
///
|
||||
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
|
||||
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)]
|
||||
pub enum Action {
|
||||
|
||||
@@ -108,6 +108,7 @@ pub mod frame;
|
||||
pub mod harness;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
pub mod update;
|
||||
|
||||
pub mod account;
|
||||
pub mod content;
|
||||
@@ -126,17 +127,21 @@ pub(crate) mod settings;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
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 logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||
pub use message::{Action, Message};
|
||||
pub use model::{Detail, Instances, Meta, Model, Row};
|
||||
pub use message::{Action, Message, UpdateAction};
|
||||
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row};
|
||||
pub use screen::Screen;
|
||||
pub use settings::Language;
|
||||
pub use update::{Banner, Update, UpdateStatus};
|
||||
|
||||
use iced::widget::{column, container, row, space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
@@ -150,7 +155,11 @@ use frame::filled;
|
||||
///
|
||||
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
|
||||
/// `view(&self)` 不能返回一棵借用局部变量的树。
|
||||
pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
|
||||
pub fn page<'a>(
|
||||
ui: &'a Ui,
|
||||
model: &'a Model,
|
||||
form: &'a instances::LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
render(
|
||||
ui,
|
||||
model,
|
||||
@@ -159,6 +168,7 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
form,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,6 +184,7 @@ pub fn page_with_appearance<'a>(
|
||||
ui: &'a Ui,
|
||||
model: &'a Model,
|
||||
appearance: &'a appearance::State,
|
||||
form: &'a instances::LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
render(
|
||||
ui,
|
||||
@@ -183,6 +194,7 @@ pub fn page_with_appearance<'a>(
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
form,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,8 +211,9 @@ pub fn page_with_settings<'a>(
|
||||
model: &'a Model,
|
||||
states: settings::States<'a>,
|
||||
language: Language,
|
||||
launch_form: &'a instances::LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
render(ui, model, states, language)
|
||||
render(ui, model, states, language, launch_form)
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
@@ -208,17 +221,35 @@ fn render<'a>(
|
||||
model: &'a Model,
|
||||
states: settings::States<'a>,
|
||||
language: Language,
|
||||
launch_form: &'a instances::LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
|
||||
|
||||
match model.screen {
|
||||
Screen::Instances => instances::view(ui, metrics, model),
|
||||
let page = match model.screen {
|
||||
Screen::Instances => instances::view(ui, metrics, model, launch_form),
|
||||
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
||||
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
|
||||
// M109:内容库页同样跟 `model.lang`
|
||||
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
|
||||
Screen::Settings => settings::view(ui, metrics, states, language),
|
||||
// M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁),
|
||||
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。
|
||||
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),
|
||||
};
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,8 +258,12 @@ fn render<'a>(
|
||||
/// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给
|
||||
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
|
||||
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
|
||||
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
|
||||
app_with_appearance(ui, model, None)
|
||||
pub fn app<'a>(
|
||||
ui: &'a Ui,
|
||||
model: &'a Model,
|
||||
form: &'a instances::LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
app_with_appearance(ui, model, None, form)
|
||||
}
|
||||
|
||||
/// 同 [`app`],但可以带上**外观运行期状态**。
|
||||
@@ -241,6 +276,7 @@ pub fn app_with_appearance<'a>(
|
||||
ui: &'a Ui,
|
||||
model: &'a Model,
|
||||
appearance: Option<&'a appearance::State>,
|
||||
form: &'a instances::LaunchForm,
|
||||
) -> Element<'a, Message> {
|
||||
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
|
||||
let body = render(
|
||||
@@ -251,6 +287,7 @@ pub fn app_with_appearance<'a>(
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
form,
|
||||
);
|
||||
|
||||
let shell = row![nav::rail(ui, model.active), body]
|
||||
@@ -271,14 +308,19 @@ pub fn app_with_appearance<'a>(
|
||||
/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。
|
||||
///
|
||||
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
|
||||
pub fn demo_model(screen: Screen, window_width: f32) -> Model {
|
||||
let mut model = Model::new(screen, window_width, Some(screen.nav()));
|
||||
///
|
||||
/// 高度也是模型的一部分(M161):内容库在矮窗口下要换版面,出图时 `--height` 必须
|
||||
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
|
||||
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 {
|
||||
Screen::Instances => model.instances = instances::demo(),
|
||||
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
|
||||
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
|
||||
Screen::Logs => model.logs = logs::demo(model.lang),
|
||||
Screen::Settings => {}
|
||||
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
|
||||
// 只给这一页:其它页的基线图(尤其实例页的启动参数区)不该因为更新提示条而整体下移。
|
||||
Screen::Settings => model.update = update::demo(),
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::app::components::interaction::Interaction;
|
||||
|
||||
use super::content::Content;
|
||||
use super::logs::Logs;
|
||||
use super::update::Update;
|
||||
use super::{Download, Screen};
|
||||
|
||||
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
|
||||
@@ -64,6 +65,30 @@ pub struct Detail {
|
||||
pub can_launch: bool,
|
||||
/// 「Stop」可用
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 实例页要画的东西。
|
||||
@@ -82,6 +107,11 @@ pub struct Model {
|
||||
pub screen: Screen,
|
||||
/// 窗口宽度(**含外壳**:布局档位判据就是它)
|
||||
pub window_width: f32,
|
||||
/// 窗口高度(**含外壳**)。
|
||||
///
|
||||
/// 只有「矮窗口要换版面」这一类判据用它(M161:内容库在低高度下不能把列表压扁)——
|
||||
/// 宽度档位仍只看 [`Self::window_width`]。
|
||||
pub window_height: f32,
|
||||
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
|
||||
pub active: Option<NavSection>,
|
||||
/// 实例页的数据(其它三页还是占位视觉,不读)
|
||||
@@ -92,6 +122,8 @@ pub struct Model {
|
||||
pub logs: Logs,
|
||||
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
|
||||
pub content: Content,
|
||||
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
|
||||
pub update: Update,
|
||||
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
|
||||
///
|
||||
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
|
||||
@@ -107,16 +139,23 @@ pub struct Model {
|
||||
|
||||
impl Model {
|
||||
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
|
||||
pub fn new(screen: Screen, window_width: f32, active: Option<NavSection>) -> Self {
|
||||
pub fn new(
|
||||
screen: Screen,
|
||||
window_width: f32,
|
||||
window_height: f32,
|
||||
active: Option<NavSection>,
|
||||
) -> Self {
|
||||
Self {
|
||||
screen,
|
||||
window_width,
|
||||
window_height,
|
||||
active,
|
||||
instances: Instances::default(),
|
||||
download: Download::loading(),
|
||||
logs: Logs::default(),
|
||||
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
|
||||
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
|
||||
update: Update::default(),
|
||||
lang: Lang::En,
|
||||
forced: None,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.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`)
|
||||
//!
|
||||
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
|
||||
@@ -135,6 +141,7 @@ pub(crate) mod java;
|
||||
pub(crate) mod language_appearance;
|
||||
mod message;
|
||||
mod state;
|
||||
pub(crate) mod update;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -154,6 +161,7 @@ use unlucky_i18n::{Lang, LangChoice};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::layout::{FrameMetrics, PageLayout};
|
||||
use super::update::Update;
|
||||
use super::Message;
|
||||
|
||||
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
|
||||
@@ -207,11 +215,14 @@ struct Group {
|
||||
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
|
||||
///
|
||||
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
|
||||
const GROUPS: [Group; 4] = [
|
||||
const GROUPS: [Group; 5] = [
|
||||
general::GROUP,
|
||||
appearance::GROUP,
|
||||
java::GROUP,
|
||||
download::GROUP,
|
||||
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
|
||||
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
|
||||
update::GROUP,
|
||||
];
|
||||
|
||||
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
|
||||
@@ -230,13 +241,21 @@ const GENERAL_GROUP: usize = 0;
|
||||
///
|
||||
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
|
||||
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
|
||||
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [
|
||||
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
|
||||
unlucky_i18n::Key::SettingsGroupGeneral,
|
||||
unlucky_i18n::Key::SettingsGroupAppearance,
|
||||
unlucky_i18n::Key::SettingsGroupJava,
|
||||
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 接进来的外观运行期状态
|
||||
@@ -246,6 +265,7 @@ pub fn view<'a>(
|
||||
metrics: FrameMetrics,
|
||||
states: States<'a>,
|
||||
language: Language,
|
||||
update: &'a Update,
|
||||
) -> Element<'a, Message> {
|
||||
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
|
||||
let selected = states
|
||||
@@ -256,9 +276,12 @@ pub fn view<'a>(
|
||||
PageLayout::SupportingPane => frame::panes(
|
||||
metrics,
|
||||
frame::group_nav(ui, selected, language.effective),
|
||||
frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)),
|
||||
frame::left_aligned(
|
||||
metrics.column,
|
||||
form(ui, Some(selected), states, language, update),
|
||||
),
|
||||
_ => frame::single_column(metrics, form(ui, None, states, language)),
|
||||
),
|
||||
_ => frame::single_column(metrics, form(ui, None, states, language, update)),
|
||||
};
|
||||
|
||||
frame::page(ui, metrics, frame::header(ui, language.effective), body)
|
||||
@@ -277,6 +300,7 @@ fn form<'a>(
|
||||
only: Option<usize>,
|
||||
states: States<'a>,
|
||||
language: Language,
|
||||
update: &'a Update,
|
||||
) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
|
||||
@@ -294,6 +318,9 @@ fn form<'a>(
|
||||
// 无关(harness 那条路径也画得出真控件)。
|
||||
let card = if index == GENERAL_GROUP {
|
||||
general::body(ui, language)
|
||||
} else if index == UPDATE_GROUP {
|
||||
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
|
||||
update::body(ui, update, language.effective)
|
||||
} else {
|
||||
group
|
||||
.body
|
||||
|
||||
@@ -40,6 +40,7 @@ mod appearance_section;
|
||||
mod download_section;
|
||||
mod general_section;
|
||||
mod java_section;
|
||||
mod update_section;
|
||||
|
||||
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
|
||||
use super::*;
|
||||
@@ -128,7 +129,10 @@ fn the_appearance_group_is_where_the_module_says_it_is() {
|
||||
#[test]
|
||||
fn the_split_kept_the_left_column_order() {
|
||||
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
|
||||
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
|
||||
);
|
||||
}
|
||||
|
||||
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
|
||||
@@ -193,6 +197,6 @@ fn the_settings_module_does_not_name_any_group_state() {
|
||||
fn every_line_can_be_wired_alone() {
|
||||
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
|
||||
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
|
||||
assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
|
||||
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组");
|
||||
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! 设置页「更新」分组(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(), "没有候选时安装键不可点");
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! 设置页「更新」分组(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)
|
||||
}
|
||||
@@ -54,10 +54,11 @@ fn screens_declare_the_layout_their_page_needs() {
|
||||
#[test]
|
||||
fn every_screen_builds_at_the_three_comparison_widths() {
|
||||
let ui = ui();
|
||||
let form = demo_launch_form();
|
||||
for width in [1180.0, 900.0, 700.0] {
|
||||
for screen in Screen::ALL {
|
||||
let model = demo_model(screen, width);
|
||||
let _element: Element<'_, Message> = app(&ui, &model);
|
||||
let model = demo_model(screen, width, 760.0);
|
||||
let _element: Element<'_, Message> = app(&ui, &model, &form);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,9 +67,10 @@ fn every_screen_builds_at_the_three_comparison_widths() {
|
||||
#[test]
|
||||
fn an_empty_snapshot_still_builds() {
|
||||
let ui = ui();
|
||||
let form = demo_launch_form();
|
||||
for screen in Screen::ALL {
|
||||
let model = Model::new(screen, 1180.0, Some(screen.nav()));
|
||||
let _element: Element<'_, Message> = app(&ui, &model);
|
||||
let model = Model::new(screen, 1180.0, 760.0, Some(screen.nav()));
|
||||
let _element: Element<'_, Message> = app(&ui, &model, &form);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +368,7 @@ fn every_rail_item_points_at_a_route_that_highlights_it() {
|
||||
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
|
||||
#[test]
|
||||
fn the_demo_model_carries_the_m84_baseline() {
|
||||
let model = demo_model(Screen::Instances, 1180.0);
|
||||
let model = demo_model(Screen::Instances, 1180.0, 760.0);
|
||||
assert_eq!(model.instances.rows.len(), 12);
|
||||
let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
|
||||
assert_eq!(detail.meta.len(), 6, "元信息 6 格");
|
||||
@@ -375,7 +377,7 @@ fn the_demo_model_carries_the_m84_baseline() {
|
||||
|
||||
// 其余三页不带实例数据(占位视觉不读它)
|
||||
for screen in [Screen::Download, Screen::Settings, Screen::Logs] {
|
||||
let model = demo_model(screen, 1180.0);
|
||||
let model = demo_model(screen, 1180.0, 760.0);
|
||||
assert!(
|
||||
model.instances.rows.is_empty(),
|
||||
"{} 不该带实例数据",
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! 自更新(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;
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 「这一条更新动作此刻能不能点」(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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 自更新视图模型的单测(从 `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 { .. })));
|
||||
}
|
||||
@@ -22,10 +22,10 @@ path = "src/lib.rs"
|
||||
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
|
||||
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
|
||||
[dependencies]
|
||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
||||
unlucky-core = { path = "../core", version = "0.2.2" }
|
||||
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
|
||||
# core 侧五个端口在 src/commands/agent/ports/ 装配)。
|
||||
unlucky-agent = { path = "../agent", version = "0.2.1" }
|
||||
unlucky-agent = { path = "../agent", version = "0.2.2" }
|
||||
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -14,6 +14,9 @@ pub const fn general() -> &'static str {
|
||||
|
||||
命令:
|
||||
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]
|
||||
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
|
||||
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
|
||||
@@ -34,11 +37,13 @@ pub const fn general() -> &'static str {
|
||||
config set <key> <value> 写配置(走 setting schema 校验)
|
||||
java list 列出可用 Java(含每个 JDK 的实际落点)
|
||||
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
|
||||
--vendor temurin|oracle|microsoft|zulu 钉死一家)
|
||||
java vendors 列出四家发行版的镜像/支持版本/当前优先级
|
||||
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家)
|
||||
java vendors 列出各发行版的镜像/支持版本/当前优先级
|
||||
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
|
||||
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
|
||||
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
|
||||
update check 查一次更新(只读:不动任何文件;失败退出码 1)
|
||||
update apply [--dest <path>] 下载 → SHA256 校验 → 替换(缺省替换本进程 exe)
|
||||
version 版本 + 系统摘要(同 --version)
|
||||
help [命令] 本帮助
|
||||
|
||||
@@ -69,7 +74,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
|
||||
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine / liteloader 只能由别的
|
||||
启动器装好后继承进来,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(
|
||||
"\
|
||||
@@ -91,6 +113,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
|
||||
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
|
||||
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(
|
||||
"\
|
||||
@@ -157,7 +197,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
|
||||
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
|
||||
并打印每个 JDK 的实际落点(managed 项带目录)
|
||||
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
|
||||
%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
|
||||
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
|
||||
|
||||
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
|
||||
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
|
||||
@@ -168,7 +208,9 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
|
||||
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
|
||||
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
|
||||
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
|
||||
每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。",
|
||||
Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链;
|
||||
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
|
||||
--json 的 chain 块与人读的「降级:」行。",
|
||||
),
|
||||
"agent" => Some(
|
||||
"\
|
||||
|
||||
@@ -13,7 +13,8 @@ pub mod program;
|
||||
|
||||
pub use model::{
|
||||
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
|
||||
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
|
||||
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
|
||||
PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
|
||||
};
|
||||
pub use parse::parse;
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ pub enum Command {
|
||||
Help(Option<String>),
|
||||
/// `instances list`。
|
||||
InstancesList,
|
||||
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
|
||||
InstancesLaunchArgs(InstanceLaunchArgs),
|
||||
/// `install <version> [--loader X] [--name N]`。
|
||||
Install(InstallArgs),
|
||||
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
|
||||
@@ -67,6 +69,10 @@ pub enum Command {
|
||||
},
|
||||
/// `agent serve`:对外 MCP server(长驻)。
|
||||
AgentServe(AgentServeArgs),
|
||||
/// `update check`:查一次更新,不动任何文件。
|
||||
UpdateCheck(UpdateCheckArgs),
|
||||
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
|
||||
UpdateApply(UpdateApplyArgs),
|
||||
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
|
||||
AgentConfig {
|
||||
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
|
||||
@@ -74,6 +80,17 @@ 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.*`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AgentServeArgs {
|
||||
@@ -185,6 +202,38 @@ pub struct DiagArgs {
|
||||
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` 的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigGetArgs {
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::error::CliError;
|
||||
|
||||
mod accounts;
|
||||
mod agent;
|
||||
mod instances;
|
||||
mod update;
|
||||
|
||||
use super::model::{
|
||||
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
|
||||
@@ -81,7 +83,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
match name.as_str() {
|
||||
"version" => Ok(Command::Version),
|
||||
"help" => Ok(Command::Help(tail.first().cloned())),
|
||||
"instances" => parse_instances(tail),
|
||||
"instances" => instances::parse_instances(tail),
|
||||
"install" => parse_install(tail, globals),
|
||||
"pack" => parse_pack(tail),
|
||||
"launch" => parse_launch(tail),
|
||||
@@ -92,6 +94,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
"java" => parse_java(tail),
|
||||
"accounts" => accounts::parse_accounts(tail),
|
||||
"agent" => agent::parse_agent(tail),
|
||||
"update" => update::parse_update(tail),
|
||||
other => {
|
||||
Err(CliError::usage(format!("未知命令 `{other}`"))
|
||||
.with_hint("%prog% --help 查看命令列表"))
|
||||
@@ -99,20 +102,6 @@ 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> {
|
||||
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
|
||||
let version = require_single_positional(&parsed, "install <version>")?;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `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"),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! `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}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ use crate::output::Report;
|
||||
/// 敏感值在输出里的占位。
|
||||
const MASK: &str = "<redacted>";
|
||||
|
||||
mod prefs_migration;
|
||||
|
||||
use prefs_migration::migrate_update_prefs;
|
||||
|
||||
/// `ull config get [<key>] [--show-secret]`。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -46,6 +50,9 @@ pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, Cl
|
||||
|
||||
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
|
||||
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 store = SettingStore::load(&path, launcher_schema());
|
||||
let schema = store.doc().schema().clone();
|
||||
@@ -65,7 +72,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
||||
value.clone()
|
||||
};
|
||||
let problems = render_problems(&store);
|
||||
let data = json!({
|
||||
let mut data = json!({
|
||||
"key": key,
|
||||
"value": shown,
|
||||
"redacted": hidden,
|
||||
@@ -83,6 +90,12 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
||||
}
|
||||
)];
|
||||
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 {
|
||||
human.push(format!("问题:{problem}"));
|
||||
}
|
||||
@@ -92,6 +105,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
||||
|
||||
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
|
||||
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
let migration = migrate_update_prefs(ctx);
|
||||
let path = ctx.launcher_config();
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let schema = store.doc().schema().clone();
|
||||
@@ -140,14 +154,16 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
ctx.data.game_home.display()
|
||||
));
|
||||
human.extend(text.lines().map(str::to_string));
|
||||
if let Some(note) = &migration {
|
||||
human.push(note.human.clone());
|
||||
}
|
||||
for problem in &problems {
|
||||
human.push(format!("问题:{problem}"));
|
||||
}
|
||||
if masked {
|
||||
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
|
||||
}
|
||||
Ok(Report::new(
|
||||
json!({
|
||||
let mut data = json!({
|
||||
"path": path.display().to_string(),
|
||||
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
|
||||
"config": document,
|
||||
@@ -158,9 +174,13 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
"launcher_home": ctx.data.launcher_home.display().to_string(),
|
||||
"game_home": ctx.data.game_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
human,
|
||||
))
|
||||
});
|
||||
if let Some(note) = migration {
|
||||
if let Some(object) = data.as_object_mut() {
|
||||
object.insert("migration".to_string(), note.data);
|
||||
}
|
||||
}
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// `ull config set <key> <value>`。
|
||||
@@ -171,6 +191,8 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
///
|
||||
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
|
||||
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 mut store = SettingStore::load(&path, launcher_schema());
|
||||
let sensitive = is_sensitive(store.doc().schema(), key);
|
||||
@@ -195,7 +217,7 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
||||
})?;
|
||||
match &outcome {
|
||||
SaveOutcome::Saved(saved) => {
|
||||
let data = json!({
|
||||
let mut data = json!({
|
||||
"key": key,
|
||||
"old": old,
|
||||
"new": value,
|
||||
@@ -209,13 +231,25 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
||||
),
|
||||
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));
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty) => {
|
||||
let mut human = vec![format!("{key} 已经是该值,未写盘")];
|
||||
if let Some(note) = &migration {
|
||||
human.push(note.human.clone());
|
||||
}
|
||||
Ok(Report::new(
|
||||
json!({ "key": key, "new": value, "saved": null }),
|
||||
vec![format!("{key} 已经是该值,未写盘")],
|
||||
)),
|
||||
human,
|
||||
))
|
||||
}
|
||||
SaveOutcome::Skipped(reason) => {
|
||||
let error = CliError::user(
|
||||
"config_read_only",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -254,3 +254,67 @@ fn unknown_schema_prefix_warns_and_still_shows_file_values() {
|
||||
report.human
|
||||
);
|
||||
}
|
||||
|
||||
/// M161:`ull config get update.auto_check` 读的是**主配置**,而且会把 M159 遗留的
|
||||
/// `cache/update/prefs.json` 先迁进来(迁移收据在 `migration` 里可查)。
|
||||
///
|
||||
/// 这条是 mission「`ull config get update.auto_check` 真能读到」的端到端证明:
|
||||
/// 旧文件里 `false` → 迁移 → 主配置 → CLI 输出 `false`(**不是**默认的 `true`)。
|
||||
#[test]
|
||||
fn the_update_switch_is_migrated_and_readable_from_the_cli() {
|
||||
let temp = TempDir::new("update-prefs");
|
||||
let exe = temp.path().join("exe");
|
||||
let ctx = ctx_with(Globals::default(), &exe);
|
||||
|
||||
// 旧域内文件(M159 的落点):开关是关的。
|
||||
let legacy = ctx.layout().cache().join("update").join("prefs.json");
|
||||
write_json(&legacy, &json!({ "auto_check": false }));
|
||||
|
||||
let report = get(&ctx, Some("update.auto_check"), false).expect("读 update.auto_check");
|
||||
assert_eq!(
|
||||
report.data["value"],
|
||||
json!(false),
|
||||
"旧文件里的 false 必须读到(默认值是 true,读成默认就是丢值)"
|
||||
);
|
||||
assert_eq!(
|
||||
report.data["path"],
|
||||
json!(ctx.launcher_config().display().to_string()),
|
||||
"落点必须是主配置(schema API),不是更新域自己的文件"
|
||||
);
|
||||
assert_eq!(
|
||||
report.data["migration"]["id"],
|
||||
json!("migrated"),
|
||||
"迁移要走一遍并如实报出来:{report:?}"
|
||||
);
|
||||
let receipt = report.data["migration"]["receipt"]
|
||||
.as_str()
|
||||
.expect("收据路径")
|
||||
.to_string();
|
||||
assert!(Path::new(&receipt).is_file(), "收据可查:{receipt}");
|
||||
assert!(!legacy.exists(), "旧文件必须退役");
|
||||
assert!(
|
||||
report
|
||||
.human
|
||||
.iter()
|
||||
.any(|line| line.contains("已把旧更新偏好迁入主配置")),
|
||||
"{:?}",
|
||||
report.human
|
||||
);
|
||||
|
||||
// 第二遍:什么都不做(幂等),值仍是 false。
|
||||
let again = get(&ctx, Some("update.auto_check"), false).expect("再读一遍");
|
||||
assert_eq!(again.data["value"], json!(false));
|
||||
assert!(
|
||||
again.data.get("migration").is_none(),
|
||||
"第二遍不该再报迁移:{again:?}"
|
||||
);
|
||||
|
||||
// `channels` 里只有 stable 时,写 beta 要当面被拒(而不是静默写成「设了没生效」)。
|
||||
let error = set(&ctx, "update.channel", "beta").expect_err("beta 不在允许值里");
|
||||
assert!(error.to_string().contains("beta"), "{error}");
|
||||
|
||||
// 允许值写得进去,且读得回来。
|
||||
set(&ctx, "update.channel", "stable").expect("写 stable");
|
||||
let channel = get(&ctx, Some("update.channel"), false).expect("读 update.channel");
|
||||
assert_eq!(channel.data["value"], json!("stable"));
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! `instances launch-args <instance>`(M157):实例级启动参数的读/写。
|
||||
//!
|
||||
//! 五项的**含义与回落规则**都在 core([`unlucky_core::game::launch_overrides`]),本命令只做
|
||||
//! 三件事:找实例、把命令行选项拼成 [`LaunchOverridesPatch`]、把生效值印出来。
|
||||
//!
|
||||
//! ```text
|
||||
//! ull instances launch-args 生存 # 读:实例层 + 生效值 + 后端门槛
|
||||
//! ull instances launch-args 生存 --jvm "-XX:+UseG1GC" --backend vulkan
|
||||
//! ull instances launch-args 生存 --memory 8192 --game "--fullscreen"
|
||||
//! ull instances launch-args 生存 --clear # 五项全部回「跟随全局」
|
||||
//! ```
|
||||
//!
|
||||
//! 判定「版本够不够 Vulkan」用 core 的组合判据
|
||||
//! [`unlucky_core::launch::overrides::backend_allowed`](继承解析后的库表 + 版本门槛),
|
||||
//! 与 GUI 灰化、拼装链的告警是同一个函数——CLI 不另写一条。
|
||||
//!
|
||||
//! 全局层(`java.extra_args` / `java.default_memory_mb` / `java.selected_path`)在这里读一次
|
||||
//! 设置文档填 [`GlobalLaunchDefaults`];presenter 的 host 侧有一份同形的读法(两个 crate 各自
|
||||
//! 依赖 core,没法共享那三行 IO),字段口径以 core 的回落表为准。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::game::install::instance::{
|
||||
read_instance_config, write_instance_config, InstanceConfig,
|
||||
};
|
||||
use unlucky_core::game::launch_overrides::{
|
||||
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverridesPatch, ResolvedLaunch,
|
||||
};
|
||||
use unlucky_core::game::{self, VersionJson};
|
||||
use unlucky_core::launch::game_args::GRAPHICS_BACKEND_MIN_VERSION;
|
||||
use unlucky_core::launch::game_version::GameVersion;
|
||||
use unlucky_core::launch::overrides::backend_allowed;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::InstanceLaunchArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
use super::instances::read_profiles;
|
||||
|
||||
/// `ull instances launch-args`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例不存在、值非法(内存越界 / 未知后端)、实例文件读写失败时返回用户错误。
|
||||
pub fn run(ctx: &Ctx, args: &InstanceLaunchArgs) -> Result<Report, CliError> {
|
||||
let profiles = read_profiles(ctx)?;
|
||||
let profile = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CliError::user("not_found", format!("找不到实例 `{}`", args.instance))
|
||||
.with_hint("用 `%prog% instances list` 看已登记的实例")
|
||||
})?;
|
||||
let id = profile.id.as_str().to_string();
|
||||
let layout = ctx.layout();
|
||||
|
||||
let mut config = read_instance_config(&layout, &id)
|
||||
.map_err(|error| CliError::user("instance_config", format!("读实例设置失败:{error}")))?
|
||||
.unwrap_or_else(|| InstanceConfig::new(&id, &profile.version_id));
|
||||
|
||||
let mut updated = false;
|
||||
if !args.is_read_only() {
|
||||
config.launch = config
|
||||
.launch
|
||||
.patched(&patch_from(args))
|
||||
.map_err(CliError::usage)?;
|
||||
write_instance_config(&layout, &config).map_err(|error| {
|
||||
CliError::user("instance_config", format!("写实例设置失败:{error}"))
|
||||
})?;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
let effective = resolve(Some(&config.launch), &global_defaults(ctx));
|
||||
let (allowed, mc_version) = backend_gate(ctx, &profile.version_id);
|
||||
let layer = &config.launch;
|
||||
|
||||
let data = json!({
|
||||
"instance_id": id,
|
||||
"name": profile.name,
|
||||
"version_id": profile.version_id,
|
||||
"updated": updated,
|
||||
"launch": {
|
||||
"jvm_args": layer.jvm_args.clone().unwrap_or_default(),
|
||||
"game_args": layer.game_args.clone().unwrap_or_default(),
|
||||
"memory_mb": layer.memory_mb,
|
||||
"java_path": layer.java_path.as_ref().map(|path| path.display().to_string()),
|
||||
"graphics_backend": layer.graphics_backend.unwrap_or_default().as_str(),
|
||||
},
|
||||
"effective": {
|
||||
"jvm_args": effective.instance_jvm_args.clone().unwrap_or_default(),
|
||||
"global_jvm_args": effective.global_jvm_args.clone().unwrap_or_default(),
|
||||
"game_args": effective.game_args.clone().unwrap_or_default(),
|
||||
"memory_mb": effective.memory_mb,
|
||||
"java_path": effective.java_path.as_ref().map(|path| path.display().to_string()),
|
||||
"graphics_backend": effective.graphics_backend.as_str(),
|
||||
"graphics_backend_allowed": allowed,
|
||||
"graphics_backend_min_version": GRAPHICS_BACKEND_MIN_VERSION,
|
||||
"mc_version": mc_version,
|
||||
},
|
||||
"graphics_backend_options": GraphicsBackend::IDS,
|
||||
});
|
||||
|
||||
let mut human = vec![format!(
|
||||
"{}({} · {}){}",
|
||||
profile.name,
|
||||
profile.version_id,
|
||||
id,
|
||||
if updated { " ← 已保存" } else { "" }
|
||||
)];
|
||||
human.push(format!(
|
||||
" JVM 参数(实例): {}",
|
||||
layer
|
||||
.jvm_args
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(跟随全局)".to_string())
|
||||
));
|
||||
human.push(format!(
|
||||
" 游戏参数(实例): {}",
|
||||
layer
|
||||
.game_args
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(无)".to_string())
|
||||
));
|
||||
human.push(format!(
|
||||
" 最大内存: {} MiB{}",
|
||||
effective.memory_mb,
|
||||
match layer.memory_mb {
|
||||
Some(_) => "(实例覆盖)",
|
||||
None => "(跟随全局 / 出厂默认)",
|
||||
}
|
||||
));
|
||||
human.push(format!(
|
||||
" Java: {}",
|
||||
effective
|
||||
.java_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "(自动挑:托管 > JAVA_HOME > PATH)".to_string())
|
||||
));
|
||||
human.push(format!(
|
||||
" 图形后端: {}{}",
|
||||
effective.graphics_backend.as_str(),
|
||||
if effective.graphics_backend.is_default() {
|
||||
"(不下发 --graphicsBackend)".to_string()
|
||||
} else if allowed {
|
||||
format!("({} 可下发)", mc_version)
|
||||
} else {
|
||||
format!("(版本 {mc_version} 不支持,不会下发;需 MC {GRAPHICS_BACKEND_MIN_VERSION}+)")
|
||||
}
|
||||
));
|
||||
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 命令行选项 → core 的文本补丁(`--clear` 先清五项,随后给出的项覆盖)。
|
||||
fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch {
|
||||
let mut patch = if args.clear {
|
||||
LaunchOverridesPatch::clear_all()
|
||||
} else {
|
||||
LaunchOverridesPatch::default()
|
||||
};
|
||||
if let Some(value) = &args.jvm_args {
|
||||
patch.jvm_args = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.game_args {
|
||||
patch.game_args = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.memory_mb {
|
||||
patch.memory_mb = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.java {
|
||||
patch.java_path = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = &args.backend {
|
||||
patch.graphics_backend = Some(value.clone());
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
/// 全局层(三个设置键)。
|
||||
pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
let text = |key: &str| -> Option<String> {
|
||||
store
|
||||
.doc()
|
||||
.effective_str(key)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
GlobalLaunchDefaults {
|
||||
jvm_args: text("java.extra_args"),
|
||||
game_args: None,
|
||||
memory_mb: store
|
||||
.effective("java.default_memory_mb")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
java_path: text("java.selected_path").map(std::path::PathBuf::from),
|
||||
graphics_backend: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 三级回落后的生效值(启动装配用;`instance_id` 为空 = 没有实例档案,只用全局层)。
|
||||
///
|
||||
/// 与 presenter 的 `host::launch_args::resolved` 同义:那边给 GUI 用,这边给 `ull launch` 用,
|
||||
/// 规则都在 core 的 [`resolve`],两处只剩「读哪几份文件」的差别。
|
||||
pub(crate) fn resolved(ctx: &Ctx, instance_id: &str) -> ResolvedLaunch {
|
||||
let overrides = if instance_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
read_instance_config(&ctx.layout(), instance_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|config| config.launch)
|
||||
};
|
||||
resolve(overrides.as_ref(), &global_defaults(ctx))
|
||||
}
|
||||
|
||||
/// 版本能不能用 `--graphicsBackend`,以及判定依据的游戏版本(继承链根)。
|
||||
fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
|
||||
let game_layout = ctx.game();
|
||||
let Ok(text) = std::fs::read_to_string(game_layout.version_json(version_id)) else {
|
||||
return (false, version_id.to_string());
|
||||
};
|
||||
let Ok(manifest) = VersionJson::from_json_str(&text) else {
|
||||
return (false, version_id.to_string());
|
||||
};
|
||||
let mc_version = root_version_id(&game_layout, &manifest);
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
std::fs::read_to_string(game_layout.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
let resolved = game::resolve(&source, &manifest).unwrap_or(manifest);
|
||||
let allowed = backend_allowed(&GameVersion::parse(&mc_version), &resolved);
|
||||
(allowed, mc_version)
|
||||
}
|
||||
|
||||
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。
|
||||
///
|
||||
/// `pub(crate)`:`snapshot::prepare` 的装配(`SnapshotInput.game_version`)也要这一份口径,
|
||||
/// 门槛判定(本模块的 `backend_gate`)与拼装必须用**同一个**根版本,否则会出现
|
||||
/// 「UI 说可用、命令行里没有这个参数」的静默偏差。
|
||||
pub(crate) fn root_version_id(
|
||||
layout: &unlucky_core::game::GameLayout,
|
||||
manifest: &VersionJson,
|
||||
) -> String {
|
||||
let mut current = manifest.id.clone().unwrap_or_default();
|
||||
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
|
||||
for _ in 0..16 {
|
||||
let Some(next) = parent else { break };
|
||||
let Ok(text) = std::fs::read_to_string(layout.version_json(&next)) else {
|
||||
return next;
|
||||
};
|
||||
let Ok(json) = VersionJson::from_json_str(&text) else {
|
||||
return next;
|
||||
};
|
||||
current = json.id.clone().unwrap_or(next);
|
||||
parent = json.inherits_from.clone().filter(|id| !id.is_empty());
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,212 @@
|
||||
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
|
||||
//!
|
||||
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
|
||||
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
|
||||
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
|
||||
|
||||
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn arg(&self) -> String {
|
||||
self.0.display().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
|
||||
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
|
||||
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
|
||||
std::fs::create_dir_all(&dir).expect("建配置目录");
|
||||
let id = "11111111-2222-4333-8444-555555555555".to_string();
|
||||
let document = json!({
|
||||
"schema_version": 1,
|
||||
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
|
||||
});
|
||||
std::fs::write(
|
||||
dir.join("profiles.json"),
|
||||
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
|
||||
)
|
||||
.expect("写 profiles.json");
|
||||
id
|
||||
}
|
||||
|
||||
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
|
||||
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
|
||||
let path = data_root
|
||||
.join(".minecraft")
|
||||
.join("versions")
|
||||
.join(id)
|
||||
.join(format!("{id}.json"));
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
let libs = if vulkan {
|
||||
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
|
||||
} else {
|
||||
"[]"
|
||||
};
|
||||
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
|
||||
}
|
||||
|
||||
/// 跑一次 CLI,返回(退出码, stdout)。
|
||||
fn run_cli(args: &[&str]) -> (i32, String) {
|
||||
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut err: Vec<u8> = Vec::new();
|
||||
let code = crate::run(&owned, &mut out, &mut err);
|
||||
(code, String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
|
||||
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
|
||||
let root = dir.arg();
|
||||
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
|
||||
args.extend_from_slice(rest);
|
||||
let (code, stdout) = run_cli(&args);
|
||||
assert_eq!(code, 0, "{stdout}");
|
||||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||||
assert_eq!(value["ok"], true, "{stdout}");
|
||||
value["data"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_reports_instance_layer_and_effective_values() {
|
||||
let dir = TempDir::new("read");
|
||||
let id = write_profiles(dir.path(), "生存", "1.20.4");
|
||||
let data = launch_args(&dir, &[&id]);
|
||||
assert_eq!(data["instance_id"], id.as_str());
|
||||
assert_eq!(data["updated"], false);
|
||||
assert_eq!(data["launch"]["graphics_backend"], "default");
|
||||
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
|
||||
assert_eq!(data["effective"]["memory_mb"], 2048);
|
||||
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
|
||||
assert_eq!(
|
||||
data["graphics_backend_options"],
|
||||
json!(["default", "opengl", "vulkan"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_from_flags_then_read_back() {
|
||||
let dir = TempDir::new("write");
|
||||
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
|
||||
write_version(dir.path(), "26.2-snapshot-2", true);
|
||||
|
||||
let data = launch_args(
|
||||
&dir,
|
||||
&[
|
||||
&id,
|
||||
"--jvm=-XX:+UseG1GC",
|
||||
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
|
||||
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
|
||||
"--game=--fullscreen",
|
||||
"--memory",
|
||||
"6144",
|
||||
"--backend",
|
||||
"vulkan",
|
||||
],
|
||||
);
|
||||
assert_eq!(data["updated"], true);
|
||||
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
|
||||
assert_eq!(data["launch"]["memory_mb"], 6144);
|
||||
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
|
||||
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
|
||||
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
|
||||
|
||||
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
|
||||
let again = launch_args(&dir, &[&id]);
|
||||
assert_eq!(again["updated"], false);
|
||||
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
|
||||
assert_eq!(again["launch"]["game_args"], "--fullscreen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_restores_follow_the_global() {
|
||||
let dir = TempDir::new("clear");
|
||||
let id = write_profiles(dir.path(), "生存", "1.20.4");
|
||||
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
|
||||
let cleared = launch_args(&dir, &[&id, "--clear"]);
|
||||
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
|
||||
assert_eq!(cleared["launch"]["graphics_backend"], "default");
|
||||
assert_eq!(cleared["effective"]["memory_mb"], 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_values_fail_with_a_usage_error() {
|
||||
let dir = TempDir::new("invalid");
|
||||
let id = write_profiles(dir.path(), "生存", "1.20.4");
|
||||
let root = dir.arg();
|
||||
for (flag, value) in [
|
||||
("--backend", "vulkan2"),
|
||||
("--memory", "4g"),
|
||||
("--memory", "1"),
|
||||
] {
|
||||
let (code, stdout) = run_cli(&[
|
||||
"instances",
|
||||
"launch-args",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&root,
|
||||
&id,
|
||||
flag,
|
||||
value,
|
||||
]);
|
||||
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
|
||||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||||
assert_eq!(value["error"]["code"], "usage", "{stdout}");
|
||||
}
|
||||
// 失败的写入不许留下半截设置文件。
|
||||
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
|
||||
assert!(
|
||||
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
|
||||
.expect("读")
|
||||
.is_none(),
|
||||
"非法补丁不该落盘"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_instance_is_a_user_error() {
|
||||
let dir = TempDir::new("missing");
|
||||
let root = dir.arg();
|
||||
let (code, stdout) = run_cli(&[
|
||||
"instances",
|
||||
"launch-args",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&root,
|
||||
"nope",
|
||||
]);
|
||||
assert_ne!(code, 0);
|
||||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||||
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::download::MirrorTable;
|
||||
use unlucky_core::game::java::{vendor, JdkVendor};
|
||||
use unlucky_core::game::java::{termux, vendor, JdkVendor};
|
||||
|
||||
use super::java::{region_profile, JavaSettings};
|
||||
use crate::context::Ctx;
|
||||
@@ -23,7 +23,11 @@ use crate::output::Report;
|
||||
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let settings = JavaSettings::load(ctx);
|
||||
let domestic = region_profile(ctx).is_domestic();
|
||||
let order = vendor::order(&settings.priority, domestic);
|
||||
let mut order = vendor::order(&settings.priority, domestic);
|
||||
// Termux 上系统包是一条真实可用的发行版:不进默认回退序,但展示时要让人看见。
|
||||
if termux::available() && !order.contains(&JdkVendor::Termux) {
|
||||
order.insert(0, JdkVendor::Termux);
|
||||
}
|
||||
let table = MirrorTable::builtin();
|
||||
let rows: Vec<Value> = order
|
||||
.iter()
|
||||
@@ -114,6 +118,10 @@ fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
|
||||
.zulu()
|
||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||
.unwrap_or_default(),
|
||||
JdkVendor::Termux => termux::MIRRORS
|
||||
.iter()
|
||||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
||||
.collect(),
|
||||
};
|
||||
json!({
|
||||
"rank": index + 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod agent;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
pub mod install;
|
||||
pub mod instance_launch;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
@@ -14,6 +15,7 @@ pub mod meta;
|
||||
pub mod pack;
|
||||
pub mod sessions;
|
||||
pub mod snapshot;
|
||||
pub mod update;
|
||||
|
||||
use crate::cli::{Command, Globals, Invocation};
|
||||
use crate::context::Ctx;
|
||||
@@ -32,6 +34,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
|
||||
Command::Version => Ok(Outcome::new(meta::version())),
|
||||
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
|
||||
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
|
||||
Command::InstancesLaunchArgs(args) => {
|
||||
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
|
||||
}
|
||||
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
|
||||
Command::PackImport(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
|
||||
@@ -53,6 +58,12 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
|
||||
java::install(ctx, *major, *vendor)
|
||||
}),
|
||||
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
|
||||
Command::UpdateCheck(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| update::check(ctx, args))
|
||||
}
|
||||
Command::UpdateApply(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| update::apply(ctx, args))
|
||||
}
|
||||
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
|
||||
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
|
||||
Command::AccountsRemove { name } => {
|
||||
@@ -84,10 +95,12 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
|
||||
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
|
||||
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
|
||||
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
|
||||
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
|
||||
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
|
||||
AuthError::Unsupported(_) => Some(format!(
|
||||
"先配 Azure 公共客户端 id(id 不进仓库):\
|
||||
`{env}=<id> %prog% accounts add msa`;\
|
||||
想让二进制自带就构建时注入 `{env}=<id> cargo build`。环境变量优先于构建期注入。",
|
||||
"官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
|
||||
运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
|
||||
或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
|
||||
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
|
||||
)),
|
||||
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
|
||||
|
||||
+143
-13
@@ -194,26 +194,73 @@ fn outcome(
|
||||
if let Some(hint) = error.hint() {
|
||||
human.push(format!("提示:{hint}"));
|
||||
}
|
||||
if report.rolled_back {
|
||||
human.push("已整包回滚:目标目录与导入前一致".to_string());
|
||||
} else if let Some(rollback) = &report.rollback {
|
||||
human.push(format!(
|
||||
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
|
||||
rollback.failure_count()
|
||||
));
|
||||
}
|
||||
human.push(rollback_line(report));
|
||||
}
|
||||
|
||||
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
|
||||
}
|
||||
|
||||
/// 状态的人读名。**失败要按回滚是否干净分叉**:不完整时不能说"已回滚"。
|
||||
/// 状态的人读名。**失败要按「动没动过目标目录」分叉**(见 [`rollback_line`])。
|
||||
fn status_label(report: &ImportReport) -> &'static str {
|
||||
match report.status {
|
||||
match rollback_stage(report) {
|
||||
Some(RollbackStage::NotStarted) => "失败(未写入:目标目录没被动过)",
|
||||
Some(RollbackStage::RolledBack) => "失败(已回滚)",
|
||||
Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
|
||||
None => match report.status {
|
||||
ImportStatus::Success => "成功",
|
||||
ImportStatus::Partial => "部分成功",
|
||||
ImportStatus::Failed if report.rolled_back => "失败(已回滚)",
|
||||
ImportStatus::Failed => "失败(回滚不完整)",
|
||||
_ => "部分成功",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 「这次失败到底动没动过目标目录」(三档)。
|
||||
///
|
||||
/// 与 `presenter` 的 `host::addon::pack::convert::failure_stage` **同义**——CLI 不依赖 presenter
|
||||
/// 层(`unlucky-cli` 只依赖 core + agent),所以这套判据在这里各写一份。已登记 finding:
|
||||
/// 更干净的做法是把三档挪进 `core::addon::import::ImportReport`(那儿有 `rollback` /
|
||||
/// `rolled_back` 两个机器位),两处都改成读它。
|
||||
///
|
||||
/// 为什么要三档而不是一个 `rolled_back` 布尔:core 的 `rolled_back` 在「回滚完整完成」与
|
||||
/// **「从未写过目标目录」**两种情形下都是 `true`。只按它出词,预检失败会被说成「已整包回滚:目标
|
||||
/// 目录与导入前一致」——那句话在**一个字节都没写**时是假的(M161 复审的 P1 同族问题)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RollbackStage {
|
||||
/// 预检/装配阶段就失败:目标目录一个字节都没写
|
||||
NotStarted,
|
||||
/// 写过,回滚**完整**完成
|
||||
RolledBack,
|
||||
/// 写过,回滚有失败项(目标目录可能与导入前不同)
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
/// 三档判据(与 presenter 那份逐条对应)。
|
||||
fn rollback_stage(report: &ImportReport) -> Option<RollbackStage> {
|
||||
if report.status != ImportStatus::Failed {
|
||||
return None;
|
||||
}
|
||||
Some(match &report.rollback {
|
||||
Some(outcome) if outcome.is_complete() => RollbackStage::RolledBack,
|
||||
Some(_) => RollbackStage::Incomplete,
|
||||
None if report.rolled_back => RollbackStage::NotStarted,
|
||||
None => RollbackStage::Incomplete,
|
||||
})
|
||||
}
|
||||
|
||||
/// 失败收尾那一段人读文案(**三档三句**,不许共用)。
|
||||
fn rollback_line(report: &ImportReport) -> String {
|
||||
match rollback_stage(report) {
|
||||
Some(RollbackStage::NotStarted) => {
|
||||
"预检未通过:目标目录一个字节都没写(不需要回滚)".to_string()
|
||||
}
|
||||
Some(RollbackStage::RolledBack) => "已整包回滚:目标目录与导入前一致".to_string(),
|
||||
Some(RollbackStage::Incomplete) => match &report.rollback {
|
||||
Some(rollback) => format!(
|
||||
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
|
||||
rollback.failure_count()
|
||||
),
|
||||
None => "回滚状态不明:目标目录可能与导入前不同(详见 --json)".to_string(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,3 +291,86 @@ fn build_engine(ctx: &Ctx) -> DownloadEngine {
|
||||
Arc::new(ContentCache::open(&cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! `pack import` 失败收尾的三档文案(M161 复审「不计入但该记」第一条:CLI 也曾把
|
||||
//! 预检失败说成「已整包回滚」)。
|
||||
//!
|
||||
//! 抓得住:三档塌成两句(预检 → 已回滚);`is_complete()` 被丢掉;`status != Failed` 也出话。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn failed() -> ImportReport {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
|
||||
report.fail(ImportError::integrity("sha1 mismatch"));
|
||||
report
|
||||
}
|
||||
|
||||
/// 三档三句,**互不相同**(同族的谎就是这个 mission 要消灭的那类)。
|
||||
#[test]
|
||||
fn the_three_rollback_stages_get_three_different_sentences() {
|
||||
// ① 预检没过:一个字节都没写。
|
||||
let mut preflight = failed();
|
||||
preflight.rolled_back = true;
|
||||
assert_eq!(rollback_stage(&preflight), Some(RollbackStage::NotStarted));
|
||||
let not_started = rollback_line(&preflight);
|
||||
assert!(not_started.contains("没写"), "{not_started}");
|
||||
assert!(
|
||||
!not_started.contains("回滚:目标目录"),
|
||||
"预检失败不许说「已回滚」:{not_started}"
|
||||
);
|
||||
assert_eq!(status_label(&preflight), "失败(未写入:目标目录没被动过)");
|
||||
|
||||
// ② 写过又完整回滚。
|
||||
let mut rolled_back = failed();
|
||||
rolled_back.rolled_back = true;
|
||||
rolled_back.rollback = Some(RollbackOutcome {
|
||||
attempted: 2,
|
||||
failed: Vec::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
rollback_stage(&rolled_back),
|
||||
Some(RollbackStage::RolledBack)
|
||||
);
|
||||
assert!(rollback_line(&rolled_back).contains("已整包回滚"));
|
||||
assert_eq!(status_label(&rolled_back), "失败(已回滚)");
|
||||
|
||||
// ③ 回滚没做完。
|
||||
let mut incomplete = failed();
|
||||
incomplete.rolled_back = false;
|
||||
incomplete.rollback = Some(RollbackOutcome {
|
||||
attempted: 2,
|
||||
failed: vec!["mods/a.jar".to_string()],
|
||||
});
|
||||
assert_eq!(rollback_stage(&incomplete), Some(RollbackStage::Incomplete));
|
||||
assert!(rollback_line(&incomplete).contains("1 项没回去"));
|
||||
assert_eq!(status_label(&incomplete), "失败(回滚不完整)");
|
||||
|
||||
// 三句必须互不相同(把任何两档并成一句,这里就红)。
|
||||
let lines = [
|
||||
rollback_line(&preflight),
|
||||
rollback_line(&rolled_back),
|
||||
rollback_line(&incomplete),
|
||||
];
|
||||
assert_eq!(
|
||||
lines.iter().collect::<std::collections::HashSet<_>>().len(),
|
||||
3,
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不是致命失败 ⇒ 没有回滚那一段话(成功/部分成功不该出现「已回滚」)。
|
||||
#[test]
|
||||
fn a_non_fatal_report_says_nothing_about_rollback() {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
|
||||
report.status = ImportStatus::Partial;
|
||||
assert_eq!(rollback_stage(&report), None);
|
||||
assert_eq!(rollback_line(&report), "");
|
||||
assert_eq!(status_label(&report), "部分成功");
|
||||
}
|
||||
}
|
||||
@@ -75,18 +75,38 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
|
||||
let mut libraries: Vec<LibraryInput> = Vec::new();
|
||||
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
||||
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
|
||||
if !library.applies(&environment) {
|
||||
continue;
|
||||
}
|
||||
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
|
||||
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
|
||||
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
|
||||
let arm64 = library.linux_arm64_native_name(&environment);
|
||||
if arm64.is_none() && library.native_classifier(&environment).is_some() {
|
||||
continue;
|
||||
}
|
||||
let (coordinate, name) = match arm64 {
|
||||
Some(rewritten) => {
|
||||
let Ok(coordinate) =
|
||||
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
(coordinate, rewritten)
|
||||
}
|
||||
None => {
|
||||
let Ok(coordinate) = library.coordinate() else {
|
||||
continue;
|
||||
};
|
||||
(coordinate, library.name.clone())
|
||||
}
|
||||
};
|
||||
let path = if library.is_local() {
|
||||
game.version_library(&version_id, &coordinate)
|
||||
} else {
|
||||
game.library(&coordinate)
|
||||
};
|
||||
libraries.push(LibraryInput::new(path, library.name.clone()));
|
||||
libraries.push(LibraryInput::new(path, name));
|
||||
}
|
||||
|
||||
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
|
||||
@@ -111,11 +131,14 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
.clone()
|
||||
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
|
||||
|
||||
// 同 presenter:装配门限按**继承链的 MC 根版本**判(loader 实例的 version_id 带加载器后缀,
|
||||
// 周快照的 6 字符形态判据在那儿失效,`--graphicsBackend` 会被静默跳过)。
|
||||
let root_version = super::instance_launch::root_version_id(&game, &manifest);
|
||||
let snapshot = SnapshotInput {
|
||||
version_id: &version_id,
|
||||
version_name: Some(&input.instance_name),
|
||||
version_type: None,
|
||||
game_version: None,
|
||||
game_version: Some(root_version.as_str()),
|
||||
main_class: resolved.main_class.as_deref(),
|
||||
jvm_arguments: &expanded.jvm,
|
||||
game_arguments: &expanded.game,
|
||||
@@ -131,11 +154,21 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
game_assets: assets_root,
|
||||
};
|
||||
|
||||
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
|
||||
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
|
||||
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
|
||||
if let Some(mb) = input.max_memory {
|
||||
effective.memory_mb = mb;
|
||||
}
|
||||
if let Some(path) = &input.java {
|
||||
effective.java_path = Some(path.clone());
|
||||
}
|
||||
|
||||
let options = AdapterOptions {
|
||||
java: super::java::selection(input.java.as_deref()),
|
||||
java: super::java::selection(effective.java_path.as_deref()),
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: input.max_memory,
|
||||
max_mb: Some(effective.memory_mb),
|
||||
},
|
||||
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
|
||||
player_name: input
|
||||
@@ -161,6 +194,8 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
request.instance.id = input.instance_id.clone();
|
||||
}
|
||||
request.instance.name = input.instance_name.clone();
|
||||
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
|
||||
unlucky_core::launch::overrides::apply(&mut request, &effective);
|
||||
|
||||
Ok(Plan {
|
||||
version_id,
|
||||
@@ -203,3 +238,94 @@ fn describe_warning(warning: &AdapterWarning) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 一次性临时数据根(`--data-dir` 的等价物:custom 模式 → `<root>/.ULL` + `<root>/.minecraft`)。
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ull-cli-snapshot-{tag}-{}-{seq}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_at(dir: &TempDir) -> Ctx {
|
||||
let globals = crate::cli::Globals {
|
||||
data_dir: Some(dir.path().to_path_buf()),
|
||||
..crate::cli::Globals::default()
|
||||
};
|
||||
Ctx::resolve(&globals).expect("解析数据根")
|
||||
}
|
||||
|
||||
fn write_version(ctx: &Ctx, id: &str, json: &str) {
|
||||
let path = ctx.game().version_json(id);
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
std::fs::write(&path, json).expect("写版本 JSON");
|
||||
}
|
||||
|
||||
/// **周快照 + 加载器实例**(M157 r3 复审 P2-1):`prepare` 交给装配的游戏版本是
|
||||
/// 继承链根的 `26w14a`,不是 `26w14a-fabric-0.16.5`。
|
||||
///
|
||||
/// 抓得住:`SnapshotInput.game_version` 回退成 `None`(装配用带后缀的 id 判门限,
|
||||
/// `--graphicsBackend` 会被静默跳过,而 CLI 自己的门槛显示「可下发」)。
|
||||
/// 抓不住:完整 argv(`crates/core/src/launch/overrides/tests.rs` 的周快照用例)。
|
||||
#[test]
|
||||
fn weekly_loader_instance_is_assembled_against_the_mc_root_version() {
|
||||
let dir = TempDir::new("weekly");
|
||||
let ctx = ctx_at(&dir);
|
||||
write_version(
|
||||
&ctx,
|
||||
"26w14a",
|
||||
r#"{"id":"26w14a","mainClass":"net.minecraft.client.main.Main",
|
||||
"libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#,
|
||||
);
|
||||
write_version(
|
||||
&ctx,
|
||||
"26w14a-fabric-0.16.5",
|
||||
r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a",
|
||||
"mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#,
|
||||
);
|
||||
|
||||
let plan = prepare(
|
||||
&ctx,
|
||||
&PlanInput {
|
||||
version_id: "26w14a-fabric-0.16.5".to_string(),
|
||||
instance_name: "周快照 Fabric".to_string(),
|
||||
..PlanInput::default()
|
||||
},
|
||||
)
|
||||
.expect("装配应成功");
|
||||
|
||||
assert_eq!(
|
||||
plan.request.instance.version.raw(),
|
||||
"26w14a",
|
||||
"装配的游戏版本必须是继承链的 MC 根版本(CLI 门槛与拼装必须同一口径)"
|
||||
);
|
||||
assert!(plan.request.instance.version.is_weekly_snapshot());
|
||||
assert!(plan.request.manifest.has_vulkan_backend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `ull update check` / `ull update apply`(M159):**复用 core 的同一内核**。
|
||||
//!
|
||||
//! 两条命令都只做「解析参数 → 造一个 [`Job`] → 调 `check` / `apply` → 排版输出」,
|
||||
//! 版本比较、渠道准入、清单校验、长度/SHA256 双检、原子替换全在 `core::update`
|
||||
//! (GUI 走的是同一个 `Job`,见 `crates/presenter/src/host/update.rs`)。
|
||||
//!
|
||||
//! 三条不许折叠的边界:
|
||||
//!
|
||||
//! | 情形 | 输出 |
|
||||
//! |---|---|
|
||||
//! | 已是最新 | `status=up_to_date`(退出码 0) |
|
||||
//! | 有可装的更新 | `status=available`(退出码 0;`apply` 才动文件) |
|
||||
//! | 检查失败 | **错误**(退出码 1,`code=update_check_failed`)——绝不说成「已是最新」 |
|
||||
//!
|
||||
//! 还有一条单独报的:`missing_artifact`(有更高版本但里面没有本平台的产物)。那是发布侧的漏项,
|
||||
//! 同样不许说成「已是最新」。
|
||||
//!
|
||||
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
|
||||
|
||||
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::{Outcome, Report};
|
||||
|
||||
/// D50:先只发 stable。
|
||||
const CHANNEL: unlucky_core::update::Channel = unlucky_core::update::Channel::Stable;
|
||||
|
||||
/// 本进程版本(与 `ull --version` 同源)。
|
||||
fn current_version() -> Result<Version, CliError> {
|
||||
let text = env!("CARGO_PKG_VERSION");
|
||||
Version::parse(text)
|
||||
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
|
||||
}
|
||||
|
||||
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
|
||||
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
|
||||
let engine = super::install::build_engine(ctx);
|
||||
let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache());
|
||||
match dest {
|
||||
Some(dest) => job.with_dest(dest),
|
||||
None => job,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败 → CLI 错误(`code` 说「哪条命令的哪一类失败」,`detail.kind` 说**到底为什么**)。
|
||||
///
|
||||
/// `--json` 信封示例(复审 P3-3 之后的样子):
|
||||
///
|
||||
/// ```json
|
||||
/// {"ok":false,"error":{"code":"update_apply_failed","kind":"user","message":"应用更新失败(digest_mismatch):…",
|
||||
/// "hint":"…","detail":{"kind":"digest_mismatch","retryable":true}}}
|
||||
/// ```
|
||||
///
|
||||
/// 为什么要两层:`code` 是命令级的(脚本据此决定「要不要提示用户重跑」),`detail.kind` 是
|
||||
/// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。
|
||||
/// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。
|
||||
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
|
||||
let hint = if error.is_retryable() {
|
||||
"网络/占用类失败,过一会儿重试一次通常就好"
|
||||
} else {
|
||||
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
|
||||
};
|
||||
CliError::user(
|
||||
code,
|
||||
format!("{action}失败({}):{error}", error.kind.as_str()),
|
||||
)
|
||||
.with_detail(json!({
|
||||
"kind": error.kind.as_str(),
|
||||
"retryable": error.is_retryable(),
|
||||
}))
|
||||
.with_hint(hint)
|
||||
}
|
||||
|
||||
/// `ull update check`:只查、不动任何文件。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 清单取不到 / 清单不合法 / 渠道不符 → 用户错误(退出码 1)。
|
||||
pub fn check(ctx: &Ctx, _args: &UpdateCheckArgs) -> Result<Outcome, CliError> {
|
||||
let current = current_version()?;
|
||||
let job = job(ctx, None);
|
||||
let outcome = job
|
||||
.check(¤t, CHANNEL)
|
||||
.map_err(|error| failure("update_check_failed", "检查更新", &error))?;
|
||||
Ok(Outcome::new(report_of(¤t, job.source_id(), &outcome)))
|
||||
}
|
||||
|
||||
/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。
|
||||
pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
||||
let current = current_version()?;
|
||||
let job = job(ctx, args.dest.clone());
|
||||
let dest = job.dest().display().to_string();
|
||||
match job
|
||||
.apply(¤t, CHANNEL)
|
||||
.map_err(|error| failure("update_apply_failed", "应用更新", &error))?
|
||||
{
|
||||
Applied::Installed { receipt, version } => {
|
||||
let backup = receipt
|
||||
.backup
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string());
|
||||
let mut human = vec![
|
||||
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
|
||||
format!("已替换:{dest}"),
|
||||
];
|
||||
human.push(match &backup {
|
||||
Some(backup) => format!("旧版本备份:{backup}"),
|
||||
None => "原本没有旧文件(首次落盘)".to_string(),
|
||||
});
|
||||
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
|
||||
Ok(Outcome::new(Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"status": "installed",
|
||||
"version": version,
|
||||
"path": dest,
|
||||
"bytes": receipt.bytes,
|
||||
"backup": backup,
|
||||
}),
|
||||
human,
|
||||
)))
|
||||
}
|
||||
Applied::Nothing(outcome) => {
|
||||
let report = report_of(¤t, job.source_id(), &outcome);
|
||||
Ok(Outcome::new(Report::new(
|
||||
json!({ "status": "nothing_to_apply", "check": report.data }),
|
||||
{
|
||||
let mut human =
|
||||
vec!["复查之后没有可应用的更新(已经不是最新版本冲突)".to_string()];
|
||||
human.extend(report.human);
|
||||
human
|
||||
},
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把检查结论排成「人读几行 + `--json` 一份」。
|
||||
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
|
||||
let base = |status: &str| {
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"status": status,
|
||||
})
|
||||
};
|
||||
match outcome {
|
||||
CheckOutcome::UpToDate { latest, .. } => {
|
||||
let latest = latest.as_ref().map(ToString::to_string);
|
||||
let mut data = base("up_to_date");
|
||||
data["latest"] = json!(latest);
|
||||
Report::new(
|
||||
data,
|
||||
vec![match &latest {
|
||||
Some(latest) => format!("已是最新:v{current}(渠道 {latest} 内最高)"),
|
||||
None => format!("已是最新:v{current}(该渠道还没有任何发布)"),
|
||||
}],
|
||||
)
|
||||
}
|
||||
CheckOutcome::Available(candidate) => {
|
||||
let version = candidate.release.version.to_string();
|
||||
let forced = candidate.urgency == unlucky_core::update::Urgency::Forced;
|
||||
let notes = candidate.release.notes.clone().unwrap_or_default();
|
||||
let mut human = vec![format!(
|
||||
"发现新版本 v{version}(当前 v{current},{} 字节{})",
|
||||
candidate.artifact.size,
|
||||
if forced { ",强制更新" } else { "" }
|
||||
)];
|
||||
if !notes.trim().is_empty() {
|
||||
human.push(format!("说明:{}", notes.trim()));
|
||||
}
|
||||
human.push("用 `%prog% update apply` 下载并应用(下次启动生效)".to_string());
|
||||
Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"status": "available",
|
||||
"version": version,
|
||||
"size": candidate.artifact.size,
|
||||
"forced": forced,
|
||||
"notes": notes,
|
||||
"target": candidate.artifact.target.as_str(),
|
||||
}),
|
||||
human,
|
||||
)
|
||||
}
|
||||
CheckOutcome::MissingArtifact { release } => {
|
||||
let version = release.version.to_string();
|
||||
Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"status": "missing_artifact",
|
||||
"version": version,
|
||||
"target": unlucky_core::update::Target::current().as_str(),
|
||||
}),
|
||||
vec![format!(
|
||||
"v{version} 已发布,但没有为本平台({})提供产物——这是发布侧漏项,不是「已是最新」",
|
||||
unlucky_core::update::Target::current().as_str()
|
||||
)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user