From 5300fd0c9ceefd63b1fff44ddd7789b12a232db4 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 22 Sep 2026 04:07:30 +0000 Subject: [PATCH] Bundle PyQtWebEngine on macOS and ship the app as a DMG pywebview[qt] no longer depends on PyQtWebEngine, so fresh CI builds produced a macOS app without its Chromium engine that crashed on launch. Package MediaHive.app as a compressed DMG instead of zipping the raw onedir folder; release.py accepts .dmg artifacts. --- pyproject.toml | 2 ++ scripts/guibuild.py | 40 +++++++++++++++++++++++++++++++++++----- scripts/release.py | 33 ++++++++++++++++++--------------- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ea38a64..8ba6e34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,8 @@ gui = [ "pywebview>=6.2.1; platform_system == 'Windows'", "qtpy>=2.4.1; platform_system == 'Darwin'", "PyQt5>=5.15.11; platform_system == 'Darwin'", + # pywebview[qt] no longer pulls this in; the macOS Qt backend needs it + "PyQtWebEngine>=5.15.7; platform_system == 'Darwin'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pyinstaller>=6.0", ] diff --git a/scripts/guibuild.py b/scripts/guibuild.py index fb1bbfc..9c04440 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -1,5 +1,5 @@ #!/usr/bin/env -S uv run -"""Build the desktop GUI application and package it as a version-numbered ZIP. +"""Build the desktop GUI application and package it as a versioned ZIP/DMG. Usage: uv run scripts/winbuild.py @@ -13,7 +13,7 @@ This script: 3. On Windows, downloads the latest ffmpeg.exe for bundling 4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling 4. Builds MediaHive using PyInstaller - 5. Creates a ZIP file with the version number + 5. Creates a versioned ZIP (Windows/Linux) or DMG (macOS) artifact """ import io @@ -238,6 +238,33 @@ 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") @@ -258,10 +285,13 @@ def main() -> None: ) build_wheel() build_executable() - zip_path = create_zip(version) + if sys.platform == "darwin": + artifact_path = create_dmg(version) + else: + artifact_path = create_zip(version) - print(f"✓ Built successfully: {zip_path}") - print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB") + 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) diff --git a/scripts/release.py b/scripts/release.py index d10b6ce..d6ee465 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -67,17 +67,17 @@ def load_token() -> str: # ZIP + dist helpers # --------------------------------------------------------------------------- -# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc. +# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.dmg, etc. # Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip -_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$") +_CLEAN_ARTIFACT_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg)$") -def find_releasable_zips() -> list[tuple[Path, str, str]]: - """Return (path, version, platform_tag) for clean-versioned ZIPs in build/.""" +def find_releasable_artifacts() -> list[tuple[Path, str, str]]: + """Return (path, version, platform_tag) for clean-versioned ZIPs/DMGs in build/.""" build_dir = REPO_ROOT / "build" results = [] - for p in sorted(build_dir.glob("MediaHive-*.zip")): - m = _CLEAN_ZIP_RE.match(p.name) + for p in sorted(build_dir.glob("MediaHive-*")): + m = _CLEAN_ARTIFACT_RE.match(p.name) if m: results.append((p, m.group(1), m.group(2))) return results @@ -180,7 +180,10 @@ def upload_asset( """Upload a file to the release and return the download URL.""" url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets" size_mb = path.stat().st_size / (1024 * 1024) - mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream" + mime = { + ".zip": "application/zip", + ".dmg": "application/x-apple-diskimage", + }.get(path.suffix, "application/octet-stream") print(f"Uploading {path.name} ({size_mb:.1f} MB) ...") with Path(path).open("rb") as fh: resp = client.post( @@ -222,10 +225,10 @@ def main() -> None: cfg = load_gitea_config() token = load_token() - zips = find_releasable_zips() - if not zips: + artifacts = find_releasable_artifacts() + if not artifacts: print( - "No clean-versioned ZIPs found in build/.\n" + "No clean-versioned ZIPs/DMGs found in build/.\n" "Run scripts/guibuild.py first.", file=sys.stderr, ) @@ -234,7 +237,7 @@ def main() -> None: # Validate all dist files exist before touching Gitea dist_files: dict[str, list[Path]] = {} if not args.no_dist: - for _, version, _platform_tag in zips: + for _, version, _platform_tag in artifacts: dist_files[version] = find_dist_files(version) base_url = cfg["url"].rstrip("/") @@ -242,7 +245,7 @@ def main() -> None: with httpx.Client(headers=gitea_headers(token)) as client: releases: dict[str, tuple[int, set[str]]] = {} - for zip_path, version, platform_tag in zips: + for artifact_path, version, platform_tag in artifacts: print(f"\nReleasing {version} ...") tag = f"v{version}" if version not in releases: @@ -257,11 +260,11 @@ def main() -> None: upload_asset(client, base_url, repo, release_id, path) release_id, uploaded = releases[version] - if zip_path.name in uploaded: - print(f"Skipping {zip_path.name}, already on the release.") + if artifact_path.name in uploaded: + print(f"Skipping {artifact_path.name}, already on the release.") continue print(f"Uploading platform artifact: {platform_tag}") - upload_asset(client, base_url, repo, release_id, zip_path) + upload_asset(client, base_url, repo, release_id, artifact_path) print(f" ✓ {tag} published") print("\nDone. To publish to PyPI, run:")