fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
- 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:
+19
-5
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user