Velopack packaging on all platforms with in-app auto-updates
release / gui-build (linux, bash) (push) Failing after 50s
release / gui-build (macos, bash) (push) Successful in 1m44s
release / gui-build (windows, cmd) (push) Successful in 1m48s

- macOS: .pkg installer replaces the DMG; Linux: .AppImage replaces the ZIP
- winmain runs velopack.App() first (proper hook handling) and checks for
  updates in the background; downloads are applied on next launch
- release.py uploads the vpk update feed (releases.<channel>.json, nupkgs)
  so GiteaSource finds updates on the latest release
- guibuild.py bootstraps a .NET runtime into build/dotnet when the runner
  host lacks one
This commit is contained in:
2026-09-22 23:50:50 +00:00
parent e8d2c5773a
commit 303aabc181
7 changed files with 215 additions and 81 deletions
+131 -67
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env -S uv run
"""Build the desktop GUI application and package it as a versioned ZIP/DMG.
"""Build the desktop GUI application and package it with Velopack.
Usage:
uv run scripts/winbuild.py
@@ -10,10 +10,12 @@ 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, downloads the latest ffmpeg.exe for bundling
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
3. On Windows/macOS, downloads the ffmpeg binary for bundling
4. Builds MediaHive using PyInstaller
5. Creates a versioned ZIP (Windows/Linux) or DMG (macOS) artifact
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
@@ -23,6 +25,7 @@ import shutil
import stat
import subprocess
import sys
import tarfile
import urllib.request
import zipfile
from pathlib import Path
@@ -215,36 +218,119 @@ def fetch_vpk() -> Path:
return vpk_dll
def _dotnet() -> str:
"""Resolve the dotnet host with an SDK/modern runtime.
_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",
}
A plain `dotnet` may resolve to a runtime-only installation (e.g.
C:\\Program Files\\dotnet), and long-running services (CI runners) may
carry a stale environment without DOTNET_ROOT or scoop paths — so probe
known locations explicitly.
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.
"""
candidates = []
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))
candidates.append(Path(root) / exe_name)
if sys.platform == "win32":
candidates.append(Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current"))
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
for base in candidates:
exe = base / exe_name
if exe.exists():
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)
return "dotnet"
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(io.BytesIO(data), "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_setup(version: str) -> Path:
"""Build a Velopack per-user Setup.exe from the PyInstaller output folder.
def build_velopack(version: str) -> Path:
"""Build the Velopack installer/bundle for this platform.
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.
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.
"""
dist_folder = _REPO_ROOT / "build" / "MediaHive"
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}")
@@ -252,7 +338,7 @@ def build_setup(version: str) -> Path:
releases_dir = _REPO_ROOT / "build" / "velopack"
cmd = [
_dotnet(),
fetch_dotnet(),
str(vpk_dll),
"pack",
"--packId",
@@ -262,13 +348,15 @@ def build_setup(version: str) -> Path:
"--packDir",
str(dist_folder),
"--mainExe",
"MediaHive.exe",
main_exe,
"--packAuthors",
"MediaHive",
"--packTitle",
"MediaHive",
"--icon",
str(_ASSETS_DIR / "mediahive.ico"),
str(icon),
"--runtime",
rid,
"--outputDir",
str(releases_dir),
]
@@ -283,10 +371,16 @@ def build_setup(version: str) -> Path:
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
setup = next(releases_dir.glob("*-win-Setup.exe"), None)
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{artifact_ext}"))), 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"
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}")
artifact = (
_REPO_ROOT
/ "build"
/ f"MediaHive-{version}-{_platform_zip_suffix()}-setup{artifact_ext}"
)
artifact.unlink(missing_ok=True)
setup.rename(artifact)
return artifact
@@ -353,33 +447,6 @@ def create_zip(version: str) -> Path:
return zip_path
def create_dmg(version: str) -> Path:
"""Create a version-numbered DMG containing MediaHive.app (macOS only)."""
repo_root = _REPO_ROOT
app_bundle = repo_root / "build" / "MediaHive.app"
if not app_bundle.exists():
raise FileNotFoundError(f"App bundle not found: {app_bundle}")
dmg_path = repo_root / "build" / f"MediaHive-{version}-{_platform_zip_suffix()}.dmg"
dmg_path.parent.mkdir(parents=True, exist_ok=True)
dmg_path.unlink(missing_ok=True)
cmd = [
"hdiutil", "create",
"-volname", "MediaHive",
"-srcfolder", str(app_bundle),
"-ov",
"-format", "UDZO",
str(dmg_path),
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"hdiutil failed with exit code {result.returncode}")
return dmg_path
def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace")
@@ -400,18 +467,15 @@ def main() -> None:
)
build_wheel()
build_executable()
if sys.platform == "darwin":
artifact_path = create_dmg(version)
else:
artifact_path = create_zip(version)
print(f"✓ Built successfully: {artifact_path}")
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
artifacts = [build_velopack(version)]
if sys.platform == "win32":
setup_path = build_setup(version)
print(f"✓ Built successfully: {setup_path}")
print(f" Size: {setup_path.stat().st_size / (1024 * 1024):.1f} MB")
# 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)
+28 -4
View File
@@ -67,10 +67,12 @@ def load_token() -> str:
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# 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|exe)$")
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
# MediaHive-1.2.3-macos-arm64-setup.pkg, MediaHive-1.2.3-linux-x64.AppImage, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
_CLEAN_ARTIFACT_RE = re.compile(
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
)
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
@@ -113,6 +115,21 @@ def find_dist_files(version: str) -> list[Path]:
return [wheel, sdist]
def find_velopack_feed_files() -> list[Path]:
"""Velopack update feed files produced by vpk pack in build/velopack/.
These keep their original names — the feed JSONs reference them — and
in-app updates (GiteaSource) download them from the latest release.
"""
releases_dir = REPO_ROOT / "build" / "velopack"
if not releases_dir.exists():
return []
files: list[Path] = []
for pattern in ("RELEASES", "releases.*.json", "assets.*.json", "*.nupkg"):
files.extend(sorted(releases_dir.glob(pattern)))
return files
# ---------------------------------------------------------------------------
# Gitea API helpers
# ---------------------------------------------------------------------------
@@ -266,6 +283,13 @@ def main() -> None:
continue
print(f"Uploading platform artifact: {platform_tag}")
upload_asset(client, base_url, repo, release_id, artifact_path)
uploaded.add(artifact_path.name)
for feed_file in find_velopack_feed_files():
if feed_file.name in uploaded:
print(f"Skipping {feed_file.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, feed_file)
uploaded.add(feed_file.name)
print(f"{tag} published")
print("\nDone. To publish to PyPI, run:")