1 Commits
Author SHA1 Message Date
AskaEth 1105a86448 Unlucky Launcher v0.2.2
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-07 08:46:11 +08:00
68 changed files with 6959 additions and 863 deletions

No files matched your search

+49 -1
View File
@@ -132,7 +132,55 @@ jobs:
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。 # 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条 # 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。 # `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
- run: cargo test --workspace --all-targets #
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
# 事各自对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
# 注解是匿名侧唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
log="$RUNNER_TEMP/cargo-test.log"
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
# 不是 tee 的码)。
set +e
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
code=${PIPESTATUS[0]}
set -e
echo "cargo exit=$code"
if [ "$code" -ne 0 ]; then
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
fi
exit "$code"
- name: cargo test --workspace --all-targets(windows,失败升注解)
if: runner.os == 'Windows'
shell: pwsh
run: |
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
# 不会把它改掉,所以它是 cargo 自己的码。
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE
Write-Host "cargo exit=$code"
if ($code -ne 0) {
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
build: build:
name: build release (${{ matrix.os }}) name: build release (${{ matrix.os }})
+247
View File
@@ -0,0 +1,247 @@
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"
+177
View File
@@ -0,0 +1,177 @@
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: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
shell: bash
env:
TAG: ${{ github.ref_name }}
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
run: |
set -euo pipefail
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
python3 packaging/make_manifest.py --self-test
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
# 后者在 Release 正文里已有,两处各写一份迟早漂。
python3 packaging/make_manifest.py \
--dist dist \
--version "$TAG" \
--channel stable \
--asset-base-url "$RELEASE_BASE/$TAG" \
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
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` 是独立资产。
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
# D51 定案(2026-11-06,方案甲):GitHub 的 `releases/latest/download/*` 只指向**非
# prerelease** 的最新 Release。稳定版发布时显式 dispatch `prerelease=false`(latest 随之生效,
# 客户端地址不变);push tag 默认仍 prerelease(视为预发布观察,不进稳定通道)。
# 发版 checklist 第六件 = 确认 prerelease 标记与通道一致;v0.2.2 起首个适用。
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi
+31 -305
View File
@@ -8,6 +8,14 @@ name: Release
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与 # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
#
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build /
# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
on: on:
push: push:
@@ -78,323 +86,41 @@ jobs:
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr) print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
PY PY
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
build: build:
name: build (${{ matrix.slot }}) name: build (${{ matrix.slot }})
needs: plan needs: plan
strategy: strategy:
fail-fast: false fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
runs-on: ${{ matrix.os }} # 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。
steps: permissions:
- uses: actions/checkout@v4 contents: read
uses: ./.github/workflows/release-build.yml
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# (M141/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static`
# 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要
# token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。
# **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e——
# M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出),
# **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测
# 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就
# 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。
- name: Windows ARM64 工具链体检(红不挡发布,只取证)
if: matrix.slot == 'windows-arm64'
shell: bash
run: |
set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath=""
[ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true)
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
# 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处
# `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); }
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true)
scan
# 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。
sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice
say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}"
v=""
for d in "${sdk[@]}"; do [ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done
say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})"
v=""
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的
# SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
if [ -x "$inst/setup.exe" ] && [ -n "$vspath" ]; then
say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
--add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
scan
else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发"
exit 1
fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
set -euo pipefail
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with: with:
name: dist-${{ matrix.slot }} slot: ${{ matrix.slot }}
path: dist os: ${{ matrix.os }}
if-no-files-found: error build_dir: ${{ matrix.build_dir }}
retention-days: 14 rustflags: ${{ matrix.rustflags }}
secrets:
# (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」—— # (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。
# 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。 # 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
- name: arm64 失败收尾注解(红不挡发布) # 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白
if: failure() && matrix.slot == 'windows-arm64' # 当「没配」——产物只是不带 id,不会拿空 id 去联网。
shell: bash ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release"
release: release:
name: GitHub Release name: GitHub Release
needs: build needs: build
# `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) # `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档)
# 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 # 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
if: always() && !cancelled() && github.ref_type == 'tag' if: always() && !cancelled() && github.ref_type == 'tag'
runs-on: ubuntu-latest # `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
permissions: permissions:
contents: write contents: write
env: uses: ./.github/workflows/release-publish.yml
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)── secrets:
# **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着 # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
# arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据), PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# **红了不挡发布**。**可逆**: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
+8 -8
View File
@@ -4982,7 +4982,7 @@ checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]] [[package]]
name = "unlucky-agent" name = "unlucky-agent"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
@@ -4997,7 +4997,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-app-iced" name = "unlucky-app-iced"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"iced", "iced",
"image", "image",
@@ -5010,7 +5010,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-cli" name = "unlucky-cli"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5021,7 +5021,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-core" name = "unlucky-core"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
@@ -5040,15 +5040,15 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-design" name = "unlucky-design"
version = "0.2.1" version = "0.2.2"
[[package]] [[package]]
name = "unlucky-i18n" name = "unlucky-i18n"
version = "0.2.1" version = "0.2.2"
[[package]] [[package]]
name = "unlucky-palette-extract" name = "unlucky-palette-extract"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"image", "image",
"unlucky-design", "unlucky-design",
@@ -5056,7 +5056,7 @@ dependencies = [
[[package]] [[package]]
name = "unlucky-presenter" name = "unlucky-presenter"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -30,7 +30,7 @@ members = [
exclude = ["crates/app"] exclude = ["crates/app"]
[workspace.package] [workspace.package]
version = "0.2.1" version = "0.2.2"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["AskaEth <i@yeij.top>"] authors = ["AskaEth <i@yeij.top>"]
+33 -27
View File
@@ -83,16 +83,17 @@ Three tracks on one page:
- **Third-party community auth servers** (authlib-injector style). - **Third-party community auth servers** (authlib-injector style).
- **Local offline profiles**, for isolated testing. - **Local offline profiles**, for isolated testing.
**Microsoft sign-in needs an Azure public client id, and no release binary carries one.** The id is **Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public
not in this repo and CI injects none, so set `ULL_MSA_CLIENT_ID=<id>` before launching, or build with client id they need (a public client id, not a credential — it ships inside the binary), so there is
it (`ULL_MSA_CLIENT_ID=<id> cargo build`, which bakes it in via `option_env!`). Without one, sign-in nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the
stops before any network call and the alert says exactly what is missing: the title "Microsoft variable is read at startup, so restart the launcher after changing it. Building from source bakes in
sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id
restart the launcher — a running process does not pick up a new variable), and anywhere stops before any network call, and its alert says exactly what is missing: the title
`Code: msa_not_configured`. It offers no Retry button, because retrying in that same process cannot "Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with
help. Every other sign-in failure keeps the older shape: one category line plus its own `Code:` it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that
line, with the full reason only in the launcher log. Community auth servers and local profiles need same process cannot help. Every other sign-in failure keeps the older shape: one category line plus
no id. 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 ### Look and language
@@ -172,10 +173,11 @@ This list is the reason to believe the rest. None of it is hidden behind marketi
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
management from the GUI is the instance-wizard work still outstanding, and settings change as you management from the GUI is the instance-wizard work still outstanding, and settings change as you
edit them rather than on a Save. edit them rather than on a Save.
- **Linux and macOS artifacts are unverified.** The reference machine is Windows; the `linux-x64`, - **Linux and macOS artifacts are unverified, and this release does not even build them.** The
`linux-arm64`, `macos-arm64` and `macos-x64` archives are produced by the CI matrix and have not reference machine is Windows; the `linux-x64`, `linux-arm64`, `macos-arm64` and `macos-x64` slots
been downloaded and run end to end. Treat them as untested output, not as "supported because the sit in the CI matrix but the default release run skips them, and they have never been downloaded and
matrix lists them". run end to end. `gh workflow run release.yml -f all_slots=true` builds them for evidence. Treat them
as untested output, not as "supported because the matrix lists them".
- **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why - **macOS is not finalised.** It has two rounds of real-device evidence (Apple Silicon), which is why
the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts the build exists — but the menu-bar name, the placement of the three window buttons (macOS puts
them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS them top-left; ours are drawn top-right), and 300s+ steady state still need a real machine. macOS
@@ -206,19 +208,20 @@ Grab the file for your platform from
|------|-------|-------| |------|-------|-------|
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run | | `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below | | `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **not yet published**, see below |
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory | | `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory — **not yet published** |
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same | | `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **not yet published** |
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same | | `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not yet published** |
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same | | `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not yet published** |
| every slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset | | every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly | | all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
`windows-arm64` is the one row documented but **not in the current release**: the CI ARM64 runner is **This release publishes `windows-x64` and nothing else.** The publish job keeps only the slots named
missing the Windows SDK's ARM64 import libraries — the cause is located and the fix is in progress. in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before
The row stays because the architecture is still supported and the slot still builds (unpublished) in it writes `SHA256SUMS.txt`, so a row marked **not yet published** is a naming rule, not a downloadable
the matrix; what decides whether a slot reaches a Release is `PUBLISH_SLOTS` in asset. Of those rows `windows-arm64` is the one CI still builds — natively, on GitHub's
`.github/workflows/release.yml`, which today names `windows-x64` only — so `windows-x64` is the `windows-11-arm` runner, for evidence — but that slot's toolchain check has not gone green yet, so its
only downloadable Windows asset. `.exe` stays out of the release. The row stays because the architecture is still supported; it rejoins
the download list once the build is green.
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
@@ -243,7 +246,7 @@ upstream cargo. On Linux, X11 is the preferred path today and Wayland is the lon
Capability differences are feature gates with runtime probing, never a compile-time "this platform Capability differences are feature gates with runtime probing, never a compile-time "this platform
gets less". gets less".
First run with a Microsoft account? Set `ULL_MSA_CLIENT_ID` first — see First run with a Microsoft account? Nothing to configure — official builds carry the client id; see
[Accounts](#accounts). Nothing else needs configuring. [Accounts](#accounts). Nothing else needs configuring.
## Build from source ## Build from source
@@ -266,6 +269,9 @@ cargo deny check licenses bans sources # dependency licences (re
What the build produces: What the build produces:
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
— see [Accounts](#accounts).
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed - `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
@@ -289,7 +295,7 @@ merge. The macOS probe is not there yet — manual dispatch only, result recorde
|----------|---------| |----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard | | `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
| `.github/workflows/release.yml` | tag `v*` → six-slot build → single-file artifacts + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → build both Windows slots (`windows-x64` + `windows-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` only; `-f all_slots=true` builds all six slots for evidence) |
## Architecture and module map ## Architecture and module map
+24 -19
View File
@@ -68,12 +68,13 @@ Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启
- **第三方社区认证服**(authlib-injector 一类)。 - **第三方社区认证服**(authlib-injector 一类)。
- **本地离线档案**,用于隔离测试。 - **本地离线档案**,用于隔离测试。
**微软登录需要一个 Azure 公共客户端 id,而发布版二进制里没有。** 这个 id 不在仓库里,CI 也不注入,所以 **微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是
启动前设 `ULL_MSA_CLIENT_ID=<id>`(或构建时注入:`ULL_MSA_CLIENT_ID=<id> cargo build`,它通过 凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值:
`option_env!` 烧进二进制)。没有它时点登录**不会联网**,弹窗直接说清是这一项没配:标题「微软登录未配置」, 变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`,
正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后要**重启启动器**——已在运行的进程读不到新设的环境 经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题
变量),并带 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它 「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带
登录失败仍是老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。 `代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
### 外观与语言 ### 外观与语言
@@ -139,8 +140,9 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。 - **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。日志页:*打开文件夹*。
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改 设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。 ——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
- **Linux 与 macOS 产物未验证。** 参考机是 Windows;`linux-x64`、`linux-arm64`、`macos-arm64`、 - **Linux 与 macOS 产物未验证,而且本版连构建都不跑。** 参考机是 Windows;`linux-x64`、`linux-arm64`、
`macos-x64` 四份归档由 CI 矩阵产出,还没被下载下来端到端跑过。把它们当**未经测试的产出**,而不是 `macos-arm64`、`macos-x64` 四档只躺在 CI 矩阵里,默认的发布流程会跳过它们,也还没被下载下来端到端跑过;
`gh workflow run release.yml -f all_slots=true` 才编它们来取证。把它们当**未经测试的产出**,而不是
「矩阵里有就算支持」。 「矩阵里有就算支持」。
- **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置 - **macOS 未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、三个窗口按钮的位置
(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉 (macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,首次启动要自己清掉
@@ -168,17 +170,18 @@ unlucky-launcher --version # CLI,版本 + 系统摘要
|------|------|------| |------|------|------|
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 | | `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 | | `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **暂未发布**,见下 |
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 | | `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录 —— **暂未发布** |
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 | | `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **暂未发布** |
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 | | `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— **暂未发布** |
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 | | `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— **暂未发布** |
| 每个槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 | | 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 | | 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
`windows-arm64` 是表里唯一「写了但**本次未发布**」的一行:CI 的 ARM64 runner 上缺 Windows SDK 的 **本版只发布 `windows-x64`。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
ARM64 导入库——原因已定位,正在修。这一行不删,是因为架构仍然要支持、槽位也仍在矩阵里构建(只是不进 (`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
Release);决定一个槽位能否进 Release 的是 `.github/workflows/release.yml` 里的 `PUBLISH_SLOTS`, **暂未发布** 的行是命名规则,不是本版可下载的资产。这几行里 `windows-arm64` 是 CI 唯一真跑的那档:M140 起在
它现在只写了 `windows-x64`——所以 `windows-x64` 是当前唯一可下载的 Windows 资产。 GitHub 原生 `windows-11-arm` runner 上编来取证,但该槽位的工具链体检还没绿,所以它的 `.exe` 不进 Release;
行不删是因为架构仍要支持,绿了就把键加回 `PUBLISH_SLOTS`。
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe` UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款 (一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
@@ -197,7 +200,7 @@ Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。W
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异 (MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。 一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
第一次用微软账户?先设 `ULL_MSA_CLIENT_ID`——见[账户](#账户)。其它都不需要配置。 第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。
## 从源码构建 ## 从源码构建
@@ -218,6 +221,8 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
编出来的 bin: 编出来的 bin:
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
cargo build`);官方发布产物已内置,见[账户](#账户)。
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo - `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成 的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。 `unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
@@ -237,7 +242,7 @@ cargo deny check licenses bans sources # 依赖许可(CI 必
|----------|------| |----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) | | `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 | | `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 六槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release | | `.github/workflows/release.yml` | tag `v*` → 构建两个 Windows 槽位(`windows-x64` + `windows-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(只发布 `windows-x64`;`-f all_slots=true` 才整表六槽位取证) |
## 架构与模块地图 ## 架构与模块地图
+1 -1
View File
@@ -13,7 +13,7 @@ publish.workspace = true
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 # 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 # 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), # 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
# 工具结果 / 审计记录 / 事件流都用 serde derive。 # 工具结果 / 审计记录 / 事件流都用 serde derive。
+4 -4
View File
@@ -22,12 +22,12 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.2" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", version = "0.2.1" } unlucky-presenter = { path = "../presenter", version = "0.2.2" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
@@ -57,11 +57,11 @@ image = { version = "0.25", default-features = false, features = [
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli → # 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull` # core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。 # 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.2.1" } unlucky-cli = { path = "../cli", version = "0.2.2" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced, # M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。 # 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.2.1" } unlucky-i18n = { path = "../i18n", version = "0.2.2" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`, # M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` / # 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
+13
View File
@@ -52,6 +52,9 @@ impl Dispatch {
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。 /// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> { pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
// 只有 `RawWindowHandle::Win32` 那条臂用到它,其余平台只剩 `_ => None`
// ⇒ 无条件导入在非 Windows 上是 `unused_imports`(M147)。
#[cfg(target_os = "windows")]
use window::raw_window_handle::RawWindowHandle; use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?; let handle = handle.window_handle().ok()?;
@@ -99,11 +102,21 @@ mod tests {
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。 // 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5)); let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED"); assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
// 「做不到」的语义随平台不同(M147 实证:这条断言原先按 Windows 写死,Ubuntu 上
// 622 passed / 1 failed):Windows 上 FFI 真被调用并失败 ⇒ `Failed`;非 Windows 走
// `transparency.rs` 的空实现 ⇒ `Unsupported`。两边都不许表现成 `Applied` / `Opaque`。
#[cfg(target_os = "windows")]
assert!( assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }), matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}", "{:?}",
invalid.alpha invalid.alpha
); );
#[cfg(not(target_os = "windows"))]
assert_eq!(
invalid.alpha,
AlphaOutcome::Unsupported,
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
);
} }
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`), /// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
@@ -18,6 +18,10 @@
//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事 //! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事
//! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。 //! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。
// M147:本模块七项(三个属性号 + 四个读函数)只在 Windows 上有使用者——唯一调用方 `probe::run_one`
// 的 Windows 版被门着,Linux + `-D warnings` 下就是 dead_code 错。用模块级 `cfg_attr` 而不是
// 逐项 `cfg`:`cfg` 会把这些项连同本文件那几条与平台无关的单测一起门掉(逐项 `cfg_attr` 多 7 行,顶破红线)。
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
/// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。 /// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。
pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34; pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34;
/// `DWMWCP_DONOTROUND`:显式直角。 /// `DWMWCP_DONOTROUND`:显式直角。
@@ -120,7 +124,7 @@ pub fn set_preference(hwnd: isize, preference: u32) -> CornerOutcome {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
let _ = (hwnd, preference); let _ = (hwnd, preference);
return CornerOutcome::UnsupportedPlatform; CornerOutcome::UnsupportedPlatform
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -19,6 +19,9 @@
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多 //! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
//! (实测:同 argv 开/关穿透,hash 完全相同)。 //! (实测:同 argv 开/关穿透,hash 完全相同)。
// 唯一使用者是下面的 `click_through`(它自己按平台门禁),非 Windows 上这个导入没人用
// ⇒ `unused_imports` 在 `-D warnings` 下升成错(M147)。
#[cfg(target_os = "windows")]
use super::raw_ex_style; use super::raw_ex_style;
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。 /// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
@@ -39,9 +39,14 @@
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use super::alpha::{apply_window_alpha, ensure_layered}; use super::alpha::{apply_window_alpha, ensure_layered};
// `CornerOutcome` 与平台无关(`ProbeRow` 的字段类型、非 Windows 的 `empty_row` 都用它),
// 其余九个符号只服务于 Windows 的 `run_one` —— 分开写,否则非 Windows 上整条导入无人使用
// (`unused_imports`,M147)。
use super::dwm::CornerOutcome;
#[cfg(target_os = "windows")]
use super::dwm::{ use super::dwm::{
corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness, corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness,
window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND, window_rect, window_style, DWMWCP_DONOTROUND, DWMWCP_ROUND,
}; };
/// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。 /// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。
+2 -2
View File
@@ -22,10 +22,10 @@ path = "src/lib.rs"
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, # 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 # 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[dependencies] [dependencies]
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, # agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
# core 侧五个端口在 src/commands/agent/ports/ 装配)。 # core 侧五个端口在 src/commands/agent/ports/ 装配)。
unlucky-agent = { path = "../agent", version = "0.2.1" } unlucky-agent = { path = "../agent", version = "0.2.2" }
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 # `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
tokio.workspace = true tokio.workspace = true
serde.workspace = true serde.workspace = true
+4 -4
View File
@@ -133,10 +133,10 @@ accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 **分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
凭据永不打印:list 只输出名称/轨道/UUID。 凭据永不打印:list 只输出名称/轨道/UUID。
add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。 add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。
它需要一个 Azure 公共客户端 id —— 登录链路是完整的,缺的只是这一项配置;id 不进仓库: 微软登录所需配置已内置在官方构建里,装完即可用;仍可用环境变量覆盖(优先于内置值):
ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(环境变量优先) ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(优先于内置值)
ULL_MSA_CLIENT_ID=<id> cargo build 构建时注入,二进制自带 ULL_MSA_CLIENT_ID=<id> cargo build 自己从源码构建时注入,二进制自带
两处都没有时命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。 两处都没有时(只有自建且不自带 id 才会这样)命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
), ),
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), "sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
+5 -3
View File
@@ -84,10 +84,12 @@ pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()), AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路: // M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。 // 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
AuthError::Unsupported(_) => Some(format!( AuthError::Unsupported(_) => Some(format!(
"先配 Azure 公共客户端 id(id 不进仓库):\ "官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
`{env}=<id> %prog% accounts add msa`;\ 运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
想让二进制自带就构建时注入 `{env}=<id> cargo build`。环境变量优先于构建期注入。", 或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
)), )),
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()), AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
+5 -2
View File
@@ -18,7 +18,10 @@
//! //!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义; //! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧 //! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
//! 优先级落地,不自创排序。 //! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API //! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为** //! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。 //! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
@@ -77,7 +80,7 @@ pub use fetch::{
}; };
pub use merge::{MergeAction, MergeDecision}; pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative; pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant}; pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use report::{ pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue, keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile, ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
+12 -2
View File
@@ -22,6 +22,11 @@ use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo; use crate::download::HashAlgo;
/// 阶段一:覆盖条目落暂存 + 三方合并。 /// 阶段一:覆盖条目落暂存 + 三方合并。
///
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
/// "两条 Installed、前一条的哈希其实没落地"。
pub(crate) async fn extract_overrides( pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>, request: &ImportRequest<'_>,
archive: &PackArchive, archive: &PackArchive,
@@ -31,9 +36,14 @@ pub(crate) async fn extract_overrides(
report: &mut ImportReport, report: &mut ImportReport,
next: &mut PackBaseline, next: &mut PackBaseline,
) -> Result<(), ImportError> { ) -> Result<(), ImportError> {
let total = plan.overrides.len(); let live: Vec<&super::plan::PlanOverride> = plan
.overrides
.iter()
.filter(|entry| entry.shadowed.is_none())
.collect();
let total = live.len();
let target = staging.target().to_path_buf(); let target = staging.target().to_path_buf();
for (index, entry) in plan.overrides.iter().enumerate() { for (index, entry) in live.iter().enumerate() {
tell( tell(
&request.progress, &request.progress,
ImportStage::Extract, ImportStage::Extract,
+11 -13
View File
@@ -17,19 +17,24 @@
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径 //! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径 //! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。 //! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删 //! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在 //! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
//! [`paths`]。 //! [`paths`]。
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary` //! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。 //! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
mod layer;
pub mod paths; pub mod paths;
pub use layer::Shadowed;
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant}; pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
use super::super::pack::{ use super::super::pack::{
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest, curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
PackOverrides, PackSide, PackSide,
}; };
use super::error::ImportError; use super::error::ImportError;
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent}; use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
@@ -51,6 +56,8 @@ pub struct PlanOverride {
pub disabled: bool, pub disabled: bool,
/// 可选注记(`.disabled` / `.old`)。 /// 可选注记(`.disabled` / `.old`)。
pub note: Option<ImportIssue>, pub note: Option<ImportIssue>,
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
pub shadowed: Option<Shadowed>,
} }
/// 一条要补全/下载的远端条目。 /// 一条要补全/下载的远端条目。
@@ -136,7 +143,7 @@ impl ImportPlan {
let mut overrides: Vec<PlanOverride> = Vec::new(); let mut overrides: Vec<PlanOverride> = Vec::new();
let mut skipped_server_files = 0_usize; let mut skipped_server_files = 0_usize;
for group in ordered_overrides(&manifest) { for group in layer::ordered(&manifest) {
if group.side == PackSide::Server { if group.side == PackSide::Server {
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源: // Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。 // install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
@@ -153,9 +160,11 @@ impl ImportPlan {
size: file.size, size: file.size,
disabled: variant.is_disabled(), disabled: variant.is_disabled(),
note: suffix_note(variant, &file.path), note: suffix_note(variant, &file.path),
shadowed: None,
}); });
} }
} }
layer::resolve(&mut overrides, &mut notes);
if skipped_server_files > 0 { if skipped_server_files > 0 {
notes.push(ImportIssue::new( notes.push(ImportIssue::new(
keys::NOTE_SERVER_OVERRIDES_SKIPPED, keys::NOTE_SERVER_OVERRIDES_SKIPPED,
@@ -232,17 +241,6 @@ impl ImportPlan {
} }
} }
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
fn ordered_overrides(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
groups
}
/// 选一个加载器并翻译成 [`LoaderTarget`]。 /// 选一个加载器并翻译成 [`LoaderTarget`]。
fn select_loader( fn select_loader(
manifest: &PackManifest, manifest: &PackManifest,
+208
View File
@@ -0,0 +1,208 @@
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
//!
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
use std::collections::HashMap;
use super::super::report::{keys, FileSource, ImportIssue};
use super::PlanOverride;
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
///
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
/// `server-overrides/ 被跳过` 完全同一条通道。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shadowed {
/// 胜出那条的来源侧。
pub winner: FileSource,
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
pub winner_dir: String,
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
pub same_bytes: bool,
}
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
groups
}
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
///
/// # 这是"逐文件"而不是"整包一个策略"的那一步
///
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
///
/// 现在把这层算清楚,逐条给出两个事实:
///
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
///
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
let mut last: HashMap<String, usize> = HashMap::new();
for (index, entry) in overrides.iter().enumerate() {
last.insert(entry.path.clone(), index);
}
for index in 0..overrides.len() {
let path = overrides[index].path.clone();
let Some(&winner_index) = last.get(&path) else {
continue;
};
if winner_index == index {
continue;
}
let winner_dir = overrides[winner_index].dir.clone();
let loser_dir = overrides[index].dir.clone();
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
let conclusion = if same_bytes {
"两份内容逐字节相同,只落地一次"
} else {
"两份内容不同,以这一条为准"
};
notes.push(
ImportIssue::new(
keys::NOTE_OVERRIDE_SHADOWED,
format!(
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
{conclusion}"
),
)
.with_path(path),
);
overrides[index].shadowed = Some(Shadowed {
winner: overrides[winner_index].source,
winner_dir,
same_bytes,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
PlanOverride {
dir: dir.to_string(),
path: path.to_string(),
source,
sha1: sha1.to_string(),
size: 1,
disabled: false,
note: None,
shadowed: None,
}
}
#[test]
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
let mut overrides = vec![
entry(
"overrides",
"config/dup.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/dup.toml",
FileSource::ClientOverrides,
"bbbb",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
assert_eq!(shadowed.winner_dir, "client-overrides");
assert!(!shadowed.same_bytes, "同路径不同哈希");
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
let message = &notes[0].message;
for expected in [
"overrides/config/dup.toml",
"client-overrides/config/dup.toml",
"内容不同",
] {
assert!(message.contains(expected), "缺 {expected}:{message}");
}
}
#[test]
fn identical_bytes_are_reported_as_same_bytes() {
let mut overrides = vec![
entry(
"overrides",
"config/same.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/same.toml",
FileSource::ClientOverrides,
"aaaa",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
assert!(
notes[0].message.contains("逐字节相同"),
"内容一致要说清楚,别让人以为丢了东西:{}",
notes[0].message
);
}
#[test]
fn different_paths_with_the_same_bytes_are_never_shadowed() {
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
let mut overrides = vec![
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
assert!(notes.is_empty());
}
#[test]
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
let mut overrides = vec![
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert_eq!(
overrides[0].shadowed.as_ref().expect("标记").winner,
FileSource::Overrides
);
assert_eq!(notes.len(), 1);
}
}
@@ -51,6 +51,8 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped"; pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。 /// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete"; pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 缺失:调用方没给 fetcher(离线导入)。 /// 缺失:调用方没给 fetcher(离线导入)。
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline"; pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
@@ -94,6 +96,7 @@ pub const ALL: &[&str] = &[
NOTE_BASELINE_PACK_MISMATCH, NOTE_BASELINE_PACK_MISMATCH,
NOTE_ENGINE_ARTIFACTS_DROPPED, NOTE_ENGINE_ARTIFACTS_DROPPED,
NOTE_ROLLBACK_INCOMPLETE, NOTE_ROLLBACK_INCOMPLETE,
NOTE_OVERRIDE_SHADOWED,
MISSING_OFFLINE, MISSING_OFFLINE,
MISSING_NO_SOURCE, MISSING_NO_SOURCE,
MISSING_FETCH_FAILED, MISSING_FETCH_FAILED,
+14 -82
View File
@@ -51,9 +51,7 @@
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。 //! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
use std::fmt::Debug; use std::fmt::Debug;
use std::fs::File; use std::path::Path;
use std::io::Write;
use std::path::{Path, PathBuf};
use super::archive::PackArchive; use super::archive::PackArchive;
use super::curseforge; use super::curseforge;
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
use super::manifest::PackManifest; use super::manifest::PackManifest;
use super::modrinth; use super::modrinth;
use super::multimc; use super::multimc;
use super::path::{normalize_dir, normalize_file};
mod payload; mod payload;
mod write;
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行): /// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按 /// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
/// 导出一个整包(清单 + 覆盖内容)。 /// 导出一个整包(清单 + 覆盖内容)。
/// ///
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出; /// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。 /// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
/// ///
/// # 只保证同格式往返(写给 M111/M112) /// # 只保证同格式往返(写给 M111/M112)
/// ///
@@ -148,8 +147,16 @@ pub trait PackFormat: Debug + Send + Sync {
manifest.check_paths()?; manifest.check_paths()?;
let json = self.render(manifest)?; let json = self.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。 // 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = temporary_path(target); let temp = write::temporary_path(target);
match write_container(self.id(), entry, &json, manifest, payload, &temp) { match write::container(
self.id(),
entry,
&json,
manifest,
payload,
&temp,
zip::ZIP64_BYTES_THR,
) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| { Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp); let _ = std::fs::remove_file(&temp);
PackError::io(format!( PackError::io(format!(
@@ -166,81 +173,6 @@ pub trait PackFormat: Debug + Send + Sync {
} }
} }
/// 临时文件路径(与目标同目录,`rename` 才能原子替换)。
fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("pack"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{}.part", std::process::id()));
target.with_file_name(name)
}
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
fn write_container(
format_id: &str,
entry: &'static str,
json: &str,
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
) -> PackResult<()> {
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
writer
.start_file(entry, options)
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
writer
.write_all(json.as_bytes())
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
// 未压缩总量封顶在 ZIP64 阈值(≈4 GiB):Modrinth 官方导出也是这样拒绝的,而不是
// 静默截断(HMCL 那边 `(int)` 截断只打 warning 是反面教材)。临时文件由调用方删。
let mut total = json.len() as u64;
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let name = format!("{dir}/{path}");
let source = match payload.source_for(&name) {
Some(source) => source.to_path_buf(),
None => {
let Some(root) = payload.root() else {
return Err(PackError::unsupported(format!(
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
)));
};
root.join(&dir).join(&path)
}
};
let bytes = std::fs::read(&source).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
total += bytes.len() as u64;
if total >= zip::ZIP64_BYTES_THR {
return Err(PackError::limits(format!(
"包体未压缩总量已超过 {} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包",
zip::ZIP64_BYTES_THR
))
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
}
writer
.start_file(name.clone(), options)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
writer
.write_all(&bytes)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
}
}
writer
.finish()
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
Ok(())
}
/// 一次探测的结果。 /// 一次探测的结果。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PackMatch { pub struct PackMatch {
+346
View File
@@ -0,0 +1,346 @@
//! 把模型写成 zip:**声明尺寸预检 → 写清单 → 逐条搬覆盖内容**(`format.rs` 只留 trait 与注册表)。
//!
//! # 为什么预检必须在读第一个字节之前
//!
//! 一条写死的边界(research/18-p2 §8.6,A 级 `export_mrpack.rs:305-311`):**不写 ZIP64 包**。
//! Modrinth 官方导出撞到阈值直接报错,HMCL 那边 `(int)` 截断只打 warning 是反面教材。
//!
//! 早先的实现是「读一个文件 → 累加 → 比较」:真要导出一个 4 GiB 覆盖文件的实例,
//! `std::fs::read` 先把它整份读进内存,闸门**永远够不着**——用户看到的是内存打爆或 IO 错,
//! 而不是"包太大,把这个文件移出实例"。所以现在先跑一遍 `metadata().len()` 累加
//! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。
//!
//! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。
//! **第二道自己从清单长度起算**,绝不拿预检的返回值当基数——两者合起来会重复累计,
//! 于是真实总量只要过半阈值(~2.15 GiB)就被误拒,而且拒在内容已经读出来之后(P2-1)。
//!
//! # 内容按块搬运,不整份读进内存
//!
//! 覆盖文件用 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`)。这不是优化癖:`std::fs::read` 会把
//! 一份 2.5 GiB 的 mod 整份摊在内存里,恰恰破坏"闸在内容进内存之前"这条性质——即使判据
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
//! 与内存占用无关。
//!
//! # 阈值与声明口径
//!
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
//! 官方实现同样按这个粗粒度判。
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use super::super::error::{PackError, PackResult};
use super::super::manifest::PackManifest;
use super::super::path::{normalize_dir, normalize_file};
use super::payload::PackPayload;
/// 搬覆盖内容时的缓冲大小(流式,不把文件整份读进内存)。
const COPY_CHUNK: usize = 1024 * 1024;
/// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。
pub(super) fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("pack"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{}.part", std::process::id()));
target.with_file_name(name)
}
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
pub(super) fn container(
format_id: &str,
entry: &'static str,
json: &str,
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
limit: u64,
) -> PackResult<()> {
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
writer
.start_file(entry, options)
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
writer
.write_all(json.as_bytes())
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
let mut written = Written::new(json.len() as u64, limit);
let mut buffer = vec![0_u8; COPY_CHUNK];
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let name = format!("{dir}/{path}");
let source = source_for(payload, &dir, &path)?;
let mut source_file = File::open(&source).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
writer
.start_file(name.clone(), options)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
loop {
let read = source_file.read(&mut buffer).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
written.spend(read as u64)?;
}
}
}
writer
.finish()
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
Ok(())
}
/// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。
///
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
fn preflight(
json_len: u64,
manifest: &PackManifest,
payload: PackPayload<'_>,
limit: u64,
) -> PackResult<u64> {
let mut total = json_len;
guard_with(total, limit)?;
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let source = source_for(payload, &dir, &path)?;
// 缺文件 / 读不到元数据在这里就报(与"读内容失败"同一个 `pack_io` 口径:
// 目标路径上不会留下半个包,临时文件由调用方删)。
let length = std::fs::metadata(&source)
.map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?
.len();
total += length;
guard_with(total, limit)?;
}
}
Ok(total)
}
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
let name = format!("{dir}/{path}");
if let Some(source) = payload.source_for(&name) {
return Ok(source.to_path_buf());
}
let Some(root) = payload.root() else {
return Err(PackError::unsupported(format!(
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
)));
};
Ok(root.join(dir).join(path))
}
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
///
/// 起点是**清单 JSON 的长度**,与 `preflight` 的返回值无关:两者混用会把每个文件的字节算
/// 两遍,真实总量过半阈值(~2.15 GiB)就被误拒(P2-1)。类型上也没法混用——`Written`
/// 由 `u64` 构造,而预检的返回值是"全量声明尺寸",只有显式取值才拿得到。
struct Written {
total: u64,
limit: u64,
}
impl Written {
const fn new(json_len: u64, limit: u64) -> Self {
Self {
total: json_len,
limit,
}
}
/// 记一笔实际写出的字节,并在**每一块之后**过闸(大文件因此能在中途被拦下)。
fn spend(&mut self, bytes: u64) -> PackResult<()> {
self.total = self.total.saturating_add(bytes);
guard_with(self.total, self.limit)
}
}
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
/// "撞上就不写")。
///
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
if total >= limit {
return Err(PackError::limits(format!(
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
))
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
#[test]
fn the_threshold_is_reached_not_exceeded() {
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
assert!(guard_with(99, 100).is_ok());
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(error.message().contains("100"), "{error}");
assert!(guard_with(101, 100).is_err());
}
#[test]
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let half = zip::ZIP64_BYTES_THR / 2;
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
written
.spend(2_500_000_000)
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
let error = written
.spend(zip::ZIP64_BYTES_THR)
.expect_err("撞阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
}
#[test]
fn the_container_does_not_double_count_the_preflight_total() {
// 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**:
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的
// `#[ignore]` 用例)。
let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id()));
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件");
// 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。
let json = "{\"name\":\"0123456789012345\"}";
let declared = json.len() as u64 + 40;
let limit = declared + 20;
let mut manifest = PackManifest::new("double count");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![OverrideFile {
path: "mods/a.bin".to_string(),
sha1: String::new(),
size: 40,
}],
});
let target = root.join("out.zip");
container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&target,
limit,
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
let refused = root.join("refused.zip");
let error = container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&refused,
declared,
)
.expect_err("声明总量达到 limit 必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(!refused.exists(), "预检失败时目标路径不许出现");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
let huge = root.join("overrides/mods/huge.jar");
let file = std::fs::File::create(&huge).expect("建稀疏文件");
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
drop(file);
let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![
OverrideFile {
path: "mods/small.jar".to_string(),
sha1: String::new(),
size: 10,
},
OverrideFile {
path: "mods/huge.jar".to_string(),
sha1: String::new(),
size: 0,
},
],
});
let total = preflight(
7,
&manifest,
PackPayload::from_root(&root),
zip::ZIP64_BYTES_THR,
)
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
);
let _ = std::fs::remove_dir_all(&root);
}
}
+1
View File
@@ -13,6 +13,7 @@ mod model;
mod roundtrip; mod roundtrip;
mod safety; mod safety;
mod unknown_fields; mod unknown_fields;
mod zip64;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
@@ -111,3 +111,56 @@ fn curseforge_loader_unknown_keys_survive_read_write_reread() {
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写"); assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0")); assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
} }
#[test]
fn mrpack_file_unknown_keys_survive_read_write_reread() {
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
let root = super::temp_root("mr-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"file extra keys",
"files":[{"path":"mods/a.jar",
"hashes":{"sha1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"md5":"d41d8cd98f00b204e9800998ecf8427e"},
"downloads":["https://cdn.modrinth.com/data/abc/versions/x/a.jar"],
"fileSize":42,
"mirror":"https://mirror.example.invalid/a.jar",
"optionalReason":{"why":"kept for the community"}}],
"dependencies":{"minecraft":"1.20.1"}}"#,
);
let zip = root.join("pack.mrpack");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
assert_eq!(
manifest.files[0].extra["mirror"],
json!("https://mirror.example.invalid/a.jar")
);
assert_eq!(
manifest.files[0].extra["optionalReason"],
json!({"why": "kept for the community"})
);
// 未知**哈希算法**也不丢:`hashes` 是整张表,不是两个字段。
assert_eq!(
manifest.files[0].hashes.get("md5").map(String::as_str),
Some("d41d8cd98f00b204e9800998ecf8427e")
);
let target = root.join("out.mrpack");
MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 mrpack");
let reread = MODRINTH
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
assert_eq!(
reread.files[0].extra["optionalReason"],
json!({"why": "kept for the community"})
);
}
+182
View File
@@ -0,0 +1,182 @@
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
//!
//! # 为什么这个文件以前是空的
//!
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
//!
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
//! 没有 `.part`、目标路径不存在);
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
use std::path::Path;
use super::temp_root;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
/// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: "0".repeat(40),
size: 0,
}
}
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
let mut manifest = PackManifest::new("超大包");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files,
});
manifest
}
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
fn sparse_file(path: &Path, length: u64) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建稀疏文件");
file.set_len(length).expect("拉长文件(稀疏)");
}
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| !keep.contains(&name.as_str()))
.collect();
names.sort();
names
}
#[test]
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
let root = temp_root("zip64-single");
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
sparse_file(
&root.join("overrides/mods/huge.jar"),
zip::ZIP64_BYTES_THR + 1,
);
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("超 ZIP64 阈值必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
);
assert!(!target.exists(), "失败后目标路径不许存在");
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn the_threshold_is_cumulative_not_per_file() {
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
let root = temp_root("zip64-cumulative");
let each = 1_610_612_736_u64; // 1.5 GiB
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
let mut files = Vec::new();
for index in 0..3 {
let relative = format!("mods/part-{index}.jar");
sparse_file(&root.join("overrides").join(&relative), each);
files.push(override_file(&relative));
}
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("累计超阈值同样必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(!target.exists());
}
#[test]
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
let root = temp_root("zip64-under");
let content = b"a normal sized override file";
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect("正常尺寸必须写得出来");
assert!(target.exists());
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
assert!(written.has_entry("overrides/mods/jei.jar"));
}
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
///
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
///
/// ```text
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
/// ```
///
/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为
/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。
#[test]
#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"]
fn a_single_file_inside_the_double_count_window_still_exports() {
// P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个
// 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。
// 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。
let root = temp_root("zip64-window");
let length = 2_500_000_000_u64;
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
assert!(
length > zip::ZIP64_BYTES_THR / 2,
"样本必须落在过拒窗口里(≥ 阈值一半)"
);
sparse_file(&root.join("overrides/mods/big.jar"), length);
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
let target = root.join("out.zip");
let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target);
assert!(
result.is_ok(),
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
result.err()
);
// 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。
// 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有
// 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。
let file = std::fs::File::open(&target).expect("打开导出包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
let entry = archive
.by_name("overrides/mods/big.jar")
.expect("覆盖条目要在包里");
assert_eq!(entry.size(), length, "声明尺寸要原样带过去");
// 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。
let _ = std::fs::remove_dir_all(&root);
}
@@ -0,0 +1,211 @@
//! 逐文件冲突策略的**边界样本**(research/18 §3.3 / §6 表 B #1–#2 的那几条)。
//!
//! 三条样本各自钉一个事实,缺一条就会复现不同的 bug:
//!
//! | 样本 | 必须成立的事实 | 写错的后果 |
//! |------|----------------|-----------|
//! | 同路径不同哈希 | 叠层顺序里**最后一条**胜;被遮蔽的那条不落地、只留一条具名注记 | 报告里两条 `Installed`,前一条的哈希根本没落地 |
//! | 同哈希不同路径 | **两个文件都落地**(按路径判重,不许按哈希判重) | 静默丢一个文件(内容一样不等于同一个文件) |
//! | overrides 叠层顺序 | `overrides/` 先、`client-overrides/` 后(= 后者胜,Modrinth 官方语义) | 与 HMCL 的相反语义混进来,同一包在不同启动器装出不同内容 |
//!
//! 判据的纯函数版本(`plan::layer::resolve`)在那边有单元测试;这里是**接在管线上的行为**:
//! 真打 zip、真导入、真读回磁盘内容与报告字段。
use std::sync::{Arc, Mutex};
use crate::addon::import::{
import_pack, keys, ConflictReason, FileAction, ImportProgress, ImportRequest, ImportStage,
ImportStatus,
};
use crate::download::blocking::block_on;
use super::support::sha1;
use super::{read_text, temp_root, write_file, zip_dir};
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
fn index_json(name: &str) -> String {
format!(
r#"{{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "{name}", "files": [], "dependencies": {{"minecraft": "1.20.1"}}}}"#
)
}
/// 报告里所有 `NOTE_OVERRIDE_SHADOWED` 注记的消息。
fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.filter(|note| note.message_key == keys::NOTE_OVERRIDE_SHADOWED)
.map(|note| note.message.as_str())
.collect()
}
#[test]
fn same_path_with_different_bytes_lands_the_later_side_only() {
let root = temp_root("layer-same-path");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("dup conflict").as_bytes(),
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
// Extract 阶段的总条数就是"要落地的条目数":两点声明 ⇒ 只算 1 条(被遮蔽的不读字节)。
let totals: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let sink = {
let totals = Arc::clone(&totals);
Arc::new(move |progress: &ImportProgress| {
if progress.stage == ImportStage::Extract {
totals.lock().expect("锁").push(progress.total);
}
})
};
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_progress(sink),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
// Modrinth 官方语义:client-overrides/ 后写 = 胜。
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
// 一路径一行,且那一行的哈希必须是**真落地**的那份内容。
let rows: Vec<_> = report
.files
.iter()
.filter(|file| file.path == "config/dup.toml")
.collect();
assert_eq!(rows.len(), 1, "同一路径只许一行:{:?}", report.files);
assert_eq!(
rows[0].sha1.as_deref(),
Some(sha1(b"from-client\n").as_str())
);
assert_eq!(rows[0].action, FileAction::Installed);
// 遮蔽要留下具名注记:谁赢了、两份内容不同。
let notes = shadow_notes(&report);
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
assert!(notes[0].contains("内容不同"), "{}", notes[0]);
assert!(notes[0].contains("client-overrides"), "{}", notes[0]);
assert!(
report.conflicts.is_empty(),
"包内重复声明不是用户冲突:{:?}",
report.conflicts
);
// 被遮蔽的条目不再进 Extract 的总数(1 条真落地)。
assert_eq!(totals.lock().expect("锁").last().copied(), Some(1));
}
#[test]
fn the_same_bytes_at_two_paths_are_two_files() {
let root = temp_root("layer-same-bytes");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("same bytes").as_bytes(),
);
let content = b"identical payload\n";
write_file(&dir.join("overrides/mods/a.jar"), content);
write_file(&dir.join("overrides/mods/b.jar"), content);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
for path in ["mods/a.jar", "mods/b.jar"] {
assert_eq!(
read_text(&target.join(path)),
"identical payload\n",
"{path}"
);
}
let rows: Vec<_> = report
.files
.iter()
.filter(|file| file.path.ends_with(".jar"))
.collect();
assert_eq!(rows.len(), 2, "同哈希不同路径不许折叠:{:?}", report.files);
assert!(rows
.iter()
.all(|row| row.sha1.as_deref() == Some(sha1(content).as_str())));
assert!(
shadow_notes(&report).is_empty(),
"不同路径不是重复声明:{:?}",
report.warnings
);
}
#[test]
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
let root = temp_root("layer-same-content");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("dup identical").as_bytes(),
);
let content = b"shared = true\n";
write_file(&dir.join("overrides/config/shared.toml"), content);
write_file(&dir.join("client-overrides/config/shared.toml"), content);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(
read_text(&target.join("config/shared.toml")),
"shared = true\n"
);
let notes = shadow_notes(&report);
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
assert!(
notes[0].contains("逐字节相同"),
"内容一致必须说清楚,别让人以为丢了东西:{}",
notes[0]
);
}
#[test]
fn the_baseline_remembers_the_winning_bytes() {
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
// "用户改过"(或反过来静默覆盖用户改动)。
let root = temp_root("layer-baseline");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("baseline dup").as_bytes(),
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let first = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 同一份包再导一次:磁盘内容 == 胜者内容 == 基线 ⇒ 既不该报冲突,也不该重写盘。
let second = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(second.status, ImportStatus::Success, "{:?}", second.error);
assert!(
second.conflicts.is_empty(),
"基线记的必须是胜者那份,否则这里会冒出一条假的 user_modified:{:?}",
second.conflicts
);
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
// 反向确认:冲突清单里不该出现这条路径的**任何**成因。
assert!(!second
.conflicts
.iter()
.any(|conflict| conflict.path == "config/dup.toml"
&& conflict.reason != ConflictReason::PreExisting));
}
+1
View File
@@ -22,6 +22,7 @@
mod fixtures; mod fixtures;
mod import_curseforge; mod import_curseforge;
mod import_failures; mod import_failures;
mod import_layering;
mod import_merge; mod import_merge;
mod import_modrinth; mod import_modrinth;
mod import_multimc; mod import_multimc;
+10 -3
View File
@@ -2,8 +2,9 @@
//! //!
//! ## 为什么单独一块(M117) //! ## 为什么单独一块(M117)
//! //!
//! 这个 id 不进仓库(用户定案),所以它可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`], //! 这是**公共客户端 id**(非机密,随二进制一起分发),可以来自两处:运行期环境变量 [`CLIENT_ID_ENV`],
//! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])。**两处都没有**时登录是「没配置」, //! 或构建期 `option_env!` 注入([`CLIENT_ID_BUILD`])——官方发布构建在构建时烘入它(M149),
//! 所以官方产物开箱即用;环境变量仍优先,可覆盖。**两处都没有**时登录是「没配置」,
//! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清: //! 不是「没实现」——这两件事在用户眼里完全不同,文案与日志都必须分清:
//! //!
//! | 现象 | 该说什么 | //! | 现象 | 该说什么 |
@@ -20,6 +21,9 @@
pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID"; pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID";
/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=<id> cargo build`)。 /// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=<id> cargo build`)。
///
/// 官方发布构建就是这么烘的(`.github/workflows/release-build.yml` 给构建步设了同名环境变量,M149;
/// 值来自仓库 Secret,不进 git 文件),所以官方产物走到这里恒为 `Some`;从源码自建时才可能为空。
pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID"); pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID");
/// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。 /// 两处都没配时给用户看的那句话(CLI / GUI / 启动日志共用,**不含任何真实 id**)。
@@ -180,7 +184,10 @@ mod tests {
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE); assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
assert!(!message.contains("未支持")); assert!(!message.contains("未支持"));
} }
// 构建期注入过 id 时唯一合法(本仓库 CI/本地都不注入,走不到这里)。 // 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
Ok(_) => assert!( Ok(_) => assert!(
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
"没配 id 却构造成功" "没配 id 却构造成功"
+1
View File
@@ -22,3 +22,4 @@ pub mod game;
pub mod launch; pub mod launch;
pub mod platform; pub mod platform;
pub mod setting; pub mod setting;
pub mod update;
@@ -203,7 +203,11 @@ mod tests {
use crate::platform::paths::{MapEnv, Platform}; use crate::platform::paths::{MapEnv, Platform};
const APP_DATA: &str = "C:/Users/u/AppData/Roaming"; const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
const LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local"; // 反斜杠原样:本常量经 `%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 DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper"; const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
const THEMES_QUERY: &str = const THEMES_QUERY: &str =
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme"; "reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
+113
View File
@@ -0,0 +1,113 @@
//! 更新渠道(通道)与准入规则。
//!
//! 四个渠道是一条**稳定度阶梯**,`Ord` 的派生顺序就是阶梯顺序
//! (`Stable < Beta < Alpha < Nightly`),所以「本渠道收不收那份发布」只要一次比较:
//! `manifest_channel <= requested_channel`。nightly 收全部,stable 只收 stable。
//!
//! 除了清单自报的渠道,还要看版本号里的预发布阶段([`Stage`]):清单可以撒谎
//! (`channel: "stable"` 但版本是 `1.0.0-alpha.2`),阶段这条判据不信清单只信版本号本身。
//! 两道判据**同时**成立才准入,见 [`Channel::admits`] 与 [`Channel::admits_stage`]。
//!
//! 准入阶梯(与渠道顺序同一套口径,别在别处另立一张表):
//!
//! | 渠道 | 收正式版 | 收 rc | 收 beta | 收 alpha | 收认不出的预发布 |
//! | ---- | -------- | ----- | ------- | -------- | ---------------- |
//! | stable | ✓ | | | | |
//! | beta | ✓ | ✓ | | | |
//! | alpha | ✓ | ✓ | ✓ | | |
//! | nightly | ✓ | ✓ | ✓ | ✓ | ✓ |
use std::fmt;
use std::str::FromStr;
use super::error::{UpdateError, UpdateErrorKind};
use super::version::Stage;
/// 更新渠道。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Channel {
/// 稳定版(默认渠道)。
Stable,
/// 公测版。
Beta,
/// 内测版。
Alpha,
/// 每日构建(收所有预发布,包括认不出的标识)。
Nightly,
}
impl Channel {
/// 全部渠道,从最稳到最激进。
pub const ALL: [Self; 4] = [Self::Stable, Self::Beta, Self::Alpha, Self::Nightly];
/// 清单文件名与日志用的稳定字符串 id(**不要改名**:清单里的 `channel` 字段就是它)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
Self::Alpha => "alpha",
Self::Nightly => "nightly",
}
}
/// 解析渠道名(大小写与首尾空白不敏感)。
///
/// # Errors
///
/// 认不出的名字返回 [`UpdateErrorKind::InvalidManifest`](清单里的 `channel` 写错了)。
pub fn parse(text: &str) -> Result<Self, UpdateError> {
let name = text.trim().to_ascii_lowercase();
Self::ALL
.into_iter()
.find(|channel| channel.as_str() == name)
.ok_or_else(|| {
let allowed = Self::ALL
.iter()
.map(|channel| channel.as_str())
.collect::<Vec<_>>()
.join(" / ");
UpdateError::new(
UpdateErrorKind::InvalidManifest,
format!("未知更新渠道 `{text}`(只认 {allowed})"),
)
})
}
/// 本渠道是否接受「发布自报 `release` 渠道」的那份版本。
///
/// 判定就是阶梯比较(`Ord` 的派生顺序即阶梯),不在别处另立一张表。
#[must_use]
pub fn admits(self, release: Self) -> bool {
release <= self
}
/// 本渠道是否接受该预发布阶段(正式版对所有渠道都成立)。
#[must_use]
pub const fn admits_stage(self, stage: Stage) -> bool {
matches!(
(self, stage),
(Self::Stable, Stage::Stable)
| (Self::Beta, Stage::Stable | Stage::ReleaseCandidate)
| (
Self::Alpha,
Stage::Stable | Stage::ReleaseCandidate | Stage::Beta
)
| (Self::Nightly, _)
)
}
}
impl fmt::Display for Channel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Channel {
type Err = UpdateError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
+204
View File
@@ -0,0 +1,204 @@
//! 检查更新:把「清单 + 当前版本 + 渠道 + 目标平台」收敛成一个结论。
//!
//! 判定顺序(每一步都是纯函数,可单测):
//!
//! 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
}
}
+137
View File
@@ -0,0 +1,137 @@
//! 自更新错误:一份分类 + 一句人话 + 出错路径。
//!
//! 分类只服务三件事:给日志/事件一个稳定 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,
/// 远端源取字节失败:连不上、超时、5xx、所有候选源(含镜像)全挂。
///
/// 与 [`Self::Io`] 分开是因为处置不同:网络失败值得隔一会儿重试/换镜像,
/// 本地 IO 失败(盘满、没权限)重试多少次都一样。
Network,
/// 本地 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::Network => "network",
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::Network | 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())
}
}
+342
View File
@@ -0,0 +1,342 @@
//! GitHub Releases 更新源(D50 定案:GitHub Releases 是唯一更新源)。
//!
//! # 地址形状(固定,不可配置)
//!
//! ```text
//! 清单 <base>/<owner>/<repo>/releases/latest/download/manifest.json
//! 产物 <base>/<owner>/<repo>/releases/latest/download/<清单里的产物文件名>
//! ```
//!
//! `releases/latest/download/<asset>` 是 GitHub 原生的「最新发布」地址:不用知道 tag、
//! 不用调 API(API 有速率限制、也要 token),发布侧只要把 `manifest.json` 和各平台产物
//! 挂在最新 release 上,客户端就能取到。清单里的 `url` 因此照 D49 的产物命名规则写成
//! **文件名**(如 `unlucky-launcher_0.6.0_windows_x64.exe`),由本源拼成上面的地址;
//! 清单若已经写了完整 `http(s)` 地址(例如临时挂到别处复测),原样使用。
//!
//! # 可配置面只有三个
//!
//! * `owner` / `repo`(默认 [`DEFAULT_OWNER`] / [`DEFAULT_REPO`]);
//! * `base`(默认 [`DEFAULT_BASE`];**只为测试存在**——把它指向本地假 HTTP 服务,
//! 单测就不必联网)。
//!
//! # 网络全走 `core::download`
//!
//! * **产物**(几十 MB 的包体):整段交给 [`DownloadEngine::download`]——镜像展开
//! (国内 gh-proxy 前缀)、地区判定、健康度排序、失败退避、断点续传、原子落盘全部复用,
//! 本源不再造一套网络栈,也不自己拼镜像地址;
//! * **清单**(几 KB 的 JSON):用引擎的候选规划 [`GitHubReleaseSource::candidates`] +
//! 共享客户端 [`DownloadEngine::client`] 逐候选取文本,与 `core::game::java` 的
//! 「一锤子 GET」同一手法。包体才需要退避/续传/分块,为几 KB 建一条下载流水线不划算。
//!
//! 两种路径都吃到同一条镜像策略:`mirrors.json` 的前缀规则 + 地区档案里生效的
//! `gh_proxy` 前缀([`crate::download::mirrors::MirrorTable::gh_proxy_candidates`]),
//! 国内环境下 `https://github.com/...` 会被改写成 `https://gh-proxy.com/https://github.com/...`。
//!
//! # 渠道
//!
//! `releases/latest` 只有一个,所以本源**只服务 stable**。beta/alpha/nightly 的清单枚举
//! 保留但不发布(D50「先只发 stable」),因此非 stable 渠道一律 `Ok(None)` = 没有更新——
//! 这不是错误,别让「还没开通道」变成用户机器上的红字。
//!
//! # 校验
//!
//! 本源只管「把字节放到 dest」:长度与摘要由 [`super::verify`] 统一双检(与 [`super::DirSource`]
//! 一致)。所以包体请求只带 `expected_size`(给进度预算与门户页假阳性兜底),不带摘要——
//! 摘要判据只有一处,别在这里再写一套。
//!
//! # 运行时
//!
//! [`UpdateSource`] 是同步签名,本源在方法内部用 [`crate::download::block_on`] 桥接异步引擎
//! (CLI 与 presenter 都是这么用的)。因此**不要在已有的 tokio 运行时里直接调本源的
//! `fetch_*`**:tokio 不允许嵌套 `block_on`,会 panic。更新流程请放在独立线程上跑。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::download::cache::now_ms;
use crate::download::{
Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
};
use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest};
use super::source::UpdateSource;
/// 默认仓库 owner。
pub const DEFAULT_OWNER: &str = "AskaEth";
/// 默认仓库名。
pub const DEFAULT_REPO: &str = "Unlucky-Launcher";
/// GitHub 站点根地址(`base` 的默认值)。
pub const DEFAULT_BASE: &str = "https://github.com";
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
pub const MANIFEST_ASSET: &str = "manifest.json";
/// GitHub Releases 更新源。
#[derive(Debug, Clone)]
pub struct GitHubReleaseSource {
engine: Arc<DownloadEngine>,
owner: String,
repo: String,
base: String,
id: String,
}
impl GitHubReleaseSource {
/// 用默认仓库([`DEFAULT_OWNER`] / [`DEFAULT_REPO`])与 GitHub 站点根地址构造。
#[must_use]
pub fn new(engine: Arc<DownloadEngine>) -> Self {
Self::with_repo(engine, DEFAULT_OWNER, DEFAULT_REPO)
}
/// 指定仓库构造(fork / 自建镜像仓、测试用)。
#[must_use]
pub fn with_repo(
engine: Arc<DownloadEngine>,
owner: impl Into<String>,
repo: impl Into<String>,
) -> Self {
let owner = owner.into();
let repo = repo.into();
let id = format!("github:{owner}/{repo}");
Self {
engine,
owner,
repo,
base: DEFAULT_BASE.to_string(),
id,
}
}
/// 换 `base`(站点根地址,末尾斜杠会去掉)。
///
/// 生产不用这个口子;单测把它指向本地假服务(`http://127.0.0.1:<port>`)以保持离线。
#[must_use]
pub fn with_base(mut self, base: impl Into<String>) -> Self {
self.base = base.into().trim_end_matches('/').to_string();
self
}
/// 仓库 owner。
#[must_use]
pub fn owner(&self) -> &str {
&self.owner
}
/// 仓库名。
#[must_use]
pub fn repo(&self) -> &str {
&self.repo
}
/// 站点根地址。
#[must_use]
pub fn base(&self) -> &str {
&self.base
}
/// 引擎(调用方要挂进度事件时用)。
#[must_use]
pub fn engine(&self) -> &Arc<DownloadEngine> {
&self.engine
}
/// 某渠道的清单地址;非 stable 渠道没有清单(返回 `None`)。
#[must_use]
pub fn manifest_url(&self, channel: Channel) -> Option<String> {
(channel == Channel::Stable).then(|| format!("{}/{MANIFEST_ASSET}", self.download_base()))
}
/// 把清单里的 `url` 解析成真实下载地址。
///
/// 已经是 `http(s)://` 的地址原样使用(清单可以临时指向别处);其余一律当成
/// 「最新 release 里的产物文件名」,拼到本源的下载根路径下。
///
/// # Errors
///
/// `url` 为空是 [`UpdateErrorKind::InvalidManifest`](清单坏,不是源的问题);
/// 非 http(s) 的其它协议是 [`UpdateErrorKind::UnsupportedSource`]。
pub fn artifact_url(&self, artifact: &Artifact) -> Result<String, UpdateError> {
let raw = artifact.url.trim();
if raw.is_empty() {
return Err(UpdateError::invalid_manifest("产物的 url 为空"));
}
if raw.starts_with("http://") || raw.starts_with("https://") {
return Ok(raw.to_string());
}
if raw.contains("://") {
return Err(UpdateError::new(
UpdateErrorKind::UnsupportedSource,
format!("GitHub 源只认 http(s) 地址或 release 内的产物文件名:`{raw}`"),
));
}
// `./name` 与 `/name` 都当成「release 里的文件名」,别把整串拼出个双斜杠路径。
let name = raw.trim_start_matches(['.', '/']);
Ok(format!("{}/{name}", self.download_base()))
}
/// 某地址在引擎眼里的候选源序列(镜像展开 + 健康度排序,**不发请求**)。
///
/// 生产调用走引擎自己的规划;这里是给「镜像策略是否生效」这类断言用的观察窗。
#[must_use]
pub fn candidates(&self, url: &str) -> Vec<Candidate> {
// `dest` 只是 `DownloadRequest` 的必填字段,`plan` 不看它。
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url, PathBuf::new());
self.engine.plan(&request, now_ms())
}
/// 最新的 release 产物根路径(`<base>/<owner>/<repo>/releases/latest/download`)。
fn download_base(&self) -> String {
format!(
"{}/{}/{}/releases/latest/download",
self.base, self.owner, self.repo
)
}
/// 逐候选取一段文本:`Ok(None)` = 所有候选都说「这份文件不存在」。
fn fetch_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
let candidates = self.candidates(url);
if candidates.is_empty() {
return Err(UpdateError::new(
UpdateErrorKind::Network,
format!("没有可用候选源:{url}"),
));
}
let mut failure: Option<UpdateError> = None;
let mut all_missing = true;
for candidate in candidates {
match self.get_candidate_text(&candidate.url) {
Ok(Some(text)) => return Ok(Some(text)),
// 某个候选 404 不代表没有:镜像可能比官方慢半拍,继续问下一个。
Ok(None) => {}
Err(error) => {
all_missing = false;
failure = Some(error);
}
}
}
if all_missing {
Ok(None)
} else {
Err(failure.unwrap_or_else(|| {
UpdateError::new(UpdateErrorKind::Network, format!("取 {url} 失败"))
}))
}
}
/// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。
fn get_candidate_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
let client = self.engine.client();
crate::download::block_on(async {
let response = client.get(url).send().await.map_err(|error| {
UpdateError::new(
UpdateErrorKind::Network,
format!("取更新清单失败:{error}({url})"),
)
})?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
return Ok(None);
}
if !status.is_success() {
return Err(UpdateError::new(
UpdateErrorKind::Network,
format!("更新清单返回 HTTP {status}({url})"),
));
}
let text = response.text().await.map_err(|error| {
UpdateError::new(
UpdateErrorKind::Network,
format!("读更新清单响应失败:{error}({url})"),
)
})?;
if text.trim().is_empty() {
return Err(UpdateError::invalid_manifest(format!(
"清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}"
)));
}
Ok(Some(text))
})
}
}
impl UpdateSource for GitHubReleaseSource {
fn source_id(&self) -> &str {
&self.id
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
let Some(url) = self.manifest_url(channel) else {
return Ok(None);
};
let Some(text) = self.fetch_text(&url)? else {
return Ok(None);
};
let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| {
error.message = format!("{}(来源 {url})", error.message);
error
})?;
if manifest.channel != channel {
return Err(UpdateError::new(
UpdateErrorKind::ChannelMismatch,
format!(
"请求的是 {channel} 渠道,清单 {url} 自报 {}",
manifest.channel
),
));
}
Ok(Some(manifest))
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
let url = self.artifact_url(artifact)?;
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|error| UpdateError::io_at(parent, &error))?;
}
}
// 摘要不在这里传:`update::verify` 统一做长度/摘要双检(trait 契约,别各写一套)。
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url.clone(), dest)
.with_size(artifact.size);
let outcome = crate::download::block_on(self.engine.download(request))
.map_err(|error| download_error(&error, &url))?;
Ok(outcome.bytes)
}
}
/// 把下载引擎的错误翻译成自更新的错误分类(两套分类各有各的职责,别混用)。
fn download_error(error: &DownloadError, url: &str) -> UpdateError {
let kind = match error.kind {
DownloadErrorKind::NotFound => UpdateErrorKind::SourceMissing,
DownloadErrorKind::Integrity => UpdateErrorKind::DigestMismatch,
DownloadErrorKind::Io => UpdateErrorKind::Io,
DownloadErrorKind::InvalidRequest => UpdateErrorKind::UnsupportedSource,
DownloadErrorKind::AllSourcesFailed if all_candidates_missing(error) => {
UpdateErrorKind::SourceMissing
}
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => UpdateErrorKind::Network,
};
let mut message = format!("{}({url})", error.message);
if !error.suppressed.is_empty() {
message.push_str(&format!(";各源:{}", error.suppressed.join(";")));
}
UpdateError::new(kind, message)
}
/// 所有候选是不是都回「这份文件不存在」。
///
/// 引擎把逐源失败压成一条 [`DownloadErrorKind::AllSourcesFailed`],原始的 404/410 分类只剩在
/// 失败摘要文本里(`download::http::classify_status` 的文案:`HTTP 404`)。不还原这一层,
/// 「发布清单里挂了个没上传的产物」会被报成「网络失败 + 可重试」,重试多少次都一样。
fn all_candidates_missing(error: &DownloadError) -> bool {
!error.suppressed.is_empty()
&& error
.suppressed
.iter()
.all(|line| line.contains("HTTP 404") || line.contains("HTTP 410"))
}
+268
View File
@@ -0,0 +1,268 @@
//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。
//!
//! # 为什么不用「直接覆盖」
//!
//! Windows 上**正在运行的 exe** 不能被覆盖、也不能被删除(映像被占用,改字节这条路根本
//! 不存在);`std::fs::rename` 走 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`,目标只要被任何
//! 「不共享删除」的句柄占着就报共享冲突(`os error 32`)。剩下唯一可用的动作是**改名**:
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
//!
//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,
//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了):
//!
//! ```text
//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地
//! 2. rename(暂存包 → dest) 新版本就位
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
//! ```
//!
//! # 本模块**不**负责的事
//!
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
//! [`InstallReceipt::remove_backup`])。
//!
//! # 单测覆盖到哪
//!
//! 占用场景是**真的造出来**的:测试里用 `share_mode(FILE_SHARE_READ)` 打开目标文件
//! (**不带** `FILE_SHARE_DELETE`,与 Windows 加载器打开正在运行的映像时一致),
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
//!
//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
//! 写这类用例时必须显式指定 `share_mode`。
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::error::UpdateError;
use super::verify::StagedPackage;
/// 同进程内备份文件名去重序号。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 替换策略:改名失败时重试几次、每次间隔多久。
///
/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上
/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplacePolicy {
/// 总尝试次数(含首次),`0` 按 `1` 处理。
pub attempts: u32,
/// 两次尝试之间的等待。
pub delay: Duration,
}
impl Default for ReplacePolicy {
fn default() -> Self {
Self {
attempts: 5,
delay: Duration::from_millis(80),
}
}
}
impl ReplacePolicy {
/// 构造。
#[must_use]
pub const fn new(attempts: u32, delay: Duration) -> Self {
Self { attempts, delay }
}
}
/// 替换成功后的收据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallReceipt {
/// 新版本所在的路径。
pub dest: PathBuf,
/// 旧版本被挪到哪(原本没有文件时为 `None`)。
pub backup: Option<PathBuf>,
/// 新版本字节数。
pub bytes: u64,
}
impl InstallReceipt {
/// 删掉旧版本备份(新版启动成功之后调用)。
///
/// # Errors
///
/// 删不掉(还占着、没权限)返回带路径的 [`UpdateError`]——这时**别**当成更新失败,
/// 只是留了个垃圾文件,下次启动还能再删。
pub fn remove_backup(&self) -> Result<(), UpdateError> {
let Some(backup) = &self.backup else {
return Ok(());
};
fs::remove_file(backup).map_err(|error| UpdateError::io_at(backup, &error))
}
/// 手动回滚:把备份搬回 `dest`(新版起不来时用)。成功后 `backup` 字段清空。
///
/// # Errors
///
/// 没有备份时是空操作 `Ok(())`;搬不动返回 [`UpdateError`](此时 `backup` 保持不变,
/// 旧版本还在原位置,可以再试)。
pub fn rollback(&mut self, policy: ReplacePolicy) -> Result<(), UpdateError> {
let Some(backup) = self.backup.as_ref() else {
return Ok(());
};
rename_with_retry(backup, &self.dest, policy)
.map_err(|error| UpdateError::io_at(&self.dest, &error))?;
self.backup = None;
Ok(())
}
}
/// 替换失败:原始错误 + 回滚结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallFailure {
/// 原始失败。
pub error: UpdateError,
/// 旧版本是否已回到原路径(`true` = 目标没被破坏;本来没有旧文件时也算 `true`)。
pub rolled_back: bool,
/// 回滚失败时旧版本留在哪(`None` = 已回到原位或压根没有旧文件)。
pub backup_left: Option<PathBuf>,
}
impl InstallFailure {
/// 目标是否可能处于「既不是旧版也不是新版」的坏状态(需要人工介入)。
#[must_use]
pub const fn needs_manual_repair(&self) -> bool {
!self.rolled_back
}
}
impl fmt::Display for InstallFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error)?;
if self.rolled_back {
f.write_str("(已回滚,旧版本仍在原位)")
} else {
let where_ = self.backup_left.as_ref().map_or_else(String::new, |path| {
format!(",旧文件留在 {}", path.display())
});
write!(f, "(回滚失败,需要人工处理{where_})")
}
}
}
impl std::error::Error for InstallFailure {}
/// 把校验过的暂存包装到 `dest`;失败则回滚,旧版本保持可用。
///
/// # Errors
///
/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`],
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
pub fn install_staged(
staged: &StagedPackage,
dest: &Path,
policy: ReplacePolicy,
) -> Result<InstallReceipt, InstallFailure> {
if let Some(parent) = dest
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?;
}
let mut backup = None;
if fs::symlink_metadata(dest).is_ok() {
let path = backup_path(dest);
rename_with_retry(dest, &path, policy)
.map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?;
backup = Some(path);
}
match rename_with_retry(staged.path(), dest, policy) {
Ok(()) => Ok(InstallReceipt {
dest: dest.to_path_buf(),
backup,
bytes: staged.size(),
}),
Err(error) => {
let error = UpdateError::io_at(dest, &error);
let Some(backup_path) = backup else {
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
return Err(failed(error, true, None));
};
match rename_with_retry(&backup_path, dest, policy) {
Ok(()) => Err(failed(error, true, None)),
Err(_) => Err(failed(error, false, Some(backup_path))),
}
}
}
}
fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -> InstallFailure {
InstallFailure {
error,
rolled_back,
backup_left,
}
}
/// 旧版本的备份路径:`<dest 同目录>/.<文件名>.ullold-<pid>-<seq>`。
///
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
/// 必须同目录,因为 `rename` **不能跨卷**(Unix 返回 `EXDEV`,Windows 返回
/// `ERROR_NOT_SAME_DEVICE`——`std` 的 `fs::rename` 不带 `MOVEFILE_COPY_ALLOWED`,
/// 不会偷偷降级成复制)。
#[must_use]
pub fn backup_path(dest: &Path) -> PathBuf {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let name = dest.file_name().map_or_else(
|| String::from("ull"),
|name| name.to_string_lossy().into_owned(),
);
let backup = format!(".{name}.ullold-{}-{seq}", std::process::id());
match dest.parent() {
Some(parent) => parent.join(backup),
None => PathBuf::from(backup),
}
}
/// 改名,失败时按策略重试(占用类错误才值得重试)。
fn rename_with_retry(from: &Path, to: &Path, policy: ReplacePolicy) -> io::Result<()> {
let attempts = policy.attempts.max(1);
let mut last = None;
for attempt in 0..attempts {
match fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(error) => {
let retryable = is_transient(&error) && attempt + 1 < attempts;
if !retryable {
return Err(error);
}
std::thread::sleep(policy.delay);
last = Some(error);
}
}
}
Err(last.unwrap_or_else(|| io::Error::other("rename 未执行")))
}
/// 是否属于「等一会儿可能就好了」的失败。
///
/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind),
/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。
fn is_transient(error: &io::Error) -> bool {
matches!(
error.kind(),
io::ErrorKind::PermissionDenied
| io::ErrorKind::Other
| io::ErrorKind::Interrupted
| io::ErrorKind::WouldBlock
)
}
+263
View File
@@ -0,0 +1,263 @@
//! 发布清单模型:一个渠道一份 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>,
}
+69
View File
@@ -0,0 +1,69 @@
//! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。
//!
//! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在
//! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。
//! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入
//! (本地目录源 [`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 + 本地目录源 github GitHub Releases 源(D50)
//! 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 github;
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::github::GitHubReleaseSource;
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;
+173
View File
@@ -0,0 +1,173 @@
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
//!
//! 这一层刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
//! 注入。真实源已定(D50):[`super::GitHubReleaseSource`],它在 `github.rs`,网络与镜像策略
//! 全交给 `crate::download`。
//!
//! 内置的另一个实现是 [`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发,
//! 以及给远端实现当对拍基准。
//!
//! # 本地源的目录约定
//!
//! ```text
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
//! <root>/beta.json
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
//! ```
//!
//! 清单里的 `url` 支持两种写法:`file://` 绝对地址,或相对 `<root>` 的路径。
//! 出现 `http://` / `https://` 一律 [`UpdateErrorKind::UnsupportedSource`]——那是别的
//! 实现的活,本地源不该假装能取远端字节。
use std::fs;
use std::path::{Path, PathBuf};
use super::channel::Channel;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::{Artifact, ReleaseManifest};
/// 更新源:清单与包体的来源。
///
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
/// 同步签名是为了让 CLI 与 GUI 都能直接调用,异步实现自己在方法里桥接。
pub trait UpdateSource: Send + Sync {
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
fn source_id(&self) -> &str;
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。
///
/// # Errors
///
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>;
/// 把产物取到 `dest`,返回写入的字节数。
///
/// 实现只管「把字节放到 dest」;长度与摘要校验由 [`crate::update::verify`] 统一做,
/// 别在实现里各写一套。
///
/// # Errors
///
/// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
}
/// 本地目录源(也接受 `file://` 根地址)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirSource {
root: PathBuf,
id: String,
}
impl DirSource {
/// 用本地目录构造。
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let id = format!("file:{}", root.display());
Self { root, id }
}
/// 用 `file://` 地址构造(`file:///D:/dist`、`file:///srv/ull`)。
///
/// # Errors
///
/// 不是 `file://` 开头的地址返回 [`UpdateErrorKind::UnsupportedSource`]。
pub fn from_url(url: &str) -> Result<Self, UpdateError> {
let rest = url
.strip_prefix("file://")
.ok_or_else(|| unsupported_source(url))?;
Ok(Self::new(file_url_to_path(rest)))
}
/// 根目录。
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
/// 某渠道的清单文件路径。
#[must_use]
pub fn manifest_path(&self, channel: Channel) -> PathBuf {
self.root.join(format!("{}.json", channel.as_str()))
}
/// 把清单里的 `url` 解析成源侧本地路径。
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
if let Some(rest) = url.strip_prefix("file://") {
return Ok(file_url_to_path(rest));
}
if url.contains("://") {
return Err(unsupported_source(url));
}
Ok(self.root.join(url))
}
}
fn unsupported_source(url: &str) -> UpdateError {
let hint = if url.starts_with("http://") || url.starts_with("https://") {
"本地目录源不取远端字节,请换用实现远端源的 UpdateSource"
} else {
"只支持本地相对路径与 file:// 地址"
};
UpdateError::new(
UpdateErrorKind::UnsupportedSource,
format!("更新源不支持地址 `{url}`:{hint}"),
)
}
fn file_url_to_path(rest: &str) -> PathBuf {
if cfg!(windows) {
// file:///D:/dist → 去掉盘符前的斜杠;Windows 上没有「以 / 开头的当前盘路径」这种写法。
PathBuf::from(rest.trim_start_matches('/'))
} else {
PathBuf::from(rest)
}
}
impl UpdateSource for DirSource {
fn source_id(&self) -> &str {
&self.id
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
let path = self.manifest_path(channel);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(UpdateError::io_at(&path, &source)),
};
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?;
if manifest.channel != channel {
return Err(UpdateError::new(
UpdateErrorKind::ChannelMismatch,
format!(
"请求的是 {} 渠道,清单 {} 自报 {}",
channel,
path.display(),
manifest.channel
),
)
.with_path(&path));
}
Ok(Some(manifest))
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
let from = self.resolve(&artifact.url)?;
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|error| UpdateError::io_at(parent, &error))?;
}
}
match fs::copy(&from, dest) {
Ok(bytes) => Ok(bytes),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Err(UpdateError::new(
UpdateErrorKind::SourceMissing,
format!("源里没有产物 `{}`", artifact.url),
)
.with_path(&from)),
Err(source) => Err(UpdateError::io_at(&from, &source)),
}
}
}
+196
View File
@@ -0,0 +1,196 @@
//! 产物目标平台与包格式:清单里 `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())
}
}
@@ -0,0 +1,57 @@
//! 渠道阶梯与准入:清单渠道比大小,预发布阶段按表收。
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());
}
}
@@ -0,0 +1,245 @@
//! 检查更新:渠道准入、版本高低、目标平台漏发、强制更新。
use crate::platform::os_info::OsFamily;
use crate::update::{
Channel, CheckOutcome, DirSource, Target, UpdateChecker, UpdateErrorKind, UpdateSource,
Urgency, Version,
};
use super::support::{artifact, manifest, release, TempDir};
const PAYLOAD: &[u8] = b"checker payload";
fn target() -> Target {
Target::new(OsFamily::Windows, crate::update::Arch::X64)
}
/// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。
fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) {
let dir = TempDir::new("checker");
dir.write(&format!("{}.json", channel.as_str()), json.as_bytes());
let source = DirSource::new(dir.path());
(dir, source)
}
fn check_outcome(json: &str, current: &str, channel: Channel) -> CheckOutcome {
let (_dir, source) = source_with(json, channel);
let version = Version::parse(current).expect("当前版本应可解析");
UpdateChecker::new(source, target())
.check(&version, channel)
.expect("检查不该失败")
}
fn stable_release(version: &str) -> String {
manifest(
"stable",
&[release(
version,
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
)
}
#[test]
fn offers_the_numeric_newer_version() {
// 0.9 → 0.10 的字典序陷阱在 checker 这一层也要挡住
let outcome = check_outcome(&stable_release("0.10.0"), "0.9.0", Channel::Stable);
let candidate = outcome.candidate().expect("应有更新");
assert_eq!(candidate.release.version.to_string(), "0.10.0");
assert_eq!(
candidate.artifact.format,
crate::update::PackageFormat::PortableZip
);
assert_eq!(candidate.urgency, Urgency::Optional);
}
#[test]
fn same_or_older_release_is_up_to_date() {
let outcome = check_outcome(&stable_release("0.2.1"), "0.2.1", Channel::Stable);
match outcome {
CheckOutcome::UpToDate { latest, .. } => {
assert_eq!(latest.map(|v| v.to_string()).as_deref(), Some("0.2.1"));
}
other => panic!("同等版本应已是最新,实际 {other:?}"),
}
let outcome = check_outcome(&stable_release("0.2.0"), "0.3.0", Channel::Stable);
assert!(!outcome.is_available(), "不该提示降级");
}
#[test]
fn missing_manifest_means_up_to_date() {
let tmp = TempDir::new("checker-empty");
let source = DirSource::new(tmp.path());
let current = Version::parse("0.2.1").expect("版本");
let outcome = UpdateChecker::new(source, target())
.check(&current, Channel::Stable)
.expect("没清单不是错误");
match outcome {
CheckOutcome::UpToDate { latest, .. } => assert_eq!(latest, None),
other => panic!("应为已是最新,实际 {other:?}"),
}
}
#[test]
fn prerelease_is_gated_by_channel_stage() {
let json = manifest(
"beta",
&[release(
"1.0.0-rc.1",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
// beta 渠道收 rc
let outcome = check_outcome(&json, "0.9.0", Channel::Beta);
assert_eq!(
outcome.candidate().map(|c| c.release.version.to_string()),
Some("1.0.0-rc.1".to_string())
);
// alpha 发布在 beta 渠道里不收(阶段阶梯)
let alpha_json = manifest(
"beta",
&[release(
"1.1.0-alpha.1",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
let outcome = check_outcome(&alpha_json, "0.9.0", Channel::Beta);
assert!(!outcome.is_available(), "beta 渠道不该收 alpha 预发布");
}
#[test]
fn stable_channel_never_takes_a_prerelease_even_if_the_manifest_claims_stable() {
let json = manifest(
"stable",
&[release(
"1.0.0-beta.2",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
let outcome = check_outcome(&json, "0.9.0", Channel::Stable);
assert!(
!outcome.is_available(),
"清单可以写错,版本号不会:正式版渠道不许收预发布"
);
}
#[test]
fn forced_update_comes_from_force_or_min_supported() {
let json = manifest(
"stable",
&[release(
"0.3.0",
r#","force":true"#,
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
assert_eq!(
check_outcome(&json, "0.2.0", Channel::Stable)
.candidate()
.map(|candidate| candidate.urgency),
Some(Urgency::Forced)
);
let json = manifest(
"stable",
&[release(
"0.3.0",
r#","min_supported":"0.2.5""#,
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
)],
);
assert_eq!(
check_outcome(&json, "0.2.0", Channel::Stable)
.candidate()
.map(|candidate| candidate.urgency),
Some(Urgency::Forced),
"低于最低支持版本 = 强制"
);
assert_eq!(
check_outcome(&json, "0.2.5", Channel::Stable)
.candidate()
.map(|candidate| candidate.urgency),
Some(Urgency::Optional),
"刚好等于最低支持版本不算强制"
);
}
#[test]
fn missing_target_artifact_is_reported_not_hidden() {
let json = manifest(
"stable",
&[release(
"0.3.0",
"",
&[artifact("linux-x64", "appimage", "a.AppImage", PAYLOAD)],
)],
);
let outcome = check_outcome(&json, "0.2.0", Channel::Stable);
match outcome {
CheckOutcome::MissingArtifact { release } => {
assert_eq!(release.version.to_string(), "0.3.0");
}
other => panic!("应为「有新版但没本平台产物」,实际 {other:?}"),
}
}
#[test]
fn falls_back_to_the_newest_release_that_does_have_the_target() {
let json = manifest(
"stable",
&[
release(
"0.3.0",
"",
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
),
release(
"0.4.0",
"",
&[artifact("linux-x64", "appimage", "b.AppImage", PAYLOAD)],
),
],
);
let outcome = check_outcome(&json, "0.2.0", Channel::Stable);
assert_eq!(
outcome.candidate().map(|c| c.release.version.to_string()),
Some("0.3.0".to_string()),
"0.4.0 没 win-x64 产物,应退回到 0.3.0"
);
}
#[test]
fn channel_mismatch_in_the_source_is_an_error() {
let tmp = TempDir::new("checker-mismatch");
tmp.write("stable.json", manifest("beta", &[]).as_bytes());
let source = crate::update::DirSource::new(tmp.path());
let current = crate::update::Version::parse("0.2.1").expect("版本");
let error = UpdateChecker::new(source, target())
.check(&current, Channel::Stable)
.expect_err("清单渠道不符应报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
}
#[test]
fn prepare_stages_the_selected_artifact() {
let tmp = TempDir::new("checker-prepare");
tmp.write("a.zip", PAYLOAD);
tmp.write("stable.json", stable_release("0.3.0").as_bytes());
let source = crate::update::DirSource::new(tmp.path());
let checker = UpdateChecker::new(source, target());
let current = crate::update::Version::parse("0.2.0").expect("版本");
let outcome = checker
.check(&current, Channel::Stable)
.expect("检查不该失败");
let candidate = outcome.candidate().expect("应有更新");
let staged = checker
.prepare(candidate, &tmp.join("staging"))
.expect("取包应成功");
assert_eq!(staged.size(), PAYLOAD.len() as u64);
assert_eq!(checker.target(), target());
assert!(checker.source().source_id().starts_with("file:"));
}
@@ -0,0 +1,387 @@
//! GitHub Releases 源:固定清单地址、产物 URL 解析、渠道只有 stable、与 `DirSource` 对拍。
//!
//! 全程离线:假 HTTP 服务起在 `127.0.0.1` 上,`base` 指向它——没有一个用例访问 github.com
//! 或 gh-proxy(那部分只能真联网验收,见 mission 报告的「没验的部分」)。
use std::fs;
use std::sync::Arc;
use crate::download::mirrors::SOURCE_GH_PROXY;
use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL};
use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET};
use crate::update::{
Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError,
UpdateErrorKind, UpdateSource, Version,
};
use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir};
const PAYLOAD: &[u8] = b"github release payload";
/// 假服务上清单所在路径(与本源拼出来的地址逐字符对齐)。
fn manifest_path() -> String {
format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}")
}
/// 按 D49 命名规则的产物文件名。
fn asset_name() -> &'static str {
"unlucky-launcher_0.6.0_windows_x64.exe"
}
/// 假服务上产物所在路径。
fn asset_path() -> String {
format!(
"/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}",
asset_name()
)
}
/// 一份可用的 stable 清单(产物 url 就是 D49 文件名)。
fn stable_manifest_json() -> String {
manifest(
"stable",
&[release(
"0.6.0",
r#","notes":"修掉启动崩溃""#,
&[artifact("win-x64", "portable-exe", asset_name(), PAYLOAD)],
)],
)
}
/// 解析上面那份清单(多数字段级断言都用它)。
fn parsed_stable() -> ReleaseManifest {
ReleaseManifest::from_json(&stable_manifest_json()).expect("清单应可解析")
}
fn target() -> Target {
Target::parse("win-x64").expect("目标平台写法合法")
}
fn source_with(engine: Arc<DownloadEngine>, base: &str) -> GitHubReleaseSource {
GitHubReleaseSource::new(engine).with_base(base)
}
/// 本地假服务 + 指向它的源(直连模式:不套 gh-proxy,请求落在 127.0.0.1 上)。
fn local_source(tmp: &TempDir, server: &FakeHttp) -> GitHubReleaseSource {
source_with(engine_for(tmp, RegionProfile::direct(0)), &server.base())
}
#[test]
fn manifest_url_is_the_fixed_latest_download_path() {
let tmp = TempDir::new("gh-url");
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
assert_eq!(source.owner(), DEFAULT_OWNER);
assert_eq!(source.repo(), DEFAULT_REPO);
assert_eq!(source.base(), DEFAULT_BASE);
let expected = format!(
"{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}"
);
assert_eq!(
source.manifest_url(Channel::Stable).as_deref(),
Some(expected.as_str()),
"D50 定死的固定地址,一个字符都不许飘"
);
assert_eq!(
source.source_id(),
format!("github:{DEFAULT_OWNER}/{DEFAULT_REPO}")
);
// fork / 自建镜像仓:owner/repo 可换,路径形状不变。
let fork = GitHubReleaseSource::with_repo(
engine_for(&tmp, RegionProfile::direct(0)),
"someone",
"ull-fork",
)
.with_base("http://127.0.0.1:1/");
assert_eq!(
fork.manifest_url(Channel::Stable).as_deref(),
Some("http://127.0.0.1:1/someone/ull-fork/releases/latest/download/manifest.json"),
"base 末尾斜杠要去掉,别拼出双斜杠"
);
assert_eq!(fork.source_id(), "github:someone/ull-fork");
}
#[test]
fn channels_other_than_stable_publish_nothing() {
let tmp = TempDir::new("gh-channels");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json(&stable_manifest_json()),
)]);
let source = local_source(&tmp, &server);
for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] {
assert!(
source.manifest_url(channel).is_none(),
"{channel} 不该有清单地址"
);
let manifest = source.fetch_manifest(channel).expect("没开通道不是错误");
assert!(
manifest.is_none(),
"{channel} 应返回 Ok(None)(不发布即无更新)"
);
}
assert!(
server.hits().is_empty(),
"非 stable 渠道一次请求都不该发(实际:{:?})",
server.hits()
);
}
#[test]
fn artifact_url_appends_the_release_download_path() {
let tmp = TempDir::new("gh-artifact-url");
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
let artifact = &parsed_stable().releases[0].artifacts[0];
let url = source.artifact_url(artifact).expect("应能拼出地址");
assert_eq!(
url,
format!(
"{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}",
asset_name()
)
);
// `./name` / `/name` 与裸文件名等价(清单是人手写的)。
for written in [format!("./{}", asset_name()), format!("/{}", asset_name())] {
let mut tweaked = artifact.clone();
tweaked.url = written;
assert_eq!(source.artifact_url(&tweaked).expect("应可解析"), url);
}
// 已经写了完整地址 → 原样使用(清单可以临时指向别处)。
let mut absolute = artifact.clone();
absolute.url = "https://example.invalid/dist/ull.exe".to_string();
assert_eq!(
source.artifact_url(&absolute).expect("绝对地址原样"),
"https://example.invalid/dist/ull.exe"
);
// 其它协议不认(那是别的源的活)。
let mut unsupported = artifact.clone();
unsupported.url = "ftp://example.invalid/ull.exe".to_string();
assert_eq!(
source
.artifact_url(&unsupported)
.expect_err("ftp 不是本源能取的")
.kind,
UpdateErrorKind::UnsupportedSource
);
}
#[test]
fn fetches_and_parses_the_manifest_from_the_base() {
let tmp = TempDir::new("gh-manifest-ok");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json(&stable_manifest_json()),
)]);
let source = local_source(&tmp, &server);
let manifest = source
.fetch_manifest(Channel::Stable)
.expect("应取到清单")
.expect("清单存在");
assert_eq!(manifest.channel, Channel::Stable);
assert_eq!(manifest.releases.len(), 1);
assert_eq!(manifest.releases[0].version.to_string(), "0.6.0");
assert_eq!(server.hits(), vec![manifest_path()]);
}
#[test]
fn missing_manifest_in_the_release_is_not_an_error() {
// 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。
let tmp = TempDir::new("gh-manifest-404");
let server = FakeHttp::start(vec![]);
let source = local_source(&tmp, &server);
let outcome = source
.fetch_manifest(Channel::Stable)
.expect("404 不是错误");
assert!(outcome.is_none(), "取不到清单 = 还没发过版");
assert_eq!(
server.hits(),
vec![manifest_path()],
"确实问过一次,不是压根没发请求"
);
}
#[test]
fn broken_json_is_reported_as_invalid_manifest() {
let tmp = TempDir::new("gh-manifest-broken");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json("{ not json"),
)]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("坏 JSON 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
assert!(
error.message.contains(&server.base()),
"错误里要带出来源地址,日志才定位得到(实际:{})",
error.message
);
assert!(!error.is_retryable(), "清单坏重试多少次都一样");
}
#[test]
fn illegal_manifest_fields_are_reported() {
let tmp = TempDir::new("gh-manifest-illegal");
// size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。
let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#;
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("size 为 0 必须报错");
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
}
#[test]
fn manifest_self_reporting_another_channel_is_a_mismatch() {
let tmp = TempDir::new("gh-manifest-mismatch");
let server = FakeHttp::start(vec![(
manifest_path().as_str(),
FakeResponse::json(&manifest("beta", &[])),
)]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("渠道不符必须报错");
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
}
#[test]
fn server_errors_are_network_errors() {
let tmp = TempDir::new("gh-manifest-500");
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]);
let source = local_source(&tmp, &server);
let error = source
.fetch_manifest(Channel::Stable)
.expect_err("5xx 必须报错");
assert_eq!(error.kind, UpdateErrorKind::Network);
assert!(error.is_retryable(), "网络抖动值得隔一会儿再试");
}
#[test]
fn downloads_the_artifact_through_the_engine() {
let tmp = TempDir::new("gh-artifact-download");
let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]);
let source = local_source(&tmp, &server);
let dest = tmp.join("staging/package.exe");
let written = source
.fetch_artifact(&parsed_stable().releases[0].artifacts[0], &dest)
.expect("应下载成功");
assert_eq!(written, PAYLOAD.len() as u64);
assert_eq!(fs::read(&dest).expect("目标文件应存在"), PAYLOAD);
assert_eq!(
server.hits(),
vec![asset_path()],
"包体走引擎,请求地址就是解析出来的那条"
);
}
#[test]
fn missing_artifact_is_reported_as_source_missing() {
let tmp = TempDir::new("gh-artifact-404");
let server = FakeHttp::start(vec![]);
let source = local_source(&tmp, &server);
let error: UpdateError = source
.fetch_artifact(
&parsed_stable().releases[0].artifacts[0],
&tmp.join("staging/package.exe"),
)
.expect_err("产物不存在必须报错");
assert_eq!(error.kind, UpdateErrorKind::SourceMissing);
assert!(!error.is_retryable(), "产物没上传,重试多少次都一样");
}
#[test]
fn mirror_first_region_puts_the_gh_proxy_in_front() {
// 镜像策略不新造:国内(镜像优先)环境下 gh-proxy 前缀由引擎的镜像表 + 地区档案决定。
let tmp = TempDir::new("gh-mirror");
let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::mirror_first(0)));
let artifact_url = source
.artifact_url(&parsed_stable().releases[0].artifacts[0])
.expect("应能拼出地址");
let planned = source.candidates(&artifact_url);
assert!(
planned.len() >= 2,
"镜像优先至少要有镜像与官方两条候选(实际 {planned:?})"
);
assert_eq!(planned[0].source, SOURCE_GH_PROXY);
assert_eq!(
planned[0].url,
format!("https://gh-proxy.com/{artifact_url}")
);
assert!(
planned
.iter()
.any(|candidate| candidate.source == SOURCE_OFFICIAL && candidate.url == artifact_url),
"官方地址仍要留作兜底(实际 {planned:?})"
);
// 直连模式不套前缀:只剩官方一条。
let direct = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0)));
let planned_direct = direct.candidates(&artifact_url);
assert_eq!(planned_direct.len(), 1);
assert_eq!(planned_direct[0].source, SOURCE_OFFICIAL);
// 清单地址同样吃到 gh-proxy(国内拿不到 releases/latest/download 的正是清单这一刻)。
let manifest_url = source.manifest_url(Channel::Stable).expect("stable 有清单");
let planned_manifest = source.candidates(&manifest_url);
assert_eq!(planned_manifest[0].source, SOURCE_GH_PROXY);
assert_eq!(
planned_manifest[0].url,
format!("https://gh-proxy.com/{manifest_url}")
);
}
#[test]
fn agrees_with_dir_source_on_the_same_manifest() {
// 对拍:同一份清单、同一个当前版本、同一个目标平台,两个源的判定必须一模一样。
let tmp = TempDir::new("gh-dir-parity");
let json = stable_manifest_json();
// 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。
tmp.write(
&format!("{}.json", Channel::Stable.as_str()),
json.as_bytes(),
);
tmp.write(asset_name(), PAYLOAD);
let dir = DirSource::new(tmp.path());
// GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。
let server = FakeHttp::start(vec![
(manifest_path().as_str(), FakeResponse::json(&json)),
(asset_path().as_str(), FakeResponse::bin(PAYLOAD)),
]);
let github = local_source(&tmp, &server);
let current = Version::parse("0.5.0").expect("版本号合法");
let from_dir = UpdateChecker::new(dir, target())
.check(&current, Channel::Stable)
.expect("本地源应能判定");
let from_github = UpdateChecker::new(github, target())
.check(&current, Channel::Stable)
.expect("GitHub 源应能判定");
assert!(
from_dir.is_available(),
"基准用例应当有可用更新(实际 {from_dir:?})"
);
assert_eq!(
from_github, from_dir,
"同一份清单,两个源选出的版本/产物/紧急度必须一致"
);
}
@@ -0,0 +1,192 @@
//! 替换与回滚:成功后备份还在、失败后旧版本仍在原位、目标目录写不进去要报错。
//!
//! 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),
"同一进程两次调用不能撞名"
);
}
@@ -0,0 +1,258 @@
//! 清单解析与校验:字段拼错、重复版本、零长度、摘要格式、产物优先级。
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());
}
+24
View File
@@ -0,0 +1,24 @@
//! `update` 的单元测试。
//!
//! 覆盖面(与 mission 的验收条目一一对应):
//!
//! * 版本比较边界:`0.9` vs `0.10`、预发布 vs 正式、alpha/beta/rc、构建元数据不排序;
//! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式;
//! * 渠道准入:阶梯比较 + 预发布阶段表;
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
//!
//! 全程离线:所有字节都在临时目录或 127.0.0.1 的假 HTTP 服务上造,没有一个用例发公网请求。
mod channel_cases;
mod checker_cases;
mod github_cases;
mod install_cases;
mod manifest_cases;
mod source_cases;
mod support;
mod target_cases;
mod verify_cases;
mod version_cases;
@@ -0,0 +1,179 @@
//! 本地目录源:清单缺失不是错误、渠道对不上要报错、远端地址一律拒绝。
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);
}
+267
View File
@@ -0,0 +1,267 @@
//! `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::{
ContentCache, DownloadEngine, EngineConfig, HashAlgo, HealthRegistry, MirrorTable,
RegionProfile,
};
/// 一次性临时目录(`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(",")
)
}
/// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。
///
/// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。
pub(crate) fn engine_for(tmp: &TempDir, region: RegionProfile) -> std::sync::Arc<DownloadEngine> {
std::sync::Arc::new(DownloadEngine::new(
EngineConfig {
// 失败用例不必等三次退避。
max_attempts_per_source: 1,
..EngineConfig::default()
},
std::sync::Arc::new(MirrorTable::builtin()),
std::sync::Arc::new(region),
std::sync::Arc::new(HealthRegistry::in_memory()),
std::sync::Arc::new(ContentCache::open(tmp.join("cache"))),
))
}
/// 假服务的一条路由应答。
#[derive(Debug, Clone)]
pub(crate) struct FakeResponse {
pub(crate) status: u16,
pub(crate) content_type: &'static str,
pub(crate) body: Vec<u8>,
}
impl FakeResponse {
/// 200 + JSON 体。
pub(crate) fn json(body: &str) -> Self {
Self {
status: 200,
content_type: "application/json",
body: body.as_bytes().to_vec(),
}
}
/// 200 + 二进制体(模拟 release 里的产物)。
pub(crate) fn bin(body: &[u8]) -> Self {
Self {
status: 200,
content_type: "application/octet-stream",
body: body.to_vec(),
}
}
/// 固定状态码 + 空体(404 / 500 用)。
pub(crate) fn status(status: u16) -> Self {
Self {
status,
content_type: "text/plain",
body: Vec::new(),
}
}
}
/// 进程内极简 HTTP/1.1 假服务:按**完整请求路径**查表应答,未命中的路径一律 404。
///
/// 用它而不是 httpmock:项目里下载测试就是这么干的(零额外依赖),而且能把
/// 「请求真的发到了哪个 URL」记下来给断言用。
pub(crate) struct FakeHttp {
addr: std::net::SocketAddr,
hits: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl FakeHttp {
/// 起服务(`routes` 是「路径 → 应答」表)。
pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口");
let addr = listener.local_addr().expect("取本地地址");
let routes: Vec<(String, FakeResponse)> = routes
.into_iter()
.map(|(path, response)| (path.to_string(), response))
.collect();
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let worker_hits = std::sync::Arc::clone(&hits);
let worker_stop = std::sync::Arc::clone(&stop);
let handle = std::thread::spawn(move || {
for stream in listener.incoming() {
if worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
break;
}
let Ok(mut stream) = stream else { break };
let Some(path) = read_request_path(&mut stream) else {
continue;
};
worker_hits
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(path.clone());
let response = routes.iter().find(|(route, _)| route == &path).map_or_else(
|| FakeResponse::status(404),
|(_, response)| response.clone(),
);
let _ = write_response(&mut stream, &response);
}
});
Self {
addr,
hits,
stop,
handle: Some(handle),
}
}
/// 站点根地址(可直接喂给 `GitHubReleaseSource::with_base`)。
pub(crate) fn base(&self) -> String {
format!("http://{}", self.addr)
}
/// 已经收到的请求路径(按到达顺序)。
pub(crate) fn hits(&self) -> Vec<String> {
self.hits
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
}
impl Drop for FakeHttp {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
// 自己连一下唤醒阻塞中的 accept,否则 join 会一直等。
let _ = std::net::TcpStream::connect(self.addr);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
/// 读一个请求的路径(只读头部,够用)。
fn read_request_path(stream: &mut std::net::TcpStream) -> Option<String> {
use std::io::Read;
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).ok()?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let head = String::from_utf8_lossy(&buffer).to_string();
head.lines()
.next()?
.split_whitespace()
.nth(1)
.map(str::to_string)
}
fn write_response(
stream: &mut std::net::TcpStream,
response: &FakeResponse,
) -> std::io::Result<()> {
use std::io::Write;
let head = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response.status,
reason(response.status),
response.content_type,
response.body.len()
);
stream.write_all(head.as_bytes())?;
stream.write_all(&response.body)?;
stream.flush()
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Status",
}
}
@@ -0,0 +1,91 @@
//! 目标平台与包形态的清单写法解析。
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}");
}
}
@@ -0,0 +1,138 @@
//! 校验层:摘要不符、包体截断都必须被挡下,且暂存文件不许留下。
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 落地后应自行清理");
}
@@ -0,0 +1,135 @@
//! 版本比较的边界用例:数值比而非字典序、预发布 vs 正式、构建元数据不参与排序。
use std::cmp::Ordering;
use crate::update::{Stage, Version};
fn version(text: &str) -> Version {
Version::parse(text).expect("测试里的版本号应可解析")
}
#[test]
fn minor_numbers_compare_numerically() {
// 字典序会把 "0.9.0" 判成比 "0.10.0" 新,这正是这条用例要钉死的行为。
assert!(version("0.10.0") > version("0.9.0"));
assert!(version("0.10.1") < version("0.11.0"));
assert!(version("1.0.0") > version("0.999.999"));
assert!(version("2.0.0") > version("1.99.99"));
}
#[test]
fn prerelease_sorts_below_the_release() {
assert!(version("1.0.0-rc.1") < version("1.0.0"));
assert!(version("1.0.0-alpha") < version("1.0.0"));
// 预发布阶段之间:alpha < beta < rc(ASCII 序,不是特例表)
assert!(version("1.0.0-alpha") < version("1.0.0-beta"));
assert!(version("1.0.0-beta.2") < version("1.0.0-rc.1"));
// 主次修订号优先于预发布标识
assert!(version("1.0.1-alpha.1") > version("1.0.0"));
}
#[test]
fn prerelease_identifiers_follow_semver_rules() {
assert!(version("1.0.0-alpha.1") < version("1.0.0-alpha.2"));
assert!(version("1.0.0-alpha.2") < version("1.0.0-alpha.10"));
assert!(version("1.0.0-alpha") < version("1.0.0-alpha.1"));
// 纯数字段小于任何非数字段
assert!(version("1.0.0-1") < version("1.0.0-alpha"));
// 非数字段按 ASCII 比
assert!(version("1.0.0-alpha-1") < version("1.0.0-alpha-x"));
}
#[test]
fn build_metadata_is_ignored_by_ordering_and_equality() {
let with = version("1.2.3+build.7");
let without = version("1.2.3");
assert_eq!(with.cmp(&without), Ordering::Equal);
assert_eq!(with, without);
assert!(version("1.2.4+zzz") > version("1.2.3+aaa"));
assert_eq!(with.to_string(), "1.2.3+build.7");
assert_eq!(with.build_metadata(), Some("build.7"));
assert_eq!(without.build_metadata(), None);
}
#[test]
fn stages_are_recognised_from_the_first_identifier() {
assert_eq!(version("1.0.0").stage(), Stage::Stable);
assert_eq!(version("1.0.0-alpha.3").stage(), Stage::Alpha);
assert_eq!(version("1.0.0-a.2").stage(), Stage::Alpha);
assert_eq!(version("1.0.0-beta.1").stage(), Stage::Beta);
assert_eq!(version("1.0.0-rc.4").stage(), Stage::ReleaseCandidate);
assert_eq!(version("1.0.0-preview.2").stage(), Stage::ReleaseCandidate);
assert_eq!(version("1.0.0-spike-1").stage(), Stage::Unknown);
assert_eq!(version("1.0.0-7").stage(), Stage::Unknown);
}
#[test]
fn round_trips_through_display() {
for text in [
"0.2.1",
"1.0.0-alpha.1",
"3.17.0+build-9",
"10.0.12-rc.2+ci.9",
] {
let parsed = version(text);
let rendered = parsed.to_string();
assert_eq!(version(&rendered), parsed, "{text} 回读不一致");
}
}
#[test]
fn accepts_v_prefix_and_reports_prerelease() {
assert_eq!(version("v1.4.2"), version("1.4.2"));
assert_eq!(version("V1.4.2"), version("1.4.2"));
assert!(!version("1.4.2").is_prerelease());
assert!(version("1.4.2-rc.1").is_prerelease());
assert_eq!(version("1.4.2-rc.1").prerelease().as_deref(), Some("rc.1"));
assert_eq!(version("1.4.2").prerelease(), None);
assert_eq!(
(
version("1.4.2").major(),
version("1.4.2").minor(),
version("1.4.2").patch()
),
(1, 4, 2)
);
}
#[test]
fn rejects_non_semver_input() {
for text in [
"",
"1",
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-",
"1.2.3-alpha..1",
"1.2.3+",
"1.2.3-é",
"1.-2.3",
"1.2.x",
"1.2.3-01",
] {
assert!(
Version::parse(text).is_err(),
"`{text}` 不该被当成合法版本号"
);
}
}
#[test]
fn version_error_carries_input_and_reason() {
let error = Version::parse("1.2.x").expect_err("应失败");
assert_eq!(error.input(), "1.2.x");
assert!(!error.reason().is_empty());
assert!(error.to_string().contains("1.2.x"));
}
#[test]
fn new_builds_a_plain_release() {
assert_eq!(Version::new(0, 2, 1), version("0.2.1"));
assert!(!Version::new(0, 2, 1).is_prerelease());
}
+134
View File
@@ -0,0 +1,134 @@
//! 下载产物的校验:长度(截断)+ 摘要(内容),不符即拒绝安装。
//!
//! 承 `download` 的规矩「先校验后落盘」:包体先落在暂存目录的中间文件里,长度与摘要都对上
//! 才算 [`StagedPackage`];对不上就**立刻删掉**,绝不让它有机会顶到目标路径上。
//!
//! 两道判据的顺序是有意的:先比长度(能抓住截断,且不需要读全文件),再比摘要(能抓住
//! 「长度对但内容错」)。顺序反过来会让「被截断的包」白白多读一遍盘。
//!
//! 长度判据用「**恰好等于**」而不是「不小于」:清单里的 `size` 是我们自己发的字节数,
//! 多了同样是错的(比如拼接了两个包),多一个字节也不许装。
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::download::verify::{digest_file, matches};
use crate::download::Integrity;
use super::error::{UpdateError, UpdateErrorKind};
use super::manifest::Artifact;
use super::source::UpdateSource;
/// 暂存文件的进程内序号(同一进程多次更新不撞名)。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 校验通过的暂存包。
///
/// `Drop` 时删掉暂存文件(装好之后 `rename` 已经把它搬走,删除是无操作);
/// 因此「校验失败要清理」这件事不需要调用方记得做。
#[derive(Debug)]
pub struct StagedPackage {
path: PathBuf,
size: u64,
integrity: Integrity,
}
impl StagedPackage {
/// 暂存文件路径。
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// 实际字节数(已与清单核对过,等于清单声明的 `size`)。
#[must_use]
pub const fn size(&self) -> u64 {
self.size
}
/// 校验用的摘要。
#[must_use]
pub fn integrity(&self) -> &Integrity {
&self.integrity
}
}
impl Drop for StagedPackage {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
/// 从源取包体 → 落暂存 → 长度与摘要双检。
///
/// # Errors
///
/// 取包失败透传 [`UpdateError`];长度不符是 [`UpdateErrorKind::SizeMismatch`],
/// 摘要不符是 [`UpdateErrorKind::DigestMismatch`]。失败时暂存文件已被删掉。
pub fn stage(
source: &dyn UpdateSource,
artifact: &Artifact,
staging_dir: &Path,
) -> Result<StagedPackage, UpdateError> {
fs::create_dir_all(staging_dir).map_err(|error| UpdateError::io_at(staging_dir, &error))?;
let path = staging_dir.join(format!(
"{}-{}.ullupd",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let outcome = source
.fetch_artifact(artifact, &path)
.and_then(|_| verify_file(&path, &artifact.integrity, artifact.size));
match outcome {
Ok(size) => Ok(StagedPackage {
path,
size,
integrity: artifact.integrity.clone(),
}),
Err(error) => {
let _ = fs::remove_file(&path);
Err(error)
}
}
}
/// 校验一个已经落盘的文件:长度必须**恰好**等于 `expected_size`,摘要必须相等。
///
/// # Errors
///
/// 读不到文件/读失败是 [`UpdateErrorKind::Io`];长度不符是 `SizeMismatch`;
/// 摘要不符是 `DigestMismatch`。
pub fn verify_file(
path: &Path,
integrity: &Integrity,
expected_size: u64,
) -> Result<u64, UpdateError> {
let metadata = fs::metadata(path).map_err(|error| UpdateError::io_at(path, &error))?;
let actual = metadata.len();
if actual != expected_size {
return Err(UpdateError::new(
UpdateErrorKind::SizeMismatch,
format!(
"包体 {actual} 字节,清单声明 {expected_size} 字节(差 {} 字节;短了通常是下载被截断)",
expected_size.abs_diff(actual)
),
)
.with_path(path));
}
let digest =
digest_file(path, integrity.algo).map_err(|error| UpdateError::io_at(path, &error))?;
if !matches(&integrity.hex, &digest) {
return Err(UpdateError::new(
UpdateErrorKind::DigestMismatch,
format!(
"{} 摘要不符:清单 {} / 实际 {}(拒绝安装)",
integrity.algo.as_str(),
integrity.short_prefix(),
&digest[..digest.len().min(8)]
),
)
.with_path(path));
}
Ok(actual)
}
+363
View File
@@ -0,0 +1,363 @@
//! 语义化版本:解析、比较、预发布阶段识别。
//!
//! 排序规则严格按 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)
}
}
+5 -6
View File
@@ -374,13 +374,12 @@ mod tests {
#[test] #[test]
fn accent_on_fixed_neutral_only_moves_the_neutral_hue() { fn accent_on_fixed_neutral_only_moves_the_neutral_hue() {
let official = Palettes::official_baseline(); let official = Palettes::official_baseline();
// 中性色相 = 基线色相时,与官方的 from_seed 完全等价(不放宽算法)。 // 中性色相取**种子色相的实测值**而不是 `NEUTRAL_HUE_DEG` 字面量:两者只差 `atan2()` 的末位
let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, NEUTRAL_HUE_DEG, 12.0); // (见下一条测试),逐位比会在 glibc 与 UCRT 之间翻脸;取实测值两侧同源,断言仍是逐位相等。
let per_seed = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, 12.0); let per_seed = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, 12.0);
assert_eq!( let seed_hue = per_seed.neutral.hue_deg();
same, per_seed, let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, seed_hue, 12.0);
"中性色相取种子色相时必须与 TonalSpot 逐项相同" assert_eq!(same, per_seed, "中性色相取种子色相时须与 TonalSpot 相同");
);
// 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。 // 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。
let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0); let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0);
+1 -1
View File
@@ -12,7 +12,7 @@ publish.workspace = true
# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 # 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。
# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 # 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。
[dependencies] [dependencies]
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.2" }
# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): # 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内):
# - png:启动器背景图最常见的无损格式,也是截图通道用的格式; # - png:启动器背景图最常见的无损格式,也是截图通道用的格式;
+1 -1
View File
@@ -22,7 +22,7 @@ serde_json.workspace = true
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。 # 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 # host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.1" } unlucky-core = { path = "../core", version = "0.2.2" }
[lints] [lints]
workspace = true workspace = true
+61 -363
View File
@@ -4,367 +4,31 @@
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。 `packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。 本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
## 1. 产物矩阵 ## 索引:§1–§12 各在哪份文件(M145 拆分)
| 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 | 拆分原因:本文件与 `.github/workflows/release.yml` 都已是 400 行整,下一处改动必撞全仓行数红线(`guards.yml` 的
|------|---------|-----------|------|---------| `file-size`,单文件 ≤400 行)。按主题分开后每份都留出余量,**内容一字未改、只搬家**;**节号沿用原编号**,
| `windows-x64` | ✅ 发布 | `unlucky-launcher_<ver>_windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 | 所以全仓那些「`packaging/README.md` §4 / §7.4 / §9」式引用不必改动 —— 按下表找承载文件即可。
| `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` | 单二进制归档 | 同上 | | §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` |
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 | | §2 | 版本来源(唯一真相源) | `packaging/docs/artifacts-and-version.md` |
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` | | §3 | 怎么构建(本地 / CI) | `packaging/docs/build-and-ci.md` |
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) | | §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` |
- **「不构建」= CI 默认根本不跑**(M139):那四档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走 | §8 | 发行合规 | `packaging/docs/compliance-support-boundaries.md` |
`gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**只有 `windows-x64`**(M142:arm64 照构建、不发布)。 | §9 | 支持矩阵(对外口径) | `packaging/docs/compliance-support-boundaries.md` |
- **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。 | §10 | 已知边界 / 后续 | `packaging/docs/compliance-support-boundaries.md` |
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本", | §11 | CI 运行时冒烟(Linux 真渲染一帧) | 本文下方(细节在 `packaging/ci-runtime-smoke.md`) |
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。 | §12 | 文件清单 | 本文下方 |
- 归档解压后是**一个顶层目录** `unlucky-launcher_<ver>_<平台>_<架构>/`,用户不会解压出一堆散文件。
- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放 本文件仍是**契约的唯一真相源入口**:`.github/workflows/release.yml`(及拆出的 `release-build.yml` /
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。 `release-publish.yml`)的注释、`make_artifacts.py` 的说明、根 `README.md` / `README.zh-CN.md` 与
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的同一套命令(差异见 §4 与 §7.4),两者不是重复品。 `packaging/release-notes.md` 都按「`packaging/README.md` §N」引用。改契约时先改上表对应的承载文件,
M118:GUI 的**构建产物**名是 `unlucky-launcher-iced`(`crates/app` 已被 workspace `exclude`,不再产出 `unlucky-launcher`);对外文件名仍是 `unlucky-launcher`,改名在 `make_artifacts.py` 落盘时(`LAUNCHER_BIN` 找产物 / `LAUNCHER_ASSET` 定对外名),所以下载链接 / `SHA256SUMS` / 本文矩阵不变。 再看一眼 §12 的行数预算 —— 任何一份超过 400 行都要继续按主题拆。
- `<ver>` 不带前导 `v`(tag 是 `v0.2.1`,文件名是 `0.2.1`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
`cputype`),与槽位声明的架构不符即失败——不会出现"名字是 arm64、内容还是 x64"的假产物。
## 2. 版本来源(唯一真相源)
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
任何一个 workspace member 版本不一致时直接失败。
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
**不一致即失败**——避免出现 tag 说 0.2.1、产物里写 0.2.0 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
## 3. 怎么构建
### 本地(当前平台)
```bash
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
python packaging/make_artifacts.py --version v0.2.1 # 顺带校验版本一致性
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
```
脚本按宿主自动判定槽位(Windows x64 / Windows arm64 / Linux x64 / Linux arm64 / macOS arm64 /
macOS x64),其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
交叉构建(例如在 x64 宿主上产 Windows arm64 产物)靠两个参数把"产物根"和"槽位"显式喂进去,
脚本不去猜宿主与产物是不是同一架构(**CI 不用这条路径**,见下):
```bash
rustup target add aarch64-pc-windows-msvc
cargo build --release --locked --target aarch64-pc-windows-msvc \
--bin unlucky-launcher-iced --bin ull # 不用 --bins:矩阵只发 GUI + ull;GUI 的构建产物名是 iced 的 bin(M118)
python packaging/make_artifacts.py --slot windows-arm64 \
--build-dir target/aarch64-pc-windows-msvc/release --out dist
```
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)。**CI 不走交叉**(M140):
`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 的同类
冒烟只在 `all_slots=true` 的取证跑里出现(`ubuntu-22.04-arm` 原生 runner 上真跑 `ull --version`)。
### CI(默认只跑两个 Windows 槽位;`all_slots=true` 才整表六槽位)
`.github/workflows/release.yml`:
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.1`。
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
「dispatch 只构建不发布」与「dispatch 能关 pre-release」是两条互不冲突的规则。
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC +
`-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编);
`ubuntu-22.04`(glibc 2.35 地板)/ `ubuntu-22.04-arm`(原生 ARM64,同地板)/ `macos-latest` /
`macos-15-intel` 标 `mode: probe`——**未验证,默认一个都不编**,只有 `all_slots=true` 才进矩阵。
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位与 `rustup
target add` 一并没了)。arm64 槽位多一步编译前体检:查 VC 的 `lib/arm64/libcmt.lib`(`+crt-static`
靠它)与 SDK 的 `um/arm64/kernel32.lib`,缺了就 fail;**注解里固定打 4 行实况**(探测根 / SDK Lib 版本→um
架构 / MSVC lib 架构 / 两个关键库)。M142 修的正是「诊断自己先死」:`set -uo pipefail` 关不掉 runner 注入的
`-e`,`find` 扫不存在的根被 pipefail 判非零就当场退出 → 现在 `set +e` + 处处 `|| true`,清单先打完再定红绿。
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M142 起只有
`windows-x64`**:arm64 连试四轮未走通,而齐全性检查是「名单内少一档就 fail」,挂着它就等于 x64 编成功也发不出去;
它仍留在矩阵里照跑取证、红了不挡发布。**可逆**:arm64 修好后把键加回 `release.yml` 那一行,再同步 §1/§9 与
`release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
- `release` job:下载全部 artifact → **按白名单裁剪** → 重算 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
`workflow_dispatch` 显式给了输入才断言(`prerelease=true` → `--prerelease`,`false` → `--prerelease=false`)。
要改标记两条路等价且互不打架:① 走流水线 `gh workflow run release.yml --ref v0.2.1 -f prerelease=false`
(会落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑
不碰该标记,不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
## 4. 每平台运行时依赖
### Windows x64(Win10/11)
| 依赖 | 来源 | 说明 |
|------|------|------|
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
本机(GNU 工具链)实测导入表证据见 §7。
### Windows ARM64(Win11 on ARM)
**本版不发布**(CI 照构建取证,红了不挡 x64 发布;等它绿了把键加回 `PUBLISH_SLOTS` 即可)。产物形态与门禁跟 x64
一样(裸 exe + 静态 CRT + dumpbin 导入表硬门禁),差别只在**怎么编**:**M140 起在原生 `windows-11-arm` 上编**
(GitHub 文档 2026 的 Standard runners 表明列该标签:4C/16G、公开仓库免费),产物落在 `target/release`。原路线
是在 `windows-latest` 上交叉到 `aarch64-pc-windows-msvc`,但**CI 上从未成功过一次**(v0.2.1 首次真跑即红在
「ARM64 工具链体检」)。M142 已确认那几轮「只剩一行 exit code 1」是**诊断脚本自己先死**(`set -uo pipefail` 关不掉
runner 注入的 `-e`,`find` 扫不存在的根被 pipefail 判非零就退出),已修;ARM64 的静态库在这台 runner 上到底缺不缺
要看下一次运行的新注解(固定 4 行实况清单,见 §3)。原生的附赠好处:宿主=产物架构,`ull --version` 冒烟**真跑**。
运行时依赖与 x64 同表(UCRT 是 Win11 ARM 的系统组件,`+crt-static` 静态链 CRT,不需要可再发行包)。
游戏侧如实说:**启动器原生运行**;Temurin 没有 windows-aarch64 产物,Microsoft OpenJDK 有该
发行版(core 的 `microsoft` 源按 `<os>-<arch>` 拼名,命名已覆盖),但 Minecraft 本体没有
Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系统 x64 模拟层 + x64 JDK。
所以本槽位只承诺「启动器产物」支持,不替 Java/游戏管理做架构级承诺。
### Linux x64
| 依赖 | 说明 |
|------|------|
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定(该槽位默认不编,`all_slots=true` 取证跑才有产物);更老的发行版请自行重建或等 musl 版 |
| `libxkbcommon.so.0` | winit 键盘处理 |
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
| `libGL.so.1` / `libEGL.so.1` | Slint 版:femtovg 的 GL 渲染;iced 版:wgpu 的 GL/EGL 后端(**推断,未实测**)。两者缺失都退到 CPU 软渲染(tiny-skia) |
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
| `libwayland-client.so.0` | Wayland 后端 |
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
**M118(D26)口径**:除上表里标注「推断,未实测」的那一行外,这张表与下面这条结论都是 **Slint 版 GUI** 的实测记录(M82:`readelf -d` + `ldd` + `nm -D`);iced 版(`crates/app-iced`)的 `DT_NEEDED` / `dlopen` 集合**尚未重测**,请按「同一族库、细节待验」读,别当成 iced 版的实测承诺。
仍然成立的部分:上表是**开窗**要用的库,不是进程启动门槛——实测(§7.4,Slint 版)除 `libfontconfig.so.1` 外它们**都不在** ELF 的 `DT_NEEDED` 里,由 `dlopen` 在开窗那一刻才加载,所以没有 X11 / Wayland / GL 的机器上 `unlucky-launcher instances list` 照样跑得起来,`ull` 连 fontconfig 都不需要。
arm64 侧同理(同一份依赖树;历史口径里的「winit + femtovg」中 femtovg 只属于 Slint 版)。
### Linux arm64(aarch64)
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
**glibc 地板也相同**:arm64 槽位跑在原生 `ubuntu-22.04-arm` runner 上(默认矩阵不编它,只有 `all_slots=true` 取证跑才出产物),地板 = **2.35**,与 x64
用的 `ubuntu-22.04` 一致。选 22.04 而不是 24.04 就是为了这个地板——24.04 的 glibc 更高,会把
Debian 12(2.36)、Raspberry Pi OS Bookworm(2.36)这些实际在跑的 ARM 发行版挡在门外。
要抬地板(例如将来 ARM 生态整体上移)换 `release.yml` 里那个槽位的 `os` 即可,脚本与产物名都不用动;
`ubuntu-24.04-arm` / `ubuntu-26.04-arm` 都是同一家族的标准公开仓库标签(4C/16GB、免费不限量)。
### macOS 12+
| 依赖 | 说明 |
|------|------|
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
| `libSystem` | 系统 |
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
**现役入口(iced 版)**:`unlucky-launcher-iced --software-renderer`(等价 `--renderer=cpu`,即 `ICED_BACKEND=tiny-skia`)
走 tiny-skia 光栅化,这是现役构建唯一的软渲染入口——CI 的 runtime-smoke 与 `packaging/linux-smoke-docker.sh` 都这么跑(§11)。
**历史资产(`v0.1.0` 的 Slint 版)**:那批已发布二进制仍认 `unlucky-launcher --software-renderer`
(解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的那个串)。
这条 Slint 路径(默认 `renderer-femtovg`/GL)已不在默认构建与发布矩阵内——`crates/app` 被 workspace `exclude`,
源码仅作参考、不编译——所以它**对现役 `unlucky-launcher-iced` 无效**,别照抄到 iced 版上。
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
- 截图通道 `--screenshot <png>`:历史实现 `crates/app/src/screenshot.rs`,现役 `crates/app-iced/src/shot/`;
软渲染下照样出图,是 CI 与远程机唯一可靠的"真出图"证据(发布冒烟见 §7,本机实跑 `--software-renderer --screenshot`)。
## 6. Cargo 构建档位(release 体积档 / fast 速度档)
| 档位 | 用在哪 | opt-level | lto | codegen-units | panic |
|------|--------|-----------|-----|---------------|-------|
| `release` | 发布 / 验收产物 | `"z"`(workspace 自身 crate) | `fat` | 1 | `abort` |
| `release.package."*"` | 同上,**只作用于依赖** | `"z"` | 不可覆盖 | 16 | 继承 |
| `fast` | 日常迭代、本机跑测试 | 2 | `thin` | 16 | `unwind` |
- **`release` 是体积档**:`fat` + 单 CGU 换来实测 GUI −10.8% / CLI −18.7%(§7.1)。变慢的根源在 `codegen-units = 1`
(单 CGU → LTO 只一个线程可跑;`fat` 只管"跨 crate 全量参与",不等于单线程),375 个 crate 排队链接就是十几分钟的量。
- **`release.package."*"` 放宽的只有并行度,不是 opt-level**:实测把依赖 opt-level 抬到 3 反而**又慢 29.6%
又大 19~24%**(§7.1),所以保持 `"z"`、只把 `codegen-units` 放到 16。package 覆盖**只允许调优化强度**:
`panic` / `lto` / `rpath` 写进去 cargo 直接报错,`strip` 会改变整个二进制的运行时行为——都不许出现。
- **`fast` 是速度档,不是发布物**:`thin` LTO + 16 CGU 可并行,`opt-level = 2` 介于 `z` 与 3 之间。
`panic = "unwind"` 是给**编出来的二进制**留的(`release` 是 `abort`,`inherits` 会照抄);`cargo test`
**不**依赖它——cargo 对 test 目标自己强制 unwind(实测 release/abort 档下 `cargo test --profile release`
照跑、23 个测试全过,test 单元的 rustc 参数里没有 `-Cpanic=`)。要看这行是否生效,比 `cargo build -v`
的 rustc 参数:release 有 `panic=abort`、fast 没有。它继承 `strip="symbols"`,所以能 unwind 但**没有符号
回溯**——要栈回溯就给 fast 档加 `strip = false`,代价是体积再涨。
- **`fast` 在机械盘机器上冷编译并无收益**(比 release 还慢、体积 2.3 倍,§7.1),价值在 SSD 机器与增量重编。
- **`[profile.fast.package."*"]` 是承重墙**:`inherits` 会把 release 的 package 覆盖一起带过来(实测:删掉它,fast 档外部依赖静默退回 `"z"`),必须存在。
- 本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release 档构建。
## 7. 实测(本机,不是估算)
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.1_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
| 档位 | `unlucky-launcher.exe` | `unlucky-launcher-iced.exe` | `ull.exe` | 冷构建耗时 |
|------|-----------------------|----------------------------|-----------|-----------|
| `release`,`thin`(M46 前) | 16,366,080 B = 15.61 MiB | 未测(当时无 iced 版) | 7,572,992 B = 7.22 MiB | 11m52s |
| `release`,`fat`,依赖全 `"z"`(M46,改前) | **14,595,584 B = 13.92 MiB** | 未测 | **6,157,824 B = 5.87 MiB** | 9m27s(增量) |
| `release`,`fat` + 依赖 opt-3 / cu16(M96 试过的那版,**已否**) | 20,407,296 B = 19.46 MiB | 18,371,584 B = 17.52 MiB | 7,743,488 B = 7.38 MiB | 22m45s(冷) |
| `release`,`fat` + 依赖 `"z"` / cu16(**M96 起,发布档**) | **16,479,232 B = 15.72 MiB** | **13,910,528 B = 13.27 MiB** | **6,256,128 B = 5.97 MiB** | **16m01s(冷)** |
| `fast`,`thin` + cu16(M96 起,日常档) | 38,182,400 B = 36.41 MiB | 33,570,304 B = 32.02 MiB | 15,967,744 B = 15.23 MiB | 24m27s(冷) |
测量条件(M96 三行):12 核 / `x86_64-pc-windows-gnu` / `-j 4`,源码机械盘、`CARGO_TARGET_DIR` 在 SSD,均为**删空 target 后的
冷编译**、同机同晚无并发负载;M46 两行是更早提交上的数字(依赖已热 / 另有并发),**跨行比耗时没意义**。
① **依赖 opt-level 从 3 退回 `"z"` 又快 29.6% 又小 19~24%**(两臂同为 cu16,唯一自变量是 opt-level)——抬 opt-level 是拿编译时间和体积换运行期;
`cu1 → cu16` 的收益本轮**没有同条件对照**,未归因。② `fast` **冷编译比 `release` 还慢**(24m27s vs 16m01s)、体积 2.3 倍,瓶颈在两个大 UI
crate + 链接 + 机械盘 IO——定位是 **SSD 机器与增量重编**,不是冷编译加速。体积归因:GUI 相对旧基线 +12.9%(13.92 → 15.72 MiB)里
「代码增长」与「依赖 cu16」**未分离**;`ull` 只 +1.7%,在 +1~3% 口径内。体积构成(**M118 前的 Slint 版,历史数**):Slint 运行时(双渲染器 + winit/accesskit/托盘)+ rustls/reqwest + rig/rmcp/axum 为主,`ull` 更小是 D18(CLI 零 UI 依赖)的结果。
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
| 命令 | 实测结果 |
|------|---------|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …`(Slint 版,历史实测) | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)|
### 7.3 打包验证(本机 Windows 槽位真跑;产物名是 D49 改名前的形态)
```
smoke: ull.exe --version -> 0.1.0 (ok)
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
sums: SHA256SUMS.txt
$ sha256sum -c SHA256SUMS.txt
unlucky-launcher-0.1.0-windows-x64.exe: OK
```
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
(`windows-latest` 与 `windows-11-arm` 两个镜像都自带 VS 工具链,基本找得到;跳过等于让门禁在最需要它时失效)。
总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
Linux 侧同一件事的对照(M82 实测:`readelf -d` + `ldd` + `nm -D`,容器内 Debian 12 glibc 2.36;被测的是 **Slint 版 GUI,历史实测**):Slint
GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so.6` / `libc.so.6` /
`ld-linux-x86-64.so.2`;`libX11` / `libxcb` / `libxkbcommon` / `libwayland-client` / `libGL` / `libEGL`
**都不在**导入表里,而是以字符串形式由 `dlopen` 在开窗那一刻加载(`nm -D` 可见 `U dlopen@GLIBC_2.34` /
`dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI;
`ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。
**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon /
libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version`
与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是
恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
| 取证项 | 结果 |
|--------|------|
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe`(当次实测的连字符名)14.01 MiB,`SHA256SUMS.txt` 行名一致 |
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`(旧连字符名,当次实测),权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
**没在本地验证、只能由 CI 补的**:`windows-arm64` 在 `windows-11-arm` 上的真实链接与 ARM64 导入表、
`linux-arm64` 在原生 arm64 runner 上的真跑(本机既没有 ARM 机器,也连 `raw.githubusercontent.com` 都解析不了)。
M141/M142 保留了编译前体检(查 VC 的 `lib/arm64/libcmt.lib` 与 SDK 的 `um/arm64/kernel32.lib`,无论齐不齐都先打 4 行
实况注解)。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),而是 arm64 注解转绿 + 把键加回 `PUBLISH_SLOTS`。
## 8. 发行合规
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
- Slint 的 RF 2.0 义务只跟 `v0.1.0` 那批资产:当时关于页有 `AboutSlint` 部件、选的是不触发 GPL 分支的 RF 分支
(见 `deny.toml` 的许可证白名单注释)。现役 iced UI 里没有、也不需要任何 Slint 部件。
- **M118 后这条义务只跟着已发布的 `v0.1.0` 资产走**:`crates/app` 已被 workspace `exclude`,发布矩阵里再没有链接 Slint 的二进制,新的 iced 版产物不链接 Slint;`deny.toml` 那条 RF-2.0 allow 因此暂时是死条目(不删,留给将来真有别的路径引入 Slint 时兜底)。
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
| 平台 | 本版发布 | 级别 | 运行时要求 |
|------|---------|------|-----------|
| Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) |
| Windows 11 ARM64 | ❌ 不发布(构建取证中,待修) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine |
| Linux x64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
| Linux x64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | — | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
## 10. 已知边界 / 后续
1. **GUI 的 `--version` 已解决**(M82):现在命中子命令分发,输出与 `ull --version` 同一条版本 + 系统摘要后退出,
不再开窗;§7.2 里「无输出、8 秒后被 timeout 杀掉」那行是**改之前**的记录,留作对照。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但现役 iced 版(winit + wgpu)在 musl 上的可行性未验证,未纳入本矩阵。
5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 `warning: linker
stderr: corrupt .drectve at end of def file`(MinGW `ld` 的老式抱怨,不影响产物;CI 的 MSVC 槽位没有)。
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既无 ARM 机器也无 MSVC 的 ARM64 链接库组合,
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
## 11. CI 运行时冒烟(Linux 真渲染一帧) ## 11. CI 运行时冒烟(Linux 真渲染一帧)
@@ -391,10 +55,44 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| 文件 | 作用 | 行数上限 | | 文件 | 作用 | 行数上限 |
|------|------|---------| |------|------|---------|
| `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 | | `packaging/README.md` | 本文:索引 + §11 / §12 / §13(契约入口;§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/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
| `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤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 |
| `packaging/make_manifest.py` | 更新清单 `manifest.json` 的生成与自验(D50,见 §13) | ≤400 |
| `packaging/tests/manifest.golden.json` | 清单 golden 样例:`make_manifest.py --self-test` 逐字节对拍 | ≤400 |
| `.github/workflows/release-publish.yml` | `workflow_call`:发布(`PUBLISH_SLOTS` 裁剪 / SHA256SUMS / 清单 / Release) | ≤400 |
## 13. 更新清单 `manifest.json`(D50)
清单作为**固定文件名**的 Release 资产挂着;客户端按 `releases/latest/download/manifest.json` 取(D50)。
字段形状的唯一权威是 `crates/core/src/update/manifest.rs` 的 `ReleaseManifest`(`deny_unknown_fields` +
逐字段校验:semver、`size > 0`、摘要长度/字符集、`target`/`format` 取值表)——生成器只负责把那套形状填对。
- **生成器**:`packaging/make_manifest.py`(零第三方依赖)。CI 在 `release-publish.yml` 里**重算
`SHA256SUMS.txt` 之后**调它,所以清单描述的是「真正要发的资产」,不是「构建出来的全部」。
- **同源,不双写**:每个 `sha256` 直接读 `SHA256SUMS.txt`,`size` 取自同一批文件的 `stat()`;产物名里的版本段
必须等于 `--version`(tag 与 `Cargo.toml` 漂了就没有产物可对,当场失败)。`--self-test` 用
`packaging/tests/manifest.golden.json` 逐字节对拍,并跑 14 条负例证明严格解析不是摆设。
- **只放启动器产物**:`ull` 不进清单。DTO 的 `Artifact` 没有角色字段,`Release::artifact_for()` 取「第一个
target 命中的产物」,把 CLI 并列写进去会让「该覆盖哪个文件」取决于数组顺序(`ull` 照旧进 `SHA256SUMS.txt`)。
- **`url` 两种形态**:默认裸文件名(相对源根,`DirSource` 口径,本地/内网用);CI 传 `--asset-base-url`
拼绝对地址,且**钉 tag 不钉 latest**——清单里每条都指向与它自报版本相符的字节。
- `min_supported` / `notes` 只是**可填而不默认填**:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
后者在 Release 正文里已有,两处各写一份迟早漂。
- 本地复跑:`python3 packaging/make_artifacts.py --manifest` → 与 CI 同一段代码,产出 `dist/manifest.json`。
**D51 定案(2026-11-06,方案甲)**:GitHub 的 `releases/latest/download/*` 只指向**非 prerelease** 的最新
Release。稳定版发布时显式 dispatch `prerelease=false` 让 latest 生效(客户端地址不变);push tag 默认仍
`prerelease=true`,视为预发布观察、不进稳定通道。发版 checklist 第六件 = 确认 prerelease 标记与通道一致;
v0.2.2 起首个适用。
+49
View File
@@ -0,0 +1,49 @@
# 产物矩阵与版本来源(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` 可直吃) |
| 全平台 | 仅已发布资产 | `manifest.json` | **更新清单**(D50,见 `README.md` §13) | 渠道 + 版本 + 各平台产物(`target`/`format`/`url`/`size`/`sha256`);生成器 `make_manifest.py` |
约定细节:
- **「不构建」= 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.2`,文件名是 `0.2.2`)——由脚本统一裁剪。
- 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.2、产物里写 0.2.1 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
+89
View File
@@ -0,0 +1,89 @@
# 构建与 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.2 # 顺带校验版本一致性
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.2`。
分支 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.2 -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 链接器,收益为零。
@@ -0,0 +1,54 @@
# 发行合规、支持矩阵与已知边界(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 未验证,不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
## 10. 已知边界 / 后续
1. **GUI 的 `--version` 已解决**(M82):现在命中子命令分发,输出与 `ull --version` 同一条版本 + 系统摘要后退出,
不再开窗;§7.2 里「无输出、8 秒后被 timeout 杀掉」那行是**改之前**的记录,留作对照。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但现役 iced 版(winit + wgpu)在 musl 上的可行性未验证,未纳入本矩阵。
5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 `warning: linker
stderr: corrupt .drectve at end of def file`(MinGW `ld` 的老式抱怨,不影响产物;CI 的 MSVC 槽位没有)。
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既无 ARM 机器也无 MSVC 的 ARM64 链接库组合,
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
+124
View File
@@ -0,0 +1,124 @@
# 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.2_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
| 档位 | `unlucky-launcher.exe` | `unlucky-launcher-iced.exe` | `ull.exe` | 冷构建耗时 |
|------|-----------------------|----------------------------|-----------|-----------|
| `release`,`thin`(M46 前) | 16,366,080 B = 15.61 MiB | 未测(当时无 iced 版) | 7,572,992 B = 7.22 MiB | 11m52s |
| `release`,`fat`,依赖全 `"z"`(M46,改前) | **14,595,584 B = 13.92 MiB** | 未测 | **6,157,824 B = 5.87 MiB** | 9m27s(增量) |
| `release`,`fat` + 依赖 opt-3 / cu16(M96 试过的那版,**已否**) | 20,407,296 B = 19.46 MiB | 18,371,584 B = 17.52 MiB | 7,743,488 B = 7.38 MiB | 22m45s(冷) |
| `release`,`fat` + 依赖 `"z"` / cu16(**M96 起,发布档**) | **16,479,232 B = 15.72 MiB** | **13,910,528 B = 13.27 MiB** | **6,256,128 B = 5.97 MiB** | **16m01s(冷)** |
| `fast`,`thin` + cu16(M96 起,日常档) | 38,182,400 B = 36.41 MiB | 33,570,304 B = 32.02 MiB | 15,967,744 B = 15.23 MiB | 24m27s(冷) |
测量条件(M96 三行):12 核 / `x86_64-pc-windows-gnu` / `-j 4`,源码机械盘、`CARGO_TARGET_DIR` 在 SSD,均为**删空 target 后的
冷编译**、同机同晚无并发负载;M46 两行是更早提交上的数字(依赖已热 / 另有并发),**跨行比耗时没意义**。
① **依赖 opt-level 从 3 退回 `"z"` 又快 29.6% 又小 19~24%**(两臂同为 cu16,唯一自变量是 opt-level)——抬 opt-level 是拿编译时间和体积换运行期;
`cu1 → cu16` 的收益本轮**没有同条件对照**,未归因。② `fast` **冷编译比 `release` 还慢**(24m27s vs 16m01s)、体积 2.3 倍,瓶颈在两个大 UI
crate + 链接 + 机械盘 IO——定位是 **SSD 机器与增量重编**,不是冷编译加速。体积归因:GUI 相对旧基线 +12.9%(13.92 → 15.72 MiB)里
「代码增长」与「依赖 cu16」**未分离**;`ull` 只 +1.7%,在 +1~3% 口径内。体积构成(**M118 前的 Slint 版,历史数**):Slint 运行时(双渲染器 + winit/accesskit/托盘)+ rustls/reqwest + rig/rmcp/axum 为主,`ull` 更小是 D18(CLI 零 UI 依赖)的结果。
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
| 命令 | 实测结果 |
|------|---------|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …`(Slint 版,历史实测) | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)|
### 7.3 打包验证(本机 Windows 槽位真跑;产物名是 D49 改名前的形态)
```
smoke: ull.exe --version -> 0.1.0 (ok)
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
sums: SHA256SUMS.txt
$ sha256sum -c SHA256SUMS.txt
unlucky-launcher-0.1.0-windows-x64.exe: OK
```
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
(`windows-latest` 与 `windows-11-arm` 两个镜像都自带 VS 工具链,基本找得到;跳过等于让门禁在最需要它时失效)。
总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
Linux 侧同一件事的对照(M82 实测:`readelf -d` + `ldd` + `nm -D`,容器内 Debian 12 glibc 2.36;被测的是 **Slint 版 GUI,历史实测**):Slint
GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so.6` / `libc.so.6` /
`ld-linux-x86-64.so.2`;`libX11` / `libxcb` / `libxkbcommon` / `libwayland-client` / `libGL` / `libEGL`
**都不在**导入表里,而是以字符串形式由 `dlopen` 在开窗那一刻加载(`nm -D` 可见 `U dlopen@GLIBC_2.34` /
`dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI;
`ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。
**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon /
libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version`
与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是
恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
| 取证项 | 结果 |
|--------|------|
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe`(当次实测的连字符名)14.01 MiB,`SHA256SUMS.txt` 行名一致 |
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`(旧连字符名,当次实测),权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
**没在本地验证、只能由 CI 补的**:`windows-arm64` 在 `windows-11-arm` 上的真实链接与 ARM64 导入表、
`linux-arm64` 在原生 arm64 runner 上的真跑(本机既没有 ARM 机器,也连 `raw.githubusercontent.com` 都解析不了)。
M141/M142 保留了编译前体检(查 VC 的 `lib/arm64/libcmt.lib` 与 SDK 的 `um/arm64/kernel32.lib`,无论齐不齐都先打 4 行
实况注解)。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),而是 arm64 注解转绿 + 把键加回 `PUBLISH_SLOTS`。
+86
View File
@@ -0,0 +1,86 @@
# 每平台运行时依赖与软渲染兜底(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`)。
+116
View File
@@ -0,0 +1,116 @@
#!/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 才算绿)"
+28 -5
View File
@@ -4,13 +4,14 @@
Release matrix (see `packaging/README.md` for the full contract): Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip) Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip)
Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, cross-built) Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, native arm64 runner)
Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz
Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz
macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive) every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
all SHA256SUMS.txt all SHA256SUMS.txt
all manifest.json (only with --manifest, see below)
Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore
separated, and **no implementation detail in the file name** — the asset never says which UI separated, and **no implementation detail in the file name** — the asset never says which UI
@@ -31,10 +32,19 @@ carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
The launcher binary also answers every CLI subcommand, so `ull` is not a second The launcher binary also answers every CLI subcommand, so `ull` is not a second
interface — it is the version of it that links no graphics stack at all. interface — it is the version of it that links no graphics stack at all.
Slot and build directory are overridable because a cross build's binaries live in `--manifest` additionally writes `manifest.json` (the update manifest, D50) into the same
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI output directory. The generation itself lives in `packaging/make_manifest.py`, invoked as a
passes both from its matrix, and the executable header is read back so a binary can subprocess: this file stays the single source of truth for **naming** (`asset_stem()`), that
never be shipped under a slot name it was not built for. one is the single writer of the **manifest**, and it derives every sha256/size from the
`SHA256SUMS.txt` this script just wrote — one set of facts, no second bookkeeping. CI does not
use this flag: `release-publish.yml` recomputes `SHA256SUMS.txt` after pruning the publish
whitelist and generates the manifest from *that*, so the manifest always describes what ships.
Slot and build directory are overridable because the build host is not always the
artifact: a cross build's binaries live in `target/<triple>/release` under an x64
host, while every native slot (all six in CI, M140) puts them in `target/release`.
CI passes both from its matrix, and the executable header is read back so a binary
can never be shipped under a slot name it was not built for.
Usage: Usage:
cargo build --release --locked cargo build --release --locked
@@ -209,6 +219,15 @@ def write_sums(out_dir: Path, assets: list[Path]) -> Path:
return sums return sums
def write_manifest(out_dir: Path, version: str) -> Path:
"""`manifest.json`(D50):跑的是 CI 发布侧同一份脚本,事实全部来自刚写完的 SHA256SUMS.txt。"""
script = REPO_ROOT / "packaging" / "make_manifest.py"
proc = subprocess.run([sys.executable, str(script), "--dist", str(out_dir), "--version", version])
if proc.returncode != 0:
fail(f"manifest generation failed ({script.name} exited {proc.returncode})")
return out_dir / "manifest.json"
def asset_stem(name: str, version: str, slot: str) -> str: def asset_stem(name: str, version: str, slot: str) -> str:
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。 """`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
@@ -267,6 +286,7 @@ def main() -> int:
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host") parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})") parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run") parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
parser.add_argument("--manifest", action="store_true", help="also write manifest.json (update manifest, D50)")
args = parser.parse_args() args = parser.parse_args()
version = workspace_version() version = workspace_version()
@@ -310,6 +330,9 @@ def main() -> int:
size_mb = item.stat().st_size / (1024 * 1024) size_mb = item.stat().st_size / (1024 * 1024)
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}") print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
print(f"sums: {sums.name}") print(f"sums: {sums.name}")
if args.manifest:
manifest = write_manifest(out_dir, version)
print(f"manifest: {manifest.name}")
return 0 return 0
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""Generate `manifest.json` — the update manifest the launcher reads (D50).
Contract and rationale live in `packaging/README.md` §13; this docstring only states what the
code must not get wrong.
* **Shape authority** = `crates/core/src/update/manifest.rs` (`ReleaseManifest`,
`deny_unknown_fields` + per-field validation). This script only writes that shape.
* **Facts come from one place**: every `sha256` is read out of the Release's own
`SHA256SUMS.txt`, every `size` is `stat()` of the same file — the two can never disagree.
The version token inside each asset name is asserted to equal `--version`, so a tag that
drifted from `Cargo.toml` cannot yield a manifest pointing at files that do not exist.
* **Only launcher artifacts go in.** `ull` does not: the DTO has no role field and
`Release::artifact_for(target)` returns the *first* match, so listing the CLI beside the GUI
would make "which file do I overwrite" depend on array order.
* **`url`**: bare asset name by default (the `DirSource` convention in
`crates/core/src/update/source.rs`); with `--asset-base-url` a tag-pinned absolute URL,
which is what CI uses.
Usage:
python3 packaging/make_manifest.py --dist dist --version v0.2.2 --channel stable \\
--asset-base-url https://github.com/o/r/releases/download/v0.2.2
python3 packaging/make_manifest.py --self-test # golden + strict-parse self check
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
from pathlib import Path
from typing import NoReturn
# 本机 Python 可能是 isolated 模式(`safe_path=True`,脚本目录不进 `sys.path`),CI 的标准
# python3 则会自动加入。显式补一次,两种环境下 `import make_artifacts` 都成立。
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 只借常量:产物命名(SLOTS / LAUNCHER_ASSET)、CLI 名与 SHA256SUMS 文件名都是 make_artifacts
# 的单一口径,这里不复制一份表(复制就会漂);反向依赖不存在(make_artifacts 只用子进程调本脚本)。
import make_artifacts
MANIFEST_NAME = "manifest.json"
CHANNELS = ("stable", "beta", "alpha", "nightly")
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。
TARGET_OS = {"win", "windows", "mac", "macos", "osx", "darwin", "linux"}
TARGET_ARCH = {
"x64", "x86_64", "x86-64", "amd64", "arm64", "aarch64", "arm64e", "x86", "i686", "i386", "x86_32",
}
FORMATS = {"msi", "dmg", "appimage", "deb", "rpm", "portable-zip", "zip", "portable-exe", "exe"}
SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$")
SUMS_LINE = re.compile(r"^([0-9A-Fa-f]{64})[ \t]+\*?(.+)$")
def fail(message: str) -> NoReturn:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def normalize_version(text: str) -> str:
"""`v0.2.2` -> `0.2.2`(tag 带 `v`,产物名与清单都不带)。"""
version = text.strip()
version = version[1:] if version.startswith("v") else version
if not semver_ok(version):
fail(f"version `{text}` is not a semver")
return version
def semver_ok(text: str) -> bool:
"""Mirror of `crates/core/src/update/version.rs::Version::parse` on the accepted shapes."""
match = SEMVER.match(text)
if not match:
return False
if any(len(number) > 1 and number.startswith("0") for number in match.group(1, 2, 3)):
return False
pre, build = match.group(4), match.group(5)
if pre is not None:
for ident in pre.split("."):
if not ident or (ident.isdigit() and len(ident) > 1 and ident.startswith("0")):
return False
if build is not None and any(not ident for ident in build.split(".")):
return False
return True
def read_sums(dist: Path) -> dict[str, str]:
"""`SHA256SUMS.txt` -> {file name: lowercase sha256}. Missing/odd entries are fatal."""
sums_path = dist / make_artifacts.SUMS_NAME
if not sums_path.is_file():
fail(f"missing {sums_path}(清单的 sha256 只从这里取,不另算一份)")
sums: dict[str, str] = {}
for lineno, line in enumerate(sums_path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
match = SUMS_LINE.match(line)
if not match:
fail(f"{sums_path.name}:{lineno} 不是 `<sha256> <文件名>` 形态:{line!r}")
digest, name = match.group(1).lower(), match.group(2)
if name in sums:
fail(f"{sums_path.name} 里 {name} 出现两次(无法确定哪个 sha256 是对的)")
sums[name] = digest
if not sums:
fail(f"{sums_path} 是空的:没有产物就没有清单")
return sums
def split_asset(name: str, version: str) -> tuple[str, str, str, str]:
"""`<name>_<version>_<platform>_<arch>[.exe|.tar.gz]` -> (name, platform, arch, ext)."""
stem, ext = name, ""
for candidate in EXT_FORMAT:
if stem.endswith(candidate):
stem, ext = stem[: -len(candidate)], candidate
break
parts = stem.rsplit("_", 3)
if len(parts) != 4:
fail(f"`{name}` 不符合 `<名字>_<版本>_<平台>_<架构>` 命名(见 make_artifacts.asset_stem)")
product, tag_version, platform_name, arch_name = parts
if tag_version != version:
fail(f"`{name}` 里的版本是 {tag_version},清单要的是 {version}(tag 与 Cargo.toml 漂了?)")
if (platform_name, arch_name) not in {
(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()
}:
fail(f"`{name}` 的平台/架构 {platform_name}/{arch_name} 不在发布矩阵里")
return product, platform_name, arch_name, ext
def build_artifacts(dist: Path, version: str, base_url: str) -> list[dict[str, object]]:
"""Launcher artifacts from `SHA256SUMS.txt`, in matrix order (= priority order)."""
sums = read_sums(dist)
by_slot: dict[tuple[str, str], dict[str, object]] = {}
for name, digest in sums.items():
product, platform_name, arch_name, ext = split_asset(name, version)
if product != make_artifacts.LAUNCHER_ASSET:
continue # `ull` 不进清单,理由见模块注释
fmt = EXT_FORMAT.get(ext)
if fmt is None:
fail(f"`{name}` 没有可识别的扩展名,无法判定包形态(format)")
path = dist / name
if not path.is_file():
fail(f"{make_artifacts.SUMS_NAME} 提到 {name},但 {path} 不存在")
size = path.stat().st_size
if size == 0:
fail(f"{path} 是 0 字节:这类产物只可能是打包漏了")
url = f"{base_url.rstrip('/')}/{name}" if base_url else name
if (platform_name, arch_name) in by_slot:
fail(f"{platform_name}/{arch_name} 出现两个启动器产物,清单无法确定该用哪一个")
by_slot[(platform_name, arch_name)] = {
"target": f"{PLATFORM_TARGET[platform_name]}-{arch_name}",
"format": fmt,
"url": url,
"size": size,
"sha256": digest,
}
order = [(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()]
artifacts_out = [by_slot[key] for key in order if key in by_slot]
if not artifacts_out:
fail(f"{make_artifacts.SUMS_NAME} 里没有任何 `{make_artifacts.LAUNCHER_ASSET}_*` 产物,清单会是空的")
return artifacts_out
def build_manifest(
dist: Path,
version: str,
channel: str,
*,
base_url: str = "",
min_supported: str | None = None,
notes: str | None = None,
released_at: str | None = None,
force: bool = False,
) -> dict[str, object]:
"""Assemble the manifest document (the shape `ReleaseManifest` deserializes)."""
if channel not in CHANNELS:
fail(f"未知渠道 `{channel}`(只认 {' / '.join(CHANNELS)})")
release: dict[str, object] = {"version": version, "channel": channel}
if released_at:
release["released_at"] = released_at
if notes:
release["notes"] = notes
release["force"] = force
if min_supported:
release["min_supported"] = normalize_version(min_supported)
release["artifacts"] = build_artifacts(dist, version, base_url)
return {"channel": channel, "releases": [release]}
def render(doc: dict[str, object]) -> str:
"""Deterministic JSON: same facts + same args ⇒ byte-identical file (golden-testable)."""
return json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
# ── 严格解析镜像(DTO 的看门规则)───────────────────────────────────────────────────────
# `manifest.rs` 用 `deny_unknown_fields` + 逐字段校验;这里是它的 Python 镜像,只为了在没有
# Rust 的机器上(本地打包、CI 发布步)也能当场炸掉拼错的字段。字段/取值的权威永远是 .rs。
def check(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def check_keys(where: str, obj: object, allowed: set[str], required: set[str]) -> dict:
check(isinstance(obj, dict), f"{where} 必须是对象")
unknown = set(obj) - allowed
check(not unknown, f"{where} 出现未知字段 {sorted(unknown)}(DTO 是 deny_unknown_fields)")
missing = required - set(obj)
check(not missing, f"{where} 缺字段 {sorted(missing)}")
return obj
def strict_parse(doc: object) -> None:
"""Raise `ValueError` on anything `ReleaseManifest::from_json` would reject."""
top = check_keys("清单", doc, {"channel", "releases"}, {"channel"})
check(top["channel"] in CHANNELS, f"清单渠道 `{top['channel']}` 无法识别")
releases = top.get("releases", [])
check(isinstance(releases, list), "releases 必须是数组")
seen: list[str] = []
for index, raw in enumerate(releases):
where = f"releases[{index}]"
release = check_keys(
where,
raw,
{"version", "channel", "released_at", "notes", "force", "min_supported", "artifacts"},
{"version"},
)
version = release["version"]
check(isinstance(version, str) and semver_ok(version), f"{where}.version 不是合法 semver")
check(version not in seen, f"{where}.version {version} 在清单里重复出现")
seen.append(version)
if "channel" in release:
check(release["channel"] in CHANNELS, f"{where}.channel 无法识别")
for key in ("released_at", "notes"):
check(release.get(key) is None or isinstance(release.get(key), str), f"{where}.{key} 必须是字符串")
check(isinstance(release.get("force", False), bool), f"{where}.force 必须是布尔")
if release.get("min_supported") is not None:
check(semver_ok(str(release["min_supported"])), f"{where}.min_supported 不是合法 semver")
artifacts_list = release.get("artifacts", [])
check(isinstance(artifacts_list, list), f"{where}.artifacts 必须是数组")
for art_index, raw_art in enumerate(artifacts_list):
art_where = f"{where}.artifacts[{art_index}]"
art = check_keys(
art_where,
raw_art,
{"target", "format", "url", "size", "sha256", "sha1"},
{"target", "format", "url", "size"},
)
os_part, _, arch_part = str(art["target"]).strip().partition("-")
check(
bool(arch_part) and os_part.lower() in TARGET_OS and arch_part.lower() in TARGET_ARCH,
f"{art_where}.target `{art['target']}` 无法识别(形如 win-x64)",
)
check(str(art["format"]).lower() in FORMATS, f"{art_where}.format 无法识别")
check(isinstance(art["url"], str) and art["url"].strip(), f"{art_where}.url 为空")
size = art["size"]
check(isinstance(size, int) and not isinstance(size, bool) and size > 0, f"{art_where}.size 必须 > 0")
sha256, sha1 = art.get("sha256"), art.get("sha1")
check(sha256 is not None or sha1 is not None, f"{art_where} 既没有 sha256 也没有 sha1")
digest_hex = str(sha256 if sha256 is not None else sha1)
expect = 64 if sha256 is not None else 40
check(
len(digest_hex) == expect and all(c in "0123456789abcdefABCDEF" for c in digest_hex),
f"{art_where} 的摘要应为 {expect} 位十六进制",
)
def self_test() -> int:
"""Golden 对拍(`.gitattributes` 的 `eol=lf` 保证跨平台同字节)+ 严格解析正负例:证明写出来的东西能被 DTO 吃下。"""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
dist = Path(tmp)
version = "9.9.9"
# 两种启动器形态(裸 exe / 归档)+ 一个 CLI 资产:既覆盖 target/format 映射,
# 也钉住「`ull` 不进清单」这条决定。
payloads = {
f"{make_artifacts.LAUNCHER_ASSET}_{version}_windows_x64.exe": b"gui payload\n",
f"{make_artifacts.LAUNCHER_ASSET}_{version}_linux_x64.tar.gz": b"tar payload\n",
f"{make_artifacts.CLI_BIN}_{version}_windows_x64.exe": b"cli payload\n",
}
for name, payload in payloads.items():
(dist / name).write_bytes(payload)
(dist / make_artifacts.SUMS_NAME).write_text(
"".join(f"{make_artifacts.sha256(dist / name)} {name}\n" for name in payloads),
encoding="utf-8",
newline="\n",
)
doc = build_manifest(
dist,
version,
"stable",
min_supported="9.0.0",
notes="golden 样例(自验用,不是真实发布)",
released_at="2026-01-02T03:04:05Z",
)
text = render(doc)
if "--write-golden" in sys.argv:
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
GOLDEN.write_text(text, encoding="utf-8", newline="\n")
print(f"golden: {GOLDEN} 已重写")
expected = GOLDEN.read_text(encoding="utf-8")
if text != expected:
print("--- generated ---", text, "--- golden ---", expected, sep="\n", file=sys.stderr)
fail("生成的清单与 golden 不一致(改了口径就 --write-golden 重出并对 diff)")
print(f"golden: 与 {GOLDEN.name} 逐字节一致")
strict_parse(doc)
print("strict: 生成物通过严格解析镜像(deny_unknown_fields + 逐字段校验)")
# 负例:只测「顺利通过」等于没测——每条篡改都必须被拒。
for label, mutate in (
("未知字段", lambda d: d.update(extra=1)),
("未知版本字段", lambda d: d["releases"][0].update(extra=1)),
("未知产物字段", lambda d: d["releases"][0]["artifacts"][0].update(extra=1)),
("缺 sha256", lambda d: d["releases"][0]["artifacts"][0].pop("sha256")),
("摘要长度错", lambda d: d["releases"][0]["artifacts"][0].update(sha256="deadbeef")),
("size 为 0", lambda d: d["releases"][0]["artifacts"][0].update(size=0)),
("size 是字符串", lambda d: d["releases"][0]["artifacts"][0].update(size="12")),
("未知 target", lambda d: d["releases"][0]["artifacts"][0].update(target="solaris-x64")),
("未知 format", lambda d: d["releases"][0]["artifacts"][0].update(format="msix")),
("空 url", lambda d: d["releases"][0]["artifacts"][0].update(url=" ")),
("版本重复", lambda d: d["releases"].append(copy.deepcopy(d["releases"][0]))),
("版本号不合法", lambda d: d["releases"][0].update(version="1.0")),
("渠道不认识", lambda d: d.update(channel="canary")),
("缺顶层 channel", lambda d: d.pop("channel")),
):
tampered = copy.deepcopy(doc)
mutate(tampered)
try:
strict_parse(tampered)
except ValueError:
print(f"strict: 负例「{label}」被正确拒绝")
continue
fail(f"严格解析放过了负例「{label}」——这条校验形同虚设")
print("self-test: ok")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dist", default="dist", help="directory holding the assets + SHA256SUMS.txt (default: dist/)")
parser.add_argument("--version", help="release version, e.g. v0.2.2 (must match the asset file names)")
parser.add_argument("--channel", default="stable", choices=CHANNELS, help="manifest channel (D50: stable only for now)")
parser.add_argument("--out", help=f"output path (default: <dist>/{MANIFEST_NAME})")
parser.add_argument("--asset-base-url", default="", help="prefix for artifact urls; omit for bare file names")
parser.add_argument("--min-supported", help="lowest version still supported (omitted when not given)")
parser.add_argument("--notes", help="plain-text release notes for the update prompt")
parser.add_argument("--notes-file", help="read --notes from this file")
parser.add_argument("--released-at", help="RFC3339 timestamp (display only)")
parser.add_argument("--force", action="store_true", help="mark this release as force-update")
parser.add_argument("--self-test", action="store_true", help="golden + strict-parse self check (no files written)")
parser.add_argument("--write-golden", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.self_test:
return self_test()
if not args.version:
parser.error("--version is required (it must match the asset file names)")
dist = Path(args.dist)
if not dist.is_absolute():
dist = make_artifacts.REPO_ROOT / dist
notes = args.notes
if args.notes_file:
notes = (make_artifacts.REPO_ROOT / args.notes_file).read_text(encoding="utf-8").strip()
doc = build_manifest(
dist,
normalize_version(args.version),
args.channel,
base_url=args.asset_base_url,
min_supported=args.min_supported,
notes=notes,
released_at=args.released_at,
force=args.force,
)
text = render(doc)
strict_parse(doc)
out = Path(args.out) if args.out else dist / MANIFEST_NAME
if not out.is_absolute():
out = make_artifacts.REPO_ROOT / out
out.write_text(text, encoding="utf-8", newline="\n")
release = doc["releases"][0]
artifacts_list = release["artifacts"]
print(f"manifest: {out.name} channel={doc['channel']} version={release['version']} artifacts={len(artifacts_list)}")
for item in artifacts_list:
print(f" {item['target']:<14} {item['format']:<13} {item['size']:>10} B sha256={str(item['sha256'])[:12]} {item['url']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9 -7
View File
@@ -100,14 +100,16 @@ Get-FileHash .\unlucky-launcher_{{VERSION}}_windows_x64.exe -Algorithm SHA256
## Known limitations of this build ## Known limitations of this build
- Microsoft (MSA) sign-in needs an Azure public client id, and release binaries do not carry one: set - Microsoft (MSA) sign-in works out of the box: this build carries the Azure public client id it needs
`ULL_MSA_CLIENT_ID=<id>` before launching, or build with that variable set. Without it sign-in stops (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
before any network call, and the GUI banner says so plainly: the title "Microsoft sign-in is not before any network call, and the GUI banner says so plainly: the title "Microsoft sign-in is not
configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart the configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart) and
launcher — a running process does not pick up a new variable) and `Code: msa_not_configured`, with `Code: msa_not_configured`, with no Retry button. Every other sign-in failure keeps the generic
no Retry button. Every other sign-in failure keeps the generic banner plus its own `Code:` line, banner plus its own `Code:` line, with the full reason only in the launcher log. `ull accounts add
with the full reason only in the launcher log. `ull accounts add msa` prints the same fix on the msa` prints the same fix on the command line. Offline profiles and authlib-injector servers need no
command line. Offline profiles and authlib-injector servers need no id. id.
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first - Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first
launch. The checksums above are the integrity reference for now. launch. The checksums above are the integrity reference for now.
- When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock - When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock
+29
View File
@@ -0,0 +1,29 @@
{
"channel": "stable",
"releases": [
{
"version": "9.9.9",
"channel": "stable",
"released_at": "2026-01-02T03:04:05Z",
"notes": "golden 样例(自验用,不是真实发布)",
"force": false,
"min_supported": "9.0.0",
"artifacts": [
{
"target": "win-x64",
"format": "portable-exe",
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
"size": 12,
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
},
{
"target": "linux-x64",
"format": "portable-zip",
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
"size": 12,
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
}
]
}
]
}