Build Windows installer with Velopack instead of WiX
vpk runs on the machine's modern .NET runtime (WiX 3.11's .NET Framework shim fails under the SYSTEM account) and produces a per-user Setup.exe that needs no runtime on end-user machines.
This commit is contained in:
+85
-76
@@ -17,8 +17,8 @@ This script:
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -41,14 +41,15 @@ _FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||
|
||||
# WiX 3.11 standalone binaries (candle/light/heat run on .NET Framework 4.x,
|
||||
# no .NET SDK needed, unlike WiX v4 which is a dotnet tool).
|
||||
_WIX_URL = (
|
||||
"https://github.com/wixtoolset/wix3/releases/download/wix3112rtm"
|
||||
"/wix311-binaries.zip"
|
||||
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
|
||||
# produced Setup.exe/Update.exe are native and need no runtime on end-user
|
||||
# machines. Pin a version whose tools target an installed .NET major.
|
||||
_VPK_VERSION = "1.2.158"
|
||||
_VPK_URL = (
|
||||
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
|
||||
f"/vpk.{_VPK_VERSION}.nupkg"
|
||||
)
|
||||
_WIX_STAGING = _REPO_ROOT / "build" / "wix311"
|
||||
_WIX_WXS = _REPO_ROOT / "scripts" / "installer" / "MediaHive.wxs"
|
||||
_VPK_STAGING = _REPO_ROOT / "build" / "vpk"
|
||||
|
||||
|
||||
def _platform_zip_suffix() -> str:
|
||||
@@ -190,86 +191,94 @@ def ensure_macos_icon() -> Path:
|
||||
return icon_icns
|
||||
|
||||
|
||||
def fetch_wix() -> Path:
|
||||
"""Download WiX 3.11 standalone binaries into build/wix311/ (cached)."""
|
||||
candle = _WIX_STAGING / "candle.exe"
|
||||
if candle.exists():
|
||||
print(f"WiX already staged at {_WIX_STAGING}, skipping download.")
|
||||
return _WIX_STAGING
|
||||
def fetch_vpk() -> Path:
|
||||
"""Download the Velopack CLI package into build/vpk/ (cached).
|
||||
|
||||
_WIX_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading WiX from {_WIX_URL} ...")
|
||||
with urllib.request.urlopen(_WIX_URL) as resp:
|
||||
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
|
||||
"""
|
||||
vpk_dll = _VPK_STAGING / "tools" / "net10.0" / "any" / "vpk.dll"
|
||||
if vpk_dll.exists():
|
||||
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
|
||||
return vpk_dll
|
||||
|
||||
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading vpk from {_VPK_URL} ...")
|
||||
with urllib.request.urlopen(_VPK_URL) as resp:
|
||||
data = resp.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
zf.extractall(_WIX_STAGING)
|
||||
zf.extractall(_VPK_STAGING)
|
||||
|
||||
print(f"WiX staged at {_WIX_STAGING}")
|
||||
return _WIX_STAGING
|
||||
if not vpk_dll.exists():
|
||||
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
|
||||
print(f"vpk staged at {_VPK_STAGING}")
|
||||
return vpk_dll
|
||||
|
||||
|
||||
def build_msi(version: str) -> Path:
|
||||
"""Build a per-user MSI installer from the PyInstaller output folder.
|
||||
def _dotnet() -> str:
|
||||
"""Resolve the dotnet host, preferring DOTNET_ROOT (a plain `dotnet` may
|
||||
resolve to a runtime-only installation without the SDK/runtime we need)."""
|
||||
root = os.environ.get("DOTNET_ROOT")
|
||||
if root:
|
||||
exe = Path(root) / ("dotnet.exe" if sys.platform == "win32" else "dotnet")
|
||||
if exe.exists():
|
||||
return str(exe)
|
||||
return "dotnet"
|
||||
|
||||
MSI-installed files carry no Mark-of-the-Web, so the .NET CLR loads
|
||||
pythonnet/pywebview assemblies that it refuses from a downloaded ZIP.
|
||||
|
||||
def build_setup(version: str) -> Path:
|
||||
"""Build a Velopack per-user Setup.exe from the PyInstaller output folder.
|
||||
|
||||
Velopack installs to %LOCALAPPDATA% (no admin) and installed files carry
|
||||
no Mark-of-the-Web, so the .NET CLR loads pythonnet/pywebview assemblies
|
||||
that it refuses from a downloaded ZIP.
|
||||
"""
|
||||
dist_folder = _REPO_ROOT / "build" / "MediaHive"
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
# MSI versions must be numeric x.y.z[.w]; strip any local/pre-release part.
|
||||
msi_version = re.match(r"\d+(?:\.\d+){0,3}", version)
|
||||
if not msi_version:
|
||||
raise ValueError(f"Cannot derive MSI version from {version!r}")
|
||||
vpk_dll = fetch_vpk()
|
||||
releases_dir = _REPO_ROOT / "build" / "velopack"
|
||||
|
||||
wix = fetch_wix()
|
||||
obj_dir = _REPO_ROOT / "build" / "wix-obj"
|
||||
obj_dir.mkdir(parents=True, exist_ok=True)
|
||||
files_wxs = _REPO_ROOT / "build" / "wix-files.wxs"
|
||||
msi_path = _REPO_ROOT / "build" / f"MediaHive-{version}-{_platform_zip_suffix()}.msi"
|
||||
cmd = [
|
||||
_dotnet(),
|
||||
str(vpk_dll),
|
||||
"pack",
|
||||
"--packId",
|
||||
"MediaHive",
|
||||
"--packVersion",
|
||||
version,
|
||||
"--packDir",
|
||||
str(dist_folder),
|
||||
"--mainExe",
|
||||
"MediaHive.exe",
|
||||
"--packAuthors",
|
||||
"MediaHive",
|
||||
"--packTitle",
|
||||
"MediaHive",
|
||||
"--icon",
|
||||
str(_ASSETS_DIR / "mediahive.ico"),
|
||||
"--outputDir",
|
||||
str(releases_dir),
|
||||
]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
try:
|
||||
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"vpk failed to start: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"vpk pack failed with exit code {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
def run(tool: str, args: list[str]) -> None:
|
||||
cmd = [str(wix / tool), *args]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, cwd=_REPO_ROOT, capture_output=True, text=True
|
||||
)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"{tool} failed to start: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"{tool} failed with exit code {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
run("heat.exe", [
|
||||
"dir", str(dist_folder),
|
||||
"-cg", "MediaHiveFiles",
|
||||
"-dr", "INSTALLFOLDER",
|
||||
"-gg", "-g1",
|
||||
"-sfrag", "-sreg", "-srd",
|
||||
"-var", "var.MediaHiveSourceDir",
|
||||
"-out", str(files_wxs),
|
||||
])
|
||||
run("candle.exe", [
|
||||
f"-dMediaHiveVersion={msi_version.group(0)}",
|
||||
f"-dMediaHiveSourceDir={dist_folder}",
|
||||
"-arch", "x64",
|
||||
str(_WIX_WXS),
|
||||
str(files_wxs),
|
||||
"-out", f"{obj_dir}\\",
|
||||
])
|
||||
run("light.exe", [
|
||||
"-ext", "WixUIExtension",
|
||||
"-sice:ICE38", "-sice:ICE61", "-sice:ICE64", "-sice:ICE91",
|
||||
str(obj_dir / "MediaHive.wixobj"),
|
||||
str(obj_dir / "wix-files.wixobj"),
|
||||
"-out", str(msi_path),
|
||||
])
|
||||
return msi_path
|
||||
setup = next(releases_dir.glob("*-win-Setup.exe"), None)
|
||||
if setup is None:
|
||||
raise RuntimeError(f"vpk produced no Setup.exe in {releases_dir}")
|
||||
artifact = _REPO_ROOT / "build" / f"MediaHive-{version}-{_platform_zip_suffix()}-setup.exe"
|
||||
artifact.unlink(missing_ok=True)
|
||||
setup.rename(artifact)
|
||||
return artifact
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
@@ -386,9 +395,9 @@ def main() -> None:
|
||||
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
|
||||
if sys.platform == "win32":
|
||||
msi_path = build_msi(version)
|
||||
print(f"✓ Built successfully: {msi_path}")
|
||||
print(f" Size: {msi_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
setup_path = build_setup(version)
|
||||
print(f"✓ Built successfully: {setup_path}")
|
||||
print(f" Size: {setup_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user