Adds the PyQt6 Windows client (Homepage, Scan & results, About & updates, Settings), ClamAV scanning and quarantine, browser-download watch with an exclusive scan gate, Bolt loopback/PKCE sign-in with DPAPI key storage, Gitea release feed with checksum verification, opt-in diagnostics, and Windows/portable build tooling. Co-Authored-By: Claude Code <noreply@anthropic.com>
177 lines
6.9 KiB
Python
177 lines
6.9 KiB
Python
#!/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())
|