Files
mediahive/scripts/guibuild.py
T
LeoVasanko 37f786f5ed
release / gui-build (linux, bash) (push) Failing after 51s
release / gui-build (macos, bash) (push) Successful in 1m43s
release / gui-build (windows, cmd) (push) Successful in 1m48s
Add welcome page text to macOS pkg installer
Tells the user to allow the permission prompts macOS may show during
install and first launch. License page remains absent; Destination
Select was already stripped, leaving Introduction, Install, Summary.
2026-09-23 00:17:51 +00:00

512 lines
17 KiB
Python
Executable File

#!/usr/bin/env -S uv run
"""Build the desktop GUI application and package it with Velopack.
Usage:
uv run scripts/winbuild.py
This runs in the project environment where dependencies
are available via pyproject.toml.
This script:
1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist
3. On Windows/macOS, downloads the ffmpeg binary for bundling
4. Builds MediaHive using PyInstaller
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
.AppImage (Linux), plus the update feed in build/velopack/
that release.py uploads for in-app auto-updates
6. On Windows, also creates a portable ZIP (no auto-updates)
"""
import io
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import urllib.request
import zipfile
from pathlib import Path
import setuptools_scm
# BtbN automated builds always publish a 'latest' tag with this asset.
_FFMPEG_URL = (
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
"/ffmpeg-master-latest-win64-gpl.zip"
)
_MACOS_ARM64_TOOL_URLS = {
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
}
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
_REPO_ROOT = Path(__file__).parent.parent
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
# 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"
)
_VPK_STAGING = _REPO_ROOT / "build" / "vpk"
def _platform_zip_suffix() -> str:
machine = platform.machine().lower()
arch = {
"x86_64": "x64",
"amd64": "x64",
"arm64": "arm64",
"aarch64": "arm64",
}.get(machine, machine or "unknown")
if sys.platform == "win32":
return "win64"
if sys.platform == "darwin":
return f"macos-{arch}"
return f"linux-{arch}"
def fetch_ffmpeg() -> Path:
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
dest = _FFMPEG_STAGING / "ffmpeg.exe"
if dest.exists():
print(f"ffmpeg already staged at {dest}, skipping download.")
return dest
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
with urllib.request.urlopen(_FFMPEG_URL) as resp:
data = resp.read()
print("Extracting ffmpeg.exe ...")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
ffmpeg_entry = next(
name for name in zf.namelist() if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src:
Path(dest).write_bytes(src.read())
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
return dest
def fetch_macos_arm64_binaries() -> dict[str, Path]:
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
if sys.platform != "darwin" or platform.machine().lower() not in {
"arm64",
"aarch64",
}:
raise RuntimeError("macOS bundling is only supported for arm64 builds")
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
staged: dict[str, Path] = {}
for tool_name, url in _MACOS_ARM64_TOOL_URLS.items():
dest = _FFMPEG_STAGING / tool_name
if dest.exists():
print(f"{tool_name} already staged at {dest}, skipping download.")
staged[tool_name] = dest
continue
print(f"Downloading {tool_name} from {url} ...")
with urllib.request.urlopen(url) as resp:
data = resp.read()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
entry_name = next(
name
for name in zf.namelist()
if Path(name).name == tool_name and not name.endswith("/")
)
with zf.open(entry_name) as src:
Path(dest).write_bytes(src.read())
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# Make the bundled binary runnable when copied out of the zip/app on macOS.
subprocess.run(["xattr", "-cr", str(dest)], check=False)
subprocess.run(["codesign", "-f", "-s", "-", str(dest)], check=True)
staged[tool_name] = dest
print(
f"{tool_name} staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)"
)
return staged
def ensure_macos_icon() -> Path:
"""Create mediahive.icns from mediahive.ico when building on macOS."""
icon_icns = _ASSETS_DIR / "mediahive.icns"
if icon_icns.exists():
return icon_icns
icon_ico = _ASSETS_DIR / "mediahive.ico"
if not icon_ico.exists():
raise FileNotFoundError(f"Missing source icon: {icon_ico}")
iconset_dir = _REPO_ROOT / "build" / "mediahive.iconset"
iconset_dir.mkdir(parents=True, exist_ok=True)
base_png = _REPO_ROOT / "build" / "mediahive-icon-1024.png"
subprocess.run(
["sips", "-s", "format", "png", str(icon_ico), "--out", str(base_png)],
check=True,
)
size_entries = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for pixels, name in size_entries:
subprocess.run(
[
"sips",
"-z",
str(pixels),
str(pixels),
str(base_png),
"--out",
str(iconset_dir / name),
],
check=True,
)
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(icon_icns)],
check=True,
)
print(f"macOS app icon generated: {icon_icns}")
return icon_icns
def fetch_vpk() -> Path:
"""Download the Velopack CLI package into build/vpk/ (cached).
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(_VPK_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
_DOTNET_STAGING = _REPO_ROOT / "build" / "dotnet"
# aka.ms latest-runtime archives per platform (vpk needs .NET >= 8).
_DOTNET_RUNTIME_URLS = {
("win32", "x64"): "https://aka.ms/dotnet/10.0/dotnet-runtime-win-x64.zip",
("darwin", "arm64"): "https://aka.ms/dotnet/10.0/dotnet-runtime-osx-arm64.tar.gz",
("linux", "x64"): "https://aka.ms/dotnet/10.0/dotnet-runtime-linux-x64.tar.gz",
}
def _dotnet_has_runtime(exe: Path) -> bool:
"""Check that `exe` runs and has Microsoft.NETCore.App >= 8."""
try:
result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
return False
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
try:
if int(parts[1].split(".")[0]) >= 8:
return True
except ValueError:
continue
return False
def fetch_dotnet() -> str:
"""Resolve a dotnet host with a modern (>= 8) runtime, bootstrapping one into build/dotnet/ if none is found.
A plain `dotnet` may resolve to a runtime-only installation, and
long-running services (CI runners) may carry a stale environment without
DOTNET_ROOT or scoop paths — so probe known locations explicitly.
"""
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
candidates: list[Path] = []
root = os.environ.get("DOTNET_ROOT")
if root:
candidates.append(Path(root) / exe_name)
if sys.platform == "win32":
candidates.append(
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name
)
which = shutil.which("dotnet")
if which:
candidates.append(Path(which))
staged = _DOTNET_STAGING / exe_name
candidates.append(staged)
for exe in candidates:
if exe.exists() and _dotnet_has_runtime(exe):
print(f"Using dotnet at {exe}")
return str(exe)
machine = platform.machine().lower()
arch = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"}.get(
machine, machine
)
url = _DOTNET_RUNTIME_URLS.get((sys.platform, arch))
if url is None:
raise RuntimeError(f"No dotnet runtime download for {sys.platform}/{arch}")
print(f"Downloading dotnet runtime from {url} ...")
with urllib.request.urlopen(url) as resp:
data = resp.read()
_DOTNET_STAGING.mkdir(parents=True, exist_ok=True)
if url.endswith(".zip"):
with zipfile.ZipFile(io.BytesIO(data)) as zf:
zf.extractall(_DOTNET_STAGING)
else:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
tf.extractall(_DOTNET_STAGING)
staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if not _dotnet_has_runtime(staged):
raise RuntimeError(f"Bootstrapped dotnet at {staged} failed runtime check")
print(f"dotnet staged at {_DOTNET_STAGING}")
return str(staged)
def build_velopack(version: str) -> Path:
"""Build the Velopack installer/bundle for this platform.
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
Also produces the update feed (releases.<channel>.json, *.nupkg) in
build/velopack/ for release.py to upload — in-app auto-updates read it
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
a downloaded ZIP.
"""
if sys.platform == "darwin":
dist_folder = _REPO_ROOT / "build" / "MediaHive.app"
icon = _ASSETS_DIR / "mediahive.icns"
rid = "osx-arm64"
main_exe = "MediaHive"
artifact_ext = ".pkg"
elif sys.platform == "win32":
dist_folder = _REPO_ROOT / "build" / "MediaHive"
icon = _ASSETS_DIR / "mediahive.ico"
rid = "win-x64"
main_exe = "MediaHive.exe"
artifact_ext = ".exe"
else:
dist_folder = _REPO_ROOT / "build" / "MediaHive"
icon = _ASSETS_DIR / "mediahive.png"
rid = "linux-x64"
main_exe = "MediaHive"
artifact_ext = ".AppImage"
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
vpk_dll = fetch_vpk()
releases_dir = _REPO_ROOT / "build" / "velopack"
cmd = [
fetch_dotnet(),
str(vpk_dll),
"pack",
"--packId",
"MediaHive",
"--packVersion",
version,
"--packDir",
str(dist_folder),
"--mainExe",
main_exe,
"--packAuthors",
"MediaHive",
"--packTitle",
"MediaHive",
"--icon",
str(icon),
"--runtime",
rid,
"--outputDir",
str(releases_dir),
]
if sys.platform == "darwin":
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
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}"
)
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{artifact_ext}"))), None)
if setup is None:
setup = next(iter(sorted(releases_dir.glob(f"*{artifact_ext}"))), None)
if setup is None:
raise RuntimeError(f"vpk produced no *{artifact_ext} in {releases_dir}")
if sys.platform == "darwin":
minimize_macos_installer(setup)
artifact = (
_REPO_ROOT
/ "build"
/ f"MediaHive-{version}-{_platform_zip_suffix()}-setup{artifact_ext}"
)
artifact.unlink(missing_ok=True)
setup.rename(artifact)
return artifact
def minimize_macos_installer(pkg: Path) -> None:
"""Strip the Destination Select page from the Velopack-generated pkg.
Velopack hardcodes two install domains (currentUserHome + localSystem) in
the distribution XML, which makes macOS Installer show a Destination
Select page. With a single domain that page is skipped, leaving Apple's
minimum: Introduction, Install, Summary. Installs go to /Applications as
before (the component pkg is non-relocatable with a fixed location).
"""
expanded = pkg.with_name(pkg.stem + "-expanded")
shutil.rmtree(expanded, ignore_errors=True)
subprocess.run(["pkgutil", "--expand", str(pkg), str(expanded)], check=True)
dist_xml = expanded / "Distribution"
xml = dist_xml.read_text()
new_xml, count = re.subn(r"<domains [^>]*/>", '<domains enable_localSystem="true" />', xml)
if count != 1:
raise RuntimeError("Unexpected distribution.xml: <domains> not found")
dist_xml.write_text(new_xml)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
shutil.rmtree(expanded)
def read_version() -> str:
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
return setuptools_scm.get_version(root=str(_REPO_ROOT))
def build_wheel() -> None:
"""Run uv build to produce the wheel and sdist."""
repo_root = _REPO_ROOT
cmd = ["uv", "build"]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
def build_executable() -> None:
"""Run PyInstaller to build the desktop GUI app."""
repo_root = _REPO_ROOT
spec_file = Path(__file__).parent / "MediaHive.spec"
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--distpath",
str(repo_root / "build"),
"--workpath",
str(repo_root / "build" / ".pyinstaller-work"),
str(spec_file),
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_zip(version: str) -> Path:
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
repo_root = _REPO_ROOT
dist_folder = repo_root / "build" / "MediaHive"
if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
if sys.platform == "win32":
# Distinguish from the Velopack installer (MediaHive-*-win64-setup.exe)
zip_name = zip_name.replace("-win64.zip", "-win64-portable.zip")
zip_path = repo_root / "build" / zip_name
zip_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
"zip",
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
)
return zip_path
def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
try:
version = read_version()
print(f"MediaHive version: {version}")
if sys.platform == "win32":
fetch_ffmpeg()
elif sys.platform == "darwin":
fetch_macos_arm64_binaries()
ensure_macos_icon()
else:
print(
"Skipping ffmpeg bundling on this platform "
"(uses system ffmpeg if available)."
)
build_wheel()
build_executable()
artifacts = [build_velopack(version)]
if sys.platform == "win32":
# Velopack-less plain-folder distribution (with MOTW strip)
artifacts.append(create_zip(version))
for artifact_path in artifacts:
print(f"✓ Built successfully: {artifact_path}")
print(f" Size: {artifact_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)
if __name__ == "__main__":
main()