#!/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 runtime_files: tuple[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", runtime_files=("WebView2Loader.dll",), ), } @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_runtime_files(frontend: FrontendLayout, binary_dir: Path) -> list[Path]: resolved: list[Path] = [] for filename in frontend.runtime_files: path = binary_dir / filename if not path.exists(): raise PackagingError( f"required runtime file missing for {frontend.id}: {path}" ) resolved.append(path) return resolved 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) runtime_files = ensure_runtime_files(frontend, binary.parent) 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) for runtime_file in runtime_files: shutil.copy2(runtime_file, stage / runtime_file.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", ] required.extend(pkg_root / filename for filename in frontend.runtime_files) 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())