fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
This commit is contained in:
2026-10-21 09:25:17 +08:00
parent 6a0eb14b28
commit 054379dc64
52 changed files with 1070 additions and 280 deletions
+19 -5
View File
@@ -51,19 +51,33 @@ try {
$espClang = Join-Path $env:USERPROFILE '.rustup\toolchains\esp-1.97.0.0\xtensa-esp32-elf-clang\esp-clang\bin'
if (Test-Path -LiteralPath (Join-Path $espClang 'libclang.dll')) {
$env:LIBCLANG_PATH = $espClang
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $espClang })) {
$env:PATH = $espClang + ';' + $env:PATH
}
# An existing entry can be behind another LLVM installation. Move it
# to the front so libclang-cpp.dll and the runtime match libclang.dll.
$otherPaths = @($env:PATH -split ';' | Where-Object { $_ -ne $espClang })
$env:PATH = (@($espClang) + $otherPaths) -join ';'
}
$env:CARGO_WORKSPACE_DIR = (Get-Location).Path
$env:ESP_IDF_GLOB_BASE = (Get-Location).Path
$env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv'
& cargo build --release --locked @CargoArgs
$firmwareElf = $null
if ($Package) {
# Use Cargo's actual artifact path, including --target-dir / --config
# overrides, rather than guessing where this build wrote its ELF.
& cargo build --release --locked --message-format=json-render-diagnostics @CargoArgs | ForEach-Object {
$message = $_ | ConvertFrom-Json
if ($message.reason -eq 'compiler-artifact' -and $message.target.name -eq 'esp32' -and $message.executable) {
$firmwareElf = $message.executable
}
}
} else {
& cargo build --release --locked @CargoArgs
}
if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." }
if ($Package) {
if (-not $firmwareElf) { throw 'Cargo did not report the esp32 firmware ELF; refusing to package old images.' }
$python = Get-Command python -ErrorAction SilentlyContinue
if ($null -eq $python) {
throw '-Package requires Python 3 on PATH (used by scripts/package_firmware.py).'
@@ -76,7 +90,7 @@ try {
$info = Get-Content -LiteralPath 'version.json' -Raw | ConvertFrom-Json
$versionArgs = @('--version', "v$($info.version)+$($info.build)")
}
& python scripts/package_firmware.py --firmware target/firmware --name 'RMIC 图传固件' @versionArgs
& python scripts/package_firmware.py --firmware target/firmware --elf $firmwareElf --name 'RMIC 图传固件' @versionArgs
if ($LASTEXITCODE -ne 0) { throw "package_firmware.py failed (exit $LASTEXITCODE)." }
}
} finally {
+63 -22
View File
@@ -3,9 +3,9 @@
流程(在 cargo build 之后):
make_bundle.py → bundle.json(沿用现有清单)
package_firmware.py
├─ 读 bundle.json / BUILD-INFO.json
├─ 从 Cargo 产物导出 App,同步 Bootloader / 分区表
├─ 刷新 BUILD-INFO.json,并调用 make_bundle.py 生成 bundle.json
├─ 固定条目顺序与 mtime,生成可复现的 zip
└─ 写 <zip>.sha256
@@ -32,7 +32,7 @@ import subprocess
import sys
import time
import zipfile
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
@@ -59,19 +59,31 @@ def parse_epoch(build: dict) -> int:
return int(time.time())
def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
"""编译后 App 镜像可能还没导出;优先用 espflash 从最新 ELF 重新导出。"""
elf = repo / "target" / "xtensa-esp32s3-espidf" / "release" / "esp32"
def resolve_elf(repo: Path, explicit: str) -> Path:
if explicit:
return Path(explicit).resolve()
# Cargo metadata also respects build.target-dir in Cargo configuration.
result = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=repo, capture_output=True, text=True, check=True,
)
target = Path(json.loads(result.stdout)["target_directory"])
return target / "xtensa-esp32s3-espidf" / "release" / "esp32"
def export_app(repo: Path, firmware: Path, elf: Path, allow_stale: bool) -> None:
"""从选中的 ELF 导出 App,并同步同次构建的配套镜像。"""
app = firmware / "esp32s3-app.bin"
if not elf.is_file():
return
if app.is_file() and app.stat().st_mtime >= elf.stat().st_mtime:
return
if allow_stale and app.is_file():
print(f"警告:缺少 {elf};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"固件 ELF 不存在:{elf}。请先构建,或用 --elf 指定本次构建的 ELF。")
if app.is_file():
print(f"提示:{app} 早于 {elf},将尝试从最新 ELF 重新导出", file=sys.stderr)
else:
print(f"提示:缺少 {app},将尝试从最新 ELF 导出", file=sys.stderr)
companions = [elf.parent / name for name in ("bootloader.bin", "partition-table.bin")]
for source in companions:
if not source.is_file():
raise SystemExit(f"缺少同次构建的配套镜像:{source}")
espflash = repo / "target" / "tools" / "espflash" / "espflash.exe"
if not espflash.is_file():
@@ -82,7 +94,7 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
if allow_stale:
print(f"警告:{message};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"{message}。请先导出 target/firmware/esp32s3-app.bin,或加 --allow-stale。")
raise SystemExit(f"{message}。请安装 espflash,或加 --allow-stale 打包现有镜像。")
firmware.mkdir(parents=True, exist_ok=True)
print(f"导出 App:{elf} -> {app}")
@@ -94,6 +106,13 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
"esp32s3",
"--format",
"esp-idf",
"--flash-size",
"16mb",
"--flash-mode",
"dio",
"--flash-freq",
"40mhz",
"--skip-update-check",
str(elf),
str(app),
]
@@ -101,28 +120,50 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
if result.returncode != 0:
raise SystemExit(f"espflash save-image 失败(exit {result.returncode})")
for source in companions:
destination = firmware / source.name
if source.resolve() != destination.resolve():
shutil.copy2(source, destination)
# Do not attach a previous build's hardware/performance claims to new images.
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
dirty = subprocess.check_output(
["git", "status", "--porcelain", "--untracked-files=normal"], cwd=repo, text=True,
).strip()
build = {
"packaged_at": datetime.fromtimestamp(elf.stat().st_mtime, timezone.utc).isoformat(),
"source_revision": revision,
"includes_local_changes": bool(dirty),
"target": "xtensa-esp32s3-espidf",
"app_sha256": hashlib.sha256(app.read_bytes()).hexdigest(),
"verification": {"hardware_flashed_and_tested": False},
}
(firmware / "BUILD-INFO.json").write_text(
json.dumps(build, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
)
def main() -> int:
parser = argparse.ArgumentParser(description="生成官方 .zip 固件包")
parser.add_argument("--firmware", default="target/firmware", help="固件产物目录")
parser.add_argument("--elf", default="", help="本次构建的 ELF;默认通过 cargo metadata 查找 release 产物")
parser.add_argument("--name", default="", help="传递给 make_bundle.py 的名称")
parser.add_argument("--version", default="", help="传递给 make_bundle.py 的版本")
parser.add_argument("--version", default="", help="传递给 make_bundle.py 的版本;默认读取 version.json")
parser.add_argument("--skip-manifest", action="store_true", help="不重新生成 bundle.json")
parser.add_argument("--allow-stale", action="store_true", help="允许打包早于 ELF 的 App 镜像")
parser.add_argument("--allow-stale", action="store_true", help="缺少 ELF 或 espflash 时,允许打包目录中的现有镜像")
parser.add_argument("--output", default="", help="输出 zip 路径;默认 target/firmware/camfw-*.zip")
args = parser.parse_args()
repo = Path(__file__).resolve().parent.parent
if not args.version and (repo / "version.json").is_file():
version = json.loads((repo / "version.json").read_text(encoding="utf-8"))
args.version = f"v{version['version']}+{version['build']}"
firmware = Path(args.firmware)
if not firmware.is_absolute():
firmware = (Path.cwd() / firmware).resolve()
if not firmware.is_dir():
print(f"固件目录不存在:{firmware}", file=sys.stderr)
return 1
try:
export_app_if_stale(repo, firmware, args.allow_stale)
except SystemExit as error:
export_app(repo, firmware, resolve_elf(repo, args.elf), args.allow_stale)
except (SystemExit, OSError, subprocess.CalledProcessError, ValueError) as error:
print(error, file=sys.stderr)
return 1
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""固件打包回归:外部 Cargo 目录、旧镜像隔离、配套文件及 ZIP 完整性。"""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile
REPO = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location("package_firmware", REPO / "scripts/package_firmware.py")
package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(package)
class PackageFirmwareTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="rmic-package-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.release = self.root / "external-target/xtensa-esp32s3-espidf/release"
self.release.mkdir(parents=True)
self.elf = self.release / "esp32"
self.elf.write_bytes(b"current-elf")
(self.release / "bootloader.bin").write_bytes(b"current-bootloader")
(self.release / "partition-table.bin").write_bytes(b"\xff" * 0xC00)
self.output = self.root / "new-firmware"
def test_cargo_metadata_respects_external_target_directory(self):
with patch.dict(os.environ, {"CARGO_TARGET_DIR": str(self.root / "external-target")}):
self.assertEqual(package.resolve_elf(REPO, "").resolve(), self.elf.resolve())
def test_cargo_config_target_directory(self):
# Run real metadata in an isolated crate, without compiling dependencies.
crate = self.root / "crate"
(crate / ".cargo").mkdir(parents=True)
(crate / "src").mkdir()
(crate / "src/main.rs").write_text("fn main() {}\n")
(crate / "Cargo.toml").write_text('[package]\nname="fixture"\nversion="0.1.0"\n')
(crate / ".cargo/config.toml").write_text(
'[build]\ntarget-dir=' + json.dumps(str(self.root / "external-target")) + "\n"
)
with patch.dict(os.environ):
os.environ.pop("CARGO_TARGET_DIR", None)
self.assertEqual(package.resolve_elf(crate, "").resolve(), self.elf.resolve())
def test_missing_elf_does_not_reuse_old_app(self):
self.output.mkdir()
app = self.output / "esp32s3-app.bin"
app.write_bytes(b"old-app")
with self.assertRaisesRegex(SystemExit, "ELF 不存在"):
package.export_app(REPO, self.output, self.release / "missing", False)
self.assertEqual(app.read_bytes(), b"old-app")
def test_missing_companion_stops_before_export(self):
(self.release / "bootloader.bin").unlink()
with patch.object(package.subprocess, "run") as run:
with self.assertRaisesRegex(SystemExit, "配套镜像"):
package.export_app(REPO, self.output, self.elf, False)
run.assert_not_called()
def test_export_failure_does_not_create_archive(self):
with patch.object(package.shutil, "which", return_value=sys.executable):
with patch.object(package.subprocess, "run", return_value=subprocess.CompletedProcess([], 1)):
with self.assertRaisesRegex(SystemExit, "save-image 失败"):
package.export_app(REPO, self.output, self.elf, False)
self.assertEqual(list(self.root.rglob("*.zip")), [])
def test_package_replaces_newer_old_images_and_is_reproducible(self):
self.output.mkdir()
old_app = self.output / "esp32s3-app.bin"
old_app.write_bytes(b"old-app")
os.utime(old_app, (self.elf.stat().st_mtime + 100, self.elf.stat().st_mtime + 100))
(self.output / "BUILD-INFO.json").write_text(
'{"verification":{"hardware_flashed_and_tested":true},"old_test_results":true}'
)
original_run = subprocess.run
def run(command, *args, **kwargs):
if "save-image" in command:
self.assertEqual(Path(command[-2]).resolve(), self.elf.resolve())
Path(command[-1]).write_bytes(b"app-from-current-elf")
return subprocess.CompletedProcess(command, 0)
return original_run(command, *args, **kwargs)
arguments = ["package_firmware.py", "--elf", str(self.elf), "--firmware", str(self.output)]
with patch.object(sys, "argv", arguments), patch.object(package.subprocess, "run", side_effect=run):
with patch.object(package.shutil, "which", return_value=sys.executable):
self.assertEqual(package.main(), 0)
archive = next(self.output.glob("*.zip"))
first = archive.read_bytes()
self.assertEqual(package.main(), 0)
self.assertEqual(archive.read_bytes(), first)
with zipfile.ZipFile(archive) as bundle:
self.assertIsNone(bundle.testzip())
self.assertEqual(len(bundle.namelist()), 5)
self.assertEqual(bundle.read("esp32s3-app.bin"), b"app-from-current-elf")
self.assertEqual(bundle.read("bootloader.bin"), b"current-bootloader")
self.assertEqual(bundle.read("partition-table.bin"), b"\xff" * 0xC00)
manifest = json.loads(bundle.read("bundle.json"))
for filename, key in (("esp32s3-app.bin", "appSha256"),
("partition-table.bin", "partitionTableSha256"),
("bootloader.bin", "bootloaderSha256")):
self.assertEqual(hashlib.sha256(bundle.read(filename)).hexdigest(), manifest[key])
metadata = json.loads(bundle.read("BUILD-INFO.json"))
self.assertFalse(metadata["verification"]["hardware_flashed_and_tested"])
self.assertNotIn("old_test_results", metadata)
digest = hashlib.sha256(first).hexdigest()
self.assertEqual(archive.with_name(archive.name + ".sha256").read_text().split()[0], digest)
if __name__ == "__main__":
unittest.main()