Files
Sprimo/scripts/package_windows.py
2026-02-13 11:22:46 +08:00

239 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Build and package portable Windows ZIPs for Bevy/Tauri frontends."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
ASSETS_REL = ROOT / "assets"
class PackagingError(RuntimeError):
"""Raised when packaging preconditions are not met."""
@dataclass(frozen=True)
class FrontendLayout:
id: str
crate: str
binary_name: str
artifact_name: str
readme_run: str
FRONTENDS: dict[str, FrontendLayout] = {
"bevy": FrontendLayout(
id="bevy",
crate="sprimo-app",
binary_name="sprimo-app.exe",
artifact_name="sprimo-windows-x64",
readme_run="sprimo-app.exe",
),
"tauri": FrontendLayout(
id="tauri",
crate="sprimo-tauri",
binary_name="sprimo-tauri.exe",
artifact_name="sprimo-tauri-windows-x64",
readme_run="sprimo-tauri.exe",
),
}
@dataclass(frozen=True)
class PackageLayout:
frontend: str
version: str
zip_path: Path
checksum_path: Path
def run(cmd: list[str], cwd: Path = ROOT) -> None:
result = subprocess.run(cmd, cwd=cwd, check=False)
if result.returncode != 0:
raise PackagingError(f"command failed ({result.returncode}): {' '.join(cmd)}")
def read_version() -> str:
cmd = ["cargo", "metadata", "--format-version", "1", "--no-deps"]
proc = subprocess.run(
cmd,
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise PackagingError(f"failed to read cargo metadata: {proc.stderr.strip()}")
data = json.loads(proc.stdout)
root_pkg = data.get("workspace_root")
if not root_pkg:
raise PackagingError("workspace metadata missing workspace_root")
# Prefer workspace package version.
manifest_path = ROOT / "Cargo.toml"
manifest = manifest_path.read_text(encoding="utf-8")
marker = "version = "
for line in manifest.splitlines():
stripped = line.strip()
if stripped.startswith(marker):
return stripped.split("=", 1)[1].strip().strip('"')
# Fallback to first package version from metadata.
packages = data.get("packages", [])
if packages:
return packages[0]["version"]
raise PackagingError("could not determine version")
def ensure_release_binary(frontend: FrontendLayout) -> Path:
binary_path = ROOT / "target" / "release" / frontend.binary_name
if not binary_path.exists():
run(["cargo", "build", "--release", "-p", frontend.crate])
if not binary_path.exists():
raise PackagingError(f"release binary missing: {binary_path}")
return binary_path
def ensure_assets() -> None:
required = [
ASSETS_REL / "sprite-packs" / "default" / "manifest.json",
ASSETS_REL / "sprite-packs" / "default" / "sprite.png",
]
missing = [path for path in required if not path.exists()]
if missing:
joined = ", ".join(str(path) for path in missing)
raise PackagingError(f"required assets missing: {joined}")
def iter_stage_files(stage_root: Path) -> Iterable[Path]:
for path in stage_root.rglob("*"):
if path.is_file():
yield path
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def package(frontend: FrontendLayout) -> PackageLayout:
version = read_version()
ensure_assets()
binary = ensure_release_binary(frontend)
DIST.mkdir(parents=True, exist_ok=True)
artifact_name = f"{frontend.artifact_name}-v{version}"
zip_path = DIST / f"{artifact_name}.zip"
checksum_path = DIST / f"{artifact_name}.zip.sha256"
with tempfile.TemporaryDirectory(prefix="sprimo-package-") as temp_dir:
stage = Path(temp_dir) / artifact_name
stage.mkdir(parents=True, exist_ok=True)
shutil.copy2(binary, stage / frontend.binary_name)
shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True)
readme = stage / "README.txt"
readme.write_text(
"Sprimo portable package\n"
f"Frontend: {frontend.id}\n"
f"Run: {frontend.readme_run}\n"
"Assets are expected at ./assets relative to the executable.\n",
encoding="utf-8",
)
archive_base = DIST / artifact_name
if zip_path.exists():
zip_path.unlink()
if checksum_path.exists():
checksum_path.unlink()
shutil.make_archive(str(archive_base), "zip", root_dir=stage.parent, base_dir=stage.name)
checksum = sha256_file(zip_path)
checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8")
return PackageLayout(
frontend=frontend.id,
version=version,
zip_path=zip_path,
checksum_path=checksum_path,
)
def smoke(frontend: FrontendLayout) -> None:
layout = package(frontend)
print(f"package created: {layout.zip_path}")
print(f"checksum file: {layout.checksum_path}")
with tempfile.TemporaryDirectory(prefix="sprimo-smoke-") as temp_dir:
unzip_dir = Path(temp_dir) / "unpacked"
shutil.unpack_archive(layout.zip_path, unzip_dir)
root_candidates = [p for p in unzip_dir.iterdir() if p.is_dir()]
if not root_candidates:
raise PackagingError("smoke failed: package root directory missing")
pkg_root = root_candidates[0]
required = [
pkg_root / frontend.binary_name,
pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json",
pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png",
]
missing = [path for path in required if not path.exists()]
if missing:
joined = ", ".join(str(path) for path in missing)
raise PackagingError(f"smoke failed: expected files missing: {joined}")
print("smoke check passed: package structure is valid")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
parser.add_argument("command", choices=["package", "smoke"], help="action to execute")
parser.add_argument(
"--frontend",
choices=sorted(FRONTENDS.keys()),
default="bevy",
help="frontend package target",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
frontend = FRONTENDS[args.frontend]
try:
if args.command == "package":
layout = package(frontend)
print(f"created: {layout.zip_path}")
print(f"sha256: {layout.checksum_path}")
else:
smoke(frontend)
return 0
except PackagingError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())