Unlucky Launcher v0.2.2
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release / 选槽位(默认两个 Windows) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release / 选槽位(默认两个 Windows) (push) Waiting to run
This commit is contained in:
commit
1105a86448
1128 files changed
+217164
No files matched your search
@@ -0,0 +1,98 @@
|
||||
# 打包与发布工程(packaging/)
|
||||
|
||||
单一真相源:**单文件产物矩阵 + 发布契约**。`.github/workflows/release.yml` 按本文实现,
|
||||
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
|
||||
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
|
||||
|
||||
## 索引:§1–§12 各在哪份文件(M145 拆分)
|
||||
|
||||
拆分原因:本文件与 `.github/workflows/release.yml` 都已是 400 行整,下一处改动必撞全仓行数红线(`guards.yml` 的
|
||||
`file-size`,单文件 ≤400 行)。按主题分开后每份都留出余量,**内容一字未改、只搬家**;**节号沿用原编号**,
|
||||
所以全仓那些「`packaging/README.md` §4 / §7.4 / §9」式引用不必改动 —— 按下表找承载文件即可。
|
||||
|
||||
| 节 | 主题 | 承载文件 |
|
||||
|----|------|---------|
|
||||
| §1 | 产物矩阵 | `packaging/docs/artifacts-and-version.md` |
|
||||
| §2 | 版本来源(唯一真相源) | `packaging/docs/artifacts-and-version.md` |
|
||||
| §3 | 怎么构建(本地 / CI) | `packaging/docs/build-and-ci.md` |
|
||||
| §4 | 每平台运行时依赖 | `packaging/docs/runtime-deps.md` |
|
||||
| §5 | 软渲染兜底 | `packaging/docs/runtime-deps.md` |
|
||||
| §6 | Cargo 构建档位 | `packaging/docs/profiles-and-measurements.md` |
|
||||
| §7 | 实测(本机,不是估算) | `packaging/docs/profiles-and-measurements.md` |
|
||||
| §8 | 发行合规 | `packaging/docs/compliance-support-boundaries.md` |
|
||||
| §9 | 支持矩阵(对外口径) | `packaging/docs/compliance-support-boundaries.md` |
|
||||
| §10 | 已知边界 / 后续 | `packaging/docs/compliance-support-boundaries.md` |
|
||||
| §11 | CI 运行时冒烟(Linux 真渲染一帧) | 本文下方(细节在 `packaging/ci-runtime-smoke.md`) |
|
||||
| §12 | 文件清单 | 本文下方 |
|
||||
|
||||
本文件仍是**契约的唯一真相源入口**:`.github/workflows/release.yml`(及拆出的 `release-build.yml` /
|
||||
`release-publish.yml`)的注释、`make_artifacts.py` 的说明、根 `README.md` / `README.zh-CN.md` 与
|
||||
`packaging/release-notes.md` 都按「`packaging/README.md` §N」引用。改契约时先改上表对应的承载文件,
|
||||
再看一眼 §12 的行数预算 —— 任何一份超过 400 行都要继续按主题拆。
|
||||
|
||||
## 11. CI 运行时冒烟(Linux 真渲染一帧)
|
||||
|
||||
**细节与依据在 [`packaging/ci-runtime-smoke.md`](ci-runtime-smoke.md)**(本节再增长也往那里写,两个文件都必须 ≤400 行)。
|
||||
|
||||
`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**证据:装 `xvfb` + 软渲染依赖 +
|
||||
字体,用 `xvfb-run` 起 GUI 进程渲出一帧 PNG,再对**像素本身**断言(退出码 0 / 尺寸 / 非空白,artifact
|
||||
`runtime-smoke-screenshot`)。
|
||||
|
||||
要点三条:
|
||||
|
||||
- 只看退出码不够:「合法 PNG + 退出码 0」不等于画面有内容;「完全没字体」与「非软渲染」都是**硬失败**(panic /
|
||||
报错,不产生 PNG;review 实测)。
|
||||
- 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,**不证明界面元素齐全,
|
||||
更不证明视觉正确**——视觉靠人眼看 artifact。
|
||||
- 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。
|
||||
|
||||
**本地复跑**:`bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke`——脚本逐步复刻这个 job(同样的 apt 依赖、
|
||||
`--bin unlucky-launcher-iced`、同样的 `xvfb-run … --software-renderer --screenshot --data-dir`);输出目录别写 `/tmp`,
|
||||
Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)。
|
||||
**M131 真跑过一次**(本机 Docker / Debian 12 容器 / 2026-11-05):`exit=0`,截图 1180×760、`[ok] 截图非空白`。
|
||||
|
||||
## 12. 文件清单
|
||||
|
||||
| 文件 | 作用 | 行数上限 |
|
||||
|------|------|---------|
|
||||
| `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/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
||||
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
|
||||
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
|
||||
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
||||
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
||||
| `.github/workflows/release-build.yml` | `workflow_call`:单槽位构建(体检 / cargo / 打包 / 上传 artifact) | ≤400 |
|
||||
| `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 起首个适用。
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。
|
||||
|
||||
为什么需要它:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
|
||||
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
|
||||
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
|
||||
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
|
||||
FemtoVG backend`(退出码 1)。其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计。
|
||||
|
||||
三个判据(阈值依据见 `--selftest` 与 packaging/ci-runtime-smoke.md):
|
||||
|
||||
- `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。
|
||||
- `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。
|
||||
- `edge_ratio`:**强边缘像素占比**——水平相邻像素对里亮度差 ≥8 的比例。纯色图 = 0;真实 UI 的文字与
|
||||
边框占千分之几到百分之几。用它而不是「平均亮度差」:空数据目录的主界面 57% 是同一片底色,
|
||||
平均差会被大片平面稀释(实测只有 0.4),而强边缘占比只数真正的边界,跨主题/跨字体都稳定。
|
||||
|
||||
只用 Python 标准库:`zlib` 解压 PNG 的 deflate 流,自己反过滤(filter 0~4)。
|
||||
不引 Pillow 是因为 CI 不该为了「看一张图」多装一套图像栈(也符合项目的依赖白名单纪律)。
|
||||
|
||||
用法::
|
||||
|
||||
python packaging/check_screenshot.py shot.png # 判定,退出码 0/1
|
||||
python packaging/check_screenshot.py shot.png --json # 机器可读(CI 日志用)
|
||||
python packaging/check_screenshot.py --selftest # 打印阈值依据的两组数值
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
# 判定阈值。两侧数值实测(M62 本机 Docker + xvfb + Windows 历史截图):
|
||||
# - 真实帧:唯一色 342~2352、亮度σ 19.45~27.46、强边缘占比 0.0076~0.0433(最弱是 Linux 空数据主界面);
|
||||
# - 人为纯色帧:1 / 0.0 / 0.0。
|
||||
# 阈值取在两者之间、贴近真实下限的 1/5~1/8:换主题/换字体/换窗口尺寸都不会误报,空白帧一定红。
|
||||
DEFAULT_MIN_UNIQUE = 64
|
||||
DEFAULT_MIN_STDDEV = 3.0
|
||||
DEFAULT_MIN_EDGE_RATIO = 0.001
|
||||
|
||||
# 「强边缘」的亮度差门槛:≥8/255 肉眼即视为一条边界;抗锯齿造成的 1~2 级抖动不算。
|
||||
EDGE_LUMA_DELTA = 8
|
||||
|
||||
# PNG 颜色类型 → 每像素通道数(调色板 3 走索引,需要 PLTE,本脚本不解)
|
||||
CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4}
|
||||
|
||||
|
||||
class PngError(Exception):
|
||||
"""PNG 结构不受支持或损坏。"""
|
||||
|
||||
|
||||
def _iter_chunks(data: bytes):
|
||||
"""按 PNG 规范切块:长度(4) + 类型(4) + 数据 + CRC(4)。"""
|
||||
offset = len(PNG_SIGNATURE)
|
||||
while offset + 12 <= len(data):
|
||||
(length,) = struct.unpack(">I", data[offset : offset + 4])
|
||||
yield data[offset + 4 : offset + 8], data[offset + 8 : offset + 8 + length]
|
||||
offset += 12 + length
|
||||
|
||||
|
||||
def _unfilter(kind: int, line: bytearray, prev: bytearray, bpp: int) -> None:
|
||||
"""就地把一条扫描线反过滤(PNG 规范 §9.2 的五个过滤类型)。"""
|
||||
if kind == 0:
|
||||
return
|
||||
if kind == 1: # Sub:加上左边
|
||||
for index in range(bpp, len(line)):
|
||||
line[index] = (line[index] + line[index - bpp]) & 0xFF
|
||||
elif kind == 2: # Up:加上正上方
|
||||
for index in range(len(line)):
|
||||
line[index] = (line[index] + prev[index]) & 0xFF
|
||||
elif kind == 3: # Average:加上 (左 + 上) // 2
|
||||
for index in range(len(line)):
|
||||
left = line[index - bpp] if index >= bpp else 0
|
||||
line[index] = (line[index] + ((left + prev[index]) >> 1)) & 0xFF
|
||||
elif kind == 4: # Paeth:加上 Paeth 预测子
|
||||
for index in range(len(line)):
|
||||
left = line[index - bpp] if index >= bpp else 0
|
||||
up = prev[index]
|
||||
upper_left = prev[index - bpp] if index >= bpp else 0
|
||||
estimate = left + up - upper_left
|
||||
dist_left = abs(estimate - left)
|
||||
dist_up = abs(estimate - up)
|
||||
dist_corner = abs(estimate - upper_left)
|
||||
if dist_left <= dist_up and dist_left <= dist_corner:
|
||||
predictor = left
|
||||
elif dist_up <= dist_corner:
|
||||
predictor = up
|
||||
else:
|
||||
predictor = upper_left
|
||||
line[index] = (line[index] + predictor) & 0xFF
|
||||
else:
|
||||
raise PngError(f"未知的行过滤类型 {kind}")
|
||||
|
||||
|
||||
def decode_png(path: Path) -> tuple[int, int, list[bytearray], int]:
|
||||
"""读 8bit 非隔行 PNG,返回 (宽, 高, 每行原始字节, 通道数)。"""
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(PNG_SIGNATURE):
|
||||
raise PngError(f"{path} 不是 PNG(签名不符,可能是 0 字节文件或半截写入)")
|
||||
|
||||
width = height = depth = color_type = interlace = None
|
||||
compressed = bytearray()
|
||||
for kind, body in _iter_chunks(data):
|
||||
if kind == b"IHDR":
|
||||
width, height, depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
compressed.extend(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
|
||||
if width is None or height is None:
|
||||
raise PngError("缺 IHDR 块")
|
||||
if interlace:
|
||||
raise PngError("不支持隔行(Adam7)PNG")
|
||||
if depth != 8:
|
||||
raise PngError(f"只支持 8bit PNG,实际 {depth}bit")
|
||||
channels = CHANNELS_BY_COLOR_TYPE.get(color_type)
|
||||
if channels is None:
|
||||
raise PngError(f"不支持的颜色类型 {color_type}(调色板 PNG 请先转成 RGB/RGBA)")
|
||||
|
||||
raw = zlib.decompress(bytes(compressed))
|
||||
stride = width * channels
|
||||
if len(raw) < (stride + 1) * height:
|
||||
raise PngError(f"像素数据不足:期望 {(stride + 1) * height} 字节,实际 {len(raw)}")
|
||||
|
||||
rows: list[bytearray] = []
|
||||
previous = bytearray(stride)
|
||||
offset = 0
|
||||
for _ in range(height):
|
||||
kind = raw[offset]
|
||||
offset += 1
|
||||
line = bytearray(raw[offset : offset + stride])
|
||||
offset += stride
|
||||
_unfilter(kind, line, previous, channels)
|
||||
rows.append(line)
|
||||
previous = line
|
||||
return width, height, rows, channels
|
||||
|
||||
|
||||
def measure(width: int, height: int, rows: list[bytearray], channels: int) -> dict:
|
||||
"""统计「这帧图里有没有信息」的三项指标。"""
|
||||
colors: Counter[int] = Counter()
|
||||
luma_sum = 0
|
||||
luma_square_sum = 0
|
||||
strong_edges = 0
|
||||
pair_count = 0
|
||||
|
||||
for row in rows:
|
||||
previous_luma = None
|
||||
for x in range(width):
|
||||
base = x * channels
|
||||
if channels >= 3:
|
||||
red, green, blue = row[base], row[base + 1], row[base + 2]
|
||||
else:
|
||||
red = green = blue = row[base]
|
||||
colors[(red << 16) | (green << 8) | blue] += 1
|
||||
# 整数亮度(ITU-R BT.601 系数),避免浮点误差影响阈值比较
|
||||
luma = (red * 299 + green * 587 + blue * 114) // 1000
|
||||
luma_sum += luma
|
||||
luma_square_sum += luma * luma
|
||||
if previous_luma is not None:
|
||||
pair_count += 1
|
||||
if abs(luma - previous_luma) >= EDGE_LUMA_DELTA:
|
||||
strong_edges += 1
|
||||
previous_luma = luma
|
||||
|
||||
total = width * height
|
||||
mean = luma_sum / total if total else 0.0
|
||||
variance = max(luma_square_sum / total - mean * mean, 0.0) if total else 0.0
|
||||
top_color, top_count = colors.most_common(1)[0] if colors else (0, 0)
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"pixels": total,
|
||||
"unique_colors": len(colors),
|
||||
"top_color": f"#{top_color:06x}",
|
||||
"top_color_share": round(top_count / total, 4) if total else 0.0,
|
||||
"luma_mean": round(mean, 2),
|
||||
"luma_stddev": round(math.sqrt(variance), 2),
|
||||
"edge_ratio": round(strong_edges / pair_count, 5) if pair_count else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def verdict(
|
||||
stats: dict, min_unique: int, min_stddev: float, min_edge_ratio: float
|
||||
) -> list[str]:
|
||||
"""把指标折成「失败原因清单」;空清单 = 通过。"""
|
||||
problems = []
|
||||
if stats["pixels"] <= 0:
|
||||
problems.append("图像尺寸为 0(宽或高是 0)")
|
||||
return problems
|
||||
if stats["unique_colors"] < min_unique:
|
||||
problems.append(
|
||||
f"唯一色数 {stats['unique_colors']} < 阈值 {min_unique}(像纯色/空白帧)"
|
||||
)
|
||||
if stats["luma_stddev"] < min_stddev:
|
||||
problems.append(
|
||||
f"亮度标准差 {stats['luma_stddev']} < 阈值 {min_stddev}(整帧明暗无层次)"
|
||||
)
|
||||
if stats["edge_ratio"] < min_edge_ratio:
|
||||
problems.append(
|
||||
f"强边缘占比 {stats['edge_ratio']} < 阈值 {min_edge_ratio}(没有边界/文字的迹象)"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def encode_png(width: int, height: int, rgb_row) -> bytes:
|
||||
"""最小 PNG 编码器(8bit RGB、filter 0、zlib 压缩)——只给 `--selftest` 造样图。"""
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0)
|
||||
raw.extend(rgb_row(y))
|
||||
out = bytearray(PNG_SIGNATURE)
|
||||
for kind, body in (
|
||||
(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)),
|
||||
(b"IDAT", zlib.compress(bytes(raw), 6)),
|
||||
(b"IEND", b""),
|
||||
):
|
||||
out.extend(struct.pack(">I", len(body)))
|
||||
out.extend(kind)
|
||||
out.extend(body)
|
||||
out.extend(struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def selftest() -> int:
|
||||
"""造两张对照样图跑一遍,打印阈值依据的两组数值。
|
||||
|
||||
- 全白 / 全黑:模拟「窗口起来了但一个像素都没画」的空白帧;
|
||||
- 一像素黑边框 + 中部一条横线:最弱的「有内容」下限(远弱于真实 UI)。
|
||||
"""
|
||||
width, height = 1180, 760
|
||||
cases = {
|
||||
"flat-white": lambda y: b"\xff" * (width * 3),
|
||||
"flat-black": lambda y: b"\x00" * (width * 3),
|
||||
"border-line": lambda y: b"".join(
|
||||
b"\x00\x00\x00"
|
||||
if (y in (0, height - 1) or x in (0, width - 1) or y == height // 2)
|
||||
else b"\xff\xff\xff"
|
||||
for x in range(width)
|
||||
),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, describe in cases.items():
|
||||
path = Path(tmp) / f"{name}.png"
|
||||
path.write_bytes(encode_png(width, height, describe))
|
||||
stats = measure(*decode_png(path))
|
||||
failed = verdict(
|
||||
stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"case": name,
|
||||
"unique_colors": stats["unique_colors"],
|
||||
"luma_stddev": stats["luma_stddev"],
|
||||
"edge_ratio": stats["edge_ratio"],
|
||||
"verdict": "fail" if failed else "pass",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="截图非空白判定(CI 运行时冒烟门禁)")
|
||||
parser.add_argument("png", nargs="?", type=Path, help="待判定的 PNG 路径")
|
||||
parser.add_argument("--json", action="store_true", help="以 JSON 输出统计结果")
|
||||
parser.add_argument("--min-unique", type=int, default=DEFAULT_MIN_UNIQUE, help="唯一色数下限")
|
||||
parser.add_argument("--min-stddev", type=float, default=DEFAULT_MIN_STDDEV, help="亮度标准差下限")
|
||||
parser.add_argument(
|
||||
"--min-edge-ratio", type=float, default=DEFAULT_MIN_EDGE_RATIO, help="强边缘占比下限"
|
||||
)
|
||||
parser.add_argument("--selftest", action="store_true", help="打印阈值依据的对照数值后退出")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.selftest:
|
||||
return selftest()
|
||||
if args.png is None:
|
||||
parser.error("需要给出 PNG 路径(或 --selftest)")
|
||||
|
||||
try:
|
||||
stats = measure(*decode_png(args.png))
|
||||
except (PngError, OSError, zlib.error) as error:
|
||||
print(f"[fail] 读不出截图 {args.png}: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
problems = verdict(stats, args.min_unique, args.min_stddev, args.min_edge_ratio)
|
||||
if args.json:
|
||||
print(json.dumps({**stats, "problems": problems}, ensure_ascii=False))
|
||||
else:
|
||||
print(
|
||||
f"{args.png}: {stats['width']}x{stats['height']} "
|
||||
f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} "
|
||||
f"强边缘 {stats['edge_ratio']} 主色 {stats['top_color']}"
|
||||
f"({stats['top_color_share'] * 100:.1f}%)"
|
||||
)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"[fail] {problem}", file=sys.stderr)
|
||||
return 1
|
||||
print("[ok] 截图非空白")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,78 @@
|
||||
# CI 运行时冒烟(Linux 真渲染一帧)
|
||||
|
||||
`packaging/README.md` §11 是本节的索引;细节在这里。为什么拆:本节要写阈值依据、失效模式与复跑口径,
|
||||
塞回 README 会撞 400 行硬门禁——**两文件都必须 ≤400 行**,任一边再增长就再拆一节。
|
||||
|
||||
「编得过」≠「跑得起来」。`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**
|
||||
运行证据:装 `xvfb`(虚拟 X 服务器)+ 软渲染依赖 + 字体,用 `xvfb-run` 把 GUI 进程拉起来,走既有的
|
||||
进程内截图通道(`--software-renderer --screenshot <png> --data-dir <tmp>`)渲出一帧 PNG,再对**像素本身**断言。
|
||||
|
||||
## 1. 三道断言
|
||||
|
||||
`packaging/check_screenshot.py` 退出码 0 才算过;artifact 名 `runtime-smoke-screenshot`。
|
||||
|
||||
1. 进程退出码 0(截图通道失败会以非 0 退出,不留半张图);
|
||||
2. PNG 存在、可解析、宽高 > 0(0 字节 / 非 PNG / 路径不存在都是硬失败,脚本 exit 1);
|
||||
3. **非空白**:唯一色数 / 亮度标准差 / 强边缘占比三项同时过阈值。
|
||||
|
||||
为什么不能只看退出码:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
|
||||
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
|
||||
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
|
||||
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
|
||||
FemtoVG backend`(退出码 1)。这两条由 M62 review 实测复现(本 mission 未自己跑这两种破坏性实验)。
|
||||
其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计,不宣称已复现。
|
||||
|
||||
## 2. 阈值与依据
|
||||
|
||||
`python packaging/check_screenshot.py --selftest` 可复现后三行:
|
||||
|
||||
| 样本 | 唯一色数 | 亮度σ | 强边缘占比 | 判定 |
|
||||
|------|---------|-------|-----------|------|
|
||||
| 本机 Docker 真截图(xvfb + 软渲染,1180x760,空数据目录) | 342 | 19.45 | 0.00761 | pass |
|
||||
| Windows 历史截图(M45/M52,同一界面深浅两套,1180x760) | 529~699 | 20.29~27.46 | 0.0186~0.0433 | pass |
|
||||
| 人为纯白 / 纯黑(空白帧) | 1 | 0.0 | 0.0 | fail |
|
||||
| 最弱内容(1 像素黑边框 + 一条中线) | 2 | 19.09 | 0.00169 | fail(卡唯一色数) |
|
||||
| **阈值** | ≥64 | ≥3.0 | ≥0.001 | — |
|
||||
|
||||
阈值留 **5~8 倍**余量(342/64 = 5.3×、19.45/3.0 = 6.5×、0.00761/0.001 = 7.6×):换主题/换字体不会误报,
|
||||
空白帧一定红。
|
||||
|
||||
第三项用**强边缘占比**(水平相邻像素亮度差 ≥8 的比例)而不是平均亮度差:空数据目录的主界面 57% 是同一片
|
||||
底色,平均差被稀释到 0.4,强边缘只数真正的边界,跨主题稳定(同一界面深浅两套差 2 倍,仍都过线)。
|
||||
|
||||
**这道闸门的下限 ≈「至少画出一条标题栏」**(review 实测切片):只画 1180×40 标题栏的切片(174 / 35.10 /
|
||||
0.02623)仍 pass,右下 400×300 的切片也 pass。所以它证明的是「场景被真实渲染过」,**不证明界面元素齐全、
|
||||
更不证明视觉正确**;视觉正确只能靠人眼看 artifact。
|
||||
|
||||
## 3. 覆盖哪个平台
|
||||
|
||||
只有 Linux x64(X11 + Xvfb + 软渲染)。
|
||||
|
||||
- **macOS**:`runtime-smoke-macos` 只在 `workflow_dispatch` 触发且 `continue-on-error: true`。官方 macOS
|
||||
runner 处于 LaunchDaemon 上下文、无登录 WindowServer 会话,winit 大概率建不了窗口;本机没有 macOS
|
||||
且 worker 禁 push,这条路径**尚无实测证据**,故不拿它卡 PR 门禁。首跑定去留:绿 → 删 `if` 与
|
||||
`continue-on-error` 升常驻;红 → 错误原文进 progress,按 D26 归入 iced 重写再规划。
|
||||
- **Windows**:不新增 job——`--software-renderer --screenshot` 已在本机验收里多次人眼验证(M45/M52)。
|
||||
|
||||
## 4. 本地复跑
|
||||
|
||||
```bash
|
||||
bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke
|
||||
```
|
||||
|
||||
用 Docker(`rust:1-bookworm` + TUNA apt/rustup 源)复刻同一套步骤,截图落在 `<输出目录>/runtime-smoke.png`。
|
||||
**输出目录别写 `/tmp/...`**:Git Bash 里 `/tmp` 是 MSYS 虚拟路径,`docker -v` 会把它当 Linux VM 内的路径,
|
||||
结果是「脚本说成功、宿主机什么都没有」。脚本对 `out_dir`/`repo_root` 统一做 `cygpath -m` 归一,并回显真实
|
||||
路径;没有 `cygpath` 的平台(Linux/macOS)按原样用。
|
||||
|
||||
诚实边界:本机容器是 Debian 12(glibc 2.36),官方 runner 是 Ubuntu 24.04(glibc 2.39),图形栈来源不同,
|
||||
容器绿只证明「这条通道能跑」,官方 runner 首跑仍要单独看。
|
||||
|
||||
## 5. 已知跳过项与迁移风险
|
||||
|
||||
**跳过项**(D26 收窄,本条只做「框架无关的最小运行时冒烟」):Wayland(`weston --headless`)、
|
||||
`--screenshot-page` 子页覆盖、gsettings 主题探测回退都不在本 job 内,随 iced 重写另行规划。
|
||||
|
||||
**迁移风险**:本 job 绑死 Slint 的截图通道(`Window::take_snapshot`)。**UI 迁 iced 后必须重新验证**——
|
||||
iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的等价通道,否则本 job 会静默退化成「能起进程」
|
||||
而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。
|
||||
@@ -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 触发流水线。
|
||||
@@ -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 走完全流程)。
|
||||
@@ -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`。
|
||||
@@ -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`)。
|
||||
@@ -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 才算绿)"
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地复跑 `.github/workflows/ci.yml` 的 `runtime-smoke` job:Linux + Xvfb + 软渲染截图。
|
||||
#
|
||||
# 为什么要有这条脚本:CI 的 runtime-smoke 在 GitHub 的 ubuntu-latest 上跑,
|
||||
# 但「改动这个 job 的人」需要在没有 Linux 桌面的开发机上先自证一遍——否则改的就是没人验过的 YAML。
|
||||
# 本脚本在 Docker 里复刻 job 的步骤(同样的 apt 依赖、同样的构建、同样的 xvfb-run 命令),
|
||||
# 并把截图与判定结果留在宿主目录,供人眼与 `check_screenshot.py` 复核。
|
||||
#
|
||||
# 用法:
|
||||
# bash packaging/linux-smoke-docker.sh [仓库根目录] [输出目录]
|
||||
# ULL_SMOKE_SKIP_BUILD=1 bash packaging/linux-smoke-docker.sh # 复用缓存的二进制
|
||||
#
|
||||
# 说明(诚实边界):
|
||||
# - 基础镜像 `rust:1-bookworm` 是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**。
|
||||
# 两者图形栈(X11/xvfb/Mesa 软渲染)来源不同,因此本脚本证明的是「Linux 上这条通道能真跑出图」,
|
||||
# 不能代替官方 runner 的首跑验证。
|
||||
# - 国内网络慢时可换 TUNA 镜像:脚本默认就用 TUNA 的 Debian 源(`ULL_APT_MIRROR` 可覆盖)。
|
||||
# - 受检 bin 是 iced 版 `unlucky-launcher-iced`,与 `ci.yml` 的 runtime-smoke 逐步对应(M118:Slint 版
|
||||
# bin/包 `unlucky-launcher`/`unlucky-app` 已随 `crates/app` 退出 workspace,改了这里要两边一起改)。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "${1:-.}" && pwd)
|
||||
out_dir=${2:-ull-smoke}
|
||||
image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm}
|
||||
cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg}
|
||||
target_volume=${ULL_CARGO_TARGET_VOLUME:-ull-linux-target}
|
||||
apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}
|
||||
rustup_mirror=${ULL_RUSTUP_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn/rustup}
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
out_dir=$(cd "$out_dir" && pwd)
|
||||
# Git Bash 里 /tmp、/d/x 都是 MSYS 虚拟路径:直接喂给 `docker -v` 会被当成 Linux VM 内的路径,
|
||||
# 结果「脚本说成功、宿主机什么都没有」。有 cygpath 就统一转成 Windows 混合路径(D:/x)并回显真实位置。
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
repo_root=$(cygpath -m "$repo_root")
|
||||
out_dir=$(cygpath -m "$out_dir")
|
||||
fi
|
||||
echo "== 仓库: $repo_root"
|
||||
echo "== 输出: $out_dir(截图 $out_dir/runtime-smoke.png)"
|
||||
|
||||
# 1) 构建镜像:rust 工具链 + xvfb + 图形栈编译依赖 + 字体(软渲染器要从系统字体库取字形)
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_SMOKE_REBUILD_IMAGE:-0}" = "1" ]; then
|
||||
context=$(mktemp -d)
|
||||
trap 'rm -rf "$context"' EXIT
|
||||
cat >"$context/Dockerfile" <<EOF
|
||||
FROM rust:1-bookworm
|
||||
# 工具链组件(rustfmt/clippy)在 rust-toolchain.toml 里是必备项,rustup 会去补齐;
|
||||
# 默认 dist 服务器在国内会挂成「rustc 0.6% CPU 睡 12 分钟」的假死(M62 实测),所以走 TUNA。
|
||||
ENV RUSTUP_DIST_SERVER=$rustup_mirror \\
|
||||
RUSTUP_UPDATE_ROOT=$rustup_mirror/rustup
|
||||
RUN sed -i 's|http://deb.debian.org|$apt_mirror|g' /etc/apt/sources.list.d/debian.sources \\
|
||||
&& apt-get update \\
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\
|
||||
xvfb xauth python3 \\
|
||||
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \\
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \\
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 \\
|
||||
fonts-dejavu-core fonts-liberation2 \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN rustup component add rustfmt clippy && rustup toolchain list
|
||||
WORKDIR /src
|
||||
EOF
|
||||
docker build -t "$image" "$context"
|
||||
fi
|
||||
|
||||
# 2) 跑容器:构建 → xvfb 截图 → 像素判定
|
||||
# 注意缓存卷要挂到 `registry` 子目录:直接挂 /usr/local/cargo 会把镜像里的 `bin/`(cargo 本体)盖掉,
|
||||
# 变成 `cargo: command not found`(M62 实测踩到)。
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$repo_root:/src" \
|
||||
-v "$cargo_volume:/usr/local/cargo/registry" \
|
||||
-v "$target_volume:/target" \
|
||||
-v "$out_dir:/out" \
|
||||
-w /src \
|
||||
-e CARGO_TARGET_DIR=/target \
|
||||
-e ULL_SMOKE_SKIP_BUILD="${ULL_SMOKE_SKIP_BUILD:-0}" \
|
||||
-e CARGO_TERM_COLOR=never \
|
||||
"$image" bash -euo pipefail -c '
|
||||
rustc --version && cargo --version && cat /etc/debian_version
|
||||
if [ "$ULL_SMOKE_SKIP_BUILD" != "1" ]; then
|
||||
# 共享热缓存卷可能喂陈旧产物(踩坑记录 N7):先清掉受检 crate 再编
|
||||
# 清不掉就算了(N7 兜底用);CI 里**没有**对应两行,别误抄进 ci.yml —— 那边每次都是干净 runner
|
||||
cargo clean -p unlucky-app-iced || true
|
||||
cargo build --bin unlucky-launcher-iced --locked -j 6
|
||||
fi
|
||||
mkdir -p /out/data
|
||||
set +e
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
/target/debug/unlucky-launcher-iced --software-renderer \
|
||||
--screenshot /out/runtime-smoke.png --data-dir /out/data
|
||||
code=$?
|
||||
set -e
|
||||
echo "exit=$code"
|
||||
python3 packaging/check_screenshot.py /out/runtime-smoke.png
|
||||
exit $code
|
||||
'
|
||||
|
||||
echo "== 截图与数据目录已留在 $out_dir"
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the single-file release artifacts for the current platform.
|
||||
|
||||
Release matrix (see `packaging/README.md` for the full contract):
|
||||
|
||||
Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip)
|
||||
Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, native arm64 runner)
|
||||
Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz
|
||||
Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz
|
||||
macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz
|
||||
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
|
||||
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
|
||||
all SHA256SUMS.txt
|
||||
all manifest.json (only with --manifest, see below)
|
||||
|
||||
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
|
||||
framework produced it. `asset_stem()` is the single source of truth for that.
|
||||
|
||||
`--version` is taken from the cargo workspace so a tag can never drift away from
|
||||
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
|
||||
The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which is
|
||||
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
|
||||
selling point) and the same three documents are attached to the Release next to it.
|
||||
|
||||
The CLI ships as its **own bare asset** on every platform rather than inside the tar.gz:
|
||||
the point of `ull` is a small single file you download and run on a machine with no
|
||||
display, and an archive it has to be extracted from adds a step for that user. The
|
||||
launcher is the archive because it needs the license texts beside it; the Release
|
||||
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
|
||||
interface — it is the version of it that links no graphics stack at all.
|
||||
|
||||
`--manifest` additionally writes `manifest.json` (the update manifest, D50) into the same
|
||||
output directory. The generation itself lives in `packaging/make_manifest.py`, invoked as a
|
||||
subprocess: this file stays the single source of truth for **naming** (`asset_stem()`), that
|
||||
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:
|
||||
cargo build --release --locked
|
||||
python3 packaging/make_artifacts.py [--version v0.2.0] [--out dist] [--no-smoke]
|
||||
# cross build (arm64 exe from an x64 host):
|
||||
python3 packaging/make_artifacts.py --slot windows-arm64 \\
|
||||
--build-dir target/aarch64-pc-windows-msvc/release
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
# M118(D26):GUI 的**构建产物**名是 iced 版的 `unlucky-launcher-iced`。旧值 `unlucky-launcher`
|
||||
# 是 `crates/app`(Slint 退役树)的 bin —— 那棵树已被根 Cargo.toml `exclude`,再也不产出它了。
|
||||
# **对外**文件名仍叫 `unlucky-launcher`:下载链接 / SHA256SUMS / README 都绑着这个名字,
|
||||
# 改名是「翻牌」那一步的事,不是打包这一步的事。所以刻意拆成两个常量:
|
||||
# 找文件用 LAUNCHER_BIN,落盘(tar 内文件名 / exe 资产名)用 LAUNCHER_ASSET。
|
||||
LAUNCHER_BIN = "unlucky-launcher-iced"
|
||||
LAUNCHER_ASSET = "unlucky-launcher"
|
||||
CLI_BIN = "ull"
|
||||
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
|
||||
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
|
||||
SUMS_NAME = "SHA256SUMS.txt"
|
||||
DEFAULT_BUILD_DIR = "target/release"
|
||||
|
||||
# 发布矩阵:槽位 -> (OS 家族, 架构, 产物形态)。键名即产物文件名里的槽位段,
|
||||
# 单一真相源是 packaging/README.md §1;未知槽位一律拒绝,不出"凑合能跑"的产物。
|
||||
SLOTS = {
|
||||
"windows-x64": ("windows", "x64", "exe"),
|
||||
"windows-arm64": ("windows", "arm64", "exe"),
|
||||
"linux-x64": ("linux", "x64", "tar"),
|
||||
"linux-arm64": ("linux", "arm64", "tar"),
|
||||
"macos-arm64": ("macos", "arm64", "tar"),
|
||||
"macos-x64": ("macos", "x64", "tar"),
|
||||
}
|
||||
# `sys.platform` / `platform.machine()` 的别名归一(aarch64 与 arm64 是同一架构的两种叫法)。
|
||||
OS_FAMILIES = (("win", "windows"), ("linux", "linux"), ("darwin", "macos"))
|
||||
MACHINE_ALIASES = {"amd64": "x64", "x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}
|
||||
# 可执行文件头里的机器码 -> 架构(PE / ELF / Mach-O)。
|
||||
IMAGE_ARCH = {
|
||||
("pe", 0x8664): "x64",
|
||||
("pe", 0xAA64): "arm64",
|
||||
("elf", 0x3E): "x64",
|
||||
("elf", 0xB7): "arm64",
|
||||
("macho", 0x01000007): "x64",
|
||||
("macho", 0x0100000C): "arm64",
|
||||
}
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
print(f"error: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def workspace_version() -> str:
|
||||
"""Read `[workspace.package] version` through cargo itself (single source of truth)."""
|
||||
try:
|
||||
raw = subprocess.run(
|
||||
["cargo", "metadata", "--no-deps", "--format-version", "1"],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# Windows 默认 GBK:cargo 的 package description 是中文,不显式指定会解码失败。
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
fail(f"cargo metadata failed: {exc}")
|
||||
packages = json.loads(raw)["packages"]
|
||||
versions = {pkg["version"] for pkg in packages}
|
||||
if len(versions) != 1:
|
||||
fail(f"workspace members disagree on version: {sorted(versions)}")
|
||||
return versions.pop()
|
||||
|
||||
|
||||
def host_slot(os_name: str | None = None, machine: str | None = None) -> str:
|
||||
"""Map the build host to a matrix slot.
|
||||
|
||||
Both arguments are injectable so the mapping can be exercised for a host we do not
|
||||
own (no ARM machine here); they default to the real ones.
|
||||
"""
|
||||
os_name = (sys.platform if os_name is None else os_name).lower()
|
||||
raw_machine = platform.machine() if machine is None else machine
|
||||
arch = MACHINE_ALIASES.get(raw_machine.lower())
|
||||
family = next((fam for prefix, fam in OS_FAMILIES if os_name.startswith(prefix)), None)
|
||||
for slot, (slot_os, slot_arch, _) in SLOTS.items():
|
||||
if (slot_os, slot_arch) == (family, arch):
|
||||
return slot
|
||||
# 报错带**原始**取值而不是归一后的 None:`riscv64` 这类不认识的架构得在诊断里看得见。
|
||||
fail(f"unsupported build host {os_name}/{raw_machine}; matrix is {' / '.join(SLOTS)}")
|
||||
|
||||
|
||||
def binary(name: str, build_dir: Path, family: str) -> Path:
|
||||
path = build_dir / (f"{name}.exe" if family == "windows" else name)
|
||||
if not path.is_file():
|
||||
fail(f"missing {path}; run `cargo build --release --locked` first")
|
||||
return path
|
||||
|
||||
|
||||
def image_arch(path: Path) -> str | None:
|
||||
"""Architecture stamped into the executable header, or `None` if unreadable."""
|
||||
with path.open("rb") as handle:
|
||||
head = handle.read(0x40)
|
||||
if head[:4] == b"\x7fELF":
|
||||
order = "little" if head[5] == 1 else "big"
|
||||
return IMAGE_ARCH.get(("elf", int.from_bytes(head[18:20], order)))
|
||||
if head[:2] == b"MZ":
|
||||
handle.seek(int.from_bytes(head[0x3C:0x40], "little"))
|
||||
pe = handle.read(6)
|
||||
if pe[:4] == b"PE\0\0":
|
||||
return IMAGE_ARCH.get(("pe", int.from_bytes(pe[4:6], "little")))
|
||||
return None
|
||||
if head[:4] == b"\xcf\xfa\xed\xfe": # thin 64-bit little-endian Mach-O
|
||||
return IMAGE_ARCH.get(("macho", int.from_bytes(head[4:8], "little")))
|
||||
return None
|
||||
|
||||
|
||||
def verify_arch(path: Path, slot: str) -> None:
|
||||
"""Prove the binary really is built for the slot it is about to be named after."""
|
||||
want = SLOTS[slot][1]
|
||||
got = image_arch(path)
|
||||
if got is None:
|
||||
fail(f"cannot read the machine type of {path}; refusing to ship an unverified binary")
|
||||
if got != want:
|
||||
fail(f"{path} is a {got} binary but slot {slot} says {want}")
|
||||
print(f"arch: {path.name} -> {got} (ok for {slot})")
|
||||
|
||||
|
||||
def smoke_version(cli: Path, version: str) -> None:
|
||||
"""`ull --version` must report the workspace version (guards tag/version drift)."""
|
||||
proc = subprocess.run(
|
||||
[str(cli), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
if proc.returncode != 0:
|
||||
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
|
||||
if version not in out:
|
||||
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
|
||||
print(f"smoke: {cli.name} --version -> {version} (ok)")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_sums(out_dir: Path, assets: list[Path]) -> Path:
|
||||
sums = out_dir / SUMS_NAME
|
||||
lines = [f"{sha256(path)} {path.name}" for path in sorted(assets)]
|
||||
sums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
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:
|
||||
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
|
||||
|
||||
用户令(2026-11-05):产物名按 `名字_版本号_平台_架构` 下划线分段,且**不得出现 `iced`
|
||||
这类实现细节字样** —— 下载的人不该关心我们内部用哪个 UI 框架。槽位键(`windows-x64`)
|
||||
仍是脚本内部的写法,落到文件名时拆成 `windows_x64` 两段。
|
||||
"""
|
||||
family, arch, _kind = SLOTS[slot]
|
||||
return f"{name}_{version}_{family}_{arch}"
|
||||
|
||||
|
||||
def stage_cli(out_dir: Path, version: str, slot: str, cli: Path) -> Path:
|
||||
"""Copy the CLI out as its own bare asset (`ull_<ver>_<platform>_<arch>[.exe]`)."""
|
||||
name = asset_stem(CLI_BIN, version, slot) + (".exe" if cli.suffix == ".exe" else "")
|
||||
asset = out_dir / name
|
||||
shutil.copy2(cli, asset)
|
||||
# 裸文件下载即可运行:Windows 宿主机上源文件没有 x 位,这里补上(UNIX 侧才有意义)。
|
||||
asset.chmod(0o755)
|
||||
return asset
|
||||
|
||||
|
||||
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
|
||||
"""tar.gz with one top-level dir holding the binary + compliance docs."""
|
||||
stem = asset_stem(LAUNCHER_ASSET, version, tag)
|
||||
stage = out_dir / stem
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
stage.mkdir(parents=True)
|
||||
shutil.copy2(launcher, stage / LAUNCHER_ASSET)
|
||||
for doc in COMPLIANCE_DOCS:
|
||||
source = REPO_ROOT / doc
|
||||
if not source.is_file():
|
||||
fail(f"compliance file {source} is missing")
|
||||
shutil.copy2(source, stage / doc)
|
||||
# tar 内的 mtime/uid/mode 与 gzip 头的 mtime 全部归零 → 同源构建的 sha256 与打包时刻无关。
|
||||
archive = out_dir / f"{stem}.tar.gz"
|
||||
with archive.open("wb") as raw, gzip.GzipFile(
|
||||
fileobj=raw, mode="wb", compresslevel=9, mtime=0
|
||||
) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
|
||||
for entry in sorted(stage.iterdir()):
|
||||
info = tar.gettarinfo(str(entry), arcname=f"{stem}/{entry.name}")
|
||||
info.mtime = 0
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mode = 0o755 if entry.name == LAUNCHER_ASSET else 0o644
|
||||
with entry.open("rb") as handle:
|
||||
tar.addfile(info, handle)
|
||||
shutil.rmtree(stage)
|
||||
return archive
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
|
||||
parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
|
||||
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("--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()
|
||||
|
||||
version = workspace_version()
|
||||
if args.version:
|
||||
expected = args.version[1:] if args.version.startswith("v") else args.version
|
||||
if expected != version:
|
||||
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
|
||||
native = host_slot()
|
||||
slot = args.slot or native
|
||||
family, _, kind = SLOTS[slot]
|
||||
build_dir = Path(args.build_dir)
|
||||
if not build_dir.is_absolute():
|
||||
build_dir = REPO_ROOT / build_dir
|
||||
launcher = binary(LAUNCHER_BIN, build_dir, family)
|
||||
verify_arch(launcher, slot)
|
||||
# 跨槽位产物(宿主 x64 / 目标 arm64)在本机根本执行不了,冒烟只能等 CI 真机;
|
||||
# 拿"跳过了"当通过是假证据,所以把跳过原因原样打出来。
|
||||
if args.no_smoke or slot != native:
|
||||
reason = "--no-smoke" if args.no_smoke else f"host is {native}, artifact is {slot}"
|
||||
print(f"smoke: skipped ({reason})")
|
||||
else:
|
||||
smoke_version(binary(CLI_BIN, build_dir, family), version)
|
||||
|
||||
out_dir = Path(args.out)
|
||||
if not out_dir.is_absolute():
|
||||
out_dir = REPO_ROOT / out_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if kind == "exe":
|
||||
asset = out_dir / f"{asset_stem(LAUNCHER_ASSET, version, slot)}.exe"
|
||||
shutil.copy2(launcher, asset)
|
||||
else:
|
||||
asset = stage_archive(out_dir, slot, version, launcher)
|
||||
# CLI 与 GUI 同一矩阵槽位、各自独立资产:架构同样回读文件头校验,不给 arm64 槽位混进 x64 的 ull。
|
||||
cli = binary(CLI_BIN, build_dir, family)
|
||||
verify_arch(cli, slot)
|
||||
cli_asset = stage_cli(out_dir, version, slot, cli)
|
||||
|
||||
sums = write_sums(out_dir, [asset, cli_asset])
|
||||
for item in (asset, cli_asset):
|
||||
size_mb = item.stat().st_size / (1024 * 1024)
|
||||
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
|
||||
print(f"sums: {sums.name}")
|
||||
if args.manifest:
|
||||
manifest = write_manifest(out_dir, version)
|
||||
print(f"manifest: {manifest.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,125 @@
|
||||
# Unlucky Launcher {{VERSION}}
|
||||
|
||||
Rust-native **single-file** Minecraft launcher: iced 0.14 + Material 3 Expressive UI, built-in AI agent
|
||||
(chat / setting edits / crash diagnosis) and an MCP server other agent tools can attach to.
|
||||
No WebView, no bundled runtime, no JRE download needed to start the launcher itself.
|
||||
|
||||
## Downloads
|
||||
|
||||
Asset names are `<name>_<version>_<platform>_<arch>`, underscore separated — the rule
|
||||
`packaging/make_artifacts.py::asset_stem()` applies:
|
||||
|
||||
| Platform | Asset | Notes |
|
||||
| --- | --- | --- |
|
||||
| Windows 10/11 x64 | `unlucky-launcher_{{VERSION}}_windows_x64.exe` | single exe, double-click to run; this is also what you run on ARM64 Windows (x64 emulation) |
|
||||
|
||||
**Windows ARM64 is not part of this release.** CI still builds it on a native `windows-11-arm` runner for
|
||||
evidence, but that slot's toolchain check has not gone green yet, so
|
||||
`unlucky-launcher_{{VERSION}}_windows_arm64.exe` is deliberately kept out of the download list and out of
|
||||
`SHA256SUMS.txt` instead of handing out a build nobody has verified. `windows-x64` is the only published
|
||||
slot; ARM64 rejoins this table as soon as its build is green (one line in the workflow's `PUBLISH_SLOTS`).
|
||||
|
||||
**This release ships Windows only, and CI builds Windows only.** The Linux (x64 / arm64) and macOS
|
||||
(Apple Silicon / Intel) slots are not built by the release workflow at all — they have not been verified
|
||||
on real machines yet, so nothing unverified is handed out and no build minutes are spent on them. They
|
||||
stay out of the download list and out of `SHA256SUMS.txt` until that verification happens; a manual
|
||||
workflow run can build all six slots for that purpose, but the four extra slots are never published. The
|
||||
support matrix below marks those slots accordingly.
|
||||
|
||||
The Windows asset is a bare `.exe`, so `LICENSE`, `NOTICE` and `README.md` are attached to this release
|
||||
next to it: the license terms travel with every download. Slots that ship an archive (Linux / macOS, once
|
||||
published) unpack to one top-level directory of the same name holding the binary plus those three
|
||||
documents.
|
||||
|
||||
## Command line
|
||||
|
||||
Each published slot also ships the command line as its own small bare asset,
|
||||
`ull_{{VERSION}}_<platform>_<arch>` (`.exe` on Windows):
|
||||
|
||||
```powershell
|
||||
.\ull_{{VERSION}}_windows_x64.exe instances list # servers and CI: no graphics stack needed
|
||||
```
|
||||
|
||||
`ull` runs `instances`, `install`, `pack import`, `launch`, `accounts`, `sessions`, `logs`, `diag`,
|
||||
`config`, `java` and `agent`, and links no graphics library at all. The GUI binary accepts the same set
|
||||
before it ever opens a window, so a desktop machine does not need `ull`.
|
||||
|
||||
## Support matrix
|
||||
|
||||
Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt`.
|
||||
|
||||
| Platform | Status in this release | Level | Runtime requirements |
|
||||
| --- | --- | --- | --- |
|
||||
| Windows 10/11 x64 | **published** | first class | nothing beyond the OS (UCRT is part of Win10+) |
|
||||
| Windows 11 ARM64 | not published (build chasing, unverified) | first class (launcher artifact) | nothing extra; the launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
|
||||
| macOS 12+ Apple Silicon | not built (unverified) | first class (unverified) | nothing; unsigned build, needs the Gatekeeper workaround |
|
||||
| macOS 12+ Intel | not built (unverified) | first class (unverified) | same as above |
|
||||
| Linux x64, X11 | not built (unverified) | first class (unverified) | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
|
||||
| Linux x64, Wayland | not built (unverified) | supported (unverified) | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps |
|
||||
| Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
|
||||
| Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` |
|
||||
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
|
||||
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI only and only when built from source; GUI unverified |
|
||||
|
||||
`windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green
|
||||
(see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and
|
||||
Linux arm64 is not built by default — someone has to run it on real hardware (via a manual all-slots run)
|
||||
before it joins this list. The rows above and the matrix table in `packaging/README.md` §9 are the same
|
||||
list — change one, change the other.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows** — put the `.exe` anywhere and run it. `_windows_x64.exe` is the only Windows asset in this
|
||||
release, and it is also what you run on ARM64 Windows (the OS runs it through its x64 emulation layer).
|
||||
The first run creates `.ULL/` and `.minecraft/` next to the exe (portable is the default data mode); the
|
||||
per-user `%APPDATA%\ULL` plus `%APPDATA%\.minecraft`, or a custom root, are selectable instead.
|
||||
|
||||
Linux / macOS are not part of this release; their asset names are already fixed by the same rule
|
||||
(`unlucky-launcher_{{VERSION}}_linux_x64.tar.gz`, `_linux_arm64`, `_macos_arm64`, `_macos_x64`) so the
|
||||
links will not change shape when those slots open.
|
||||
|
||||
## No GPU? Software renderer
|
||||
|
||||
The UI renders through `wgpu` (GPU) and falls back to the `tiny-skia` CPU rasteriser when no usable
|
||||
adapter is present; `--software-renderer` (or its equivalent `--renderer=cpu`) forces that path.
|
||||
|
||||
## Verify the download
|
||||
|
||||
`SHA256SUMS.txt` lists exactly the assets attached to this release (Windows only), and `sha256sum -c`
|
||||
checks the ones you actually downloaded:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS.txt --ignore-missing # Linux / WSL
|
||||
shasum -a 256 -c SHA256SUMS.txt # macOS (sha256sum is not bundled)
|
||||
Get-FileHash .\unlucky-launcher_{{VERSION}}_windows_x64.exe -Algorithm SHA256 # Windows PowerShell
|
||||
```
|
||||
|
||||
```
|
||||
{{SUMS}}
|
||||
```
|
||||
|
||||
## Known limitations of this build
|
||||
|
||||
- Microsoft (MSA) sign-in works out of the box: this build carries the Azure public client id it needs
|
||||
(a public client id, not a credential — it ships inside the binary), so there is nothing to configure
|
||||
before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` before launching still overrides it; the variable
|
||||
is read at startup, so restart the launcher after changing it. A build with no id anywhere stops
|
||||
before any network call, and the GUI banner says so plainly: the title "Microsoft sign-in is not
|
||||
configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart) and
|
||||
`Code: msa_not_configured`, with no Retry button. Every other sign-in failure keeps the generic
|
||||
banner plus its own `Code:` line, with the full reason only in the launcher log. `ull accounts add
|
||||
msa` prints the same fix on the command line. Offline profiles and authlib-injector servers need no
|
||||
id.
|
||||
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first
|
||||
launch. The checksums above are the integrity reference for now.
|
||||
- When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock
|
||||
icon or Finder double-click — launch it from a terminal.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0, third-party notices in `NOTICE`. Not affiliated with Mojang or Microsoft; no game files ship.
|
||||
|
||||
## UI framework in this build
|
||||
|
||||
Every asset here is built from the iced UI (`crates/app-iced`, iced 0.14, MIT); no other UI framework is
|
||||
linked into it.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{
|
||||
"version": "9.9.9",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-01-02T03:04:05Z",
|
||||
"notes": "golden 样例(自验用,不是真实发布)",
|
||||
"force": false,
|
||||
"min_supported": "9.0.0",
|
||||
"artifacts": [
|
||||
{
|
||||
"target": "win-x64",
|
||||
"format": "portable-exe",
|
||||
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
|
||||
"size": 12,
|
||||
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
|
||||
},
|
||||
{
|
||||
"target": "linux-x64",
|
||||
"format": "portable-zip",
|
||||
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
|
||||
"size": 12,
|
||||
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user