Collapse the Velopack-generated distribution.xml to a single install domain so macOS Installer skips Destination Select, leaving Apple's minimum pages (Introduction, Install, Summary). Welcome/license/readme/ conclusion pages are already absent (no --inst* options passed).
510 lines
17 KiB
Python
Executable File
510 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),
|
|
]
|
|
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()
|