Compare commits
1
Commits
termux-glibc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5046f96f7e |
No files matched your search
@@ -132,55 +132,7 @@ jobs:
|
||||
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
|
||||
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
|
||||
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
|
||||
#
|
||||
# ---- 取证(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
|
||||
- run: cargo test --workspace --all-targets
|
||||
|
||||
build:
|
||||
name: build release (${{ matrix.os }})
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+306
-32
@@ -8,14 +8,6 @@ name: Release
|
||||
# 契约唯一真相源 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 与
|
||||
# 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:
|
||||
push:
|
||||
@@ -86,41 +78,323 @@ jobs:
|
||||
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
|
||||
PY
|
||||
|
||||
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
|
||||
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
|
||||
build:
|
||||
name: build (${{ matrix.slot }})
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
slot: ${{ matrix.slot }}
|
||||
os: ${{ matrix.os }}
|
||||
build_dir: ${{ matrix.build_dir }}
|
||||
rustflags: ${{ matrix.rustflags }}
|
||||
secrets:
|
||||
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
|
||||
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白
|
||||
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
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: matrix.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
|
||||
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
|
||||
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
|
||||
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
|
||||
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
|
||||
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
|
||||
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
|
||||
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
|
||||
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
|
||||
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
|
||||
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
|
||||
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
|
||||
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
|
||||
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
|
||||
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
|
||||
scan
|
||||
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
|
||||
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
|
||||
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
|
||||
v=""
|
||||
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
|
||||
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
|
||||
done
|
||||
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
|
||||
v=""
|
||||
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
|
||||
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
|
||||
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
|
||||
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
|
||||
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
|
||||
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
|
||||
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
|
||||
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
|
||||
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
|
||||
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
|
||||
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
|
||||
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
|
||||
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
|
||||
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
|
||||
scan
|
||||
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
|
||||
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
|
||||
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
|
||||
exit 1
|
||||
fi
|
||||
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
|
||||
|
||||
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
|
||||
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
|
||||
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
|
||||
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
|
||||
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
|
||||
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
|
||||
fi
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
|
||||
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
|
||||
for p in "${pats[@]}"; do
|
||||
for pat in "${p//\//\\}" "$p"; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
|
||||
find_pat=()
|
||||
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
|
||||
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio" \
|
||||
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
|
||||
fi
|
||||
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
ls -l "$EXE"
|
||||
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
|
||||
fi
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ matrix.slot }}
|
||||
BUILD_DIR: ${{ matrix.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
|
||||
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||
ls -l dist
|
||||
|
||||
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for archive in dist/*.tar.gz; do
|
||||
[ -e "$archive" ] || continue
|
||||
listing=$(tar -tzf "$archive")
|
||||
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||
if ! grep -q "/$required$" <<<"$listing"; then
|
||||
echo "::error file=$archive::$archive 缺少 $required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||
done
|
||||
if ls dist/*.exe >/dev/null 2>&1; then
|
||||
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」——
|
||||
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。
|
||||
- name: arm64 失败收尾注解(红不挡发布)
|
||||
if: failure() && matrix.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: build
|
||||
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
|
||||
# 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
|
||||
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
|
||||
# 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
|
||||
if: always() && !cancelled() && github.ref_type == 'tag'
|
||||
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/release-publish.yml
|
||||
secrets:
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
|
||||
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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.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
@@ -5003,7 +5003,6 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"unlucky-cli",
|
||||
"unlucky-core",
|
||||
"unlucky-design",
|
||||
"unlucky-i18n",
|
||||
"unlucky-presenter",
|
||||
|
||||
@@ -83,17 +83,16 @@ Three tracks on one page:
|
||||
- **Third-party community auth servers** (authlib-injector style).
|
||||
- **Local offline profiles**, for isolated testing.
|
||||
|
||||
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public
|
||||
client id they need (a public client id, not a credential — it ships inside the binary), so there is
|
||||
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the
|
||||
variable is read at startup, so restart the launcher after changing it. Building from source bakes in
|
||||
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id
|
||||
anywhere stops before any network call, and its alert says exactly what is missing: 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 `Code: msa_not_configured`. It offers no Retry button, because retrying in that
|
||||
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus
|
||||
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local
|
||||
profiles need no id.
|
||||
**Microsoft sign-in needs an Azure public client id, and no release binary carries one.** The id is
|
||||
not in this repo and CI injects none, so set `ULL_MSA_CLIENT_ID=<id>` before launching, or build with
|
||||
it (`ULL_MSA_CLIENT_ID=<id> cargo build`, which bakes it in via `option_env!`). Without one, sign-in
|
||||
stops before any network call and the alert says exactly what is missing: the title "Microsoft
|
||||
sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then
|
||||
restart the launcher — a running process does not pick up a new variable), and
|
||||
`Code: msa_not_configured`. It offers no Retry button, because retrying in that same process cannot
|
||||
help. Every other sign-in failure keeps the older shape: one category line plus its own `Code:`
|
||||
line, with the full reason only in the launcher log. Community auth servers and local profiles need
|
||||
no id.
|
||||
|
||||
### 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.
|
||||
- **Install progress is stage-level**, not byte-level, on the content-library install path.
|
||||
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
|
||||
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
|
||||
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
|
||||
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only.
|
||||
- **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is
|
||||
unverified there.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
## Build from source
|
||||
@@ -268,9 +266,6 @@ cargo deny check licenses bans sources # dependency licences (re
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
|
||||
|
||||
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
|
||||
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
|
||||
|
||||
## 已验证
|
||||
|
||||
| 项 | 状态 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;CPU-only(tiny-skia) |
|
||||
| 窗口显示 | ✅ | termux-x11 `:0`,截图 1180×760,首帧 ~330 ms |
|
||||
| 中文 | ✅ | 启动时注册 `/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 运行 | 🟡 | 栈已验证;启动器已接线(natives arm64 / softpipe env);待真实 MC 验证 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
完整配方(工具链、patchelf、linker、门禁)见
|
||||
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
|
||||
|
||||
```sh
|
||||
termux-x11 :0 -ac &
|
||||
DISPLAY=:0 ./unlucky-launcher-iced --software-renderer
|
||||
```
|
||||
|
||||
- 没有 GPU 栈时**必须**加 `--software-renderer`(CPU 渲染)。
|
||||
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
|
||||
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 仅单机(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——Temurin 21 JRE + AWT、
|
||||
LWJGL arm64 + GLFW + GLX/softpipe 已手动跑通(GL 3.3)。启动器已接线:
|
||||
Linux/aarch64 自动选 `natives-linux-arm64`(阿里云 Maven 兜底),MC 子进程默认带
|
||||
softpipe 环境。还差把实例 Java 指到 glibc JRE 后跑一次真实 MC。
|
||||
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
|
||||
PC 的 Linux CI 不受影响。
|
||||
|
||||
## 后续
|
||||
|
||||
1. MC 运行链接线(natives arm64 映射 + glibc JVM 启动 + softpipe env)
|
||||
2. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)
|
||||
3. 剪贴板/IME 在 termux-x11 下的行为验证
|
||||
+8
-11
@@ -68,13 +68,12 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
|
||||
- **第三方社区认证服**(authlib-injector 一类)。
|
||||
- **本地离线档案**,用于隔离测试。
|
||||
|
||||
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是
|
||||
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值:
|
||||
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`,
|
||||
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题
|
||||
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带
|
||||
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是
|
||||
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
|
||||
**微软登录需要一个 Azure 公共客户端 id,而发布版二进制里没有。** 这个 id 不在仓库里,CI 也不注入,所以
|
||||
启动前设 `ULL_MSA_CLIENT_ID=<id>`(或构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`,它通过
|
||||
`option_env!` 烧进二进制)。没有它时点登录**不会联网**,弹窗直接说清是这一项没配:标题「微软登录未配置」,
|
||||
正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后要**重启启动器**——已在运行的进程读不到新设的环境
|
||||
变量),并带 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它
|
||||
登录失败仍是老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 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 是大方向。能力差异
|
||||
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
|
||||
|
||||
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。
|
||||
第一次用微软账户?先设 `ULL_MSA_CLIENT_ID`——见[账户](#账户)。其它都不需要配置。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
@@ -219,8 +218,6 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|
||||
|
||||
编出来的 bin:
|
||||
|
||||
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
|
||||
cargo build`);官方发布产物已内置,见[账户](#账户)。
|
||||
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
|
||||
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
|
||||
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
|
||||
|
||||
@@ -24,9 +24,6 @@ path = "src/lib.rs"
|
||||
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||
unlucky-design = { path = "../design", version = "0.2.1" }
|
||||
|
||||
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
|
||||
unlucky-core = { path = "../core", version = "0.2.1" }
|
||||
|
||||
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
|
||||
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
|
||||
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
|
||||
|
||||
@@ -30,8 +30,6 @@ use super::{MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod diagnostics;
|
||||
mod fallback;
|
||||
mod termux_gpu;
|
||||
mod termux_window;
|
||||
|
||||
use fallback::{on_failure, respawn, OnFailure};
|
||||
|
||||
@@ -41,8 +39,6 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let boot_started = Instant::now();
|
||||
|
||||
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
||||
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
|
||||
termux_gpu::enable();
|
||||
let boot = boot_probe(&options);
|
||||
let decision = boot.decision;
|
||||
diagnostics::report_boot(&options, &boot.probe, &decision);
|
||||
@@ -135,20 +131,10 @@ fn launch(
|
||||
|
||||
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
|
||||
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
|
||||
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
|
||||
// 失败就退回 1180×760 的验收尺寸。
|
||||
let (default_size, default_source) = termux_window::default_size(
|
||||
let (size, size_source) = acceptance::window_size(
|
||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||
(MIN_SIZE.width, MIN_SIZE.height),
|
||||
)
|
||||
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
|
||||
let (size, size_source) =
|
||||
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
|
||||
let size_source = if size_source == "default" {
|
||||
default_source
|
||||
} else {
|
||||
size_source
|
||||
};
|
||||
);
|
||||
let settings = diagnostics::window_settings(size);
|
||||
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
||||
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,6 @@ impl Dispatch {
|
||||
|
||||
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
|
||||
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;
|
||||
|
||||
let handle = handle.window_handle().ok()?;
|
||||
@@ -102,21 +99,11 @@ mod tests {
|
||||
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
|
||||
let invalid = dispatch(1, Some(0.5));
|
||||
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!(
|
||||
matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
|
||||
"{:?}",
|
||||
invalid.alpha
|
||||
);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
invalid.alpha,
|
||||
AlphaOutcome::Unsupported,
|
||||
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
|
||||
);
|
||||
}
|
||||
|
||||
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
|
||||
|
||||
@@ -139,43 +139,8 @@ pub fn resolve() -> Resolved {
|
||||
resolve_with(None)
|
||||
}
|
||||
|
||||
/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。
|
||||
///
|
||||
/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录,
|
||||
/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。
|
||||
/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。
|
||||
fn register_termux_fonts() {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| {
|
||||
if !termux::is_termux(&ProcessEnv) {
|
||||
return;
|
||||
}
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"/system/fonts/NotoSansCJK-Regular.ttc",
|
||||
"/system/fonts/NotoSansCJKSC-Regular.otf",
|
||||
"/system/fonts/DroidSansFallback.ttf",
|
||||
];
|
||||
let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else {
|
||||
return;
|
||||
};
|
||||
let db = font_system.raw().db_mut();
|
||||
for path in CANDIDATES {
|
||||
if db.load_font_file(path).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
register_termux_fonts();
|
||||
|
||||
let platform = Platform::current();
|
||||
let faces = system_faces();
|
||||
|
||||
|
||||
@@ -18,10 +18,6 @@
|
||||
//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事
|
||||
//! (比如把 `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)。
|
||||
pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34;
|
||||
/// `DWMWCP_DONOTROUND`:显式直角。
|
||||
@@ -124,7 +120,7 @@ pub fn set_preference(hwnd: isize, preference: u32) -> CornerOutcome {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (hwnd, preference);
|
||||
CornerOutcome::UnsupportedPlatform
|
||||
return CornerOutcome::UnsupportedPlatform;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
@@ -19,9 +19,6 @@
|
||||
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
|
||||
//! (实测:同 argv 开/关穿透,hash 完全相同)。
|
||||
|
||||
// 唯一使用者是下面的 `click_through`(它自己按平台门禁),非 Windows 上这个导入没人用
|
||||
// ⇒ `unused_imports` 在 `-D warnings` 下升成错(M147)。
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::raw_ex_style;
|
||||
|
||||
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
|
||||
|
||||
@@ -39,14 +39,9 @@
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
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::{
|
||||
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,不用。
|
||||
|
||||
@@ -34,8 +34,8 @@ pub const fn general() -> &'static str {
|
||||
config set <key> <value> 写配置(走 setting schema 校验)
|
||||
java list 列出可用 Java(含每个 JDK 的实际落点)
|
||||
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
|
||||
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家)
|
||||
java vendors 列出各发行版的镜像/支持版本/当前优先级
|
||||
--vendor temurin|oracle|microsoft|zulu 钉死一家)
|
||||
java vendors 列出四家发行版的镜像/支持版本/当前优先级
|
||||
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
|
||||
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
|
||||
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
|
||||
@@ -133,10 +133,10 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
|
||||
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
|
||||
凭据永不打印:list 只输出名称/轨道/UUID。
|
||||
add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。
|
||||
微软登录所需配置已内置在官方构建里,装完即可用;仍可用环境变量覆盖(优先于内置值):
|
||||
ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(优先于内置值)
|
||||
ULL_MSA_CLIENT_ID=<id> cargo build 自己从源码构建时注入,二进制自带
|
||||
两处都没有时(只有自建且不自带 id 才会这样)命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
|
||||
它需要一个 Azure 公共客户端 id —— 登录链路是完整的,缺的只是这一项配置;id 不进仓库:
|
||||
ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(环境变量优先)
|
||||
ULL_MSA_CLIENT_ID=<id> cargo build 构建时注入,二进制自带
|
||||
两处都没有时命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
|
||||
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
|
||||
),
|
||||
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
|
||||
@@ -157,7 +157,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
|
||||
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
|
||||
并打印每个 JDK 的实际落点(managed 项带目录)
|
||||
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
|
||||
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
|
||||
%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
|
||||
|
||||
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
|
||||
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
|
||||
@@ -168,9 +168,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
|
||||
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
|
||||
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
|
||||
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
|
||||
Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链;
|
||||
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
|
||||
--json 的 chain 块与人读的「降级:」行。",
|
||||
每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。",
|
||||
),
|
||||
"agent" => Some(
|
||||
"\
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
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 crate::context::Ctx;
|
||||
@@ -23,11 +23,7 @@ use crate::output::Report;
|
||||
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let settings = JavaSettings::load(ctx);
|
||||
let domestic = region_profile(ctx).is_domestic();
|
||||
let mut order = vendor::order(&settings.priority, domestic);
|
||||
// Termux 上系统包是一条真实可用的发行版:不进默认回退序,但展示时要让人看见。
|
||||
if termux::available() && !order.contains(&JdkVendor::Termux) {
|
||||
order.insert(0, JdkVendor::Termux);
|
||||
}
|
||||
let order = vendor::order(&settings.priority, domestic);
|
||||
let table = MirrorTable::builtin();
|
||||
let rows: Vec<Value> = order
|
||||
.iter()
|
||||
@@ -118,10 +114,6 @@ fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
|
||||
.zulu()
|
||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||
.unwrap_or_default(),
|
||||
JdkVendor::Termux => termux::MIRRORS
|
||||
.iter()
|
||||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
||||
.collect(),
|
||||
};
|
||||
json!({
|
||||
"rank": index + 1,
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
//!
|
||||
//! ## 为什么单独一块(M117)
|
||||
//!
|
||||
//! 这是**公共客户端 id**(非机密,随二进制一起分发),可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`],
|
||||
//! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])——官方发布构建在构建时烘入它(M149),
|
||||
//! 所以官方产物开箱即用;环境变量仍优先,可覆盖。**两处都没有**时登录是「没配置」,
|
||||
//! 这个 id 不进仓库(用户定案),所以它可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`],
|
||||
//! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])。**两处都没有**时登录是「没配置」,
|
||||
//! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清:
|
||||
//!
|
||||
//! | 现象 | 该说什么 |
|
||||
@@ -21,9 +20,6 @@
|
||||
pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID";
|
||||
|
||||
/// 构建期可注入的 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");
|
||||
|
||||
/// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。
|
||||
@@ -184,10 +180,7 @@ mod tests {
|
||||
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
|
||||
assert!(!message.contains("未支持"));
|
||||
}
|
||||
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
|
||||
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
|
||||
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
|
||||
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
|
||||
// 构建期注入过 id 时唯一合法(本仓库 CI/本地都不注入,走不到这里)。
|
||||
Ok(_) => assert!(
|
||||
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
|
||||
"没配 id 却构造成功"
|
||||
|
||||
@@ -79,42 +79,31 @@ pub fn plan(
|
||||
let Some(classifier) = library.native_classifier(environment) else {
|
||||
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 {
|
||||
warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
|
||||
continue;
|
||||
};
|
||||
let dest = layout.libraries_dir().join(&native_relative);
|
||||
// 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!(
|
||||
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
|
||||
library.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
|
||||
library.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !artifact.has_url() {
|
||||
warnings.push(format!(
|
||||
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
|
||||
library.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
files.push(PlanFile {
|
||||
role: FileRole::Native,
|
||||
name: format!("{}:{classifier}", library.name),
|
||||
url,
|
||||
sha1,
|
||||
size,
|
||||
dest,
|
||||
url: artifact.url.clone().unwrap_or_default(),
|
||||
sha1: artifact.sha1.clone(),
|
||||
size: artifact.size,
|
||||
dest: layout.libraries_dir().join(native_relative),
|
||||
resource: ResourceKind::Library,
|
||||
});
|
||||
}
|
||||
@@ -152,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)]
|
||||
mod tests {
|
||||
@@ -282,52 +255,4 @@ mod tests {
|
||||
.dest
|
||||
.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 linux_x86_64_keeps_the_manifest_classifier() {
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"org.lwjgl:lwjgl:3.3.2","natives":{"linux":"natives-linux"}}"#,
|
||||
);
|
||||
let env = Environment {
|
||||
os: "linux".to_string(),
|
||||
os_version: String::new(),
|
||||
arch: "x86_64".to_string(),
|
||||
..environment()
|
||||
};
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, warnings) = plan(&layout, "1.20.4", &[library], &env);
|
||||
assert!(files.is_empty(), "x86_64 没有清单下载信息,不该凭空拼 URL");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("natives-linux")),
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
//! | --- | --- |
|
||||
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
|
||||
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
|
||||
//! | Termux | `$PREFIX/lib/jvm/*`(`java-17-openjdk` 这类;`$PREFIX` 由 [`crate::platform::termux`] 识别) |
|
||||
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
|
||||
//!
|
||||
//! 三个纪律:
|
||||
@@ -23,9 +22,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::platform::paths::{EnvSource, ProcessEnv};
|
||||
use crate::platform::termux;
|
||||
|
||||
use super::extract::java_binary_name;
|
||||
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` 的父目录的父目录)。
|
||||
#[must_use]
|
||||
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();
|
||||
if cfg!(windows) {
|
||||
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"] {
|
||||
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.dedup();
|
||||
@@ -225,9 +211,8 @@ pub fn path_key(path: &Path) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
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::platform::paths::MapEnv;
|
||||
|
||||
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
|
||||
JavaRuntime {
|
||||
@@ -338,41 +323,4 @@ mod tests {
|
||||
let roots = standard_roots();
|
||||
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn termux_jvm_root_is_scanned() {
|
||||
let base = std::env::temp_dir().join(format!("ull-termux-jvm-{}", std::process::id()));
|
||||
let jdk = base.join("lib").join("jvm").join("java-17-openjdk");
|
||||
std::fs::create_dir_all(jdk.join("bin")).unwrap();
|
||||
std::fs::write(jdk.join("bin").join("java"), b"").unwrap();
|
||||
|
||||
let env = MapEnv::new()
|
||||
.with("TERMUX_VERSION", "0.118.3")
|
||||
.with("PREFIX", base.to_str().unwrap());
|
||||
let roots = standard_roots_in(&env);
|
||||
assert!(
|
||||
roots.contains(&jdk),
|
||||
"Termux 的 $PREFIX/lib/jvm 必须进扫描表:{roots:?}"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_termux_env_does_not_scan_prefix() {
|
||||
let base = std::env::temp_dir().join(format!("ull-not-termux-{}", std::process::id()));
|
||||
let jdk = base.join("lib").join("jvm").join("java-17-openjdk");
|
||||
std::fs::create_dir_all(&jdk).unwrap();
|
||||
|
||||
let env = MapEnv::new().with("PREFIX", base.to_str().unwrap());
|
||||
let roots = standard_roots_in(&env);
|
||||
assert!(
|
||||
!roots.contains(&jdk),
|
||||
"非 Termux 不该扫 $PREFIX/lib/jvm:{roots:?}"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ use super::error::{io_error, JavaError, JavaErrorKind};
|
||||
use super::extract;
|
||||
use super::location::{JavaInstallRoot, LocationMode};
|
||||
use super::probe::{self, JavaOrigin, JavaRuntime};
|
||||
use super::release::{JdkArchive, JdkRelease};
|
||||
use super::release::JdkRelease;
|
||||
use super::source::{self, ChainLog};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::{self, JdkVendor};
|
||||
@@ -184,6 +184,7 @@ pub async fn install(
|
||||
location: &JavaInstallRoot,
|
||||
request: InstallRequest,
|
||||
) -> Result<InstalledJdk, JavaError> {
|
||||
let target = AdoptiumTarget::host()?;
|
||||
let domestic = engine.region().is_domestic();
|
||||
let priority = request.order(domestic);
|
||||
let mut chain = ChainLog::new();
|
||||
@@ -191,75 +192,6 @@ pub async fn install(
|
||||
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
|
||||
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 {
|
||||
let vendor = *candidate;
|
||||
if !vendor.supports(request.major) {
|
||||
@@ -273,15 +205,6 @@ pub async fn install(
|
||||
reasons.push(format!("{}:{reason}", vendor.id()));
|
||||
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 {
|
||||
Ok(release) => release,
|
||||
Err(error) => {
|
||||
|
||||
@@ -54,7 +54,6 @@ pub mod release;
|
||||
pub mod select;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
pub mod termux;
|
||||
pub mod vendor;
|
||||
pub mod zulu;
|
||||
|
||||
|
||||
@@ -56,11 +56,6 @@ pub async fn fetch(
|
||||
})?;
|
||||
zulu::fetch_latest(client, table, major, target).await
|
||||
}
|
||||
JdkVendor::Termux => Err(JavaError::new(
|
||||
super::error::JavaErrorKind::Unsupported,
|
||||
"Termux 的 JDK 由系统包管理器安装,不走归档下载链",
|
||||
)
|
||||
.with_hint("在 Termux 里用 `ull java install <major>`(默认走 termux 源)")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
//! Termux 系统 JDK(`openjdk-<major>` 包)的安装与定位。
|
||||
//!
|
||||
//! Termux 的 openjdk 是 `.deb`(bionic),由 apt/dpkg 装进 `$PREFIX`,与 Temurin 的
|
||||
//! zip/tar.gz 归档不是同一形态,所以**不走** `source::fetch` 的归档链。这里委托包管理器:
|
||||
//!
|
||||
//! 1. 用内置镜像生成一份**隔离**的 sources.list;
|
||||
//! 2. `apt-get update && install` 的 lists/cache 都指向临时目录,不污染用户的 apt 状态;
|
||||
//! 3. 装完在 `$PREFIX/lib/jvm` 下找 `java-<major>-openjdk`,用 `bin/java` 是否存在做判据。
|
||||
//!
|
||||
//! 镜像顺序是实测可用的国内源;全部失败才报错,错误里带每一跳的原因。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::platform::paths::ProcessEnv;
|
||||
use crate::platform::termux as host;
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
|
||||
/// 内置镜像(termux-main 仓库根)。顺序即尝试顺序。
|
||||
pub const MIRRORS: &[&str] = &[
|
||||
"https://mirrors.sau.edu.cn/termux/apt/termux-main",
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main",
|
||||
"https://mirrors.ustc.edu.cn/termux/apt/termux-main",
|
||||
"https://packages.termux.dev/apt/termux-main",
|
||||
];
|
||||
|
||||
/// 当前宿主是不是 Termux。
|
||||
#[must_use]
|
||||
pub fn available() -> bool {
|
||||
host::is_termux(&ProcessEnv)
|
||||
}
|
||||
|
||||
/// 大版本 → Termux 包名(主源只有 17 / 21 / 25)。
|
||||
#[must_use]
|
||||
pub fn package_for(major: u32) -> Option<String> {
|
||||
matches!(major, 17 | 21 | 25).then(|| format!("openjdk-{major}"))
|
||||
}
|
||||
|
||||
/// `$PREFIX/lib/jvm` 下与 `major` 对应的 JDK 根(`bin/java` 必须存在)。
|
||||
#[must_use]
|
||||
pub fn installed_home(prefix: &Path, major: u32) -> Option<PathBuf> {
|
||||
let root = prefix.join("lib").join("jvm");
|
||||
let entries = std::fs::read_dir(root).ok()?;
|
||||
let dashed = format!("java-{major}-openjdk");
|
||||
let plain = format!("openjdk-{major}");
|
||||
let mut found: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir() && path.join("bin").join("java").is_file())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == dashed || name == plain)
|
||||
})
|
||||
.collect();
|
||||
found.sort();
|
||||
found.into_iter().next()
|
||||
}
|
||||
|
||||
/// 一条 `deb` 行(测试与安装共用同一份拼法)。
|
||||
#[must_use]
|
||||
pub fn sources_line(mirror: &str) -> String {
|
||||
format!("deb {} stable main\n", mirror.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// 装一个 Termux JDK,返回它的根目录。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 当前不在 Termux、主源没有该大版本、所有镜像都失败、装完仍找不到 `bin/java` 时返回错误。
|
||||
///
|
||||
/// 返回 `(JDK 根, 是否复用了已装)`:包管理器刚装完与「本来就有」对调用方是两种结果
|
||||
/// (一个要报安装,一个要报「未走网络」)。
|
||||
pub async fn install(major: u32) -> Result<(PathBuf, bool), JavaError> {
|
||||
let Some(package) = package_for(major) else {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
format!("Termux 主源没有 Java {major}(只有 17 / 21 / 25)"),
|
||||
)
|
||||
.with_hint("`pkg search openjdk` 看可用包;其它大版本请用归档发行版"));
|
||||
};
|
||||
let Some(prefix) = host::prefix(&ProcessEnv) else {
|
||||
return Err(
|
||||
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
|
||||
.with_hint("普通 Linux/Windows 用 Temurin / Oracle / MS / Zulu"),
|
||||
);
|
||||
};
|
||||
|
||||
// 幂等:已经装了同一大版本就不碰网络。
|
||||
if let Some(home) = installed_home(&prefix, major) {
|
||||
return Ok((home, true));
|
||||
}
|
||||
|
||||
let workspace = workspace_dir(&prefix, major);
|
||||
std::fs::create_dir_all(&workspace).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!(
|
||||
"建 Termux 安装临时目录失败({}):{error}",
|
||||
workspace.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let package_for_task = package.clone();
|
||||
tokio::task::spawn_blocking(move || run_install(&prefix, &workspace, &package_for_task, major))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("Termux 安装任务没能完成:{error}"),
|
||||
)
|
||||
})?
|
||||
.map(|home| (home, false))
|
||||
}
|
||||
|
||||
/// 逐镜像尝试 `apt-get`(阻塞,跑在 `spawn_blocking` 里)。
|
||||
fn run_install(
|
||||
prefix: &Path,
|
||||
workspace: &Path,
|
||||
package: &str,
|
||||
major: u32,
|
||||
) -> Result<PathBuf, JavaError> {
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
for mirror in MIRRORS {
|
||||
match apt_install(prefix, workspace, mirror, package) {
|
||||
Ok(()) => {
|
||||
if let Some(home) = installed_home(prefix, major) {
|
||||
// 装完把隔离的 lists/cache/archives 清掉(几十到上百 MB,别留在 $PREFIX/tmp)。
|
||||
let _ = std::fs::remove_dir_all(workspace);
|
||||
return Ok(home);
|
||||
}
|
||||
failures.push(format!("{mirror}:装完找不到 bin/java"));
|
||||
}
|
||||
Err(error) => failures.push(format!("{mirror}:{error}")),
|
||||
}
|
||||
}
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!(
|
||||
"Termux 包安装失败(试过 {} 个镜像):{}",
|
||||
MIRRORS.len(),
|
||||
failures.join(";")
|
||||
),
|
||||
)
|
||||
.with_hint("确认 apt 可用、磁盘空间足够,或手动 `pkg install openjdk-<major>`"))
|
||||
}
|
||||
|
||||
/// 对单个镜像跑 `apt-get update` + `install`(lists/cache 隔离在 workspace 内)。
|
||||
fn apt_install(prefix: &Path, workspace: &Path, mirror: &str, package: &str) -> Result<(), String> {
|
||||
let sources = workspace.join("sources.list");
|
||||
std::fs::write(&sources, sources_line(mirror))
|
||||
.map_err(|error| format!("写 sources.list 失败:{error}"))?;
|
||||
|
||||
let lists = workspace.join("lists");
|
||||
let cache = workspace.join("cache");
|
||||
let archives = workspace.join("archives");
|
||||
for dir in [&lists, &cache, &archives] {
|
||||
std::fs::create_dir_all(dir).map_err(|error| format!("建临时目录失败:{error}"))?;
|
||||
}
|
||||
|
||||
let options = [
|
||||
format!("Dir::Etc::sourcelist={}", sources.display()),
|
||||
"Dir::Etc::sourceparts=-".to_string(),
|
||||
format!("Dir::State::lists={}", lists.display()),
|
||||
format!("Dir::Cache={}", cache.display()),
|
||||
format!("Dir::Cache::archives={}", archives.display()),
|
||||
"Acquire::ForceIPv4=true".to_string(),
|
||||
"Acquire::Retries=2".to_string(),
|
||||
"Acquire::http::Timeout=30".to_string(),
|
||||
"Acquire::https::Timeout=30".to_string(),
|
||||
];
|
||||
|
||||
run_apt(prefix, &options, &["update"])?;
|
||||
run_apt(
|
||||
prefix,
|
||||
&options,
|
||||
&["install", "-y", "--no-install-suggests", package],
|
||||
)
|
||||
}
|
||||
|
||||
/// 执行一次 `apt-get`(`-o` 选项在前,子命令在后)。
|
||||
fn run_apt(prefix: &Path, options: &[String], args: &[&str]) -> Result<(), String> {
|
||||
let apt = prefix.join("bin").join("apt-get");
|
||||
let mut command = Command::new(&apt);
|
||||
command.env("DEBIAN_FRONTEND", "noninteractive");
|
||||
for option in options {
|
||||
command.arg("-o").arg(option);
|
||||
}
|
||||
command.args(args);
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|error| format!("执行 {} 失败:{error}", apt.display()))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let last = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("(无 stderr 输出)");
|
||||
Err(format!(
|
||||
"apt-get {} 退出 {}:{last}",
|
||||
args.first().copied().unwrap_or("?"),
|
||||
output.status.code().unwrap_or(-1)
|
||||
))
|
||||
}
|
||||
|
||||
/// 临时工作区(按大版本区分,避免两次安装互相踩 lists)。
|
||||
fn workspace_dir(prefix: &Path, major: u32) -> PathBuf {
|
||||
prefix
|
||||
.join("tmp")
|
||||
.join("ull-java-termux")
|
||||
.join(format!("jdk-{major}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{installed_home, package_for, sources_line, MIRRORS};
|
||||
|
||||
#[test]
|
||||
fn only_the_majors_that_termux_ships_are_installable() {
|
||||
assert_eq!(package_for(17).as_deref(), Some("openjdk-17"));
|
||||
assert_eq!(package_for(21).as_deref(), Some("openjdk-21"));
|
||||
assert_eq!(package_for(25).as_deref(), Some("openjdk-25"));
|
||||
assert_eq!(package_for(8), None);
|
||||
assert_eq!(package_for(11), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_line_has_one_deb_and_keeps_the_mirror_root() {
|
||||
let line = sources_line("https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main/");
|
||||
assert_eq!(
|
||||
line,
|
||||
"deb https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main stable main\n"
|
||||
);
|
||||
assert!(MIRRORS.iter().all(|mirror| mirror.starts_with("https://")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_home_requires_bin_java_and_matching_major() {
|
||||
let base = std::env::temp_dir().join(format!("ull-termux-home-{}", std::process::id()));
|
||||
let good = base.join("lib").join("jvm").join("java-21-openjdk");
|
||||
let empty = base.join("lib").join("jvm").join("java-17-openjdk");
|
||||
std::fs::create_dir_all(good.join("bin")).unwrap();
|
||||
std::fs::write(good.join("bin").join("java"), b"").unwrap();
|
||||
std::fs::create_dir_all(&empty).unwrap();
|
||||
|
||||
assert_eq!(installed_home(&base, 21), Some(good));
|
||||
assert_eq!(installed_home(&base, 17), None, "没有 bin/java 的目录不算");
|
||||
assert_eq!(installed_home(&base, 25), None);
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,7 @@
|
||||
//! `zulu` / `api`(Temurin)四个模块里。
|
||||
|
||||
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。
|
||||
///
|
||||
/// `termux` 只在 Termux 宿主上可用(系统包管理器安装),默认序里不出现;
|
||||
/// 显式写进优先级或 `--vendor termux` 才走它。
|
||||
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
||||
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
|
||||
|
||||
/// 一家 JDK 发行版。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -31,19 +28,11 @@ pub enum JdkVendor {
|
||||
Microsoft,
|
||||
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
|
||||
Zulu,
|
||||
/// Termux 的系统 openjdk(`pkg install openjdk-<major>`;只在 Termux 里可用)。
|
||||
Termux,
|
||||
}
|
||||
|
||||
impl JdkVendor {
|
||||
/// 全部发行版(枚举顺序,不是优先级顺序)。
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Temurin,
|
||||
Self::Oracle,
|
||||
Self::Microsoft,
|
||||
Self::Zulu,
|
||||
Self::Termux,
|
||||
];
|
||||
pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu];
|
||||
|
||||
/// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
|
||||
#[must_use]
|
||||
@@ -53,7 +42,6 @@ impl JdkVendor {
|
||||
Self::Oracle => "oracle",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::Zulu => "zulu",
|
||||
Self::Termux => "termux",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +53,6 @@ impl JdkVendor {
|
||||
Self::Oracle => "Oracle OpenJDK",
|
||||
Self::Microsoft => "Microsoft OpenJDK",
|
||||
Self::Zulu => "Azul Zulu",
|
||||
Self::Termux => "Termux OpenJDK",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +66,6 @@ impl JdkVendor {
|
||||
Some(Self::Microsoft)
|
||||
}
|
||||
"zulu" | "azul" => Some(Self::Zulu),
|
||||
"termux" | "termux-openjdk" | "pkg" => Some(Self::Termux),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -94,7 +80,6 @@ impl JdkVendor {
|
||||
Self::Temurin | Self::Zulu => major >= 8,
|
||||
Self::Oracle => major >= 9,
|
||||
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::Microsoft => &[11, 17, 21, 25],
|
||||
Self::Zulu => &[8, 11, 17, 21, 25],
|
||||
Self::Termux => &[17, 21, 25],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,11 +172,6 @@ impl Library {
|
||||
}
|
||||
|
||||
/// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。
|
||||
///
|
||||
/// **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> {
|
||||
let raw = self.natives.as_ref()?.get(&env.os)?;
|
||||
let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") {
|
||||
@@ -184,20 +179,7 @@ impl Library {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let classifier = 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:")
|
||||
Some(raw.replace("${arch}", arch))
|
||||
}
|
||||
|
||||
/// natives 构件下载信息(按分类器查 `downloads.classifiers`)。
|
||||
|
||||
@@ -174,13 +174,6 @@ pub fn environment(req: &LaunchRequest, os: OsFamily) -> Vec<(String, String)> {
|
||||
set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe");
|
||||
set_env(&mut env, "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 let Some(icd) = &req.renderer.vulkan_icd {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,3 @@ pub mod game;
|
||||
pub mod launch;
|
||||
pub mod platform;
|
||||
pub mod setting;
|
||||
pub mod update;
|
||||
@@ -40,7 +40,6 @@ pub mod os_info;
|
||||
pub mod paths;
|
||||
pub mod receipt;
|
||||
pub mod redirect;
|
||||
pub mod termux;
|
||||
pub mod theme_probe;
|
||||
pub mod vk_probe;
|
||||
pub mod wallpaper;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Termux(Android 上的用户空间)宿主识别。
|
||||
//!
|
||||
//! Termux 的软件前缀不在 `/usr`,而是 `$PREFIX`(典型值
|
||||
//! `/data/data/com.termux/files/usr`);JDK 由 `pkg install openjdk-17` 落到
|
||||
//! `$PREFIX/lib/jvm/java-17-openjdk`。这里只做**识别与路径推导**,不碰包管理,
|
||||
//! 也不把 `$PREFIX` 当成编译期常量写死。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::paths::EnvSource;
|
||||
|
||||
/// Termux 的默认前缀(`PREFIX` 缺失时的兜底)。
|
||||
pub const DEFAULT_PREFIX: &str = "/data/data/com.termux/files/usr";
|
||||
|
||||
/// 当前环境是否运行在 Termux 里。
|
||||
///
|
||||
/// 判据取 Termux 自己导出的两个变量:`TERMUX_VERSION`(版本)或 `PREFIX`
|
||||
/// 指向默认前缀。两者都没有时按普通 Linux 处理——不猜。
|
||||
#[must_use]
|
||||
pub fn is_termux(env: &dyn EnvSource) -> bool {
|
||||
env.var("TERMUX_VERSION").is_some()
|
||||
|| env
|
||||
.var("PREFIX")
|
||||
.is_some_and(|prefix| prefix.trim_end_matches('/') == DEFAULT_PREFIX)
|
||||
}
|
||||
|
||||
/// Termux 的软件前缀(仅当识别为 Termux 时)。
|
||||
#[must_use]
|
||||
pub fn prefix(env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
if !is_termux(env) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
env.var("PREFIX")
|
||||
.map_or_else(|| PathBuf::from(DEFAULT_PREFIX), PathBuf::from),
|
||||
)
|
||||
}
|
||||
|
||||
/// Termux 的 JDK 安装根(`$PREFIX/lib/jvm`)。
|
||||
#[must_use]
|
||||
pub fn jvm_root(env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
prefix(env).map(|prefix| prefix.join("lib").join("jvm"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_termux, jvm_root, prefix, DEFAULT_PREFIX};
|
||||
use crate::platform::paths::MapEnv;
|
||||
|
||||
#[test]
|
||||
fn plain_linux_is_not_termux() {
|
||||
let env = MapEnv::new().with("HOME", "/home/u");
|
||||
assert!(!is_termux(&env));
|
||||
assert_eq!(prefix(&env), None);
|
||||
assert_eq!(jvm_root(&env), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_prefix_is_recognised() {
|
||||
let env = MapEnv::new().with("PREFIX", DEFAULT_PREFIX);
|
||||
assert!(is_termux(&env));
|
||||
assert_eq!(
|
||||
jvm_root(&env).unwrap(),
|
||||
std::path::PathBuf::from(DEFAULT_PREFIX).join("lib/jvm")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_variable_alone_is_enough() {
|
||||
let env = MapEnv::new()
|
||||
.with("TERMUX_VERSION", "0.118.3")
|
||||
.with("PREFIX", "/data/data/com.termux/files/usr");
|
||||
assert!(is_termux(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_prefix_is_not_termux() {
|
||||
// `PREFIX` 被搬到别的目录不在识别范围内——只有版本变量或默认前缀算数。
|
||||
let env = MapEnv::new().with("PREFIX", "/opt/termux");
|
||||
assert!(!is_termux(&env));
|
||||
}
|
||||
}
|
||||
@@ -203,11 +203,7 @@ mod tests {
|
||||
use crate::platform::paths::{MapEnv, Platform};
|
||||
|
||||
const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
|
||||
// 反斜杠原样:本常量经 `%LOCALAPPDATA%` 展开后要与 `CUSTOM_THEME`(反斜杠原文)拼出**同一个
|
||||
// `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 LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local";
|
||||
const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
|
||||
const THEMES_QUERY: &str =
|
||||
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
|
||||
|
||||
@@ -39,7 +39,7 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
||||
///
|
||||
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
|
||||
/// (分层纪律),一致性由 `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 版本。
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, 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(¤t, 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(¤t, 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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -175,10 +175,7 @@ fn sha256_texts_are_parsed_the_same_way_for_every_upstream() {
|
||||
|
||||
#[test]
|
||||
fn vendor_ids_are_the_setting_whitelist() {
|
||||
assert_eq!(
|
||||
VENDOR_IDS,
|
||||
&["temurin", "oracle", "microsoft", "zulu", "termux"]
|
||||
);
|
||||
assert_eq!(VENDOR_IDS, &["temurin", "oracle", "microsoft", "zulu"]);
|
||||
for id in VENDOR_IDS {
|
||||
assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id));
|
||||
}
|
||||
|
||||
@@ -374,12 +374,13 @@ mod tests {
|
||||
#[test]
|
||||
fn accent_on_fixed_neutral_only_moves_the_neutral_hue() {
|
||||
let official = Palettes::official_baseline();
|
||||
// 中性色相取**种子色相的实测值**而不是 `NEUTRAL_HUE_DEG` 字面量:两者只差 `atan2()` 的末位
|
||||
// (见下一条测试),逐位比会在 glibc 与 UCRT 之间翻脸;取实测值两侧同源,断言仍是逐位相等。
|
||||
// 中性色相 = 基线色相时,与官方的 from_seed 完全等价(不放宽算法)。
|
||||
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 seed_hue = per_seed.neutral.hue_deg();
|
||||
let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, seed_hue, 12.0);
|
||||
assert_eq!(same, per_seed, "中性色相取种子色相时须与 TonalSpot 相同");
|
||||
assert_eq!(
|
||||
same, per_seed,
|
||||
"中性色相取种子色相时必须与 TonalSpot 逐项相同"
|
||||
);
|
||||
|
||||
// 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。
|
||||
let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0);
|
||||
|
||||
@@ -80,10 +80,6 @@ fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) ->
|
||||
.zulu()
|
||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||
.unwrap_or_default(),
|
||||
JdkVendor::Termux => unlucky_core::game::java::termux::MIRRORS
|
||||
.iter()
|
||||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+360
-32
@@ -4,31 +4,367 @@
|
||||
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
|
||||
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 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` /
|
||||
`release-publish.yml`)的注释、`make_artifacts.py` 的说明、根 `README.md` / `README.zh-CN.md` 与
|
||||
`packaging/release-notes.md` 都按「`packaging/README.md` §N」引用。改契约时先改上表对应的承载文件,
|
||||
再看一眼 §12 的行数预算 —— 任何一份超过 400 行都要继续按主题拆。
|
||||
- **「不构建」= 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 触发流水线。
|
||||
|
||||
## 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 真渲染一帧)
|
||||
|
||||
@@ -55,18 +391,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
|
||||
| 文件 | 作用 | 行数上限 |
|
||||
|------|------|---------|
|
||||
| `packaging/README.md` | 本文:索引 + §11 / §12(契约入口;§1–§10 见 `docs/`) | ≤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/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
|
||||
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
|
||||
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
||||
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤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 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
||||
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
|
||||
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / Release) | ≤400 |
|
||||
| `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 |
|
||||
@@ -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 触发流水线。
|
||||
@@ -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 走完全流程)。
|
||||
@@ -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`。
|
||||
@@ -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`)。
|
||||
@@ -1,273 +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`)即可。
|
||||
|
||||
## 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 文件**。
|
||||
@@ -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 才算绿)"
|
||||
@@ -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, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` |
|
||||
| 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
|
||||
(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
|
||||
|
||||
- Microsoft (MSA) sign-in works out of the box: this build carries the Azure public client id it needs
|
||||
(a public client id, not a credential — it ships inside the binary), so there is nothing to configure
|
||||
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
|
||||
- Microsoft (MSA) sign-in needs an Azure public client id, and release binaries do not carry one: set
|
||||
`ULL_MSA_CLIENT_ID=<id>` before launching, or build with that variable set. Without it sign-in stops
|
||||
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
|
||||
`Code: msa_not_configured`, with no Retry button. Every other sign-in failure keeps the generic
|
||||
banner plus its own `Code:` line, with the full reason only in the launcher log. `ull accounts add
|
||||
msa` prints the same fix on the command line. Offline profiles and authlib-injector servers need no
|
||||
id.
|
||||
configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart the
|
||||
launcher — a running process does not pick up a new variable) and `Code: msa_not_configured`, with
|
||||
no Retry button. Every other sign-in failure keeps the generic banner plus its own `Code:` line,
|
||||
with the full reason only in the launcher log. `ull accounts add msa` prints the same fix on the
|
||||
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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user