From 3377702e982d17d9b0af8882cad426d98f758ddc Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 22 Sep 2026 23:11:30 +0000 Subject: [PATCH] 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. --- scripts/guibuild.py | 161 +++++++++++++++++--------------- scripts/installer/MediaHive.wxs | 62 ------------ scripts/release.py | 9 +- 3 files changed, 89 insertions(+), 143 deletions(-) delete mode 100644 scripts/installer/MediaHive.wxs diff --git a/scripts/guibuild.py b/scripts/guibuild.py index 8be17c6..198fce5 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -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) diff --git a/scripts/installer/MediaHive.wxs b/scripts/installer/MediaHive.wxs deleted file mode 100644 index 5ee3171..0000000 --- a/scripts/installer/MediaHive.wxs +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/scripts/release.py b/scripts/release.py index 7440af8..21059be 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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, )