diff --git a/mediahive/winmain.py b/mediahive/winmain.py index e5e30b3..ad15709 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -1126,8 +1126,26 @@ def _configure_windows_event_loop_policy() -> None: asyncio.set_event_loop_policy(policy_cls()) +def _strip_mark_of_the_web() -> None: + """Remove Zone.Identifier streams from bundled DLLs (frozen Windows only). + + Files extracted from a downloaded ZIP carry the Mark-of-the-Web, and the + .NET Framework CLR refuses to load such assemblies — pythonnet then fails + with "Failed to resolve Python.Runtime.Loader.Initialize from + .../Python.Runtime.dll". Strip the mark from the bundled DLLs before + pywebview loads the CLR. + """ + if not getattr(sys, "frozen", False) or sys.platform != "win32": + return + meipass = Path(sys._MEIPASS) # type: ignore[attr-defined] + for dll in meipass.rglob("*.dll"): + with contextlib.suppress(OSError): + os.remove(f"{dll}:Zone.Identifier") + + def winmain() -> None: _configure_windows_event_loop_policy() + _strip_mark_of_the_web() parser = argparse.ArgumentParser(description="MediaHive") parser.add_argument( diff --git a/scripts/guibuild.py b/scripts/guibuild.py index 9c04440..31c4d7a 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -18,6 +18,7 @@ This script: import io import platform +import re import shutil import stat import subprocess @@ -40,6 +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" +) +_WIX_STAGING = _REPO_ROOT / "build" / "wix311" +_WIX_WXS = _REPO_ROOT / "scripts" / "installer" / "MediaHive.wxs" + def _platform_zip_suffix() -> str: machine = platform.machine().lower() @@ -180,6 +190,80 @@ 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 + + _WIX_STAGING.mkdir(parents=True, exist_ok=True) + print(f"Downloading WiX from {_WIX_URL} ...") + with urllib.request.urlopen(_WIX_URL) as resp: + data = resp.read() + + with zipfile.ZipFile(io.BytesIO(data)) as zf: + zf.extractall(_WIX_STAGING) + + print(f"WiX staged at {_WIX_STAGING}") + return _WIX_STAGING + + +def build_msi(version: str) -> Path: + """Build a per-user MSI installer from the PyInstaller output folder. + + MSI-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}") + + 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" + + def run(tool: str, args: list[str]) -> None: + cmd = [str(wix / tool), *args] + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=_REPO_ROOT) + if result.returncode != 0: + raise RuntimeError(f"{tool} failed with exit code {result.returncode}") + + 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 + + def read_version() -> str: """Read version via setuptools_scm (same logic as hatch-vcs).""" return setuptools_scm.get_version(root=str(_REPO_ROOT)) @@ -292,6 +376,11 @@ def main() -> None: print(f"✓ Built successfully: {artifact_path}") 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") 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 new file mode 100644 index 0000000..5ee3171 --- /dev/null +++ b/scripts/installer/MediaHive.wxs @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/release.py b/scripts/release.py index d6ee465..7440af8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -67,13 +67,14 @@ def load_token() -> str: # ZIP + dist helpers # --------------------------------------------------------------------------- -# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.dmg, etc. +# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-win64.msi, +# 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)$") +_CLEAN_ARTIFACT_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|msi)$") def find_releasable_artifacts() -> list[tuple[Path, str, str]]: - """Return (path, version, platform_tag) for clean-versioned ZIPs/DMGs in build/.""" + """Return (path, version, platform_tag) for clean-versioned ZIPs/DMGs/MSIs in build/.""" build_dir = REPO_ROOT / "build" results = [] for p in sorted(build_dir.glob("MediaHive-*")): @@ -183,6 +184,7 @@ 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: @@ -228,7 +230,7 @@ def main() -> None: artifacts = find_releasable_artifacts() if not artifacts: print( - "No clean-versioned ZIPs/DMGs found in build/.\n" + "No clean-versioned ZIPs/DMGs/MSIs found in build/.\n" "Run scripts/guibuild.py first.", file=sys.stderr, )