From f9002b19a240b3d6a05815bf193489dace14761c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 00:28:11 +0000 Subject: [PATCH] Require system .NET SDK; cache vpk/ffmpeg persistently Per-build downloads of the dotnet runtime (~80 MB) were slow and flaky (IncompleteRead killed a CI run). The .NET SDK is now a build-host prerequisite; vpk and ffmpeg download once into a user-level cache (~/.cache/mediahive-build, %LOCALAPPDATA%\mediahive-build) that survives the per-run build dir. --- docs/development.md | 2 +- scripts/MediaHive.spec | 13 +++++- scripts/guibuild.py | 94 ++++++++++++++++++++---------------------- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/docs/development.md b/docs/development.md index 4bd3d95..7fea272 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,7 +44,7 @@ This launches the same pywebview-based desktop flow used by the Windows build. The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/.py`): -- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). `vpk` (and a .NET runtime, if the host lacks one) are downloaded into `build/` automatically. +- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires the .NET SDK (>= 8 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows). - `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release. Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`. diff --git a/scripts/MediaHive.spec b/scripts/MediaHive.spec index 9d36c6f..2f8ddc1 100644 --- a/scripts/MediaHive.spec +++ b/scripts/MediaHive.spec @@ -5,8 +5,9 @@ # pyinstaller --noconfirm --clean scripts/MediaHive.spec # # Or use the build script (recommended—handles versioning and packaging): -# uv run scripts/winbuild.py +# uv run scripts/guibuild.py +import os import sys import mediahive.winmain import mediahive.server @@ -20,7 +21,15 @@ _frontend_build = _pkg / "frontend-build" _logo_webp = _pkg / "assets" / "mediahive.webp" _icon_win = _pkg / "assets" / "mediahive.ico" _icon_mac = _pkg / "assets" / "mediahive.icns" -_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg" +# ffmpeg staging lives in the persistent build cache (same logic as +# scripts/guibuild.py); fall back to the legacy build/ffmpeg location. +if sys.platform == "win32": + _cache_base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) +else: + _cache_base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) +_tools_dir = _cache_base / "mediahive-build" / "ffmpeg" +if not _tools_dir.exists(): + _tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg" _tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"] _binaries = [] diff --git a/scripts/guibuild.py b/scripts/guibuild.py index bba948f..820eda8 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -2,7 +2,7 @@ """Build the desktop GUI application and package it with Velopack. Usage: - uv run scripts/winbuild.py + uv run scripts/guibuild.py This runs in the project environment where dependencies are available via pyproject.toml. @@ -26,7 +26,6 @@ import shutil import stat import subprocess import sys -import tarfile import urllib.request import zipfile from pathlib import Path @@ -41,10 +40,21 @@ _FFMPEG_URL = ( _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" + +def _build_cache_dir() -> Path: + """Return the persistent cross-build cache dir for downloaded tools (CI wipes build/).""" + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local") + return Path(base) / "mediahive-build" + base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") + return Path(base) / "mediahive-build" + + +_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg" + # 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. @@ -53,7 +63,7 @@ _VPK_URL = ( f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}" f"/vpk.{_VPK_VERSION}.nupkg" ) -_VPK_STAGING = _REPO_ROOT / "build" / "vpk" +_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}" def _platform_zip_suffix() -> str: @@ -73,7 +83,7 @@ def _platform_zip_suffix() -> str: def fetch_ffmpeg() -> Path: - """Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/.""" + """Download latest ffmpeg.exe from BtbN builds into the persistent build cache.""" dest = _FFMPEG_STAGING / "ffmpeg.exe" if dest.exists(): print(f"ffmpeg already staged at {dest}, skipping download.") @@ -98,7 +108,7 @@ def fetch_ffmpeg() -> Path: def fetch_macos_arm64_binaries() -> dict[str, Path]: - """Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/.""" + """Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache.""" if sys.platform != "darwin" or platform.machine().lower() not in { "arm64", "aarch64", @@ -196,7 +206,7 @@ def ensure_macos_icon() -> Path: def fetch_vpk() -> Path: - """Download the Velopack CLI package into build/vpk/ (cached). + """Download the Velopack CLI package into the persistent build cache. Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`. """ @@ -219,15 +229,6 @@ def fetch_vpk() -> Path: 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: @@ -250,57 +251,50 @@ def _dotnet_has_runtime(exe: Path) -> bool: def fetch_dotnet() -> str: - """Resolve a dotnet host with a modern (>= 8) runtime, bootstrapping one into build/dotnet/ if none is found. + """Resolve a dotnet host with a modern (>= 8) runtime from the system. - 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. + The .NET SDK is a build prerequisite installed on the build machine — + downloading a runtime per build is slow and flaky. 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) + if sys.platform == "win32": + candidates += [ + Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name, + Path(r"C:\Program Files\dotnet") / exe_name, + ] + elif sys.platform == "darwin": + candidates += [ + Path("/opt/homebrew/bin") / exe_name, + Path("/usr/local/share/dotnet") / exe_name, + ] + else: + candidates += [ + Path("/usr/share/dotnet") / exe_name, + Path("/usr/lib/dotnet") / exe_name, + Path.home() / ".dotnet" / exe_name, + ] 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 + raise RuntimeError( + "No dotnet with Microsoft.NETCore.App >= 8 found. " + "Install the .NET SDK on this build machine " + "(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; " + "Linux: distro `dotnet-sdk` package or the dotnet-install script)." ) - 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: