BoltClam 1.0.0: ClamAV desktop protection with Bolt sign-in

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>
This commit is contained in:
TheMajesticMagician
2026-10-25 16:21:14 -06:00
co-authored by Claude Code
commit addd00364d
76 changed files with 12503 additions and 0 deletions
BIN
View File
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
# Packaging
## Windows-native build
On Windows 10/11 with Python 3.11 x64:
```powershell
.\build_windows.ps1
```
The script creates `.venv-build`, installs PyInstaller, builds
`dist\BoltClam\BoltClam.exe`, zips it, and runs Inno Setup when it is
installed. `-OneFile` builds a single executable instead.
## Portable cross-build (Linux/macOS)
`build_portable_windows.py` downloads the official Python embeddable
distribution and Windows PyQt6 wheels, copies `src/boltclam` into `app/`,
adds `resources/`, and places a tiny launcher at the root. It does not run
the Windows app on the build host.
```bash
.venv/bin/python packaging/build_portable_windows.py
```
The build uses the checked-in `BoltClam-launcher.exe` unless `--zig` (or a
`zig` on `PATH`) is available, in which case it compiles `launcher.c` from
source. The prebuilt launcher is a 176,640-byte GUI-subsystem PE32+ binary:
```
SHA-256 95cdb1e0a652f0b2e3870220cf1ec79c1e2972bc56e307bcb1381b2b9591a24b
```
To rebuild it with Zig 0.14.1:
```bash
zig cc -target x86_64-windows-gnu -O2 -Wl,--subsystem,windows \
packaging/launcher.c -o packaging/BoltClam-launcher.exe
```
The output portable package is written to
`dist/BoltClam-1.0.0-windows-x64-portable.zip` with `dist/SHA256SUMS.txt`.
It is unsigned; the release checksum is the integrity signal.
## Layout of the portable package
```
BoltClam/
BoltClam.exe GUI launcher
pythonw.exe embedded Python 3.11.9
python311.dll
python311.zip stdlib
python311._pth
app/boltclam/ app source
resources/ icon, fonts, QSS rasters
Lib/site-packages/ Windows PyQt6 6.11 wheels
```
Keep the whole folder together. The launcher refuses to start if
`pythonw.exe` or `app/` is missing rather than showing a silent failure.
If the app exits before a window appears, run `python.exe -m boltclam` from
the extracted folder in a command prompt to see the import/startup error.
## Release upload
The `Windows release` workflow runs `build_windows.ps1` and uploads the
installer/zip to a Gitea release. On a machine without the workflow, after
building:
```powershell
$env:GITEA_TOKEN = "…"
.\packaging\publish_release.ps1 -Tag v1.0.0
```
`publish_release.ps1` creates (or reuses) the release and uploads
`dist/BoltClam-*.exe`, `dist/BoltClam-*.zip`, and `dist/SHA256SUMS.txt`.
Gitea's asset endpoint expects multipart form data, so the script uses
`curl.exe -F attachment=@…`.
+176
View File
@@ -0,0 +1,176 @@
#!/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())
+79
View File
@@ -0,0 +1,79 @@
/*
* BoltClam portable Windows launcher.
*
* This is a tiny GUI-subsystem bootstrap for the portable package. It finds
* the runtime beside itself, starts pythonw.exe with the bundled app, and
* exits immediately. It contains no product logic; the Qt application lives
* in app/boltclam.
*
* Build (from the repository root):
* zig cc -target x86_64-windows-gnu -O2 -mwindows packaging/launcher.c \
* -o build/portable/BoltClam.exe
*/
#define UNICODE
#define _UNICODE
#include <windows.h>
#include <wchar.h>
static void fail(const wchar_t *message) {
MessageBoxW(NULL, message, L"BoltClam", MB_OK | MB_ICONERROR);
}
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR command_line, int show) {
(void)instance;
(void)previous;
(void)command_line;
(void)show;
wchar_t exe[MAX_PATH];
DWORD length = GetModuleFileNameW(NULL, exe, MAX_PATH);
if (length == 0 || length >= MAX_PATH) {
fail(L"BoltClam could not locate its own executable.");
return 1;
}
wchar_t *slash = wcsrchr(exe, L'\\');
if (slash == NULL) {
fail(L"BoltClam could not locate its runtime folder.");
return 1;
}
*slash = L'\0';
wchar_t pythonw[MAX_PATH * 2];
_snwprintf(pythonw, MAX_PATH * 2, L"%s\\pythonw.exe", exe);
if (GetFileAttributesW(pythonw) == INVALID_FILE_ATTRIBUTES) {
fail(L"BoltClam's runtime is incomplete: pythonw.exe is missing.\n\n"
L"Extract the whole portable folder, not just BoltClam.exe.");
return 1;
}
wchar_t appdir[MAX_PATH * 2];
_snwprintf(appdir, MAX_PATH * 2, L"%s\\app", exe);
if (GetFileAttributesW(appdir) == INVALID_FILE_ATTRIBUTES) {
fail(L"BoltClam's runtime is incomplete: the app folder is missing.\n\n"
L"Extract the whole portable folder, not just BoltClam.exe.");
return 1;
}
SetCurrentDirectoryW(appdir);
wchar_t cmdline[MAX_PATH * 4];
_snwprintf(cmdline, MAX_PATH * 4, L"\"%s\" -m boltclam", pythonw);
STARTUPINFOW startup;
PROCESS_INFORMATION process;
ZeroMemory(&startup, sizeof(startup));
ZeroMemory(&process, sizeof(process));
startup.cb = sizeof(startup);
if (!CreateProcessW(pythonw, cmdline, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, NULL, appdir,
&startup, &process)) {
wchar_t error[512];
_snwprintf(error, 512,
L"BoltClam could not start its runtime (error %lu).",
(unsigned long)GetLastError());
fail(error);
return 1;
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return 0;
}
+85
View File
@@ -0,0 +1,85 @@
#requires -Version 5.1
[CmdletBinding()]
param(
[string]$Tag = "",
[string]$Token = ""
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$repo = $env:GITHUB_REPOSITORY
if (-not $repo) { $repo = "TheMajesticNetwork/BoltClam" }
$server = $env:GITHUB_SERVER_URL
if (-not $server) { $server = "https://git.themajesticnetwork.com" }
$server = $server.TrimEnd("/")
$api = "$server/api/v1/repos/$repo"
if (-not $Tag) {
$Tag = $env:GITHUB_REF_NAME
}
if (-not $Tag) { $Tag = "v1.0.0" }
if (-not $Token) { $Token = $env:GITEA_TOKEN }
if (-not $Token) { $Token = $env:GITHUB_TOKEN }
if (-not $Token) {
throw "Set GITEA_TOKEN (a Gitea token with repo write access) before publishing."
}
$headers = @{
Authorization = "token $Token"
Accept = "application/json"
}
$releaseBody = @{
tag_name = $Tag
name = "BoltClam $Tag"
body = "BoltClam $Tag`n`nSee SHA256SUMS.txt for checksums."
draft = $false
prerelease = ($Tag -match "-")
} | ConvertTo-Json
try {
$release = Invoke-RestMethod -Method Post -Uri "$api/releases" `
-Headers $headers -ContentType "application/json" -Body $releaseBody
Write-Host "Created release $($release.tag_name)"
} catch {
$status = $_.Exception.Response.StatusCode.value__
if ($status -ne 409) { throw }
$release = Invoke-RestMethod -Uri "$api/releases/tags/$Tag" -Headers $headers
Write-Host "Release $Tag already exists; adding assets"
}
$existing = @()
try {
$existing = @(Invoke-RestMethod -Uri "$api/releases/$($release.id)/assets" -Headers $headers)
} catch {
$existing = @()
}
foreach ($asset in $existing) {
if ($asset.name -match '^BoltClam-.*\.(exe|zip)$' -or $asset.name -eq "SHA256SUMS.txt") {
Invoke-RestMethod -Method Delete `
-Uri "$api/releases/$($release.id)/assets/$($asset.id)" -Headers $headers | Out-Null
Write-Host "Removed old asset $($asset.name)"
}
}
$files = Get-ChildItem -Path (Join-Path $Root "dist") -File | Where-Object {
$_.Name -match '^BoltClam-.*\.(exe|zip)$' -or $_.Name -eq "SHA256SUMS.txt"
}
if (-not $files) { throw "No release artifacts found in dist." }
foreach ($file in $files) {
$name = [uri]::EscapeDataString($file.Name)
# Gitea's attachment endpoint is multipart/form-data, unlike GitHub's
# binary upload. Windows 10/11 ships curl.exe; use it for the file field.
& curl.exe --fail --silent --show-error -X POST `
-H "Authorization: token $Token" `
-F "attachment=@$($file.FullName)" `
"$api/releases/$($release.id)/assets?name=$name"
if ($LASTEXITCODE -ne 0) {
throw "Upload failed for $($file.Name)."
}
Write-Host "Uploaded $($file.Name)"
}
Write-Host "Release published: $($release.html_url)"