#!/usr/bin/env python3 """Build the portable Windows BoltClam package from a Linux/macOS host. The result is a folder and zip containing: BoltClam.exe tiny GUI launcher (packaging/launcher.c) pythonw.exe official Windows embeddable Python 3.11 python311._pth points at app/ and the Windows PyQt6 wheels app/boltclam/ BoltClam source resources/ fonts and icon assets Lib/site-packages/ Windows PyQt6 wheels This is the no-Windows-machine path. On a Windows build host, prefer `build_windows.ps1` / PyInstaller for a branded one-file EXE and Inno Setup installer. This script downloads public build inputs and never uses a platform-specific Python interpreter to run the Windows app. """ from __future__ import annotations import argparse import hashlib import os import shutil import subprocess import sys import tarfile import urllib.request import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BUILD = ROOT / "build" / "portable" CACHE = ROOT / "build" / "cache" DIST = ROOT / "dist" VERSION = "1.0.0" PYTHON_VERSION = "3.11.9" PYTHON_URL = (f"https://www.python.org/ftp/python/{PYTHON_VERSION}/" f"python-{PYTHON_VERSION}-embed-amd64.zip") PYTHON_SHA256 = "009d6bf7e3b2ddca3d784fa09f90fe54336d5b60f0e0f305c37f400bf83cfd3b" PYQT_PACKAGES = ("PyQt6==6.11.0", "PyQt6-Qt6==6.11.2", "PyQt6-sip==13.13.0") ZIG_VERSION = "0.14.1" ZIG_URL = f"https://ziglang.org/download/{ZIG_VERSION}/zig-x86_64-linux-{ZIG_VERSION}.tar.xz" def _sha256(path: Path) -> str: digest = hashlib.sha256() with open(path, "rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def download(url: str, destination: Path, expected: str = "") -> Path: destination.parent.mkdir(parents=True, exist_ok=True) if destination.is_file() and (not expected or _sha256(destination) == expected): return destination print(f"Downloading {url}") temp = destination.with_suffix(destination.suffix + ".part") with urllib.request.urlopen(url, timeout=120) as response, open(temp, "wb") as handle: shutil.copyfileobj(response, handle) if expected and _sha256(temp) != expected: temp.unlink(missing_ok=True) raise RuntimeError(f"Checksum mismatch for {url}") os.replace(temp, destination) return destination def find_zig(explicit: str) -> Path | None: if explicit: return Path(explicit) found = shutil.which("zig") if found: return Path(found) return None def download_zig() -> Path: archive = download(ZIG_URL, CACHE / f"zig-{ZIG_VERSION}.tar.xz") target = CACHE / f"zig-x86_64-linux-{ZIG_VERSION}" if not (target / "zig").is_file(): print("Extracting Zig") with tarfile.open(archive, "r:xz") as tar: tar.extractall(CACHE) return target / "zig" def build(use_zig: str = "", keep_build: bool = True) -> Path: if BUILD.exists(): shutil.rmtree(BUILD) BUILD.mkdir(parents=True) CACHE.mkdir(parents=True, exist_ok=True) python_zip = download(PYTHON_URL, CACHE / f"python-{PYTHON_VERSION}-embed-amd64.zip", PYTHON_SHA256) with zipfile.ZipFile(python_zip) as archive: archive.extractall(BUILD) app = BUILD / "app" app.mkdir() shutil.copytree(ROOT / "src" / "boltclam", app / "boltclam", ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")) # paths.resource_dir() resolves to parents[2]/resources, i.e. the # portable root, for app/boltclam/paths.py. Keep resources there. shutil.copytree(ROOT / "resources", BUILD / "resources") site_packages = BUILD / "Lib" / "site-packages" site_packages.mkdir(parents=True) print("Installing Windows PyQt6 wheels") subprocess.check_call([ sys.executable, "-m", "pip", "install", "--quiet", "--target", str(site_packages), "--platform", "win_amd64", "--python-version", "3.11", "--implementation", "cp", "--abi", "cp311", "--only-binary=:all:", *PYQT_PACKAGES, ]) (BUILD / "python311._pth").write_text( "python311.zip\n.\nLib\\site-packages\napp\nimport site\n", encoding="utf-8") zig = find_zig(use_zig) launcher_source = ROOT / "packaging" / "launcher.c" launcher_binary = ROOT / "packaging" / "BoltClam-launcher.exe" if zig is None and launcher_binary.is_file(): print("Using the checked-in portable launcher") shutil.copy2(launcher_binary, BUILD / "BoltClam.exe") else: if zig is None: zig = download_zig() print(f"Compiling launcher with {zig}") subprocess.check_call([ str(zig), "cc", "-target", "x86_64-windows-gnu", "-O2", "-Wl,--subsystem,windows", str(launcher_source), "-o", str(BUILD / "BoltClam.exe"), ]) (BUILD / "README.txt").write_text( "BoltClam portable for Windows x64\r\n" "=================================\r\n\r\n" "Double-click BoltClam.exe. Sign in with your Bolt account when the\r\n" "browser opens. ClamAV is installed separately: install it from\r\n" "https://www.clamav.net/downloads, then open Settings and point BoltClam\r\n" "at clamscan.exe (Auto-detect usually finds it).\r\n\r\n" "The first definition update downloads ClamAV signatures into\r\n" "%LOCALAPPDATA%\\BoltClam\\clamav-db. No administrator rights are needed\r\n" "for BoltClam's own data.\r\n\r\n" "This build is unsigned. Windows SmartScreen may warn about a new\r\n" "publisher; use the checksum published with the release.\r\n\r\n" "Keep the whole folder together. BoltClam.exe is only a launcher for\r\n" "pythonw.exe and app/.\r\n\r\n" "Troubleshooting: if BoltClam.exe does nothing, open a command prompt in\r\n" "this folder and run `python.exe -m boltclam` to see the startup error.\r\n", encoding="utf-8") DIST.mkdir(parents=True, exist_ok=True) zip_path = DIST / f"BoltClam-{VERSION}-windows-x64-portable.zip" print(f"Writing {zip_path}") with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: for path in sorted(BUILD.rglob("*")): if path.is_file(): archive.write(path, arcname="BoltClam/" + path.relative_to(BUILD).as_posix()) digest = _sha256(zip_path) (DIST / "SHA256SUMS.txt").write_text(f"{digest} {zip_path.name}\n", encoding="utf-8") print(f"{zip_path} ({zip_path.stat().st_size / (1024 * 1024):.1f} MiB)") print(f"SHA-256 {digest}") return zip_path def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--zig", default="", help="path to a Zig executable") args = parser.parse_args() build(use_zig=args.zig) return 0 if __name__ == "__main__": raise SystemExit(main())