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:
Executable
BIN
Binary file not shown.
@@ -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=@…`.
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)"
|
||||
Reference in New Issue
Block a user