Compare commits
47
Commits
termux-glibc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,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:判定没变就不重下、不重记日志)。
|
||||
|
||||
@@ -124,6 +124,7 @@ impl App {
|
||||
agent_host: super::presenter::AgentHost::from_env(),
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
launch_form: pages::LaunchForm::default(),
|
||||
install_cancelled_seen: 0,
|
||||
content: pages::content::PageState::default(),
|
||||
account_form: pages::account::Form::new(),
|
||||
|
||||
@@ -8,6 +8,8 @@ use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
@@ -52,10 +54,35 @@ impl App {
|
||||
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 的惯例:右栏不该开局就是空的)。
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
|
||||
ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
@@ -104,149 +103,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,
|
||||
|
||||
@@ -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,158 @@
|
||||
//! 自更新动作的落点(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 => self.dispatch(AppCommand::CheckUpdate),
|
||||
UpdateAction::Apply => 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()
|
||||
}
|
||||
|
||||
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
||||
///
|
||||
/// 截图通道(`--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));
|
||||
}
|
||||
|
||||
/// 截图通道不重启:取证进程不许把自己重启成死循环。
|
||||
#[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(),
|
||||
"重启不是一条后端指令(不该派发任何命令)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,11 @@ mod account_page;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
mod update;
|
||||
|
||||
use instances::{detail_of, runtime_status, 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)
|
||||
@@ -176,6 +180,8 @@ pub(super) fn model(
|
||||
// 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` 就是静息态,不叠状态层。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
||||
|
||||
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 {
|
||||
@@ -135,6 +135,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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -223,6 +223,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`]。这样截图里看到的每一行字都是产品路径会画的那一行,
|
||||
@@ -68,6 +68,21 @@ 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));
|
||||
@@ -194,6 +209,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` 承担,这里只改名字)
|
||||
|
||||
@@ -26,15 +26,22 @@
|
||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
|
||||
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};
|
||||
|
||||
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 +68,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 +91,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 +200,8 @@ pub struct Pack {
|
||||
pub cancelled_note: &'static str,
|
||||
/// 导入结果
|
||||
pub report: Option<Report>,
|
||||
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
|
||||
pub pick: Option<Pick>,
|
||||
/// 导出块
|
||||
pub export: Export,
|
||||
}
|
||||
@@ -181,6 +251,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 +269,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),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod pick;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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} \
|
||||
配色来源={:?} 选中分组={}",
|
||||
@@ -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,33 @@ 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; 19] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
"pid",
|
||||
"reason",
|
||||
"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",
|
||||
"gated",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
@@ -248,9 +272,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,31 @@ 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),
|
||||
// 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 +254,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 +272,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 +283,7 @@ pub fn app_with_appearance<'a>(
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
form,
|
||||
);
|
||||
|
||||
let shell = row![nav::rail(ui, model.active), body]
|
||||
@@ -278,7 +311,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 实例页要画的东西。
|
||||
@@ -92,6 +117,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(..)`),
|
||||
@@ -117,6 +144,7 @@ impl Model {
|
||||
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,123 @@
|
||||
//! 设置页「更新」分组(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);
|
||||
assert_eq!(model.update, crate::pages::update::demo());
|
||||
assert!(
|
||||
model.update.available().is_some(),
|
||||
"演示快照要带一个可装的版本"
|
||||
);
|
||||
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
|
||||
assert_eq!(
|
||||
crate::pages::demo_model(Screen::Instances, 1180.0).update,
|
||||
Update::default()
|
||||
);
|
||||
let catalog: Catalog = Lang::En.catalog();
|
||||
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! 设置页「更新」分组(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.auto_check));
|
||||
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()
|
||||
}
|
||||
|
||||
/// 自动检查开关 + 「立即检查」按钮。
|
||||
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, auto_check: bool) -> Element<'a, Message> {
|
||||
row![
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsUpdateAutoCheck),
|
||||
auto_check,
|
||||
Message::Update(UpdateAction::SetAutoCheck(!auto_check)),
|
||||
),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsUpdateCheckNow),
|
||||
Message::Update(UpdateAction::CheckNow),
|
||||
),
|
||||
),
|
||||
]
|
||||
.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) = if update.applying {
|
||||
(catalog.text(Key::SettingsUpdateApplying).to_string(), false)
|
||||
} else {
|
||||
status_line(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().filter(|_| !update.applying)
|
||||
{
|
||||
let mut actions = row![buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsUpdateApply),
|
||||
Message::Update(UpdateAction::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()
|
||||
}
|
||||
|
||||
/// 一条状态行(文案 + 是不是「错」那一类)。
|
||||
///
|
||||
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
|
||||
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 _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 _element: Element<'_, Message> = app(&ui, &model, &form);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! 自更新(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,
|
||||
}
|
||||
|
||||
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` 转换)。
|
||||
#[must_use]
|
||||
pub fn demo() -> 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)]
|
||||
mod tests {
|
||||
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}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
let Ok(coordinate) = library.coordinate() else {
|
||||
// 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()
|
||||
)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,17 @@ pub struct CliError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
/// `--json` 信封里 `error.detail` 的结构化载荷(可选)。
|
||||
///
|
||||
/// 为什么要有它:`code` 只分到「哪条命令的哪一类失败」(`update_apply_failed`),
|
||||
/// 而脚本真正要分流的是**底层原因**(`digest_mismatch` / `network` / `invalid_manifest`)。
|
||||
/// 那些原因以前只出现在人读 `message` 里(`应用更新失败(digest_mismatch):…`),
|
||||
/// 脚本不解析散文就分不出来。`detail` 把它们挪进机器可读字段。
|
||||
///
|
||||
/// **装箱**:`serde_json::Value` 有 32 字节,直接塞进 `CliError` 会把它顶到 144 字节,
|
||||
/// 于是所有 `Result<_, CliError>` 都触发 `clippy::result_large_err`(阈值 128)。
|
||||
/// `Option<Box<_>>` 只占 8 字节,错误对象保持轻量。
|
||||
detail: Option<Box<Value>>,
|
||||
}
|
||||
|
||||
/// 把模板里的程序名占位符渲染掉(M82 r3)。
|
||||
@@ -73,6 +84,7 @@ impl CliError {
|
||||
code: "usage",
|
||||
message: render(message),
|
||||
hint: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +96,7 @@ impl CliError {
|
||||
code,
|
||||
message: render(message),
|
||||
hint: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +108,7 @@ impl CliError {
|
||||
code,
|
||||
message: render(message),
|
||||
hint: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +125,22 @@ impl CliError {
|
||||
self
|
||||
}
|
||||
|
||||
/// 补一段**机器可读**的细节(`--json` 里落在 `error.detail`)。
|
||||
///
|
||||
/// 约定:`detail` 里只放脚本要分流的结构化字段(底层分类 id、可重试位、目标路径…),
|
||||
/// 人话仍在 `message` / `hint` 里——两条出口各管一件事,别把散文塞进 `detail`。
|
||||
#[must_use]
|
||||
pub fn with_detail(mut self, detail: Value) -> Self {
|
||||
self.detail = Some(Box::new(detail));
|
||||
self
|
||||
}
|
||||
|
||||
/// 机器可读细节(没有则为 `None`)。
|
||||
#[must_use]
|
||||
pub fn detail(&self) -> Option<&Value> {
|
||||
self.detail.as_deref()
|
||||
}
|
||||
|
||||
/// 错误大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ErrorKind {
|
||||
@@ -152,6 +182,9 @@ impl CliError {
|
||||
if let Some(hint) = &self.hint {
|
||||
error["hint"] = Value::String(hint.clone());
|
||||
}
|
||||
if let Some(detail) = &self.detail {
|
||||
error["detail"] = (**detail).clone();
|
||||
}
|
||||
json!({ "ok": false, "error": error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,51 @@ fn java_subcommands() {
|
||||
assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_subcommands() {
|
||||
use unlucky_cli::cli::{UpdateApplyArgs, UpdateCheckArgs};
|
||||
|
||||
assert_eq!(
|
||||
invoke(&["update", "check"]).command,
|
||||
Command::UpdateCheck(UpdateCheckArgs {})
|
||||
);
|
||||
assert_eq!(
|
||||
invoke(&["update", "apply"]).command,
|
||||
Command::UpdateApply(UpdateApplyArgs { dest: None })
|
||||
);
|
||||
// `--dest` 两种写法都要认(钉替换目标 = 取证 / 自定义安装位置)
|
||||
for args in [
|
||||
vec!["update", "apply", "--dest", "D:/tmp/ull.exe"],
|
||||
vec!["update", "apply", "--dest=D:/tmp/ull.exe"],
|
||||
] {
|
||||
assert_eq!(
|
||||
invoke(&args).command,
|
||||
Command::UpdateApply(UpdateApplyArgs {
|
||||
dest: Some(std::path::PathBuf::from("D:/tmp/ull.exe")),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `update` 的用法错误:没子命令 / 未知子命令 / 多余位置参数 / 未知选项——四条都是 `usage`
|
||||
/// (退出码 1),不许静默退化成某个默认动作。
|
||||
#[test]
|
||||
fn update_usage_errors() {
|
||||
for args in [
|
||||
vec!["update"],
|
||||
vec!["update", "install"],
|
||||
vec!["update", "check", "extra"],
|
||||
vec!["update", "apply", "extra"],
|
||||
vec!["update", "apply", "--dest"],
|
||||
vec!["update", "apply", "--nope"],
|
||||
] {
|
||||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||||
let error = parse(&owned).expect_err(&format!("{args:?} 应当报用法错误"));
|
||||
assert_eq!(error.kind(), ErrorKind::User, "{args:?}");
|
||||
assert_eq!(error.code(), "usage", "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_errors_are_user_errors() {
|
||||
let cases: &[&[&str]] = &[
|
||||
@@ -328,6 +373,7 @@ fn help_text_covers_every_command() {
|
||||
"diag",
|
||||
"config",
|
||||
"java",
|
||||
"update",
|
||||
] {
|
||||
assert!(
|
||||
unlucky_cli::cli::help::topic(topic).is_some(),
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
|
||||
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
|
||||
//!
|
||||
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
|
||||
//!
|
||||
//! | 步骤 | 入口 | 写盘吗 |
|
||||
//! |------|------|--------|
|
||||
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
|
||||
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
|
||||
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
|
||||
//!
|
||||
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
|
||||
//!
|
||||
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
|
||||
//! 目标不存在、以及用户删过的文件都不打扰用户;
|
||||
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
|
||||
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
|
||||
//!
|
||||
//! # 三条硬要求落在哪
|
||||
//!
|
||||
//! | 要求 | 落点 |
|
||||
@@ -18,7 +33,10 @@
|
||||
//!
|
||||
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
||||
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
|
||||
//! 优先级落地,不自创排序。
|
||||
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
|
||||
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
|
||||
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
|
||||
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
|
||||
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
|
||||
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
|
||||
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
|
||||
@@ -60,6 +78,7 @@
|
||||
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
|
||||
|
||||
pub mod baseline;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
pub mod fetch;
|
||||
pub mod merge;
|
||||
@@ -67,9 +86,11 @@ pub mod phase;
|
||||
pub mod pipeline;
|
||||
pub mod plan;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod stage;
|
||||
|
||||
pub use baseline::{PackBaseline, BASELINE_FILE};
|
||||
pub use decision::ConflictDecisions;
|
||||
pub use error::{ImportError, ImportErrorKind};
|
||||
pub use fetch::{
|
||||
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
|
||||
@@ -77,12 +98,13 @@ pub use fetch::{
|
||||
};
|
||||
pub use merge::{MergeAction, MergeDecision};
|
||||
pub use pipeline::baseline_relative;
|
||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant};
|
||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
|
||||
pub use report::{
|
||||
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
|
||||
};
|
||||
pub use scan::{scan_pack, ImportConflict, ImportScan};
|
||||
pub use stage::{RollbackOutcome, Staging};
|
||||
|
||||
use std::path::Path;
|
||||
@@ -90,7 +112,15 @@ use std::path::Path;
|
||||
use crate::addon::pack::PackLimits;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 冲突处置策略(默认保留用户文件)。
|
||||
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
|
||||
///
|
||||
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
|
||||
/// [`ConflictDecisions::default_choice`] 的构造器:
|
||||
///
|
||||
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
|
||||
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
|
||||
///
|
||||
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConflictPolicy {
|
||||
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
|
||||
@@ -100,6 +130,17 @@ pub enum ConflictPolicy {
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl ConflictPolicy {
|
||||
/// 对应的逐项决策缺省档。
|
||||
#[must_use]
|
||||
const fn choice(self) -> ConflictChoice {
|
||||
match self {
|
||||
Self::KeepUser => ConflictChoice::KeepUser,
|
||||
Self::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入请求。
|
||||
pub struct ImportRequest<'a> {
|
||||
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
|
||||
@@ -112,8 +153,8 @@ pub struct ImportRequest<'a> {
|
||||
pub register: Option<&'a LauncherLayout>,
|
||||
/// 实例展示名(缺省用包名)。
|
||||
pub instance_name: Option<&'a str>,
|
||||
/// 冲突策略。
|
||||
pub conflict_policy: ConflictPolicy,
|
||||
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
|
||||
pub decisions: ConflictDecisions,
|
||||
/// 包读取限额(条数 / 单条体积 / 解压总量)。
|
||||
pub limits: PackLimits,
|
||||
/// 进度回调。
|
||||
@@ -130,7 +171,7 @@ impl<'a> ImportRequest<'a> {
|
||||
fetcher: None,
|
||||
register: None,
|
||||
instance_name: None,
|
||||
conflict_policy: ConflictPolicy::KeepUser,
|
||||
decisions: ConflictDecisions::default(),
|
||||
limits: PackLimits::default(),
|
||||
progress: None,
|
||||
}
|
||||
@@ -151,10 +192,24 @@ impl<'a> ImportRequest<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// 改冲突策略。
|
||||
/// 整批缺省档(逐项决策仍然优先)。
|
||||
#[must_use]
|
||||
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
|
||||
self.conflict_policy = policy;
|
||||
self.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
|
||||
self
|
||||
}
|
||||
|
||||
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
|
||||
#[must_use]
|
||||
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
|
||||
self.decisions = decisions;
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
|
||||
#[must_use]
|
||||
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||||
self.decisions.set(path, choice);
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 逐文件冲突决策:`path → Keep / Overwrite`,以及"没收到决策就等于保留"这条硬线。
|
||||
//!
|
||||
//! # 三个入口,一个通道
|
||||
//!
|
||||
//! | 用户在界面上点的 | 落到这里的形状 |
|
||||
//! |---|---|
|
||||
//! | 全部保留 | [`ConflictDecisions::keep_all`](= [`ConflictDecisions::default`]) |
|
||||
//! | 全部覆盖 | [`ConflictDecisions::overwrite_all`] |
|
||||
//! | 逐项 | [`ConflictDecisions::with`] 一条一条挂上去,整批档仍是"保留" |
|
||||
//!
|
||||
//! 三者共用一个类型,是因为它们要回答的是同一个问题:「这个路径上的冲突该怎么处置」。
|
||||
//! 两条独立的落盘规则迟早会打架(一处按整批档、一处按逐项表),所以合并成一处。
|
||||
//!
|
||||
//! # 缺省为什么必须保留
|
||||
//!
|
||||
//! [`ConflictChoice::default`] 是 `KeepUser`:**表里没有这个路径**、或者调用方根本没设过决策时,
|
||||
//! 结论是"用户那份文件留着"。这不是保守,是正确性——导入的输入是别人做的整合包,用户的
|
||||
//! 配置/存档/自己改过的 mod 不能因为"我们没问"就消失。要看"覆盖"只有一个来路:调用方
|
||||
//! **显式**说了覆盖(整批档或逐项决策)。
|
||||
//!
|
||||
//! # 决策只作用于**冲突**
|
||||
//!
|
||||
//! 非冲突路径(磁盘上没有、或内容与包内一致)根本不看这张表:没有冲突就没有"保留/覆盖"这个
|
||||
//! 问题,`merge::decide` 的结论照旧。于是"表里没有这条"永远不会把一份该装的文件漏掉。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::report::ConflictChoice;
|
||||
|
||||
/// 一次导入的逐文件决策表。
|
||||
///
|
||||
/// 顺序稳定(`BTreeMap`),所以同一份决策序列化出来的形状是确定的——诊断与测试都能直接比。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConflictDecisions {
|
||||
/// 表里没点名的冲突按它处置。
|
||||
default: ConflictChoice,
|
||||
/// 逐项决策(实例内相对路径 → 处置)。
|
||||
entries: BTreeMap<String, ConflictChoice>,
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// **不许改**:这是"没收到决策不覆盖"的落点。
|
||||
default: ConflictChoice::KeepUser,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisions {
|
||||
/// 全部保留(= 缺省)。
|
||||
#[must_use]
|
||||
pub fn keep_all() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 全部覆盖(用户显式点了「整合包说了算」)。
|
||||
#[must_use]
|
||||
pub fn overwrite_all() -> Self {
|
||||
Self {
|
||||
default: ConflictChoice::Overwrite,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 改整批缺省档(逐项决策优先于它)。
|
||||
#[must_use]
|
||||
pub fn with_default(mut self, choice: ConflictChoice) -> Self {
|
||||
self.default = choice;
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(链式)。
|
||||
#[must_use]
|
||||
pub fn with(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||||
self.set(path, choice);
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(就地)。
|
||||
pub fn set(&mut self, path: impl Into<String>, choice: ConflictChoice) {
|
||||
self.entries.insert(path.into(), choice);
|
||||
}
|
||||
|
||||
/// 整批缺省档。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoice {
|
||||
self.default
|
||||
}
|
||||
|
||||
/// 这个路径的处置:逐项决策优先,其次整批档(**整批档缺省是保留**)。
|
||||
#[must_use]
|
||||
pub fn choice_for(&self, path: &str) -> ConflictChoice {
|
||||
self.entries.get(path).copied().unwrap_or(self.default)
|
||||
}
|
||||
|
||||
/// 这个路径有没有被逐项点名(诊断用:区分"用户选的"与"缺省档")。
|
||||
#[must_use]
|
||||
pub fn explicit(&self, path: &str) -> Option<ConflictChoice> {
|
||||
self.entries.get(path).copied()
|
||||
}
|
||||
|
||||
/// 逐项决策的条数(不含整批缺省档)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// 逐项决策(按路径排序;进报告/日志用)。
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, ConflictChoice)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|(path, choice)| (path.as_str(), *choice))
|
||||
}
|
||||
|
||||
/// 逐项决策里"覆盖"的条数。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|choice| **choice == ConflictChoice::Overwrite)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_decision_never_overwrites() {
|
||||
// 这张表是"没收到决策就覆盖"的唯一防线:默认档与空表的答案都必须是保留。
|
||||
assert_eq!(ConflictChoice::default(), ConflictChoice::KeepUser);
|
||||
let empty = ConflictDecisions::default();
|
||||
assert_eq!(empty.choice_for("config/a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
ConflictDecisions::keep_all().default_choice(),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_item_wins_over_the_batch_default() {
|
||||
let decisions =
|
||||
ConflictDecisions::overwrite_all().with("mods/pin.jar", ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
decisions.choice_for("mods/pin.jar"),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert_eq!(
|
||||
decisions.choice_for("mods/other.jar"),
|
||||
ConflictChoice::Overwrite
|
||||
);
|
||||
assert_eq!(
|
||||
decisions.explicit("mods/pin.jar"),
|
||||
Some(ConflictChoice::KeepUser)
|
||||
);
|
||||
assert_eq!(decisions.explicit("mods/other.jar"), None);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_idempotent_per_path() {
|
||||
let mut decisions = ConflictDecisions::keep_all();
|
||||
decisions.set("a.toml", ConflictChoice::Overwrite);
|
||||
decisions.set("a.toml", ConflictChoice::KeepUser);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
assert_eq!(decisions.choice_for("a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
let paths: Vec<&str> = decisions.iter().map(|(path, _)| path).collect();
|
||||
assert_eq!(paths, vec!["a.toml"]);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@
|
||||
//! | 有 | 有 | `D == B` | [`MergeAction::Install`] | 用户没动过 ⇒ 整合包升级,覆盖是对的 |
|
||||
//! | 有 | 有 | `D != B` | [`MergeAction::KeepUser`] + `UserModified` | 用户改过:**不许**静默覆盖 |
|
||||
//!
|
||||
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:
|
||||
//! `ImportRequest::conflict_policy = Overwrite` 时,`KeepUser` 才会被判据的调用方翻成
|
||||
//! [`MergeAction::Install`],**判据本身不读策略**——这样"哪些情形算冲突"与"冲突怎么处置"
|
||||
//! 是两个可独立验证的事实。
|
||||
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:决策
|
||||
//! (逐项 [`super::decision::ConflictDecisions`],或整批 [`super::ConflictPolicy`])为
|
||||
//! `Overwrite` 时,`KeepUser` 才会被判据的调用方翻成 [`MergeAction::Install`],**判据本身
|
||||
//! 不读决策**——这样"哪些情形算冲突"与"冲突怎么处置"是两个可独立验证的事实。
|
||||
//!
|
||||
//! # `.disabled` / `.old` 不参与判据
|
||||
//!
|
||||
@@ -76,7 +76,11 @@ pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>)
|
||||
// 磁盘上没有这个文件。
|
||||
(None, None) => MergeDecision::plain(MergeAction::Install),
|
||||
(None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted),
|
||||
// 磁盘上有。
|
||||
// 磁盘上有、内容与包内**逐字节相同**(同一份 SHA-1)。
|
||||
//
|
||||
// 这是"内容相同"的**唯一判据**:预检(`super::scan::judge`)刻意不再自己写一遍
|
||||
// `disk == pack` 的早退(复审 r1 P3-2),而是走这里的 `reason == None`。改这一行
|
||||
// 会同时影响"导入怎么处置"与"预检列不列这条",所以两边的测试都会红。
|
||||
(Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical),
|
||||
(Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting),
|
||||
(Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install),
|
||||
|
||||
@@ -22,6 +22,11 @@ use crate::download::verify::{self, digest_bytes};
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
/// 阶段一:覆盖条目落暂存 + 三方合并。
|
||||
///
|
||||
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
|
||||
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
|
||||
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
|
||||
/// "两条 Installed、前一条的哈希其实没落地"。
|
||||
pub(crate) async fn extract_overrides(
|
||||
request: &ImportRequest<'_>,
|
||||
archive: &PackArchive,
|
||||
@@ -31,9 +36,14 @@ pub(crate) async fn extract_overrides(
|
||||
report: &mut ImportReport,
|
||||
next: &mut PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
let total = plan.overrides.len();
|
||||
let live: Vec<&super::plan::PlanOverride> = plan
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|entry| entry.shadowed.is_none())
|
||||
.collect();
|
||||
let total = live.len();
|
||||
let target = staging.target().to_path_buf();
|
||||
for (index, entry) in plan.overrides.iter().enumerate() {
|
||||
for (index, entry) in live.iter().enumerate() {
|
||||
tell(
|
||||
&request.progress,
|
||||
ImportStage::Extract,
|
||||
@@ -52,7 +62,9 @@ pub(crate) async fn extract_overrides(
|
||||
next.insert(entry.path.clone(), pack_sha1.clone());
|
||||
let disk = disk_sha1(&target.join(&entry.path))?;
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&entry.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
// 逐项决策优先,其次整批档(缺省是"保留"):见 `decision::ConflictDecisions`。
|
||||
let picked = request.decisions.choice_for(&entry.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if let Some(reason) = decision.reason {
|
||||
report.conflicts.push(conflict(
|
||||
entry.path.clone(),
|
||||
@@ -111,7 +123,8 @@ pub(crate) async fn fetch_remote(
|
||||
// 有声明摘要就能先判合并:命中"保留用户 / 已一致"时**不必下载**(省一次网络)。
|
||||
if let Some(declared) = resolved.sha1.clone() {
|
||||
let decision = merge::decide(&declared, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
let picked = request.decisions.choice_for(&resolved.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if !action.wrote() {
|
||||
next.insert(resolved.path.clone(), declared.clone());
|
||||
if let Some(reason) = decision.reason {
|
||||
@@ -175,7 +188,8 @@ pub(crate) async fn fetch_remote(
|
||||
}
|
||||
}
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
let picked = request.decisions.choice_for(&resolved.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if let Some(reason) = decision.reason {
|
||||
report.conflicts.push(conflict(
|
||||
resolved.path.clone(),
|
||||
@@ -266,13 +280,17 @@ async fn resolve(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 合并判定 + 策略 → 实际动作。
|
||||
/// 合并判定 + 决策 → 实际动作。
|
||||
///
|
||||
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
|
||||
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错
|
||||
/// 一个保留"不会把一份全新文件漏掉。
|
||||
fn settle(
|
||||
decision: merge::MergeDecision,
|
||||
disk_exists: bool,
|
||||
policy: super::ConflictPolicy,
|
||||
picked: ConflictChoice,
|
||||
) -> (FileAction, ConflictChoice) {
|
||||
let overwrite = policy == super::ConflictPolicy::Overwrite;
|
||||
let overwrite = picked == ConflictChoice::Overwrite;
|
||||
match decision.action {
|
||||
MergeAction::Install => (
|
||||
if disk_exists {
|
||||
|
||||
@@ -49,7 +49,11 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
};
|
||||
account_plan(&mut report, &plan);
|
||||
|
||||
let baseline = load_baseline(request.target, &plan, &mut report);
|
||||
let baseline = {
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
report.warnings.extend(warnings);
|
||||
baseline
|
||||
};
|
||||
let staging = match Staging::begin(request.target) {
|
||||
Ok(staging) => staging,
|
||||
Err(error) => return abandoned(report, error),
|
||||
@@ -134,7 +138,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
}
|
||||
|
||||
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
|
||||
fn probe_and_plan(request: &ImportRequest<'_>) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
///
|
||||
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
|
||||
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
|
||||
pub(super) fn probe_and_plan(
|
||||
request: &ImportRequest<'_>,
|
||||
) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
|
||||
let archive = PackArchive::open(request.pack, request.limits)?;
|
||||
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
|
||||
@@ -159,17 +168,18 @@ fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
|
||||
///
|
||||
/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过
|
||||
/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。
|
||||
fn load_baseline(
|
||||
///
|
||||
/// 返回的告警由调用方写进报告(预检也是同一批文案——两侧对"有没有基线"必须有同一个答案)。
|
||||
pub(super) fn load_baseline(
|
||||
target: &std::path::Path,
|
||||
plan: &ImportPlan,
|
||||
report: &mut ImportReport,
|
||||
) -> PackBaseline {
|
||||
) -> (PackBaseline, Vec<ImportIssue>) {
|
||||
match PackBaseline::load(target) {
|
||||
Ok(baseline) => {
|
||||
if baseline.matches_pack(&plan.format, &plan.manifest.name) {
|
||||
return baseline;
|
||||
return (baseline, Vec::new());
|
||||
}
|
||||
report.warnings.push(ImportIssue::new(
|
||||
let warning = ImportIssue::new(
|
||||
keys::NOTE_BASELINE_PACK_MISMATCH,
|
||||
format!(
|
||||
"基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容",
|
||||
@@ -180,17 +190,17 @@ fn load_baseline(
|
||||
},
|
||||
baseline.pack_name
|
||||
),
|
||||
));
|
||||
PackBaseline::default()
|
||||
);
|
||||
(PackBaseline::default(), vec![warning])
|
||||
}
|
||||
Err(reason) => {
|
||||
report.warnings.push(ImportIssue::new(
|
||||
let warning = ImportIssue::new(
|
||||
keys::NOTE_BASELINE_UNREADABLE,
|
||||
format!(
|
||||
"基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}"
|
||||
),
|
||||
));
|
||||
PackBaseline::default()
|
||||
);
|
||||
(PackBaseline::default(), vec![warning])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,24 @@
|
||||
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
|
||||
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
|
||||
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
|
||||
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
|
||||
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
|
||||
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
|
||||
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
|
||||
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
|
||||
//! [`paths`]。
|
||||
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
|
||||
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
|
||||
|
||||
mod layer;
|
||||
pub mod paths;
|
||||
|
||||
pub use layer::Shadowed;
|
||||
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
|
||||
|
||||
use super::super::pack::{
|
||||
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
|
||||
PackOverrides, PackSide,
|
||||
PackSide,
|
||||
};
|
||||
use super::error::ImportError;
|
||||
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
|
||||
@@ -51,6 +56,8 @@ pub struct PlanOverride {
|
||||
pub disabled: bool,
|
||||
/// 可选注记(`.disabled` / `.old`)。
|
||||
pub note: Option<ImportIssue>,
|
||||
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
|
||||
pub shadowed: Option<Shadowed>,
|
||||
}
|
||||
|
||||
/// 一条要补全/下载的远端条目。
|
||||
@@ -136,7 +143,7 @@ impl ImportPlan {
|
||||
|
||||
let mut overrides: Vec<PlanOverride> = Vec::new();
|
||||
let mut skipped_server_files = 0_usize;
|
||||
for group in ordered_overrides(&manifest) {
|
||||
for group in layer::ordered(&manifest) {
|
||||
if group.side == PackSide::Server {
|
||||
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
|
||||
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
|
||||
@@ -153,9 +160,11 @@ impl ImportPlan {
|
||||
size: file.size,
|
||||
disabled: variant.is_disabled(),
|
||||
note: suffix_note(variant, &file.path),
|
||||
shadowed: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
layer::resolve(&mut overrides, &mut notes);
|
||||
if skipped_server_files > 0 {
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
|
||||
@@ -232,17 +241,6 @@ impl ImportPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
|
||||
fn ordered_overrides(manifest: &PackManifest) -> Vec<&PackOverrides> {
|
||||
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
|
||||
groups.sort_by_key(|group| match group.side {
|
||||
PackSide::Both => 0_u8,
|
||||
PackSide::Client => 1,
|
||||
PackSide::Server => 2,
|
||||
});
|
||||
groups
|
||||
}
|
||||
|
||||
/// 选一个加载器并翻译成 [`LoaderTarget`]。
|
||||
fn select_loader(
|
||||
manifest: &PackManifest,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
|
||||
//!
|
||||
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
|
||||
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::super::report::{keys, FileSource, ImportIssue};
|
||||
use super::PlanOverride;
|
||||
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
|
||||
|
||||
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
|
||||
///
|
||||
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
|
||||
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
|
||||
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
|
||||
/// `server-overrides/ 被跳过` 完全同一条通道。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Shadowed {
|
||||
/// 胜出那条的来源侧。
|
||||
pub winner: FileSource,
|
||||
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
|
||||
pub winner_dir: String,
|
||||
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
|
||||
pub same_bytes: bool,
|
||||
}
|
||||
|
||||
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
|
||||
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
|
||||
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
|
||||
groups.sort_by_key(|group| match group.side {
|
||||
PackSide::Both => 0_u8,
|
||||
PackSide::Client => 1,
|
||||
PackSide::Server => 2,
|
||||
});
|
||||
groups
|
||||
}
|
||||
|
||||
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
|
||||
///
|
||||
/// # 这是"逐文件"而不是"整包一个策略"的那一步
|
||||
///
|
||||
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
|
||||
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
|
||||
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
|
||||
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
|
||||
///
|
||||
/// 现在把这层算清楚,逐条给出两个事实:
|
||||
///
|
||||
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
|
||||
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
|
||||
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
|
||||
///
|
||||
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
|
||||
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
|
||||
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
|
||||
let mut last: HashMap<String, usize> = HashMap::new();
|
||||
for (index, entry) in overrides.iter().enumerate() {
|
||||
last.insert(entry.path.clone(), index);
|
||||
}
|
||||
for index in 0..overrides.len() {
|
||||
let path = overrides[index].path.clone();
|
||||
let Some(&winner_index) = last.get(&path) else {
|
||||
continue;
|
||||
};
|
||||
if winner_index == index {
|
||||
continue;
|
||||
}
|
||||
let winner_dir = overrides[winner_index].dir.clone();
|
||||
let loser_dir = overrides[index].dir.clone();
|
||||
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
|
||||
let conclusion = if same_bytes {
|
||||
"两份内容逐字节相同,只落地一次"
|
||||
} else {
|
||||
"两份内容不同,以这一条为准"
|
||||
};
|
||||
notes.push(
|
||||
ImportIssue::new(
|
||||
keys::NOTE_OVERRIDE_SHADOWED,
|
||||
format!(
|
||||
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
|
||||
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
|
||||
{conclusion}"
|
||||
),
|
||||
)
|
||||
.with_path(path),
|
||||
);
|
||||
overrides[index].shadowed = Some(Shadowed {
|
||||
winner: overrides[winner_index].source,
|
||||
winner_dir,
|
||||
same_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
|
||||
PlanOverride {
|
||||
dir: dir.to_string(),
|
||||
path: path.to_string(),
|
||||
source,
|
||||
sha1: sha1.to_string(),
|
||||
size: 1,
|
||||
disabled: false,
|
||||
note: None,
|
||||
shadowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
|
||||
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/dup.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/dup.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"bbbb",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
|
||||
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
|
||||
assert_eq!(shadowed.winner_dir, "client-overrides");
|
||||
assert!(!shadowed.same_bytes, "同路径不同哈希");
|
||||
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
|
||||
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
|
||||
let message = ¬es[0].message;
|
||||
for expected in [
|
||||
"overrides/config/dup.toml",
|
||||
"client-overrides/config/dup.toml",
|
||||
"内容不同",
|
||||
] {
|
||||
assert!(message.contains(expected), "缺 {expected}:{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_are_reported_as_same_bytes() {
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/same.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/same.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"aaaa",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
|
||||
assert!(
|
||||
notes[0].message.contains("逐字节相同"),
|
||||
"内容一致要说清楚,别让人以为丢了东西:{}",
|
||||
notes[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_paths_with_the_same_bytes_are_never_shadowed() {
|
||||
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
|
||||
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert_eq!(
|
||||
overrides[0].shadowed.as_ref().expect("标记").winner,
|
||||
FileSource::Overrides
|
||||
);
|
||||
assert_eq!(notes.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ impl ConflictReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] 决定)。
|
||||
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] / [`super::super::ConflictDecisions`] 决定)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictChoice {
|
||||
@@ -89,6 +89,13 @@ pub enum ConflictChoice {
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl Default for ConflictChoice {
|
||||
/// **缺省是保留**:没有决策时绝不覆盖用户已有文件(M160 的硬线,见 `import::decision`)。
|
||||
fn default() -> Self {
|
||||
Self::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConflictFile {
|
||||
|
||||
@@ -51,6 +51,10 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi
|
||||
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
|
||||
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
|
||||
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
||||
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
|
||||
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
|
||||
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
|
||||
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
|
||||
|
||||
/// 缺失:调用方没给 fetcher(离线导入)。
|
||||
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
|
||||
@@ -94,6 +98,8 @@ pub const ALL: &[&str] = &[
|
||||
NOTE_BASELINE_PACK_MISMATCH,
|
||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||
NOTE_ROLLBACK_INCOMPLETE,
|
||||
NOTE_OVERRIDE_SHADOWED,
|
||||
NOTE_REMOTE_PATH_UNKNOWN,
|
||||
MISSING_OFFLINE,
|
||||
MISSING_NO_SOURCE,
|
||||
MISSING_FETCH_FAILED,
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! 导入**预检**(M160):只读地算出「冲突清单」——**只列真正需要用户点头的文件**。
|
||||
//!
|
||||
//! # 什么进清单,什么不进(口径只此一处)
|
||||
//!
|
||||
//! 一个路径进清单**当且仅当**:目标实例里同名路径是一个普通文件,且它的内容与包内**不同**。
|
||||
//! 等值判据是 **SHA-1**(包内那条来自 M110 读包时现算的摘要,磁盘那条现算);体积只作展示,
|
||||
//! 不参与判定——同一份内容不可能有两个哈希,而"体积相同"并不说明内容相同。
|
||||
//!
|
||||
//! 不进清单的四种情形,每一种都有理由:
|
||||
//!
|
||||
//! | 情形 | 为什么不必打扰用户 |
|
||||
//! |------|--------------------|
|
||||
//! | 目标里没有这个文件 | 全新文件,装就是了 |
|
||||
//! | 内容与包内一致 | 已经是最新,写盘是浪费 |
|
||||
//! | 用户删过(基线有、磁盘没有) | 删是用户的决定,**不偷偷装回来**(`merge::decide` 的 `UserDeleted`) |
|
||||
//! | 包内被同包另一条声明遮蔽(`shadowed`) | 它不落地,用户看不到它,问了也无从选 |
|
||||
//!
|
||||
//! # 叠层优先级不在这层重判
|
||||
//!
|
||||
//! `client-overrides/` 胜 `overrides/`、`server-overrides/` 客户端不解压——这些**已由**
|
||||
//! [`super::plan::ImportPlan::build`] 与 `plan::layer::resolve` 算定(一路径一行,胜者才在
|
||||
//! `overrides` 表里且 `shadowed.is_none()`)。本模块只按胜者取 `source`,从不自己再排一次。
|
||||
//!
|
||||
//! # 远端条目的两种命运
|
||||
//!
|
||||
//! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs
|
||||
//! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有
|
||||
//! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进
|
||||
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。计数与
|
||||
//! 文案同一口径:**判不了**的两种来路(补全前没有路径 / 有路径但缺 `hashes.sha1`)都算。
|
||||
//!
|
||||
//! # 便宜在哪、贵在哪
|
||||
//!
|
||||
//! 预检不读包内字节(摘要来自 M110 的读包结果),但要给每条冲突填"目标现有体积与哈希",
|
||||
//! 所以**磁盘上那些同名文件要真读一遍**。于是 `scan` + `import` 会把它们读两次——这是本轮
|
||||
//! 的已知代价(要省就得让导入直接吃预检的判定结果,那是另一件事,报告里登记)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::baseline::PackBaseline;
|
||||
use super::decision::ConflictDecisions;
|
||||
use super::error::ImportError;
|
||||
use super::merge;
|
||||
use super::pipeline::{load_baseline, probe_and_plan};
|
||||
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify;
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
/// 一条**要用户点头**的冲突(目标里已有同名文件且内容不同)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportConflict {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内 `overrides/` / `client-overrides/` / mrpack 或 CF 的 `files[]`)。
|
||||
pub source: FileSource,
|
||||
/// 成因(`PreExisting` = 没有基线可判;`UserModified` = 基线里记过、用户改过)。
|
||||
pub reason: ConflictReason,
|
||||
/// 包内那条是不是禁用态(`.disabled` 后缀原样保留)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportConflict {
|
||||
/// 覆盖它会丢掉多少字节(界面上"覆盖会丢掉 12.4 MB"就是它)。
|
||||
#[must_use]
|
||||
pub const fn lost_bytes(&self) -> u64 {
|
||||
self.disk_size
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次预检的结果。
|
||||
///
|
||||
/// `error` 为 `Some` 时其余字段是"没算出来":探测/开包/组计划失败与导入管线同一条口径
|
||||
/// (同一个 [`PackError`] → [`ImportError`] 的映射),不去新造一套。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportScan {
|
||||
/// 目标实例目录。
|
||||
pub target: PathBuf,
|
||||
/// 命中的格式 id。
|
||||
pub format: Option<String>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器计划输入(`None` = 纯原版)。
|
||||
pub loader: Option<LoaderTarget>,
|
||||
/// 冲突清单(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflict>,
|
||||
/// 预检阶段**判不了**的远端条目数。
|
||||
///
|
||||
/// 判不了有两种来路,计数把两者都算上(口径与注记文案一致,复审 r1 P3-4):
|
||||
/// ①CurseForge `files[]` 补全前**没有路径**(常态);②极少数 mrpack 条目**有路径但缺
|
||||
/// `hashes.sha1`**,没有声明摘要就没法与磁盘现状比对。
|
||||
pub unknown_remote: usize,
|
||||
/// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。
|
||||
pub notes: Vec<ImportIssue>,
|
||||
/// 开包或组计划失败(`Failed` 口径,目标目录一个字节都没动)。
|
||||
pub error: Option<ImportError>,
|
||||
}
|
||||
|
||||
impl ImportScan {
|
||||
fn empty(target: PathBuf) -> Self {
|
||||
Self {
|
||||
target,
|
||||
format: None,
|
||||
pack_name: None,
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
conflicts: Vec::new(),
|
||||
unknown_remote: 0,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检成功了吗(`false` = 看 [`Self::error`])。
|
||||
#[must_use]
|
||||
pub const fn is_ok(&self) -> bool {
|
||||
self.error.is_none()
|
||||
}
|
||||
|
||||
/// 有没有需要用户点头的项(`false` = 可以直接导入,不必打扰用户)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 「全部保留」的决策(= 缺省档,不覆盖任何用户文件)。
|
||||
#[must_use]
|
||||
pub fn keep_all(&self) -> ConflictDecisions {
|
||||
ConflictDecisions::keep_all()
|
||||
}
|
||||
|
||||
/// 「全部覆盖」的决策(用户显式要求"整合包说了算")。
|
||||
#[must_use]
|
||||
pub fn overwrite_all(&self) -> ConflictDecisions {
|
||||
ConflictDecisions::overwrite_all()
|
||||
}
|
||||
|
||||
/// 覆盖会丢掉的字节总量(界面上的计数摘要)。
|
||||
#[must_use]
|
||||
pub fn lost_bytes(&self) -> u64 {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.map(ImportConflict::lost_bytes)
|
||||
.fold(0, u64::saturating_add)
|
||||
}
|
||||
|
||||
/// 包内那些条目声明了多大(= 覆盖后会占用的字节数)。
|
||||
#[must_use]
|
||||
pub fn incoming_bytes(&self) -> u64 {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.pack_size)
|
||||
.fold(0, u64::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线)。
|
||||
#[must_use]
|
||||
pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
|
||||
let mut scan = ImportScan::empty(request.target.to_path_buf());
|
||||
let plan = match probe_and_plan(request) {
|
||||
Ok((_, plan)) => plan,
|
||||
Err(error) => {
|
||||
scan.error = Some(error);
|
||||
return scan;
|
||||
}
|
||||
};
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
scan.notes = warnings;
|
||||
scan.format = Some(plan.format.clone());
|
||||
scan.pack_name = Some(plan.manifest.name.clone());
|
||||
scan.pack_version = plan.manifest.version.clone();
|
||||
scan.game_version = Some(plan.mc_version.clone());
|
||||
scan.loader = plan.loader.clone();
|
||||
|
||||
// 一路径一行:同一路径被覆盖条目与远端条目同时声明时,只留一条(决策是按路径给的)。
|
||||
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
|
||||
for entry in plan
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|entry| entry.shadowed.is_none())
|
||||
{
|
||||
let Some(conflict) = judge(
|
||||
&entry.path,
|
||||
entry.source,
|
||||
&entry.sha1,
|
||||
entry.size,
|
||||
entry.disabled,
|
||||
&baseline,
|
||||
request.target,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
seen.insert(entry.path.clone(), ());
|
||||
scan.conflicts.push(conflict);
|
||||
}
|
||||
for entry in &plan.remote {
|
||||
let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else {
|
||||
// 两种来路都算「判不了」:没有路径(CF 待补全)或没有声明摘要(缺 `hashes.sha1`)。
|
||||
scan.unknown_remote += 1;
|
||||
continue;
|
||||
};
|
||||
if seen.contains_key(path) {
|
||||
continue;
|
||||
}
|
||||
let Some(conflict) = judge(
|
||||
path,
|
||||
entry.source,
|
||||
declared,
|
||||
entry.file.size.unwrap_or(0),
|
||||
entry.disabled,
|
||||
&baseline,
|
||||
request.target,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
seen.insert(path.to_string(), ());
|
||||
scan.conflicts.push(conflict);
|
||||
}
|
||||
if scan.unknown_remote > 0 {
|
||||
scan.notes.push(ImportIssue::new(
|
||||
keys::NOTE_REMOTE_PATH_UNKNOWN,
|
||||
format!(
|
||||
"有 {} 条远端条目预检判不了:CurseForge files[] 补全前没有路径,个别条目有路径但没有 sha1 声明;这些条目会不会撞上已有文件只能等补全/下载后才知道,导入时仍按「缺省保留」处置",
|
||||
scan.unknown_remote
|
||||
),
|
||||
));
|
||||
}
|
||||
scan.conflicts
|
||||
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
scan
|
||||
}
|
||||
|
||||
/// 判一个路径:`Some` = 要用户点头的冲突。
|
||||
///
|
||||
/// # 「内容相同」的判据**只此一处**:[`merge::decide`]
|
||||
///
|
||||
/// 这里**故意不再写** `if disk.sha1 == pack_sha1 { return None }`(复审 r1 P3-2 抓到的重复判据):
|
||||
/// 那种早退与 `merge::decide` 的 `SkipIdentical` 分支是同一件事的两次实现,改一处漏一处的后果
|
||||
/// 就是"预检说没冲突、导入却判 SkipIdentical"或者反过来。
|
||||
///
|
||||
/// 现在只有一条链路:`decide` 给出 `MergeDecision`,**没有 `reason` 就不是冲突**
|
||||
/// (`SkipIdentical` 与 `Install` 都是 `reason == None`),`?` 把它一步交代掉。
|
||||
/// 于是"内容相同 ⇒ 不进清单"这条结论的源头是 `merge.rs` 的那张表——与导入用的是同一张。
|
||||
fn judge(
|
||||
path: &str,
|
||||
source: FileSource,
|
||||
pack_sha1: &str,
|
||||
pack_size: u64,
|
||||
disabled: bool,
|
||||
baseline: &PackBaseline,
|
||||
target: &Path,
|
||||
) -> Option<ImportConflict> {
|
||||
let disk = disk_state(&target.join(path))?;
|
||||
// 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。
|
||||
let base_sha1 = baseline.sha1(path).map(str::to_string);
|
||||
// 唯一判据:`reason` 为 `None` 就是"不是冲突"(内容相同 → `SkipIdentical`;
|
||||
// 磁盘上没有 → `Install`,而那一条 `disk_state` 已经先返回 `None` 了)。
|
||||
let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1));
|
||||
let reason = decision.reason?;
|
||||
Some(ImportConflict {
|
||||
path: path.to_string(),
|
||||
source,
|
||||
reason,
|
||||
disabled,
|
||||
pack_sha1: pack_sha1.to_string(),
|
||||
pack_size,
|
||||
disk_sha1: disk.sha1,
|
||||
disk_size: disk.size,
|
||||
base_sha1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 磁盘现状:`None` = 那里没有普通文件(不存在或是目录)。
|
||||
struct DiskState {
|
||||
size: u64,
|
||||
sha1: String,
|
||||
}
|
||||
|
||||
fn disk_state(path: &Path) -> Option<DiskState> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
let sha1 = verify::digest_file(path, HashAlgo::Sha1).ok()?;
|
||||
Some(DiskState {
|
||||
size: metadata.len(),
|
||||
sha1,
|
||||
})
|
||||
}
|
||||
@@ -31,9 +31,10 @@ pub use export::{
|
||||
RemoteHit,
|
||||
};
|
||||
pub use import::{
|
||||
import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress,
|
||||
ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile,
|
||||
MissingFile, PackBaseline, PackFetcher, RejectedComponent,
|
||||
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy,
|
||||
ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport,
|
||||
ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline,
|
||||
PackFetcher, RejectedComponent,
|
||||
};
|
||||
pub use loader::{
|
||||
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
|
||||
@@ -44,5 +45,7 @@ pub use pack::{
|
||||
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod temp;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -60,7 +60,10 @@ pub mod path;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget};
|
||||
pub use archive::{
|
||||
PackArchive, PackEntryInfo, PackLimits, ReadBudget, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
|
||||
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
pub use curseforge::CurseForge;
|
||||
pub use error::{PackError, PackErrorKind, PackResult};
|
||||
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
|
||||
|
||||
@@ -50,7 +50,8 @@ mod open;
|
||||
pub use entry::PackEntryInfo;
|
||||
pub use limits::{
|
||||
PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
|
||||
DEFAULT_MAX_UNCOMPRESSED_BYTES,
|
||||
DEFAULT_MAX_UNCOMPRESSED_BYTES, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
|
||||
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
|
||||
use limits::read_capped;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
|
||||
//!
|
||||
//! # 只此一份口径(导入读取 **与** 导出写入共用)
|
||||
//!
|
||||
//! [`PACK_MAX_ENTRIES`] / [`PACK_MAX_UNCOMPRESSED_BYTES`] / [`PACK_MAX_ENTRY_BYTES`] 是**唯一**
|
||||
//! 的体积口径:读侧 [`PackLimits::default`] 从这里取,写侧 `pack::format::write` 的预检也从
|
||||
//! 这里取(以前写侧直接拿 `zip::ZIP64_BYTES_THR`,于是"导得出来、导不回去"——导出允许 ~4 GiB,
|
||||
//! 导入只允许 512 MiB)。现在的关系是硬约束:
|
||||
//!
|
||||
//! ```text
|
||||
//! PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR ≤ u32::MAX
|
||||
//! ```
|
||||
//!
|
||||
//! 所以「我们能写出的包一定能被自己读回来」,而 ZIP64 阈值退化成一条**不会再被触碰的**
|
||||
//! 物理上限(`tests/limits.rs` 钉住这条不等式,也钉住两侧用的是同一批常数)。
|
||||
//!
|
||||
//! 为什么不让写侧跟着 ZIP64 放宽:读侧把单条目整条读进内存([`read_capped`]),64 MiB 是**内存**
|
||||
//! 护栏;放宽它等于用一个看不见的内存峰值换一个几乎用不到的大包能力。
|
||||
//!
|
||||
//! # 两阶段限额(打开 vs 读取)
|
||||
//!
|
||||
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
|
||||
@@ -13,12 +30,24 @@ use std::io::Read;
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
|
||||
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
|
||||
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// 条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const PACK_MAX_ENTRIES: usize = 20_000;
|
||||
/// 解压/打包总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const PACK_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 单条目上限(64 MiB;同时是读侧的**内存**护栏与写侧的单文件护栏)。
|
||||
pub const PACK_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// ZIP64 的物理阈值(`zip` crate 的口径,≈4 GiB)。
|
||||
///
|
||||
/// 生产路径**不再**用它当判据:PACK_MAX_UNCOMPRESSED_BYTES 远低于它(0.5 GiB ≪ 4 GiB),
|
||||
/// 所以 `tests/limits.rs` 只需钉住"统一上限 < 本阈值",ZIP64 就永远不会被需要。
|
||||
pub const PACK_ZIP64_BYTES_THR: u64 = zip::ZIP64_BYTES_THR;
|
||||
|
||||
/// 默认条目数上限(= [`PACK_MAX_ENTRIES`],保留旧名以免破坏既有调用点)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = PACK_MAX_ENTRIES;
|
||||
/// 默认解压总体积上限(= [`PACK_MAX_UNCOMPRESSED_BYTES`])。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = PACK_MAX_UNCOMPRESSED_BYTES;
|
||||
/// 默认单条目上限(= [`PACK_MAX_ENTRY_BYTES`])。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = PACK_MAX_ENTRY_BYTES;
|
||||
|
||||
/// 包读取限额。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -51,9 +51,7 @@
|
||||
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::curseforge;
|
||||
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
|
||||
use super::manifest::PackManifest;
|
||||
use super::modrinth;
|
||||
use super::multimc;
|
||||
use super::path::{normalize_dir, normalize_file};
|
||||
|
||||
mod payload;
|
||||
mod write;
|
||||
|
||||
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
|
||||
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
|
||||
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
/// 导出一个整包(清单 + 覆盖内容)。
|
||||
///
|
||||
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
|
||||
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。
|
||||
/// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
|
||||
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
|
||||
///
|
||||
/// # 只保证同格式往返(写给 M111/M112)
|
||||
///
|
||||
@@ -148,8 +147,17 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
manifest.check_paths()?;
|
||||
let json = self.render(manifest)?;
|
||||
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
|
||||
let temp = temporary_path(target);
|
||||
match write_container(self.id(), entry, &json, manifest, payload, &temp) {
|
||||
let temp = write::temporary_path(target);
|
||||
match write::container(
|
||||
self.id(),
|
||||
entry,
|
||||
&json,
|
||||
manifest,
|
||||
payload,
|
||||
&temp,
|
||||
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
|
||||
write::WriteLimits::production(),
|
||||
) {
|
||||
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
PackError::io(format!(
|
||||
@@ -166,81 +174,6 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// 临时文件路径(与目标同目录,`rename` 才能原子替换)。
|
||||
fn temporary_path(target: &Path) -> PathBuf {
|
||||
let mut name = target.file_name().map_or_else(
|
||||
|| std::ffi::OsString::from("pack"),
|
||||
std::ffi::OsStr::to_os_string,
|
||||
);
|
||||
name.push(format!(".{}.part", std::process::id()));
|
||||
target.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
|
||||
fn write_container(
|
||||
format_id: &str,
|
||||
entry: &'static str,
|
||||
json: &str,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
) -> PackResult<()> {
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
// 未压缩总量封顶在 ZIP64 阈值(≈4 GiB):Modrinth 官方导出也是这样拒绝的,而不是
|
||||
// 静默截断(HMCL 那边 `(int)` 截断只打 warning 是反面教材)。临时文件由调用方删。
|
||||
let mut total = json.len() as u64;
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let name = format!("{dir}/{path}");
|
||||
let source = match payload.source_for(&name) {
|
||||
Some(source) => source.to_path_buf(),
|
||||
None => {
|
||||
let Some(root) = payload.root() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
|
||||
)));
|
||||
};
|
||||
root.join(&dir).join(&path)
|
||||
}
|
||||
};
|
||||
let bytes = std::fs::read(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
total += bytes.len() as u64;
|
||||
if total >= zip::ZIP64_BYTES_THR {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包",
|
||||
zip::ZIP64_BYTES_THR
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
}
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 一次探测的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackMatch {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! 把模型写成 zip:**声明尺寸预检 → 写清单 → 逐条搬覆盖内容**(`format.rs` 只留 trait 与注册表)。
|
||||
//!
|
||||
//! # 为什么预检必须在读第一个字节之前
|
||||
//!
|
||||
//! 一条写死的边界(research/18-p2 §8.6,A 级 `export_mrpack.rs:305-311`):**不写 ZIP64 包**。
|
||||
//! Modrinth 官方导出撞到阈值直接报错,HMCL 那边 `(int)` 截断只打 warning 是反面教材。
|
||||
//!
|
||||
//! 早先的实现是「读一个文件 → 累加 → 比较」:真要导出一个 4 GiB 覆盖文件的实例,
|
||||
//! `std::fs::read` 先把它整份读进内存,闸门**永远够不着**——用户看到的是内存打爆或 IO 错,
|
||||
//! 而不是"包太大,把这个文件移出实例"。所以现在先跑一遍 `metadata().len()` 累加
|
||||
//! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。
|
||||
//!
|
||||
//! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。
|
||||
//! **第二道自己从清单长度起算**,绝不拿预检的返回值当基数——两者合起来会重复累计,
|
||||
//! 于是真实总量只要过半阈值(~2.15 GiB)就被误拒,而且拒在内容已经读出来之后(P2-1)。
|
||||
//!
|
||||
//! # 内容按块搬运,不整份读进内存
|
||||
//!
|
||||
//! 覆盖文件用 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`)。这不是优化癖:`std::fs::read` 会把
|
||||
//! 一份 2.5 GiB 的 mod 整份摊在内存里,恰恰破坏"闸在内容进内存之前"这条性质——即使判据
|
||||
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
|
||||
//! 与内存占用无关。
|
||||
//!
|
||||
//! # 阈值与声明口径(**与导入读取同一份常量**)
|
||||
//!
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)**与单条目体积**,两个数
|
||||
//! 都来自 `pack::archive::limits`([`WriteLimits::production`]):以前写侧直接拿
|
||||
//! `zip::ZIP64_BYTES_THR`(≈4 GiB),导入读取只允许 512 MiB ⇒「导得出来、导不回去」。现在
|
||||
//! 只此一份口径,关系是
|
||||
//! `PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR`,
|
||||
//! 所以 ZIP64 阈值永远不会被触碰(`pack/tests/limits.rs` 钉住这条不等式)。
|
||||
//! zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,官方实现同样按这个
|
||||
//! 粗粒度判。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
use super::super::manifest::PackManifest;
|
||||
use super::super::path::{normalize_dir, normalize_file};
|
||||
use super::payload::PackPayload;
|
||||
|
||||
/// 搬覆盖内容时的缓冲大小(流式,不把文件整份读进内存)。
|
||||
const COPY_CHUNK: usize = 1024 * 1024;
|
||||
|
||||
/// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。
|
||||
pub(super) fn temporary_path(target: &Path) -> PathBuf {
|
||||
let mut name = target.file_name().map_or_else(
|
||||
|| std::ffi::OsString::from("pack"),
|
||||
std::ffi::OsStr::to_os_string,
|
||||
);
|
||||
name.push(format!(".{}.part", std::process::id()));
|
||||
target.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
|
||||
pub(super) fn container(
|
||||
format_id: &str,
|
||||
entry: &'static str,
|
||||
json: &str,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
limits: WriteLimits,
|
||||
) -> PackResult<()> {
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份几百 MiB 的内容。
|
||||
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
|
||||
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
|
||||
let _ = preflight(json.len() as u64, manifest, payload, limits)?;
|
||||
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
|
||||
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
|
||||
let mut written = Written::new(json.len() as u64, limits.total);
|
||||
let mut buffer = vec![0_u8; COPY_CHUNK];
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let name = format!("{dir}/{path}");
|
||||
let source = source_for(payload, &dir, &path)?;
|
||||
let mut source_file = File::open(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
loop {
|
||||
let read = source_file.read(&mut buffer).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
written.spend(read as u64)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。
|
||||
///
|
||||
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
|
||||
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
|
||||
/// 阈值就被误拒(P2-1,已有端到端用例钉住这个窗口)。
|
||||
///
|
||||
/// 判据顺序是**先总量、后单条**:总量超限时给出的那句"包太大"更贴用户的实际处境
|
||||
/// ("把这个文件移出实例"只对单条超限才有意义)。
|
||||
fn preflight(
|
||||
json_len: u64,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
limits: WriteLimits,
|
||||
) -> PackResult<u64> {
|
||||
// **条目数闸**(读侧同一条:`PACK_MAX_ENTRIES`)。放在读盘之前:超 2 万条时连一次
|
||||
// `metadata()` 都不必做,也顺手证明"闸在碰内容之前"。清单条目自己也占一条。
|
||||
let declared_entries = 1 + manifest
|
||||
.overrides
|
||||
.iter()
|
||||
.map(|group| group.files.len())
|
||||
.fold(0_usize, usize::saturating_add);
|
||||
guard_entries(declared_entries, limits.entries)?;
|
||||
|
||||
let mut total = json_len;
|
||||
let mut entries: Vec<(String, u64)> = Vec::new();
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let source = source_for(payload, &dir, &path)?;
|
||||
// 缺文件 / 读不到元数据在这里就报(与"读内容失败"同一个 `pack_io` 口径:
|
||||
// 目标路径上不会留下半个包,临时文件由调用方删)。
|
||||
let length = std::fs::metadata(&source)
|
||||
.map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?
|
||||
.len();
|
||||
total += length;
|
||||
entries.push((format!("{dir}/{path}"), length));
|
||||
}
|
||||
}
|
||||
guard_with(total, limits.total)?;
|
||||
for (name, length) in entries {
|
||||
guard_entry(&name, length, limits.entry)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct WriteLimits {
|
||||
/// 未压缩总量上限。
|
||||
pub(super) total: u64,
|
||||
/// 单条目上限。
|
||||
pub(super) entry: u64,
|
||||
/// 条目数上限(**与读侧同一个数**:清单条目 + 每个覆盖文件)。
|
||||
pub(super) entries: usize,
|
||||
}
|
||||
|
||||
impl WriteLimits {
|
||||
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
|
||||
pub(super) const fn production() -> Self {
|
||||
Self {
|
||||
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
|
||||
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
|
||||
entries: super::super::archive::limits::PACK_MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式给值(测试用:把边界缩小到可测尺寸)。
|
||||
#[cfg(test)]
|
||||
pub(super) const fn new(total: u64, entry: u64, entries: usize) -> Self {
|
||||
Self {
|
||||
total,
|
||||
entry,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 条目数闸:与读侧的 `PACK_MAX_ENTRIES` **同一份口径**。
|
||||
///
|
||||
/// 读侧在 `archive::open` 里对"中央目录条数"判 `>= limit` 就拒;写侧产出的是"清单 + N 个覆盖
|
||||
/// 文件",比读侧少掉目录条目,所以这里按同一侧的口径判(`count > limit` 拒,`== limit` 许可,
|
||||
/// 与读侧能读回的最大值一致)。没有这道闸时,一个内容目录超过 2 万条的实例**导得出来、
|
||||
/// 自己的导入器拒读**——正是"M152 遗留的限额不衔接"那条。
|
||||
fn guard_entries(count: usize, entries_limit: usize) -> PackResult<()> {
|
||||
if count > entries_limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"包内条目数 {count} 超过统一上限 {entries_limit}(导入读取用的也是这个数)"
|
||||
))
|
||||
.with_hint("把内容目录里多余的文件移出实例后再导出;单文件与总量上限也是同一份口径"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。
|
||||
///
|
||||
/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条
|
||||
/// 内容整块读进内存,所以有单条上限)。写侧照着读侧的上限拒,才不会产出自己读不回来的包。
|
||||
fn guard_entry(name: &str, length: u64, entry_limit: u64) -> PackResult<()> {
|
||||
if length > entry_limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"覆盖条目 {name} 有 {length} 字节,超过单条目统一上限 {entry_limit}(导入读取用的也是这个数)"
|
||||
))
|
||||
.with_hint("把该文件移出实例(或换一个不需要内联它的内容目录)后再导出"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
|
||||
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
|
||||
let name = format!("{dir}/{path}");
|
||||
if let Some(source) = payload.source_for(&name) {
|
||||
return Ok(source.to_path_buf());
|
||||
}
|
||||
let Some(root) = payload.root() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
|
||||
)));
|
||||
};
|
||||
Ok(root.join(dir).join(path))
|
||||
}
|
||||
|
||||
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
|
||||
///
|
||||
/// 起点是**清单 JSON 的长度**,与 `preflight` 的返回值无关:两者混用会把每个文件的字节算
|
||||
/// 两遍,真实总量过半阈值(~2.15 GiB)就被误拒(P2-1)。类型上也没法混用——`Written`
|
||||
/// 由 `u64` 构造,而预检的返回值是"全量声明尺寸",只有显式取值才拿得到。
|
||||
struct Written {
|
||||
total: u64,
|
||||
limit: u64,
|
||||
}
|
||||
|
||||
impl Written {
|
||||
const fn new(json_len: u64, limit: u64) -> Self {
|
||||
Self {
|
||||
total: json_len,
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记一笔实际写出的字节,并在**每一块之后**过闸(大文件因此能在中途被拦下)。
|
||||
fn spend(&mut self, bytes: u64) -> PackResult<()> {
|
||||
self.total = self.total.saturating_add(bytes);
|
||||
guard_with(self.total, self.limit)
|
||||
}
|
||||
}
|
||||
|
||||
/// 总量闸(`pack_limits`):`total` **达到**上限就拒(`>=` 不是 `>`,撞上就不写)。
|
||||
///
|
||||
/// 生产路径的 `limit` 来自 [`WriteLimits::production`](= 读侧 `PACK_MAX_UNCOMPRESSED_BYTES`);
|
||||
/// 把它做成参数是为了能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事。
|
||||
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
|
||||
if total >= limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量超过统一上限 {limit} 字节(ZIP64 阈值 {} 远在其上,所以永远不需要写 ZIP64)",
|
||||
super::super::archive::limits::PACK_ZIP64_BYTES_THR
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;导入读取用的是同一个上限"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `format/write.rs` 的单测:**从 `write.rs` 拆出来守 400 行硬线**。
|
||||
//!
|
||||
//! 拆分口径与仓库其它拆分一致:**纯搬移**,断言与注释一行不改(只在外面加一层 `mod tests;`)。
|
||||
//! 这些用例钉的是「闸在碰内容之前」「边界在 `>=` 上」「写循环不许重复累计」三件事。
|
||||
|
||||
use super::*;
|
||||
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
|
||||
use crate::addon::temp::TempRoot;
|
||||
|
||||
#[test]
|
||||
fn the_threshold_is_reached_not_exceeded() {
|
||||
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
|
||||
assert!(guard_with(99, 100).is_ok());
|
||||
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
assert!(error.message().contains("100"), "{error}");
|
||||
assert!(guard_with(101, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
|
||||
// P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
|
||||
// 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
|
||||
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
|
||||
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
|
||||
let limit = WriteLimits::production().total;
|
||||
let inside = limit / 2 + limit / 4;
|
||||
let mut written = Written::new(28, limit);
|
||||
written.spend(inside).expect("没到统一上限,第二道闸不许拒");
|
||||
assert!(inside > limit / 2, "样本要落在过拒窗口里");
|
||||
|
||||
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
|
||||
let mut written = Written::new(28, limit);
|
||||
let error = written.spend(limit).expect_err("撞上限必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_does_not_double_count_the_preflight_total() {
|
||||
// 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**:
|
||||
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
|
||||
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
|
||||
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
|
||||
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的
|
||||
// `#[ignore]` 用例)。
|
||||
// 临时根用 `TempRoot`(drop 即删、panic 也删):这两个用例以前自己拼 `%TEMP%` 路径、
|
||||
// 只在**正常收尾**时删,断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。
|
||||
let root = TempRoot::new("double-count");
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件");
|
||||
// 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。
|
||||
let json = "{\"name\":\"0123456789012345\"}";
|
||||
let declared = json.len() as u64 + 40;
|
||||
let limit = declared + 20;
|
||||
let mut manifest = PackManifest::new("double count");
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files: vec![OverrideFile {
|
||||
path: "mods/a.bin".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 40,
|
||||
}],
|
||||
});
|
||||
let target = root.join("out.zip");
|
||||
|
||||
container(
|
||||
"curseforge",
|
||||
"manifest.json",
|
||||
json,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
WriteLimits::new(limit, 1024, 16),
|
||||
)
|
||||
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
|
||||
|
||||
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
|
||||
let refused = root.join("refused.zip");
|
||||
let error = container(
|
||||
"curseforge",
|
||||
"manifest.json",
|
||||
json,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&refused,
|
||||
WriteLimits::new(declared, 1024, 16),
|
||||
)
|
||||
.expect_err("声明总量达到 limit 必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
assert!(!refused.exists(), "预检失败时目标路径不许出现");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preflight_measures_declared_sizes_not_content() {
|
||||
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
|
||||
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
|
||||
let root = TempRoot::new("preflight");
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
|
||||
let huge = root.join("overrides/mods/huge.jar");
|
||||
let file = std::fs::File::create(&huge).expect("建稀疏文件");
|
||||
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
|
||||
drop(file);
|
||||
|
||||
let mut manifest = PackManifest::new("preflight");
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files: vec![
|
||||
OverrideFile {
|
||||
path: "mods/small.jar".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 10,
|
||||
},
|
||||
OverrideFile {
|
||||
path: "mods/huge.jar".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let total = preflight(
|
||||
7,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
// 本条只关心"预检不读内容",所以三道闸都放到最宽(生产口径见 `tests/limits.rs`)。
|
||||
WriteLimits::new(
|
||||
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
|
||||
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
|
||||
crate::addon::pack::archive::limits::PACK_MAX_ENTRIES,
|
||||
),
|
||||
)
|
||||
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
|
||||
assert_eq!(
|
||||
total,
|
||||
7 + 10 + 3 * 1024 * 1024 * 1024,
|
||||
"清单 + 每个覆盖文件的声明尺寸"
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中);
|
||||
//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。
|
||||
|
||||
use super::{fixtures, temp_root, write_zip};
|
||||
use super::{fixtures, write_zip, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -23,8 +23,9 @@ const MMC_PACK: &[u8] =
|
||||
br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#;
|
||||
|
||||
/// 同时带三种身份标记的"不标准包"。
|
||||
fn hybrid_pack(tag: &str) -> std::path::PathBuf {
|
||||
let target = temp_root(tag).join("hybrid.zip");
|
||||
fn hybrid_pack(tag: &str) -> TempRoot {
|
||||
// 返回的是**守卫**(Deref 到 `hybrid.zip`):包文件活到调用点用完为止。
|
||||
let target = TempRoot::at(tag, "hybrid.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -48,7 +49,8 @@ fn builtin_priority_is_pinned() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包");
|
||||
let hybrid = hybrid_pack("detect-hybrid");
|
||||
let pack = PackArchive::open_default(&hybrid).expect("打开混合包");
|
||||
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
|
||||
assert_eq!(matched.format().id(), "curseforge");
|
||||
assert_eq!(
|
||||
@@ -62,7 +64,8 @@ fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
#[test]
|
||||
fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
// `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。
|
||||
let target = temp_root("detect-plain").join("plain.zip");
|
||||
let root = TempRoot::new("detect-plain");
|
||||
let target = root.join("plain.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -79,7 +82,8 @@ fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
#[test]
|
||||
fn registration_order_decides_the_winner() {
|
||||
// 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。
|
||||
let target = temp_root("detect-order").join("both.zip");
|
||||
let root = TempRoot::new("detect-order");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -107,7 +111,8 @@ fn registration_order_decides_the_winner() {
|
||||
|
||||
#[test]
|
||||
fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
let target = temp_root("detect-unknown").join("unknown.zip");
|
||||
let root = TempRoot::new("detect-unknown");
|
||||
let target = root.join("unknown.zip");
|
||||
write_zip(&target, &[("readme.txt", &b"nothing to see"[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
assert!(PackRegistry::builtin().detect_all(&pack).is_empty());
|
||||
@@ -126,7 +131,8 @@ fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
|
||||
#[test]
|
||||
fn multimc_needs_a_non_empty_component_graph() {
|
||||
let target = temp_root("detect-mmc-empty").join("empty-mmc.zip");
|
||||
let root = TempRoot::new("detect-mmc-empty");
|
||||
let target = root.join("empty-mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
@@ -165,7 +171,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
|
||||
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
|
||||
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
|
||||
// 把"可以重下文件"的包判成"不支持"。
|
||||
let target = temp_root("corrupt-manifest").join("bad.zip");
|
||||
let root = TempRoot::new("corrupt-manifest");
|
||||
let target = root.join("bad.zip");
|
||||
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
|
||||
|
||||
@@ -183,7 +190,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
|
||||
#[test]
|
||||
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
|
||||
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
|
||||
let target = temp_root("corrupt-foreign").join("mixed.zip");
|
||||
let root = TempRoot::new("corrupt-foreign");
|
||||
let target = root.join("mixed.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -202,7 +210,8 @@ fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
|
||||
#[test]
|
||||
fn a_missing_manifest_is_not_an_error() {
|
||||
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
|
||||
let target = temp_root("no-manifest").join("mmc.zip");
|
||||
let root = TempRoot::new("no-manifest");
|
||||
let target = root.join("mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
|
||||
|
||||
use super::{fixtures, temp_root};
|
||||
use super::{fixtures, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -67,7 +67,8 @@ fn curseforge_refuses_more_than_one_both_side_group() {
|
||||
.overrides
|
||||
.push(PackOverrides::new("extra-overrides", PackSide::Both));
|
||||
|
||||
let target = temp_root("export-cf-two").join("out.zip");
|
||||
let root = TempRoot::new("export-cf-two");
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
|
||||
@@ -90,7 +91,7 @@ fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
|
||||
}],
|
||||
});
|
||||
|
||||
let root = temp_root("export-half");
|
||||
let root = TempRoot::new("export-half");
|
||||
std::fs::create_dir_all(&root).expect("建目录");
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
@@ -142,7 +143,8 @@ fn mrpack_refuses_two_override_groups_for_the_same_side() {
|
||||
.overrides
|
||||
.push(PackOverrides::new("overrides", PackSide::Both));
|
||||
|
||||
let target = temp_root("export-mr-same-side").join("out.mrpack");
|
||||
let root = TempRoot::new("export-mr-same-side");
|
||||
let target = root.join("out.mrpack");
|
||||
let error = MODRINTH
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("同一侧两个覆盖目录必须拒");
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{temp_root, write_file};
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
|
||||
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
|
||||
use crate::addon::pack::curseforge::fingerprint;
|
||||
@@ -46,8 +46,9 @@ pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
|
||||
}
|
||||
|
||||
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
|
||||
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("game");
|
||||
pub(super) fn fabric_instance(tag: &str) -> TempRoot {
|
||||
// 返回守卫本身(`Deref` 到 `<root>/game`):实例目录活到调用点用完为止,drop 即删。
|
||||
let root = TempRoot::under(tag, "game");
|
||||
write_layer(&root, "1.20.1", VANILLA_LAYER);
|
||||
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
|
||||
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
|
||||
@@ -58,8 +59,8 @@ pub(super) fn fabric_instance(tag: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
|
||||
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("game");
|
||||
pub(super) fn neoforge_instance(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "game");
|
||||
write_layer(&root, "1.20.1", VANILLA_LAYER);
|
||||
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
|
||||
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::Path;
|
||||
use super::export_harness::{
|
||||
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
|
||||
};
|
||||
use super::{temp_root, write_file};
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::export::classify::classify_with;
|
||||
use crate::addon::export::scan::{is_disabled_path, scan_instance};
|
||||
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
|
||||
@@ -300,7 +300,7 @@ fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
|
||||
let target = root.with_file_name("disabled.mrpack");
|
||||
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
|
||||
|
||||
let again = temp_root("export-disabled-reimport");
|
||||
let again = TempRoot::new("export-disabled-reimport");
|
||||
let file = std::fs::File::open(&target).expect("打开包");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
for index in 0..archive.len() {
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
|
||||
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::export_harness::{
|
||||
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
|
||||
};
|
||||
use super::{fixtures, temp_root, write_file};
|
||||
use super::{fixtures, write_file, TempRoot};
|
||||
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
|
||||
use crate::addon::export::{export_instance, ExportRequest};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
@@ -34,8 +34,8 @@ use crate::addon::pack::multimc::MULTIMC;
|
||||
use crate::addon::LoaderKind;
|
||||
|
||||
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
|
||||
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
|
||||
let game = temp_root(tag).join("game");
|
||||
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> TempRoot {
|
||||
let game = TempRoot::under(tag, "game");
|
||||
for (_, file) in manifest.override_files() {
|
||||
let group = manifest
|
||||
.overrides
|
||||
@@ -226,7 +226,7 @@ fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
|
||||
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
|
||||
|
||||
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
|
||||
let unpacked = temp_root("rt-mmc-unpacked");
|
||||
let unpacked = TempRoot::new("rt-mmc-unpacked");
|
||||
for entry in pack.entries() {
|
||||
let name = entry.name();
|
||||
let Some(relative) = name.strip_prefix("overrides/") else {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{temp_root, write_file, write_zip};
|
||||
use super::{write_file, write_zip, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::{PackError, PackErrorKind, PackResult};
|
||||
@@ -87,8 +87,8 @@ impl PackFormat for ToyFormat {
|
||||
}
|
||||
|
||||
/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。
|
||||
fn toy_pack(tag: &str) -> std::path::PathBuf {
|
||||
let dir = temp_root(tag).join("toy");
|
||||
fn toy_pack(tag: &str) -> TempRoot {
|
||||
let dir = TempRoot::under(tag, "toy");
|
||||
write_file(
|
||||
&dir.join("toy.json"),
|
||||
br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#,
|
||||
@@ -140,7 +140,7 @@ fn a_new_format_needs_one_registration_line() {
|
||||
#[test]
|
||||
fn appending_a_format_never_steals_existing_packs() {
|
||||
// 同一份包同时有 CF 清单与 toy 清单。
|
||||
let root = temp_root("extend-priority");
|
||||
let root = TempRoot::new("extend-priority");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
|
||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user