Compare commits
1
Commits
termux-glibc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1105a86448 |
No files matched your search
@@ -94,6 +94,27 @@ jobs:
|
|||||||
sha256sum -c SHA256SUMS.txt
|
sha256sum -c SHA256SUMS.txt
|
||||||
cat SHA256SUMS.txt
|
cat SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
|
||||||
|
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
|
||||||
|
python3 packaging/make_manifest.py --self-test
|
||||||
|
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
|
||||||
|
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
|
||||||
|
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||||
|
# 后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||||
|
python3 packaging/make_manifest.py \
|
||||||
|
--dist dist \
|
||||||
|
--version "$TAG" \
|
||||||
|
--channel stable \
|
||||||
|
--asset-base-url "$RELEASE_BASE/$TAG" \
|
||||||
|
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
- name: 生成 Release 说明(支持矩阵模板)
|
- name: 生成 Release 说明(支持矩阵模板)
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -141,7 +162,12 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
||||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
|
||||||
|
# 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
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
|
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
|
||||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||||
|
|||||||
Generated
+8
-9
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-agent"
|
name = "unlucky-agent"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -4997,13 +4997,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-app-iced"
|
name = "unlucky-app-iced"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
"unlucky-cli",
|
"unlucky-cli",
|
||||||
"unlucky-core",
|
|
||||||
"unlucky-design",
|
"unlucky-design",
|
||||||
"unlucky-i18n",
|
"unlucky-i18n",
|
||||||
"unlucky-presenter",
|
"unlucky-presenter",
|
||||||
@@ -5011,7 +5010,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-cli"
|
name = "unlucky-cli"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5022,7 +5021,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-core"
|
name = "unlucky-core"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -5041,15 +5040,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-design"
|
name = "unlucky-design"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-i18n"
|
name = "unlucky-i18n"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-palette-extract"
|
name = "unlucky-palette-extract"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"unlucky-design",
|
"unlucky-design",
|
||||||
@@ -5057,7 +5056,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unlucky-presenter"
|
name = "unlucky-presenter"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ members = [
|
|||||||
exclude = ["crates/app"]
|
exclude = ["crates/app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
authors = ["AskaEth <i@yeij.top>"]
|
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
|
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
|
||||||
management from the GUI is the instance-wizard work still outstanding, and settings change as you
|
management from the GUI is the instance-wizard work still outstanding, and settings change as you
|
||||||
edit them rather than on a Save.
|
edit them rather than on a Save.
|
||||||
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`,
|
- **Linux and macOS artifacts are unverified, and this release does not even build them.** The
|
||||||
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not
|
reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots
|
||||||
been downloaded and run end to end. Treat them as untested output, not as "supported because the
|
sit in the CI matrix but the default release run skips them, and they have never been downloaded and
|
||||||
matrix lists them".
|
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
|
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why
|
||||||
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts
|
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts
|
||||||
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
|
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
|
||||||
@@ -193,9 +194,8 @@ 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.
|
report lists every conflict so nothing is overwritten silently.
|
||||||
- **Install progress is stage-level**, not byte-level, on the content-library install path.
|
- **Install progress is stage-level**, not byte-level, on the content-library install path.
|
||||||
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
|
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
|
||||||
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
|
- **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is
|
||||||
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
|
unverified there.
|
||||||
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only.
|
|
||||||
|
|
||||||
## Download and run
|
## Download and run
|
||||||
|
|
||||||
@@ -208,19 +208,20 @@ Grab the file for your platform from
|
|||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
|
||||||
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
|
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
|
||||||
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory |
|
| `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 |
|
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **not yet published** |
|
||||||
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same |
|
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not yet published** |
|
||||||
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same |
|
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not yet published** |
|
||||||
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
||||||
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
|
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
|
||||||
|
|
||||||
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is
|
**This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named
|
||||||
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress.
|
in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before
|
||||||
The row stays because the architecture is still supported and the slot still builds (unpublished) in
|
it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable
|
||||||
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in
|
asset. Of those rows `windows-arm64` is the one CI still builds — natively, on GitHub's
|
||||||
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the
|
`windows-11-arm` runner, for evidence — but that slot's toolchain check has not gone green yet, so its
|
||||||
only downloadable Windows asset.
|
`.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
|
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
|
||||||
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
|
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
|
||||||
@@ -294,7 +295,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|
|||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
|
||||||
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
|
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
|
||||||
| `.github/workflows/release.yml` | tag `v*` → 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
|
## Architecture and module map
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
|
|
||||||
|
|
||||||
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
|
|
||||||
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
|
|
||||||
|
|
||||||
> 给主线的改动清单、兼容性说明与合并指南见
|
|
||||||
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
|
|
||||||
|
|
||||||
## 已验证
|
|
||||||
|
|
||||||
| 项 | 状态 | 证据 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
|
|
||||||
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
|
|
||||||
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
|
|
||||||
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
|
|
||||||
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
|
|
||||||
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
|
|
||||||
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
|
|
||||||
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
|
|
||||||
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
完整配方(工具链、patchelf、linker、门禁)见
|
|
||||||
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
termux-x11 :0 -ac &
|
|
||||||
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
|
|
||||||
```
|
|
||||||
|
|
||||||
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
|
|
||||||
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
|
|
||||||
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
|
|
||||||
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
|
|
||||||
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
|
|
||||||
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
|
|
||||||
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
|
|
||||||
|
|
||||||
## 已知边界
|
|
||||||
|
|
||||||
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
|
|
||||||
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
|
|
||||||
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
|
|
||||||
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
|
|
||||||
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
|
|
||||||
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
|
|
||||||
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
|
|
||||||
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
|
|
||||||
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
|
|
||||||
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
|
|
||||||
PC 的 Linux CI 不受影响。
|
|
||||||
|
|
||||||
## 后续
|
|
||||||
|
|
||||||
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
|
|
||||||
2. 剪贴板/IME 在 termux-x11 下的行为验证
|
|
||||||
3. narrator/flite 与音频设备(当前 missing 但不阻断)
|
|
||||||
+15
-13
@@ -140,8 +140,9 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
|||||||
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
|
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
|
||||||
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
|
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
|
||||||
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
|
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
|
||||||
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、
|
- **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、
|
||||||
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是
|
`macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过;
|
||||||
|
`gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是
|
||||||
「矩阵里有就算支持」。
|
「矩阵里有就算支持」。
|
||||||
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
|
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
|
||||||
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
|
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
|
||||||
@@ -157,7 +158,7 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
|||||||
文件是被静默覆盖的。
|
文件是被静默覆盖的。
|
||||||
- **内容库的安装进度是阶段级**,不是字节级。
|
- **内容库的安装进度是阶段级**,不是字节级。
|
||||||
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
|
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
|
||||||
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),目前只在 CPU 渲染下验证过一次。
|
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。
|
||||||
|
|
||||||
## 下载与运行
|
## 下载与运行
|
||||||
|
|
||||||
@@ -169,17 +170,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
|
||||||
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
|
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
|
||||||
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 |
|
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** |
|
||||||
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 |
|
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** |
|
||||||
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 |
|
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** |
|
||||||
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 |
|
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** |
|
||||||
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
||||||
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
|
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
|
||||||
|
|
||||||
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的
|
**本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
|
||||||
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进
|
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
|
||||||
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`,
|
**暂未发布** 的行是命名规则,不是本版可下载的资产。这几行里 `windows-arm64` 是 CI 唯一真跑的那档:M140 起在
|
||||||
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。
|
GitHub 原生 `windows-11-arm` runner 上编来取证,但该槽位的工具链体检还没绿,所以它的 `.exe` 不进 Release;
|
||||||
|
行不删是因为架构仍要支持,绿了就把键加回 `PUBLISH_SLOTS`。
|
||||||
|
|
||||||
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
|
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
|
||||||
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
|
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 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/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
|
||||||
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
||||||
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
|
| `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) |
|
||||||
|
|
||||||
## 架构与模块地图
|
## 架构与模块地图
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
# Termux 适配:方案、改动清单与合并指南
|
|
||||||
|
|
||||||
> 分支:`termux-glibc`(基于 `origin/dev`,14 个提交,HEAD `38d3024`)
|
|
||||||
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
|
|
||||||
|
|
||||||
## 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`(分支基于 `7d8b3b9` 之后的 dev;
|
|
||||||
dev 若前进,先 rebase 再推)。
|
|
||||||
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 启动。
|
|
||||||
|
|
||||||
## 验证矩阵(本机)
|
|
||||||
|
|
||||||
| 项 | 结果 |
|
|
||||||
| --- | --- |
|
|
||||||
| 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 实现
|
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
|
||||||
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
|
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
|
||||||
[dependencies]
|
[dependencies]
|
||||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
unlucky-core = { path = "../core", version = "0.2.2" }
|
||||||
|
|
||||||
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
||||||
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
||||||
|
|||||||
@@ -22,15 +22,12 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
# 令牌唯一真源(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.1" }
|
|
||||||
|
|
||||||
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
|
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
|
||||||
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
|
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
|
||||||
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
|
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
|
||||||
unlucky-presenter = { path = "../presenter", version = "0.2.1" }
|
unlucky-presenter = { path = "../presenter", version = "0.2.2" }
|
||||||
|
|
||||||
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
||||||
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
||||||
@@ -60,11 +57,11 @@ image = { version = "0.25", default-features = false, features = [
|
|||||||
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
||||||
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
|
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
|
||||||
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
||||||
unlucky-cli = { path = "../cli", version = "0.2.1" }
|
unlucky-cli = { path = "../cli", version = "0.2.2" }
|
||||||
|
|
||||||
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
|
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
|
||||||
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
|
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
|
||||||
unlucky-i18n = { path = "../i18n", version = "0.2.1" }
|
unlucky-i18n = { path = "../i18n", version = "0.2.2" }
|
||||||
|
|
||||||
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
||||||
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ use super::{MIN_SIZE, WINDOW_SIZE};
|
|||||||
|
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod fallback;
|
mod fallback;
|
||||||
mod termux_gpu;
|
|
||||||
mod termux_window;
|
|
||||||
|
|
||||||
use fallback::{on_failure, respawn, OnFailure};
|
use fallback::{on_failure, respawn, OnFailure};
|
||||||
|
|
||||||
@@ -41,8 +39,6 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
|||||||
let boot_started = Instant::now();
|
let boot_started = Instant::now();
|
||||||
|
|
||||||
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
||||||
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
|
|
||||||
termux_gpu::enable();
|
|
||||||
let boot = boot_probe(&options);
|
let boot = boot_probe(&options);
|
||||||
let decision = boot.decision;
|
let decision = boot.decision;
|
||||||
diagnostics::report_boot(&options, &boot.probe, &decision);
|
diagnostics::report_boot(&options, &boot.probe, &decision);
|
||||||
@@ -135,20 +131,10 @@ fn launch(
|
|||||||
|
|
||||||
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
|
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
|
||||||
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
|
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
|
||||||
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
|
let (size, size_source) = acceptance::window_size(
|
||||||
// 失败就退回 1180×760 的验收尺寸。
|
|
||||||
let (default_size, default_source) = termux_window::default_size(
|
|
||||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||||
(MIN_SIZE.width, MIN_SIZE.height),
|
(MIN_SIZE.width, MIN_SIZE.height),
|
||||||
)
|
);
|
||||||
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
|
|
||||||
let (size, size_source) =
|
|
||||||
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
|
|
||||||
let size_source = if size_source == "default" {
|
|
||||||
default_source
|
|
||||||
} else {
|
|
||||||
size_source
|
|
||||||
};
|
|
||||||
let settings = diagnostics::window_settings(size);
|
let settings = diagnostics::window_settings(size);
|
||||||
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
||||||
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
|
|
||||||
//!
|
|
||||||
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
|
|
||||||
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
|
|
||||||
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
|
|
||||||
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
|
|
||||||
//!
|
|
||||||
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
|
|
||||||
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use unlucky_core::platform::paths::ProcessEnv;
|
|
||||||
use unlucky_core::platform::termux;
|
|
||||||
|
|
||||||
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
|
|
||||||
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
|
|
||||||
|
|
||||||
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
|
|
||||||
pub(super) fn enable() {
|
|
||||||
if !cfg!(target_os = "linux") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|
|
||||||
|| std::env::var_os("VK_DRIVER_FILES").is_some()
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(prefix) = termux::prefix(&ProcessEnv) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let icd = icd_path(&prefix);
|
|
||||||
if icd.is_file() {
|
|
||||||
std::env::set_var("VK_ICD_FILENAMES", &icd);
|
|
||||||
std::env::set_var("VK_DRIVER_FILES", &icd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
|
|
||||||
fn icd_path(prefix: &Path) -> PathBuf {
|
|
||||||
prefix.join(ICD_REL)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::icd_path;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn icd_path_is_under_the_glibc_prefix() {
|
|
||||||
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
|
|
||||||
assert_eq!(
|
|
||||||
path,
|
|
||||||
std::path::PathBuf::from(
|
|
||||||
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
|
|
||||||
//!
|
|
||||||
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
|
|
||||||
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
|
|
||||||
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
|
|
||||||
//!
|
|
||||||
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
|
|
||||||
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use unlucky_core::platform::paths::ProcessEnv;
|
|
||||||
use unlucky_core::platform::termux;
|
|
||||||
|
|
||||||
/// 屏幕四周留白(像素)。
|
|
||||||
const MARGIN: f32 = 32.0;
|
|
||||||
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
|
|
||||||
const MAX_SCALE: f32 = 2.0;
|
|
||||||
/// `xdpyinfo` 里尺寸那一行的前缀。
|
|
||||||
const DIMENSIONS_PREFIX: &str = "dimensions:";
|
|
||||||
|
|
||||||
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
|
|
||||||
///
|
|
||||||
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
|
|
||||||
/// 这里不复制常量。
|
|
||||||
#[must_use]
|
|
||||||
pub(super) fn default_size(
|
|
||||||
default: (f32, f32),
|
|
||||||
min: (f32, f32),
|
|
||||||
) -> Option<((f32, f32), &'static str)> {
|
|
||||||
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let screen = x_screen_size()?;
|
|
||||||
Some((fit_screen(default, min, screen), "termux-screen"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
|
|
||||||
#[must_use]
|
|
||||||
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
|
|
||||||
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
|
|
||||||
return default;
|
|
||||||
}
|
|
||||||
let available = (
|
|
||||||
(screen.0 - MARGIN * 2.0).max(1.0),
|
|
||||||
(screen.1 - MARGIN * 2.0).max(1.0),
|
|
||||||
);
|
|
||||||
let scale = (available.0 / default.0)
|
|
||||||
.min(available.1 / default.1)
|
|
||||||
.min(MAX_SCALE);
|
|
||||||
let width = (default.0 * scale).max(min.0).min(screen.0);
|
|
||||||
let height = (default.1 * scale).max(min.1).min(screen.1);
|
|
||||||
(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
|
|
||||||
fn x_screen_size() -> Option<(f32, f32)> {
|
|
||||||
let output = Command::new("xdpyinfo").output().ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let line = text
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
|
|
||||||
let value = line.split_whitespace().nth(1)?;
|
|
||||||
let (width, height) = value.split_once('x')?;
|
|
||||||
Some((width.parse().ok()?, height.parse().ok()?))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::fit_screen;
|
|
||||||
|
|
||||||
const DEFAULT: (f32, f32) = (1180.0, 760.0);
|
|
||||||
const MIN: (f32, f32) = (880.0, 560.0);
|
|
||||||
|
|
||||||
fn close(actual: (f32, f32), expected: (f32, f32)) {
|
|
||||||
assert!(
|
|
||||||
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
|
|
||||||
"expected {expected:?}, got {actual:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn landscape_screen_scales_up_keeping_aspect() {
|
|
||||||
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
|
|
||||||
close(
|
|
||||||
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
|
|
||||||
(1875.58, 1208.0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn portrait_screen_still_fits_width() {
|
|
||||||
// 1272×2066:宽度是瓶颈 → 1208×778。
|
|
||||||
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn huge_screen_is_capped_at_two_times() {
|
|
||||||
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tiny_screen_never_goes_below_min_or_overflow() {
|
|
||||||
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
|
|
||||||
assert_eq!(width, 800.0, "宽度受屏幕限制");
|
|
||||||
assert_eq!(height, 560.0, "高度不小于最小尺寸");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_screen_falls_back_to_default() {
|
|
||||||
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -139,43 +139,8 @@ pub fn resolve() -> Resolved {
|
|||||||
resolve_with(None)
|
resolve_with(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
|
|
||||||
///
|
|
||||||
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
|
|
||||||
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
|
|
||||||
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
|
|
||||||
fn register_termux_fonts() {
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use unlucky_core::platform::paths::ProcessEnv;
|
|
||||||
use unlucky_core::platform::termux;
|
|
||||||
|
|
||||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
|
||||||
REGISTERED.get_or_init(|| {
|
|
||||||
if !termux::is_termux(&ProcessEnv) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const CANDIDATES: &[&str] = &[
|
|
||||||
"/system/fonts/NotoSansCJK-Regular.ttc",
|
|
||||||
"/system/fonts/NotoSansCJKSC-Regular.otf",
|
|
||||||
"/system/fonts/DroidSansFallback.ttf",
|
|
||||||
];
|
|
||||||
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let db = font_system.raw().db_mut();
|
|
||||||
for path in CANDIDATES {
|
|
||||||
if db.load_font_file(path).is_ok() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||||
register_termux_fonts();
|
|
||||||
|
|
||||||
let platform = Platform::current();
|
let platform = Platform::current();
|
||||||
let faces = system_faces();
|
let faces = system_faces();
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ path = "src/lib.rs"
|
|||||||
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
|
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
|
||||||
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
|
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
|
||||||
[dependencies]
|
[dependencies]
|
||||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
unlucky-core = { path = "../core", version = "0.2.2" }
|
||||||
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
|
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
|
||||||
# core 侧五个端口在 src/commands/agent/ports/ 装配)。
|
# core 侧五个端口在 src/commands/agent/ports/ 装配)。
|
||||||
unlucky-agent = { path = "../agent", version = "0.2.1" }
|
unlucky-agent = { path = "../agent", version = "0.2.2" }
|
||||||
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
|
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ pub const fn general() -> &'static str {
|
|||||||
config set <key> <value> 写配置(走 setting schema 校验)
|
config set <key> <value> 写配置(走 setting schema 校验)
|
||||||
java list 列出可用 Java(含每个 JDK 的实际落点)
|
java list 列出可用 Java(含每个 JDK 的实际落点)
|
||||||
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
|
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
|
||||||
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家)
|
--vendor temurin|oracle|microsoft|zulu 钉死一家)
|
||||||
java vendors 列出各发行版的镜像/支持版本/当前优先级
|
java vendors 列出四家发行版的镜像/支持版本/当前优先级
|
||||||
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
|
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
|
||||||
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
|
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
|
||||||
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
|
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
|
||||||
@@ -157,7 +157,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
|
|||||||
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
|
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
|
||||||
并打印每个 JDK 的实际落点(managed 项带目录)
|
并打印每个 JDK 的实际落点(managed 项带目录)
|
||||||
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
|
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
|
||||||
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
|
%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
|
||||||
|
|
||||||
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
|
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
|
||||||
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
|
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
|
||||||
@@ -168,9 +168,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
|
|||||||
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
|
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
|
||||||
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
|
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
|
||||||
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
|
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
|
||||||
Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链;
|
每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。",
|
||||||
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
|
|
||||||
--json 的 chain 块与人读的「降级:」行。",
|
|
||||||
),
|
),
|
||||||
"agent" => Some(
|
"agent" => Some(
|
||||||
"\
|
"\
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use unlucky_core::download::MirrorTable;
|
use unlucky_core::download::MirrorTable;
|
||||||
use unlucky_core::game::java::{termux, vendor, JdkVendor};
|
use unlucky_core::game::java::{vendor, JdkVendor};
|
||||||
|
|
||||||
use super::java::{region_profile, JavaSettings};
|
use super::java::{region_profile, JavaSettings};
|
||||||
use crate::context::Ctx;
|
use crate::context::Ctx;
|
||||||
@@ -23,11 +23,7 @@ use crate::output::Report;
|
|||||||
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
|
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
|
||||||
let settings = JavaSettings::load(ctx);
|
let settings = JavaSettings::load(ctx);
|
||||||
let domestic = region_profile(ctx).is_domestic();
|
let domestic = region_profile(ctx).is_domestic();
|
||||||
let mut order = vendor::order(&settings.priority, domestic);
|
let order = vendor::order(&settings.priority, domestic);
|
||||||
// Termux 上系统包是一条真实可用的发行版:不进默认回退序,但展示时要让人看见。
|
|
||||||
if termux::available() && !order.contains(&JdkVendor::Termux) {
|
|
||||||
order.insert(0, JdkVendor::Termux);
|
|
||||||
}
|
|
||||||
let table = MirrorTable::builtin();
|
let table = MirrorTable::builtin();
|
||||||
let rows: Vec<Value> = order
|
let rows: Vec<Value> = order
|
||||||
.iter()
|
.iter()
|
||||||
@@ -118,10 +114,6 @@ fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
|
|||||||
.zulu()
|
.zulu()
|
||||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
JdkVendor::Termux => termux::MIRRORS
|
|
||||||
.iter()
|
|
||||||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
json!({
|
json!({
|
||||||
"rank": index + 1,
|
"rank": index + 1,
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
|
|||||||
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
|
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
|
||||||
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
|
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
|
||||||
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
|
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
|
||||||
|
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
|
||||||
|
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
|
||||||
AuthError::Unsupported(_) => Some(format!(
|
AuthError::Unsupported(_) => Some(format!(
|
||||||
"先配 Azure 公共客户端 id(id 不进仓库):\
|
"官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
|
||||||
`{env}=<id> %prog% accounts add msa`;\
|
运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
|
||||||
想让二进制自带就构建时注入 `{env}=<id> cargo build`。环境变量优先于构建期注入。",
|
或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
|
||||||
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
|
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
|
||||||
)),
|
)),
|
||||||
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
|
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
|
||||||
|
|||||||
@@ -75,38 +75,18 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
|||||||
|
|
||||||
let mut libraries: Vec<LibraryInput> = Vec::new();
|
let mut libraries: Vec<LibraryInput> = Vec::new();
|
||||||
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
||||||
if !library.applies(&environment) {
|
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
|
let Ok(coordinate) = library.coordinate() else {
|
||||||
// ≤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;
|
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() {
|
let path = if library.is_local() {
|
||||||
game.version_library(&version_id, &coordinate)
|
game.version_library(&version_id, &coordinate)
|
||||||
} else {
|
} else {
|
||||||
game.library(&coordinate)
|
game.library(&coordinate)
|
||||||
};
|
};
|
||||||
libraries.push(LibraryInput::new(path, name));
|
libraries.push(LibraryInput::new(path, library.name.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
|
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
|
||||||
|
|||||||
@@ -18,7 +18,10 @@
|
|||||||
//!
|
//!
|
||||||
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
||||||
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
|
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
|
||||||
//! 优先级落地,不自创排序。
|
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
|
||||||
|
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
|
||||||
|
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
|
||||||
|
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
|
||||||
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
|
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
|
||||||
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
|
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
|
||||||
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
|
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
|
||||||
@@ -77,7 +80,7 @@ pub use fetch::{
|
|||||||
};
|
};
|
||||||
pub use merge::{MergeAction, MergeDecision};
|
pub use merge::{MergeAction, MergeDecision};
|
||||||
pub use pipeline::baseline_relative;
|
pub use pipeline::baseline_relative;
|
||||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant};
|
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
|
||||||
pub use report::{
|
pub use report::{
|
||||||
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
||||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ use crate::download::verify::{self, digest_bytes};
|
|||||||
use crate::download::HashAlgo;
|
use crate::download::HashAlgo;
|
||||||
|
|
||||||
/// 阶段一:覆盖条目落暂存 + 三方合并。
|
/// 阶段一:覆盖条目落暂存 + 三方合并。
|
||||||
|
///
|
||||||
|
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
|
||||||
|
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
|
||||||
|
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
|
||||||
|
/// "两条 Installed、前一条的哈希其实没落地"。
|
||||||
pub(crate) async fn extract_overrides(
|
pub(crate) async fn extract_overrides(
|
||||||
request: &ImportRequest<'_>,
|
request: &ImportRequest<'_>,
|
||||||
archive: &PackArchive,
|
archive: &PackArchive,
|
||||||
@@ -31,9 +36,14 @@ pub(crate) async fn extract_overrides(
|
|||||||
report: &mut ImportReport,
|
report: &mut ImportReport,
|
||||||
next: &mut PackBaseline,
|
next: &mut PackBaseline,
|
||||||
) -> Result<(), ImportError> {
|
) -> 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();
|
let target = staging.target().to_path_buf();
|
||||||
for (index, entry) in plan.overrides.iter().enumerate() {
|
for (index, entry) in live.iter().enumerate() {
|
||||||
tell(
|
tell(
|
||||||
&request.progress,
|
&request.progress,
|
||||||
ImportStage::Extract,
|
ImportStage::Extract,
|
||||||
|
|||||||
@@ -17,19 +17,24 @@
|
|||||||
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
|
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
|
||||||
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
|
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
|
||||||
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
|
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
|
||||||
|
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
|
||||||
|
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
|
||||||
|
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
|
||||||
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
|
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
|
||||||
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
|
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
|
||||||
//! [`paths`]。
|
//! [`paths`]。
|
||||||
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
|
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
|
||||||
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
|
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
|
||||||
|
|
||||||
|
mod layer;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|
||||||
|
pub use layer::Shadowed;
|
||||||
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
|
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
|
||||||
|
|
||||||
use super::super::pack::{
|
use super::super::pack::{
|
||||||
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
|
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
|
||||||
PackOverrides, PackSide,
|
PackSide,
|
||||||
};
|
};
|
||||||
use super::error::ImportError;
|
use super::error::ImportError;
|
||||||
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
|
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
|
||||||
@@ -51,6 +56,8 @@ pub struct PlanOverride {
|
|||||||
pub disabled: bool,
|
pub disabled: bool,
|
||||||
/// 可选注记(`.disabled` / `.old`)。
|
/// 可选注记(`.disabled` / `.old`)。
|
||||||
pub note: Option<ImportIssue>,
|
pub note: Option<ImportIssue>,
|
||||||
|
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
|
||||||
|
pub shadowed: Option<Shadowed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一条要补全/下载的远端条目。
|
/// 一条要补全/下载的远端条目。
|
||||||
@@ -136,7 +143,7 @@ impl ImportPlan {
|
|||||||
|
|
||||||
let mut overrides: Vec<PlanOverride> = Vec::new();
|
let mut overrides: Vec<PlanOverride> = Vec::new();
|
||||||
let mut skipped_server_files = 0_usize;
|
let mut skipped_server_files = 0_usize;
|
||||||
for group in ordered_overrides(&manifest) {
|
for group in layer::ordered(&manifest) {
|
||||||
if group.side == PackSide::Server {
|
if group.side == PackSide::Server {
|
||||||
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
|
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
|
||||||
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
|
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
|
||||||
@@ -153,9 +160,11 @@ impl ImportPlan {
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
disabled: variant.is_disabled(),
|
disabled: variant.is_disabled(),
|
||||||
note: suffix_note(variant, &file.path),
|
note: suffix_note(variant, &file.path),
|
||||||
|
shadowed: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
layer::resolve(&mut overrides, &mut notes);
|
||||||
if skipped_server_files > 0 {
|
if skipped_server_files > 0 {
|
||||||
notes.push(ImportIssue::new(
|
notes.push(ImportIssue::new(
|
||||||
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
|
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`]。
|
/// 选一个加载器并翻译成 [`LoaderTarget`]。
|
||||||
fn select_loader(
|
fn select_loader(
|
||||||
manifest: &PackManifest,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ 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";
|
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
|
||||||
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
|
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
|
||||||
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
||||||
|
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
|
||||||
|
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
|
||||||
|
|
||||||
/// 缺失:调用方没给 fetcher(离线导入)。
|
/// 缺失:调用方没给 fetcher(离线导入)。
|
||||||
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
|
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
|
||||||
@@ -94,6 +96,7 @@ pub const ALL: &[&str] = &[
|
|||||||
NOTE_BASELINE_PACK_MISMATCH,
|
NOTE_BASELINE_PACK_MISMATCH,
|
||||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||||
NOTE_ROLLBACK_INCOMPLETE,
|
NOTE_ROLLBACK_INCOMPLETE,
|
||||||
|
NOTE_OVERRIDE_SHADOWED,
|
||||||
MISSING_OFFLINE,
|
MISSING_OFFLINE,
|
||||||
MISSING_NO_SOURCE,
|
MISSING_NO_SOURCE,
|
||||||
MISSING_FETCH_FAILED,
|
MISSING_FETCH_FAILED,
|
||||||
|
|||||||
@@ -51,9 +51,7 @@
|
|||||||
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
|
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
|
||||||
|
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::fs::File;
|
use std::path::Path;
|
||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::archive::PackArchive;
|
use super::archive::PackArchive;
|
||||||
use super::curseforge;
|
use super::curseforge;
|
||||||
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
|
|||||||
use super::manifest::PackManifest;
|
use super::manifest::PackManifest;
|
||||||
use super::modrinth;
|
use super::modrinth;
|
||||||
use super::multimc;
|
use super::multimc;
|
||||||
use super::path::{normalize_dir, normalize_file};
|
|
||||||
|
|
||||||
mod payload;
|
mod payload;
|
||||||
|
mod write;
|
||||||
|
|
||||||
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
|
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
|
||||||
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
|
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
|
||||||
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
|||||||
/// 导出一个整包(清单 + 覆盖内容)。
|
/// 导出一个整包(清单 + 覆盖内容)。
|
||||||
///
|
///
|
||||||
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
|
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
|
||||||
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。
|
/// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
|
||||||
|
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
|
||||||
///
|
///
|
||||||
/// # 只保证同格式往返(写给 M111/M112)
|
/// # 只保证同格式往返(写给 M111/M112)
|
||||||
///
|
///
|
||||||
@@ -148,8 +147,16 @@ pub trait PackFormat: Debug + Send + Sync {
|
|||||||
manifest.check_paths()?;
|
manifest.check_paths()?;
|
||||||
let json = self.render(manifest)?;
|
let json = self.render(manifest)?;
|
||||||
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
|
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
|
||||||
let temp = temporary_path(target);
|
let temp = write::temporary_path(target);
|
||||||
match write_container(self.id(), entry, &json, manifest, payload, &temp) {
|
match write::container(
|
||||||
|
self.id(),
|
||||||
|
entry,
|
||||||
|
&json,
|
||||||
|
manifest,
|
||||||
|
payload,
|
||||||
|
&temp,
|
||||||
|
zip::ZIP64_BYTES_THR,
|
||||||
|
) {
|
||||||
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
||||||
let _ = std::fs::remove_file(&temp);
|
let _ = std::fs::remove_file(&temp);
|
||||||
PackError::io(format!(
|
PackError::io(format!(
|
||||||
@@ -166,81 +173,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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PackMatch {
|
pub struct PackMatch {
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! 把模型写成 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 + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
|
||||||
|
//! (≈4 GiB)。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,
|
||||||
|
limit: u64,
|
||||||
|
) -> PackResult<()> {
|
||||||
|
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
|
||||||
|
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
|
||||||
|
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
|
||||||
|
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
|
||||||
|
|
||||||
|
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, limit);
|
||||||
|
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()`,不读内容。
|
||||||
|
///
|
||||||
|
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
|
||||||
|
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
|
||||||
|
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
|
||||||
|
fn preflight(
|
||||||
|
json_len: u64,
|
||||||
|
manifest: &PackManifest,
|
||||||
|
payload: PackPayload<'_>,
|
||||||
|
limit: u64,
|
||||||
|
) -> PackResult<u64> {
|
||||||
|
let mut total = json_len;
|
||||||
|
guard_with(total, limit)?;
|
||||||
|
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;
|
||||||
|
guard_with(total, limit)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
|
||||||
|
/// "撞上就不写")。
|
||||||
|
///
|
||||||
|
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
|
||||||
|
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
|
||||||
|
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
|
||||||
|
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
|
||||||
|
if total >= limit {
|
||||||
|
return Err(PackError::limits(format!(
|
||||||
|
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
|
||||||
|
))
|
||||||
|
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
|
||||||
|
|
||||||
|
#[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 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
|
||||||
|
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
|
||||||
|
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
|
||||||
|
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
|
||||||
|
let half = zip::ZIP64_BYTES_THR / 2;
|
||||||
|
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
|
||||||
|
written
|
||||||
|
.spend(2_500_000_000)
|
||||||
|
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
|
||||||
|
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
|
||||||
|
|
||||||
|
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
|
||||||
|
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
|
||||||
|
let error = written
|
||||||
|
.spend(zip::ZIP64_BYTES_THR)
|
||||||
|
.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]` 用例)。
|
||||||
|
let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id()));
|
||||||
|
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,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
|
||||||
|
|
||||||
|
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
|
||||||
|
let refused = root.join("refused.zip");
|
||||||
|
let error = container(
|
||||||
|
"curseforge",
|
||||||
|
"manifest.json",
|
||||||
|
json,
|
||||||
|
&manifest,
|
||||||
|
PackPayload::from_root(&root),
|
||||||
|
&refused,
|
||||||
|
declared,
|
||||||
|
)
|
||||||
|
.expect_err("声明总量达到 limit 必须拒");
|
||||||
|
assert_eq!(
|
||||||
|
error.kind(),
|
||||||
|
super::super::super::error::PackErrorKind::Limits
|
||||||
|
);
|
||||||
|
assert!(!refused.exists(), "预检失败时目标路径不许出现");
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_preflight_measures_declared_sizes_not_content() {
|
||||||
|
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
|
||||||
|
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
|
||||||
|
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
|
||||||
|
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),
|
||||||
|
zip::ZIP64_BYTES_THR,
|
||||||
|
)
|
||||||
|
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
|
||||||
|
assert_eq!(
|
||||||
|
total,
|
||||||
|
7 + 10 + 3 * 1024 * 1024 * 1024,
|
||||||
|
"清单 + 每个覆盖文件的声明尺寸"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ mod model;
|
|||||||
mod roundtrip;
|
mod roundtrip;
|
||||||
mod safety;
|
mod safety;
|
||||||
mod unknown_fields;
|
mod unknown_fields;
|
||||||
|
mod zip64;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|||||||
@@ -111,3 +111,56 @@ fn curseforge_loader_unknown_keys_survive_read_write_reread() {
|
|||||||
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
|
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
|
||||||
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
|
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mrpack_file_unknown_keys_survive_read_write_reread() {
|
||||||
|
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
|
||||||
|
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
|
||||||
|
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
|
||||||
|
let root = super::temp_root("mr-file-extra");
|
||||||
|
let dir = root.join("src");
|
||||||
|
super::write_file(
|
||||||
|
&dir.join("modrinth.index.json"),
|
||||||
|
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"file extra keys",
|
||||||
|
"files":[{"path":"mods/a.jar",
|
||||||
|
"hashes":{"sha1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
"md5":"d41d8cd98f00b204e9800998ecf8427e"},
|
||||||
|
"downloads":["https://cdn.modrinth.com/data/abc/versions/x/a.jar"],
|
||||||
|
"fileSize":42,
|
||||||
|
"mirror":"https://mirror.example.invalid/a.jar",
|
||||||
|
"optionalReason":{"why":"kept for the community"}}],
|
||||||
|
"dependencies":{"minecraft":"1.20.1"}}"#,
|
||||||
|
);
|
||||||
|
let zip = root.join("pack.mrpack");
|
||||||
|
super::zip_dir(&dir, &zip);
|
||||||
|
|
||||||
|
let pack = PackArchive::open_default(&zip).expect("打开包");
|
||||||
|
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
|
||||||
|
assert_eq!(
|
||||||
|
manifest.files[0].extra["mirror"],
|
||||||
|
json!("https://mirror.example.invalid/a.jar")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.files[0].extra["optionalReason"],
|
||||||
|
json!({"why": "kept for the community"})
|
||||||
|
);
|
||||||
|
// 未知**哈希算法**也不丢:`hashes` 是整张表,不是两个字段。
|
||||||
|
assert_eq!(
|
||||||
|
manifest.files[0].hashes.get("md5").map(String::as_str),
|
||||||
|
Some("d41d8cd98f00b204e9800998ecf8427e")
|
||||||
|
);
|
||||||
|
|
||||||
|
let target = root.join("out.mrpack");
|
||||||
|
MODRINTH
|
||||||
|
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||||
|
.expect("导出 mrpack");
|
||||||
|
let reread = MODRINTH
|
||||||
|
.read(&PackArchive::open_default(&target).expect("打开导出包"))
|
||||||
|
.expect("再读");
|
||||||
|
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
|
||||||
|
assert_eq!(
|
||||||
|
reread.files[0].extra["optionalReason"],
|
||||||
|
json!({"why": "kept for the community"})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||||
|
//!
|
||||||
|
//! # 为什么这个文件以前是空的
|
||||||
|
//!
|
||||||
|
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
|
||||||
|
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
|
||||||
|
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
|
||||||
|
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
|
||||||
|
//!
|
||||||
|
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
|
||||||
|
//! 没有 `.part`、目标路径不存在);
|
||||||
|
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
|
||||||
|
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::temp_root;
|
||||||
|
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||||
|
use crate::addon::pack::error::PackErrorKind;
|
||||||
|
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||||
|
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
|
||||||
|
|
||||||
|
/// 一个覆盖文件的声明(内容由调用方提供)。
|
||||||
|
fn override_file(path: &str) -> OverrideFile {
|
||||||
|
OverrideFile {
|
||||||
|
path: path.to_string(),
|
||||||
|
sha1: "0".repeat(40),
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
|
||||||
|
let mut manifest = PackManifest::new("超大包");
|
||||||
|
manifest.version = Some("1.0.0".to_string());
|
||||||
|
manifest.minecraft = Some("1.20.1".to_string());
|
||||||
|
manifest.overrides.push(PackOverrides {
|
||||||
|
dir: "overrides".to_string(),
|
||||||
|
side: PackSide::Both,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
|
||||||
|
fn sparse_file(path: &Path, length: u64) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("建目录");
|
||||||
|
}
|
||||||
|
let file = std::fs::File::create(path).expect("建稀疏文件");
|
||||||
|
file.set_len(length).expect("拉长文件(稀疏)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
|
||||||
|
fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(root)
|
||||||
|
.expect("列目录")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||||
|
.filter(|name| !keep.contains(&name.as_str()))
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||||
|
let root = temp_root("zip64-single");
|
||||||
|
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
|
||||||
|
sparse_file(
|
||||||
|
&root.join("overrides/mods/huge.jar"),
|
||||||
|
zip::ZIP64_BYTES_THR + 1,
|
||||||
|
);
|
||||||
|
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
|
||||||
|
let target = root.join("out.zip");
|
||||||
|
|
||||||
|
let error = CURSEFORGE
|
||||||
|
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||||
|
.expect_err("超 ZIP64 阈值必须拒写");
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||||
|
assert!(error.message().contains("ZIP64"), "{error}");
|
||||||
|
assert!(
|
||||||
|
error.hint().is_some_and(|hint| hint.contains("移出实例")),
|
||||||
|
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
|
||||||
|
);
|
||||||
|
assert!(!target.exists(), "失败后目标路径不许存在");
|
||||||
|
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
|
||||||
|
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_threshold_is_cumulative_not_per_file() {
|
||||||
|
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
|
||||||
|
let root = temp_root("zip64-cumulative");
|
||||||
|
let each = 1_610_612_736_u64; // 1.5 GiB
|
||||||
|
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
|
||||||
|
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for index in 0..3 {
|
||||||
|
let relative = format!("mods/part-{index}.jar");
|
||||||
|
sparse_file(&root.join("overrides").join(&relative), each);
|
||||||
|
files.push(override_file(&relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = root.join("out.zip");
|
||||||
|
let error = CURSEFORGE
|
||||||
|
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
|
||||||
|
.expect_err("累计超阈值同样必须拒写");
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||||
|
assert!(error.message().contains("ZIP64"), "{error}");
|
||||||
|
assert!(!target.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||||
|
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
|
||||||
|
let root = temp_root("zip64-under");
|
||||||
|
let content = b"a normal sized override file";
|
||||||
|
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||||
|
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
|
||||||
|
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
|
||||||
|
let target = root.join("out.zip");
|
||||||
|
|
||||||
|
CURSEFORGE
|
||||||
|
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||||
|
.expect("正常尺寸必须写得出来");
|
||||||
|
assert!(target.exists());
|
||||||
|
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
|
||||||
|
assert!(written.has_entry("overrides/mods/jei.jar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
|
||||||
|
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
|
||||||
|
///
|
||||||
|
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
|
||||||
|
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
|
||||||
|
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为
|
||||||
|
/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。
|
||||||
|
#[test]
|
||||||
|
#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"]
|
||||||
|
fn a_single_file_inside_the_double_count_window_still_exports() {
|
||||||
|
// P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个
|
||||||
|
// 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。
|
||||||
|
// 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。
|
||||||
|
let root = temp_root("zip64-window");
|
||||||
|
let length = 2_500_000_000_u64;
|
||||||
|
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
|
||||||
|
assert!(
|
||||||
|
length > zip::ZIP64_BYTES_THR / 2,
|
||||||
|
"样本必须落在过拒窗口里(≥ 阈值一半)"
|
||||||
|
);
|
||||||
|
sparse_file(&root.join("overrides/mods/big.jar"), length);
|
||||||
|
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
|
||||||
|
let target = root.join("out.zip");
|
||||||
|
|
||||||
|
let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。
|
||||||
|
// 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有
|
||||||
|
// 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。
|
||||||
|
let file = std::fs::File::open(&target).expect("打开导出包");
|
||||||
|
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||||
|
let entry = archive
|
||||||
|
.by_name("overrides/mods/big.jar")
|
||||||
|
.expect("覆盖条目要在包里");
|
||||||
|
assert_eq!(entry.size(), length, "声明尺寸要原样带过去");
|
||||||
|
|
||||||
|
// 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! 逐文件冲突策略的**边界样本**(research/18 §3.3 / §6 表 B #1–#2 的那几条)。
|
||||||
|
//!
|
||||||
|
//! 三条样本各自钉一个事实,缺一条就会复现不同的 bug:
|
||||||
|
//!
|
||||||
|
//! | 样本 | 必须成立的事实 | 写错的后果 |
|
||||||
|
//! |------|----------------|-----------|
|
||||||
|
//! | 同路径不同哈希 | 叠层顺序里**最后一条**胜;被遮蔽的那条不落地、只留一条具名注记 | 报告里两条 `Installed`,前一条的哈希根本没落地 |
|
||||||
|
//! | 同哈希不同路径 | **两个文件都落地**(按路径判重,不许按哈希判重) | 静默丢一个文件(内容一样不等于同一个文件) |
|
||||||
|
//! | overrides 叠层顺序 | `overrides/` 先、`client-overrides/` 后(= 后者胜,Modrinth 官方语义) | 与 HMCL 的相反语义混进来,同一包在不同启动器装出不同内容 |
|
||||||
|
//!
|
||||||
|
//! 判据的纯函数版本(`plan::layer::resolve`)在那边有单元测试;这里是**接在管线上的行为**:
|
||||||
|
//! 真打 zip、真导入、真读回磁盘内容与报告字段。
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::addon::import::{
|
||||||
|
import_pack, keys, ConflictReason, FileAction, ImportProgress, ImportRequest, ImportStage,
|
||||||
|
ImportStatus,
|
||||||
|
};
|
||||||
|
use crate::download::blocking::block_on;
|
||||||
|
|
||||||
|
use super::support::sha1;
|
||||||
|
use super::{read_text, temp_root, write_file, zip_dir};
|
||||||
|
|
||||||
|
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
|
||||||
|
fn index_json(name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
|
||||||
|
"name": "{name}", "files": [], "dependencies": {{"minecraft": "1.20.1"}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 报告里所有 `NOTE_OVERRIDE_SHADOWED` 注记的消息。
|
||||||
|
fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
|
||||||
|
report
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.filter(|note| note.message_key == keys::NOTE_OVERRIDE_SHADOWED)
|
||||||
|
.map(|note| note.message.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_path_with_different_bytes_lands_the_later_side_only() {
|
||||||
|
let root = temp_root("layer-same-path");
|
||||||
|
let dir = root.join("src");
|
||||||
|
write_file(
|
||||||
|
&dir.join("modrinth.index.json"),
|
||||||
|
index_json("dup conflict").as_bytes(),
|
||||||
|
);
|
||||||
|
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
|
||||||
|
write_file(
|
||||||
|
&dir.join("client-overrides/config/dup.toml"),
|
||||||
|
b"from-client\n",
|
||||||
|
);
|
||||||
|
let pack = root.join("pack.mrpack");
|
||||||
|
zip_dir(&dir, &pack);
|
||||||
|
let target = root.join("instance");
|
||||||
|
|
||||||
|
// Extract 阶段的总条数就是"要落地的条目数":两点声明 ⇒ 只算 1 条(被遮蔽的不读字节)。
|
||||||
|
let totals: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink = {
|
||||||
|
let totals = Arc::clone(&totals);
|
||||||
|
Arc::new(move |progress: &ImportProgress| {
|
||||||
|
if progress.stage == ImportStage::Extract {
|
||||||
|
totals.lock().expect("锁").push(progress.total);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let report = block_on(import_pack(
|
||||||
|
&ImportRequest::new(&pack, &target).with_progress(sink),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||||
|
// Modrinth 官方语义:client-overrides/ 后写 = 胜。
|
||||||
|
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
|
||||||
|
// 一路径一行,且那一行的哈希必须是**真落地**的那份内容。
|
||||||
|
let rows: Vec<_> = report
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|file| file.path == "config/dup.toml")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(rows.len(), 1, "同一路径只许一行:{:?}", report.files);
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].sha1.as_deref(),
|
||||||
|
Some(sha1(b"from-client\n").as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(rows[0].action, FileAction::Installed);
|
||||||
|
// 遮蔽要留下具名注记:谁赢了、两份内容不同。
|
||||||
|
let notes = shadow_notes(&report);
|
||||||
|
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
|
||||||
|
assert!(notes[0].contains("内容不同"), "{}", notes[0]);
|
||||||
|
assert!(notes[0].contains("client-overrides"), "{}", notes[0]);
|
||||||
|
assert!(
|
||||||
|
report.conflicts.is_empty(),
|
||||||
|
"包内重复声明不是用户冲突:{:?}",
|
||||||
|
report.conflicts
|
||||||
|
);
|
||||||
|
// 被遮蔽的条目不再进 Extract 的总数(1 条真落地)。
|
||||||
|
assert_eq!(totals.lock().expect("锁").last().copied(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_bytes_at_two_paths_are_two_files() {
|
||||||
|
let root = temp_root("layer-same-bytes");
|
||||||
|
let dir = root.join("src");
|
||||||
|
write_file(
|
||||||
|
&dir.join("modrinth.index.json"),
|
||||||
|
index_json("same bytes").as_bytes(),
|
||||||
|
);
|
||||||
|
let content = b"identical payload\n";
|
||||||
|
write_file(&dir.join("overrides/mods/a.jar"), content);
|
||||||
|
write_file(&dir.join("overrides/mods/b.jar"), content);
|
||||||
|
let pack = root.join("pack.mrpack");
|
||||||
|
zip_dir(&dir, &pack);
|
||||||
|
let target = root.join("instance");
|
||||||
|
|
||||||
|
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||||
|
|
||||||
|
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||||
|
for path in ["mods/a.jar", "mods/b.jar"] {
|
||||||
|
assert_eq!(
|
||||||
|
read_text(&target.join(path)),
|
||||||
|
"identical payload\n",
|
||||||
|
"{path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let rows: Vec<_> = report
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|file| file.path.ends_with(".jar"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(rows.len(), 2, "同哈希不同路径不许折叠:{:?}", report.files);
|
||||||
|
assert!(rows
|
||||||
|
.iter()
|
||||||
|
.all(|row| row.sha1.as_deref() == Some(sha1(content).as_str())));
|
||||||
|
assert!(
|
||||||
|
shadow_notes(&report).is_empty(),
|
||||||
|
"不同路径不是重复声明:{:?}",
|
||||||
|
report.warnings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
|
||||||
|
let root = temp_root("layer-same-content");
|
||||||
|
let dir = root.join("src");
|
||||||
|
write_file(
|
||||||
|
&dir.join("modrinth.index.json"),
|
||||||
|
index_json("dup identical").as_bytes(),
|
||||||
|
);
|
||||||
|
let content = b"shared = true\n";
|
||||||
|
write_file(&dir.join("overrides/config/shared.toml"), content);
|
||||||
|
write_file(&dir.join("client-overrides/config/shared.toml"), content);
|
||||||
|
let pack = root.join("pack.mrpack");
|
||||||
|
zip_dir(&dir, &pack);
|
||||||
|
let target = root.join("instance");
|
||||||
|
|
||||||
|
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||||
|
|
||||||
|
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||||
|
assert_eq!(
|
||||||
|
read_text(&target.join("config/shared.toml")),
|
||||||
|
"shared = true\n"
|
||||||
|
);
|
||||||
|
let notes = shadow_notes(&report);
|
||||||
|
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
|
||||||
|
assert!(
|
||||||
|
notes[0].contains("逐字节相同"),
|
||||||
|
"内容一致必须说清楚,别让人以为丢了东西:{}",
|
||||||
|
notes[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_baseline_remembers_the_winning_bytes() {
|
||||||
|
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
|
||||||
|
// "用户改过"(或反过来静默覆盖用户改动)。
|
||||||
|
let root = temp_root("layer-baseline");
|
||||||
|
let dir = root.join("src");
|
||||||
|
write_file(
|
||||||
|
&dir.join("modrinth.index.json"),
|
||||||
|
index_json("baseline dup").as_bytes(),
|
||||||
|
);
|
||||||
|
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
|
||||||
|
write_file(
|
||||||
|
&dir.join("client-overrides/config/dup.toml"),
|
||||||
|
b"from-client\n",
|
||||||
|
);
|
||||||
|
let pack = root.join("pack.mrpack");
|
||||||
|
zip_dir(&dir, &pack);
|
||||||
|
let target = root.join("instance");
|
||||||
|
let first = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||||
|
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
|
||||||
|
|
||||||
|
// 同一份包再导一次:磁盘内容 == 胜者内容 == 基线 ⇒ 既不该报冲突,也不该重写盘。
|
||||||
|
let second = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||||
|
assert_eq!(second.status, ImportStatus::Success, "{:?}", second.error);
|
||||||
|
assert!(
|
||||||
|
second.conflicts.is_empty(),
|
||||||
|
"基线记的必须是胜者那份,否则这里会冒出一条假的 user_modified:{:?}",
|
||||||
|
second.conflicts
|
||||||
|
);
|
||||||
|
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
|
||||||
|
// 反向确认:冲突清单里不该出现这条路径的**任何**成因。
|
||||||
|
assert!(!second
|
||||||
|
.conflicts
|
||||||
|
.iter()
|
||||||
|
.any(|conflict| conflict.path == "config/dup.toml"
|
||||||
|
&& conflict.reason != ConflictReason::PreExisting));
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
mod fixtures;
|
mod fixtures;
|
||||||
mod import_curseforge;
|
mod import_curseforge;
|
||||||
mod import_failures;
|
mod import_failures;
|
||||||
|
mod import_layering;
|
||||||
mod import_merge;
|
mod import_merge;
|
||||||
mod import_modrinth;
|
mod import_modrinth;
|
||||||
mod import_multimc;
|
mod import_multimc;
|
||||||
|
|||||||
@@ -39,20 +39,6 @@ pub fn plan(
|
|||||||
if !library.applies(environment) {
|
if !library.applies(environment) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 现代清单把 natives 作为独立库条目(`org.lwjgl:lwjgl:3.3.2:natives-linux`),
|
|
||||||
// 不是 `natives` 表。Linux arm64 上改写坐标并兜底到 Maven 镜像。
|
|
||||||
if let Some(rewritten) = linux_arm64_native_entry(library, environment) {
|
|
||||||
files.push(PlanFile {
|
|
||||||
role: FileRole::Native,
|
|
||||||
name: rewritten.name,
|
|
||||||
url: rewritten.url,
|
|
||||||
sha1: None,
|
|
||||||
size: None,
|
|
||||||
dest: layout.libraries_dir().join(&rewritten.relative),
|
|
||||||
resource: ResourceKind::Library,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if library.coordinate().is_err() {
|
if library.coordinate().is_err() {
|
||||||
warnings.push(format!("库坐标不合法,已跳过:{}", library.name));
|
warnings.push(format!("库坐标不合法,已跳过:{}", library.name));
|
||||||
continue;
|
continue;
|
||||||
@@ -93,42 +79,31 @@ pub fn plan(
|
|||||||
let Some(classifier) = library.native_classifier(environment) else {
|
let Some(classifier) = library.native_classifier(environment) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let Some(artifact) = library.native_artifact(&classifier) else {
|
||||||
|
warnings.push(format!(
|
||||||
|
"{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
|
||||||
|
library.name
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let Ok(native_relative) = library.native_relative_path(&classifier) else {
|
let Ok(native_relative) = library.native_relative_path(&classifier) else {
|
||||||
warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
|
warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let dest = layout.libraries_dir().join(&native_relative);
|
if !artifact.has_url() {
|
||||||
// Linux arm64:Mojang 清单没有 arm64 分类器,兜底到 Maven 镜像(见常量注释)。
|
warnings.push(format!(
|
||||||
let arm64_fallback = classifier == LINUX_ARM64_CLASSIFIER;
|
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
|
||||||
let (url, sha1, size) = match library.native_artifact(&classifier) {
|
library.name
|
||||||
Some(artifact) if artifact.has_url() => (
|
));
|
||||||
artifact.url.clone().unwrap_or_default(),
|
continue;
|
||||||
artifact.sha1.clone(),
|
}
|
||||||
artifact.size,
|
|
||||||
),
|
|
||||||
_ if arm64_fallback => (arm64_native_url(&native_relative), None, None),
|
|
||||||
Some(_) => {
|
|
||||||
warnings.push(format!(
|
|
||||||
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
|
|
||||||
library.name
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
warnings.push(format!(
|
|
||||||
"{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
|
|
||||||
library.name
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
files.push(PlanFile {
|
files.push(PlanFile {
|
||||||
role: FileRole::Native,
|
role: FileRole::Native,
|
||||||
name: format!("{}:{classifier}", library.name),
|
name: format!("{}:{classifier}", library.name),
|
||||||
url,
|
url: artifact.url.clone().unwrap_or_default(),
|
||||||
sha1,
|
sha1: artifact.sha1.clone(),
|
||||||
size,
|
size: artifact.size,
|
||||||
dest,
|
dest: layout.libraries_dir().join(native_relative),
|
||||||
resource: ResourceKind::Library,
|
resource: ResourceKind::Library,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,39 +125,6 @@ fn library_dest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 现代清单里 LWJGL3 natives 条目的改写结果。
|
|
||||||
struct Arm64Native {
|
|
||||||
name: String,
|
|
||||||
relative: PathBuf,
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Linux arm64:把 `org.lwjgl:<module>:<version>:natives-linux` 改写成 arm64。
|
|
||||||
fn linux_arm64_native_entry(library: &Library, env: &Environment) -> Option<Arm64Native> {
|
|
||||||
let name = library.linux_arm64_native_name(env)?;
|
|
||||||
let base = name.strip_suffix(":natives-linux-arm64")?;
|
|
||||||
let mut parts = base.split(':');
|
|
||||||
let group = parts.next()?;
|
|
||||||
let artifact = parts.next()?;
|
|
||||||
let version = parts.next()?;
|
|
||||||
if parts.next().is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let relative = PathBuf::from(format!(
|
|
||||||
"{}/{artifact}/{version}/{artifact}-{version}-natives-linux-arm64.jar",
|
|
||||||
group.replace('.', "/")
|
|
||||||
));
|
|
||||||
let url = format!(
|
|
||||||
"{LINUX_ARM64_NATIVES_BASE}{}",
|
|
||||||
relative.to_string_lossy().replace('\\', "/")
|
|
||||||
);
|
|
||||||
Some(Arm64Native {
|
|
||||||
name,
|
|
||||||
relative,
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按库声明(或默认)的 Maven 基址拼下载地址。
|
/// 按库声明(或默认)的 Maven 基址拼下载地址。
|
||||||
fn maven_url(library: &Library, relative: &Path) -> String {
|
fn maven_url(library: &Library, relative: &Path) -> String {
|
||||||
let base = library
|
let base = library
|
||||||
@@ -199,22 +141,6 @@ fn maven_url(library: &Library, relative: &Path) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linux arm64 的 natives 兜底仓库:Mojang 的 `libraries.minecraft.net` 对
|
|
||||||
/// `natives-linux-arm64` 一律 404(实测 3.3.2 的 lwjgl/glfw/opengl/stb/tinyfd/openal),
|
|
||||||
/// 同一坐标在 Maven Central / 阿里云镜像有。
|
|
||||||
pub const LINUX_ARM64_NATIVES_BASE: &str = "https://maven.aliyun.com/repository/public/";
|
|
||||||
|
|
||||||
/// Linux arm64 的 natives 分类器(见 `Library::native_classifier`)。
|
|
||||||
pub const LINUX_ARM64_CLASSIFIER: &str = "natives-linux-arm64";
|
|
||||||
|
|
||||||
/// arm64 natives 的兜底 URL(按相对路径拼镜像基址)。
|
|
||||||
fn arm64_native_url(relative: &Path) -> String {
|
|
||||||
format!(
|
|
||||||
"{LINUX_ARM64_NATIVES_BASE}{}",
|
|
||||||
relative.to_string_lossy().replace('\\', "/")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
|
/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -329,81 +255,4 @@ mod tests {
|
|||||||
.dest
|
.dest
|
||||||
.ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar"));
|
.ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linux_arm64_lwjgl3_natives_fall_back_to_maven_mirror() {
|
|
||||||
// Mojang 的清单只有 `natives-linux`(x86_64)且没有 arm64 分类器下载信息;
|
|
||||||
// Linux arm64 要改写成 `natives-linux-arm64` 并兜底到 Maven 镜像。
|
|
||||||
let library = library_with_artifact(
|
|
||||||
r#"{"name":"org.lwjgl:lwjgl:3.3.2","natives":{"linux":"natives-linux"}}"#,
|
|
||||||
);
|
|
||||||
let env = Environment {
|
|
||||||
os: "linux".to_string(),
|
|
||||||
os_version: String::new(),
|
|
||||||
arch: "arm64".to_string(),
|
|
||||||
..environment()
|
|
||||||
};
|
|
||||||
let layout = GameLayout::new("/g/.minecraft");
|
|
||||||
let (files, warnings) = plan(&layout, "1.20.4", &[library], &env);
|
|
||||||
assert!(warnings.is_empty(), "不该有告警:{warnings:?}");
|
|
||||||
assert_eq!(files.len(), 1);
|
|
||||||
assert_eq!(files[0].name, "org.lwjgl:lwjgl:3.3.2:natives-linux-arm64");
|
|
||||||
assert_eq!(
|
|
||||||
files[0].url,
|
|
||||||
"https://maven.aliyun.com/repository/public/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux-arm64.jar"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
files[0].sha1.is_none(),
|
|
||||||
"镜像兜底没有 sha1,只能靠大小/存在性"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn modern_lwjgl_native_entry_is_rewritten_to_arm64_maven() {
|
|
||||||
// 1.20.x 的清单把 natives 作为独立条目,分类器写在 name 里。
|
|
||||||
let library = library_with_artifact(
|
|
||||||
r#"{"name":"org.lwjgl:lwjgl:3.3.2:natives-linux",
|
|
||||||
"rules":[{"action":"allow","os":{"name":"linux"}}],
|
|
||||||
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux.jar","url":"https://libraries.minecraft.net/x.jar","sha1":"aa"}}}"#,
|
|
||||||
);
|
|
||||||
let env = Environment {
|
|
||||||
os: "linux".to_string(),
|
|
||||||
os_version: String::new(),
|
|
||||||
arch: "arm64".to_string(),
|
|
||||||
..environment()
|
|
||||||
};
|
|
||||||
let layout = GameLayout::new("/g/.minecraft");
|
|
||||||
let (files, warnings) = plan(&layout, "1.20.4", &[library], &env);
|
|
||||||
assert!(warnings.is_empty(), "不该有告警:{warnings:?}");
|
|
||||||
assert_eq!(files.len(), 1);
|
|
||||||
assert_eq!(files[0].role.as_str(), "native");
|
|
||||||
assert_eq!(files[0].name, "org.lwjgl:lwjgl:3.3.2:natives-linux-arm64");
|
|
||||||
assert_eq!(
|
|
||||||
files[0].url,
|
|
||||||
"https://maven.aliyun.com/repository/public/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux-arm64.jar"
|
|
||||||
);
|
|
||||||
assert!(files[0]
|
|
||||||
.dest
|
|
||||||
.ends_with("libraries/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux-arm64.jar"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linux_x86_64_keeps_the_manifest_classifier() {
|
|
||||||
let library = library_with_artifact(
|
|
||||||
r#"{"name":"org.lwjgl:lwjgl:3.3.2","natives":{"linux":"natives-linux"}}"#,
|
|
||||||
);
|
|
||||||
let env = Environment {
|
|
||||||
os: "linux".to_string(),
|
|
||||||
os_version: String::new(),
|
|
||||||
arch: "x86_64".to_string(),
|
|
||||||
..environment()
|
|
||||||
};
|
|
||||||
let layout = GameLayout::new("/g/.minecraft");
|
|
||||||
let (files, warnings) = plan(&layout, "1.20.4", &[library], &env);
|
|
||||||
assert!(files.is_empty(), "x86_64 没有清单下载信息,不该凭空拼 URL");
|
|
||||||
assert!(
|
|
||||||
warnings.iter().any(|w| w.contains("natives-linux")),
|
|
||||||
"{warnings:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
//! | --- | --- |
|
//! | --- | --- |
|
||||||
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
|
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
|
||||||
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
|
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
|
||||||
//! | Termux | `$PREFIX/lib/jvm/*`(`java-17-openjdk` 这类;`$PREFIX` 由 [`crate::platform::termux`] 识别) |
|
|
||||||
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
|
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
|
||||||
//!
|
//!
|
||||||
//! 三个纪律:
|
//! 三个纪律:
|
||||||
@@ -23,9 +22,6 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::platform::paths::{EnvSource, ProcessEnv};
|
|
||||||
use crate::platform::termux;
|
|
||||||
|
|
||||||
use super::extract::java_binary_name;
|
use super::extract::java_binary_name;
|
||||||
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
|
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
|
||||||
|
|
||||||
@@ -77,12 +73,6 @@ pub fn scan_java_binaries() -> Vec<PathBuf> {
|
|||||||
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
|
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn standard_roots() -> Vec<PathBuf> {
|
pub fn standard_roots() -> Vec<PathBuf> {
|
||||||
standard_roots_in(&ProcessEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同 [`standard_roots`],但环境变量可注入(测试 / 便携化封装用)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn standard_roots_in(env: &dyn EnvSource) -> Vec<PathBuf> {
|
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
|
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||||
@@ -109,10 +99,6 @@ pub fn standard_roots_in(env: &dyn EnvSource) -> Vec<PathBuf> {
|
|||||||
for base in ["/usr/lib/jvm", "/usr/java"] {
|
for base in ["/usr/lib/jvm", "/usr/java"] {
|
||||||
roots.extend(child_dirs(Path::new(base)));
|
roots.extend(child_dirs(Path::new(base)));
|
||||||
}
|
}
|
||||||
// Termux 的 JDK 落在 `$PREFIX/lib/jvm`(如 `java-17-openjdk`),不在 `/usr` 下。
|
|
||||||
if let Some(root) = termux::jvm_root(env) {
|
|
||||||
roots.extend(child_dirs(&root));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
roots.sort();
|
roots.sort();
|
||||||
roots.dedup();
|
roots.dedup();
|
||||||
@@ -225,9 +211,8 @@ pub fn path_key(path: &Path) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{dedupe, parse_java_home, path_key, standard_roots, standard_roots_in};
|
use super::{dedupe, parse_java_home, path_key, standard_roots};
|
||||||
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
|
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
|
||||||
use crate::platform::paths::MapEnv;
|
|
||||||
|
|
||||||
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
|
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
|
||||||
JavaRuntime {
|
JavaRuntime {
|
||||||
@@ -338,41 +323,4 @@ mod tests {
|
|||||||
let roots = standard_roots();
|
let roots = standard_roots();
|
||||||
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
|
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn termux_jvm_root_is_scanned() {
|
|
||||||
let base = std::env::temp_dir().join(format!("ull-termux-jvm-{}", std::process::id()));
|
|
||||||
let jdk = base.join("lib").join("jvm").join("java-17-openjdk");
|
|
||||||
std::fs::create_dir_all(jdk.join("bin")).unwrap();
|
|
||||||
std::fs::write(jdk.join("bin").join("java"), b"").unwrap();
|
|
||||||
|
|
||||||
let env = MapEnv::new()
|
|
||||||
.with("TERMUX_VERSION", "0.118.3")
|
|
||||||
.with("PREFIX", base.to_str().unwrap());
|
|
||||||
let roots = standard_roots_in(&env);
|
|
||||||
assert!(
|
|
||||||
roots.contains(&jdk),
|
|
||||||
"Termux 的 $PREFIX/lib/jvm 必须进扫描表:{roots:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn non_termux_env_does_not_scan_prefix() {
|
|
||||||
let base = std::env::temp_dir().join(format!("ull-not-termux-{}", std::process::id()));
|
|
||||||
let jdk = base.join("lib").join("jvm").join("java-17-openjdk");
|
|
||||||
std::fs::create_dir_all(&jdk).unwrap();
|
|
||||||
|
|
||||||
let env = MapEnv::new().with("PREFIX", base.to_str().unwrap());
|
|
||||||
let roots = standard_roots_in(&env);
|
|
||||||
assert!(
|
|
||||||
!roots.contains(&jdk),
|
|
||||||
"非 Termux 不该扫 $PREFIX/lib/jvm:{roots:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ use super::error::{io_error, JavaError, JavaErrorKind};
|
|||||||
use super::extract;
|
use super::extract;
|
||||||
use super::location::{JavaInstallRoot, LocationMode};
|
use super::location::{JavaInstallRoot, LocationMode};
|
||||||
use super::probe::{self, JavaOrigin, JavaRuntime};
|
use super::probe::{self, JavaOrigin, JavaRuntime};
|
||||||
use super::release::{JdkArchive, JdkRelease};
|
use super::release::JdkRelease;
|
||||||
use super::source::{self, ChainLog};
|
use super::source::{self, ChainLog};
|
||||||
use super::target::AdoptiumTarget;
|
use super::target::AdoptiumTarget;
|
||||||
use super::vendor::{self, JdkVendor};
|
use super::vendor::{self, JdkVendor};
|
||||||
@@ -184,6 +184,7 @@ pub async fn install(
|
|||||||
location: &JavaInstallRoot,
|
location: &JavaInstallRoot,
|
||||||
request: InstallRequest,
|
request: InstallRequest,
|
||||||
) -> Result<InstalledJdk, JavaError> {
|
) -> Result<InstalledJdk, JavaError> {
|
||||||
|
let target = AdoptiumTarget::host()?;
|
||||||
let domestic = engine.region().is_domestic();
|
let domestic = engine.region().is_domestic();
|
||||||
let priority = request.order(domestic);
|
let priority = request.order(domestic);
|
||||||
let mut chain = ChainLog::new();
|
let mut chain = ChainLog::new();
|
||||||
@@ -191,75 +192,6 @@ pub async fn install(
|
|||||||
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
|
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
|
||||||
let _ = super::location::cleanup_orphans(&location.root);
|
let _ = super::location::cleanup_orphans(&location.root);
|
||||||
|
|
||||||
// Termux:JDK 由系统包管理器装进 `$PREFIX`,不是可下载的归档。
|
|
||||||
// 默认(没钉发行版)时优先走它;`--vendor termux` 时只走它。
|
|
||||||
let want_termux = match request.vendor {
|
|
||||||
Some(JdkVendor::Termux) => true,
|
|
||||||
Some(_) => false,
|
|
||||||
None => super::termux::available(),
|
|
||||||
};
|
|
||||||
if want_termux {
|
|
||||||
if !super::termux::available() {
|
|
||||||
return Err(
|
|
||||||
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
|
|
||||||
.with_hint("普通 Linux/Windows 用 --vendor temurin|oracle|microsoft|zulu"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match super::termux::install(request.major).await {
|
|
||||||
Ok((home, reused)) => {
|
|
||||||
let mut runtime = probe::probe(&extract::java_binary(&home), JavaOrigin::Managed);
|
|
||||||
runtime.vendor = Some(JdkVendor::Termux.id().to_string());
|
|
||||||
let version = runtime
|
|
||||||
.version
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| request.major.to_string());
|
|
||||||
let release = JdkRelease {
|
|
||||||
vendor: JdkVendor::Termux,
|
|
||||||
major: request.major,
|
|
||||||
version,
|
|
||||||
semver: None,
|
|
||||||
release_name: format!("Termux openjdk-{}", request.major),
|
|
||||||
archive: JdkArchive {
|
|
||||||
name: format!("openjdk-{}.deb", request.major),
|
|
||||||
url: String::new(),
|
|
||||||
sha256: String::new(),
|
|
||||||
size: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
chain.hit(JdkVendor::Termux, format!("命中 {}", release.summary()));
|
|
||||||
return Ok(InstalledJdk {
|
|
||||||
major: release.major,
|
|
||||||
vendor: JdkVendor::Termux,
|
|
||||||
release,
|
|
||||||
root: home,
|
|
||||||
mode: location.mode,
|
|
||||||
archive: PathBuf::new(),
|
|
||||||
runtime,
|
|
||||||
downloaded: if reused {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some("termux".to_string())
|
|
||||||
},
|
|
||||||
bytes: 0,
|
|
||||||
files: 0,
|
|
||||||
extracted: false,
|
|
||||||
chain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
if request.vendor == Some(JdkVendor::Termux) {
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
chain.failed(JdkVendor::Termux, error.message().to_string());
|
|
||||||
reasons.push(format!("termux:{}", error.message()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported,
|
|
||||||
// 不提前 `?` 退出——让每一家都记一条降级原因,错误里能看见全貌。
|
|
||||||
let target = AdoptiumTarget::host();
|
|
||||||
|
|
||||||
for candidate in &priority {
|
for candidate in &priority {
|
||||||
let vendor = *candidate;
|
let vendor = *candidate;
|
||||||
if !vendor.supports(request.major) {
|
if !vendor.supports(request.major) {
|
||||||
@@ -273,15 +205,6 @@ pub async fn install(
|
|||||||
reasons.push(format!("{}:{reason}", vendor.id()));
|
reasons.push(format!("{}:{reason}", vendor.id()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let target = match &target {
|
|
||||||
Ok(target) => *target,
|
|
||||||
Err(error) => {
|
|
||||||
let message = error.message().to_string();
|
|
||||||
chain.failed(vendor, message.clone());
|
|
||||||
reasons.push(format!("{}:{message}", vendor.id()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let release = match source::fetch(engine, vendor, request.major, target).await {
|
let release = match source::fetch(engine, vendor, request.major, target).await {
|
||||||
Ok(release) => release,
|
Ok(release) => release,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ pub mod release;
|
|||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
pub mod termux;
|
|
||||||
pub mod vendor;
|
pub mod vendor;
|
||||||
pub mod zulu;
|
pub mod zulu;
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ pub async fn fetch(
|
|||||||
})?;
|
})?;
|
||||||
zulu::fetch_latest(client, table, major, target).await
|
zulu::fetch_latest(client, table, major, target).await
|
||||||
}
|
}
|
||||||
JdkVendor::Termux => Err(JavaError::new(
|
|
||||||
super::error::JavaErrorKind::Unsupported,
|
|
||||||
"Termux 的 JDK 由系统包管理器安装,不走归档下载链",
|
|
||||||
)
|
|
||||||
.with_hint("在 Termux 里用 `ull java install <major>`(默认走 termux 源)")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
//! Termux 系统 JDK(`openjdk-<major>` 包)的安装与定位。
|
|
||||||
//!
|
|
||||||
//! Termux 的 openjdk 是 `.deb`(bionic),由 apt/dpkg 装进 `$PREFIX`,与 Temurin 的
|
|
||||||
//! zip/tar.gz 归档不是同一形态,所以**不走** `source::fetch` 的归档链。这里委托包管理器:
|
|
||||||
//!
|
|
||||||
//! 1. 用内置镜像生成一份**隔离**的 sources.list;
|
|
||||||
//! 2. `apt-get update && install` 的 lists/cache 都指向临时目录,不污染用户的 apt 状态;
|
|
||||||
//! 3. 装完在 `$PREFIX/lib/jvm` 下找 `java-<major>-openjdk`,用 `bin/java` 是否存在做判据。
|
|
||||||
//!
|
|
||||||
//! 镜像顺序是实测可用的国内源;全部失败才报错,错误里带每一跳的原因。
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use crate::platform::paths::ProcessEnv;
|
|
||||||
use crate::platform::termux as host;
|
|
||||||
|
|
||||||
use super::error::{JavaError, JavaErrorKind};
|
|
||||||
|
|
||||||
/// 内置镜像(termux-main 仓库根)。顺序即尝试顺序。
|
|
||||||
pub const MIRRORS: &[&str] = &[
|
|
||||||
"https://mirrors.sau.edu.cn/termux/apt/termux-main",
|
|
||||||
"https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main",
|
|
||||||
"https://mirrors.ustc.edu.cn/termux/apt/termux-main",
|
|
||||||
"https://packages.termux.dev/apt/termux-main",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 当前宿主是不是 Termux。
|
|
||||||
#[must_use]
|
|
||||||
pub fn available() -> bool {
|
|
||||||
host::is_termux(&ProcessEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 大版本 → Termux 包名(主源只有 17 / 21 / 25)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn package_for(major: u32) -> Option<String> {
|
|
||||||
matches!(major, 17 | 21 | 25).then(|| format!("openjdk-{major}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `$PREFIX/lib/jvm` 下与 `major` 对应的 JDK 根(`bin/java` 必须存在)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn installed_home(prefix: &Path, major: u32) -> Option<PathBuf> {
|
|
||||||
let root = prefix.join("lib").join("jvm");
|
|
||||||
let entries = std::fs::read_dir(root).ok()?;
|
|
||||||
let dashed = format!("java-{major}-openjdk");
|
|
||||||
let plain = format!("openjdk-{major}");
|
|
||||||
let mut found: Vec<PathBuf> = entries
|
|
||||||
.flatten()
|
|
||||||
.map(|entry| entry.path())
|
|
||||||
.filter(|path| path.is_dir() && path.join("bin").join("java").is_file())
|
|
||||||
.filter(|path| {
|
|
||||||
path.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.is_some_and(|name| name == dashed || name == plain)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
found.sort();
|
|
||||||
found.into_iter().next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一条 `deb` 行(测试与安装共用同一份拼法)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn sources_line(mirror: &str) -> String {
|
|
||||||
format!("deb {} stable main\n", mirror.trim_end_matches('/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 装一个 Termux JDK,返回它的根目录。
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// 当前不在 Termux、主源没有该大版本、所有镜像都失败、装完仍找不到 `bin/java` 时返回错误。
|
|
||||||
///
|
|
||||||
/// 返回 `(JDK 根, 是否复用了已装)`:包管理器刚装完与「本来就有」对调用方是两种结果
|
|
||||||
/// (一个要报安装,一个要报「未走网络」)。
|
|
||||||
pub async fn install(major: u32) -> Result<(PathBuf, bool), JavaError> {
|
|
||||||
let Some(package) = package_for(major) else {
|
|
||||||
return Err(JavaError::new(
|
|
||||||
JavaErrorKind::Unsupported,
|
|
||||||
format!("Termux 主源没有 Java {major}(只有 17 / 21 / 25)"),
|
|
||||||
)
|
|
||||||
.with_hint("`pkg search openjdk` 看可用包;其它大版本请用归档发行版"));
|
|
||||||
};
|
|
||||||
let Some(prefix) = host::prefix(&ProcessEnv) else {
|
|
||||||
return Err(
|
|
||||||
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
|
|
||||||
.with_hint("普通 Linux/Windows 用 Temurin / Oracle / MS / Zulu"),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 幂等:已经装了同一大版本就不碰网络。
|
|
||||||
if let Some(home) = installed_home(&prefix, major) {
|
|
||||||
return Ok((home, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace = workspace_dir(&prefix, major);
|
|
||||||
std::fs::create_dir_all(&workspace).map_err(|error| {
|
|
||||||
JavaError::new(
|
|
||||||
JavaErrorKind::Io,
|
|
||||||
format!(
|
|
||||||
"建 Termux 安装临时目录失败({}):{error}",
|
|
||||||
workspace.display()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let package_for_task = package.clone();
|
|
||||||
tokio::task::spawn_blocking(move || run_install(&prefix, &workspace, &package_for_task, major))
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
JavaError::new(
|
|
||||||
JavaErrorKind::Io,
|
|
||||||
format!("Termux 安装任务没能完成:{error}"),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.map(|home| (home, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 逐镜像尝试 `apt-get`(阻塞,跑在 `spawn_blocking` 里)。
|
|
||||||
fn run_install(
|
|
||||||
prefix: &Path,
|
|
||||||
workspace: &Path,
|
|
||||||
package: &str,
|
|
||||||
major: u32,
|
|
||||||
) -> Result<PathBuf, JavaError> {
|
|
||||||
let mut failures: Vec<String> = Vec::new();
|
|
||||||
for mirror in MIRRORS {
|
|
||||||
match apt_install(prefix, workspace, mirror, package) {
|
|
||||||
Ok(()) => {
|
|
||||||
if let Some(home) = installed_home(prefix, major) {
|
|
||||||
// 装完把隔离的 lists/cache/archives 清掉(几十到上百 MB,别留在 $PREFIX/tmp)。
|
|
||||||
let _ = std::fs::remove_dir_all(workspace);
|
|
||||||
return Ok(home);
|
|
||||||
}
|
|
||||||
failures.push(format!("{mirror}:装完找不到 bin/java"));
|
|
||||||
}
|
|
||||||
Err(error) => failures.push(format!("{mirror}:{error}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(JavaError::new(
|
|
||||||
JavaErrorKind::Network,
|
|
||||||
format!(
|
|
||||||
"Termux 包安装失败(试过 {} 个镜像):{}",
|
|
||||||
MIRRORS.len(),
|
|
||||||
failures.join(";")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with_hint("确认 apt 可用、磁盘空间足够,或手动 `pkg install openjdk-<major>`"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 对单个镜像跑 `apt-get update` + `install`(lists/cache 隔离在 workspace 内)。
|
|
||||||
fn apt_install(prefix: &Path, workspace: &Path, mirror: &str, package: &str) -> Result<(), String> {
|
|
||||||
let sources = workspace.join("sources.list");
|
|
||||||
std::fs::write(&sources, sources_line(mirror))
|
|
||||||
.map_err(|error| format!("写 sources.list 失败:{error}"))?;
|
|
||||||
|
|
||||||
let lists = workspace.join("lists");
|
|
||||||
let cache = workspace.join("cache");
|
|
||||||
let archives = workspace.join("archives");
|
|
||||||
for dir in [&lists, &cache, &archives] {
|
|
||||||
std::fs::create_dir_all(dir).map_err(|error| format!("建临时目录失败:{error}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let options = [
|
|
||||||
format!("Dir::Etc::sourcelist={}", sources.display()),
|
|
||||||
"Dir::Etc::sourceparts=-".to_string(),
|
|
||||||
format!("Dir::State::lists={}", lists.display()),
|
|
||||||
format!("Dir::Cache={}", cache.display()),
|
|
||||||
format!("Dir::Cache::archives={}", archives.display()),
|
|
||||||
"Acquire::ForceIPv4=true".to_string(),
|
|
||||||
"Acquire::Retries=2".to_string(),
|
|
||||||
"Acquire::http::Timeout=30".to_string(),
|
|
||||||
"Acquire::https::Timeout=30".to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
run_apt(prefix, &options, &["update"])?;
|
|
||||||
run_apt(
|
|
||||||
prefix,
|
|
||||||
&options,
|
|
||||||
&["install", "-y", "--no-install-suggests", package],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 执行一次 `apt-get`(`-o` 选项在前,子命令在后)。
|
|
||||||
fn run_apt(prefix: &Path, options: &[String], args: &[&str]) -> Result<(), String> {
|
|
||||||
let apt = prefix.join("bin").join("apt-get");
|
|
||||||
let mut command = Command::new(&apt);
|
|
||||||
command.env("DEBIAN_FRONTEND", "noninteractive");
|
|
||||||
for option in options {
|
|
||||||
command.arg("-o").arg(option);
|
|
||||||
}
|
|
||||||
command.args(args);
|
|
||||||
let output = command
|
|
||||||
.output()
|
|
||||||
.map_err(|error| format!("执行 {} 失败:{error}", apt.display()))?;
|
|
||||||
if output.status.success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
let last = stderr
|
|
||||||
.lines()
|
|
||||||
.rev()
|
|
||||||
.find(|line| !line.trim().is_empty())
|
|
||||||
.unwrap_or("(无 stderr 输出)");
|
|
||||||
Err(format!(
|
|
||||||
"apt-get {} 退出 {}:{last}",
|
|
||||||
args.first().copied().unwrap_or("?"),
|
|
||||||
output.status.code().unwrap_or(-1)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 临时工作区(按大版本区分,避免两次安装互相踩 lists)。
|
|
||||||
fn workspace_dir(prefix: &Path, major: u32) -> PathBuf {
|
|
||||||
prefix
|
|
||||||
.join("tmp")
|
|
||||||
.join("ull-java-termux")
|
|
||||||
.join(format!("jdk-{major}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{installed_home, package_for, sources_line, MIRRORS};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_the_majors_that_termux_ships_are_installable() {
|
|
||||||
assert_eq!(package_for(17).as_deref(), Some("openjdk-17"));
|
|
||||||
assert_eq!(package_for(21).as_deref(), Some("openjdk-21"));
|
|
||||||
assert_eq!(package_for(25).as_deref(), Some("openjdk-25"));
|
|
||||||
assert_eq!(package_for(8), None);
|
|
||||||
assert_eq!(package_for(11), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sources_line_has_one_deb_and_keeps_the_mirror_root() {
|
|
||||||
let line = sources_line("https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main/");
|
|
||||||
assert_eq!(
|
|
||||||
line,
|
|
||||||
"deb https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main stable main\n"
|
|
||||||
);
|
|
||||||
assert!(MIRRORS.iter().all(|mirror| mirror.starts_with("https://")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn installed_home_requires_bin_java_and_matching_major() {
|
|
||||||
let base = std::env::temp_dir().join(format!("ull-termux-home-{}", std::process::id()));
|
|
||||||
let good = base.join("lib").join("jvm").join("java-21-openjdk");
|
|
||||||
let empty = base.join("lib").join("jvm").join("java-17-openjdk");
|
|
||||||
std::fs::create_dir_all(good.join("bin")).unwrap();
|
|
||||||
std::fs::write(good.join("bin").join("java"), b"").unwrap();
|
|
||||||
std::fs::create_dir_all(&empty).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(installed_home(&base, 21), Some(good));
|
|
||||||
assert_eq!(installed_home(&base, 17), None, "没有 bin/java 的目录不算");
|
|
||||||
assert_eq!(installed_home(&base, 25), None);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,10 +15,7 @@
|
|||||||
//! `zulu` / `api`(Temurin)四个模块里。
|
//! `zulu` / `api`(Temurin)四个模块里。
|
||||||
|
|
||||||
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。
|
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。
|
||||||
///
|
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
|
||||||
/// `termux` 只在 Termux 宿主上可用(系统包管理器安装),默认序里不出现;
|
|
||||||
/// 显式写进优先级或 `--vendor termux` 才走它。
|
|
||||||
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
|
||||||
|
|
||||||
/// 一家 JDK 发行版。
|
/// 一家 JDK 发行版。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
@@ -31,19 +28,11 @@ pub enum JdkVendor {
|
|||||||
Microsoft,
|
Microsoft,
|
||||||
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
|
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
|
||||||
Zulu,
|
Zulu,
|
||||||
/// Termux 的系统 openjdk(`pkg install openjdk-<major>`;只在 Termux 里可用)。
|
|
||||||
Termux,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JdkVendor {
|
impl JdkVendor {
|
||||||
/// 全部发行版(枚举顺序,不是优先级顺序)。
|
/// 全部发行版(枚举顺序,不是优先级顺序)。
|
||||||
pub const ALL: [Self; 5] = [
|
pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu];
|
||||||
Self::Temurin,
|
|
||||||
Self::Oracle,
|
|
||||||
Self::Microsoft,
|
|
||||||
Self::Zulu,
|
|
||||||
Self::Termux,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
|
/// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -53,7 +42,6 @@ impl JdkVendor {
|
|||||||
Self::Oracle => "oracle",
|
Self::Oracle => "oracle",
|
||||||
Self::Microsoft => "microsoft",
|
Self::Microsoft => "microsoft",
|
||||||
Self::Zulu => "zulu",
|
Self::Zulu => "zulu",
|
||||||
Self::Termux => "termux",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +53,6 @@ impl JdkVendor {
|
|||||||
Self::Oracle => "Oracle OpenJDK",
|
Self::Oracle => "Oracle OpenJDK",
|
||||||
Self::Microsoft => "Microsoft OpenJDK",
|
Self::Microsoft => "Microsoft OpenJDK",
|
||||||
Self::Zulu => "Azul Zulu",
|
Self::Zulu => "Azul Zulu",
|
||||||
Self::Termux => "Termux OpenJDK",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +66,6 @@ impl JdkVendor {
|
|||||||
Some(Self::Microsoft)
|
Some(Self::Microsoft)
|
||||||
}
|
}
|
||||||
"zulu" | "azul" => Some(Self::Zulu),
|
"zulu" | "azul" => Some(Self::Zulu),
|
||||||
"termux" | "termux-openjdk" | "pkg" => Some(Self::Termux),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +80,6 @@ impl JdkVendor {
|
|||||||
Self::Temurin | Self::Zulu => major >= 8,
|
Self::Temurin | Self::Zulu => major >= 8,
|
||||||
Self::Oracle => major >= 9,
|
Self::Oracle => major >= 9,
|
||||||
Self::Microsoft => matches!(major, 11 | 17 | 21 | 25),
|
Self::Microsoft => matches!(major, 11 | 17 | 21 | 25),
|
||||||
Self::Termux => matches!(major, 17 | 21 | 25),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +91,6 @@ impl JdkVendor {
|
|||||||
Self::Oracle => &[17, 21, 25],
|
Self::Oracle => &[17, 21, 25],
|
||||||
Self::Microsoft => &[11, 17, 21, 25],
|
Self::Microsoft => &[11, 17, 21, 25],
|
||||||
Self::Zulu => &[8, 11, 17, 21, 25],
|
Self::Zulu => &[8, 11, 17, 21, 25],
|
||||||
Self::Termux => &[17, 21, 25],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,11 +172,6 @@ impl Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。
|
/// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。
|
||||||
///
|
|
||||||
/// **Linux arm64 特例**:Mojang 的 LWJGL3 条目只写 `natives-linux`(x86_64),
|
|
||||||
/// 但同一个 Maven 坐标有 `natives-linux-arm64`。原样用 `natives-linux` 会下到 x86_64
|
|
||||||
/// 的 `.so`,在 aarch64 JVM 里加载失败;这里改成 arm64 分类器(下载地址的兜底见
|
|
||||||
/// `install::libraries`)。
|
|
||||||
pub fn native_classifier(&self, env: &Environment) -> Option<String> {
|
pub fn native_classifier(&self, env: &Environment) -> Option<String> {
|
||||||
let raw = self.natives.as_ref()?.get(&env.os)?;
|
let raw = self.natives.as_ref()?.get(&env.os)?;
|
||||||
let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") {
|
let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") {
|
||||||
@@ -184,33 +179,7 @@ impl Library {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
let classifier = raw.replace("${arch}", arch);
|
Some(raw.replace("${arch}", arch))
|
||||||
if env.os == "linux"
|
|
||||||
&& env.arch == "arm64"
|
|
||||||
&& classifier == "natives-linux"
|
|
||||||
&& self.is_lwjgl3()
|
|
||||||
{
|
|
||||||
return Some("natives-linux-arm64".to_owned());
|
|
||||||
}
|
|
||||||
Some(classifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 是否是 LWJGL 3(`org.lwjgl:<module>`;LWJGL 2 的 group 是 `org.lwjgl.lwjgl`)。
|
|
||||||
fn is_lwjgl3(&self) -> bool {
|
|
||||||
self.name.starts_with("org.lwjgl:")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 现代清单(1.19+)把 natives 作为独立库条目:Linux arm64 上把
|
|
||||||
/// `org.lwjgl:<module>:<ver>:natives-linux` 改写成 `...:natives-linux-arm64`。
|
|
||||||
///
|
|
||||||
/// 返回 None 表示不适用(非 Linux/arm64、非 LWJGL3、或不是 natives 条目)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn linux_arm64_native_name(&self, env: &Environment) -> Option<String> {
|
|
||||||
if env.os != "linux" || env.arch != "arm64" || !self.is_lwjgl3() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let base = self.name.strip_suffix(":natives-linux")?;
|
|
||||||
Some(format!("{base}:natives-linux-arm64"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// natives 构件下载信息(按分类器查 `downloads.classifiers`)。
|
/// natives 构件下载信息(按分类器查 `downloads.classifiers`)。
|
||||||
|
|||||||
@@ -174,13 +174,6 @@ pub fn environment(req: &LaunchRequest, os: OsFamily) -> Vec<(String, String)> {
|
|||||||
set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe");
|
set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe");
|
||||||
set_env(&mut env, "GALLIUM_DRIVER", "llvmpipe");
|
set_env(&mut env, "GALLIUM_DRIVER", "llvmpipe");
|
||||||
}
|
}
|
||||||
// Termux + glibc 构建:termux-x11 下 glibc Mesa 的 zink 卡 DRI3,softpipe 是当前
|
|
||||||
// 可用路径。放在软件渲染块之后、用户自定义之前(用户显式设置仍可覆盖)。
|
|
||||||
if cfg!(target_os = "linux")
|
|
||||||
&& crate::platform::termux::is_termux(&crate::platform::paths::ProcessEnv)
|
|
||||||
{
|
|
||||||
apply_termux_gl_defaults(&mut env);
|
|
||||||
}
|
|
||||||
if os == OsFamily::MacOs {
|
if os == OsFamily::MacOs {
|
||||||
if let Some(icd) = &req.renderer.vulkan_icd {
|
if let Some(icd) = &req.renderer.vulkan_icd {
|
||||||
set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string());
|
set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string());
|
||||||
@@ -208,43 +201,3 @@ fn set_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
|
|||||||
env.push((key.to_string(), value.to_string()));
|
env.push((key.to_string(), value.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Termux(glibc 构建)上的默认 GL 环境:softpipe。
|
|
||||||
///
|
|
||||||
/// glibc Mesa 26 的 zink 在 termux-x11 下报 `DRI3 not available`,softpipe 是当前
|
|
||||||
/// 可用的软件路径;硬件路径等 DRI3 问题解决后再切。
|
|
||||||
fn apply_termux_gl_defaults(env: &mut Vec<(String, String)>) {
|
|
||||||
set_env(env, "MESA_LOADER_DRIVER_OVERRIDE", "swrast");
|
|
||||||
set_env(env, "LIBGL_ALWAYS_SOFTWARE", "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::apply_termux_gl_defaults;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn termux_gl_defaults_force_softpipe() {
|
|
||||||
let mut env = Vec::new();
|
|
||||||
apply_termux_gl_defaults(&mut env);
|
|
||||||
assert!(env.contains(&(
|
|
||||||
"MESA_LOADER_DRIVER_OVERRIDE".to_string(),
|
|
||||||
"swrast".to_string()
|
|
||||||
)));
|
|
||||||
assert!(env.contains(&("LIBGL_ALWAYS_SOFTWARE".to_string(), "1".to_string())));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn termux_gl_defaults_overwrite_earlier_llvmpipe_choice() {
|
|
||||||
let mut env = vec![(
|
|
||||||
"MESA_LOADER_DRIVER_OVERRIDE".to_string(),
|
|
||||||
"llvmpipe".to_string(),
|
|
||||||
)];
|
|
||||||
apply_termux_gl_defaults(&mut env);
|
|
||||||
assert_eq!(
|
|
||||||
env.iter()
|
|
||||||
.find(|(key, _)| key == "MESA_LOADER_DRIVER_OVERRIDE")
|
|
||||||
.map(|(_, value)| value.as_str()),
|
|
||||||
Some("swrast")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,6 @@ pub mod os_info;
|
|||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod receipt;
|
pub mod receipt;
|
||||||
pub mod redirect;
|
pub mod redirect;
|
||||||
pub mod termux;
|
|
||||||
pub mod theme_probe;
|
pub mod theme_probe;
|
||||||
pub mod vk_probe;
|
pub mod vk_probe;
|
||||||
pub mod wallpaper;
|
pub mod wallpaper;
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
//! Termux(Android 上的用户空间)宿主识别。
|
|
||||||
//!
|
|
||||||
//! Termux 的软件前缀不在 `/usr`,而是 `$PREFIX`(典型值
|
|
||||||
//! `/data/data/com.termux/files/usr`);JDK 由 `pkg install openjdk-17` 落到
|
|
||||||
//! `$PREFIX/lib/jvm/java-17-openjdk`。这里只做**识别与路径推导**,不碰包管理,
|
|
||||||
//! 也不把 `$PREFIX` 当成编译期常量写死。
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use super::paths::EnvSource;
|
|
||||||
|
|
||||||
/// Termux 的默认前缀(`PREFIX` 缺失时的兜底)。
|
|
||||||
pub const DEFAULT_PREFIX: &str = "/data/data/com.termux/files/usr";
|
|
||||||
|
|
||||||
/// 当前环境是否运行在 Termux 里。
|
|
||||||
///
|
|
||||||
/// 判据取 Termux 自己导出的两个变量:`TERMUX_VERSION`(版本)或 `PREFIX`
|
|
||||||
/// 指向默认前缀。两者都没有时按普通 Linux 处理——不猜。
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_termux(env: &dyn EnvSource) -> bool {
|
|
||||||
env.var("TERMUX_VERSION").is_some()
|
|
||||||
|| env
|
|
||||||
.var("PREFIX")
|
|
||||||
.is_some_and(|prefix| prefix.trim_end_matches('/') == DEFAULT_PREFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Termux 的软件前缀(仅当识别为 Termux 时)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn prefix(env: &dyn EnvSource) -> Option<PathBuf> {
|
|
||||||
if !is_termux(env) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(
|
|
||||||
env.var("PREFIX")
|
|
||||||
.map_or_else(|| PathBuf::from(DEFAULT_PREFIX), PathBuf::from),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Termux 的 JDK 安装根(`$PREFIX/lib/jvm`)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn jvm_root(env: &dyn EnvSource) -> Option<PathBuf> {
|
|
||||||
prefix(env).map(|prefix| prefix.join("lib").join("jvm"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{is_termux, jvm_root, prefix, DEFAULT_PREFIX};
|
|
||||||
use crate::platform::paths::MapEnv;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plain_linux_is_not_termux() {
|
|
||||||
let env = MapEnv::new().with("HOME", "/home/u");
|
|
||||||
assert!(!is_termux(&env));
|
|
||||||
assert_eq!(prefix(&env), None);
|
|
||||||
assert_eq!(jvm_root(&env), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_prefix_is_recognised() {
|
|
||||||
let env = MapEnv::new().with("PREFIX", DEFAULT_PREFIX);
|
|
||||||
assert!(is_termux(&env));
|
|
||||||
assert_eq!(
|
|
||||||
jvm_root(&env).unwrap(),
|
|
||||||
std::path::PathBuf::from(DEFAULT_PREFIX).join("lib/jvm")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn version_variable_alone_is_enough() {
|
|
||||||
let env = MapEnv::new()
|
|
||||||
.with("TERMUX_VERSION", "0.118.3")
|
|
||||||
.with("PREFIX", "/data/data/com.termux/files/usr");
|
|
||||||
assert!(is_termux(&env));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn other_prefix_is_not_termux() {
|
|
||||||
// `PREFIX` 被搬到别的目录不在识别范围内——只有版本变量或默认前缀算数。
|
|
||||||
let env = MapEnv::new().with("PREFIX", "/opt/termux");
|
|
||||||
assert!(!is_termux(&env));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,7 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
|||||||
///
|
///
|
||||||
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
|
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
|
||||||
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
||||||
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
|
||||||
|
|
||||||
/// launcher.json 当前 schema 版本。
|
/// launcher.json 当前 schema 版本。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ pub enum UpdateErrorKind {
|
|||||||
SourceMissing,
|
SourceMissing,
|
||||||
/// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。
|
/// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。
|
||||||
UnsupportedSource,
|
UnsupportedSource,
|
||||||
|
/// 远端源取字节失败:连不上、超时、5xx、所有候选源(含镜像)全挂。
|
||||||
|
///
|
||||||
|
/// 与 [`Self::Io`] 分开是因为处置不同:网络失败值得隔一会儿重试/换镜像,
|
||||||
|
/// 本地 IO 失败(盘满、没权限)重试多少次都一样。
|
||||||
|
Network,
|
||||||
/// 本地 IO 失败:读源、写暂存、替换落盘。
|
/// 本地 IO 失败:读源、写暂存、替换落盘。
|
||||||
Io,
|
Io,
|
||||||
/// 摘要不符:拿到的字节与清单声明的不是同一份。
|
/// 摘要不符:拿到的字节与清单声明的不是同一份。
|
||||||
@@ -40,6 +45,7 @@ impl UpdateErrorKind {
|
|||||||
Self::ChannelMismatch => "channel_mismatch",
|
Self::ChannelMismatch => "channel_mismatch",
|
||||||
Self::SourceMissing => "source_missing",
|
Self::SourceMissing => "source_missing",
|
||||||
Self::UnsupportedSource => "unsupported_source",
|
Self::UnsupportedSource => "unsupported_source",
|
||||||
|
Self::Network => "network",
|
||||||
Self::Io => "io",
|
Self::Io => "io",
|
||||||
Self::DigestMismatch => "digest_mismatch",
|
Self::DigestMismatch => "digest_mismatch",
|
||||||
Self::SizeMismatch => "size_mismatch",
|
Self::SizeMismatch => "size_mismatch",
|
||||||
@@ -48,11 +54,14 @@ impl UpdateErrorKind {
|
|||||||
|
|
||||||
/// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。
|
/// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。
|
||||||
///
|
///
|
||||||
/// 清单本身不合法、协议不支持、目标平台无权写盘这三类换多少源都一样,
|
/// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层
|
||||||
/// 所以只有字节层(摘要/长度)与 IO(可能是瞬时占用)算可重试。
|
/// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn is_retryable(self) -> bool {
|
pub const fn is_retryable(self) -> bool {
|
||||||
matches!(self, Self::DigestMismatch | Self::SizeMismatch | Self::Io)
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
//! GitHub Releases 更新源(D50 定案:GitHub Releases 是唯一更新源)。
|
||||||
|
//!
|
||||||
|
//! # 地址形状(固定,不可配置)
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 清单 <base>/<owner>/<repo>/releases/latest/download/manifest.json
|
||||||
|
//! 产物 <base>/<owner>/<repo>/releases/latest/download/<清单里的产物文件名>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `releases/latest/download/<asset>` 是 GitHub 原生的「最新发布」地址:不用知道 tag、
|
||||||
|
//! 不用调 API(API 有速率限制、也要 token),发布侧只要把 `manifest.json` 和各平台产物
|
||||||
|
//! 挂在最新 release 上,客户端就能取到。清单里的 `url` 因此照 D49 的产物命名规则写成
|
||||||
|
//! **文件名**(如 `unlucky-launcher_0.6.0_windows_x64.exe`),由本源拼成上面的地址;
|
||||||
|
//! 清单若已经写了完整 `http(s)` 地址(例如临时挂到别处复测),原样使用。
|
||||||
|
//!
|
||||||
|
//! # 可配置面只有三个
|
||||||
|
//!
|
||||||
|
//! * `owner` / `repo`(默认 [`DEFAULT_OWNER`] / [`DEFAULT_REPO`]);
|
||||||
|
//! * `base`(默认 [`DEFAULT_BASE`];**只为测试存在**——把它指向本地假 HTTP 服务,
|
||||||
|
//! 单测就不必联网)。
|
||||||
|
//!
|
||||||
|
//! # 网络全走 `core::download`
|
||||||
|
//!
|
||||||
|
//! * **产物**(几十 MB 的包体):整段交给 [`DownloadEngine::download`]——镜像展开
|
||||||
|
//! (国内 gh-proxy 前缀)、地区判定、健康度排序、失败退避、断点续传、原子落盘全部复用,
|
||||||
|
//! 本源不再造一套网络栈,也不自己拼镜像地址;
|
||||||
|
//! * **清单**(几 KB 的 JSON):用引擎的候选规划 [`GitHubReleaseSource::candidates`] +
|
||||||
|
//! 共享客户端 [`DownloadEngine::client`] 逐候选取文本,与 `core::game::java` 的
|
||||||
|
//! 「一锤子 GET」同一手法。包体才需要退避/续传/分块,为几 KB 建一条下载流水线不划算。
|
||||||
|
//!
|
||||||
|
//! 两种路径都吃到同一条镜像策略:`mirrors.json` 的前缀规则 + 地区档案里生效的
|
||||||
|
//! `gh_proxy` 前缀([`crate::download::mirrors::MirrorTable::gh_proxy_candidates`]),
|
||||||
|
//! 国内环境下 `https://github.com/...` 会被改写成 `https://gh-proxy.com/https://github.com/...`。
|
||||||
|
//!
|
||||||
|
//! # 渠道
|
||||||
|
//!
|
||||||
|
//! `releases/latest` 只有一个,所以本源**只服务 stable**。beta/alpha/nightly 的清单枚举
|
||||||
|
//! 保留但不发布(D50「先只发 stable」),因此非 stable 渠道一律 `Ok(None)` = 没有更新——
|
||||||
|
//! 这不是错误,别让「还没开通道」变成用户机器上的红字。
|
||||||
|
//!
|
||||||
|
//! # 校验
|
||||||
|
//!
|
||||||
|
//! 本源只管「把字节放到 dest」:长度与摘要由 [`super::verify`] 统一双检(与 [`super::DirSource`]
|
||||||
|
//! 一致)。所以包体请求只带 `expected_size`(给进度预算与门户页假阳性兜底),不带摘要——
|
||||||
|
//! 摘要判据只有一处,别在这里再写一套。
|
||||||
|
//!
|
||||||
|
//! # 运行时
|
||||||
|
//!
|
||||||
|
//! [`UpdateSource`] 是同步签名,本源在方法内部用 [`crate::download::block_on`] 桥接异步引擎
|
||||||
|
//! (CLI 与 presenter 都是这么用的)。因此**不要在已有的 tokio 运行时里直接调本源的
|
||||||
|
//! `fetch_*`**:tokio 不允许嵌套 `block_on`,会 panic。更新流程请放在独立线程上跑。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::download::cache::now_ms;
|
||||||
|
use crate::download::{
|
||||||
|
Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::channel::Channel;
|
||||||
|
use super::error::{UpdateError, UpdateErrorKind};
|
||||||
|
use super::manifest::{Artifact, ReleaseManifest};
|
||||||
|
use super::source::UpdateSource;
|
||||||
|
|
||||||
|
/// 默认仓库 owner。
|
||||||
|
pub const DEFAULT_OWNER: &str = "AskaEth";
|
||||||
|
/// 默认仓库名。
|
||||||
|
pub const DEFAULT_REPO: &str = "Unlucky-Launcher";
|
||||||
|
/// GitHub 站点根地址(`base` 的默认值)。
|
||||||
|
pub const DEFAULT_BASE: &str = "https://github.com";
|
||||||
|
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
|
||||||
|
pub const MANIFEST_ASSET: &str = "manifest.json";
|
||||||
|
|
||||||
|
/// GitHub Releases 更新源。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitHubReleaseSource {
|
||||||
|
engine: Arc<DownloadEngine>,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
base: String,
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitHubReleaseSource {
|
||||||
|
/// 用默认仓库([`DEFAULT_OWNER`] / [`DEFAULT_REPO`])与 GitHub 站点根地址构造。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(engine: Arc<DownloadEngine>) -> Self {
|
||||||
|
Self::with_repo(engine, DEFAULT_OWNER, DEFAULT_REPO)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定仓库构造(fork / 自建镜像仓、测试用)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_repo(
|
||||||
|
engine: Arc<DownloadEngine>,
|
||||||
|
owner: impl Into<String>,
|
||||||
|
repo: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
let owner = owner.into();
|
||||||
|
let repo = repo.into();
|
||||||
|
let id = format!("github:{owner}/{repo}");
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
base: DEFAULT_BASE.to_string(),
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换 `base`(站点根地址,末尾斜杠会去掉)。
|
||||||
|
///
|
||||||
|
/// 生产不用这个口子;单测把它指向本地假服务(`http://127.0.0.1:<port>`)以保持离线。
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_base(mut self, base: impl Into<String>) -> Self {
|
||||||
|
self.base = base.into().trim_end_matches('/').to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仓库 owner。
|
||||||
|
#[must_use]
|
||||||
|
pub fn owner(&self) -> &str {
|
||||||
|
&self.owner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仓库名。
|
||||||
|
#[must_use]
|
||||||
|
pub fn repo(&self) -> &str {
|
||||||
|
&self.repo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 站点根地址。
|
||||||
|
#[must_use]
|
||||||
|
pub fn base(&self) -> &str {
|
||||||
|
&self.base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 引擎(调用方要挂进度事件时用)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn engine(&self) -> &Arc<DownloadEngine> {
|
||||||
|
&self.engine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某渠道的清单地址;非 stable 渠道没有清单(返回 `None`)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn manifest_url(&self, channel: Channel) -> Option<String> {
|
||||||
|
(channel == Channel::Stable).then(|| format!("{}/{MANIFEST_ASSET}", self.download_base()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把清单里的 `url` 解析成真实下载地址。
|
||||||
|
///
|
||||||
|
/// 已经是 `http(s)://` 的地址原样使用(清单可以临时指向别处);其余一律当成
|
||||||
|
/// 「最新 release 里的产物文件名」,拼到本源的下载根路径下。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// `url` 为空是 [`UpdateErrorKind::InvalidManifest`](清单坏,不是源的问题);
|
||||||
|
/// 非 http(s) 的其它协议是 [`UpdateErrorKind::UnsupportedSource`]。
|
||||||
|
pub fn artifact_url(&self, artifact: &Artifact) -> Result<String, UpdateError> {
|
||||||
|
let raw = artifact.url.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(UpdateError::invalid_manifest("产物的 url 为空"));
|
||||||
|
}
|
||||||
|
if raw.starts_with("http://") || raw.starts_with("https://") {
|
||||||
|
return Ok(raw.to_string());
|
||||||
|
}
|
||||||
|
if raw.contains("://") {
|
||||||
|
return Err(UpdateError::new(
|
||||||
|
UpdateErrorKind::UnsupportedSource,
|
||||||
|
format!("GitHub 源只认 http(s) 地址或 release 内的产物文件名:`{raw}`"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// `./name` 与 `/name` 都当成「release 里的文件名」,别把整串拼出个双斜杠路径。
|
||||||
|
let name = raw.trim_start_matches(['.', '/']);
|
||||||
|
Ok(format!("{}/{name}", self.download_base()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某地址在引擎眼里的候选源序列(镜像展开 + 健康度排序,**不发请求**)。
|
||||||
|
///
|
||||||
|
/// 生产调用走引擎自己的规划;这里是给「镜像策略是否生效」这类断言用的观察窗。
|
||||||
|
#[must_use]
|
||||||
|
pub fn candidates(&self, url: &str) -> Vec<Candidate> {
|
||||||
|
// `dest` 只是 `DownloadRequest` 的必填字段,`plan` 不看它。
|
||||||
|
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url, PathBuf::new());
|
||||||
|
self.engine.plan(&request, now_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最新的 release 产物根路径(`<base>/<owner>/<repo>/releases/latest/download`)。
|
||||||
|
fn download_base(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}/{}/releases/latest/download",
|
||||||
|
self.base, self.owner, self.repo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐候选取一段文本:`Ok(None)` = 所有候选都说「这份文件不存在」。
|
||||||
|
fn fetch_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
|
||||||
|
let candidates = self.candidates(url);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Err(UpdateError::new(
|
||||||
|
UpdateErrorKind::Network,
|
||||||
|
format!("没有可用候选源:{url}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut failure: Option<UpdateError> = None;
|
||||||
|
let mut all_missing = true;
|
||||||
|
for candidate in candidates {
|
||||||
|
match self.get_candidate_text(&candidate.url) {
|
||||||
|
Ok(Some(text)) => return Ok(Some(text)),
|
||||||
|
// 某个候选 404 不代表没有:镜像可能比官方慢半拍,继续问下一个。
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => {
|
||||||
|
all_missing = false;
|
||||||
|
failure = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_missing {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Err(failure.unwrap_or_else(|| {
|
||||||
|
UpdateError::new(UpdateErrorKind::Network, format!("取 {url} 失败"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。
|
||||||
|
fn get_candidate_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
|
||||||
|
let client = self.engine.client();
|
||||||
|
crate::download::block_on(async {
|
||||||
|
let response = client.get(url).send().await.map_err(|error| {
|
||||||
|
UpdateError::new(
|
||||||
|
UpdateErrorKind::Network,
|
||||||
|
format!("取更新清单失败:{error}({url})"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(UpdateError::new(
|
||||||
|
UpdateErrorKind::Network,
|
||||||
|
format!("更新清单返回 HTTP {status}({url})"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let text = response.text().await.map_err(|error| {
|
||||||
|
UpdateError::new(
|
||||||
|
UpdateErrorKind::Network,
|
||||||
|
format!("读更新清单响应失败:{error}({url})"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return Err(UpdateError::invalid_manifest(format!(
|
||||||
|
"清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Some(text))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateSource for GitHubReleaseSource {
|
||||||
|
fn source_id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||||
|
let Some(url) = self.manifest_url(channel) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(text) = self.fetch_text(&url)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| {
|
||||||
|
error.message = format!("{}(来源 {url})", error.message);
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
if manifest.channel != channel {
|
||||||
|
return Err(UpdateError::new(
|
||||||
|
UpdateErrorKind::ChannelMismatch,
|
||||||
|
format!(
|
||||||
|
"请求的是 {channel} 渠道,清单 {url} 自报 {}",
|
||||||
|
manifest.channel
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(manifest))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||||
|
let url = self.artifact_url(artifact)?;
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| UpdateError::io_at(parent, &error))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 摘要不在这里传:`update::verify` 统一做长度/摘要双检(trait 契约,别各写一套)。
|
||||||
|
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url.clone(), dest)
|
||||||
|
.with_size(artifact.size);
|
||||||
|
let outcome = crate::download::block_on(self.engine.download(request))
|
||||||
|
.map_err(|error| download_error(&error, &url))?;
|
||||||
|
Ok(outcome.bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把下载引擎的错误翻译成自更新的错误分类(两套分类各有各的职责,别混用)。
|
||||||
|
fn download_error(error: &DownloadError, url: &str) -> UpdateError {
|
||||||
|
let kind = match error.kind {
|
||||||
|
DownloadErrorKind::NotFound => UpdateErrorKind::SourceMissing,
|
||||||
|
DownloadErrorKind::Integrity => UpdateErrorKind::DigestMismatch,
|
||||||
|
DownloadErrorKind::Io => UpdateErrorKind::Io,
|
||||||
|
DownloadErrorKind::InvalidRequest => UpdateErrorKind::UnsupportedSource,
|
||||||
|
DownloadErrorKind::AllSourcesFailed if all_candidates_missing(error) => {
|
||||||
|
UpdateErrorKind::SourceMissing
|
||||||
|
}
|
||||||
|
DownloadErrorKind::ServerError
|
||||||
|
| DownloadErrorKind::Timeout
|
||||||
|
| DownloadErrorKind::Network
|
||||||
|
| DownloadErrorKind::RangeUnsupported
|
||||||
|
| DownloadErrorKind::AllSourcesFailed => UpdateErrorKind::Network,
|
||||||
|
};
|
||||||
|
let mut message = format!("{}({url})", error.message);
|
||||||
|
if !error.suppressed.is_empty() {
|
||||||
|
message.push_str(&format!(";各源:{}", error.suppressed.join(";")));
|
||||||
|
}
|
||||||
|
UpdateError::new(kind, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 所有候选是不是都回「这份文件不存在」。
|
||||||
|
///
|
||||||
|
/// 引擎把逐源失败压成一条 [`DownloadErrorKind::AllSourcesFailed`],原始的 404/410 分类只剩在
|
||||||
|
/// 失败摘要文本里(`download::http::classify_status` 的文案:`HTTP 404`)。不还原这一层,
|
||||||
|
/// 「发布清单里挂了个没上传的产物」会被报成「网络失败 + 可重试」,重试多少次都一样。
|
||||||
|
fn all_candidates_missing(error: &DownloadError) -> bool {
|
||||||
|
!error.suppressed.is_empty()
|
||||||
|
&& error
|
||||||
|
.suppressed
|
||||||
|
.iter()
|
||||||
|
.all(|line| line.contains("HTTP 404") || line.contains("HTTP 410"))
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
//! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。
|
//! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。
|
||||||
//!
|
//!
|
||||||
//! 用户已定的主线是「安装包 + 自动更新通道」,但**更新源还没拍板**。所以这一层只做与源无关
|
//! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在
|
||||||
//! 的部分,源本身是插拔的 [`UpdateSource`]:core 里没有任何 CDN 常量、没有任何厂商名,
|
//! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。
|
||||||
//! 接哪家由上层注入(本地目录源 [`DirSource`] 是内置的对照/测试实现)。
|
//! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入
|
||||||
|
//! (本地目录源 [`DirSource`] 留作对拍基准与内网手工分发)。
|
||||||
//!
|
//!
|
||||||
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
|
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
|
||||||
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
|
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
|
||||||
@@ -23,9 +24,9 @@
|
|||||||
//! ```text
|
//! ```text
|
||||||
//! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入
|
//! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入
|
||||||
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
||||||
//! source UpdateSource trait + 本地目录源 verify 暂存 + 长度/摘要校验
|
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
|
||||||
//! checker 选择哪一份发布、急不急 install 原子替换 + 回滚
|
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
|
||||||
//! error 分类与文案
|
//! install 原子替换 + 回滚 error 分类与文案
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! # 刻意不做的事
|
//! # 刻意不做的事
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod checker;
|
pub mod checker;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod github;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
@@ -53,6 +55,7 @@ pub use crate::platform::os_info::OsFamily;
|
|||||||
pub use self::channel::Channel;
|
pub use self::channel::Channel;
|
||||||
pub use self::checker::{CheckOutcome, UpdateCandidate, UpdateChecker, Urgency};
|
pub use self::checker::{CheckOutcome, UpdateCandidate, UpdateChecker, Urgency};
|
||||||
pub use self::error::{UpdateError, UpdateErrorKind};
|
pub use self::error::{UpdateError, UpdateErrorKind};
|
||||||
|
pub use self::github::GitHubReleaseSource;
|
||||||
pub use self::install::{
|
pub use self::install::{
|
||||||
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
|
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
|
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
|
||||||
//!
|
//!
|
||||||
//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里
|
//! 这一层刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
|
||||||
//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
|
//! 注入。真实源已定(D50):[`super::GitHubReleaseSource`],它在 `github.rs`,网络与镜像策略
|
||||||
//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或
|
//! 全交给 `crate::download`。
|
||||||
//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。
|
|
||||||
//!
|
//!
|
||||||
//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、
|
//! 内置的另一个实现是 [`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发,
|
||||||
//! 以及给将来的 HTTP 实现当对照物。
|
//! 以及给远端实现当对拍基准。
|
||||||
//!
|
//!
|
||||||
//! # 本地源的目录约定
|
//! # 本地源的目录约定
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
//! GitHub Releases 源:固定清单地址、产物 URL 解析、渠道只有 stable、与 `DirSource` 对拍。
|
||||||
|
//!
|
||||||
|
//! 全程离线:假 HTTP 服务起在 `127.0.0.1` 上,`base` 指向它——没有一个用例访问 github.com
|
||||||
|
//! 或 gh-proxy(那部分只能真联网验收,见 mission 报告的「没验的部分」)。
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::download::mirrors::SOURCE_GH_PROXY;
|
||||||
|
use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL};
|
||||||
|
use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET};
|
||||||
|
use crate::update::{
|
||||||
|
Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError,
|
||||||
|
UpdateErrorKind, UpdateSource, Version,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir};
|
||||||
|
|
||||||
|
const PAYLOAD: &[u8] = b"github release payload";
|
||||||
|
|
||||||
|
/// 假服务上清单所在路径(与本源拼出来的地址逐字符对齐)。
|
||||||
|
fn manifest_path() -> String {
|
||||||
|
format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 D49 命名规则的产物文件名。
|
||||||
|
fn asset_name() -> &'static str {
|
||||||
|
"unlucky-launcher_0.6.0_windows_x64.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 假服务上产物所在路径。
|
||||||
|
fn asset_path() -> String {
|
||||||
|
format!(
|
||||||
|
"/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}",
|
||||||
|
asset_name()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一份可用的 stable 清单(产物 url 就是 D49 文件名)。
|
||||||
|
fn stable_manifest_json() -> String {
|
||||||
|
manifest(
|
||||||
|
"stable",
|
||||||
|
&[release(
|
||||||
|
"0.6.0",
|
||||||
|
r#","notes":"修掉启动崩溃""#,
|
||||||
|
&[artifact("win-x64", "portable-exe", asset_name(), PAYLOAD)],
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析上面那份清单(多数字段级断言都用它)。
|
||||||
|
fn parsed_stable() -> ReleaseManifest {
|
||||||
|
ReleaseManifest::from_json(&stable_manifest_json()).expect("清单应可解析")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target() -> Target {
|
||||||
|
Target::parse("win-x64").expect("目标平台写法合法")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_with(engine: Arc<DownloadEngine>, base: &str) -> GitHubReleaseSource {
|
||||||
|
GitHubReleaseSource::new(engine).with_base(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地假服务 + 指向它的源(直连模式:不套 gh-proxy,请求落在 127.0.0.1 上)。
|
||||||
|
fn local_source(tmp: &TempDir, server: &FakeHttp) -> GitHubReleaseSource {
|
||||||
|
source_with(engine_for(tmp, RegionProfile::direct(0)), &server.base())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_url_is_the_fixed_latest_download_path() {
|
||||||
|
let tmp = TempDir::new("gh-url");
|
||||||
|
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
|
||||||
|
assert_eq!(source.owner(), DEFAULT_OWNER);
|
||||||
|
assert_eq!(source.repo(), DEFAULT_REPO);
|
||||||
|
assert_eq!(source.base(), DEFAULT_BASE);
|
||||||
|
let expected = format!(
|
||||||
|
"{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source.manifest_url(Channel::Stable).as_deref(),
|
||||||
|
Some(expected.as_str()),
|
||||||
|
"D50 定死的固定地址,一个字符都不许飘"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source.source_id(),
|
||||||
|
format!("github:{DEFAULT_OWNER}/{DEFAULT_REPO}")
|
||||||
|
);
|
||||||
|
|
||||||
|
// fork / 自建镜像仓:owner/repo 可换,路径形状不变。
|
||||||
|
let fork = GitHubReleaseSource::with_repo(
|
||||||
|
engine_for(&tmp, RegionProfile::direct(0)),
|
||||||
|
"someone",
|
||||||
|
"ull-fork",
|
||||||
|
)
|
||||||
|
.with_base("http://127.0.0.1:1/");
|
||||||
|
assert_eq!(
|
||||||
|
fork.manifest_url(Channel::Stable).as_deref(),
|
||||||
|
Some("http://127.0.0.1:1/someone/ull-fork/releases/latest/download/manifest.json"),
|
||||||
|
"base 末尾斜杠要去掉,别拼出双斜杠"
|
||||||
|
);
|
||||||
|
assert_eq!(fork.source_id(), "github:someone/ull-fork");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channels_other_than_stable_publish_nothing() {
|
||||||
|
let tmp = TempDir::new("gh-channels");
|
||||||
|
let server = FakeHttp::start(vec![(
|
||||||
|
manifest_path().as_str(),
|
||||||
|
FakeResponse::json(&stable_manifest_json()),
|
||||||
|
)]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] {
|
||||||
|
assert!(
|
||||||
|
source.manifest_url(channel).is_none(),
|
||||||
|
"{channel} 不该有清单地址"
|
||||||
|
);
|
||||||
|
let manifest = source.fetch_manifest(channel).expect("没开通道不是错误");
|
||||||
|
assert!(
|
||||||
|
manifest.is_none(),
|
||||||
|
"{channel} 应返回 Ok(None)(不发布即无更新)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
server.hits().is_empty(),
|
||||||
|
"非 stable 渠道一次请求都不该发(实际:{:?})",
|
||||||
|
server.hits()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artifact_url_appends_the_release_download_path() {
|
||||||
|
let tmp = TempDir::new("gh-artifact-url");
|
||||||
|
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
|
||||||
|
let artifact = &parsed_stable().releases[0].artifacts[0];
|
||||||
|
|
||||||
|
let url = source.artifact_url(artifact).expect("应能拼出地址");
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
format!(
|
||||||
|
"{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}",
|
||||||
|
asset_name()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `./name` / `/name` 与裸文件名等价(清单是人手写的)。
|
||||||
|
for written in [format!("./{}", asset_name()), format!("/{}", asset_name())] {
|
||||||
|
let mut tweaked = artifact.clone();
|
||||||
|
tweaked.url = written;
|
||||||
|
assert_eq!(source.artifact_url(&tweaked).expect("应可解析"), url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已经写了完整地址 → 原样使用(清单可以临时指向别处)。
|
||||||
|
let mut absolute = artifact.clone();
|
||||||
|
absolute.url = "https://example.invalid/dist/ull.exe".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
source.artifact_url(&absolute).expect("绝对地址原样"),
|
||||||
|
"https://example.invalid/dist/ull.exe"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 其它协议不认(那是别的源的活)。
|
||||||
|
let mut unsupported = artifact.clone();
|
||||||
|
unsupported.url = "ftp://example.invalid/ull.exe".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
source
|
||||||
|
.artifact_url(&unsupported)
|
||||||
|
.expect_err("ftp 不是本源能取的")
|
||||||
|
.kind,
|
||||||
|
UpdateErrorKind::UnsupportedSource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetches_and_parses_the_manifest_from_the_base() {
|
||||||
|
let tmp = TempDir::new("gh-manifest-ok");
|
||||||
|
let server = FakeHttp::start(vec![(
|
||||||
|
manifest_path().as_str(),
|
||||||
|
FakeResponse::json(&stable_manifest_json()),
|
||||||
|
)]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let manifest = source
|
||||||
|
.fetch_manifest(Channel::Stable)
|
||||||
|
.expect("应取到清单")
|
||||||
|
.expect("清单存在");
|
||||||
|
assert_eq!(manifest.channel, Channel::Stable);
|
||||||
|
assert_eq!(manifest.releases.len(), 1);
|
||||||
|
assert_eq!(manifest.releases[0].version.to_string(), "0.6.0");
|
||||||
|
assert_eq!(server.hits(), vec![manifest_path()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_manifest_in_the_release_is_not_an_error() {
|
||||||
|
// 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。
|
||||||
|
let tmp = TempDir::new("gh-manifest-404");
|
||||||
|
let server = FakeHttp::start(vec![]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let outcome = source
|
||||||
|
.fetch_manifest(Channel::Stable)
|
||||||
|
.expect("404 不是错误");
|
||||||
|
assert!(outcome.is_none(), "取不到清单 = 还没发过版");
|
||||||
|
assert_eq!(
|
||||||
|
server.hits(),
|
||||||
|
vec![manifest_path()],
|
||||||
|
"确实问过一次,不是压根没发请求"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broken_json_is_reported_as_invalid_manifest() {
|
||||||
|
let tmp = TempDir::new("gh-manifest-broken");
|
||||||
|
let server = FakeHttp::start(vec![(
|
||||||
|
manifest_path().as_str(),
|
||||||
|
FakeResponse::json("{ not json"),
|
||||||
|
)]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let error = source
|
||||||
|
.fetch_manifest(Channel::Stable)
|
||||||
|
.expect_err("坏 JSON 必须报错");
|
||||||
|
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||||
|
assert!(
|
||||||
|
error.message.contains(&server.base()),
|
||||||
|
"错误里要带出来源地址,日志才定位得到(实际:{})",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
assert!(!error.is_retryable(), "清单坏重试多少次都一样");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn illegal_manifest_fields_are_reported() {
|
||||||
|
let tmp = TempDir::new("gh-manifest-illegal");
|
||||||
|
// size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。
|
||||||
|
let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#;
|
||||||
|
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let error = source
|
||||||
|
.fetch_manifest(Channel::Stable)
|
||||||
|
.expect_err("size 为 0 必须报错");
|
||||||
|
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_self_reporting_another_channel_is_a_mismatch() {
|
||||||
|
let tmp = TempDir::new("gh-manifest-mismatch");
|
||||||
|
let server = FakeHttp::start(vec![(
|
||||||
|
manifest_path().as_str(),
|
||||||
|
FakeResponse::json(&manifest("beta", &[])),
|
||||||
|
)]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let error = source
|
||||||
|
.fetch_manifest(Channel::Stable)
|
||||||
|
.expect_err("渠道不符必须报错");
|
||||||
|
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_errors_are_network_errors() {
|
||||||
|
let tmp = TempDir::new("gh-manifest-500");
|
||||||
|
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let error = source
|
||||||
|
.fetch_manifest(Channel::Stable)
|
||||||
|
.expect_err("5xx 必须报错");
|
||||||
|
assert_eq!(error.kind, UpdateErrorKind::Network);
|
||||||
|
assert!(error.is_retryable(), "网络抖动值得隔一会儿再试");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downloads_the_artifact_through_the_engine() {
|
||||||
|
let tmp = TempDir::new("gh-artifact-download");
|
||||||
|
let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
let dest = tmp.join("staging/package.exe");
|
||||||
|
|
||||||
|
let written = source
|
||||||
|
.fetch_artifact(&parsed_stable().releases[0].artifacts[0], &dest)
|
||||||
|
.expect("应下载成功");
|
||||||
|
assert_eq!(written, PAYLOAD.len() as u64);
|
||||||
|
assert_eq!(fs::read(&dest).expect("目标文件应存在"), PAYLOAD);
|
||||||
|
assert_eq!(
|
||||||
|
server.hits(),
|
||||||
|
vec![asset_path()],
|
||||||
|
"包体走引擎,请求地址就是解析出来的那条"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_artifact_is_reported_as_source_missing() {
|
||||||
|
let tmp = TempDir::new("gh-artifact-404");
|
||||||
|
let server = FakeHttp::start(vec![]);
|
||||||
|
let source = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let error: UpdateError = source
|
||||||
|
.fetch_artifact(
|
||||||
|
&parsed_stable().releases[0].artifacts[0],
|
||||||
|
&tmp.join("staging/package.exe"),
|
||||||
|
)
|
||||||
|
.expect_err("产物不存在必须报错");
|
||||||
|
assert_eq!(error.kind, UpdateErrorKind::SourceMissing);
|
||||||
|
assert!(!error.is_retryable(), "产物没上传,重试多少次都一样");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_first_region_puts_the_gh_proxy_in_front() {
|
||||||
|
// 镜像策略不新造:国内(镜像优先)环境下 gh-proxy 前缀由引擎的镜像表 + 地区档案决定。
|
||||||
|
let tmp = TempDir::new("gh-mirror");
|
||||||
|
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::mirror_first(0)));
|
||||||
|
let artifact_url = source
|
||||||
|
.artifact_url(&parsed_stable().releases[0].artifacts[0])
|
||||||
|
.expect("应能拼出地址");
|
||||||
|
|
||||||
|
let planned = source.candidates(&artifact_url);
|
||||||
|
assert!(
|
||||||
|
planned.len() >= 2,
|
||||||
|
"镜像优先至少要有镜像与官方两条候选(实际 {planned:?})"
|
||||||
|
);
|
||||||
|
assert_eq!(planned[0].source, SOURCE_GH_PROXY);
|
||||||
|
assert_eq!(
|
||||||
|
planned[0].url,
|
||||||
|
format!("https://gh-proxy.com/{artifact_url}")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
planned
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.source == SOURCE_OFFICIAL && candidate.url == artifact_url),
|
||||||
|
"官方地址仍要留作兜底(实际 {planned:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 直连模式不套前缀:只剩官方一条。
|
||||||
|
let direct = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
|
||||||
|
let planned_direct = direct.candidates(&artifact_url);
|
||||||
|
assert_eq!(planned_direct.len(), 1);
|
||||||
|
assert_eq!(planned_direct[0].source, SOURCE_OFFICIAL);
|
||||||
|
|
||||||
|
// 清单地址同样吃到 gh-proxy(国内拿不到 releases/latest/download 的正是清单这一刻)。
|
||||||
|
let manifest_url = source.manifest_url(Channel::Stable).expect("stable 有清单");
|
||||||
|
let planned_manifest = source.candidates(&manifest_url);
|
||||||
|
assert_eq!(planned_manifest[0].source, SOURCE_GH_PROXY);
|
||||||
|
assert_eq!(
|
||||||
|
planned_manifest[0].url,
|
||||||
|
format!("https://gh-proxy.com/{manifest_url}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrees_with_dir_source_on_the_same_manifest() {
|
||||||
|
// 对拍:同一份清单、同一个当前版本、同一个目标平台,两个源的判定必须一模一样。
|
||||||
|
let tmp = TempDir::new("gh-dir-parity");
|
||||||
|
let json = stable_manifest_json();
|
||||||
|
|
||||||
|
// 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。
|
||||||
|
tmp.write(
|
||||||
|
&format!("{}.json", Channel::Stable.as_str()),
|
||||||
|
json.as_bytes(),
|
||||||
|
);
|
||||||
|
tmp.write(asset_name(), PAYLOAD);
|
||||||
|
let dir = DirSource::new(tmp.path());
|
||||||
|
|
||||||
|
// GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。
|
||||||
|
let server = FakeHttp::start(vec![
|
||||||
|
(manifest_path().as_str(), FakeResponse::json(&json)),
|
||||||
|
(asset_path().as_str(), FakeResponse::bin(PAYLOAD)),
|
||||||
|
]);
|
||||||
|
let github = local_source(&tmp, &server);
|
||||||
|
|
||||||
|
let current = Version::parse("0.5.0").expect("版本号合法");
|
||||||
|
let from_dir = UpdateChecker::new(dir, target())
|
||||||
|
.check(¤t, Channel::Stable)
|
||||||
|
.expect("本地源应能判定");
|
||||||
|
let from_github = UpdateChecker::new(github, target())
|
||||||
|
.check(¤t, Channel::Stable)
|
||||||
|
.expect("GitHub 源应能判定");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
from_dir.is_available(),
|
||||||
|
"基准用例应当有可用更新(实际 {from_dir:?})"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
from_github, from_dir,
|
||||||
|
"同一份清单,两个源选出的版本/产物/紧急度必须一致"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@
|
|||||||
//! * 渠道准入:阶梯比较 + 预发布阶段表;
|
//! * 渠道准入:阶梯比较 + 预发布阶段表;
|
||||||
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
||||||
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
||||||
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝。
|
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
|
||||||
|
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
|
||||||
//!
|
//!
|
||||||
//! 全程离线:所有字节都在临时目录里造,没有一个用例发网络请求。
|
//! 全程离线:所有字节都在临时目录或 127.0.0.1 的假 HTTP 服务上造,没有一个用例发公网请求。
|
||||||
|
|
||||||
mod channel_cases;
|
mod channel_cases;
|
||||||
mod checker_cases;
|
mod checker_cases;
|
||||||
|
mod github_cases;
|
||||||
mod install_cases;
|
mod install_cases;
|
||||||
mod manifest_cases;
|
mod manifest_cases;
|
||||||
mod source_cases;
|
mod source_cases;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use crate::download::verify::digest_bytes;
|
use crate::download::verify::digest_bytes;
|
||||||
use crate::download::HashAlgo;
|
use crate::download::{
|
||||||
|
ContentCache, DownloadEngine, EngineConfig, HashAlgo, HealthRegistry, MirrorTable,
|
||||||
|
RegionProfile,
|
||||||
|
};
|
||||||
|
|
||||||
/// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。
|
/// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。
|
||||||
pub(crate) struct TempDir {
|
pub(crate) struct TempDir {
|
||||||
@@ -80,3 +83,185 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String {
|
|||||||
releases.join(",")
|
releases.join(",")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。
|
||||||
|
///
|
||||||
|
/// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。
|
||||||
|
pub(crate) fn engine_for(tmp: &TempDir, region: RegionProfile) -> std::sync::Arc<DownloadEngine> {
|
||||||
|
std::sync::Arc::new(DownloadEngine::new(
|
||||||
|
EngineConfig {
|
||||||
|
// 失败用例不必等三次退避。
|
||||||
|
max_attempts_per_source: 1,
|
||||||
|
..EngineConfig::default()
|
||||||
|
},
|
||||||
|
std::sync::Arc::new(MirrorTable::builtin()),
|
||||||
|
std::sync::Arc::new(region),
|
||||||
|
std::sync::Arc::new(HealthRegistry::in_memory()),
|
||||||
|
std::sync::Arc::new(ContentCache::open(tmp.join("cache"))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 假服务的一条路由应答。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct FakeResponse {
|
||||||
|
pub(crate) status: u16,
|
||||||
|
pub(crate) content_type: &'static str,
|
||||||
|
pub(crate) body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeResponse {
|
||||||
|
/// 200 + JSON 体。
|
||||||
|
pub(crate) fn json(body: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: 200,
|
||||||
|
content_type: "application/json",
|
||||||
|
body: body.as_bytes().to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 200 + 二进制体(模拟 release 里的产物)。
|
||||||
|
pub(crate) fn bin(body: &[u8]) -> Self {
|
||||||
|
Self {
|
||||||
|
status: 200,
|
||||||
|
content_type: "application/octet-stream",
|
||||||
|
body: body.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 固定状态码 + 空体(404 / 500 用)。
|
||||||
|
pub(crate) fn status(status: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
content_type: "text/plain",
|
||||||
|
body: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进程内极简 HTTP/1.1 假服务:按**完整请求路径**查表应答,未命中的路径一律 404。
|
||||||
|
///
|
||||||
|
/// 用它而不是 httpmock:项目里下载测试就是这么干的(零额外依赖),而且能把
|
||||||
|
/// 「请求真的发到了哪个 URL」记下来给断言用。
|
||||||
|
pub(crate) struct FakeHttp {
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
hits: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
||||||
|
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
handle: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeHttp {
|
||||||
|
/// 起服务(`routes` 是「路径 → 应答」表)。
|
||||||
|
pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self {
|
||||||
|
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口");
|
||||||
|
let addr = listener.local_addr().expect("取本地地址");
|
||||||
|
let routes: Vec<(String, FakeResponse)> = routes
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, response)| (path.to_string(), response))
|
||||||
|
.collect();
|
||||||
|
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
|
||||||
|
let worker_hits = std::sync::Arc::clone(&hits);
|
||||||
|
let worker_stop = std::sync::Arc::clone(&stop);
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
if worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Ok(mut stream) = stream else { break };
|
||||||
|
let Some(path) = read_request_path(&mut stream) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
worker_hits
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.push(path.clone());
|
||||||
|
let response = routes.iter().find(|(route, _)| route == &path).map_or_else(
|
||||||
|
|| FakeResponse::status(404),
|
||||||
|
|(_, response)| response.clone(),
|
||||||
|
);
|
||||||
|
let _ = write_response(&mut stream, &response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
addr,
|
||||||
|
hits,
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 站点根地址(可直接喂给 `GitHubReleaseSource::with_base`)。
|
||||||
|
pub(crate) fn base(&self) -> String {
|
||||||
|
format!("http://{}", self.addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已经收到的请求路径(按到达顺序)。
|
||||||
|
pub(crate) fn hits(&self) -> Vec<String> {
|
||||||
|
self.hits
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FakeHttp {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
// 自己连一下唤醒阻塞中的 accept,否则 join 会一直等。
|
||||||
|
let _ = std::net::TcpStream::connect(self.addr);
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读一个请求的路径(只读头部,够用)。
|
||||||
|
fn read_request_path(stream: &mut std::net::TcpStream) -> Option<String> {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut chunk).ok()?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer.extend_from_slice(&chunk[..read]);
|
||||||
|
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let head = String::from_utf8_lossy(&buffer).to_string();
|
||||||
|
head.lines()
|
||||||
|
.next()?
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_response(
|
||||||
|
stream: &mut std::net::TcpStream,
|
||||||
|
response: &FakeResponse,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
response.status,
|
||||||
|
reason(response.status),
|
||||||
|
response.content_type,
|
||||||
|
response.body.len()
|
||||||
|
);
|
||||||
|
stream.write_all(head.as_bytes())?;
|
||||||
|
stream.write_all(&response.body)?;
|
||||||
|
stream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason(status: u16) -> &'static str {
|
||||||
|
match status {
|
||||||
|
200 => "OK",
|
||||||
|
404 => "Not Found",
|
||||||
|
500 => "Internal Server Error",
|
||||||
|
_ => "Status",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,10 +175,7 @@ fn sha256_texts_are_parsed_the_same_way_for_every_upstream() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vendor_ids_are_the_setting_whitelist() {
|
fn vendor_ids_are_the_setting_whitelist() {
|
||||||
assert_eq!(
|
assert_eq!(VENDOR_IDS, &["temurin", "oracle", "microsoft", "zulu"]);
|
||||||
VENDOR_IDS,
|
|
||||||
&["temurin", "oracle", "microsoft", "zulu", "termux"]
|
|
||||||
);
|
|
||||||
for id in VENDOR_IDS {
|
for id in VENDOR_IDS {
|
||||||
assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id));
|
assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ publish.workspace = true
|
|||||||
# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。
|
# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。
|
||||||
# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。
|
# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。
|
||||||
[dependencies]
|
[dependencies]
|
||||||
unlucky-design = { path = "../design", version = "0.2.1" }
|
unlucky-design = { path = "../design", version = "0.2.2" }
|
||||||
|
|
||||||
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
|
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
|
||||||
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
|
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ serde_json.workspace = true
|
|||||||
|
|
||||||
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
|
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
|
||||||
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
|
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
|
||||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
unlucky-core = { path = "../core", version = "0.2.2" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -80,10 +80,6 @@ fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) ->
|
|||||||
.zulu()
|
.zulu()
|
||||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
JdkVendor::Termux => unlucky_core::game::java::termux::MIRRORS
|
|
||||||
.iter()
|
|
||||||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,38 +134,18 @@ pub fn build_request(
|
|||||||
|
|
||||||
let mut libraries: Vec<LibraryInput> = Vec::new();
|
let mut libraries: Vec<LibraryInput> = Vec::new();
|
||||||
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
||||||
if !library.applies(&environment) {
|
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
|
let Ok(coordinate) = library.coordinate() else {
|
||||||
// ≤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;
|
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() {
|
let path = if library.is_local() {
|
||||||
env.game.version_library(&version_id, &coordinate)
|
env.game.version_library(&version_id, &coordinate)
|
||||||
} else {
|
} else {
|
||||||
env.game.library(&coordinate)
|
env.game.library(&coordinate)
|
||||||
};
|
};
|
||||||
libraries.push(LibraryInput::new(path, name));
|
libraries.push(LibraryInput::new(path, library.name.clone()));
|
||||||
}
|
}
|
||||||
let natives = unlucky_core::launch::natives::collect(
|
let natives = unlucky_core::launch::natives::collect(
|
||||||
&env.game,
|
&env.game,
|
||||||
|
|||||||
+28
-2
@@ -55,7 +55,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
|||||||
|
|
||||||
| 文件 | 作用 | 行数上限 |
|
| 文件 | 作用 | 行数上限 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| `packaging/README.md` | 本文:索引 + §11 / §12(契约入口;§1–§10 见 `docs/`) | ≤400 |
|
| `packaging/README.md` | 本文:索引 + §11 / §12 / §13(契约入口;§1–§10 见 `docs/`) | ≤400 |
|
||||||
| `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 |
|
| `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 |
|
||||||
| `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 |
|
| `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 |
|
||||||
| `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |
|
| `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |
|
||||||
@@ -69,4 +69,30 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
|||||||
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
||||||
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
||||||
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
|
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
|
||||||
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / Release) | ≤400 |
|
| `packaging/make_manifest.py` | 更新清单 `manifest.json` 的生成与自验(D50,见 §13) | ≤400 |
|
||||||
|
| `packaging/tests/manifest.golden.json` | 清单 golden 样例:`make_manifest.py --self-test` 逐字节对拍 | ≤400 |
|
||||||
|
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / 清单 / Release) | ≤400 |
|
||||||
|
|
||||||
|
## 13. 更新清单 `manifest.json`(D50)
|
||||||
|
|
||||||
|
清单作为**固定文件名**的 Release 资产挂着;客户端按 `releases/latest/download/manifest.json` 取(D50)。
|
||||||
|
字段形状的唯一权威是 `crates/core/src/update/manifest.rs` 的 `ReleaseManifest`(`deny_unknown_fields` +
|
||||||
|
逐字段校验:semver、`size > 0`、摘要长度/字符集、`target`/`format` 取值表)——生成器只负责把那套形状填对。
|
||||||
|
|
||||||
|
- **生成器**:`packaging/make_manifest.py`(零第三方依赖)。CI 在 `release-publish.yml` 里**重算
|
||||||
|
`SHA256SUMS.txt` 之后**调它,所以清单描述的是「真正要发的资产」,不是「构建出来的全部」。
|
||||||
|
- **同源,不双写**:每个 `sha256` 直接读 `SHA256SUMS.txt`,`size` 取自同一批文件的 `stat()`;产物名里的版本段
|
||||||
|
必须等于 `--version`(tag 与 `Cargo.toml` 漂了就没有产物可对,当场失败)。`--self-test` 用
|
||||||
|
`packaging/tests/manifest.golden.json` 逐字节对拍,并跑 14 条负例证明严格解析不是摆设。
|
||||||
|
- **只放启动器产物**:`ull` 不进清单。DTO 的 `Artifact` 没有角色字段,`Release::artifact_for()` 取「第一个
|
||||||
|
target 命中的产物」,把 CLI 并列写进去会让「该覆盖哪个文件」取决于数组顺序(`ull` 照旧进 `SHA256SUMS.txt`)。
|
||||||
|
- **`url` 两种形态**:默认裸文件名(相对源根,`DirSource` 口径,本地/内网用);CI 传 `--asset-base-url`
|
||||||
|
拼绝对地址,且**钉 tag 不钉 latest**——清单里每条都指向与它自报版本相符的字节。
|
||||||
|
- `min_supported` / `notes` 只是**可填而不默认填**:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||||
|
后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||||
|
- 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。
|
||||||
|
|
||||||
|
**D51 定案(2026-11-06,方案甲)**:GitHub 的 `releases/latest/download/*` 只指向**非 prerelease** 的最新
|
||||||
|
Release。稳定版发布时显式 dispatch `prerelease=false` 让 latest 生效(客户端地址不变);push tag 默认仍
|
||||||
|
`prerelease=true`,视为预发布观察、不进稳定通道。发版 checklist 第六件 = 确认 prerelease 标记与通道一致;
|
||||||
|
v0.2.2 起首个适用。
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
|
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
|
||||||
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
|
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
|
||||||
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
||||||
|
| 全平台 | 仅已发布资产 | `manifest.json` | **更新清单**(D50,见 `README.md` §13) | 渠道 + 版本 + 各平台产物(`target`/`format`/`url`/`size`/`sha256`);生成器 `make_manifest.py` |
|
||||||
|
|
||||||
约定细节:
|
约定细节:
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
|
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
|
||||||
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的同一套命令(差异见 §4 与 §7.4),两者不是重复品。
|
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的同一套命令(差异见 §4 与 §7.4),两者不是重复品。
|
||||||
M118:GUI 的**构建产物**名是 `unlucky-launcher-iced`(`crates/app` 已被 workspace `exclude`,不再产出 `unlucky-launcher`);对外文件名仍是 `unlucky-launcher`,改名在 `make_artifacts.py` 落盘时(`LAUNCHER_BIN` 找产物 / `LAUNCHER_ASSET` 定对外名),所以下载链接 / `SHA256SUMS` / 本文矩阵不变。
|
M118:GUI 的**构建产物**名是 `unlucky-launcher-iced`(`crates/app` 已被 workspace `exclude`,不再产出 `unlucky-launcher`);对外文件名仍是 `unlucky-launcher`,改名在 `make_artifacts.py` 落盘时(`LAUNCHER_BIN` 找产物 / `LAUNCHER_ASSET` 定对外名),所以下载链接 / `SHA256SUMS` / 本文矩阵不变。
|
||||||
- `<ver>` 不带前导 `v`(tag 是 `v0.2.1`,文件名是 `0.2.1`)——由脚本统一裁剪。
|
- `<ver>` 不带前导 `v`(tag 是 `v0.2.2`,文件名是 `0.2.2`)——由脚本统一裁剪。
|
||||||
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
|
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
|
||||||
同源构建的 sha256 不受打包时刻影响。
|
同源构建的 sha256 不受打包时刻影响。
|
||||||
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
|
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
|
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
|
||||||
任何一个 workspace member 版本不一致时直接失败。
|
任何一个 workspace member 版本不一致时直接失败。
|
||||||
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
|
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
|
||||||
**不一致即失败**——避免出现 tag 说 0.2.1、产物里写 0.2.0 的发布事故。
|
**不一致即失败**——避免出现 tag 说 0.2.2、产物里写 0.2.1 的发布事故。
|
||||||
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
|
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
|
||||||
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
|
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
|
||||||
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
|
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
|
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
|
||||||
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
|
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
|
||||||
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
|
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
|
||||||
python packaging/make_artifacts.py --version v0.2.1 # 顺带校验版本一致性
|
python packaging/make_artifacts.py --version v0.2.2 # 顺带校验版本一致性
|
||||||
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
|
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
|||||||
|
|
||||||
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
|
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
|
||||||
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
|
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
|
||||||
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.1`。
|
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.2`。
|
||||||
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
|
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
|
||||||
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
|
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
|
||||||
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
|
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
|
||||||
@@ -66,7 +66,7 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
|||||||
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
|
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
|
||||||
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
|
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
|
||||||
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
|
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
|
||||||
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.1 -f prerelease=false`
|
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.2 -f prerelease=false`
|
||||||
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
|
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
|
||||||
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
|
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
|
||||||
- **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
|
- **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
|
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
|
||||||
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
|
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
|
||||||
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
|
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
|
||||||
| Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机 CPU 渲染下验证(`packaging/docs/termux-glibc-build.md`),不在本发布矩阵内 |
|
| Android Termux + X11 | — | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
|
||||||
|
|
||||||
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
|
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
|
||||||
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
|
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。
|
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。
|
||||||
|
|
||||||
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.1_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
|
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.2_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
|
||||||
|
|
||||||
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
|
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
|
||||||
|
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
# Termux + termux-x11 上的 glibc 构建(试验级)
|
|
||||||
|
|
||||||
本页记录**在 Termux 里构建 glibc 版 GUI,并在 termux-x11 里跑起来**的可复现步骤。
|
|
||||||
2026-11-06 在一台 OnePlus 15(Android 17、Termux、KirinSU/root 环境)上实测通过:
|
|
||||||
`--software-renderer --screenshot` 出 1180×760 PNG,中文正常,`msa client_id=configured source=build`。
|
|
||||||
|
|
||||||
**定位**:试验级、不承诺。发布矩阵里没有面向 Termux 的资产;本文只描述源码自建路径。
|
|
||||||
GUI 只在上述设备 + CPU 渲染(tiny-skia)上验证过,GPU 路径见 §5(尚未在 glibc 侧验证)。
|
|
||||||
|
|
||||||
## 1. 为什么不能直接 `cargo build`
|
|
||||||
|
|
||||||
Termux 的 `rustc` host 是 `aarch64-linux-android`(`target_os = "android"`):
|
|
||||||
|
|
||||||
- `crates/app-iced/Cargo.toml` 的 `x11` / `wayland` feature 只挂在 `cfg(target_os = "linux")` 下;
|
|
||||||
- iced 的窗口层 winit 0.30 在 android target 上走 Android(NativeActivity)后端,
|
|
||||||
会拉 `android-activity` 并要求 `game-activity`/`native-activity` feature,普通进程编不过也跑不了。
|
|
||||||
|
|
||||||
所以要在 Termux 里产出一个 **`aarch64-unknown-linux-gnu`(glibc)** 二进制,靠 Termux 的
|
|
||||||
[glibc 运行时](https://packages.termux.dev/apt/termux-glibc/) 原生执行。
|
|
||||||
|
|
||||||
## 2. 工具链(一次性)
|
|
||||||
|
|
||||||
以下路径用变量表示,实际可放在任意本地目录(示例放在仓库内的 `.gnu/`,已 gitignore):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PREFIX="${PREFIX:-/data/data/com.termux/files/usr}"
|
|
||||||
G="$PWD/.gnu"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.1 glibc 运行时
|
|
||||||
|
|
||||||
确认 `$PREFIX/glibc/lib/ld-linux-aarch64.so.1` 存在(`pkg install glibc-runner` 或装过 glibc 仓库
|
|
||||||
的包即可)。**不需要 `gcc-glibc`**:链接用 Termux 自带的 clang + lld,glibc 的 CRT/头文件由
|
|
||||||
`glibc` 包提供。
|
|
||||||
|
|
||||||
### 2.2 Rust gnu 工具链
|
|
||||||
|
|
||||||
下载 Rust 1.99.1(或与 `rust-version` 兼容的 stable)的 `rustc` / `rust-std` / `cargo`,
|
|
||||||
以及跑门禁需要的 `clippy` / `rustfmt` 五个 `aarch64-unknown-linux-gnu` 组件。官方源在国内很慢,
|
|
||||||
用 USTC 镜像:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mkdir -p "$TMPDIR/ull-gnu" && cd "$TMPDIR/ull-gnu"
|
|
||||||
for c in rustc rust-std cargo clippy rustfmt; do
|
|
||||||
curl -fLO "https://mirrors.ustc.edu.cn/rust-static/dist/$c-1.99.1-aarch64-unknown-linux-gnu.tar.xz"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
解包后按组件布局合并到 `$G/toolchain`(`rustc/bin`、`cargo/bin` → `toolchain/bin`,
|
|
||||||
两者的 `lib/` → `toolchain/lib`,`rust-std-*/lib/rustlib/aarch64-unknown-linux-gnu` →
|
|
||||||
`toolchain/lib/rustlib/`)。
|
|
||||||
|
|
||||||
### 2.3 改 interpreter / rpath
|
|
||||||
|
|
||||||
工具链里的 `rustc`、`cargo`、`rustdoc` 是 glibc ELF,默认 interpreter 是 `/lib/ld-linux-*.so.1`,
|
|
||||||
在 Termux 上不存在:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
LD="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
|
|
||||||
for f in rustc cargo rustdoc clippy-driver cargo-clippy rustfmt cargo-fmt; do
|
|
||||||
patchelf --set-interpreter "$LD" --set-rpath "$PREFIX/glibc/lib:$G/toolchain/lib" \
|
|
||||||
"$G/toolchain/bin/$f"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
> **坑(实测)**:`cargo clippy` 会在 PATH 里找 `cargo-clippy`。如果只装了 Termux 的 Rust,
|
|
||||||
> 找到的是 android 版 `cargo-clippy`,`aws-lc-sys` 等构建脚本会被当成
|
|
||||||
> `--target=aarch64-linux-android` 编译并失败。把 gnu 工具链的 `bin` 放在 PATH 最前面即可;
|
|
||||||
> `rustc`/`rustfmt` 同理。
|
|
||||||
|
|
||||||
### 2.4 gcc 运行时片段
|
|
||||||
|
|
||||||
`clang -fuse-ld=lld` 仍然需要 `crtbeginS.o` / `crtendS.o` / `libgcc.a`。从 Debian 的
|
|
||||||
`libgcc-14-dev`(arm64)取即可(只解包,不安装):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -fLO https://deb.debian.org/debian/pool/main/g/gcc-14/libgcc-14-dev_14.2.0-19_arm64.deb
|
|
||||||
dpkg-deb -x libgcc-14-dev_14.2.0-19_arm64.deb "$G/gnu-support"
|
|
||||||
# crt/libgcc 在 $G/gnu-support/usr/lib/gcc/aarch64-linux-gnu/14/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 linker 驱动
|
|
||||||
|
|
||||||
写一个 `$G/bin/cc-glibc`(`chmod +x`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/data/data/com.termux/files/usr/bin/sh
|
|
||||||
unset LD_PRELOAD
|
|
||||||
P=$PREFIX
|
|
||||||
S="<仓库>/.gnu/gnu-support/usr/lib/gcc/aarch64-linux-gnu/14"
|
|
||||||
C="clang --target=aarch64-unknown-linux-gnu --sysroot=/ -isystem $P/glibc/include \
|
|
||||||
-B$P/glibc/lib -B$S -L$S -L$P/glibc/lib -fuse-ld=lld"
|
|
||||||
for a in "$@"; do
|
|
||||||
case "$a" in -c|-E|-S) exec $C "$@" ;; esac
|
|
||||||
done
|
|
||||||
exec $C -Wl,--dynamic-linker=$P/glibc/lib/ld-linux-aarch64.so.1 -Wl,-rpath,$P/glibc/lib "$@"
|
|
||||||
```
|
|
||||||
|
|
||||||
要点:`--sysroot=/` 而不是 `--sysroot=$PREFIX/glibc`——glibc 的 `libc.so` 是含绝对路径的
|
|
||||||
linker script,带 sysroot 会被二次拼接;`-B $P/glibc/lib` 让 clang 找到 `Scrt1.o`/`crti.o`/`crtn.o`。
|
|
||||||
|
|
||||||
## 3. 构建
|
|
||||||
|
|
||||||
```sh
|
|
||||||
unset LD_PRELOAD # 必须:Termux 的 libtermux-exec 会被 glibc loader 当成坏 ELF
|
|
||||||
G="$PWD/.gnu"
|
|
||||||
export PATH="$G/toolchain/bin:$PATH"
|
|
||||||
export CARGO_TARGET_DIR="$G/target"
|
|
||||||
export RUSTFLAGS="-C linker=$G/bin/cc-glibc"
|
|
||||||
export CC="$G/bin/cc-glibc" CXX="$G/bin/cc-glibc" AR="$PREFIX/glibc/bin/ar"
|
|
||||||
export CARGO_HTTP_CAINFO="$PREFIX/etc/tls/cert.pem" # glibc 版 cargo 不认 Android 的 CA 路径
|
|
||||||
export ULL_MSA_CLIENT_ID=<id> # 可选;烘微软登录 id,值不进 git
|
|
||||||
|
|
||||||
# crates 走 USTC 稀疏索引(官方源在部分网络下只有 ~20 KB/s)
|
|
||||||
cargo --config 'source.crates-io.replace-with="ustc"' \
|
|
||||||
--config 'source.ustc.registry="sparse+https://mirrors.ustc.edu.cn/crates.io-index/"' \
|
|
||||||
build --release -p unlucky-app-iced --no-default-features
|
|
||||||
```
|
|
||||||
|
|
||||||
`--no-default-features` 关掉 `gpu`,只编 tiny-skia(CPU)。产物:`.gnu/target/release/unlucky-launcher-iced`。
|
|
||||||
|
|
||||||
门禁(与 CI 同一组命令,环境同上):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo fmt --all --check
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo test --workspace --all-targets --no-fail-fast
|
|
||||||
```
|
|
||||||
|
|
||||||
Termux 上有三条**环境差异**失败(不是回归):`launch` 测试读
|
|
||||||
`/proc/<pid>/task/<pid>/children`(Android 内核没有)、natives 事件的长路径被脱敏成
|
|
||||||
`<opaque>`、`settings_bridge` 的 mtime 热重载偶发(flaky)。PC 的 Linux CI 不受影响。
|
|
||||||
|
|
||||||
## 4. 运行(termux-x11)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 一次性:glibc 版 X 运行库(cf 源慢时可把 sources.list 里的 packages-cf 换成 packages.termux.dev)
|
|
||||||
pkg install libx11-glibc libxkbcommon-glibc fontconfig-glibc \
|
|
||||||
libxcursor-glibc libxrandr-glibc libxi-glibc libxext-glibc \
|
|
||||||
libxcb-glibc libwayland-glibc
|
|
||||||
|
|
||||||
termux-x11 :0 -ac & # 配合已安装的 termux-x11 App 使用
|
|
||||||
DISPLAY=:0 .gnu/target/release/unlucky-launcher-iced --software-renderer
|
|
||||||
```
|
|
||||||
|
|
||||||
无头取证(CI 同款通道):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
DISPLAY=:0 .gnu/target/release/unlucky-launcher-iced \
|
|
||||||
--software-renderer --screenshot /tmp/ull-termux.png --data-dir /tmp/ull-termux-data
|
|
||||||
```
|
|
||||||
|
|
||||||
- 中文:GUI 在 Termux 上会把 `/system/fonts/NotoSansCJK-Regular.ttc` 注册进 cosmic-text
|
|
||||||
(fontdb 不扫 Android 的字体目录),无需额外环境变量。
|
|
||||||
- 启动日志里 `[auth] msa client_id=configured source=build` 表示构建期 id 已烘入。
|
|
||||||
- **横屏优先**:Termux 上默认窗口尺寸会按当前 X 屏幕等比自适应(日志
|
|
||||||
`[window] ... source=termux-screen`),横屏铺满、竖屏也不溢出;验收尺寸 1180×760 仍是
|
|
||||||
默认基准,`ULL_WINDOW_SIZE=1520x900` 这类覆盖仍然优先。
|
|
||||||
|
|
||||||
### 4.1 Termux 上的 Java
|
|
||||||
|
|
||||||
`ull java install <major>` 在 Termux 上**默认先用系统包**(`openjdk-17/21/25`):
|
|
||||||
|
|
||||||
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序尝试;
|
|
||||||
- `apt-get` 的 lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不改用户的 apt 配置;
|
|
||||||
装完自动清理该目录(一次安装约 100 MB 临时占用);
|
|
||||||
- 装完从 `$PREFIX/lib/jvm/java-<major>-openjdk` 发现,`ull java list` 直接可见;
|
|
||||||
- `--vendor termux` 只走系统包;`temurin|oracle|microsoft|zulu` 仍走归档下载链。
|
|
||||||
- 已实测:`ull java install 17` 从 SAU 镜像装完 `openjdk-17`(含 `openjdk-17-x`),
|
|
||||||
约 20 秒;`ull java list` 随后列出 `[system]` 的 17。
|
|
||||||
|
|
||||||
### 4.2 跑 MC 需要的运行时(已手动验证)
|
|
||||||
|
|
||||||
bionic 的 Termux JVM 加载不了 MC 的 glibc natives,所以 MC 要用 **glibc JVM**:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Temurin 21 JRE(linux-aarch64),用 glibc loader 直接跑
|
|
||||||
curl -L -o jre21.tar.gz \
|
|
||||||
https://api.adoptium.net/v3/binary/latest/21/ga/linux/aarch64/jre/hotspot/normal/eclipse
|
|
||||||
tar -xzf jre21.tar.gz -C "$G/jre"
|
|
||||||
JRE="$G/jre/jdk-21.0.13+9-jre"
|
|
||||||
$PREFIX/glibc/lib/ld-linux-aarch64.so.1 \
|
|
||||||
--library-path "$JRE/lib:$JRE/lib/server:$PREFIX/glibc/lib" "$JRE/bin/java" -version
|
|
||||||
```
|
|
||||||
|
|
||||||
- AWT/X11:`DISPLAY=:0` 下 Swing 窗口正常(`headless=false`、`swing-ok`)。
|
|
||||||
- GL:需要 §5 的 Mesa 26 GL 构建(`-Dgallium-drivers=zink,softpipe -Dglx=dri`)。
|
|
||||||
`zink` 目前卡在 `DRI3 not available`;**softpipe 可用**:
|
|
||||||
`MESA_LOADER_DRIVER_OVERRIDE=swrast LIBGL_ALWAYS_SOFTWARE=1`。
|
|
||||||
- 端到端探针:LWJGL 3.3.3 arm64 natives + GLFW + GLX → `GL_VERSION=3.3 (Compatibility
|
|
||||||
Profile) Mesa 26.2.6`、`GL_RENDERER=softpipe`、`gl-ok`。
|
|
||||||
|
|
||||||
> **启动器已接线**(本分支):
|
|
||||||
> - Linux/aarch64 上 LWJGL3 的 natives 自动改写成 `natives-linux-arm64`
|
|
||||||
> (Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,下载兜底到阿里云 Maven 镜像);
|
|
||||||
> - Termux glibc 构建启动 MC 时,子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast`
|
|
||||||
> 与 `LIBGL_ALWAYS_SOFTWARE=1`(用户自定义环境变量仍可覆盖)。
|
|
||||||
>
|
|
||||||
> **还差一步**:把实例的 Java 指到 glibc JRE。Temurin 解包后 `bin/java` 默认的
|
|
||||||
> interpreter 在 Android 上不存在,先 patchelf 一次即可直接执行:
|
|
||||||
>
|
|
||||||
> ```sh
|
|
||||||
> patchelf --set-interpreter "$PREFIX/glibc/lib/ld-linux-aarch64.so.1" \
|
|
||||||
> --set-rpath "$PREFIX/glibc/lib:$JRE/lib:$JRE/lib/server" "$JRE/bin/java"
|
|
||||||
> "$JRE/bin/java" -version
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 之后在实例设置里把 Java 指向 `$JRE/bin/java`(或 `--java`)即可。
|
|
||||||
>
|
|
||||||
> **真机验证(1.20.4)**:`ull install 1.20.4 --name mc-test`(740 MB)成功后,
|
|
||||||
> `ull launch mc-test --java "$JRE/bin/java" --account Tester`,
|
|
||||||
> MC 主菜单在 termux-x11 中渲染(窗口 854×480;日志在实例目录
|
|
||||||
> `versions/1.20.4/logs/latest.log`)。非致命项:narrator 的 flite 缺失、
|
|
||||||
> LWJGL JNI 版本告警、oshi 的 cpuVendor NPE(都未阻断启动)。
|
|
||||||
|
|
||||||
## 5. GPU(已打通:自编 Mesa 26.2.6 + KGSL)
|
|
||||||
|
|
||||||
termux-x11 的 X 客户端可以走硬件;glibc 侧的问题在**驱动**,不在 loader:
|
|
||||||
|
|
||||||
1. `vulkan-icd-loader-glibc` + ICD JSON 本身正常。
|
|
||||||
2. Termux 软件源的 glibc Mesa 24.2.6 不认识 Adreno 840
|
|
||||||
(`chip_id = 44050A31 is unsupported`)。
|
|
||||||
3. Arch 的 `vulkan-freedreno 26.2.6` 能加载,但它是 Linux/DRM 构建,缺 KGSL
|
|
||||||
(`/dev/dri` 对 `untrusted_app` 被 SELinux 拒绝)。
|
|
||||||
|
|
||||||
**解法:给 glibc 自编一份带 KGSL 的 Mesa 26.2.6。** 本机实测通过:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 1) 源码 + 依赖
|
|
||||||
# mesa-26.2.6.tar.xz(archive.mesa3d.org)
|
|
||||||
# Debian libstdc++-14-dev 的头($G/gnu-support/usr/include/c++/14)
|
|
||||||
# meson / ninja(pip 装 meson)
|
|
||||||
# 2) 把源码里 *.py 的 shebang 从 #!/usr/bin/env python3 改成 Termux python3
|
|
||||||
# (Android 没有 /usr/bin/env)
|
|
||||||
# 3) meson setup(cc-glibc 包装器:-c 不带链接参数、C++ 加 -stdlib=libstdc++、
|
|
||||||
# 共享库链接加 -lstdc++;详见 §3)
|
|
||||||
meson setup build3 --native-file="$G/mesa-native.ini" --wrap-mode=nofallback \
|
|
||||||
-Dbuildtype=release -Dstrip=true -Dplatforms=x11 \
|
|
||||||
-Dvulkan-drivers=freedreno -Dfreedreno-kmds=kgsl \
|
|
||||||
-Dgallium-drivers=zink,softpipe -Dglx=dri -Dopengl=true -Degl=enabled \
|
|
||||||
-Dgles1=disabled -Dgles2=enabled -Dllvm=disabled -Dgbm=disabled \
|
|
||||||
-Dvideo-codecs= -Dzstd=enabled -Dbuild-tests=false
|
|
||||||
ninja -C build3
|
|
||||||
```
|
|
||||||
|
|
||||||
`--wrap-mode=nofallback` 是为了跳过 libarchive 子项目(它只被 freedreno 的 decode 工具需要,
|
|
||||||
且它自带的 `id_t` 兜底宏会和 glibc 头冲突)。
|
|
||||||
|
|
||||||
安装与启用:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp build3/src/freedreno/vulkan/libvulkan_freedreno.so \
|
|
||||||
"$PREFIX/glibc/lib/libvulkan_freedreno_ull.so"
|
|
||||||
cat > "$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json" <<EOF
|
|
||||||
{"ICD":{"api_version":"1.4.335","library_path":"$PREFIX/glibc/lib/libvulkan_freedreno_ull.so"},"file_format_version":"1.0.0"}
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
应用启动时(glibc 构建 + Termux + 该 JSON 存在)会自动把它设进
|
|
||||||
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`(`app/boot/termux_gpu.rs`);用户显式设了这两个
|
|
||||||
变量时不覆盖。实测日志:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[probe] adapters=1 [Adreno (TM) 840 [IntegratedGpu]] hardware=true :: request=auto -> ICED_BACKEND=wgpu
|
|
||||||
[gpu] Selected: AdapterInfo { name: "Adreno (TM) 840", driver: "turnip Mesa driver",
|
|
||||||
driver_info: "Mesa 26.2.6", backend: Vulkan }
|
|
||||||
[shot] 1657x1067 renderer=wgpu,首帧 61 ms
|
|
||||||
```
|
|
||||||
|
|
||||||
没装驱动/没有 GPU 时仍会打印具名错误并自动降级 tiny-skia(同一进程不能重建事件循环,
|
|
||||||
降级走 respawn),所以 GPU 变体在纯 CPU 环境也能跑。
|
|
||||||
|
|
||||||
## 6. 已知边界
|
|
||||||
|
|
||||||
- 没有面向 Termux 的发布资产,也不在 CI 矩阵里;每次都要源码自建。
|
|
||||||
- 只在 CPU 渲染 + 单台设备(Adreno 840 / Android 17)上验证过。
|
|
||||||
- **装好 JDK ≠ 能跑 MC**:MC 的 LWJGL natives 是 glibc `.so`,bionic 的 Termux JVM 加载不了;
|
|
||||||
这条与「启动器 GUI 能显示」是两件事,未在本文覆盖。
|
|
||||||
- `ULL_MSA_CLIENT_ID` 属于仓库 Secret,构建时从环境/Secret 注入,**不写进任何 git 文件**。
|
|
||||||
@@ -4,13 +4,14 @@
|
|||||||
Release matrix (see `packaging/README.md` for the full contract):
|
Release matrix (see `packaging/README.md` for the full contract):
|
||||||
|
|
||||||
Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip)
|
Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip)
|
||||||
Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, cross-built)
|
Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, native arm64 runner)
|
||||||
Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz
|
Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz
|
||||||
Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz
|
Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz
|
||||||
macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz
|
macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz
|
||||||
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
|
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
|
||||||
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
|
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
|
||||||
all SHA256SUMS.txt
|
all SHA256SUMS.txt
|
||||||
|
all manifest.json (only with --manifest, see below)
|
||||||
|
|
||||||
Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore
|
Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore
|
||||||
separated, and **no implementation detail in the file name** — the asset never says which UI
|
separated, and **no implementation detail in the file name** — the asset never says which UI
|
||||||
@@ -31,10 +32,19 @@ carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
|
|||||||
The launcher binary also answers every CLI subcommand, so `ull` is not a second
|
The launcher binary also answers every CLI subcommand, so `ull` is not a second
|
||||||
interface — it is the version of it that links no graphics stack at all.
|
interface — it is the version of it that links no graphics stack at all.
|
||||||
|
|
||||||
Slot and build directory are overridable because a cross build's binaries live in
|
`--manifest` additionally writes `manifest.json` (the update manifest, D50) into the same
|
||||||
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI
|
output directory. The generation itself lives in `packaging/make_manifest.py`, invoked as a
|
||||||
passes both from its matrix, and the executable header is read back so a binary can
|
subprocess: this file stays the single source of truth for **naming** (`asset_stem()`), that
|
||||||
never be shipped under a slot name it was not built for.
|
one is the single writer of the **manifest**, and it derives every sha256/size from the
|
||||||
|
`SHA256SUMS.txt` this script just wrote — one set of facts, no second bookkeeping. CI does not
|
||||||
|
use this flag: `release-publish.yml` recomputes `SHA256SUMS.txt` after pruning the publish
|
||||||
|
whitelist and generates the manifest from *that*, so the manifest always describes what ships.
|
||||||
|
|
||||||
|
Slot and build directory are overridable because the build host is not always the
|
||||||
|
artifact: a cross build's binaries live in `target/<triple>/release` under an x64
|
||||||
|
host, while every native slot (all six in CI, M140) puts them in `target/release`.
|
||||||
|
CI passes both from its matrix, and the executable header is read back so a binary
|
||||||
|
can never be shipped under a slot name it was not built for.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
@@ -209,6 +219,15 @@ def write_sums(out_dir: Path, assets: list[Path]) -> Path:
|
|||||||
return sums
|
return sums
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(out_dir: Path, version: str) -> Path:
|
||||||
|
"""`manifest.json`(D50):跑的是 CI 发布侧同一份脚本,事实全部来自刚写完的 SHA256SUMS.txt。"""
|
||||||
|
script = REPO_ROOT / "packaging" / "make_manifest.py"
|
||||||
|
proc = subprocess.run([sys.executable, str(script), "--dist", str(out_dir), "--version", version])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
fail(f"manifest generation failed ({script.name} exited {proc.returncode})")
|
||||||
|
return out_dir / "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
def asset_stem(name: str, version: str, slot: str) -> str:
|
def asset_stem(name: str, version: str, slot: str) -> str:
|
||||||
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
|
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
|
||||||
|
|
||||||
@@ -267,6 +286,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
|
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
|
||||||
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
|
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
|
||||||
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
|
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
|
||||||
|
parser.add_argument("--manifest", action="store_true", help="also write manifest.json (update manifest, D50)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
version = workspace_version()
|
version = workspace_version()
|
||||||
@@ -310,6 +330,9 @@ def main() -> int:
|
|||||||
size_mb = item.stat().st_size / (1024 * 1024)
|
size_mb = item.stat().st_size / (1024 * 1024)
|
||||||
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
|
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
|
||||||
print(f"sums: {sums.name}")
|
print(f"sums: {sums.name}")
|
||||||
|
if args.manifest:
|
||||||
|
manifest = write_manifest(out_dir, version)
|
||||||
|
print(f"manifest: {manifest.name}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate `manifest.json` — the update manifest the launcher reads (D50).
|
||||||
|
|
||||||
|
Contract and rationale live in `packaging/README.md` §13; this docstring only states what the
|
||||||
|
code must not get wrong.
|
||||||
|
|
||||||
|
* **Shape authority** = `crates/core/src/update/manifest.rs` (`ReleaseManifest`,
|
||||||
|
`deny_unknown_fields` + per-field validation). This script only writes that shape.
|
||||||
|
* **Facts come from one place**: every `sha256` is read out of the Release's own
|
||||||
|
`SHA256SUMS.txt`, every `size` is `stat()` of the same file — the two can never disagree.
|
||||||
|
The version token inside each asset name is asserted to equal `--version`, so a tag that
|
||||||
|
drifted from `Cargo.toml` cannot yield a manifest pointing at files that do not exist.
|
||||||
|
* **Only launcher artifacts go in.** `ull` does not: the DTO has no role field and
|
||||||
|
`Release::artifact_for(target)` returns the *first* match, so listing the CLI beside the GUI
|
||||||
|
would make "which file do I overwrite" depend on array order.
|
||||||
|
* **`url`**: bare asset name by default (the `DirSource` convention in
|
||||||
|
`crates/core/src/update/source.rs`); with `--asset-base-url` a tag-pinned absolute URL,
|
||||||
|
which is what CI uses.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 packaging/make_manifest.py --dist dist --version v0.2.2 --channel stable \\
|
||||||
|
--asset-base-url https://github.com/o/r/releases/download/v0.2.2
|
||||||
|
python3 packaging/make_manifest.py --self-test # golden + strict-parse self check
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
# 本机 Python 可能是 isolated 模式(`safe_path=True`,脚本目录不进 `sys.path`),CI 的标准
|
||||||
|
# python3 则会自动加入。显式补一次,两种环境下 `import make_artifacts` 都成立。
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
# 只借常量:产物命名(SLOTS / LAUNCHER_ASSET)、CLI 名与 SHA256SUMS 文件名都是 make_artifacts
|
||||||
|
# 的单一口径,这里不复制一份表(复制就会漂);反向依赖不存在(make_artifacts 只用子进程调本脚本)。
|
||||||
|
import make_artifacts
|
||||||
|
|
||||||
|
MANIFEST_NAME = "manifest.json"
|
||||||
|
CHANNELS = ("stable", "beta", "alpha", "nightly")
|
||||||
|
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
|
||||||
|
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
|
||||||
|
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
|
||||||
|
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
|
||||||
|
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
|
||||||
|
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
|
||||||
|
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。
|
||||||
|
TARGET_OS = {"win", "windows", "mac", "macos", "osx", "darwin", "linux"}
|
||||||
|
TARGET_ARCH = {
|
||||||
|
"x64", "x86_64", "x86-64", "amd64", "arm64", "aarch64", "arm64e", "x86", "i686", "i386", "x86_32",
|
||||||
|
}
|
||||||
|
FORMATS = {"msi", "dmg", "appimage", "deb", "rpm", "portable-zip", "zip", "portable-exe", "exe"}
|
||||||
|
SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$")
|
||||||
|
SUMS_LINE = re.compile(r"^([0-9A-Fa-f]{64})[ \t]+\*?(.+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> NoReturn:
|
||||||
|
print(f"error: {message}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_version(text: str) -> str:
|
||||||
|
"""`v0.2.2` -> `0.2.2`(tag 带 `v`,产物名与清单都不带)。"""
|
||||||
|
version = text.strip()
|
||||||
|
version = version[1:] if version.startswith("v") else version
|
||||||
|
if not semver_ok(version):
|
||||||
|
fail(f"version `{text}` is not a semver")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def semver_ok(text: str) -> bool:
|
||||||
|
"""Mirror of `crates/core/src/update/version.rs::Version::parse` on the accepted shapes."""
|
||||||
|
match = SEMVER.match(text)
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
if any(len(number) > 1 and number.startswith("0") for number in match.group(1, 2, 3)):
|
||||||
|
return False
|
||||||
|
pre, build = match.group(4), match.group(5)
|
||||||
|
if pre is not None:
|
||||||
|
for ident in pre.split("."):
|
||||||
|
if not ident or (ident.isdigit() and len(ident) > 1 and ident.startswith("0")):
|
||||||
|
return False
|
||||||
|
if build is not None and any(not ident for ident in build.split(".")):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def read_sums(dist: Path) -> dict[str, str]:
|
||||||
|
"""`SHA256SUMS.txt` -> {file name: lowercase sha256}. Missing/odd entries are fatal."""
|
||||||
|
sums_path = dist / make_artifacts.SUMS_NAME
|
||||||
|
if not sums_path.is_file():
|
||||||
|
fail(f"missing {sums_path}(清单的 sha256 只从这里取,不另算一份)")
|
||||||
|
sums: dict[str, str] = {}
|
||||||
|
for lineno, line in enumerate(sums_path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
match = SUMS_LINE.match(line)
|
||||||
|
if not match:
|
||||||
|
fail(f"{sums_path.name}:{lineno} 不是 `<sha256> <文件名>` 形态:{line!r}")
|
||||||
|
digest, name = match.group(1).lower(), match.group(2)
|
||||||
|
if name in sums:
|
||||||
|
fail(f"{sums_path.name} 里 {name} 出现两次(无法确定哪个 sha256 是对的)")
|
||||||
|
sums[name] = digest
|
||||||
|
if not sums:
|
||||||
|
fail(f"{sums_path} 是空的:没有产物就没有清单")
|
||||||
|
return sums
|
||||||
|
|
||||||
|
|
||||||
|
def split_asset(name: str, version: str) -> tuple[str, str, str, str]:
|
||||||
|
"""`<name>_<version>_<platform>_<arch>[.exe|.tar.gz]` -> (name, platform, arch, ext)."""
|
||||||
|
stem, ext = name, ""
|
||||||
|
for candidate in EXT_FORMAT:
|
||||||
|
if stem.endswith(candidate):
|
||||||
|
stem, ext = stem[: -len(candidate)], candidate
|
||||||
|
break
|
||||||
|
parts = stem.rsplit("_", 3)
|
||||||
|
if len(parts) != 4:
|
||||||
|
fail(f"`{name}` 不符合 `<名字>_<版本>_<平台>_<架构>` 命名(见 make_artifacts.asset_stem)")
|
||||||
|
product, tag_version, platform_name, arch_name = parts
|
||||||
|
if tag_version != version:
|
||||||
|
fail(f"`{name}` 里的版本是 {tag_version},清单要的是 {version}(tag 与 Cargo.toml 漂了?)")
|
||||||
|
if (platform_name, arch_name) not in {
|
||||||
|
(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()
|
||||||
|
}:
|
||||||
|
fail(f"`{name}` 的平台/架构 {platform_name}/{arch_name} 不在发布矩阵里")
|
||||||
|
return product, platform_name, arch_name, ext
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifacts(dist: Path, version: str, base_url: str) -> list[dict[str, object]]:
|
||||||
|
"""Launcher artifacts from `SHA256SUMS.txt`, in matrix order (= priority order)."""
|
||||||
|
sums = read_sums(dist)
|
||||||
|
by_slot: dict[tuple[str, str], dict[str, object]] = {}
|
||||||
|
for name, digest in sums.items():
|
||||||
|
product, platform_name, arch_name, ext = split_asset(name, version)
|
||||||
|
if product != make_artifacts.LAUNCHER_ASSET:
|
||||||
|
continue # `ull` 不进清单,理由见模块注释
|
||||||
|
fmt = EXT_FORMAT.get(ext)
|
||||||
|
if fmt is None:
|
||||||
|
fail(f"`{name}` 没有可识别的扩展名,无法判定包形态(format)")
|
||||||
|
path = dist / name
|
||||||
|
if not path.is_file():
|
||||||
|
fail(f"{make_artifacts.SUMS_NAME} 提到 {name},但 {path} 不存在")
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size == 0:
|
||||||
|
fail(f"{path} 是 0 字节:这类产物只可能是打包漏了")
|
||||||
|
url = f"{base_url.rstrip('/')}/{name}" if base_url else name
|
||||||
|
if (platform_name, arch_name) in by_slot:
|
||||||
|
fail(f"{platform_name}/{arch_name} 出现两个启动器产物,清单无法确定该用哪一个")
|
||||||
|
by_slot[(platform_name, arch_name)] = {
|
||||||
|
"target": f"{PLATFORM_TARGET[platform_name]}-{arch_name}",
|
||||||
|
"format": fmt,
|
||||||
|
"url": url,
|
||||||
|
"size": size,
|
||||||
|
"sha256": digest,
|
||||||
|
}
|
||||||
|
order = [(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()]
|
||||||
|
artifacts_out = [by_slot[key] for key in order if key in by_slot]
|
||||||
|
if not artifacts_out:
|
||||||
|
fail(f"{make_artifacts.SUMS_NAME} 里没有任何 `{make_artifacts.LAUNCHER_ASSET}_*` 产物,清单会是空的")
|
||||||
|
return artifacts_out
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest(
|
||||||
|
dist: Path,
|
||||||
|
version: str,
|
||||||
|
channel: str,
|
||||||
|
*,
|
||||||
|
base_url: str = "",
|
||||||
|
min_supported: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
released_at: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Assemble the manifest document (the shape `ReleaseManifest` deserializes)."""
|
||||||
|
if channel not in CHANNELS:
|
||||||
|
fail(f"未知渠道 `{channel}`(只认 {' / '.join(CHANNELS)})")
|
||||||
|
release: dict[str, object] = {"version": version, "channel": channel}
|
||||||
|
if released_at:
|
||||||
|
release["released_at"] = released_at
|
||||||
|
if notes:
|
||||||
|
release["notes"] = notes
|
||||||
|
release["force"] = force
|
||||||
|
if min_supported:
|
||||||
|
release["min_supported"] = normalize_version(min_supported)
|
||||||
|
release["artifacts"] = build_artifacts(dist, version, base_url)
|
||||||
|
return {"channel": channel, "releases": [release]}
|
||||||
|
|
||||||
|
|
||||||
|
def render(doc: dict[str, object]) -> str:
|
||||||
|
"""Deterministic JSON: same facts + same args ⇒ byte-identical file (golden-testable)."""
|
||||||
|
return json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 严格解析镜像(DTO 的看门规则)───────────────────────────────────────────────────────
|
||||||
|
# `manifest.rs` 用 `deny_unknown_fields` + 逐字段校验;这里是它的 Python 镜像,只为了在没有
|
||||||
|
# Rust 的机器上(本地打包、CI 发布步)也能当场炸掉拼错的字段。字段/取值的权威永远是 .rs。
|
||||||
|
|
||||||
|
|
||||||
|
def check(condition: bool, message: str) -> None:
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def check_keys(where: str, obj: object, allowed: set[str], required: set[str]) -> dict:
|
||||||
|
check(isinstance(obj, dict), f"{where} 必须是对象")
|
||||||
|
unknown = set(obj) - allowed
|
||||||
|
check(not unknown, f"{where} 出现未知字段 {sorted(unknown)}(DTO 是 deny_unknown_fields)")
|
||||||
|
missing = required - set(obj)
|
||||||
|
check(not missing, f"{where} 缺字段 {sorted(missing)}")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def strict_parse(doc: object) -> None:
|
||||||
|
"""Raise `ValueError` on anything `ReleaseManifest::from_json` would reject."""
|
||||||
|
top = check_keys("清单", doc, {"channel", "releases"}, {"channel"})
|
||||||
|
check(top["channel"] in CHANNELS, f"清单渠道 `{top['channel']}` 无法识别")
|
||||||
|
releases = top.get("releases", [])
|
||||||
|
check(isinstance(releases, list), "releases 必须是数组")
|
||||||
|
seen: list[str] = []
|
||||||
|
for index, raw in enumerate(releases):
|
||||||
|
where = f"releases[{index}]"
|
||||||
|
release = check_keys(
|
||||||
|
where,
|
||||||
|
raw,
|
||||||
|
{"version", "channel", "released_at", "notes", "force", "min_supported", "artifacts"},
|
||||||
|
{"version"},
|
||||||
|
)
|
||||||
|
version = release["version"]
|
||||||
|
check(isinstance(version, str) and semver_ok(version), f"{where}.version 不是合法 semver")
|
||||||
|
check(version not in seen, f"{where}.version {version} 在清单里重复出现")
|
||||||
|
seen.append(version)
|
||||||
|
if "channel" in release:
|
||||||
|
check(release["channel"] in CHANNELS, f"{where}.channel 无法识别")
|
||||||
|
for key in ("released_at", "notes"):
|
||||||
|
check(release.get(key) is None or isinstance(release.get(key), str), f"{where}.{key} 必须是字符串")
|
||||||
|
check(isinstance(release.get("force", False), bool), f"{where}.force 必须是布尔")
|
||||||
|
if release.get("min_supported") is not None:
|
||||||
|
check(semver_ok(str(release["min_supported"])), f"{where}.min_supported 不是合法 semver")
|
||||||
|
artifacts_list = release.get("artifacts", [])
|
||||||
|
check(isinstance(artifacts_list, list), f"{where}.artifacts 必须是数组")
|
||||||
|
for art_index, raw_art in enumerate(artifacts_list):
|
||||||
|
art_where = f"{where}.artifacts[{art_index}]"
|
||||||
|
art = check_keys(
|
||||||
|
art_where,
|
||||||
|
raw_art,
|
||||||
|
{"target", "format", "url", "size", "sha256", "sha1"},
|
||||||
|
{"target", "format", "url", "size"},
|
||||||
|
)
|
||||||
|
os_part, _, arch_part = str(art["target"]).strip().partition("-")
|
||||||
|
check(
|
||||||
|
bool(arch_part) and os_part.lower() in TARGET_OS and arch_part.lower() in TARGET_ARCH,
|
||||||
|
f"{art_where}.target `{art['target']}` 无法识别(形如 win-x64)",
|
||||||
|
)
|
||||||
|
check(str(art["format"]).lower() in FORMATS, f"{art_where}.format 无法识别")
|
||||||
|
check(isinstance(art["url"], str) and art["url"].strip(), f"{art_where}.url 为空")
|
||||||
|
size = art["size"]
|
||||||
|
check(isinstance(size, int) and not isinstance(size, bool) and size > 0, f"{art_where}.size 必须 > 0")
|
||||||
|
sha256, sha1 = art.get("sha256"), art.get("sha1")
|
||||||
|
check(sha256 is not None or sha1 is not None, f"{art_where} 既没有 sha256 也没有 sha1")
|
||||||
|
digest_hex = str(sha256 if sha256 is not None else sha1)
|
||||||
|
expect = 64 if sha256 is not None else 40
|
||||||
|
check(
|
||||||
|
len(digest_hex) == expect and all(c in "0123456789abcdefABCDEF" for c in digest_hex),
|
||||||
|
f"{art_where} 的摘要应为 {expect} 位十六进制",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def self_test() -> int:
|
||||||
|
"""Golden 对拍(`.gitattributes` 的 `eol=lf` 保证跨平台同字节)+ 严格解析正负例:证明写出来的东西能被 DTO 吃下。"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
dist = Path(tmp)
|
||||||
|
version = "9.9.9"
|
||||||
|
# 两种启动器形态(裸 exe / 归档)+ 一个 CLI 资产:既覆盖 target/format 映射,
|
||||||
|
# 也钉住「`ull` 不进清单」这条决定。
|
||||||
|
payloads = {
|
||||||
|
f"{make_artifacts.LAUNCHER_ASSET}_{version}_windows_x64.exe": b"gui payload\n",
|
||||||
|
f"{make_artifacts.LAUNCHER_ASSET}_{version}_linux_x64.tar.gz": b"tar payload\n",
|
||||||
|
f"{make_artifacts.CLI_BIN}_{version}_windows_x64.exe": b"cli payload\n",
|
||||||
|
}
|
||||||
|
for name, payload in payloads.items():
|
||||||
|
(dist / name).write_bytes(payload)
|
||||||
|
(dist / make_artifacts.SUMS_NAME).write_text(
|
||||||
|
"".join(f"{make_artifacts.sha256(dist / name)} {name}\n" for name in payloads),
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
doc = build_manifest(
|
||||||
|
dist,
|
||||||
|
version,
|
||||||
|
"stable",
|
||||||
|
min_supported="9.0.0",
|
||||||
|
notes="golden 样例(自验用,不是真实发布)",
|
||||||
|
released_at="2026-01-02T03:04:05Z",
|
||||||
|
)
|
||||||
|
text = render(doc)
|
||||||
|
|
||||||
|
if "--write-golden" in sys.argv:
|
||||||
|
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
GOLDEN.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print(f"golden: {GOLDEN} 已重写")
|
||||||
|
expected = GOLDEN.read_text(encoding="utf-8")
|
||||||
|
if text != expected:
|
||||||
|
print("--- generated ---", text, "--- golden ---", expected, sep="\n", file=sys.stderr)
|
||||||
|
fail("生成的清单与 golden 不一致(改了口径就 --write-golden 重出并对 diff)")
|
||||||
|
print(f"golden: 与 {GOLDEN.name} 逐字节一致")
|
||||||
|
|
||||||
|
strict_parse(doc)
|
||||||
|
print("strict: 生成物通过严格解析镜像(deny_unknown_fields + 逐字段校验)")
|
||||||
|
# 负例:只测「顺利通过」等于没测——每条篡改都必须被拒。
|
||||||
|
for label, mutate in (
|
||||||
|
("未知字段", lambda d: d.update(extra=1)),
|
||||||
|
("未知版本字段", lambda d: d["releases"][0].update(extra=1)),
|
||||||
|
("未知产物字段", lambda d: d["releases"][0]["artifacts"][0].update(extra=1)),
|
||||||
|
("缺 sha256", lambda d: d["releases"][0]["artifacts"][0].pop("sha256")),
|
||||||
|
("摘要长度错", lambda d: d["releases"][0]["artifacts"][0].update(sha256="deadbeef")),
|
||||||
|
("size 为 0", lambda d: d["releases"][0]["artifacts"][0].update(size=0)),
|
||||||
|
("size 是字符串", lambda d: d["releases"][0]["artifacts"][0].update(size="12")),
|
||||||
|
("未知 target", lambda d: d["releases"][0]["artifacts"][0].update(target="solaris-x64")),
|
||||||
|
("未知 format", lambda d: d["releases"][0]["artifacts"][0].update(format="msix")),
|
||||||
|
("空 url", lambda d: d["releases"][0]["artifacts"][0].update(url=" ")),
|
||||||
|
("版本重复", lambda d: d["releases"].append(copy.deepcopy(d["releases"][0]))),
|
||||||
|
("版本号不合法", lambda d: d["releases"][0].update(version="1.0")),
|
||||||
|
("渠道不认识", lambda d: d.update(channel="canary")),
|
||||||
|
("缺顶层 channel", lambda d: d.pop("channel")),
|
||||||
|
):
|
||||||
|
tampered = copy.deepcopy(doc)
|
||||||
|
mutate(tampered)
|
||||||
|
try:
|
||||||
|
strict_parse(tampered)
|
||||||
|
except ValueError:
|
||||||
|
print(f"strict: 负例「{label}」被正确拒绝")
|
||||||
|
continue
|
||||||
|
fail(f"严格解析放过了负例「{label}」——这条校验形同虚设")
|
||||||
|
print("self-test: ok")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--dist", default="dist", help="directory holding the assets + SHA256SUMS.txt (default: dist/)")
|
||||||
|
parser.add_argument("--version", help="release version, e.g. v0.2.2 (must match the asset file names)")
|
||||||
|
parser.add_argument("--channel", default="stable", choices=CHANNELS, help="manifest channel (D50: stable only for now)")
|
||||||
|
parser.add_argument("--out", help=f"output path (default: <dist>/{MANIFEST_NAME})")
|
||||||
|
parser.add_argument("--asset-base-url", default="", help="prefix for artifact urls; omit for bare file names")
|
||||||
|
parser.add_argument("--min-supported", help="lowest version still supported (omitted when not given)")
|
||||||
|
parser.add_argument("--notes", help="plain-text release notes for the update prompt")
|
||||||
|
parser.add_argument("--notes-file", help="read --notes from this file")
|
||||||
|
parser.add_argument("--released-at", help="RFC3339 timestamp (display only)")
|
||||||
|
parser.add_argument("--force", action="store_true", help="mark this release as force-update")
|
||||||
|
parser.add_argument("--self-test", action="store_true", help="golden + strict-parse self check (no files written)")
|
||||||
|
parser.add_argument("--write-golden", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.self_test:
|
||||||
|
return self_test()
|
||||||
|
if not args.version:
|
||||||
|
parser.error("--version is required (it must match the asset file names)")
|
||||||
|
dist = Path(args.dist)
|
||||||
|
if not dist.is_absolute():
|
||||||
|
dist = make_artifacts.REPO_ROOT / dist
|
||||||
|
notes = args.notes
|
||||||
|
if args.notes_file:
|
||||||
|
notes = (make_artifacts.REPO_ROOT / args.notes_file).read_text(encoding="utf-8").strip()
|
||||||
|
doc = build_manifest(
|
||||||
|
dist,
|
||||||
|
normalize_version(args.version),
|
||||||
|
args.channel,
|
||||||
|
base_url=args.asset_base_url,
|
||||||
|
min_supported=args.min_supported,
|
||||||
|
notes=notes,
|
||||||
|
released_at=args.released_at,
|
||||||
|
force=args.force,
|
||||||
|
)
|
||||||
|
text = render(doc)
|
||||||
|
strict_parse(doc)
|
||||||
|
out = Path(args.out) if args.out else dist / MANIFEST_NAME
|
||||||
|
if not out.is_absolute():
|
||||||
|
out = make_artifacts.REPO_ROOT / out
|
||||||
|
out.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
release = doc["releases"][0]
|
||||||
|
artifacts_list = release["artifacts"]
|
||||||
|
print(f"manifest: {out.name} channel={doc['channel']} version={release['version']} artifacts={len(artifacts_list)}")
|
||||||
|
for item in artifacts_list:
|
||||||
|
print(f" {item['target']:<14} {item['format']:<13} {item['size']:>10} B sha256={str(item['sha256'])[:12]} {item['url']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -59,7 +59,7 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt
|
|||||||
| Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
|
| Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
|
||||||
| Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` |
|
| Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` |
|
||||||
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
|
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
|
||||||
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified once on CPU rendering (see `packaging/docs/termux-glibc-build.md`) |
|
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI only and only when built from source; GUI unverified |
|
||||||
|
|
||||||
`windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green
|
`windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green
|
||||||
(see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and
|
(see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"channel": "stable",
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "9.9.9",
|
||||||
|
"channel": "stable",
|
||||||
|
"released_at": "2026-01-02T03:04:05Z",
|
||||||
|
"notes": "golden 样例(自验用,不是真实发布)",
|
||||||
|
"force": false,
|
||||||
|
"min_supported": "9.0.0",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"target": "win-x64",
|
||||||
|
"format": "portable-exe",
|
||||||
|
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
|
||||||
|
"size": 12,
|
||||||
|
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "linux-x64",
|
||||||
|
"format": "portable-zip",
|
||||||
|
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
|
||||||
|
"size": 12,
|
||||||
|
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user