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

No files matched your search

+1 -49
View File
@@ -132,55 +132,7 @@ jobs:
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。 # 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条 # 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。 # `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
# - run: cargo test --workspace --all-targets
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
# 事各自对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
# 注解是匿名侧唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
log="$RUNNER_TEMP/cargo-test.log"
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
# 不是 tee 的码)。
set +e
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "cargo exit=$code"
if [ "$code" -ne 0 ]; then
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
fi
exit "$code"
- name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows'
shell: pwsh
run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE
Write-Host "cargo exit=$code"
if ($code -ne 0) {
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
build: build:
name: build release (${{ matrix.os }}) name: build release (${{ matrix.os }})
-247
View File
@@ -1,247 +0,0 @@
name: Release / build(可复用)
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
# 本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,唯一的改动是四个矩阵键换成同名入参:
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
# `matrix.rustflags` → `inputs.rustflags`。其余 `name` / `if` / `env` / `run` / `uses` 与拆分前逐行相同。
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。
on:
workflow_call:
inputs:
slot:
description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)"
type: string
required: true
os:
description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)"
type: string
required: true
build_dir:
description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)"
type: string
required: true
rustflags:
description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)"
type: string
required: false
default: ""
# (M149)微软登录用的 Azure 公共客户端 id(公共客户端 id 随二进制一起分发,不按高密级处理,但**不进 git
# 文件**:真值放仓库 Secret,用户在仓库设置里录入)。`required: false` 是刻意的——调用方没传(本地、
# 没有 runner 的镜像站、以及 Secret 尚未录入时)求值为空串,`client_id.rs` 把空白当「没配」,
# 于是非官方构建不烘 id,官方构建才烘。**调用方必须显式传**(`secrets: inherit` 或按名传);
# 没传时这里是空,不会报错。
secrets:
ULL_MSA_CLIENT_ID:
description: "Azure 公共客户端 id,烘进发布二进制(构建期 option_env!);留空 = 不烘"
required: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。
permissions:
contents: read
jobs:
build:
name: build (${{ inputs.slot }})
runs-on: ${{ inputs.os }}
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static`
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e——
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出),
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。
- name: Windows ARM64 工具链体检(红不挡发布,只取证)
if: inputs.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
scan
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
v=""
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
v=""
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
scan
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
exit 1
fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
# (M149)`ULL_MSA_CLIENT_ID` 经 `option_env!` 烘进二进制(`crates/core/src/auth/microsoft/client_id.rs`):
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ inputs.rustflags }}
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
run: |
set -euo pipefail
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ inputs.slot }}
BUILD_DIR: ${{ inputs.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ inputs.slot }}
path: dist
if-no-files-found: error
retention-days: 14
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
- name: arm64 失败收尾注解(红不挡发布)
if: failure() && inputs.slot == 'windows-arm64'
shell: bash
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
-151
View File
@@ -1,151 +0,0 @@
name: Release / publish(可复用)
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与
# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的
# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。
on:
workflow_call:
secrets:
PUBLISH_TOKEN:
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
required: true
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
permissions:
contents: write
jobs:
release:
name: GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着
# arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据),
# **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步
# README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
set -euo pipefail
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
keep=" "
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
for f in dist/*; do
base=${f##*/}
[ "$base" = "SHA256SUMS.txt" ] && continue
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
case "$keep" in
*" $platform_arch "*)
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。
for slot in $PUBLISH_SLOTS; do
stem=${slot//-/_}
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
echo "::error::发布白名单缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ github.event.inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 github.event.inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
+305 -31
View File
@@ -8,14 +8,6 @@ name: Release
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与 # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
#
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build /
# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on: on:
push: push:
@@ -86,41 +78,323 @@ jobs:
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr) print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
PY PY
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
build: build:
name: build (${{ matrix.slot }}) name: build (${{ matrix.slot }})
needs: plan needs: plan
strategy: strategy:
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。 runs-on: ${{ matrix.os }}
permissions: steps:
contents: read - uses: actions/checkout@v4
uses: ./.github/workflows/release-build.yml
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static`
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e——
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出),
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。
- name: Windows ARM64 工具链体检(红不挡发布,只取证)
if: matrix.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
scan
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
v=""
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
v=""
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
scan
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
exit 1
fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
set -euo pipefail
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with: with:
slot: ${{ matrix.slot }} name: dist-${{ matrix.slot }}
os: ${{ matrix.os }} path: dist
build_dir: ${{ matrix.build_dir }} if-no-files-found: error
rustflags: ${{ matrix.rustflags }} retention-days: 14
secrets:
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。 # (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式, # 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白 - name: arm64 失败收尾注解(红不挡发布)
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。 if: failure() && matrix.slot == 'windows-arm64'
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }} shell: bash
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
release: release:
name: GitHub Release name: GitHub Release
needs: build needs: build
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) # `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
# 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 # 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag' if: always() && !cancelled() && github.ref_type == 'tag'
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
uses: ./.github/workflows/release-publish.yml env:
secrets: # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 # **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据),
# **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步
# README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。
PUBLISH_SLOTS: "windows-x64"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
set -euo pipefail
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
keep=" "
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
for f in dist/*; do
base=${f##*/}
[ "$base" = "SHA256SUMS.txt" ] && continue
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
case "$keep" in
*" $platform_arch "*)
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个
# 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。
# M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 fail。
for slot in $PUBLISH_SLOTS; do
stem=${slot//-/_}
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
echo "::error::发布白名单缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 只在**显式要求**时断言:PRERELEASE=true/false 来自 tag ref 上的 dispatch 输入;空值 = push
# 触发(或它的 Re-run),此时**不碰**该标记(否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release)。
# 首次 create 仍守「pre-release 默认开」老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
Generated
-1
View File
@@ -5003,7 +5003,6 @@ dependencies = [
"image", "image",
"log", "log",
"unlucky-cli", "unlucky-cli",
"unlucky-core",
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
+13 -18
View File
@@ -83,17 +83,16 @@ Three tracks on one page:
- **Third-party community auth servers** (authlib-injector style). - **Third-party community auth servers** (authlib-injector style).
- **Local offline profiles**, for isolated testing. - **Local offline profiles**, for isolated testing.
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public **Microsoft sign-in needs an Azure public client id, and no release binary carries one.** The id is
client id they need (a public client id, not a credential — it ships inside the binary), so there is not in this repo and CI injects none, so set `ULL_MSA_CLIENT_ID=<id>` before launching, or build with
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the it (`ULL_MSA_CLIENT_ID=<id> cargo build`, which bakes it in via `option_env!`). Without one, sign-in
variable is read at startup, so restart the launcher after changing it. Building from source bakes in stops before any network call and the alert says exactly what is missing: the title "Microsoft
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then
anywhere stops before any network call, and its alert says exactly what is missing: the title restart the launcher — a running process does not pick up a new variable), and
"Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with `Code: msa_not_configured`. It offers no Retry button, because retrying in that same process cannot
it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that help. Every other sign-in failure keeps the older shape: one category line plus its own `Code:`
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus line, with the full reason only in the launcher log. Community auth servers and local profiles need
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local no id.
profiles need no id.
### Look and language ### Look and language
@@ -193,9 +192,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
@@ -245,7 +243,7 @@ upstream cargo. On Linux, X11 is the preferred path today and Wayland is the lon
Capability differences are feature gates with runtime probing, never a compile-time "this platform Capability differences are feature gates with runtime probing, never a compile-time "this platform
gets less". gets less".
First run with a Microsoft account? Nothing to configure — official builds carry the client id; see First run with a Microsoft account? Set `ULL_MSA_CLIENT_ID` first — see
[Accounts](#accounts). Nothing else needs configuring. [Accounts](#accounts). Nothing else needs configuring.
## Build from source ## Build from source
@@ -268,9 +266,6 @@ cargo deny check licenses bans sources # dependency licences (re
What the build produces: What the build produces:
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
— see [Accounts](#accounts).
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed - `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
-59
View File
@@ -1,59 +0,0 @@
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
> 给主线的改动清单、兼容性说明与合并指南见
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
## 已验证
| 项 | 状态 | 证据 |
| --- | --- | --- |
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
## 快速开始
完整配方(工具链、patchelf、linker、门禁)见
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
```sh
termux-x11 :0 -ac &
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
```
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
## 已知边界
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
PC 的 Linux CI 不受影响。
## 后续
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
2. 剪贴板/IME 在 termux-x11 下的行为验证
3. narrator/flite 与音频设备(当前 missing 但不阻断)
+8 -11
View File
@@ -68,13 +68,12 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **第三方社区认证服**(authlib-injector 一类)。 - **第三方社区认证服**(authlib-injector 一类)。
- **本地离线档案**,用于隔离测试。 - **本地离线档案**,用于隔离测试。
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是 **微软登录需要一个 Azure 公共客户端 id,而发布版二进制里没有。** 这个 id 不在仓库里,CI 也不注入,所以
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值: 启动前设 `ULL_MSA_CLIENT_ID=<id>`(或构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`,它通过
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`, `option_env!` 烧进二进制)。没有它时点登录**不会联网**,弹窗直接说清是这一项没配:标题「微软登录未配置」,
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题 正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后要**重启启动器**——已在运行的进程读不到新设的环境
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带 变量),并带 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是 登录失败仍是老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
### 外观与语言 ### 外观与语言
@@ -157,7 +156,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 也未验证。
## 下载与运行 ## 下载与运行
@@ -198,7 +197,7 @@ Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。W
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异 (MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。 一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。 第一次用微软账户?先设 `ULL_MSA_CLIENT_ID`——见[账户](#账户)。其它都不需要配置。
## 从源码构建 ## 从源码构建
@@ -219,8 +218,6 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
编出来的 bin: 编出来的 bin:
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
cargo build`);官方发布产物已内置,见[账户](#账户)。
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo - `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成 的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。 `unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
-132
View File
@@ -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 路径。
-3
View File
@@ -24,9 +24,6 @@ path = "src/lib.rs"
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.1" }
# 平台识别(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「重写只换渲染」的落点。
+2 -16
View File
@@ -30,8 +30,6 @@ use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics; mod diagnostics;
mod fallback; mod fallback;
mod termux_gpu;
mod termux_window;
use fallback::{on_failure, respawn, OnFailure}; use fallback::{on_failure, respawn, OnFailure};
@@ -41,8 +39,6 @@ pub(crate) fn run(options: Options) -> iced::Result {
let boot_started = Instant::now(); let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打) // ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
termux_gpu::enable();
let boot = boot_probe(&options); let boot = boot_probe(&options);
let decision = boot.decision; let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision); diagnostics::report_boot(&options, &boot.probe, &decision);
@@ -135,20 +131,10 @@ fn launch(
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳** // M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。 // ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先), let (size, size_source) = acceptance::window_size(
// 失败就退回 1180×760 的验收尺寸。
let (default_size, default_source) = termux_window::default_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height), (WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height), (MIN_SIZE.width, MIN_SIZE.height),
) );
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
let (size, size_source) =
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
let size_source = if size_source == "default" {
default_source
} else {
size_source
};
let settings = diagnostics::window_settings(size); let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2) // 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source); diagnostics::report_window(&settings, options.scale_factor, size_source);
@@ -1,58 +0,0 @@
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
//!
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
//!
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
use std::path::{Path, PathBuf};
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
pub(super) fn enable() {
if !cfg!(target_os = "linux") {
return;
}
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|| std::env::var_os("VK_DRIVER_FILES").is_some()
{
return;
}
let Some(prefix) = termux::prefix(&ProcessEnv) else {
return;
};
let icd = icd_path(&prefix);
if icd.is_file() {
std::env::set_var("VK_ICD_FILENAMES", &icd);
std::env::set_var("VK_DRIVER_FILES", &icd);
}
}
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
fn icd_path(prefix: &Path) -> PathBuf {
prefix.join(ICD_REL)
}
#[cfg(test)]
mod tests {
use super::icd_path;
#[test]
fn icd_path_is_under_the_glibc_prefix() {
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
assert_eq!(
path,
std::path::PathBuf::from(
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
)
);
}
}
@@ -1,115 +0,0 @@
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
//!
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
//!
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
use std::process::Command;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
/// 屏幕四周留白(像素)。
const MARGIN: f32 = 32.0;
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
const MAX_SCALE: f32 = 2.0;
/// `xdpyinfo` 里尺寸那一行的前缀。
const DIMENSIONS_PREFIX: &str = "dimensions:";
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
///
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
/// 这里不复制常量。
#[must_use]
pub(super) fn default_size(
default: (f32, f32),
min: (f32, f32),
) -> Option<((f32, f32), &'static str)> {
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
return None;
}
let screen = x_screen_size()?;
Some((fit_screen(default, min, screen), "termux-screen"))
}
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
#[must_use]
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
return default;
}
let available = (
(screen.0 - MARGIN * 2.0).max(1.0),
(screen.1 - MARGIN * 2.0).max(1.0),
);
let scale = (available.0 / default.0)
.min(available.1 / default.1)
.min(MAX_SCALE);
let width = (default.0 * scale).max(min.0).min(screen.0);
let height = (default.1 * scale).max(min.1).min(screen.1);
(width, height)
}
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
fn x_screen_size() -> Option<(f32, f32)> {
let output = Command::new("xdpyinfo").output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text
.lines()
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
let value = line.split_whitespace().nth(1)?;
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::fit_screen;
const DEFAULT: (f32, f32) = (1180.0, 760.0);
const MIN: (f32, f32) = (880.0, 560.0);
fn close(actual: (f32, f32), expected: (f32, f32)) {
assert!(
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
"expected {expected:?}, got {actual:?}"
);
}
#[test]
fn landscape_screen_scales_up_keeping_aspect() {
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
close(
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
(1875.58, 1208.0),
);
}
#[test]
fn portrait_screen_still_fits_width() {
// 1272×2066:宽度是瓶颈 → 1208×778。
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
}
#[test]
fn huge_screen_is_capped_at_two_times() {
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
}
#[test]
fn tiny_screen_never_goes_below_min_or_overflow() {
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
assert_eq!(width, 800.0, "宽度受屏幕限制");
assert_eq!(height, 560.0, "高度不小于最小尺寸");
}
#[test]
fn invalid_screen_falls_back_to_default() {
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
}
}
-13
View File
@@ -52,9 +52,6 @@ impl Dispatch {
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。 /// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> { pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
// 只有 `RawWindowHandle::Win32` 那条臂用到它,其余平台只剩 `_ => None`
// ⇒ 无条件导入在非 Windows 上是 `unused_imports`(M147)。
#[cfg(target_os = "windows")]
use window::raw_window_handle::RawWindowHandle; use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?; let handle = handle.window_handle().ok()?;
@@ -102,21 +99,11 @@ mod tests {
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。 // 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5)); let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED"); assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
// 「做不到」的语义随平台不同(M147 实证:这条断言原先按 Windows 写死,Ubuntu 上
// 622 passed / 1 failed):Windows 上 FFI 真被调用并失败 ⇒ `Failed`;非 Windows 走
// `transparency.rs` 的空实现 ⇒ `Unsupported`。两边都不许表现成 `Applied` / `Opaque`。
#[cfg(target_os = "windows")]
assert!( assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }), matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}", "{:?}",
invalid.alpha invalid.alpha
); );
#[cfg(not(target_os = "windows"))]
assert_eq!(
invalid.alpha,
AlphaOutcome::Unsupported,
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
);
} }
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`), /// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
-35
View File
@@ -139,43 +139,8 @@ pub fn resolve() -> Resolved {
resolve_with(None) resolve_with(None)
} }
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
///
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
fn register_termux_fonts() {
use std::sync::OnceLock;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::platform::termux;
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| {
if !termux::is_termux(&ProcessEnv) {
return;
}
const CANDIDATES: &[&str] = &[
"/system/fonts/NotoSansCJK-Regular.ttc",
"/system/fonts/NotoSansCJKSC-Regular.otf",
"/system/fonts/DroidSansFallback.ttf",
];
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
return;
};
let db = font_system.raw().db_mut();
for path in CANDIDATES {
if db.load_font_file(path).is_ok() {
return;
}
}
});
}
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
pub fn resolve_with(setting: Option<&str>) -> Resolved { pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts();
let platform = Platform::current(); let platform = Platform::current();
let faces = system_faces(); let faces = system_faces();
@@ -18,10 +18,6 @@
//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事 //! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事
//! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。 //! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。
// M147:本模块七项(三个属性号 + 四个读函数)只在 Windows 上有使用者——唯一调用方 `probe::run_one`
// 的 Windows 版被门着,Linux + `-D warnings` 下就是 dead_code 错。用模块级 `cfg_attr` 而不是
// 逐项 `cfg`:`cfg` 会把这些项连同本文件那几条与平台无关的单测一起门掉(逐项 `cfg_attr` 多 7 行,顶破红线)。
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
/// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。 /// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。
pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34; pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34;
/// `DWMWCP_DONOTROUND`:显式直角。 /// `DWMWCP_DONOTROUND`:显式直角。
@@ -124,7 +120,7 @@ pub fn set_preference(hwnd: isize, preference: u32) -> CornerOutcome {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
let _ = (hwnd, preference); let _ = (hwnd, preference);
CornerOutcome::UnsupportedPlatform return CornerOutcome::UnsupportedPlatform;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -19,9 +19,6 @@
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多 //! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
//! (实测:同 argv 开/关穿透,hash 完全相同)。 //! (实测:同 argv 开/关穿透,hash 完全相同)。
// 唯一使用者是下面的 `click_through`(它自己按平台门禁),非 Windows 上这个导入没人用
// ⇒ `unused_imports` 在 `-D warnings` 下升成错(M147)。
#[cfg(target_os = "windows")]
use super::raw_ex_style; use super::raw_ex_style;
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。 /// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
@@ -39,14 +39,9 @@
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use super::alpha::{apply_window_alpha, ensure_layered}; use super::alpha::{apply_window_alpha, ensure_layered};
// `CornerOutcome` 与平台无关(`ProbeRow` 的字段类型、非 Windows 的 `empty_row` 都用它),
// 其余九个符号只服务于 Windows 的 `run_one` —— 分开写,否则非 Windows 上整条导入无人使用
// (`unused_imports`,M147)。
use super::dwm::CornerOutcome;
#[cfg(target_os = "windows")]
use super::dwm::{ use super::dwm::{
corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness, corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness,
window_rect, window_style, DWMWCP_DONOTROUND, DWMWCP_ROUND, window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND,
}; };
/// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。 /// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。
+8 -10
View File
@@ -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 的配置样例
@@ -133,10 +133,10 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 **分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
凭据永不打印:list 只输出名称/轨道/UUID。 凭据永不打印:list 只输出名称/轨道/UUID。
add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。 add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。
微软登录所需配置已内置在官方构建里,装完即可用;仍可用环境变量覆盖(优先于内置值): 它需要一个 Azure 公共客户端 id —— 登录链路是完整的,缺的只是这一项配置;id 不进仓库:
ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(优先于内置值) ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(环境变量优先)
ULL_MSA_CLIENT_ID=<id> cargo build 自己从源码构建时注入,二进制自带 ULL_MSA_CLIENT_ID=<id> cargo build 构建时注入,二进制自带
两处都没有时(只有自建且不自带 id 才会这样)命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。 两处都没有时命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
), ),
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), "sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
@@ -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(
"\ "\
+2 -10
View File
@@ -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,
+2 -22
View File
@@ -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;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else { let Ok(coordinate) = library.coordinate() else {
continue; 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 只搬运。
+3 -10
View File
@@ -2,9 +2,8 @@
//! //!
//! ## 为什么单独一块(M117) //! ## 为什么单独一块(M117)
//! //!
//! 这是**公共客户端 id**(非机密,随二进制一起分发),可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`], //! 这个 id 不进仓库(用户定案),所以它可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`],
//! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])——官方发布构建在构建时烘入它(M149), //! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])。**两处都没有**时登录是「没配置」,
//! 所以官方产物开箱即用;环境变量仍优先,可覆盖。**两处都没有**时登录是「没配置」,
//! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清: //! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清:
//! //!
//! | 现象 | 该说什么 | //! | 现象 | 该说什么 |
@@ -21,9 +20,6 @@
pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID"; pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID";
/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=<id> cargo build`)。 /// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=<id> cargo build`)。
///
/// 官方发布构建就是这么烘的(`.github/workflows/release-build.yml` 给构建步设了同名环境变量,M149;
/// 值来自仓库 Secret,不进 git 文件),所以官方产物走到这里恒为 `Some`;从源码自建时才可能为空。
pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID"); pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID");
/// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。 /// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。
@@ -184,10 +180,7 @@ mod tests {
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE); assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
assert!(!message.contains("未支持")); assert!(!message.contains("未支持"));
} }
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里; // 构建期注入过 id 时唯一合法(本仓库 CI/本地都不注入,走不到这里)。
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
Ok(_) => assert!( Ok(_) => assert!(
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
"没配 id 却构造成功" "没配 id 却构造成功"
+12 -163
View File
@@ -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 镜像(见常量注释)。
let arm64_fallback = classifier == LINUX_ARM64_CLASSIFIER;
let (url, sha1, size) = match library.native_artifact(&classifier) {
Some(artifact) if artifact.has_url() => (
artifact.url.clone().unwrap_or_default(),
artifact.sha1.clone(),
artifact.size,
),
_ if arm64_fallback => (arm64_native_url(&native_relative), None, None),
Some(_) => {
warnings.push(format!( warnings.push(format!(
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过", "{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
library.name library.name
)); ));
continue; 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:?}"
);
}
} }
+1 -53
View File
@@ -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();
}
} }
+2 -79
View File
@@ -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) => {
-1
View File
@@ -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;
-5
View File
@@ -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 源)")),
} }
} }
-257
View File
@@ -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();
}
}
+2 -18
View File
@@ -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],
} }
} }
+1 -32
View File
@@ -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`)。
-47
View File
@@ -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")
);
}
}
-1
View File
@@ -22,4 +22,3 @@ pub mod game;
pub mod launch; pub mod launch;
pub mod platform; pub mod platform;
pub mod setting; pub mod setting;
pub mod update;
-1
View File
@@ -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;
-82
View File
@@ -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));
}
}
@@ -203,11 +203,7 @@ mod tests {
use crate::platform::paths::{MapEnv, Platform}; use crate::platform::paths::{MapEnv, Platform};
const APP_DATA: &str = "C:/Users/u/AppData/Roaming"; const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
// 反斜杠原样:本常量经 `%LOCALAPPDATA%` 展开后要与 `CUSTOM_THEME`(反斜杠原文)拼出**同一个 const LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local";
// `PathBuf` 键**。写成 `C:/…` 会得到 `C:/Users/u/AppData/Local\Microsoft\…` 这种混合串——
// Windows 的 `Path` 把 `/` 与 `\` 都当分隔符,Linux 只认 `/`,于是同一份夹具在 Linux 上永远
// 查不到那个文件(M147 实测:CI 的 test(ubuntu) 就红在这条上)。
const LOCAL_APP_DATA: &str = r"C:\Users\u\AppData\Local";
const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper"; const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
const THEMES_QUERY: &str = const THEMES_QUERY: &str =
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme"; "reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
+1 -1
View File
@@ -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 版本。
/// ///
-113
View File
@@ -1,113 +0,0 @@
//! 更新渠道(通道)与准入规则。
//!
//! 四个渠道是一条**稳定度阶梯**,`Ord` 的派生顺序就是阶梯顺序
//! (`Stable < Beta < Alpha < Nightly`),所以「本渠道收不收那份发布」只要一次比较:
//! `manifest_channel <= requested_channel`。nightly 收全部,stable 只收 stable。
//!
//! 除了清单自报的渠道,还要看版本号里的预发布阶段([`Stage`]):清单可以撒谎
//! (`channel: "stable"` 但版本是 `1.0.0-alpha.2`),阶段这条判据不信清单只信版本号本身。
//! 两道判据**同时**成立才准入,见 [`Channel::admits`] 与 [`Channel::admits_stage`]。
//!
//! 准入阶梯(与渠道顺序同一套口径,别在别处另立一张表):
//!
//! | 渠道 | 收正式版 | 收 rc | 收 beta | 收 alpha | 收认不出的预发布 |
//! | ---- | -------- | ----- | ------- | -------- | ---------------- |
//! | stable | ✓ | | | | |
//! | beta | ✓ | ✓ | | | |
//! | alpha | ✓ | ✓ | ✓ | | |
//! | nightly | ✓ | ✓ | ✓ | ✓ | ✓ |
use std::fmt;
use std::str::FromStr;
use super::error::{UpdateError, UpdateErrorKind};
use super::version::Stage;
/// 更新渠道。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Channel {
/// 稳定版(默认渠道)。
Stable,
/// 公测版。
Beta,
/// 内测版。
Alpha,
/// 每日构建(收所有预发布,包括认不出的标识)。
Nightly,
}
impl Channel {
/// 全部渠道,从最稳到最激进。
pub const ALL: [Self; 4] = [Self::Stable, Self::Beta, Self::Alpha, Self::Nightly];
/// 清单文件名与日志用的稳定字符串 id(**不要改名**:清单里的 `channel` 字段就是它)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
Self::Alpha => "alpha",
Self::Nightly => "nightly",
}
}
/// 解析渠道名(大小写与首尾空白不敏感)。
///
/// # Errors
///
/// 认不出的名字返回 [`UpdateErrorKind::InvalidManifest`](清单里的 `channel` 写错了)。
pub fn parse(text: &str) -> Result<Self, UpdateError> {
let name = text.trim().to_ascii_lowercase();
Self::ALL
.into_iter()
.find(|channel| channel.as_str() == name)
.ok_or_else(|| {
let allowed = Self::ALL
.iter()
.map(|channel| channel.as_str())
.collect::<Vec<_>>()
.join(" / ");
UpdateError::new(
UpdateErrorKind::InvalidManifest,
format!("未知更新渠道 `{text}`(只认 {allowed})"),
)
})
}
/// 本渠道是否接受「发布自报 `release` 渠道」的那份版本。
///
/// 判定就是阶梯比较(`Ord` 的派生顺序即阶梯),不在别处另立一张表。
#[must_use]
pub fn admits(self, release: Self) -> bool {
release <= self
}
/// 本渠道是否接受该预发布阶段(正式版对所有渠道都成立)。
#[must_use]
pub const fn admits_stage(self, stage: Stage) -> bool {
matches!(
(self, stage),
(Self::Stable, Stage::Stable)
| (Self::Beta, Stage::Stable | Stage::ReleaseCandidate)
| (
Self::Alpha,
Stage::Stable | Stage::ReleaseCandidate | Stage::Beta
)
| (Self::Nightly, _)
)
}
}
impl fmt::Display for Channel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Channel {
type Err = UpdateError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
-204
View File
@@ -1,204 +0,0 @@
//! 检查更新:把「清单 + 当前版本 + 渠道 + 目标平台」收敛成一个结论。
//!
//! 判定顺序(每一步都是纯函数,可单测):
//!
//! 1. 取清单(源没有该渠道清单 = 还没发过版 = 已是最新);
//! 2. 清单自报渠道与请求渠道不符 → [`UpdateErrorKind::ChannelMismatch`](拿错文件是源的问题,
//! 不能悄悄当成「没有更新」);
//! 3. 准入过滤:[`Channel::admits`](清单渠道阶梯)+ [`Channel::admits_stage`](版本号里的
//! 预发布阶段)**同时**成立。清单可以撒谎,版本号不会;
//! 4. 只留版本号**严格大于**当前版本的候选(降级不做,nightly 想回退请手工下载);
//! 5. 按版本号从高到低找**第一个有本平台产物**的候选:命中就是 [`CheckOutcome::Available`],
//! 一个都没有则 [`CheckOutcome::MissingArtifact`](有新版但没给你的平台发包,这是发布侧的
//! 漏项,不能对用户说「已是最新」);
//! 6. 候选为空 → [`CheckOutcome::UpToDate`]。
//!
//! 强制更新([`Urgency::Forced`])有两个来源:发布自带的 `force`,或当前版本低于该发布的
//! `min_supported`(发布侧收回老版本支持)。两者都不看渠道——强制就是强制。
use std::path::Path;
use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, Release, ReleaseManifest};
use super::source::UpdateSource;
use super::target::Target;
use super::verify::{stage, StagedPackage};
use super::version::Version;
/// 这次更新急不急。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Urgency {
/// 可选:用户想升再升。
Optional,
/// 强制:`force` 或当前版本低于发布侧的最低支持版本。
Forced,
}
impl Urgency {
/// 稳定字符串 id(事件流用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Optional => "optional",
Self::Forced => "forced",
}
}
}
/// 一个可安装的候选:哪个发布、哪个产物、急不急。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateCandidate {
/// 发布信息(版本、说明、产物列表)。
pub release: Release,
/// 本平台要装的那个产物。
pub artifact: Artifact,
/// 急不急。
pub urgency: Urgency,
}
/// 检查更新的结论。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckOutcome {
/// 已是最新。
UpToDate {
/// 当前版本。
current: Version,
/// 该渠道内准入的最高版本(清单里没有可用发布时为 `None`)。
latest: Option<Version>,
},
/// 有可装的更新。
Available(Box<UpdateCandidate>),
/// 有更高的版本,但里面没有本平台的产物。
MissingArtifact {
/// 那个更高版本(没有本平台产物的那份发布)。
release: Box<Release>,
},
}
impl CheckOutcome {
/// 是否有可装的更新。
#[must_use]
pub const fn is_available(&self) -> bool {
matches!(self, Self::Available(_))
}
/// 可安装候选(没有则为 `None`)。
#[must_use]
pub fn candidate(&self) -> Option<&UpdateCandidate> {
match self {
Self::Available(candidate) => Some(candidate),
_ => None,
}
}
}
/// 检查更新的入口:绑定一个源与一个目标平台,之后每次检查只传「当前版本 + 渠道」。
#[derive(Debug, Clone)]
pub struct UpdateChecker<S> {
source: S,
target: Target,
}
impl<S: UpdateSource> UpdateChecker<S> {
/// 用显式目标平台构造(跨平台代查、测试都靠它)。
#[must_use]
pub fn new(source: S, target: Target) -> Self {
Self { source, target }
}
/// 用当前编译目标构造。
#[must_use]
pub fn for_current_target(source: S) -> Self {
Self::new(source, Target::current())
}
/// 绑定的源。
#[must_use]
pub const fn source(&self) -> &S {
&self.source
}
/// 绑定的目标平台。
#[must_use]
pub const fn target(&self) -> Target {
self.target
}
/// 查一次更新。
///
/// # Errors
///
/// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。
/// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。
pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> {
let Some(manifest) = self.source.fetch_manifest(channel)? else {
return Ok(CheckOutcome::UpToDate {
current: current.clone(),
latest: None,
});
};
if manifest.channel != channel {
return Err(UpdateError::new(
UpdateErrorKind::ChannelMismatch,
format!("请求的是 {channel} 渠道,清单自报 {}", manifest.channel),
));
}
let admitted = admitted(&manifest, channel).collect::<Vec<_>>();
let latest = admitted.iter().map(|release| release.version.clone()).max();
let mut candidates = admitted
.into_iter()
.filter(|release| release.version > *current)
.collect::<Vec<_>>();
candidates.sort_by(|left, right| right.version.cmp(&left.version));
for release in &candidates {
if let Some(artifact) = release.artifact_for(self.target) {
return Ok(CheckOutcome::Available(Box::new(UpdateCandidate {
urgency: urgency_of(release, current),
release: (*release).clone(),
artifact: artifact.clone(),
})));
}
}
match candidates.first() {
Some(release) => Ok(CheckOutcome::MissingArtifact {
release: Box::new((*release).clone()),
}),
None => Ok(CheckOutcome::UpToDate {
current: current.clone(),
latest,
}),
}
}
/// 把候选的包体取下来、落暂存、校验。成功返回的 [`StagedPackage`] 可直接交给
/// [`super::install::install_staged`]。
///
/// # Errors
///
/// 取包失败透传;长度或摘要不符分别返回 `SizeMismatch` / `DigestMismatch`,
/// 且暂存文件会被删掉(见 [`stage`])。
pub fn prepare(
&self,
candidate: &UpdateCandidate,
staging_dir: &Path,
) -> Result<StagedPackage, UpdateError> {
stage(&self.source, &candidate.artifact, staging_dir)
}
}
/// 该渠道准入的全部发布(不看版本高低)。
fn admitted(manifest: &ReleaseManifest, channel: Channel) -> impl Iterator<Item = &Release> {
manifest.releases.iter().filter(move |release| {
channel.admits(release.channel) && channel.admits_stage(release.version.stage())
})
}
fn urgency_of(release: &Release, current: &Version) -> Urgency {
if release.force || release.below_min_supported(current) {
Urgency::Forced
} else {
Urgency::Optional
}
}
-128
View File
@@ -1,128 +0,0 @@
//! 自更新错误:一份分类 + 一句人话 + 出错路径。
//!
//! 分类只服务三件事:给日志/事件一个稳定 id、判「要不要换源」、判「用户的包有没有被动过」。
//! core 不引 `thiserror`,手写 `Display`/`Error`(与 `download::error` 同一取舍)。
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use super::version::VersionError;
/// 自更新失败分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UpdateErrorKind {
/// 版本号解析失败(清单里写了不是 semver 的东西)。
InvalidVersion,
/// 清单结构不合法:缺字段、摘要格式错、同一清单里重复版本、`size` 为 0。
InvalidManifest,
/// 清单声明的渠道与请求的渠道不符(拿 stable 清单当 beta 用)。
ChannelMismatch,
/// 更新源没有这份数据:渠道清单文件、产物文件不存在。
SourceMissing,
/// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。
UnsupportedSource,
/// 本地 IO 失败:读源、写暂存、替换落盘。
Io,
/// 摘要不符:拿到的字节与清单声明的不是同一份。
DigestMismatch,
/// 长度不符:典型成因是下载被截断,也可能清单写错。
SizeMismatch,
}
impl UpdateErrorKind {
/// 稳定字符串 id(日志、事件流、诊断规则输入用;**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidVersion => "invalid_version",
Self::InvalidManifest => "invalid_manifest",
Self::ChannelMismatch => "channel_mismatch",
Self::SourceMissing => "source_missing",
Self::UnsupportedSource => "unsupported_source",
Self::Io => "io",
Self::DigestMismatch => "digest_mismatch",
Self::SizeMismatch => "size_mismatch",
}
}
/// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。
///
/// 清单本身不合法、协议不支持、目标平台无权写盘这三类换多少源都一样,
/// 所以只有字节层(摘要/长度)与 IO(可能是瞬时占用)算可重试。
#[must_use]
pub const fn is_retryable(self) -> bool {
matches!(self, Self::DigestMismatch | Self::SizeMismatch | Self::Io)
}
}
/// 自更新错误。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateError {
/// 分类。
pub kind: UpdateErrorKind,
/// 人类可读描述(进日志;用户可见文案由上层按 kind 组织,别直接贴这里)。
pub message: String,
/// 出错的文件或目录(能定位到就带上)。
pub path: Option<PathBuf>,
}
impl UpdateError {
/// 构造错误。
#[must_use]
pub fn new(kind: UpdateErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
path: None,
}
}
/// 清单不合法。
#[must_use]
pub fn invalid_manifest(message: impl Into<String>) -> Self {
Self::new(UpdateErrorKind::InvalidManifest, message)
}
/// 标注出错路径。
#[must_use]
pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
/// 分类是否可重试。
#[must_use]
pub const fn is_retryable(&self) -> bool {
self.kind.is_retryable()
}
/// 把底层 IO 错误包成「某个路径上的 IO 失败」。
#[must_use]
pub fn io_at(path: &Path, source: &io::Error) -> Self {
Self::new(UpdateErrorKind::Io, source.to_string()).with_path(path)
}
}
impl fmt::Display for UpdateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.path {
Some(path) => write!(
f,
"[{}] {}({})",
self.kind.as_str(),
self.message,
path.display()
),
None => write!(f, "[{}] {}", self.kind.as_str(), self.message),
}
}
}
impl std::error::Error for UpdateError {}
impl From<VersionError> for UpdateError {
fn from(error: VersionError) -> Self {
Self::new(UpdateErrorKind::InvalidVersion, error.to_string())
}
}
-268
View File
@@ -1,268 +0,0 @@
//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。
//!
//! # 为什么不用「直接覆盖」
//!
//! Windows 上**正在运行的 exe** 不能被覆盖、也不能被删除(映像被占用,改字节这条路根本
//! 不存在);`std::fs::rename` 走 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`,目标只要被任何
//! 「不共享删除」的句柄占着就报共享冲突(`os error 32`)。剩下唯一可用的动作是**改名**:
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
//!
//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,
//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了):
//!
//! ```text
//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地
//! 2. rename(暂存包 → dest) 新版本就位
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
//! ```
//!
//! # 本模块**不**负责的事
//!
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
//! [`InstallReceipt::remove_backup`])。
//!
//! # 单测覆盖到哪
//!
//! 占用场景是**真的造出来**的:测试里用 `share_mode(FILE_SHARE_READ)` 打开目标文件
//! (**不带** `FILE_SHARE_DELETE`,与 Windows 加载器打开正在运行的映像时一致),
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
//!
//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
//! 写这类用例时必须显式指定 `share_mode`。
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::error::UpdateError;
use super::verify::StagedPackage;
/// 同进程内备份文件名去重序号。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 替换策略:改名失败时重试几次、每次间隔多久。
///
/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上
/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplacePolicy {
/// 总尝试次数(含首次),`0` 按 `1` 处理。
pub attempts: u32,
/// 两次尝试之间的等待。
pub delay: Duration,
}
impl Default for ReplacePolicy {
fn default() -> Self {
Self {
attempts: 5,
delay: Duration::from_millis(80),
}
}
}
impl ReplacePolicy {
/// 构造。
#[must_use]
pub const fn new(attempts: u32, delay: Duration) -> Self {
Self { attempts, delay }
}
}
/// 替换成功后的收据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallReceipt {
/// 新版本所在的路径。
pub dest: PathBuf,
/// 旧版本被挪到哪(原本没有文件时为 `None`)。
pub backup: Option<PathBuf>,
/// 新版本字节数。
pub bytes: u64,
}
impl InstallReceipt {
/// 删掉旧版本备份(新版启动成功之后调用)。
///
/// # Errors
///
/// 删不掉(还占着、没权限)返回带路径的 [`UpdateError`]——这时**别**当成更新失败,
/// 只是留了个垃圾文件,下次启动还能再删。
pub fn remove_backup(&self) -> Result<(), UpdateError> {
let Some(backup) = &self.backup else {
return Ok(());
};
fs::remove_file(backup).map_err(|error| UpdateError::io_at(backup, &error))
}
/// 手动回滚:把备份搬回 `dest`(新版起不来时用)。成功后 `backup` 字段清空。
///
/// # Errors
///
/// 没有备份时是空操作 `Ok(())`;搬不动返回 [`UpdateError`](此时 `backup` 保持不变,
/// 旧版本还在原位置,可以再试)。
pub fn rollback(&mut self, policy: ReplacePolicy) -> Result<(), UpdateError> {
let Some(backup) = self.backup.as_ref() else {
return Ok(());
};
rename_with_retry(backup, &self.dest, policy)
.map_err(|error| UpdateError::io_at(&self.dest, &error))?;
self.backup = None;
Ok(())
}
}
/// 替换失败:原始错误 + 回滚结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallFailure {
/// 原始失败。
pub error: UpdateError,
/// 旧版本是否已回到原路径(`true` = 目标没被破坏;本来没有旧文件时也算 `true`)。
pub rolled_back: bool,
/// 回滚失败时旧版本留在哪(`None` = 已回到原位或压根没有旧文件)。
pub backup_left: Option<PathBuf>,
}
impl InstallFailure {
/// 目标是否可能处于「既不是旧版也不是新版」的坏状态(需要人工介入)。
#[must_use]
pub const fn needs_manual_repair(&self) -> bool {
!self.rolled_back
}
}
impl fmt::Display for InstallFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error)?;
if self.rolled_back {
f.write_str("(已回滚,旧版本仍在原位)")
} else {
let where_ = self.backup_left.as_ref().map_or_else(String::new, |path| {
format!(",旧文件留在 {}", path.display())
});
write!(f, "(回滚失败,需要人工处理{where_})")
}
}
}
impl std::error::Error for InstallFailure {}
/// 把校验过的暂存包装到 `dest`;失败则回滚,旧版本保持可用。
///
/// # Errors
///
/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`],
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
pub fn install_staged(
staged: &StagedPackage,
dest: &Path,
policy: ReplacePolicy,
) -> Result<InstallReceipt, InstallFailure> {
if let Some(parent) = dest
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?;
}
let mut backup = None;
if fs::symlink_metadata(dest).is_ok() {
let path = backup_path(dest);
rename_with_retry(dest, &path, policy)
.map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?;
backup = Some(path);
}
match rename_with_retry(staged.path(), dest, policy) {
Ok(()) => Ok(InstallReceipt {
dest: dest.to_path_buf(),
backup,
bytes: staged.size(),
}),
Err(error) => {
let error = UpdateError::io_at(dest, &error);
let Some(backup_path) = backup else {
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
return Err(failed(error, true, None));
};
match rename_with_retry(&backup_path, dest, policy) {
Ok(()) => Err(failed(error, true, None)),
Err(_) => Err(failed(error, false, Some(backup_path))),
}
}
}
}
fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -> InstallFailure {
InstallFailure {
error,
rolled_back,
backup_left,
}
}
/// 旧版本的备份路径:`<dest 同目录>/.<文件名>.ullold-<pid>-<seq>`。
///
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
/// 必须同目录,因为 `rename` **不能跨卷**(Unix 返回 `EXDEV`,Windows 返回
/// `ERROR_NOT_SAME_DEVICE`——`std` 的 `fs::rename` 不带 `MOVEFILE_COPY_ALLOWED`,
/// 不会偷偷降级成复制)。
#[must_use]
pub fn backup_path(dest: &Path) -> PathBuf {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let name = dest.file_name().map_or_else(
|| String::from("ull"),
|name| name.to_string_lossy().into_owned(),
);
let backup = format!(".{name}.ullold-{}-{seq}", std::process::id());
match dest.parent() {
Some(parent) => parent.join(backup),
None => PathBuf::from(backup),
}
}
/// 改名,失败时按策略重试(占用类错误才值得重试)。
fn rename_with_retry(from: &Path, to: &Path, policy: ReplacePolicy) -> io::Result<()> {
let attempts = policy.attempts.max(1);
let mut last = None;
for attempt in 0..attempts {
match fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(error) => {
let retryable = is_transient(&error) && attempt + 1 < attempts;
if !retryable {
return Err(error);
}
std::thread::sleep(policy.delay);
last = Some(error);
}
}
}
Err(last.unwrap_or_else(|| io::Error::other("rename 未执行")))
}
/// 是否属于「等一会儿可能就好了」的失败。
///
/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind),
/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。
fn is_transient(error: &io::Error) -> bool {
matches!(
error.kind(),
io::ErrorKind::PermissionDenied
| io::ErrorKind::Other
| io::ErrorKind::Interrupted
| io::ErrorKind::WouldBlock
)
}
-263
View File
@@ -1,263 +0,0 @@
//! 发布清单模型:一个渠道一份 JSON,列出该渠道已知的所有发布与各平台的产物。
//!
//! 字段形状取自 `DevDocs/analyse/07-settings-packaging-update-p2.md` §6.2 的扩展清单,
//! 但**只留与更新源无关的部分**:清单里没有任何 CDN 常量,`url` 是字符串,怎么解析由
//! [`crate::update::UpdateSource`] 的实现决定。
//!
//! ```json
//! {
//! "channel": "stable",
//! "releases": [
//! {
//! "version": "0.3.0",
//! "channel": "stable",
//! "released_at": "2026-01-02T03:04:05Z",
//! "notes": "修掉启动崩溃",
//! "force": false,
//! "min_supported": "0.2.0",
//! "artifacts": [
//! {"target": "win-x64", "format": "portable-zip", "url": "ull-0.3.0-win-x64.zip",
//! "size": 22100000, "sha256": "…"}
//! ]
//! }
//! ]
//! }
//! ```
//!
//! 读的方向刻意严格(`deny_unknown_fields` + 逐字段校验):清单是我们自己发的东西,
//! 拼错的字段名宁可在 CI 上炸掉,也别在用户机器上变成「查不到更新」。
//!
//! # 校验规则(`from_json` 会一次性查完)
//!
//! * `version` / `min_supported` 必须是合法 semver;
//! * 同一份清单里 `version` 不许重复(重复意味着「升到哪个」没有确定答案);
//! * `size` 必须 > 0(0 字节的包只可能是发布脚本写漏了);
//! * 每个产物必须带 `sha256` 或 `sha1`,且长度/字符集合法(两者都带时以 `sha256` 为准);
//! * `url` 不许为空。
//!
//! `released_at` / `notes` 只做展示,不解析成时间类型(core 不引时间库,排序只看版本号)。
use serde::Deserialize;
use crate::download::Integrity;
use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::target::{PackageFormat, Target};
use super::version::Version;
/// 一个渠道的发布清单。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseManifest {
/// 这份清单属于哪个渠道。
pub channel: Channel,
/// 已发布的版本(顺序无所谓:选版本的是 checker,不是清单的书写顺序)。
pub releases: Vec<Release>,
}
impl ReleaseManifest {
/// 解析清单 JSON 并做完整校验。
///
/// # Errors
///
/// JSON 语法错、字段名拼错、版本号/摘要/长度不合法都返回 [`UpdateError`],
/// 分类是 [`UpdateErrorKind::InvalidManifest`] 或 [`UpdateErrorKind::InvalidVersion`]。
pub fn from_json(text: &str) -> Result<Self, UpdateError> {
let raw: RawManifest = serde_json::from_str(text).map_err(|error| {
UpdateError::invalid_manifest(format!("清单 JSON 解析失败:{error}"))
})?;
let channel = Channel::parse(&raw.channel)?;
let mut releases = Vec::with_capacity(raw.releases.len());
for raw_release in raw.releases {
let release = Release::from_raw(channel, raw_release)?;
if releases
.iter()
.any(|existing: &Release| existing.version == release.version)
{
return Err(UpdateError::invalid_manifest(format!(
"清单 {} 里版本 {} 出现多次",
channel, release.version
)));
}
releases.push(release);
}
Ok(Self { channel, releases })
}
/// 清单里该目标平台的最高可用版本(不看渠道准入,纯查表;选择逻辑在 checker)。
#[must_use]
pub fn newest_artifact_for(&self, target: Target) -> Option<(&Release, &Artifact)> {
self.releases
.iter()
.filter_map(|release| {
release
.artifact_for(target)
.map(|artifact| (release, artifact))
})
.max_by(|left, right| left.0.version.cmp(&right.0.version))
}
}
/// 一次发布。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Release {
/// 版本号。
pub version: Version,
/// 这次发布自报的渠道(可以比清单渠道更稳,例如 beta 清单里放一个 stable 补丁版)。
pub channel: Channel,
/// 发布时间(RFC3339 字符串,仅展示)。
pub released_at: Option<String>,
/// 更新说明(纯文本,仅展示)。
pub notes: Option<String>,
/// 是否强制更新(不看版本号也得升)。
pub force: bool,
/// 客户端最低支持版本:当前版本低于它就必须升(发布侧收回老版本的支持)。
pub min_supported: Option<Version>,
/// 各平台的产物。清单里的顺序 = 同一目标多形态时的优先级。
pub artifacts: Vec<Artifact>,
}
impl Release {
/// 该目标平台的首选产物(清单顺序即优先级;一个都没有就返回 `None`)。
#[must_use]
pub fn artifact_for(&self, target: Target) -> Option<&Artifact> {
self.artifacts
.iter()
.find(|artifact| artifact.target == target)
}
/// 当前版本是否低于本发布要求的最低支持版本。
#[must_use]
pub fn below_min_supported(&self, current: &Version) -> bool {
self.min_supported
.as_ref()
.is_some_and(|minimum| current < minimum)
}
fn from_raw(manifest_channel: Channel, raw: RawRelease) -> Result<Self, UpdateError> {
let version = Version::parse(&raw.version)?;
let channel = match &raw.channel {
Some(text) => Channel::parse(text)?,
None => manifest_channel,
};
let min_supported = raw
.min_supported
.as_deref()
.map(Version::parse)
.transpose()?;
let mut artifacts = Vec::with_capacity(raw.artifacts.len());
for raw_artifact in raw.artifacts {
artifacts.push(Artifact::from_raw(&version, raw_artifact)?);
}
Ok(Self {
version,
channel,
released_at: raw.released_at,
notes: raw.notes,
force: raw.force,
min_supported,
artifacts,
})
}
}
/// 一份可下载的产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
/// 目标平台。
pub target: Target,
/// 包形态。
pub format: PackageFormat,
/// 更新源内部的地址(怎么解释由源实现决定:相对路径 / `file://` / `https://`)。
pub url: String,
/// 字节数(下载后必须**恰好**这么多,短了就是截断)。
pub size: u64,
/// 期望摘要(已归一化为小写十六进制)。
pub integrity: Integrity,
}
impl Artifact {
fn from_raw(version: &Version, raw: RawArtifact) -> Result<Self, UpdateError> {
let target = Target::parse(&raw.target).ok_or_else(|| {
UpdateError::invalid_manifest(format!(
"版本 {version} 的产物 target `{}` 无法识别(形如 win-x64 / mac-arm64 / linux-x64)",
raw.target
))
})?;
let format = PackageFormat::parse(&raw.format).ok_or_else(|| {
UpdateError::invalid_manifest(format!(
"版本 {version} 的产物 format `{}` 无法识别",
raw.format
))
})?;
if raw.url.trim().is_empty() {
return Err(UpdateError::invalid_manifest(format!(
"版本 {version}({target})的产物 url 为空"
)));
}
if raw.size == 0 {
return Err(UpdateError::invalid_manifest(format!(
"版本 {version}({target})的产物 size 为 0"
)));
}
let integrity = match (raw.sha256, raw.sha1) {
(Some(hex), _) => Integrity::sha256(hex),
(None, Some(hex)) => Integrity::sha1(hex),
(None, None) => {
return Err(UpdateError::invalid_manifest(format!(
"版本 {version}({target})的产物既没有 sha256 也没有 sha1"
)));
}
};
if !integrity.is_well_formed() {
return Err(UpdateError::new(
UpdateErrorKind::InvalidManifest,
format!(
"版本 {version}({target})的 {} 摘要格式不合法(应为 {} 位十六进制)",
integrity.algo.as_str(),
integrity.algo.hex_len()
),
));
}
Ok(Self {
target,
format,
url: raw.url,
size: raw.size,
integrity,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawManifest {
channel: String,
#[serde(default)]
releases: Vec<RawRelease>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawRelease {
version: String,
channel: Option<String>,
released_at: Option<String>,
notes: Option<String>,
#[serde(default)]
force: bool,
min_supported: Option<String>,
#[serde(default)]
artifacts: Vec<RawArtifact>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawArtifact {
target: String,
format: String,
url: String,
size: u64,
sha256: Option<String>,
sha1: Option<String>,
}
-66
View File
@@ -1,66 +0,0 @@
//! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。
//!
//! 用户已定的主线是「安装包 + 自动更新通道」,但**更新源还没拍板**。所以这一层只做与源无关
//! 的部分,源本身是插拔的 [`UpdateSource`]:core 里没有任何 CDN 常量、没有任何厂商名,
//! 接哪家由上层注入(本地目录源 [`DirSource`] 是内置的对照/测试实现)。
//!
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
//!
//! # 一条完整的更新链
//!
//! ```text
//! UpdateChecker::check(current, channel) 清单 + 版本 + 渠道 + 目标平台 → 结论
//! │ Available(candidate)
//! ├─ UpdateChecker::prepare(candidate, staging) 取包 → 长度/摘要双检
//! │ │ StagedPackage(校验通过,失败已自动清理)
//! │ └─ install::install_staged(...) 改名旧版 → 搬入新版(失败回滚)
//! └─ UpToDate / MissingArtifact
//! ```
//!
//! # 分层
//!
//! ```text
//! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
//! source UpdateSource trait + 本地目录源 verify 暂存 + 长度/摘要校验
//! checker 选择哪一份发布、急不急 install 原子替换 + 回滚
//! error 分类与文案
//! ```
//!
//! # 刻意不做的事
//!
//! * **不碰 UI**:本模块只产出可序列化的判定结果与错误分类,弹窗/进度/文案排版在壳层。
//! 入口在 `crates/presenter`/`crates/app-iced` 接,`crates/core` 保持零 UI 依赖。
//! * **不自己替换正在运行的 exe**:那需要「退出自身 → 另一个进程搬运」(HMCL 的
//! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。
//! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。
//! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。
pub mod channel;
pub mod checker;
pub mod error;
pub mod install;
pub mod manifest;
pub mod source;
pub mod target;
pub mod verify;
pub mod version;
pub use crate::download::{HashAlgo, Integrity};
pub use crate::platform::os_info::OsFamily;
pub use self::channel::Channel;
pub use self::checker::{CheckOutcome, UpdateCandidate, UpdateChecker, Urgency};
pub use self::error::{UpdateError, UpdateErrorKind};
pub use self::install::{
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
};
pub use self::manifest::{Artifact, Release, ReleaseManifest};
pub use self::source::{DirSource, UpdateSource};
pub use self::target::{Arch, PackageFormat, Target};
pub use self::verify::{stage, verify_file, StagedPackage};
pub use self::version::{Stage, Version, VersionError};
#[cfg(test)]
mod tests;
-174
View File
@@ -1,174 +0,0 @@
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
//!
//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里
//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或
//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。
//!
//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、
//! 以及给将来的 HTTP 实现当对照物。
//!
//! # 本地源的目录约定
//!
//! ```text
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
//! <root>/beta.json
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
//! ```
//!
//! 清单里的 `url` 支持两种写法:`file://` 绝对地址,或相对 `<root>` 的路径。
//! 出现 `http://` / `https://` 一律 [`UpdateErrorKind::UnsupportedSource`]——那是别的
//! 实现的活,本地源不该假装能取远端字节。
use std::fs;
use std::path::{Path, PathBuf};
use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest};
/// 更新源:清单与包体的来源。
///
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
/// 同步签名是为了让 CLI 与 GUI 都能直接调用,异步实现自己在方法里桥接。
pub trait UpdateSource: Send + Sync {
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
fn source_id(&self) -> &str;
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。
///
/// # Errors
///
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>;
/// 把产物取到 `dest`,返回写入的字节数。
///
/// 实现只管「把字节放到 dest」;长度与摘要校验由 [`crate::update::verify`] 统一做,
/// 别在实现里各写一套。
///
/// # Errors
///
/// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
}
/// 本地目录源(也接受 `file://` 根地址)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirSource {
root: PathBuf,
id: String,
}
impl DirSource {
/// 用本地目录构造。
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let id = format!("file:{}", root.display());
Self { root, id }
}
/// 用 `file://` 地址构造(`file:///D:/dist`、`file:///srv/ull`)。
///
/// # Errors
///
/// 不是 `file://` 开头的地址返回 [`UpdateErrorKind::UnsupportedSource`]。
pub fn from_url(url: &str) -> Result<Self, UpdateError> {
let rest = url
.strip_prefix("file://")
.ok_or_else(|| unsupported_source(url))?;
Ok(Self::new(file_url_to_path(rest)))
}
/// 根目录。
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
/// 某渠道的清单文件路径。
#[must_use]
pub fn manifest_path(&self, channel: Channel) -> PathBuf {
self.root.join(format!("{}.json", channel.as_str()))
}
/// 把清单里的 `url` 解析成源侧本地路径。
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
if let Some(rest) = url.strip_prefix("file://") {
return Ok(file_url_to_path(rest));
}
if url.contains("://") {
return Err(unsupported_source(url));
}
Ok(self.root.join(url))
}
}
fn unsupported_source(url: &str) -> UpdateError {
let hint = if url.starts_with("http://") || url.starts_with("https://") {
"本地目录源不取远端字节,请换用实现远端源的 UpdateSource"
} else {
"只支持本地相对路径与 file:// 地址"
};
UpdateError::new(
UpdateErrorKind::UnsupportedSource,
format!("更新源不支持地址 `{url}`:{hint}"),
)
}
fn file_url_to_path(rest: &str) -> PathBuf {
if cfg!(windows) {
// file:///D:/dist → 去掉盘符前的斜杠;Windows 上没有「以 / 开头的当前盘路径」这种写法。
PathBuf::from(rest.trim_start_matches('/'))
} else {
PathBuf::from(rest)
}
}
impl UpdateSource for DirSource {
fn source_id(&self) -> &str {
&self.id
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
let path = self.manifest_path(channel);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(UpdateError::io_at(&path, &source)),
};
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?;
if manifest.channel != channel {
return Err(UpdateError::new(
UpdateErrorKind::ChannelMismatch,
format!(
"请求的是 {} 渠道,清单 {} 自报 {}",
channel,
path.display(),
manifest.channel
),
)
.with_path(&path));
}
Ok(Some(manifest))
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
let from = self.resolve(&artifact.url)?;
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|error| UpdateError::io_at(parent, &error))?;
}
}
match fs::copy(&from, dest) {
Ok(bytes) => Ok(bytes),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Err(UpdateError::new(
UpdateErrorKind::SourceMissing,
format!("源里没有产物 `{}`", artifact.url),
)
.with_path(&from)),
Err(source) => Err(UpdateError::io_at(&from, &source)),
}
}
}
-196
View File
@@ -1,196 +0,0 @@
//! 产物目标平台与包格式:清单里 `target` / `format` 两个字段的取值。
//!
//! 系统家族直接复用 [`OsFamily`](`core::platform` 的口径),这里不另立一套
//! Windows/macOS/Linux 枚举——否则同一件事会有两个真相源。
//!
//! `target` 的清单写法是 `系统-架构`,例如 `win-x64` / `mac-arm64` / `linux-arm64`。
//! 解析容忍常见别名(`windows` / `macos` / `osx`、`x86_64` / `amd64`、`aarch64`),
//! 因为清单是人(或 CI 脚本)拼出来的,别让 `win-amd64` 这种写法静默变成「没有可用产物」。
use std::fmt;
use crate::platform::os_info::OsFamily;
/// 产物架构。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Arch {
/// x86_64 / amd64。
X64,
/// aarch64 / arm64。
Arm64,
/// i686 / x86 32 位。
X86,
/// 编译目标认不出的架构(对照组永远匹配不上,于是走「本平台没有产物」那条路)。
Unknown,
}
impl Arch {
/// 清单写法。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::X64 => "x64",
Self::Arm64 => "arm64",
Self::X86 => "x86",
Self::Unknown => "unknown",
}
}
/// 当前编译目标的架构。
#[must_use]
pub const fn current() -> Self {
if cfg!(target_arch = "x86_64") {
Self::X64
} else if cfg!(target_arch = "aarch64") {
Self::Arm64
} else if cfg!(target_arch = "x86") {
Self::X86
} else {
Self::Unknown
}
}
/// 解析清单写法(容忍 `x86_64` / `amd64` / `aarch64` / `i686` 等别名)。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
match text.trim().to_ascii_lowercase().as_str() {
"x64" | "x86_64" | "x86-64" | "amd64" => Some(Self::X64),
"arm64" | "aarch64" | "arm64e" => Some(Self::Arm64),
"x86" | "i686" | "i386" | "x86_32" => Some(Self::X86),
_ => None,
}
}
}
/// 产物目标平台(系统 + 架构)。
///
/// 没派生 `Hash`/`Ord`:`OsFamily` 尚未派生这两个 trait(`core::platform` 不在本 mission
/// 的改动范围内),需要当 Map 键时再回头补,别在这里另造一套系统枚举绕过去。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
/// 系统家族。
pub os: OsFamily,
/// 架构。
pub arch: Arch,
}
impl Target {
/// 构造。
#[must_use]
pub const fn new(os: OsFamily, arch: Arch) -> Self {
Self { os, arch }
}
/// 当前编译目标。
#[must_use]
pub fn current() -> Self {
Self::new(OsFamily::current(), Arch::current())
}
/// 解析清单写法(`系统-架构`)。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
let (os, arch) = text.trim().split_once('-')?;
Some(Self::new(parse_os(os)?, Arch::parse(arch)?))
}
/// 清单写法。
#[must_use]
pub const fn as_str(self) -> &'static str {
match (self.os, self.arch) {
(OsFamily::Windows, Arch::X64) => "win-x64",
(OsFamily::Windows, Arch::Arm64) => "win-arm64",
(OsFamily::Windows, Arch::X86) => "win-x86",
(OsFamily::Windows, Arch::Unknown) => "win-unknown",
(OsFamily::MacOs, Arch::X64) => "mac-x64",
(OsFamily::MacOs, Arch::Arm64) => "mac-arm64",
(OsFamily::MacOs, Arch::X86) => "mac-x86",
(OsFamily::MacOs, Arch::Unknown) => "mac-unknown",
(OsFamily::Linux, Arch::X64) => "linux-x64",
(OsFamily::Linux, Arch::Arm64) => "linux-arm64",
(OsFamily::Linux, Arch::X86) => "linux-x86",
(OsFamily::Linux, Arch::Unknown) => "linux-unknown",
}
}
}
fn parse_os(text: &str) -> Option<OsFamily> {
match text.trim().to_ascii_lowercase().as_str() {
"win" | "windows" => Some(OsFamily::Windows),
"mac" | "macos" | "osx" | "darwin" => Some(OsFamily::MacOs),
"linux" => Some(OsFamily::Linux),
_ => None,
}
}
impl fmt::Display for Target {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// 安装包形态。
///
/// 只区分「安装器装出来的」与「解压即用的」两大类,具体怎么装由打包层决定:
/// 安装器装的实例(msi/dmg/deb/rpm)本模块**不主张**原地覆盖(见 `install` 模块文档),
/// 便携形态(portable-zip / appimage / portable-exe)才走自替换。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PackageFormat {
/// Windows 安装器。
Msi,
/// macOS 磁盘映像。
Dmg,
/// Linux AppImage。
AppImage,
/// Debian 包。
Deb,
/// RPM 包。
Rpm,
/// 便携 zip(解压即用)。
PortableZip,
/// 便携单文件 exe。
PortableExe,
}
impl PackageFormat {
/// 清单写法。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Msi => "msi",
Self::Dmg => "dmg",
Self::AppImage => "appimage",
Self::Deb => "deb",
Self::Rpm => "rpm",
Self::PortableZip => "portable-zip",
Self::PortableExe => "portable-exe",
}
}
/// 解析清单写法。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
match text.trim().to_ascii_lowercase().as_str() {
"msi" => Some(Self::Msi),
"dmg" => Some(Self::Dmg),
"appimage" => Some(Self::AppImage),
"deb" => Some(Self::Deb),
"rpm" => Some(Self::Rpm),
"portable-zip" | "zip" => Some(Self::PortableZip),
"portable-exe" | "exe" => Some(Self::PortableExe),
_ => None,
}
}
/// 是否需要安装器代劳(这些形态不该被本模块原地覆盖)。
#[must_use]
pub const fn needs_installer(self) -> bool {
matches!(self, Self::Msi | Self::Dmg | Self::Deb | Self::Rpm)
}
}
impl fmt::Display for PackageFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
@@ -1,57 +0,0 @@
//! 渠道阶梯与准入:清单渠道比大小,预发布阶段按表收。
use crate::update::{Channel, Stage};
#[test]
fn parses_names_loosely() {
assert_eq!(Channel::parse("stable").expect("stable"), Channel::Stable);
assert_eq!(Channel::parse(" STABLE ").expect("大写"), Channel::Stable);
assert_eq!(Channel::parse("Beta").expect("beta"), Channel::Beta);
assert_eq!(
Channel::parse("nightly").expect("nightly"),
Channel::Nightly
);
let error = Channel::parse("canary").expect_err("canary 不认");
assert!(error.to_string().contains("canary"));
assert_eq!(
Channel::ALL.map(Channel::as_str),
["stable", "beta", "alpha", "nightly"]
);
}
#[test]
fn ladder_admits_only_more_stable_releases() {
assert!(Channel::Stable.admits(Channel::Stable));
assert!(!Channel::Stable.admits(Channel::Beta));
assert!(Channel::Beta.admits(Channel::Stable));
assert!(Channel::Beta.admits(Channel::Beta));
assert!(!Channel::Beta.admits(Channel::Alpha));
assert!(!Channel::Alpha.admits(Channel::Nightly));
assert!(Channel::Alpha.admits(Channel::Beta));
assert!(Channel::Nightly.admits(Channel::Alpha));
assert!(Channel::Nightly.admits(Channel::Stable));
}
#[test]
fn prerelease_stages_are_gated_by_channel() {
// 正式版对所有渠道都成立
for channel in Channel::ALL {
assert!(channel.admits_stage(Stage::Stable), "{channel}");
}
assert!(!Channel::Stable.admits_stage(Stage::ReleaseCandidate));
assert!(Channel::Beta.admits_stage(Stage::ReleaseCandidate));
assert!(!Channel::Beta.admits_stage(Stage::Beta));
assert!(Channel::Alpha.admits_stage(Stage::Beta));
assert!(!Channel::Alpha.admits_stage(Stage::Alpha));
assert!(!Channel::Alpha.admits_stage(Stage::Unknown));
assert!(Channel::Nightly.admits_stage(Stage::Alpha));
assert!(Channel::Nightly.admits_stage(Stage::Unknown));
}
#[test]
fn display_and_from_str_agree() {
for channel in Channel::ALL {
assert_eq!(channel.as_str().parse::<Channel>().expect("回读"), channel);
assert_eq!(channel.to_string(), channel.as_str());
}
}
@@ -1,245 +0,0 @@
//! 检查更新:渠道准入、版本高低、目标平台漏发、强制更新。
use crate::platform::os_info::OsFamily;
use crate::update::{
Channel, CheckOutcome, DirSource, Target, UpdateChecker, UpdateErrorKind, UpdateSource,
Urgency, Version,
};
use super::support::{artifact, manifest, release, TempDir};
const PAYLOAD: &[u8] = b"checker payload";
fn target() -> Target {
Target::new(OsFamily::Windows, crate::update::Arch::X64)
}
/// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。
fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) {
let dir = TempDir::new("checker");
dir.write(&format!("{}.json", channel.as_str()), json.as_bytes());
let source = DirSource::new(dir.path());
(dir, source)
}
fn check_outcome(json: &str, current: &str, channel: Channel) -> CheckOutcome {
let (_dir, source) = source_with(json, channel);
let version = Version::parse(current).expect("当前版本应可解析");
UpdateChecker::new(source, target())
.check(&version, channel)
.expect("检查不该失败")
}
fn stable_release(version: &str) -> String {
manifest(
"stable",
&[release(
version,
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
)
}
#[test]
fn offers_the_numeric_newer_version() {
// 0.9 → 0.10 的字典序陷阱在 checker 这一层也要挡住
let outcome = check_outcome(&stable_release("0.10.0"), "0.9.0", Channel::Stable);
let candidate = outcome.candidate().expect("应有更新");
assert_eq!(candidate.release.version.to_string(), "0.10.0");
assert_eq!(
candidate.artifact.format,
crate::update::PackageFormat::PortableZip
);
assert_eq!(candidate.urgency, Urgency::Optional);
}
#[test]
fn same_or_older_release_is_up_to_date() {
let outcome = check_outcome(&stable_release("0.2.1"), "0.2.1", Channel::Stable);
match outcome {
CheckOutcome::UpToDate { latest, .. } => {
assert_eq!(latest.map(|v| v.to_string()).as_deref(), Some("0.2.1"));
}
other => panic!("同等版本应已是最新,实际 {other:?}"),
}
let outcome = check_outcome(&stable_release("0.2.0"), "0.3.0", Channel::Stable);
assert!(!outcome.is_available(), "不该提示降级");
}
#[test]
fn missing_manifest_means_up_to_date() {
let tmp = TempDir::new("checker-empty");
let source = DirSource::new(tmp.path());
let current = Version::parse("0.2.1").expect("版本");
let outcome = UpdateChecker::new(source, target())
.check(&current, Channel::Stable)
.expect("没清单不是错误");
match outcome {
CheckOutcome::UpToDate { latest, .. } => assert_eq!(latest, None),
other => panic!("应为已是最新,实际 {other:?}"),
}
}
#[test]
fn prerelease_is_gated_by_channel_stage() {
let json = manifest(
"beta",
&[release(
"1.0.0-rc.1",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
// beta 渠道收 rc
let outcome = check_outcome(&json, "0.9.0", Channel::Beta);
assert_eq!(
outcome.candidate().map(|c| c.release.version.to_string()),
Some("1.0.0-rc.1".to_string())
);
// alpha 发布在 beta 渠道里不收(阶段阶梯)
let alpha_json = manifest(
"beta",
&[release(
"1.1.0-alpha.1",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
let outcome = check_outcome(&alpha_json, "0.9.0", Channel::Beta);
assert!(!outcome.is_available(), "beta 渠道不该收 alpha 预发布");
}
#[test]
fn stable_channel_never_takes_a_prerelease_even_if_the_manifest_claims_stable() {
let json = manifest(
"stable",
&[release(
"1.0.0-beta.2",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
let outcome = check_outcome(&json, "0.9.0", Channel::Stable);
assert!(
!outcome.is_available(),
"清单可以写错,版本号不会:正式版渠道不许收预发布"
);
}
#[test]
fn forced_update_comes_from_force_or_min_supported() {
let json = manifest(
"stable",
&[release(
"0.3.0",
r#","force":true"#,
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
assert_eq!(
check_outcome(&json, "0.2.0", Channel::Stable)
.candidate()
.map(|candidate| candidate.urgency),
Some(Urgency::Forced)
);
let json = manifest(
"stable",
&[release(
"0.3.0",
r#","min_supported":"0.2.5""#,
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
assert_eq!(
check_outcome(&json, "0.2.0", Channel::Stable)
.candidate()
.map(|candidate| candidate.urgency),
Some(Urgency::Forced),
"低于最低支持版本 = 强制"
);
assert_eq!(
check_outcome(&json, "0.2.5", Channel::Stable)
.candidate()
.map(|candidate| candidate.urgency),
Some(Urgency::Optional),
"刚好等于最低支持版本不算强制"
);
}
#[test]
fn missing_target_artifact_is_reported_not_hidden() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("linux-x64", "appimage", "a.AppImage", PAYLOAD)],
)],
);
let outcome = check_outcome(&json, "0.2.0", Channel::Stable);
match outcome {
CheckOutcome::MissingArtifact { release } => {
assert_eq!(release.version.to_string(), "0.3.0");
}
other => panic!("应为「有新版但没本平台产物」,实际 {other:?}"),
}
}
#[test]
fn falls_back_to_the_newest_release_that_does_have_the_target() {
let json = manifest(
"stable",
&[
release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
),
release(
"0.4.0",
"",
&[artifact("linux-x64", "appimage", "b.AppImage", PAYLOAD)],
),
],
);
let outcome = check_outcome(&json, "0.2.0", Channel::Stable);
assert_eq!(
outcome.candidate().map(|c| c.release.version.to_string()),
Some("0.3.0".to_string()),
"0.4.0 没 win-x64 产物,应退回到 0.3.0"
);
}
#[test]
fn channel_mismatch_in_the_source_is_an_error() {
let tmp = TempDir::new("checker-mismatch");
tmp.write("stable.json", manifest("beta", &[]).as_bytes());
let source = crate::update::DirSource::new(tmp.path());
let current = crate::update::Version::parse("0.2.1").expect("版本");
let error = UpdateChecker::new(source, target())
.check(&current, Channel::Stable)
.expect_err("清单渠道不符应报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
}
#[test]
fn prepare_stages_the_selected_artifact() {
let tmp = TempDir::new("checker-prepare");
tmp.write("a.zip", PAYLOAD);
tmp.write("stable.json", stable_release("0.3.0").as_bytes());
let source = crate::update::DirSource::new(tmp.path());
let checker = UpdateChecker::new(source, target());
let current = crate::update::Version::parse("0.2.0").expect("版本");
let outcome = checker
.check(&current, Channel::Stable)
.expect("检查不该失败");
let candidate = outcome.candidate().expect("应有更新");
let staged = checker
.prepare(candidate, &tmp.join("staging"))
.expect("取包应成功");
assert_eq!(staged.size(), PAYLOAD.len() as u64);
assert_eq!(checker.target(), target());
assert!(checker.source().source_id().starts_with("file:"));
}
@@ -1,192 +0,0 @@
//! 替换与回滚:成功后备份还在、失败后旧版本仍在原位、目标目录写不进去要报错。
//!
//! Windows 的「文件被占用」用例是**真造出来**的:用 `share_mode(FILE_SHARE_READ)` 打开目标
//! 文件(不带 `FILE_SHARE_DELETE`,与 Windows 加载器打开运行中映像的口径一致),让 `rename`
//! 撞共享冲突。它不是「运行中的 exe」(那需要真跑一个子进程,属真机验收),这一点在报告里写明。
use std::fs;
use std::path::{Path, PathBuf};
use crate::update::manifest::ReleaseManifest;
use crate::update::{
install_staged, stage, DirSource, InstallFailure, ReplacePolicy, StagedPackage,
};
use super::support::{artifact, manifest, release, TempDir};
const UPDATE: &[u8] = b"new build bytes";
const OLD: &[u8] = b"old build bytes";
fn policy() -> ReplacePolicy {
ReplacePolicy::new(2, std::time::Duration::from_millis(1))
}
/// 发布一个包体、跑完取包+校验,拿到可直接安装的 `StagedPackage`。
fn staged(tmp: &TempDir) -> StagedPackage {
tmp.write("ull-test.zip", UPDATE);
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "ull-test.zip", UPDATE)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
stage(
&DirSource::new(tmp.path()),
&parsed.releases[0].artifacts[0],
&tmp.join("staging"),
)
.expect("取包与校验应通过")
}
fn names_in(dir: &Path) -> Vec<String> {
match fs::read_dir(dir) {
Ok(entries) => {
let mut names = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
names.sort();
names
}
Err(_) => Vec::new(),
}
}
#[test]
fn installs_over_an_existing_file_and_keeps_the_backup() {
let tmp = TempDir::new("install-ok");
let dest = tmp.write("app/ull.exe", OLD);
let staged = staged(&tmp);
let receipt = install_staged(&staged, &dest, policy()).expect("替换应成功");
assert_eq!(fs::read(&dest).expect("目标应存在"), UPDATE);
assert_eq!(receipt.bytes, UPDATE.len() as u64);
assert_eq!(receipt.dest, dest);
let backup = receipt.backup.clone().expect("旧版本应被留在备份路径");
assert_eq!(fs::read(&backup).expect("备份应存在"), OLD);
assert_eq!(
backup.parent(),
dest.parent(),
"备份必须与目标同目录,跨卷 rename 会失去原子性"
);
receipt.remove_backup().expect("删备份");
assert!(!backup.exists(), "备份应被删掉");
assert_eq!(names_in(dest.parent().expect("父目录")), ["ull.exe"]);
}
#[test]
fn installing_into_a_fresh_path_has_no_backup() {
let tmp = TempDir::new("install-fresh");
let staged = staged(&tmp);
let dest = tmp.join("dist/ull.exe");
let receipt = install_staged(&staged, &dest, policy()).expect("新装应成功");
assert_eq!(receipt.backup, None);
assert_eq!(fs::read(&dest).expect("目标应存在"), UPDATE);
}
#[test]
fn failed_move_rolls_the_old_version_back() {
let tmp = TempDir::new("install-rollback");
let dest = tmp.write("app/ull.exe", OLD);
let staged = staged(&tmp);
// 让第二步(暂存包 → 目标)注定失败:把暂存文件删掉,等价于"搬到一半源没了"。
fs::remove_file(staged.path()).expect("删掉暂存文件");
let failure: InstallFailure =
install_staged(&staged, &dest, policy()).expect_err("搬不进去必须失败");
assert!(failure.rolled_back, "旧版本应回到原位:{failure}");
assert!(!failure.needs_manual_repair());
assert_eq!(failure.backup_left, None);
assert_eq!(fs::read(&dest).expect("旧版本应仍在"), OLD);
assert_eq!(
names_in(dest.parent().expect("父目录")),
["ull.exe"],
"回滚后不该留下备份文件"
);
}
#[test]
fn manual_rollback_restores_the_previous_build() {
let tmp = TempDir::new("install-manual-rollback");
let dest = tmp.write("app/ull.exe", OLD);
let staged = staged(&tmp);
let mut receipt = install_staged(&staged, &dest, policy()).expect("替换应成功");
assert_eq!(fs::read(&dest).expect("新版本"), UPDATE);
let backup = receipt.backup.clone().expect("旧版本备份");
receipt.rollback(policy()).expect("回滚应成功");
assert_eq!(fs::read(&dest).expect("旧版本应回来"), OLD);
assert!(!backup.exists(), "回滚会把备份搬回去,不留副本");
assert_eq!(receipt.backup, None, "回滚成功后不该再指着不存在的备份");
assert!(receipt.remove_backup().is_ok(), "没有备份时删备份是空操作");
}
#[test]
fn unwritable_target_directory_fails_without_touching_anything() {
let tmp = TempDir::new("install-unwritable");
let staged = staged(&tmp);
// 父路径是个普通文件:建目录必然失败,等价于"目标目录不可写"
// (比 chmod 555 更可移植:CI 里常以 root 跑,chmod 对 root 无效)。
let blocked = tmp.write("blocked", b"not a directory");
let dest = blocked.join("app/ull.exe");
let failure = install_staged(&staged, &dest, policy()).expect_err("写不进目标目录必须失败");
assert!(failure.rolled_back, "没写任何东西,旧状态未变:{failure}");
assert_eq!(
fs::read(&blocked).expect("占位文件应原样"),
b"not a directory"
);
assert!(!dest.exists());
}
#[cfg(windows)]
#[test]
fn windows_locked_target_fails_and_leaves_the_old_file_intact() {
use std::fs::OpenOptions;
use std::os::windows::fs::OpenOptionsExt;
/// `FILE_SHARE_READ`:只允许别人读,不允许改名/删除。
const FILE_SHARE_READ: u32 = 0x1;
let tmp = TempDir::new("install-locked");
let dest = tmp.write("app/ull.exe", OLD);
let staged = staged(&tmp);
// 刻意**不**共享删除:`std::fs::File::open` 默认带 FILE_SHARE_DELETE(改名会被放行),
// 而 Windows 加载器打开正在运行的映像时不带它——这才是我们要复现的占用形态。
let held = OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ)
.open(&dest)
.expect("打开目标文件");
let failure = install_staged(&staged, &dest, policy()).expect_err("占用中的文件改不了名");
assert!(failure.rolled_back, "旧版本必须完好:{failure}");
assert_eq!(fs::read(&dest).expect("旧版本应仍在"), OLD);
drop(held);
assert_eq!(
names_in(dest.parent().expect("父目录")),
["ull.exe"],
"失败的尝试不该留下备份或暂存垃圾"
);
}
#[test]
fn backup_path_stays_in_the_same_directory() {
let dest = PathBuf::from("some/dir/ull.exe");
let backup = crate::update::backup_path(&dest);
assert_eq!(backup.parent(), dest.parent());
let name = backup
.file_name()
.expect("备份名")
.to_string_lossy()
.into_owned();
assert!(name.starts_with(".ull.exe.ullold-"), "实际:{name}");
assert_ne!(
backup,
crate::update::backup_path(&dest),
"同一进程两次调用不能撞名"
);
}
@@ -1,258 +0,0 @@
//! 清单解析与校验:字段拼错、重复版本、零长度、摘要格式、产物优先级。
use crate::download::HashAlgo;
use crate::platform::os_info::OsFamily;
use crate::update::manifest::ReleaseManifest;
use crate::update::{Arch, Channel, PackageFormat, Target, UpdateErrorKind};
use super::support::{artifact, manifest, release, sha256};
const PAYLOAD: &[u8] = b"fake portable zip payload";
#[test]
fn parses_a_full_manifest() {
let json = manifest(
"stable",
&[release(
"0.3.0",
r#","released_at":"2026-01-02T03:04:05Z","notes":"修掉启动崩溃","min_supported":"0.2.0""#,
&[
artifact("win-x64", "portable-zip", "ull-0.3.0-win-x64.zip", PAYLOAD),
artifact(
"linux-x64",
"appimage",
"ull-0.3.0-linux-x64.AppImage",
PAYLOAD,
),
],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
assert_eq!(parsed.channel, Channel::Stable);
assert_eq!(parsed.releases.len(), 1);
let first = &parsed.releases[0];
assert_eq!(first.version.to_string(), "0.3.0");
assert_eq!(first.channel, Channel::Stable, "渠道字段缺省时继承清单渠道");
assert_eq!(first.notes.as_deref(), Some("修掉启动崩溃"));
assert_eq!(
first.min_supported.as_ref().map(ToString::to_string),
Some("0.2.0".to_string())
);
assert!(!first.force);
assert_eq!(first.artifacts.len(), 2);
let windows = &first.artifacts[0];
assert_eq!(windows.target, Target::new(OsFamily::Windows, Arch::X64));
assert_eq!(windows.format, PackageFormat::PortableZip);
assert_eq!(windows.integrity.algo, HashAlgo::Sha256);
assert_eq!(windows.integrity.hex, sha256(PAYLOAD));
assert_eq!(windows.size, PAYLOAD.len() as u64);
}
#[test]
fn release_can_override_the_manifest_channel() {
let json = manifest("beta", &[release("0.3.0", r#","channel":"stable""#, &[])]);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
assert_eq!(parsed.releases[0].channel, Channel::Stable);
}
#[test]
fn rejects_duplicate_versions() {
let json = manifest(
"stable",
&[
release("0.3.0", "", &[]),
release("0.3.0", r#","notes":"重复""#, &[]),
],
);
let error = ReleaseManifest::from_json(&json).expect_err("重复版本应失败");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!(error.to_string().contains("0.3.0"));
}
#[test]
fn rejects_zero_size_artifact() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[format!(
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":0,"sha256":"{}"}}"#,
sha256(b"")
)],
)],
);
let error = ReleaseManifest::from_json(&json).expect_err("size=0 应失败");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!(error.to_string().contains("size"));
}
#[test]
fn rejects_unknown_fields() {
let json = r#"{"channel":"stable","relases":[]}"#;
let error = ReleaseManifest::from_json(json).expect_err("拼错的字段名应失败");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
}
#[test]
fn rejects_artifact_without_digest() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[r#"{"target":"win-x64","format":"msi","url":"a.msi","size":12}"#.to_string()],
)],
);
let error = ReleaseManifest::from_json(&json).expect_err("缺摘要应失败");
assert!(error.to_string().contains("sha256"));
}
#[test]
fn rejects_malformed_digest() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[
r#"{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha256":"abc"}"#
.to_string(),
],
)],
);
let error = ReleaseManifest::from_json(&json).expect_err("摘要长度不对应失败");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
}
#[test]
fn accepts_sha1_only_and_normalises_case() {
// 64 位 hex 挂 sha1 名头是错长度的,先确认它真的被挡下
let wrong_length = manifest(
"stable",
&[release(
"0.3.0",
"",
&[format!(
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha1":"{}"}}"#,
sha256(PAYLOAD)
)],
)],
);
assert!(ReleaseManifest::from_json(&wrong_length).is_err());
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[format!(
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha1":"{}"}}"#,
"A".repeat(40)
)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("只有 sha1 也应可解析");
let integrity = &parsed.releases[0].artifacts[0].integrity;
assert_eq!(integrity.algo, HashAlgo::Sha1);
assert_eq!(integrity.hex, "a".repeat(40), "摘要统一归一化为小写");
}
#[test]
fn sha256_wins_when_both_digests_are_present() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[format!(
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha1":"{}","sha256":"{}"}}"#,
"a".repeat(40),
sha256(PAYLOAD)
)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("两个摘要都在时应可解析");
assert_eq!(
parsed.releases[0].artifacts[0].integrity.algo,
HashAlgo::Sha256
);
}
#[test]
fn rejects_unknown_target_and_format() {
let bad_target = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("solaris-x64", "msi", "a.msi", PAYLOAD)],
)],
);
let error = ReleaseManifest::from_json(&bad_target).expect_err("未知平台应失败");
assert!(error.to_string().contains("solaris-x64"));
let bad_format = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("win-x64", "tar.gz", "a.tgz", PAYLOAD)],
)],
);
let error = ReleaseManifest::from_json(&bad_format).expect_err("未知格式应失败");
assert!(error.to_string().contains("tar.gz"));
}
#[test]
fn artifact_order_is_the_priority() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[
artifact("win-x64", "portable-zip", "a.zip", PAYLOAD),
artifact("win-x64", "msi", "a.msi", PAYLOAD),
],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
let target = Target::new(OsFamily::Windows, Arch::X64);
let artifact = parsed.releases[0]
.artifact_for(target)
.expect("应有 win-x64 产物");
assert_eq!(artifact.format, PackageFormat::PortableZip, "先写先选");
}
#[test]
fn newest_artifact_for_skips_releases_without_the_target() {
let json = manifest(
"stable",
&[
release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
),
release(
"0.4.0",
"",
&[artifact("linux-x64", "appimage", "b.AppImage", PAYLOAD)],
),
],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
let (release, _) = parsed
.newest_artifact_for(Target::new(OsFamily::Windows, Arch::X64))
.expect("win-x64 只有 0.3.0 有货");
assert_eq!(release.version.to_string(), "0.3.0");
}
#[test]
fn empty_release_list_is_valid() {
let parsed = ReleaseManifest::from_json(r#"{"channel":"nightly","releases":[]}"#)
.expect("空清单是合法状态(还没发过版)");
assert_eq!(parsed.channel, Channel::Nightly);
assert!(parsed.releases.is_empty());
}
-22
View File
@@ -1,22 +0,0 @@
//! `update` 的单元测试。
//!
//! 覆盖面(与 mission 的验收条目一一对应):
//!
//! * 版本比较边界:`0.9` vs `0.10`、预发布 vs 正式、alpha/beta/rc、构建元数据不排序;
//! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式;
//! * 渠道准入:阶梯比较 + 预发布阶段表;
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝。
//!
//! 全程离线:所有字节都在临时目录里造,没有一个用例发网络请求。
mod channel_cases;
mod checker_cases;
mod install_cases;
mod manifest_cases;
mod source_cases;
mod support;
mod target_cases;
mod verify_cases;
mod version_cases;
@@ -1,179 +0,0 @@
//! 本地目录源:清单缺失不是错误、渠道对不上要报错、远端地址一律拒绝。
use std::fs;
use crate::update::manifest::ReleaseManifest;
use crate::update::{Channel, DirSource, UpdateErrorKind, UpdateSource};
use super::support::{artifact, manifest, release, TempDir};
const PAYLOAD: &[u8] = b"source payload";
fn publish(tmp: &TempDir, channel: Channel, json: &str) {
tmp.write(&format!("{}.json", channel.as_str()), json.as_bytes());
}
#[test]
fn missing_channel_manifest_is_not_an_error() {
let tmp = TempDir::new("source-missing");
let source = DirSource::new(tmp.path());
let manifest = source
.fetch_manifest(Channel::Stable)
.expect("缺清单不该报错");
assert!(manifest.is_none(), "还没发过版 = Ok(None)");
}
#[test]
fn reads_and_validates_the_channel_manifest() {
let tmp = TempDir::new("source-ok");
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "ull-test.zip", PAYLOAD)],
)],
);
publish(&tmp, Channel::Stable, &json);
let source = DirSource::new(tmp.path());
let parsed = source
.fetch_manifest(Channel::Stable)
.expect("应取到清单")
.expect("清单存在");
assert_eq!(parsed.channel, Channel::Stable);
assert_eq!(parsed.releases.len(), 1);
assert!(source.source_id().starts_with("file:"));
assert_eq!(source.root(), tmp.path());
assert_eq!(source.manifest_path(Channel::Beta), tmp.join("beta.json"));
}
#[test]
fn channel_mismatch_is_an_error() {
let tmp = TempDir::new("source-mismatch");
// 清单自称 beta,却躺在 stable.json 里
publish(&tmp, Channel::Stable, &manifest("beta", &[]));
let error = DirSource::new(tmp.path())
.fetch_manifest(Channel::Stable)
.expect_err("渠道不符必须报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
}
#[test]
fn broken_manifest_json_is_reported_with_path() {
let tmp = TempDir::new("source-broken");
publish(&tmp, Channel::Stable, "{ not json");
let error = DirSource::new(tmp.path())
.fetch_manifest(Channel::Stable)
.expect_err("坏 JSON 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!(error.path.is_some(), "错误应带清单路径");
}
#[test]
fn copies_artifact_from_a_relative_url() {
let tmp = TempDir::new("source-copy");
let file = tmp.write("dist/ull-test.zip", PAYLOAD);
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact(
"win-x64",
"portable-zip",
"dist/ull-test.zip",
PAYLOAD,
)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
let dest = tmp.join("staging/copy.zip");
let written = DirSource::new(tmp.path())
.fetch_artifact(&parsed.releases[0].artifacts[0], &dest)
.expect("应复制成功");
assert_eq!(written, PAYLOAD.len() as u64);
assert_eq!(fs::read(&dest).expect("目标应存在"), PAYLOAD);
assert!(file.exists());
assert!(dest.parent().is_some_and(|parent| parent.exists()));
}
#[test]
fn accepts_file_url_for_the_artifact() {
let tmp = TempDir::new("source-file-url");
let file = tmp.write("dist/ull-test.zip", PAYLOAD);
let url = format!("file://{}", file.display().to_string().replace('\\', "/"));
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", &url, PAYLOAD)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
let dest = tmp.join("staging/copy.zip");
DirSource::new(tmp.join("a-different-root"))
.fetch_artifact(&parsed.releases[0].artifacts[0], &dest)
.expect("file:// 地址应可复制");
assert_eq!(fs::read(&dest).expect("目标应存在"), PAYLOAD);
}
#[test]
fn rejects_remote_urls() {
let tmp = TempDir::new("source-remote");
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact(
"win-x64",
"portable-zip",
"https://example.invalid/ull-test.zip",
PAYLOAD,
)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
let error = DirSource::new(tmp.path())
.fetch_artifact(&parsed.releases[0].artifacts[0], &tmp.join("copy.zip"))
.expect_err("本地源不该假装能取远端字节");
assert_eq!(error.kind, UpdateErrorKind::UnsupportedSource);
}
#[test]
fn missing_artifact_file_is_source_missing() {
let tmp = TempDir::new("source-no-file");
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact(
"win-x64",
"portable-zip",
"not-there.zip",
PAYLOAD,
)],
)],
);
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
let error = DirSource::new(tmp.path())
.fetch_artifact(&parsed.releases[0].artifacts[0], &tmp.join("copy.zip"))
.expect_err("产物不存在应报错");
assert_eq!(error.kind, UpdateErrorKind::SourceMissing);
}
#[test]
fn builds_a_source_from_a_file_url() {
let tmp = TempDir::new("source-from-url");
let url = format!(
"file://{}",
tmp.path().display().to_string().replace('\\', "/")
);
let source = DirSource::from_url(&url).expect("file:// 应被接受");
assert_eq!(source.root(), tmp.path(), "根目录应与给定目录一致");
let error = DirSource::from_url("https://example.invalid/dist").expect_err("https 不是本地源");
assert_eq!(error.kind, UpdateErrorKind::UnsupportedSource);
}
-82
View File
@@ -1,82 +0,0 @@
//! `update` 测试的公共夹具:临时目录、真实摘要、清单 JSON 片段。
//!
//! 清单片段里的摘要**按真实字节算**,不用占位值——「校验和不符」那条用例正是要证明
//! 判据真的在跑,占位值会让它对所有输入都成立。
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::download::verify::digest_bytes;
use crate::download::HashAlgo;
/// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。
pub(crate) struct TempDir {
path: PathBuf,
}
impl TempDir {
pub(crate) fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("ull-update-{}-{tag}-{unique}", std::process::id()));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("建临时目录");
Self { path }
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn join(&self, name: &str) -> PathBuf {
self.path.join(name)
}
/// 写一个文件(父目录自动建),返回路径。
pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
let path = self.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("建父目录");
}
fs::write(&path, bytes).expect("写文件");
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
/// 小写十六进制 SHA-256。
pub(crate) fn sha256(bytes: &[u8]) -> String {
digest_bytes(HashAlgo::Sha256, bytes)
}
/// 清单里的一个产物对象(`sha256` 与 `size` 都按 `payload` 真实值算)。
pub(crate) fn artifact(target: &str, format: &str, url: &str, payload: &[u8]) -> String {
format!(
r#"{{"target":"{target}","format":"{format}","url":"{url}","size":{},"sha256":"{}"}}"#,
payload.len(),
sha256(payload)
)
}
/// 清单里的一个发布对象;`extra` 追加其它字段(如 `,"force":true`),注意前导逗号。
pub(crate) fn release(version: &str, extra: &str, artifacts: &[String]) -> String {
format!(
r#"{{"version":"{version}"{extra},"artifacts":[{}]}}"#,
artifacts.join(",")
)
}
/// 一份渠道清单。
pub(crate) fn manifest(channel: &str, releases: &[String]) -> String {
format!(
r#"{{"channel":"{channel}","releases":[{}]}}"#,
releases.join(",")
)
}
@@ -1,91 +0,0 @@
//! 目标平台与包形态的清单写法解析。
use crate::platform::os_info::OsFamily;
use crate::update::{Arch, PackageFormat, Target};
#[test]
fn parses_canonical_targets() {
assert_eq!(
Target::parse("win-x64"),
Some(Target::new(OsFamily::Windows, Arch::X64))
);
assert_eq!(
Target::parse("mac-arm64"),
Some(Target::new(OsFamily::MacOs, Arch::Arm64))
);
assert_eq!(
Target::parse("linux-x64"),
Some(Target::new(OsFamily::Linux, Arch::X64))
);
assert_eq!(
Target::parse("linux-arm64"),
Some(Target::new(OsFamily::Linux, Arch::Arm64))
);
}
#[test]
fn tolerates_common_aliases() {
assert_eq!(
Target::parse("windows-amd64"),
Some(Target::new(OsFamily::Windows, Arch::X64))
);
assert_eq!(
Target::parse("osx-aarch64"),
Some(Target::new(OsFamily::MacOs, Arch::Arm64))
);
assert_eq!(
Target::parse("darwin-universal"),
None,
"认不出的架构必须返回 None,不能瞎猜"
);
assert_eq!(Target::parse("win"), None, "少了架构段");
assert_eq!(Target::parse("solaris-x64"), None);
assert_eq!(Target::parse(""), None);
}
#[test]
fn current_target_renders_and_round_trips() {
let current = Target::current();
assert_eq!(Target::parse(current.as_str()), Some(current));
assert_eq!(current.to_string(), current.as_str());
assert_ne!(current.arch, Arch::Unknown, "编译目标架构应能识别");
}
#[test]
fn parses_package_formats() {
assert_eq!(PackageFormat::parse("msi"), Some(PackageFormat::Msi));
assert_eq!(PackageFormat::parse("DMG"), Some(PackageFormat::Dmg));
assert_eq!(
PackageFormat::parse("appimage"),
Some(PackageFormat::AppImage)
);
assert_eq!(
PackageFormat::parse("portable-zip"),
Some(PackageFormat::PortableZip)
);
assert_eq!(
PackageFormat::parse("zip"),
Some(PackageFormat::PortableZip)
);
assert_eq!(PackageFormat::parse("tar.gz"), None);
assert_eq!(PackageFormat::Msi.to_string(), "msi");
}
#[test]
fn installer_formats_are_flagged() {
for format in [
PackageFormat::Msi,
PackageFormat::Dmg,
PackageFormat::Deb,
PackageFormat::Rpm,
] {
assert!(format.needs_installer(), "{format}");
}
for format in [
PackageFormat::PortableZip,
PackageFormat::PortableExe,
PackageFormat::AppImage,
] {
assert!(!format.needs_installer(), "{format}");
}
}
@@ -1,138 +0,0 @@
//! 校验层:摘要不符、包体截断都必须被挡下,且暂存文件不许留下。
use std::fs;
use std::path::Path;
use crate::update::manifest::ReleaseManifest;
use crate::update::{stage, DirSource, UpdateErrorKind};
use super::support::{artifact, manifest, release, sha256, TempDir};
/// 暂存目录里的文件名(断言「失败后没留垃圾」用)。
fn staging_files(dir: &Path) -> Vec<String> {
match fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
}
}
fn artifact_of(json: &str) -> crate::update::Artifact {
ReleaseManifest::from_json(json)
.expect("清单应可解析")
.releases[0]
.artifacts[0]
.clone()
}
#[test]
fn digest_mismatch_is_rejected_and_staged_file_removed() {
let tmp = TempDir::new("verify-digest");
let source = DirSource::new(tmp.path());
// 清单声明的摘要按 A 算,实际发的是 B:长度一样,只有摘要能抓住。
let declared = b"payload-aaaa";
let delivered = b"payload-bbbb";
assert_eq!(declared.len(), delivered.len());
tmp.write("ull-test.zip", delivered);
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact(
"win-x64",
"portable-zip",
"ull-test.zip",
declared,
)],
)],
);
let artifact = artifact_of(&json);
assert_eq!(artifact.integrity.hex, sha256(declared));
let staging = tmp.join("staging");
let error = stage(&source, &artifact, &staging).expect_err("摘要不符必须拒绝");
assert_eq!(error.kind, UpdateErrorKind::DigestMismatch);
assert!(
error
.path
.as_ref()
.is_some_and(|path| path.starts_with(&staging)),
"错误应指向暂存文件:{error}"
);
assert!(
staging_files(&staging).is_empty(),
"校验失败的暂存文件必须被删掉,实际:{:?}",
staging_files(&staging)
);
}
#[test]
fn truncated_payload_is_rejected_as_size_mismatch() {
let tmp = TempDir::new("verify-truncated");
let source = DirSource::new(tmp.path());
let full = b"a full portable zip payload";
// 发布侧发一半就断了:长度先抓到,不用读摘要就能拒绝。
tmp.write("ull-test.zip", &full[..8]);
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "ull-test.zip", full)],
)],
);
let staging = tmp.join("staging");
let error = stage(&source, &artifact_of(&json), &staging).expect_err("截断的包必须拒绝");
assert_eq!(error.kind, UpdateErrorKind::SizeMismatch);
assert!(error.to_string().contains("截断"));
assert!(staging_files(&staging).is_empty());
}
#[test]
fn oversized_payload_is_rejected_too() {
let tmp = TempDir::new("verify-oversized");
let source = DirSource::new(tmp.path());
tmp.write("ull-test.zip", b"payload and then some");
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact(
"win-x64",
"portable-zip",
"ull-test.zip",
b"payload",
)],
)],
);
let error = stage(&source, &artifact_of(&json), &tmp.join("staging"))
.expect_err("多出来的字节同样不许装");
assert_eq!(error.kind, UpdateErrorKind::SizeMismatch);
}
#[test]
fn matching_payload_is_staged_and_cleaned_on_drop() {
let tmp = TempDir::new("verify-ok");
let source = DirSource::new(tmp.path());
let payload = b"a real payload, short but real";
tmp.write("ull-test.zip", payload);
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "ull-test.zip", payload)],
)],
);
let staged = stage(&source, &artifact_of(&json), &tmp.join("staging")).expect("校验应通过");
assert_eq!(staged.size(), payload.len() as u64);
assert_eq!(fs::read(staged.path()).expect("暂存文件应存在"), payload);
assert_eq!(staged.integrity().hex, sha256(payload));
let path = staged.path().to_path_buf();
drop(staged);
assert!(!path.exists(), "StagedPackage 落地后应自行清理");
}
@@ -1,135 +0,0 @@
//! 版本比较的边界用例:数值比而非字典序、预发布 vs 正式、构建元数据不参与排序。
use std::cmp::Ordering;
use crate::update::{Stage, Version};
fn version(text: &str) -> Version {
Version::parse(text).expect("测试里的版本号应可解析")
}
#[test]
fn minor_numbers_compare_numerically() {
// 字典序会把 "0.9.0" 判成比 "0.10.0" 新,这正是这条用例要钉死的行为。
assert!(version("0.10.0") > version("0.9.0"));
assert!(version("0.10.1") < version("0.11.0"));
assert!(version("1.0.0") > version("0.999.999"));
assert!(version("2.0.0") > version("1.99.99"));
}
#[test]
fn prerelease_sorts_below_the_release() {
assert!(version("1.0.0-rc.1") < version("1.0.0"));
assert!(version("1.0.0-alpha") < version("1.0.0"));
// 预发布阶段之间:alpha < beta < rc(ASCII 序,不是特例表)
assert!(version("1.0.0-alpha") < version("1.0.0-beta"));
assert!(version("1.0.0-beta.2") < version("1.0.0-rc.1"));
// 主次修订号优先于预发布标识
assert!(version("1.0.1-alpha.1") > version("1.0.0"));
}
#[test]
fn prerelease_identifiers_follow_semver_rules() {
assert!(version("1.0.0-alpha.1") < version("1.0.0-alpha.2"));
assert!(version("1.0.0-alpha.2") < version("1.0.0-alpha.10"));
assert!(version("1.0.0-alpha") < version("1.0.0-alpha.1"));
// 纯数字段小于任何非数字段
assert!(version("1.0.0-1") < version("1.0.0-alpha"));
// 非数字段按 ASCII 比
assert!(version("1.0.0-alpha-1") < version("1.0.0-alpha-x"));
}
#[test]
fn build_metadata_is_ignored_by_ordering_and_equality() {
let with = version("1.2.3+build.7");
let without = version("1.2.3");
assert_eq!(with.cmp(&without), Ordering::Equal);
assert_eq!(with, without);
assert!(version("1.2.4+zzz") > version("1.2.3+aaa"));
assert_eq!(with.to_string(), "1.2.3+build.7");
assert_eq!(with.build_metadata(), Some("build.7"));
assert_eq!(without.build_metadata(), None);
}
#[test]
fn stages_are_recognised_from_the_first_identifier() {
assert_eq!(version("1.0.0").stage(), Stage::Stable);
assert_eq!(version("1.0.0-alpha.3").stage(), Stage::Alpha);
assert_eq!(version("1.0.0-a.2").stage(), Stage::Alpha);
assert_eq!(version("1.0.0-beta.1").stage(), Stage::Beta);
assert_eq!(version("1.0.0-rc.4").stage(), Stage::ReleaseCandidate);
assert_eq!(version("1.0.0-preview.2").stage(), Stage::ReleaseCandidate);
assert_eq!(version("1.0.0-spike-1").stage(), Stage::Unknown);
assert_eq!(version("1.0.0-7").stage(), Stage::Unknown);
}
#[test]
fn round_trips_through_display() {
for text in [
"0.2.1",
"1.0.0-alpha.1",
"3.17.0+build-9",
"10.0.12-rc.2+ci.9",
] {
let parsed = version(text);
let rendered = parsed.to_string();
assert_eq!(version(&rendered), parsed, "{text} 回读不一致");
}
}
#[test]
fn accepts_v_prefix_and_reports_prerelease() {
assert_eq!(version("v1.4.2"), version("1.4.2"));
assert_eq!(version("V1.4.2"), version("1.4.2"));
assert!(!version("1.4.2").is_prerelease());
assert!(version("1.4.2-rc.1").is_prerelease());
assert_eq!(version("1.4.2-rc.1").prerelease().as_deref(), Some("rc.1"));
assert_eq!(version("1.4.2").prerelease(), None);
assert_eq!(
(
version("1.4.2").major(),
version("1.4.2").minor(),
version("1.4.2").patch()
),
(1, 4, 2)
);
}
#[test]
fn rejects_non_semver_input() {
for text in [
"",
"1",
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-",
"1.2.3-alpha..1",
"1.2.3+",
"1.2.3-é",
"1.-2.3",
"1.2.x",
"1.2.3-01",
] {
assert!(
Version::parse(text).is_err(),
"`{text}` 不该被当成合法版本号"
);
}
}
#[test]
fn version_error_carries_input_and_reason() {
let error = Version::parse("1.2.x").expect_err("应失败");
assert_eq!(error.input(), "1.2.x");
assert!(!error.reason().is_empty());
assert!(error.to_string().contains("1.2.x"));
}
#[test]
fn new_builds_a_plain_release() {
assert_eq!(Version::new(0, 2, 1), version("0.2.1"));
assert!(!Version::new(0, 2, 1).is_prerelease());
}
-134
View File
@@ -1,134 +0,0 @@
//! 下载产物的校验:长度(截断)+ 摘要(内容),不符即拒绝安装。
//!
//! 承 `download` 的规矩「先校验后落盘」:包体先落在暂存目录的中间文件里,长度与摘要都对上
//! 才算 [`StagedPackage`];对不上就**立刻删掉**,绝不让它有机会顶到目标路径上。
//!
//! 两道判据的顺序是有意的:先比长度(能抓住截断,且不需要读全文件),再比摘要(能抓住
//! 「长度对但内容错」)。顺序反过来会让「被截断的包」白白多读一遍盘。
//!
//! 长度判据用「**恰好等于**」而不是「不小于」:清单里的 `size` 是我们自己发的字节数,
//! 多了同样是错的(比如拼接了两个包),多一个字节也不许装。
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::download::verify::{digest_file, matches};
use crate::download::Integrity;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::Artifact;
use super::source::UpdateSource;
/// 暂存文件的进程内序号(同一进程多次更新不撞名)。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 校验通过的暂存包。
///
/// `Drop` 时删掉暂存文件(装好之后 `rename` 已经把它搬走,删除是无操作);
/// 因此「校验失败要清理」这件事不需要调用方记得做。
#[derive(Debug)]
pub struct StagedPackage {
path: PathBuf,
size: u64,
integrity: Integrity,
}
impl StagedPackage {
/// 暂存文件路径。
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// 实际字节数(已与清单核对过,等于清单声明的 `size`)。
#[must_use]
pub const fn size(&self) -> u64 {
self.size
}
/// 校验用的摘要。
#[must_use]
pub fn integrity(&self) -> &Integrity {
&self.integrity
}
}
impl Drop for StagedPackage {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
/// 从源取包体 → 落暂存 → 长度与摘要双检。
///
/// # Errors
///
/// 取包失败透传 [`UpdateError`];长度不符是 [`UpdateErrorKind::SizeMismatch`],
/// 摘要不符是 [`UpdateErrorKind::DigestMismatch`]。失败时暂存文件已被删掉。
pub fn stage(
source: &dyn UpdateSource,
artifact: &Artifact,
staging_dir: &Path,
) -> Result<StagedPackage, UpdateError> {
fs::create_dir_all(staging_dir).map_err(|error| UpdateError::io_at(staging_dir, &error))?;
let path = staging_dir.join(format!(
"{}-{}.ullupd",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let outcome = source
.fetch_artifact(artifact, &path)
.and_then(|_| verify_file(&path, &artifact.integrity, artifact.size));
match outcome {
Ok(size) => Ok(StagedPackage {
path,
size,
integrity: artifact.integrity.clone(),
}),
Err(error) => {
let _ = fs::remove_file(&path);
Err(error)
}
}
}
/// 校验一个已经落盘的文件:长度必须**恰好**等于 `expected_size`,摘要必须相等。
///
/// # Errors
///
/// 读不到文件/读失败是 [`UpdateErrorKind::Io`];长度不符是 `SizeMismatch`;
/// 摘要不符是 `DigestMismatch`。
pub fn verify_file(
path: &Path,
integrity: &Integrity,
expected_size: u64,
) -> Result<u64, UpdateError> {
let metadata = fs::metadata(path).map_err(|error| UpdateError::io_at(path, &error))?;
let actual = metadata.len();
if actual != expected_size {
return Err(UpdateError::new(
UpdateErrorKind::SizeMismatch,
format!(
"包体 {actual} 字节,清单声明 {expected_size} 字节(差 {} 字节;短了通常是下载被截断)",
expected_size.abs_diff(actual)
),
)
.with_path(path));
}
let digest =
digest_file(path, integrity.algo).map_err(|error| UpdateError::io_at(path, &error))?;
if !matches(&integrity.hex, &digest) {
return Err(UpdateError::new(
UpdateErrorKind::DigestMismatch,
format!(
"{} 摘要不符:清单 {} / 实际 {}(拒绝安装)",
integrity.algo.as_str(),
integrity.short_prefix(),
&digest[..digest.len().min(8)]
),
)
.with_path(path));
}
Ok(actual)
}
-363
View File
@@ -1,363 +0,0 @@
//! 语义化版本:解析、比较、预发布阶段识别。
//!
//! 排序规则严格按 semver 2.0 §11:
//!
//! 1. 主 / 次 / 修订号按**数值**比(不是字符串比,所以 `0.9.0 < 0.10.0`);
//! 2. 带预发布标识的版本**小于**同号正式版(`1.0.0-rc.1 < 1.0.0`);
//! 3. 预发布标识逐段比:纯数字段按数值比、其余按 ASCII 字典序比;纯数字段**小于**任何
//! 非数字段;前缀相同时段数少的更小(`1.0.0-alpha < 1.0.0-alpha.1`);
//! 4. 构建元数据(`+` 之后)**既不参与比较也不参与相等判定**。
//!
//! `alpha < beta < rc` 不是特例:ASCII 里 `a < b < r`,规则 3 自然得出
//! `1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0`。**别**在这里加「阶段权重表」,
//! 一旦加了,`1.0.0-preview` 与 `1.0.0-rc` 的相对顺序就和 semver 生态不一致了。
//!
//! 拒绝的写法(宁可解析失败,也别让「看起来像版本」的字符串混进清单):段数不是三段、
//! 前导零(`01.2.3`)、空标识段(`1.0.0-`、`1.0.0-alpha..1`)、非 ASCII 标识字符、
//! 留空的构建元数据。唯一放宽的是可选前缀 `v`(`v1.4.2`),因为 git tag 普遍这么写。
use std::cmp::Ordering;
use std::fmt;
/// 版本号解析失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionError {
input: String,
reason: String,
}
impl VersionError {
/// 原始输入(已 trim,但保留 `v` 前缀这种细节)。
#[must_use]
pub fn input(&self) -> &str {
&self.input
}
/// 具体原因。
#[must_use]
pub fn reason(&self) -> &str {
&self.reason
}
}
impl fmt::Display for VersionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "版本号 `{}` 不合法:{}", self.input, self.reason)
}
}
impl std::error::Error for VersionError {}
fn invalid(input: &str, reason: impl Into<String>) -> VersionError {
VersionError {
input: input.trim().to_string(),
reason: reason.into(),
}
}
/// 预发布标识的一段。
///
/// 刻意不对外暴露:外部能构造出 `Alpha("01")` 这种违反 semver 不变量的值,
/// 排序与相等就自相矛盾了。读方向只给 [`Version::prerelease`] 的字符串形式。
#[derive(Debug, Clone, PartialEq, Eq)]
enum Identifier {
Numeric(u64),
Alpha(String),
}
impl fmt::Display for Identifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Numeric(value) => write!(f, "{value}"),
Self::Alpha(text) => f.write_str(text),
}
}
}
impl Ord for Identifier {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(Self::Numeric(left), Self::Numeric(right)) => left.cmp(right),
(Self::Numeric(_), Self::Alpha(_)) => Ordering::Less,
(Self::Alpha(_), Self::Numeric(_)) => Ordering::Greater,
(Self::Alpha(left), Self::Alpha(right)) => left.cmp(right),
}
}
}
impl PartialOrd for Identifier {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// 预发布阶段(渠道准入判定用,见 [`crate::update::Channel::admits_stage`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Stage {
/// 正式版(没有预发布标识)。
Stable,
/// `rc` / `pre` / `preview`。
ReleaseCandidate,
/// `beta` / `b`。
Beta,
/// `alpha` / `a`。
Alpha,
/// 认不出的预发布标识(只有 `nightly` 渠道收)。
Unknown,
}
impl Stage {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::ReleaseCandidate => "rc",
Self::Beta => "beta",
Self::Alpha => "alpha",
Self::Unknown => "unknown",
}
}
}
/// 版本号。
///
/// 字段私有:构造只走 [`Version::parse`] 与 [`Version::new`],不变量(前导零、空标识段)
/// 因此只需要守一处。
#[derive(Debug, Clone)]
pub struct Version {
major: u64,
minor: u64,
patch: u64,
pre: Vec<Identifier>,
build: Option<String>,
}
impl Version {
/// 正式版(`major.minor.patch`,无预发布、无构建元数据)。
#[must_use]
pub const fn new(major: u64, minor: u64, patch: u64) -> Self {
Self {
major,
minor,
patch,
pre: Vec::new(),
build: None,
}
}
/// 解析 `MAJOR.MINOR.PATCH[-PRE][+BUILD]`(可选前缀 `v`)。
///
/// # Errors
///
/// 任何不符合 semver 的写法都返回 [`VersionError`],错误里带原始输入与原因。
pub fn parse(text: &str) -> Result<Self, VersionError> {
let trimmed = text.trim();
let body = trimmed
.strip_prefix('v')
.or_else(|| trimmed.strip_prefix('V'))
.unwrap_or(trimmed);
let (body, build) = match body.split_once('+') {
Some((head, build)) => (head, Some(build)),
None => (body, None),
};
if let Some(build) = build {
if build.split('.').any(|segment| !is_identifier(segment)) {
return Err(invalid(text, "构建元数据含非法字符或空段"));
}
}
let (core, pre) = match body.split_once('-') {
Some((head, pre)) => (head, Some(pre)),
None => (body, None),
};
let mut numbers = core.split('.');
let major = segment_number(text, numbers.next(), "主版本号")?;
let minor = segment_number(text, numbers.next(), "次版本号")?;
let patch = segment_number(text, numbers.next(), "修订号")?;
if numbers.next().is_some() {
return Err(invalid(text, "只接受三段数字(主.次.修订)"));
}
let pre = match pre {
Some(pre) => parse_prerelease(text, pre)?,
None => Vec::new(),
};
Ok(Self {
major,
minor,
patch,
pre,
build: build.map(str::to_string),
})
}
/// 主版本号。
#[must_use]
pub const fn major(&self) -> u64 {
self.major
}
/// 次版本号。
#[must_use]
pub const fn minor(&self) -> u64 {
self.minor
}
/// 修订号。
#[must_use]
pub const fn patch(&self) -> u64 {
self.patch
}
/// 预发布标识的原始写法(`None` = 正式版)。
#[must_use]
pub fn prerelease(&self) -> Option<String> {
if self.pre.is_empty() {
return None;
}
Some(
self.pre
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("."),
)
}
/// 构建元数据(`+` 之后,不参与比较)。
#[must_use]
pub fn build_metadata(&self) -> Option<&str> {
self.build.as_deref()
}
/// 是否预发布。
#[must_use]
pub fn is_prerelease(&self) -> bool {
!self.pre.is_empty()
}
/// 预发布阶段(正式版为 [`Stage::Stable`])。
#[must_use]
pub fn stage(&self) -> Stage {
match self.pre.first() {
None => Stage::Stable,
Some(Identifier::Numeric(_)) => Stage::Unknown,
Some(Identifier::Alpha(name)) => match name.as_str() {
"alpha" | "a" => Stage::Alpha,
"beta" | "b" => Stage::Beta,
"rc" | "pre" | "preview" => Stage::ReleaseCandidate,
_ => Stage::Unknown,
},
}
}
}
fn segment_number(whole: &str, segment: Option<&str>, label: &str) -> Result<u64, VersionError> {
let Some(segment) = segment else {
return Err(invalid(whole, "段数不足:需要 `主.次.修订` 三段"));
};
if segment.is_empty() {
return Err(invalid(whole, format!("{label}为空")));
}
if !segment.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid(whole, format!("{label}含非数字字符")));
}
if segment.len() > 1 && segment.starts_with('0') {
return Err(invalid(whole, format!("{label}不得有前导零")));
}
segment
.parse::<u64>()
.map_err(|_| invalid(whole, format!("{label}超出 u64 范围")))
}
fn is_identifier(segment: &str) -> bool {
!segment.is_empty()
&& segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn parse_prerelease(whole: &str, pre: &str) -> Result<Vec<Identifier>, VersionError> {
let mut out = Vec::new();
for segment in pre.split('.') {
if !is_identifier(segment) {
return Err(invalid(whole, "预发布标识含非法字符或空段"));
}
if segment.bytes().all(|byte| byte.is_ascii_digit()) {
if segment.len() > 1 && segment.starts_with('0') {
return Err(invalid(whole, "预发布数字段不得有前导零"));
}
let value = segment
.parse::<u64>()
.map_err(|_| invalid(whole, "预发布数字段超出 u64 范围"))?;
out.push(Identifier::Numeric(value));
} else {
out.push(Identifier::Alpha(segment.to_string()));
}
}
Ok(out)
}
fn compare_prerelease(left: &[Identifier], right: &[Identifier]) -> Ordering {
match (left.is_empty(), right.is_empty()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => left.cmp(right),
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
self.major
.cmp(&other.major)
.then_with(|| self.minor.cmp(&other.minor))
.then_with(|| self.patch.cmp(&other.patch))
.then_with(|| compare_prerelease(&self.pre, &other.pre))
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for Version {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for Version {}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if !self.pre.is_empty() {
f.write_str("-")?;
for (index, identifier) in self.pre.iter().enumerate() {
if index > 0 {
f.write_str(".")?;
}
write!(f, "{identifier}")?;
}
}
if let Some(build) = &self.build {
write!(f, "+{build}")?;
}
Ok(())
}
}
impl std::str::FromStr for Version {
type Err = VersionError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
+1 -4
View File
@@ -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));
} }
+6 -5
View File
@@ -374,12 +374,13 @@ mod tests {
#[test] #[test]
fn accent_on_fixed_neutral_only_moves_the_neutral_hue() { fn accent_on_fixed_neutral_only_moves_the_neutral_hue() {
let official = Palettes::official_baseline(); let official = Palettes::official_baseline();
// 中性色相取**种子色相的实测值**而不是 `NEUTRAL_HUE_DEG` 字面量:两者只差 `atan2()` 的末位 // 中性色相 = 基线色相时,与官方的 from_seed 完全等价(不放宽算法)。
// (见下一条测试),逐位比会在 glibc 与 UCRT 之间翻脸;取实测值两侧同源,断言仍是逐位相等。 let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, NEUTRAL_HUE_DEG, 12.0);
let per_seed = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, 12.0); let per_seed = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, 12.0);
let seed_hue = per_seed.neutral.hue_deg(); assert_eq!(
let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, seed_hue, 12.0); same, per_seed,
assert_eq!(same, per_seed, "中性色相取种子色相时须与 TonalSpot 相同"); "中性色相取种子色相时必须与 TonalSpot 逐项相同"
);
// 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。 // 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。
let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0); let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0);
-4
View File
@@ -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(),
} }
} }
+2 -22
View File
@@ -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;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else { let Ok(coordinate) = library.coordinate() else {
continue; 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,
+360 -32
View File
@@ -4,31 +4,367 @@
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。 `packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。 本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
## 索引:§1–§12 各在哪份文件(M145 拆分) ## 1. 产物矩阵
拆分原因:本文件与 `.github/workflows/release.yml` 都已是 400 行整,下一处改动必撞全仓行数红线(`guards.yml` 的 | 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 |
`file-size`,单文件 ≤400 行)。按主题分开后每份都留出余量,**内容一字未改、只搬家**;**节号沿用原编号**, |------|---------|-----------|------|---------|
所以全仓那些「`packaging/README.md` §4 / §7.4 / §9」式引用不必改动 —— 按下表找承载文件即可。 | `windows-x64` | ✅ 发布 | `unlucky-launcher_<ver>_windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
| `windows-arm64` | ❌ 不发布(构建照跑取证) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 仍在**原生** `windows-11-arm` runner 上构建(M140 起不交叉),**红了不挡 x64 发布**(M142,见 §3) |
| `linux-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
| `linux-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
| `macos-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
| 节 | 主题 | 承载文件 | 约定细节:
|----|------|---------|
| §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` |
| §2 | 版本来源(唯一真相源) | `packaging/docs/artifacts-and-version.md` |
| §3 | 怎么构建(本地 / CI) | `packaging/docs/build-and-ci.md` |
| §4 | 每平台运行时依赖 | `packaging/docs/runtime-deps.md` |
| §5 | 软渲染兜底 | `packaging/docs/runtime-deps.md` |
| §6 | Cargo 构建档位 | `packaging/docs/profiles-and-measurements.md` |
| §7 | 实测(本机,不是估算) | `packaging/docs/profiles-and-measurements.md` |
| §8 | 发行合规 | `packaging/docs/compliance-support-boundaries.md` |
| §9 | 支持矩阵(对外口径) | `packaging/docs/compliance-support-boundaries.md` |
| §10 | 已知边界 / 后续 | `packaging/docs/compliance-support-boundaries.md` |
| §11 | CI 运行时冒烟(Linux 真渲染一帧) | 本文下方(细节在 `packaging/ci-runtime-smoke.md`) |
| §12 | 文件清单 | 本文下方 |
本文件仍是**契约的唯一真相源入口**:`.github/workflows/release.yml`(及拆出的 `release-build.yml` / - **「不构建」= CI 默认根本不跑**(M139):那四档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走
`release-publish.yml`)的注释、`make_artifacts.py` 的说明、根 `README.md` / `README.zh-CN.md` 与 `gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**只有 `windows-x64`**(M142:arm64 照构建、不发布)。
`packaging/release-notes.md` 都按「`packaging/README.md` §N」引用。改契约时先改上表对应的承载文件, - **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。
再看一眼 §12 的行数预算 —— 任何一份超过 400 行都要继续按主题拆。 - **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
- 归档解压后是**一个顶层目录** `unlucky-launcher_<ver>_<平台>_<架构>/`,用户不会解压出一堆散文件。
- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
- `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` / 本文矩阵不变。
- `<ver>` 不带前导 `v`(tag 是 `v0.2.1`,文件名是 `0.2.1`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
`cputype`),与槽位声明的架构不符即失败——不会出现"名字是 arm64、内容还是 x64"的假产物。
## 2. 版本来源(唯一真相源)
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
任何一个 workspace member 版本不一致时直接失败。
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
**不一致即失败**——避免出现 tag 说 0.2.1、产物里写 0.2.0 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
## 3. 怎么构建
### 本地(当前平台)
```bash
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
python packaging/make_artifacts.py --version v0.2.1 # 顺带校验版本一致性
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
```
脚本按宿主自动判定槽位(Windows x64 / Windows arm64 / Linux x64 / Linux arm64 / macOS arm64 /
macOS x64),其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
交叉构建(例如在 x64 宿主上产 Windows arm64 产物)靠两个参数把"产物根"和"槽位"显式喂进去,
脚本不去猜宿主与产物是不是同一架构(**CI 不用这条路径**,见下):
```bash
rustup target add aarch64-pc-windows-msvc
cargo build --release --locked --target aarch64-pc-windows-msvc \
--bin unlucky-launcher-iced --bin ull # 不用 --bins:矩阵只发 GUI + ull;GUI 的构建产物名是 iced 的 bin(M118)
python packaging/make_artifacts.py --slot windows-arm64 \
--build-dir target/aarch64-pc-windows-msvc/release --out dist
```
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)。**CI 不走交叉**(M140):
`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 的同类
冒烟只在 `all_slots=true` 的取证跑里出现(`ubuntu-22.04-arm` 原生 runner 上真跑 `ull --version`)。
### CI(默认只跑两个 Windows 槽位;`all_slots=true` 才整表六槽位)
`.github/workflows/release.yml`:
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.1`。
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC +
`-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编);
`ubuntu-22.04`(glibc 2.35 地板)/ `ubuntu-22.04-arm`(原生 ARM64,同地板)/ `macos-latest` /
`macos-15-intel` 标 `mode: probe`——**未验证,默认一个都不编**,只有 `all_slots=true` 才进矩阵。
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位与 `rustup
target add` 一并没了)。arm64 槽位多一步编译前体检:查 VC 的 `lib/arm64/libcmt.lib`(`+crt-static`
靠它)与 SDK 的 `um/arm64/kernel32.lib`,缺了就 fail;**注解里固定打 4 行实况**(探测根 / SDK Lib 版本→um
架构 / MSVC lib 架构 / 两个关键库)。M142 修的正是「诊断自己先死」:`set -uo pipefail` 关不掉 runner 注入的
`-e`,`find` 扫不存在的根被 pipefail 判非零就当场退出 → 现在 `set +e` + 处处 `|| true`,清单先打完再定红绿。
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M142 起只有
`windows-x64`**:arm64 连试四轮未走通,而齐全性检查是「名单内少一档就 fail」,挂着它就等于 x64 编成功也发不出去;
它仍留在矩阵里照跑取证、红了不挡发布。**可逆**:arm64 修好后把键加回 `release.yml` 那一行,再同步 §1/§9 与
`release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
- `release` job:下载全部 artifact → **按白名单裁剪** → 重算 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.1 -f prerelease=false`
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
## 4. 每平台运行时依赖
### Windows x64(Win10/11)
| 依赖 | 来源 | 说明 |
|------|------|------|
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
本机(GNU 工具链)实测导入表证据见 §7。
### Windows ARM64(Win11 on ARM)
**本版不发布**(CI 照构建取证,红了不挡 x64 发布;等它绿了把键加回 `PUBLISH_SLOTS` 即可)。产物形态与门禁跟 x64
一样(裸 exe + 静态 CRT + dumpbin 导入表硬门禁),差别只在**怎么编**:**M140 起在原生 `windows-11-arm` 上编**
(GitHub 文档 2026 的 Standard runners 表明列该标签:4C/16G、公开仓库免费),产物落在 `target/release`。原路线
是在 `windows-latest` 上交叉到 `aarch64-pc-windows-msvc`,但**CI 上从未成功过一次**(v0.2.1 首次真跑即红在
「ARM64 工具链体检」)。M142 已确认那几轮「只剩一行 exit code 1」是**诊断脚本自己先死**(`set -uo pipefail` 关不掉
runner 注入的 `-e`,`find` 扫不存在的根被 pipefail 判非零就退出),已修;ARM64 的静态库在这台 runner 上到底缺不缺
要看下一次运行的新注解(固定 4 行实况清单,见 §3)。原生的附赠好处:宿主=产物架构,`ull --version` 冒烟**真跑**。
运行时依赖与 x64 同表(UCRT 是 Win11 ARM 的系统组件,`+crt-static` 静态链 CRT,不需要可再发行包)。
游戏侧如实说:**启动器原生运行**;Temurin 没有 windows-aarch64 产物,Microsoft OpenJDK 有该
发行版(core 的 `microsoft` 源按 `<os>-<arch>` 拼名,命名已覆盖),但 Minecraft 本体没有
Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系统 x64 模拟层 + x64 JDK。
所以本槽位只承诺「启动器产物」支持,不替 Java/游戏管理做架构级承诺。
### Linux x64
| 依赖 | 说明 |
|------|------|
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定(该槽位默认不编,`all_slots=true` 取证跑才有产物);更老的发行版请自行重建或等 musl 版 |
| `libxkbcommon.so.0` | winit 键盘处理 |
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
| `libGL.so.1` / `libEGL.so.1` | Slint 版:femtovg 的 GL 渲染;iced 版:wgpu 的 GL/EGL 后端(**推断,未实测**)。两者缺失都退到 CPU 软渲染(tiny-skia) |
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
| `libwayland-client.so.0` | Wayland 后端 |
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
**M118(D26)口径**:除上表里标注「推断,未实测」的那一行外,这张表与下面这条结论都是 **Slint 版 GUI** 的实测记录(M82:`readelf -d` + `ldd` + `nm -D`);iced 版(`crates/app-iced`)的 `DT_NEEDED` / `dlopen` 集合**尚未重测**,请按「同一族库、细节待验」读,别当成 iced 版的实测承诺。
仍然成立的部分:上表是**开窗**要用的库,不是进程启动门槛——实测(§7.4,Slint 版)除 `libfontconfig.so.1` 外它们**都不在** ELF 的 `DT_NEEDED` 里,由 `dlopen` 在开窗那一刻才加载,所以没有 X11 / Wayland / GL 的机器上 `unlucky-launcher instances list` 照样跑得起来,`ull` 连 fontconfig 都不需要。
arm64 侧同理(同一份依赖树;历史口径里的「winit + femtovg」中 femtovg 只属于 Slint 版)。
### Linux arm64(aarch64)
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
**glibc 地板也相同**:arm64 槽位跑在原生 `ubuntu-22.04-arm` runner 上(默认矩阵不编它,只有 `all_slots=true` 取证跑才出产物),地板 = **2.35**,与 x64
用的 `ubuntu-22.04` 一致。选 22.04 而不是 24.04 就是为了这个地板——24.04 的 glibc 更高,会把
Debian 12(2.36)、Raspberry Pi OS Bookworm(2.36)这些实际在跑的 ARM 发行版挡在门外。
要抬地板(例如将来 ARM 生态整体上移)换 `release.yml` 里那个槽位的 `os` 即可,脚本与产物名都不用动;
`ubuntu-24.04-arm` / `ubuntu-26.04-arm` 都是同一家族的标准公开仓库标签(4C/16GB、免费不限量)。
### macOS 12+
| 依赖 | 说明 |
|------|------|
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
| `libSystem` | 系统 |
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
**现役入口(iced 版)**:`unlucky-launcher-iced --software-renderer`(等价 `--renderer=cpu`,即 `ICED_BACKEND=tiny-skia`)
走 tiny-skia 光栅化,这是现役构建唯一的软渲染入口——CI 的 runtime-smoke 与 `packaging/linux-smoke-docker.sh` 都这么跑(§11)。
**历史资产(`v0.1.0` 的 Slint 版)**:那批已发布二进制仍认 `unlucky-launcher --software-renderer`
(解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的那个串)。
这条 Slint 路径(默认 `renderer-femtovg`/GL)已不在默认构建与发布矩阵内——`crates/app` 被 workspace `exclude`,
源码仅作参考、不编译——所以它**对现役 `unlucky-launcher-iced` 无效**,别照抄到 iced 版上。
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
- 截图通道 `--screenshot <png>`:历史实现 `crates/app/src/screenshot.rs`,现役 `crates/app-iced/src/shot/`;
软渲染下照样出图,是 CI 与远程机唯一可靠的"真出图"证据(发布冒烟见 §7,本机实跑 `--software-renderer --screenshot`)。
## 6. Cargo 构建档位(release 体积档 / fast 速度档)
| 档位 | 用在哪 | opt-level | lto | codegen-units | panic |
|------|--------|-----------|-----|---------------|-------|
| `release` | 发布 / 验收产物 | `"z"`(workspace 自身 crate) | `fat` | 1 | `abort` |
| `release.package."*"` | 同上,**只作用于依赖** | `"z"` | 不可覆盖 | 16 | 继承 |
| `fast` | 日常迭代、本机跑测试 | 2 | `thin` | 16 | `unwind` |
- **`release` 是体积档**:`fat` + 单 CGU 换来实测 GUI −10.8% / CLI −18.7%(§7.1)。变慢的根源在 `codegen-units = 1`
(单 CGU → LTO 只一个线程可跑;`fat` 只管"跨 crate 全量参与",不等于单线程),375 个 crate 排队链接就是十几分钟的量。
- **`release.package."*"` 放宽的只有并行度,不是 opt-level**:实测把依赖 opt-level 抬到 3 反而**又慢 29.6%
又大 19~24%**(§7.1),所以保持 `"z"`、只把 `codegen-units` 放到 16。package 覆盖**只允许调优化强度**:
`panic` / `lto` / `rpath` 写进去 cargo 直接报错,`strip` 会改变整个二进制的运行时行为——都不许出现。
- **`fast` 是速度档,不是发布物**:`thin` LTO + 16 CGU 可并行,`opt-level = 2` 介于 `z` 与 3 之间。
`panic = "unwind"` 是给**编出来的二进制**留的(`release` 是 `abort`,`inherits` 会照抄);`cargo test`
**不**依赖它——cargo 对 test 目标自己强制 unwind(实测 release/abort 档下 `cargo test --profile release`
照跑、23 个测试全过,test 单元的 rustc 参数里没有 `-Cpanic=`)。要看这行是否生效,比 `cargo build -v`
的 rustc 参数:release 有 `panic=abort`、fast 没有。它继承 `strip="symbols"`,所以能 unwind 但**没有符号
回溯**——要栈回溯就给 fast 档加 `strip = false`,代价是体积再涨。
- **`fast` 在机械盘机器上冷编译并无收益**(比 release 还慢、体积 2.3 倍,§7.1),价值在 SSD 机器与增量重编。
- **`[profile.fast.package."*"]` 是承重墙**:`inherits` 会把 release 的 package 覆盖一起带过来(实测:删掉它,fast 档外部依赖静默退回 `"z"`),必须存在。
- 本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release 档构建。
## 7. 实测(本机,不是估算)
构建环境: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 页或磁盘上找文件必然找不到,那不代表发布挂了。
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
| 档位 | `unlucky-launcher.exe` | `unlucky-launcher-iced.exe` | `ull.exe` | 冷构建耗时 |
|------|-----------------------|----------------------------|-----------|-----------|
| `release`,`thin`(M46 前) | 16,366,080 B = 15.61 MiB | 未测(当时无 iced 版) | 7,572,992 B = 7.22 MiB | 11m52s |
| `release`,`fat`,依赖全 `"z"`(M46,改前) | **14,595,584 B = 13.92 MiB** | 未测 | **6,157,824 B = 5.87 MiB** | 9m27s(增量) |
| `release`,`fat` + 依赖 opt-3 / cu16(M96 试过的那版,**已否**) | 20,407,296 B = 19.46 MiB | 18,371,584 B = 17.52 MiB | 7,743,488 B = 7.38 MiB | 22m45s(冷) |
| `release`,`fat` + 依赖 `"z"` / cu16(**M96 起,发布档**) | **16,479,232 B = 15.72 MiB** | **13,910,528 B = 13.27 MiB** | **6,256,128 B = 5.97 MiB** | **16m01s(冷)** |
| `fast`,`thin` + cu16(M96 起,日常档) | 38,182,400 B = 36.41 MiB | 33,570,304 B = 32.02 MiB | 15,967,744 B = 15.23 MiB | 24m27s(冷) |
测量条件(M96 三行):12 核 / `x86_64-pc-windows-gnu` / `-j 4`,源码机械盘、`CARGO_TARGET_DIR` 在 SSD,均为**删空 target 后的
冷编译**、同机同晚无并发负载;M46 两行是更早提交上的数字(依赖已热 / 另有并发),**跨行比耗时没意义**。
① **依赖 opt-level 从 3 退回 `"z"` 又快 29.6% 又小 19~24%**(两臂同为 cu16,唯一自变量是 opt-level)——抬 opt-level 是拿编译时间和体积换运行期;
`cu1 → cu16` 的收益本轮**没有同条件对照**,未归因。② `fast` **冷编译比 `release` 还慢**(24m27s vs 16m01s)、体积 2.3 倍,瓶颈在两个大 UI
crate + 链接 + 机械盘 IO——定位是 **SSD 机器与增量重编**,不是冷编译加速。体积归因:GUI 相对旧基线 +12.9%(13.92 → 15.72 MiB)里
「代码增长」与「依赖 cu16」**未分离**;`ull` 只 +1.7%,在 +1~3% 口径内。体积构成(**M118 前的 Slint 版,历史数**):Slint 运行时(双渲染器 + winit/accesskit/托盘)+ rustls/reqwest + rig/rmcp/axum 为主,`ull` 更小是 D18(CLI 零 UI 依赖)的结果。
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
| 命令 | 实测结果 |
|------|---------|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …`(Slint 版,历史实测) | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)|
### 7.3 打包验证(本机 Windows 槽位真跑;产物名是 D49 改名前的形态)
```
smoke: ull.exe --version -> 0.1.0 (ok)
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
sums: SHA256SUMS.txt
$ sha256sum -c SHA256SUMS.txt
unlucky-launcher-0.1.0-windows-x64.exe: OK
```
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
(`windows-latest` 与 `windows-11-arm` 两个镜像都自带 VS 工具链,基本找得到;跳过等于让门禁在最需要它时失效)。
总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
Linux 侧同一件事的对照(M82 实测:`readelf -d` + `ldd` + `nm -D`,容器内 Debian 12 glibc 2.36;被测的是 **Slint 版 GUI,历史实测**):Slint
GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so.6` / `libc.so.6` /
`ld-linux-x86-64.so.2`;`libX11` / `libxcb` / `libxkbcommon` / `libwayland-client` / `libGL` / `libEGL`
**都不在**导入表里,而是以字符串形式由 `dlopen` 在开窗那一刻加载(`nm -D` 可见 `U dlopen@GLIBC_2.34` /
`dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI;
`ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。
**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon /
libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version`
与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是
恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
| 取证项 | 结果 |
|--------|------|
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe`(当次实测的连字符名)14.01 MiB,`SHA256SUMS.txt` 行名一致 |
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`(旧连字符名,当次实测),权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
**没在本地验证、只能由 CI 补的**:`windows-arm64` 在 `windows-11-arm` 上的真实链接与 ARM64 导入表、
`linux-arm64` 在原生 arm64 runner 上的真跑(本机既没有 ARM 机器,也连 `raw.githubusercontent.com` 都解析不了)。
M141/M142 保留了编译前体检(查 VC 的 `lib/arm64/libcmt.lib` 与 SDK 的 `um/arm64/kernel32.lib`,无论齐不齐都先打 4 行
实况注解)。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),而是 arm64 注解转绿 + 把键加回 `PUBLISH_SLOTS`。
## 8. 发行合规
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
- Slint 的 RF 2.0 义务只跟 `v0.1.0` 那批资产:当时关于页有 `AboutSlint` 部件、选的是不触发 GPL 分支的 RF 分支
(见 `deny.toml` 的许可证白名单注释)。现役 iced UI 里没有、也不需要任何 Slint 部件。
- **M118 后这条义务只跟着已发布的 `v0.1.0` 资产走**:`crates/app` 已被 workspace `exclude`,发布矩阵里再没有链接 Slint 的二进制,新的 iced 版产物不链接 Slint;`deny.toml` 那条 RF-2.0 allow 因此暂时是死条目(不删,留给将来真有别的路径引入 Slint 时兜底)。
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
| 平台 | 本版发布 | 级别 | 运行时要求 |
|------|---------|------|-----------|
| Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) |
| Windows 11 ARM64 | ❌ 不发布(构建取证中,待修) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine |
| Linux x64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
| Linux x64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | — | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
## 10. 已知边界 / 后续
1. **GUI 的 `--version` 已解决**(M82):现在命中子命令分发,输出与 `ull --version` 同一条版本 + 系统摘要后退出,
不再开窗;§7.2 里「无输出、8 秒后被 timeout 杀掉」那行是**改之前**的记录,留作对照。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但现役 iced 版(winit + wgpu)在 musl 上的可行性未验证,未纳入本矩阵。
5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 `warning: linker
stderr: corrupt .drectve at end of def file`(MinGW `ld` 的老式抱怨,不影响产物;CI 的 MSVC 槽位没有)。
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既无 ARM 机器也无 MSVC 的 ARM64 链接库组合,
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
## 11. CI 运行时冒烟(Linux 真渲染一帧) ## 11. CI 运行时冒烟(Linux 真渲染一帧)
@@ -55,18 +391,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| 文件 | 作用 | 行数上限 | | 文件 | 作用 | 行数上限 |
|------|------|---------| |------|------|---------|
| `packaging/README.md` | 本文:索引 + §11 / §12(契约入口;§1–§10 见 `docs/`) | ≤400 | | `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
| `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 |
| `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 |
| `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |
| `packaging/docs/profiles-and-measurements.md` | §6 Cargo 构建档位 + §7 实测 | ≤400 |
| `packaging/docs/compliance-support-boundaries.md` | §8 发行合规 + §9 支持矩阵 + §10 已知边界 | ≤400 |
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 |
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / Release) | ≤400 |
-48
View File
@@ -1,48 +0,0 @@
# 产物矩阵与版本来源(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §1、§2,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 1. 产物矩阵
| 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 |
|------|---------|-----------|------|---------|
| `windows-x64` | ✅ 发布 | `unlucky-launcher_<ver>_windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
| `windows-arm64` | ❌ 不发布(构建照跑取证) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 仍在**原生** `windows-11-arm` runner 上构建(M140 起不交叉),**红了不挡 x64 发布**(M142,见 §3) |
| `linux-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
| `linux-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
| `macos-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
约定细节:
- **「不构建」= CI 默认根本不跑**(M139):那四档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走
`gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**只有 `windows-x64`**(M142:arm64 照构建、不发布)。
- **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
- 归档解压后是**一个顶层目录** `unlucky-launcher_<ver>_<平台>_<架构>/`,用户不会解压出一堆散文件。
- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
- `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` / 本文矩阵不变。
- `<ver>` 不带前导 `v`(tag 是 `v0.2.1`,文件名是 `0.2.1`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
`cputype`),与槽位声明的架构不符即失败——不会出现"名字是 arm64、内容还是 x64"的假产物。
## 2. 版本来源(唯一真相源)
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
任何一个 workspace member 版本不一致时直接失败。
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
**不一致即失败**——避免出现 tag 说 0.2.1、产物里写 0.2.0 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
-89
View File
@@ -1,89 +0,0 @@
# 构建与 CI 流水线(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §3,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 3. 怎么构建
### 本地(当前平台)
```bash
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
python packaging/make_artifacts.py --version v0.2.1 # 顺带校验版本一致性
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
```
脚本按宿主自动判定槽位(Windows x64 / Windows arm64 / Linux x64 / Linux arm64 / macOS arm64 /
macOS x64),其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
交叉构建(例如在 x64 宿主上产 Windows arm64 产物)靠两个参数把"产物根"和"槽位"显式喂进去,
脚本不去猜宿主与产物是不是同一架构(**CI 不用这条路径**,见下):
```bash
rustup target add aarch64-pc-windows-msvc
cargo build --release --locked --target aarch64-pc-windows-msvc \
--bin unlucky-launcher-iced --bin ull # 不用 --bins:矩阵只发 GUI + ull;GUI 的构建产物名是 iced 的 bin(M118)
python packaging/make_artifacts.py --slot windows-arm64 \
--build-dir target/aarch64-pc-windows-msvc/release --out dist
```
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)。**CI 不走交叉**(M140):
`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 的同类
冒烟只在 `all_slots=true` 的取证跑里出现(`ubuntu-22.04-arm` 原生 runner 上真跑 `ull --version`)。
### CI(默认只跑两个 Windows 槽位;`all_slots=true` 才整表六槽位)
`.github/workflows/release.yml`:
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.1`。
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC +
`-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编);
`ubuntu-22.04`(glibc 2.35 地板)/ `ubuntu-22.04-arm`(原生 ARM64,同地板)/ `macos-latest` /
`macos-15-intel` 标 `mode: probe`——**未验证,默认一个都不编**,只有 `all_slots=true` 才进矩阵。
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位与 `rustup
target add` 一并没了)。arm64 槽位多一步编译前体检:查 VC 的 `lib/arm64/libcmt.lib`(`+crt-static`
靠它)与 SDK 的 `um/arm64/kernel32.lib`,缺了就 fail;**注解里固定打 4 行实况**(探测根 / SDK Lib 版本→um
架构 / MSVC lib 架构 / 两个关键库)。M142 修的正是「诊断自己先死」:`set -uo pipefail` 关不掉 runner 注入的
`-e`,`find` 扫不存在的根被 pipefail 判非零就当场退出 → 现在 `set +e` + 处处 `|| true`,清单先打完再定红绿。
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M142 起只有
`windows-x64`**:arm64 连试四轮未走通,而齐全性检查是「名单内少一档就 fail」,挂着它就等于 x64 编成功也发不出去;
它仍留在矩阵里照跑取证、红了不挡发布。**可逆**:arm64 修好后把键加回 `release.yml` 那一行,再同步 §1/§9 与
`release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
- `release` job:下载全部 artifact → **按白名单裁剪** → 重算 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.1 -f prerelease=false`
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
- **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
触发、`concurrency`、槽位表(`plan`)与两个 `uses:` 编排;`build` job 的 11 步逐字搬进
`.github/workflows/release-build.yml`(`workflow_call`,四个矩阵键换成同名 `inputs.*`),`release` job 的 6 步
逐字搬进 `.github/workflows/release-publish.yml`(同 `workflow_call`,`secrets.GITHUB_TOKEN` 改为调用方显式透传
的 secret)。触发(`on`)、`env`、顶层 `permissions`、`concurrency` 与 `plan` 的槽位表**一字未动**;改槽位仍只改
`release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`,两个 arm64 修复时要改的仍是那两行)。
- **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值
`${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进
git 文件**:真值放仓库 Secret,由 `release.yml` 的 `build` job 按名显式透传(可复用工作流拿不到未显式传入的
secret;这里刻意不用 `secrets: inherit`——那会把仓库全部 secret 一起透进被调方)。Secret 未录入、或在整个
Actions 之外求值时该表达式为空串,`client_id.rs` 把空白当「没配」⇒ 非官方构建只是不烘 id,不会拿空 id 去
联网;本地自烘仍走 `ULL_MSA_CLIENT_ID=<id> cargo build`。**别把这个 env 导出到跑 `cargo test` 的 shell**:
presenter 的 M132 入口锚会在「已烘焙 id」的构建里**故意红**(它要求一个两处都没有 id 的构建)。
- 发布说明模板(`release-notes.md`)的 "Known limitations" 里,微软登录一条已改为「开箱即用、env 可覆盖」。
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
@@ -1,54 +0,0 @@
# 发行合规、支持矩阵与已知边界(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §8、§9、§10,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 8. 发行合规
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
- Slint 的 RF 2.0 义务只跟 `v0.1.0` 那批资产:当时关于页有 `AboutSlint` 部件、选的是不触发 GPL 分支的 RF 分支
(见 `deny.toml` 的许可证白名单注释)。现役 iced UI 里没有、也不需要任何 Slint 部件。
- **M118 后这条义务只跟着已发布的 `v0.1.0` 资产走**:`crates/app` 已被 workspace `exclude`,发布矩阵里再没有链接 Slint 的二进制,新的 iced 版产物不链接 Slint;`deny.toml` 那条 RF-2.0 allow 因此暂时是死条目(不删,留给将来真有别的路径引入 Slint 时兜底)。
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
| 平台 | 本版发布 | 级别 | 运行时要求 |
|------|---------|------|-----------|
| Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) |
| Windows 11 ARM64 | ❌ 不发布(构建取证中,待修) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine |
| Linux x64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
| Linux x64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机 CPU 渲染下验证(`packaging/docs/termux-glibc-build.md`),不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
## 10. 已知边界 / 后续
1. **GUI 的 `--version` 已解决**(M82):现在命中子命令分发,输出与 `ull --version` 同一条版本 + 系统摘要后退出,
不再开窗;§7.2 里「无输出、8 秒后被 timeout 杀掉」那行是**改之前**的记录,留作对照。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但现役 iced 版(winit + wgpu)在 musl 上的可行性未验证,未纳入本矩阵。
5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 `warning: linker
stderr: corrupt .drectve at end of def file`(MinGW `ld` 的老式抱怨,不影响产物;CI 的 MSVC 槽位没有)。
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既无 ARM 机器也无 MSVC 的 ARM64 链接库组合,
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
-124
View File
@@ -1,124 +0,0 @@
# Cargo 构建档位与实测数据(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §6、§7,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 6. Cargo 构建档位(release 体积档 / fast 速度档)
| 档位 | 用在哪 | opt-level | lto | codegen-units | panic |
|------|--------|-----------|-----|---------------|-------|
| `release` | 发布 / 验收产物 | `"z"`(workspace 自身 crate) | `fat` | 1 | `abort` |
| `release.package."*"` | 同上,**只作用于依赖** | `"z"` | 不可覆盖 | 16 | 继承 |
| `fast` | 日常迭代、本机跑测试 | 2 | `thin` | 16 | `unwind` |
- **`release` 是体积档**:`fat` + 单 CGU 换来实测 GUI −10.8% / CLI −18.7%(§7.1)。变慢的根源在 `codegen-units = 1`
(单 CGU → LTO 只一个线程可跑;`fat` 只管"跨 crate 全量参与",不等于单线程),375 个 crate 排队链接就是十几分钟的量。
- **`release.package."*"` 放宽的只有并行度,不是 opt-level**:实测把依赖 opt-level 抬到 3 反而**又慢 29.6%
又大 19~24%**(§7.1),所以保持 `"z"`、只把 `codegen-units` 放到 16。package 覆盖**只允许调优化强度**:
`panic` / `lto` / `rpath` 写进去 cargo 直接报错,`strip` 会改变整个二进制的运行时行为——都不许出现。
- **`fast` 是速度档,不是发布物**:`thin` LTO + 16 CGU 可并行,`opt-level = 2` 介于 `z` 与 3 之间。
`panic = "unwind"` 是给**编出来的二进制**留的(`release` 是 `abort`,`inherits` 会照抄);`cargo test`
**不**依赖它——cargo 对 test 目标自己强制 unwind(实测 release/abort 档下 `cargo test --profile release`
照跑、23 个测试全过,test 单元的 rustc 参数里没有 `-Cpanic=`)。要看这行是否生效,比 `cargo build -v`
的 rustc 参数:release 有 `panic=abort`、fast 没有。它继承 `strip="symbols"`,所以能 unwind 但**没有符号
回溯**——要栈回溯就给 fast 档加 `strip = false`,代价是体积再涨。
- **`fast` 在机械盘机器上冷编译并无收益**(比 release 还慢、体积 2.3 倍,§7.1),价值在 SSD 机器与增量重编。
- **`[profile.fast.package."*"]` 是承重墙**:`inherits` 会把 release 的 package 覆盖一起带过来(实测:删掉它,fast 档外部依赖静默退回 `"z"`),必须存在。
- 本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release 档构建。
## 7. 实测(本机,不是估算)
构建环境: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 页或磁盘上找文件必然找不到,那不代表发布挂了。
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
| 档位 | `unlucky-launcher.exe` | `unlucky-launcher-iced.exe` | `ull.exe` | 冷构建耗时 |
|------|-----------------------|----------------------------|-----------|-----------|
| `release`,`thin`(M46 前) | 16,366,080 B = 15.61 MiB | 未测(当时无 iced 版) | 7,572,992 B = 7.22 MiB | 11m52s |
| `release`,`fat`,依赖全 `"z"`(M46,改前) | **14,595,584 B = 13.92 MiB** | 未测 | **6,157,824 B = 5.87 MiB** | 9m27s(增量) |
| `release`,`fat` + 依赖 opt-3 / cu16(M96 试过的那版,**已否**) | 20,407,296 B = 19.46 MiB | 18,371,584 B = 17.52 MiB | 7,743,488 B = 7.38 MiB | 22m45s(冷) |
| `release`,`fat` + 依赖 `"z"` / cu16(**M96 起,发布档**) | **16,479,232 B = 15.72 MiB** | **13,910,528 B = 13.27 MiB** | **6,256,128 B = 5.97 MiB** | **16m01s(冷)** |
| `fast`,`thin` + cu16(M96 起,日常档) | 38,182,400 B = 36.41 MiB | 33,570,304 B = 32.02 MiB | 15,967,744 B = 15.23 MiB | 24m27s(冷) |
测量条件(M96 三行):12 核 / `x86_64-pc-windows-gnu` / `-j 4`,源码机械盘、`CARGO_TARGET_DIR` 在 SSD,均为**删空 target 后的
冷编译**、同机同晚无并发负载;M46 两行是更早提交上的数字(依赖已热 / 另有并发),**跨行比耗时没意义**。
① **依赖 opt-level 从 3 退回 `"z"` 又快 29.6% 又小 19~24%**(两臂同为 cu16,唯一自变量是 opt-level)——抬 opt-level 是拿编译时间和体积换运行期;
`cu1 → cu16` 的收益本轮**没有同条件对照**,未归因。② `fast` **冷编译比 `release` 还慢**(24m27s vs 16m01s)、体积 2.3 倍,瓶颈在两个大 UI
crate + 链接 + 机械盘 IO——定位是 **SSD 机器与增量重编**,不是冷编译加速。体积归因:GUI 相对旧基线 +12.9%(13.92 → 15.72 MiB)里
「代码增长」与「依赖 cu16」**未分离**;`ull` 只 +1.7%,在 +1~3% 口径内。体积构成(**M118 前的 Slint 版,历史数**):Slint 运行时(双渲染器 + winit/accesskit/托盘)+ rustls/reqwest + rig/rmcp/axum 为主,`ull` 更小是 D18(CLI 零 UI 依赖)的结果。
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
| 命令 | 实测结果 |
|------|---------|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …`(Slint 版,历史实测) | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)|
### 7.3 打包验证(本机 Windows 槽位真跑;产物名是 D49 改名前的形态)
```
smoke: ull.exe --version -> 0.1.0 (ok)
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
sums: SHA256SUMS.txt
$ sha256sum -c SHA256SUMS.txt
unlucky-launcher-0.1.0-windows-x64.exe: OK
```
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
(`windows-latest` 与 `windows-11-arm` 两个镜像都自带 VS 工具链,基本找得到;跳过等于让门禁在最需要它时失效)。
总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
Linux 侧同一件事的对照(M82 实测:`readelf -d` + `ldd` + `nm -D`,容器内 Debian 12 glibc 2.36;被测的是 **Slint 版 GUI,历史实测**):Slint
GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so.6` / `libc.so.6` /
`ld-linux-x86-64.so.2`;`libX11` / `libxcb` / `libxkbcommon` / `libwayland-client` / `libGL` / `libEGL`
**都不在**导入表里,而是以字符串形式由 `dlopen` 在开窗那一刻加载(`nm -D` 可见 `U dlopen@GLIBC_2.34` /
`dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI;
`ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。
**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon /
libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version`
与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是
恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
| 取证项 | 结果 |
|--------|------|
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe`(当次实测的连字符名)14.01 MiB,`SHA256SUMS.txt` 行名一致 |
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`(旧连字符名,当次实测),权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
**没在本地验证、只能由 CI 补的**:`windows-arm64` 在 `windows-11-arm` 上的真实链接与 ARM64 导入表、
`linux-arm64` 在原生 arm64 runner 上的真跑(本机既没有 ARM 机器,也连 `raw.githubusercontent.com` 都解析不了)。
M141/M142 保留了编译前体检(查 VC 的 `lib/arm64/libcmt.lib` 与 SDK 的 `um/arm64/kernel32.lib`,无论齐不齐都先打 4 行
实况注解)。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),而是 arm64 注解转绿 + 把键加回 `PUBLISH_SLOTS`。
-86
View File
@@ -1,86 +0,0 @@
# 每平台运行时依赖与软渲染兜底(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §4、§5,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 4. 每平台运行时依赖
### Windows x64(Win10/11)
| 依赖 | 来源 | 说明 |
|------|------|------|
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
本机(GNU 工具链)实测导入表证据见 §7。
### Windows ARM64(Win11 on ARM)
**本版不发布**(CI 照构建取证,红了不挡 x64 发布;等它绿了把键加回 `PUBLISH_SLOTS` 即可)。产物形态与门禁跟 x64
一样(裸 exe + 静态 CRT + dumpbin 导入表硬门禁),差别只在**怎么编**:**M140 起在原生 `windows-11-arm` 上编**
(GitHub 文档 2026 的 Standard runners 表明列该标签:4C/16G、公开仓库免费),产物落在 `target/release`。原路线
是在 `windows-latest` 上交叉到 `aarch64-pc-windows-msvc`,但**CI 上从未成功过一次**(v0.2.1 首次真跑即红在
「ARM64 工具链体检」)。M142 已确认那几轮「只剩一行 exit code 1」是**诊断脚本自己先死**(`set -uo pipefail` 关不掉
runner 注入的 `-e`,`find` 扫不存在的根被 pipefail 判非零就退出),已修;ARM64 的静态库在这台 runner 上到底缺不缺
要看下一次运行的新注解(固定 4 行实况清单,见 §3)。原生的附赠好处:宿主=产物架构,`ull --version` 冒烟**真跑**。
运行时依赖与 x64 同表(UCRT 是 Win11 ARM 的系统组件,`+crt-static` 静态链 CRT,不需要可再发行包)。
游戏侧如实说:**启动器原生运行**;Temurin 没有 windows-aarch64 产物,Microsoft OpenJDK 有该
发行版(core 的 `microsoft` 源按 `<os>-<arch>` 拼名,命名已覆盖),但 Minecraft 本体没有
Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系统 x64 模拟层 + x64 JDK。
所以本槽位只承诺「启动器产物」支持,不替 Java/游戏管理做架构级承诺。
### Linux x64
| 依赖 | 说明 |
|------|------|
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定(该槽位默认不编,`all_slots=true` 取证跑才有产物);更老的发行版请自行重建或等 musl 版 |
| `libxkbcommon.so.0` | winit 键盘处理 |
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
| `libGL.so.1` / `libEGL.so.1` | Slint 版:femtovg 的 GL 渲染;iced 版:wgpu 的 GL/EGL 后端(**推断,未实测**)。两者缺失都退到 CPU 软渲染(tiny-skia) |
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
| `libwayland-client.so.0` | Wayland 后端 |
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
**M118(D26)口径**:除上表里标注「推断,未实测」的那一行外,这张表与下面这条结论都是 **Slint 版 GUI** 的实测记录(M82:`readelf -d` + `ldd` + `nm -D`);iced 版(`crates/app-iced`)的 `DT_NEEDED` / `dlopen` 集合**尚未重测**,请按「同一族库、细节待验」读,别当成 iced 版的实测承诺。
仍然成立的部分:上表是**开窗**要用的库,不是进程启动门槛——实测(§7.4,Slint 版)除 `libfontconfig.so.1` 外它们**都不在** ELF 的 `DT_NEEDED` 里,由 `dlopen` 在开窗那一刻才加载,所以没有 X11 / Wayland / GL 的机器上 `unlucky-launcher instances list` 照样跑得起来,`ull` 连 fontconfig 都不需要。
arm64 侧同理(同一份依赖树;历史口径里的「winit + femtovg」中 femtovg 只属于 Slint 版)。
### Linux arm64(aarch64)
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
**glibc 地板也相同**:arm64 槽位跑在原生 `ubuntu-22.04-arm` runner 上(默认矩阵不编它,只有 `all_slots=true` 取证跑才出产物),地板 = **2.35**,与 x64
用的 `ubuntu-22.04` 一致。选 22.04 而不是 24.04 就是为了这个地板——24.04 的 glibc 更高,会把
Debian 12(2.36)、Raspberry Pi OS Bookworm(2.36)这些实际在跑的 ARM 发行版挡在门外。
要抬地板(例如将来 ARM 生态整体上移)换 `release.yml` 里那个槽位的 `os` 即可,脚本与产物名都不用动;
`ubuntu-24.04-arm` / `ubuntu-26.04-arm` 都是同一家族的标准公开仓库标签(4C/16GB、免费不限量)。
### macOS 12+
| 依赖 | 说明 |
|------|------|
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
| `libSystem` | 系统 |
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
**现役入口(iced 版)**:`unlucky-launcher-iced --software-renderer`(等价 `--renderer=cpu`,即 `ICED_BACKEND=tiny-skia`)
走 tiny-skia 光栅化,这是现役构建唯一的软渲染入口——CI 的 runtime-smoke 与 `packaging/linux-smoke-docker.sh` 都这么跑(§11)。
**历史资产(`v0.1.0` 的 Slint 版)**:那批已发布二进制仍认 `unlucky-launcher --software-renderer`
(解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的那个串)。
这条 Slint 路径(默认 `renderer-femtovg`/GL)已不在默认构建与发布矩阵内——`crates/app` 被 workspace `exclude`,
源码仅作参考、不编译——所以它**对现役 `unlucky-launcher-iced` 无效**,别照抄到 iced 版上。
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
- 截图通道 `--screenshot <png>`:历史实现 `crates/app/src/screenshot.rs`,现役 `crates/app-iced/src/shot/`;
软渲染下照样出图,是 CI 与远程机唯一可靠的"真出图"证据(发布冒烟见 §7,本机实跑 `--software-renderer --screenshot`)。
-279
View File
@@ -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 文件**。
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env bash
# 本地复跑 `.github/workflows/ci.yml` 的两条 **ubuntu 门禁**:`clippy --workspace --all-targets
# -- -D warnings` 与 `test --workspace --all-targets`。
#
# 为什么要有这条脚本(与 `linux-smoke-docker.sh` 同一动机,但受检对象不同):
# - smoke 脚本复刻的是 `runtime-smoke` job(能不能真跑起来);本脚本复刻的是**编译期门禁**
# (能不能编过 / 测过)。M144 的三条红里有两条(clippy ubuntu、test ubuntu)属于后者,
# 而它们只在「Linux + `--all-targets` + `-D warnings`」三件事凑齐时才炸——
# 在 Windows 本机永远复现不了(Windows 上那些 `#[cfg(target_os = "windows")]` 分支都有使用者)。
# 所以这条脚本是「本机提前挡住那两条红」的唯一通道。
#
# 用法:
# bash packaging/linux-check-docker.sh [仓库根目录]
# ULL_CHECK_STAGES=clippy bash packaging/linux-check-docker.sh # 只跑 clippy
# ULL_CHECK_TEST_ARGS='-p unlucky-app-iced --lib' \
# ULL_CHECK_STAGES=test bash packaging/linux-check-docker.sh # 只跑一个 crate 的单测
#
# 前置:基础镜像 `ull-linux-smoke:bookworm`(由 `packaging/linux-smoke-docker.sh` 建)。
# 本脚本在它之上只加一层 `cmake` + `ninja-build`:`aws-lc-sys`(rustls 的汇编后端)
# 缺 cmake 编不过,M144 实测过。
#
# 诚实边界(别把它当 runner 的等价物):
# - 基础镜像是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**;
# 两者 rustc 走同一份 `rust-toolchain.toml`(stable),所以**编译期 lint 与单测判定**可对齐,
# 但发行版差异面(系统库版本、字体)不受本脚本保护。
# - 本脚本只跑 ci.yml 里那两条命令,不跑 fmt / build / runtime-smoke。
# - 冷编很贵(全 workspace + `--all-targets`,M144 实测 15–25 分钟)。缓存卷 `ull-linux-check-target`
# 第二次起是热的;**别**把宿主机的 target 目录挂进来(路径与指纹都对不上,等于骗自己)。
set -euo pipefail
repo_root=$(cd "${1:-.}" && pwd)
base_image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm}
image=${ULL_CHECK_IMAGE:-ull-linux-check:bookworm}
cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg}
target_volume=${ULL_CHECK_TARGET_VOLUME:-ull-linux-check-target}
apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}
stages=${ULL_CHECK_STAGES:-clippy test}
test_args=${ULL_CHECK_TEST_ARGS:---workspace --all-targets --no-fail-fast}
fail_fast=${ULL_CHECK_FAIL_FAST:-0}
# Git Bash 里路径是 MSYS 虚拟路径:直接喂给 `docker -v` 会被当成容器内路径(M62 踩过)。
if command -v cygpath >/dev/null 2>&1; then
repo_root=$(cygpath -m "$repo_root")
fi
echo "== 仓库: $repo_root"
echo "== 阶段: $stages(test 参数: $test_args)"
if ! docker image inspect "$base_image" >/dev/null 2>&1; then
echo "!! 缺基础镜像 $base_image —— 先跑一次:" >&2
echo " bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke" >&2
echo " (它建的镜像里已经带了 ci.yml 那批图形栈编译依赖;本脚本只在其上加 cmake/ninja)" >&2
exit 2
fi
# 一层派生镜像:cmake + ninja-build。只在缺镜像或显式 ULL_CHECK_REBUILD_IMAGE=1 时重建。
if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_CHECK_REBUILD_IMAGE:-0}" = "1" ]; then
context=$(mktemp -d)
trap 'rm -rf "$context"' EXIT
cat >"$context/Dockerfile" <<EOF
FROM $base_image
# 基础镜像已把 apt 源改到 $apt_mirror(见 linux-smoke-docker.sh)。
# aws-lc-sys(rustls 的汇编后端)构建时要 cmake + ninja,缺了就是在 C 依赖里报错退出,
# 与 Rust 侧代码无关——别被那种红骗去改源码(M144 实测)。
RUN apt-get update \\
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\
cmake ninja-build \\
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
EOF
docker build -t "$image" "$context"
fi
# 缓存卷挂到 `registry` 子目录:直接挂 /usr/local/cargo 会盖掉镜像里的 cargo 本体(M62 踩过)。
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$repo_root:/src" \
-v "$cargo_volume:/usr/local/cargo/registry" \
-v "$target_volume:/target" \
-w /src \
-e CARGO_TARGET_DIR=/target \
-e CARGO_TERM_COLOR=never \
-e ULL_CHECK_STAGES="$stages" \
-e ULL_CHECK_TEST_ARGS="$test_args" \
-e ULL_CHECK_FAIL_FAST="$fail_fast" \
"$image" bash -euo pipefail -c '
rustc --version && cargo --version && cat /etc/debian_version
cmake --version | head -1 && ninja --version
run_clippy() { cargo clippy --workspace --all-targets -- -D warnings; }
# 故意不加引号:ULL_CHECK_TEST_ARGS 是一串参数(默认照抄 ci.yml 的那条命令)。
run_test() { cargo test $ULL_CHECK_TEST_ARGS; }
overall=0
for stage in $ULL_CHECK_STAGES; do
case "$stage" in
clippy) label="cargo clippy --workspace --all-targets -- -D warnings"; run=run_clippy ;;
test) label="cargo test $ULL_CHECK_TEST_ARGS"; run=run_test ;;
*) echo "!! 未知阶段: $stage(可选 clippy / test)" >&2; exit 2 ;;
esac
echo "== 开始: $label"
set +e
"$run"
code=$?
set -e
# 退出码单独成行:一眼能看出是「编译期 lint」还是「单测」,也能与 CI 注解里的码对照。
echo "== 结束: $label → exit=$code"
if [ "$code" -ne 0 ]; then
overall=1
if [ "$ULL_CHECK_FAIL_FAST" = "1" ]; then exit "$code"; fi
fi
done
echo "== 汇总: $ULL_CHECK_STAGES → exit=$overall"
exit "$overall"
'
echo "== 两条 ubuntu 门禁已跑完(上表 exit=0 才算绿)"
+8 -10
View File
@@ -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
@@ -100,16 +100,14 @@ Get-FileHash .\unlucky-launcher_{{VERSION}}_windows_x64.exe -Algorithm SHA256
## Known limitations of this build ## Known limitations of this build
- Microsoft (MSA) sign-in works out of the box: this build carries the Azure public client id it needs - Microsoft (MSA) sign-in needs an Azure public client id, and release binaries do not carry one: set
(a public client id, not a credential — it ships inside the binary), so there is nothing to configure `ULL_MSA_CLIENT_ID=<id>` before launching, or build with that variable set. Without it sign-in stops
before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` before launching still overrides it; the variable
is read at startup, so restart the launcher after changing it. A build with no id anywhere stops
before any network call, and the GUI banner says so plainly: the title "Microsoft sign-in is not before any network call, and the GUI banner says so plainly: the title "Microsoft sign-in is not
configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart) and configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart the
`Code: msa_not_configured`, with no Retry button. Every other sign-in failure keeps the generic launcher — a running process does not pick up a new variable) and `Code: msa_not_configured`, with
banner plus its own `Code:` line, with the full reason only in the launcher log. `ull accounts add no Retry button. Every other sign-in failure keeps the generic banner plus its own `Code:` line,
msa` prints the same fix on the command line. Offline profiles and authlib-injector servers need no with the full reason only in the launcher log. `ull accounts add msa` prints the same fix on the
id. command line. Offline profiles and authlib-injector servers need no id.
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first - Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first
launch. The checksums above are the integrity reference for now. launch. The checksums above are the integrity reference for now.
- When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock - When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock