257 lines
7.8 KiB
Python
257 lines
7.8 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
|
|
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())
|