Build Windows installer with Velopack instead of WiX
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Failing after 1m34s
release / gui-build (macos, bash) (push) Successful in 1m13s

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:
2026-09-22 23:11:30 +00:00
parent 6dee65b4c6
commit 3377702e98
3 changed files with 89 additions and 143 deletions
+78 -69
View File
@@ -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"
def run(tool: str, args: list[str]) -> None:
cmd = [str(wix / tool), *args]
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
)
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
raise RuntimeError(f"vpk failed to start: {exc}") from exc
if result.returncode != 0:
raise RuntimeError(
f"{tool} failed with exit code {result.returncode}\n"
f"vpk pack 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)
-62
View File
@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MediaHive per-user MSI installer.
Build defines (passed via candle -d):
MediaHiveVersion e.g. 1.2.3 (must be numeric x.y.z[.w] for MSI)
MediaHiveSourceDir path to the PyInstaller output folder (build/MediaHive)
-->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*"
Name="MediaHive"
Language="1033"
Version="$(var.MediaHiveVersion)"
Manufacturer="MediaHive"
UpgradeCode="8f3a2c1e-9b6d-4a7e-b5c4-2d1f0e9a8b7c">
<Package InstallerVersion="500"
Compressed="yes"
InstallScope="perUser"
InstallPrivileges="limited"
Description="MediaHive media library"
Comments="MediaHive desktop application" />
<MajorUpgrade DowngradeErrorMessage="A newer version of MediaHive is already installed."
AllowSameVersionUpgrades="yes" />
<MediaTemplate EmbedCab="yes" />
<Icon Id="MediaHiveIcon" SourceFile="$(var.MediaHiveSourceDir)\_internal\mediahive\assets\mediahive.ico" />
<Property Id="ARPPRODUCTICON" Value="MediaHiveIcon" />
<Feature Id="MainApplication" Title="MediaHive" Level="1">
<ComponentGroupRef Id="MediaHiveFiles" />
<ComponentRef Id="StartMenuShortcut" />
</Feature>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="LocalAppDataFolder">
<Directory Id="ProgramsFolder" Name="Programs">
<Directory Id="INSTALLFOLDER" Name="MediaHive" />
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder" />
</Directory>
<!-- Start Menu shortcut (needs its own component; per-user keypath is a
registry entry under HKCU). -->
<DirectoryRef Id="ProgramMenuFolder">
<Component Id="StartMenuShortcut" Guid="5c1e9a2b-4d7f-4e6b-8a3c-1d0f2e9b7a65">
<Shortcut Id="MediaHiveStartMenuShortcut"
Name="MediaHive"
Target="[INSTALLFOLDER]MediaHive.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="MediaHiveIcon" />
<RegistryValue Root="HKCU"
Key="Software\MediaHive"
Name="StartMenuShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
</Product>
</Wix>
+4 -5
View File
@@ -67,14 +67,14 @@ def load_token() -> str:
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-win64.msi,
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-win64-setup.exe,
# MediaHive-1.2.3-macos-arm64.dmg, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
_CLEAN_ARTIFACT_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|msi)$")
_CLEAN_ARTIFACT_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe)$")
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
"""Return (path, version, platform_tag) for clean-versioned ZIPs/DMGs/MSIs in build/."""
"""Return (path, version, platform_tag) for clean-versioned ZIPs/DMGs/EXEs in build/."""
build_dir = REPO_ROOT / "build"
results = []
for p in sorted(build_dir.glob("MediaHive-*")):
@@ -184,7 +184,6 @@ def upload_asset(
mime = {
".zip": "application/zip",
".dmg": "application/x-apple-diskimage",
".msi": "application/x-msi",
}.get(path.suffix, "application/octet-stream")
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
with Path(path).open("rb") as fh:
@@ -230,7 +229,7 @@ def main() -> None:
artifacts = find_releasable_artifacts()
if not artifacts:
print(
"No clean-versioned ZIPs/DMGs/MSIs found in build/.\n"
"No clean-versioned ZIPs/DMGs/EXEs found in build/.\n"
"Run scripts/guibuild.py first.",
file=sys.stderr,
)