#!/usr/bin/env python3 """ MeManga RELEASE build — end-user app. python build_app.py Output: release/MeManga/MeManga.exe (Windows; plus _internal/) release/MeManga/MeManga (Linux; plus _internal/) release/MeManga (macOS; one file, wrapped into MeManga.app by packaging/macos_app.py) release/THIRD_PARTY_NOTICES.txt This produces the app that ships on the GitHub release page: - No console window (clean double-click on Windows) - GUI only — no separate CLI .exe - Windows / Linux: one folder, not UPX-compressed, so the LGPL components (Qt, PySide6, Shiboken6, img2pdf) stay separate files users can replace (issue #381). The notices sit at the top of the folder and the release workflow archives it as .zip / .tar.gz. - On first launch the app downloads Playwright's Firefox driver under the user's local %APPDATA% (~80 MB, one-time) so we don't have to bundle 200 MB of browser into every download. If you want a dev build with the console + tracebacks, run `python build.py` instead. python build_app.py --no-install builds from the packages already installed instead of reinstalling the pinned ones first. The notices' LGPL section points users here to build with a modified component, which a reinstall would overwrite (issue #381). """ from __future__ import annotations import argparse import os import platform import shutil import subprocess import sys from pathlib import Path # Force UTF-8 on stdout/stderr so the unicode glyphs we print (→ ✓ ✗ —) # don't crash on Windows CI runners, whose default stdout encoding is # cp1252 and explodes with UnicodeEncodeError on those characters. # Safe to call on any platform — reconfigure exists since Python 3.7 # and a no-op when the stream is already UTF-8 (macOS, Linux). try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except (AttributeError, ValueError): # Stream isn't a TextIOWrapper (rare; some test runners) — skip. pass ROOT = Path(__file__).resolve().parent PACKAGING = ROOT / "packaging" SPEC = PACKAGING / "memanga-release.spec" BUILD_TMP = ROOT / "build" DIST_TMP = ROOT / "dist" # Final binary lands here. A dedicated subdirectory avoids the # macOS / Windows case-insensitive filesystem trap where a # repo-root file called `MeManga` collides with the lowercase # `memanga/` source directory and Path.unlink() fails with EPERM # trying to remove what is actually a directory. RELEASE_DIR = ROOT / "release" # Written by the release spec next to the binary; see # packaging/third_party_notices.py. NOTICES = "THIRD_PARTY_NOTICES.txt" NOTICES_TOOL = PACKAGING / "third_party_notices.py" # Windows / Linux builds are one folder (issue #381); macOS stays one file # for the .app wrapper and its signing. ONEDIR = platform.system() != "Darwin" def install_dependencies() -> bool: """Install deps for the release build. Release builds install from `requirements/build.txt`, generated from pyproject.toml with exact pins and hashes for every direct and transitive dependency, PyInstaller included, so that the binary we ship from GitHub Actions today and the one you can rebuild from the same tag in six months use IDENTICAL package versions. Every row must carry a hash and only wheels are installed, so no package is built from source with build tools the lock does not pin, and every row is reinstalled, so a copy already installed is replaced by the hash-checked download (#316). """ print("=== Installing dependencies ===") name = "requirements/build.txt" lock = ROOT / name if not lock.exists(): print(f" ! {name} missing — regenerate it with:") print(" ! python scripts/lock_dependencies.py") return False print(f" → installing from {name} (pinned for reproducible build)") r = subprocess.run( [sys.executable, "-m", "pip", "install", "--require-hashes", "--only-binary", ":all:", "--force-reinstall", "-r", str(lock), "--no-deps", "-q"], ) if r.returncode != 0: print(f" ! pip install -r {name} failed") return False print(f" ✓ {name}") return True def verify_imports() -> bool: print("\n=== Verifying imports ===") modules = [ "img2pdf", "PIL", "bs4", "cloudscraper", "pikepdf", "yaml", "PySide6", "certifi", "requests", "rich", "playwright", "playwright_stealth", ] missing = [] for mod in modules: try: __import__(mod) print(f" ✓ {mod}") except ImportError: print(f" ✗ {mod}") missing.append(mod) if missing: print(f"\nMissing: {' '.join(missing)} — run `pip install -r requirements/build.txt`") return False return True def run_pyinstaller() -> bool: layout = "one-folder" if ONEDIR else "one-file" print(f"\n=== Building release (PyInstaller, {layout}, no console) ===") if not SPEC.exists(): print(f" ! spec missing: {SPEC}") return False cmd = [ sys.executable, "-m", "PyInstaller", str(SPEC), "--noconfirm", "--clean", "--distpath", str(DIST_TMP), "--workpath", str(BUILD_TMP), ] r = subprocess.run(cmd) return r.returncode == 0 def _exe_name() -> str: return "MeManga.exe" if platform.system() == "Windows" else "MeManga" def _remove(path: Path) -> None: # Removing `release/MeManga` is safe even on case-insensitive # filesystems: it cannot alias the `memanga/` package directory, they # live at different paths. A rerun may find the other layout here (an # older one-file binary where the folder goes, or the reverse). if path.is_dir() and not path.is_symlink(): shutil.rmtree(path) elif path.exists() or path.is_symlink(): path.unlink() def collect_artifact() -> Path | None: # One-folder: dist/MeManga/ holds the launcher and _internal/. # One-file (macOS): dist/MeManga is the executable itself. src = DIST_TMP / "MeManga" if ONEDIR else DIST_TMP / _exe_name() launcher = src / _exe_name() if ONEDIR else src if not launcher.is_file(): print(f"\n! Expected output not found: {launcher}") return None RELEASE_DIR.mkdir(parents=True, exist_ok=True) dest = RELEASE_DIR / src.name _remove(dest) shutil.move(str(src), str(dest)) launcher = dest / _exe_name() if ONEDIR else dest try: launcher.chmod(launcher.stat().st_mode | 0o755) except Exception: pass # The release spec writes the third-party notices next to the binary # (issue #380); every release asset ships them, so a build without # them is incomplete. notices_src = DIST_TMP / NOTICES if not notices_src.is_file(): print(f"\n! Third-party notices not found: {notices_src}") return None if ONEDIR: # Also at the top of the folder, next to the launcher, where users # see them first (issue #381); the copy in _internal/ is the one # the app itself carries. shutil.copy2(notices_src, dest / NOTICES) notices_dest = RELEASE_DIR / NOTICES _remove(notices_dest) shutil.move(str(notices_src), str(notices_dest)) # Sweep PyInstaller scratch dirs — only the release/ output remains. for d in (BUILD_TMP, DIST_TMP): if d.exists(): shutil.rmtree(d, ignore_errors=True) return dest def check_bundle(folder: Path) -> bool: """The LGPL components must stay separate, unpacked files next to valid notices (issue #381).""" print("\n=== Checking notices and replaceable LGPL files ===") r = subprocess.run( [sys.executable, str(NOTICES_TOOL), "check-bundle", str(folder)]) return r.returncode == 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="MeManga release build") parser.add_argument( "--no-install", action="store_true", help="build from the packages already installed (e.g. a modified " "LGPL component) instead of reinstalling requirements/build.txt") args = parser.parse_args(argv) os.chdir(ROOT) if args.no_install: print("=== Skipping dependency install (--no-install) ===") elif not install_dependencies(): return 1 if not verify_imports(): return 1 if not run_pyinstaller(): print("\n! PyInstaller failed — leaving build/ + dist/ for inspection") return 1 artifact = collect_artifact() if not artifact: return 1 if ONEDIR and not check_bundle(artifact): return 1 files = [artifact] if artifact.is_file() else [ f for f in artifact.rglob("*") if f.is_file() and not f.is_symlink()] size_mb = sum(f.stat().st_size for f in files) / (1024 * 1024) print(f"\n=== Release build complete ===") print(f"Output: {artifact} ({size_mb:.1f} MB)") print(f"Notices: {RELEASE_DIR / NOTICES}") print("Ship the notices file with the app on the GitHub release page.") return 0 if __name__ == "__main__": sys.exit(main())