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
+5
View File
@@ -0,0 +1,5 @@
*.exe binary
*.ico binary
*.png binary
*.ttf binary
*.zip binary
+58
View File
@@ -0,0 +1,58 @@
name: Windows release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag (for a manual run, e.g. v1.0.0)"
required: false
jobs:
build-windows:
# Gitea Actions needs a runner with these labels. The stock Gitea
# act_runner is Linux-only; register this repository on a Windows runner,
# or run build_windows.ps1 on a Windows PC and upload the release manually.
runs-on: windows-latest
steps:
- name: Check out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
architecture: x64
- name: Install Inno Setup when available
shell: pwsh
continue-on-error: true
run: |
if (Get-Command choco -ErrorAction SilentlyContinue) {
choco install innosetup -y --no-progress
}
- name: Build
shell: pwsh
run: ./build_windows.ps1
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: BoltClam-windows
path: |
dist/*.zip
dist/*.exe
dist/SHA256SUMS.txt
if-no-files-found: error
- name: Publish Gitea release
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./packaging/publish_release.ps1
+32
View File
@@ -0,0 +1,32 @@
# Byte-compiled / caches
__pycache__/
*.py[cod]
*.pyo
.pytest_cache/
.coverage
htmlcov/
# Local environments
.venv/
.venv-build/
env/
venv/
# Build outputs
build/
dist/
*.egg-info/
*.spec.bak
*.log
# OS/editor noise
.DS_Store
Thumbs.db
desktop.ini
.idea/
.vscode/
# Local runtime data
*.db
*.db-wal
*.db-shm
+79
View File
@@ -0,0 +1,79 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for BoltClam.
Default: a one-folder Windows build (dist/BoltClam/BoltClam.exe), which is
what the Inno Setup installer packages. Set BOLTCLAM_ONEFILE=1 for a single
BoltClam.exe instead.
"""
from pathlib import Path
import os
import sys
ROOT = Path(SPECPATH).resolve()
ONE_FILE = os.environ.get("BOLTCLAM_ONEFILE") == "1"
VERSION_FILE = ROOT / "resources" / "version_info.txt"
ICON_FILE = ROOT / "resources" / "boltclam.ico"
a = Analysis(
[str(ROOT / "src" / "boltclam" / "__main__.py")],
pathex=[str(ROOT / "src")],
binaries=[],
datas=[(str(ROOT / "resources"), "resources")],
hiddenimports=[
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
"PyQt6.sip",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["tkinter", "unittest", "pydoc"],
noarchive=False,
optimize=1,
)
pyz = PYZ(a.pure)
exe_kwargs = dict(
name="BoltClam",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(ICON_FILE) if ICON_FILE.exists() else None,
version=str(VERSION_FILE) if sys.platform == "win32" and VERSION_FILE.exists() else None,
)
if ONE_FILE:
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
**exe_kwargs,
)
else:
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
**exe_kwargs,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="BoltClam",
)
+23
View File
@@ -0,0 +1,23 @@
# Changelog
## 1.0.0 — 2026-10-25
Initial BoltClam release.
- PyQt6 Windows desktop app with Homepage, Scan & results, About & updates,
and Settings screens.
- ClamAV full-disk and all-user-home scanning, live progress, scan history,
detection actions, and quarantine restore/delete.
- Browser-download watcher for Chrome, Edge, Brave, Firefox, Vivaldi and
Opera download folders, with an exclusive-handle scan-before-open gate
and configurable automatic response.
- Bolt browser sign-in using the desk API loopback/PKCE flow, with the key
stored in Windows DPAPI.
- Account/subscription display and update notifications from the BoltClam
Gitea release feed, including release/beta channels and checksum
verification.
- Opt-in diagnostics upload with token/password/path redaction.
- Portable Windows build, PyInstaller spec, Inno Setup installer, and Gitea
release workflow.
- Additive Bolt API endpoints: branded sign-in page, account, release proxy,
diagnostics intake, and owner-only log access.
+199
View File
@@ -0,0 +1,199 @@
# BoltClam
BoltClam is a Windows desktop front-end for **ClamAV**, built with PyQt6 and
signed in with a Bolt account. It scans every user's home or the full disk,
quarantines or deletes what ClamAV finds, watches browser downloads, and
receives update notifications from the private Gitea release feed.
![BoltClam icon](resources/boltclam.png)
## What it does
- **Homepage** — a live protection summary: a shell gauge for engine,
definitions, real-time protection and system firewall; security concerns;
network details; recent detections; the signed-in user and Bolt plan.
- **Scan & results** — user-home or full-disk scans, live progress, scan
history, per-detection actions, and a quarantine manager with restore.
- **About & updates** — app version, account and subscription, ClamAV
version/definitions, release notes, beta/release channels, and
checksum-verified downloads.
- **Settings** — update channel, ClamAV install location, extra clamscan
options, real-time protection, automatic response, logging/debug, and
opt-in diagnostics upload to Bolt.
- **Browser download protection** — watches the configured download folder
of Chrome, Edge, Brave, Firefox, Vivaldi and Opera (plus every readable
user's Downloads folder), waits for the file to settle, holds it with an
exclusive Windows handle while ClamAV scans a copy, then quarantines or
deletes it according to the setting.
- **Bolt sign-in** — opens a Bolt-hosted sign-in page in the default
browser, receives a one-time code on a loopback listener, and exchanges it
through PKCE for an ordinary `bolt_live_...` API key. The key is stored
with Windows DPAPI; the password is never seen or stored by BoltClam.
## Requirements
- Windows 10 or 11, x64.
- [ClamAV for Windows](https://www.clamav.net/downloads) installed separately.
BoltClam does not bundle ClamAV binaries.
- A Bolt account (the same account used by the iOS/Android/Boltville clients).
- Internet access for sign-in, updates, and the first signature download.
- Administrator rights are optional but improve coverage of other users'
profiles and protected files.
## Install
### Portable build
1. Download `BoltClam-1.0.0-windows-x64-portable.zip` and extract the whole
folder (not just `BoltClam.exe`).
2. Run `BoltClam.exe`.
3. Sign in with your Bolt account in the browser.
4. Install ClamAV, open **Settings → ClamAV engine**, choose
`clamscan.exe` (or use **Auto-detect**), and click **Update definitions**.
5. Run a scan from **Scan & results**.
The portable build is assembled by
`packaging/build_portable_windows.py` from official Python Embeddable,
PyQt6 wheels, and the tiny `packaging/launcher.c` bootstrap. It is not signed;
Windows SmartScreen may warn about an unknown publisher. Compare the SHA-256
in `SHA256SUMS.txt` with the release.
### Installer / PyInstaller
On a Windows build machine with Python 3.11 x64:
```powershell
.\build_windows.ps1
```
This creates a one-folder build under `dist\BoltClam\`, a zip, and — when
Inno Setup 6 is installed — `dist\BoltClam-1.0.0-Setup.exe`.
Use `.\build_windows.ps1 -OneFile` for a single-file `BoltClam.exe`.
`build_windows.bat` is a double-click wrapper.
## Updates
Release notifications are read from:
<https://git.themajesticnetwork.com/TheMajesticNetwork/BoltClam>
BoltClam calls the Bolt API (`/boltclam/updates`) rather than the Gitea API
directly, so the Gitea token stays server-side. The API normalizes releases,
filters them by channel, and proxies asset downloads:
- **Release** channel: stable releases only.
- **Beta** channel: prereleases and stable releases.
- Assets are downloaded through the API with the user's Bolt key.
- If the release publishes `SHA256SUMS.txt`, the downloaded installer/zip is
verified before it is offered for installation.
The Gitea repo is currently public and starts empty. The first release is
created by pushing a `v*` tag once a Windows build runner is available (see
`.gitea/workflows/windows-release.yml`) or by running `build_windows.ps1` on
Windows and uploading the assets with `packaging/publish_release.ps1`.
## Server API
The BoltClam client uses the desk API at
`https://api.themajesticnetwork.com` by default:
| Endpoint | Purpose |
|---|---|
| `GET /desk/auth/boltclam/start` | BoltClam-branded browser sign-in page; validates loopback redirect + PKCE. |
| `POST /desk/auth/boltclam/login` | Email/password form handler; returns a one-time code to the app. |
| `GET /desk/auth/oauth/{google\|discord}/start` | Existing provider flow, reused by the page's OAuth buttons. |
| `POST /desk/auth/oauth/exchange` | Existing PKCE exchange; mints the `bolt_live_...` key. |
| `GET /boltclam/account` | User, username/email, plan/subscription, balance. |
| `GET /boltclam/updates?channel=release\|beta` | Normalized Gitea release feed. |
| `GET /boltclam/updates/<release_id>/assets/<asset_id>` | Authenticated asset proxy. |
| `POST /boltclam/logs` | Opt-in diagnostics zip upload. |
| `GET /boltclam/admin/logs` | Owner-only log listing (master desk key). |
The server implementation lives in
`/home/themajesticmagician/Documents/Server/tmn-api/ai/boltclam_api.py`
(plus the small registration in `ai/desk_api.py` and the `boltclam` client
name in `ai/desk_auth.py`). See `docs/BOLTCLAM.md` in that repository for
deployment notes.
## Security and privacy
- The Bolt password is sent only to the Bolt-hosted sign-in page/API.
BoltClam receives a device-scoped API key, stored with Windows DPAPI.
- Signing out revokes only this PC's key.
- No API key is written to settings or diagnostics.
- Diagnostics upload is off by default. When enabled and explicitly sent,
the zip contains app logs, settings, scan/quarantine summaries, a system
summary and the account email. Tokens and passwords are redacted, emails
in logs are redacted, and user-profile path prefixes become
`%USERPROFILE%`. File contents are never included.
- Quarantine files are renamed to `.quarantine`, made read-only/hidden where
possible, and recorded with SHA-256 plus a JSON sidecar.
- ClamAV extra options that would delete or move files (`--remove`, `--move`,
`--copy`) are refused; remediation stays in BoltClam so it is recorded and
reversible.
**Real-time protection is user-mode, not a kernel filter driver.** Holding a
file with `dwShareMode = 0` makes Windows refuse other opens while ClamAV
scans a copy, which is a strong speed bump, but it is not a guarantee against
a kernel-level bypass. If another process already has the file open, BoltClam
falls back to scanning a copy and records that in the event detail. Read
[`docs/SECURITY.md`](docs/SECURITY.md) before relying on it as the only
anti-malware layer.
## Development
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pip install -e .
.venv/bin/python -m boltclam
QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest tests -q
```
The app reads `BOLTCLAM_API_URL` to override the API base (useful for a
local desk API). Tests redirect config/data with `BOLTCLAM_CONFIG_DIR` and
`BOLTCLAM_DATA_DIR`; never point those at a real user profile.
## Repository
```text
BoltClam/
├── src/boltclam/ application, services and PyQt pages
├── tests/ unit and offscreen UI tests
├── resources/ icon, stylesheet rasters and OFL fonts
├── packaging/ Windows portable builder, launcher, release helper
├── installer/ Inno Setup script
├── build_windows.ps1 PyInstaller + installer build on Windows
├── BoltClam.spec PyInstaller definition
└── .gitea/workflows/ Windows release workflow
```
## Push / release
The remote is already configured:
```bash
git remote -v
# origin https://git.themajesticnetwork.com/TheMajesticNetwork/BoltClam.git
```
Push the initial commit (Git will ask for your Gitea credentials/token):
```bash
git push -u origin main
git push origin v1.0.0
```
Pushing the tag triggers the Windows release workflow **if** the repository
has a Windows Actions runner and a `GITEA_TOKEN` secret. Without such a
runner, build on a Windows PC with `build_windows.ps1` and upload the
artifacts with `packaging/publish_release.ps1`.
## Licensing
BoltClam is internal software for The Majestic Network; no public license is
granted by this repository. **PyQt6 is GPLv3-or-commercial**: distributing
BoltClam binaries under proprietary terms requires a commercial PyQt license,
or GPLv3 compliance. ClamAV is GPLv2 and is installed separately, not
bundled. Fonts are under the SIL Open Font License. See
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
+23
View File
@@ -0,0 +1,23 @@
# Third-party notices
BoltClam is built with:
| Component | Version | License | Notes |
|---|---|---|---|
| Python | 3.11.9 | PSF License | Embedded in the portable Windows build. |
| PyQt6 | 6.11.0 | GPLv3 or commercial (Riverbank) | The application's distribution model must comply with GPLv3 or hold a commercial PyQt license. |
| Qt 6 | 6.11.2 | LGPLv3 / GPLv3 | Qt libraries are distributed inside the PyQt6 wheel. |
| PyQt6-sip | 13.13.0 | BSD-2-Clause | Runtime support for PyQt6. |
| ClamAV | user-installed | GPLv2 | Not bundled. BoltClam invokes the separate `clamscan.exe` / `freshclam.exe`. |
| Bricolage Grotesque | Google Fonts | SIL Open Font License 1.1 | `resources/fonts/OFL-Bricolage.txt`. |
| Atkinson Hyperlegible | Google Fonts | SIL Open Font License 1.1 | `resources/fonts/OFL-Atkinson.txt`. |
| JetBrains Mono | JetBrains | SIL Open Font License 1.1 | `resources/fonts/OFL-JetBrainsMono.txt`. |
The portable build includes the official Python embeddable distribution and
the Windows PyQt6 wheels unchanged apart from placing them in a directory
layout. Their license files are retained in the package.
**BoltClam itself is internal software for The Majestic Network; this
repository grants no public license.** If BoltClam is distributed outside an
organization that complies with the GPLv3, obtain a commercial PyQt license
from Riverbank Computing.
+3
View File
@@ -0,0 +1,3 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_windows.ps1" %*
+99
View File
@@ -0,0 +1,99 @@
#requires -Version 5.1
[CmdletBinding()]
param(
[switch]$OneFile,
[switch]$SkipInstaller
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
function Get-Python {
$candidates = @("py -3.11", "py -3", "python")
foreach ($candidate in $candidates) {
try {
$parts = $candidate.Split(" ")
$exe = $parts[0]
$exeArgs = @($parts | Select-Object -Skip 1)
$output = & $exe @exeArgs -c "import sys; print(sys.executable); print(sys.version_info[:2])" 2>$null
if ($LASTEXITCODE -eq 0 -and $output -match "3\.11") {
return $parts
}
} catch { }
}
throw "Python 3.11 x64 is required. Install it from python.org and retry."
}
$version = "1.0.0"
$venv = Join-Path $Root ".venv-build"
$python = Get-Python
if (-not (Test-Path $venv)) {
Write-Host "Creating build environment..."
$pythonExe = $python[0]
$pythonArgs = @($python | Select-Object -Skip 1)
& $pythonExe @pythonArgs -m venv $venv
}
$py = Join-Path $venv "Scripts\python.exe"
if (-not (Test-Path $py)) {
throw "The build virtual environment could not be created."
}
& $py -m pip install --upgrade pip
& $py -m pip install -r (Join-Path $Root "requirements-dev.txt")
if ($LASTEXITCODE -ne 0) { throw "Dependency installation failed." }
if ($OneFile) {
$env:BOLTCLAM_ONEFILE = "1"
} else {
Remove-Item Env:BOLTCLAM_ONEFILE -ErrorAction SilentlyContinue
}
Write-Host "Building BoltClam $version..."
& $py -m PyInstaller --noconfirm --clean (Join-Path $Root "BoltClam.spec")
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed." }
$dist = Join-Path $Root "dist"
$artifacts = @()
if ($OneFile) {
$exe = Join-Path $dist "BoltClam.exe"
if (-not (Test-Path $exe)) { throw "Expected $exe was not produced." }
$artifacts += $exe
} else {
$folder = Join-Path $dist "BoltClam"
$exe = Join-Path $folder "BoltClam.exe"
if (-not (Test-Path $exe)) { throw "Expected $exe was not produced." }
$zip = Join-Path $dist "BoltClam-$version-windows-x64.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $folder "*") -DestinationPath $zip -CompressionLevel Optimal
$artifacts += $zip
if (-not $SkipInstaller) {
$isccCandidates = @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe"
)
$iscc = $isccCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($iscc) {
Write-Host "Building installer with $iscc..."
& $iscc "/DMyAppVersion=$version" (Join-Path $Root "installer\BoltClam.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed." }
$setup = Join-Path $dist "BoltClam-$version-Setup.exe"
if (Test-Path $setup) { $artifacts += $setup }
} else {
Write-Warning "Inno Setup 6 was not found; skipping the installer."
}
}
}
$checksumPath = Join-Path $dist "SHA256SUMS.txt"
$lines = foreach ($artifact in $artifacts) {
$hash = (Get-FileHash -Algorithm SHA256 $artifact).Hash.ToLowerInvariant()
"$hash $(Split-Path -Leaf $artifact)"
}
$lines | Set-Content -Encoding ASCII $checksumPath
Write-Host ""
Write-Host "Built:"
$artifacts | ForEach-Object { Write-Host " $_" }
Write-Host "Checksums: $checksumPath"
+107
View File
@@ -0,0 +1,107 @@
# BoltClam architecture
## Process layout
BoltClam is one PyQt6 process on the user's Windows desktop. It starts no
Windows service and installs no driver. ClamAV remains an external program.
```
BoltClam.exe (PyInstaller or portable launcher)
└─ boltclam.app
├─ MainWindow ── HomePage / ScanPage / AboutPage / SettingsPage
├─ RealtimeProtection ── DownloadWatcher + QThreadPool scan tasks
├─ ScanWorker / DefinitionWorker (QThread)
├─ Storage (SQLite, WAL)
├─ QuarantineService
├─ ClamAVService → clamscan.exe / freshclam.exe
└─ ApiClient → https://api.themajesticnetwork.com
```
Settings are JSON under `%APPDATA%\BoltClam`; history and quarantine live
under `%LOCALAPPDATA%\BoltClam`. `BOLTCLAM_CONFIG_DIR` and
`BOLTCLAM_DATA_DIR` override both for tests and portable/development use.
## Page responsibilities
- **HomePage** calls `system_info.collect()` on a worker. PowerShell supplies
Defender/firewall/network facts on Windows; missing data is "not reported".
The shell gauge and concern list are derived from ClamAV state, definition
age, real-time state, firewall, last scan, active detections and quarantine
count. It never labels an unknown state as protected.
- **ScanPage** persists scope/auto-response preferences immediately, runs
`ScanWorker`, records each detection in SQLite before remediation, and
exposes manual quarantine/delete/ignore after a scan. Quarantine management
restores to the original path or a chosen path and refuses overwrites.
- **AboutPage** calls `/boltclam/account` for user/plan data and
`/boltclam/updates` for the release feed. Downloads use the API asset proxy
and optionally verify `SHA256SUMS.txt`.
- **SettingsPage** validates ClamAV paths and extra options, applies logging
changes, updates the real-time watcher, and creates/uploads diagnostics
bundles only when the user has opted in.
## Sign-in sequence
```
BoltClam Browser Bolt desk API
|-- PKCE verifier/challenge --| |
|-- loopback listener :port/cb| |
|-- open /desk/auth/boltclam/start?... ------------------->|
| |<-- branded login page -----|
| |-- email/password or OAuth->|
|<-- 302 /cb?code=...&state=... ----------------------------|
|-- POST /desk/auth/oauth/exchange (code+verifier) -------->|
|<-- {api_key, user} --------------------------------------|
|-- DPAPI-store key, GET /boltclam/account ---------------->|
```
The one-time code is bound to the PKCE challenge, the exact loopback URI and
a short TTL. The app rejects a callback whose state does not match. The API
burns an exchange code on the first attempt, successful or not.
## Scan sequence
1. `ClamAVService.info()` locates `clamscan.exe`, runs `--version`, and
chooses the managed database if it contains signatures.
2. `scan_targets("homes")` reads the Windows `ProfileList` registry and
falls back to `C:\Users`; `"full"` enumerates fixed local drives.
3. `ScanWorker` starts clamscan with an argv list, no shell, and parses
detections/progress/summary. Each `path: Signature FOUND` becomes a
`Detection` row first, then quarantine/delete/notify is applied.
4. The scan row is finished even on cancellation or process failure. The UI
reloads history and the Homepage aggregates.
## Real-time sequence
`DownloadWatcher` polls each watched download folder at 1.5 s intervals (and
refreshes browser preferences every 30 s), ignores in-progress suffixes, and
waits for a stable size/mtime. `RealtimeScanTask` runs in a QThreadPool:
```
stable file → exclusive handle (share=0) → copy → ClamAV scans copy
→ clean: release → detection: release + quarantine/delete
```
The exclusive handle is held for the entire scan. A fallback copy is used
when the file is already open, with that fact recorded in the event detail.
## Update flow
- The Bolt API reads Gitea's `/api/v1/repos/TheMajesticNetwork/BoltClam/releases`,
caches for five minutes, filters draft/prerelease by channel, and returns
normalized release metadata.
- Asset downloads go through the API, so a future private repository needs
no desktop token. The API streams the Gitea asset with its own token.
- The client compares semantic versions, selects a Windows `.exe`/`.zip`
asset, downloads it to `%LOCALAPPDATA%\BoltClam\updates`, and verifies the
release checksum when published.
## Storage schema
- `scans` — scope, status, paths JSON, counts, engine/DB versions, duration.
- `detections` — scan id, path, signature, action, quarantine id, timestamp.
- `quarantine` — id, original/quarantine path, signature, SHA-256, status.
- `realtime_events` — download path, signature, action, detail, timestamp.
All writes open their own SQLite connection; WAL mode lets the UI read while
a scan thread writes. Quarantine sidecars are JSON, so the record survives a
database rebuild.
+114
View File
@@ -0,0 +1,114 @@
# BoltClam security model
BoltClam is a **signature-based second opinion**, not a replacement for
Windows Defender or a kernel-level endpoint protection platform. This file
states exactly what the user-mode protections do, where they stop, and what
the app stores.
## Trust boundaries
- **Bolt API** (`api.themajesticnetwork.com`) is the only network service
BoltClam talks to. It proxies Gitea release metadata and assets, so a
Gitea token is never shipped to a desktop.
- **ClamAV** is a separate, user-installed executable (`clamscan.exe` /
`freshclam.exe`). BoltClam runs it as the current user and parses its
output. BoltClam does not load ClamAV DLLs into its own process.
- **Windows** is trusted for DPAPI, file handles, and security-center facts
reported by PowerShell. If PowerShell or Defender cmdlets are unavailable,
the UI says "not reported" rather than inventing a pass.
- **Gitea** is trusted for release metadata only. Downloads are either
proxied through the Bolt API or checked against a release SHA-256 when a
checksum asset exists.
## Credentials
- Sign-in uses the existing desk API browser/PKCE flow. The password is
entered on a Bolt-hosted page and is never sent to BoltClam.
- The resulting `bolt_live_...` API key is stored in
`%APPDATA%\BoltClam\credentials.dat` encrypted with Windows DPAPI
(current-user scope, with an app-specific entropy value).
- On non-Windows development hosts the credential store falls back to a
mode-0600 local file and logs a warning. That fallback is for development
only.
- A key is never written to `settings.json`, logs, or diagnostics bundles.
- Sign-out revokes only the key this PC used, then deletes the local copy.
## Scan and remediation
- Scans run `clamscan` with `--infected --verbose --stdout` and explicit
argument lists (no shell). Extra options come from the user's settings and
are parsed; `--remove`, `--move`, and `--copy` are refused so every action
goes through BoltClam's recorded quarantine/delete path.
- A detection is written to SQLite **before** remediation is attempted. If
quarantine or delete fails, the record stays visible as `failed` instead of
disappearing.
- Quarantine moves the original file into
`%LOCALAPPDATA%\BoltClam\quarantine`, renames it to `.quarantine`, records
its SHA-256 and original path, and writes a JSON sidecar. Restore refuses to
overwrite an existing file. Delete is permanent and explicitly confirmed.
- The quarantine folder is user-writable. A malicious process already running
as that user could tamper with it; strong tamper resistance would require a
privileged service and kernel driver, which this app does not install.
## Real-time download gate
BoltClam watches browser download folders and waits for a file's size and
mtime to stop changing. It then:
1. opens the file with `CreateFileW` and `dwShareMode = 0` (deny other
opens) where possible;
2. copies it to a private temp path through that handle;
3. scans the copy with ClamAV while the original remains locked;
4. on a clean result, releases the lock;
5. on a detection, closes the lock and moves the file to quarantine (or
deletes it if that setting was chosen).
This blocks ordinary `CreateFile` opens during the scan, including a
double-click, which is the meaningful user-mode speed bump. It is **not**:
- a kernel minifilter, so a driver or a process using backup semantics can
bypass it;
- a guarantee when another process already holds the file open — BoltClam
reports "File was in use; scanned a copy" and still remediates afterward;
- protection for downloads made outside a watched browser download folder
(curl, scripts, existing files, network shares);
- a replacement for Windows Defender's real-time engine.
The **Notify only** response leaves the detected file in place. Use
Quarantine (the default) if the machine must not retain detected downloads.
Files larger than `BOLTCLAM_REALTIME_MAX_MB` (default 1024 MiB) are reported
and not scanned in real time.
## Definitions and updates
- `freshclam` writes to BoltClam's own `%LOCALAPPDATA%\BoltClam\clamav-db`,
avoiding a Program Files write. `clamscan` uses that database when present
and otherwise falls back to the database beside the installed binary.
- The signed-in user can see definition age in the Homepage and Settings.
"Fresh" means no older than seven days; older than 30 days is critical.
- Update downloads require a valid Bolt key. If the release includes
`SHA256SUMS.txt`, the SHA-256 is verified before the installer is offered.
Without a checksum asset the UI says so rather than claiming verification.
- Run installers only from the BoltClam Gitea releases page. The app itself
does not silently replace its own executable.
## Diagnostics
Uploading diagnostics is **opt-in and explicit**. The bundle contains:
- redacted app logs;
- settings (API key absent);
- scan/quarantine summaries and real-time event metadata (paths redacted to
`%USERPROFILE%`);
- ClamAV and system summary;
- account email, so support can identify the upload.
It never contains file contents, browser history, passwords, API keys, or
the DPAPI credential file. Logs cap size and rotate locally.
## Reporting a problem
For a security issue in BoltClam or the BoltClam API, contact The Majestic
Network directly rather than opening a public issue with exploit details.
Include the app version, Windows version, and the relevant redacted log
excerpt.
+53
View File
@@ -0,0 +1,53 @@
#define MyAppName "BoltClam"
#ifndef MyAppVersion
#define MyAppVersion "1.0.0"
#endif
#define MyAppPublisher "The Majestic Network"
#define MyAppURL "https://git.themajesticnetwork.com/TheMajesticNetwork/BoltClam"
#define MyAppExeName "BoltClam.exe"
[Setup]
AppId={{8C7F4C7A-6BB2-47B1-93F6-9A6D1D3B1F5E}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={localappdata}\Programs\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=..\dist
OutputBaseFilename=BoltClam-{#MyAppVersion}-Setup
SetupIconFile=..\resources\boltclam.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
CloseApplications=yes
RestartApplications=no
VersionInfoVersion={#MyAppVersion}
VersionInfoCompany={#MyAppPublisher}
VersionInfoDescription={#MyAppName} installer
VersionInfoProductName={#MyAppName}
VersionInfoProductVersion={#MyAppVersion}
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"; Flags: unchecked
[Files]
Source: "..\dist\BoltClam\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent
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)"
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "boltclam"
version = "1.0.0"
description = "A Windows desktop antivirus front-end for ClamAV, with Bolt account sign-in."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Proprietary" }
authors = [{ name = "The Majestic Network" }]
dependencies = ["PyQt6>=6.6,<7"]
[project.optional-dependencies]
dev = ["pytest>=7.4", "pyinstaller>=6.6"]
[project.gui-scripts]
BoltClam = "boltclam.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
filterwarnings = ["ignore::DeprecationWarning"]
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=7.4
pyinstaller>=6.6
+1
View File
@@ -0,0 +1 @@
PyQt6>=6.6,<7
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright 2020 Braille Institute of America, Inc.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

+28
View File
@@ -0,0 +1,28 @@
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 0, 0, 0),
prodvers=(1, 0, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(
'040904B0',
[StringStruct('CompanyName', 'The Majestic Network'),
StringStruct('FileDescription', 'BoltClam ClamAV protection'),
StringStruct('FileVersion', '1.0.0'),
StringStruct('InternalName', 'BoltClam'),
StringStruct('LegalCopyright', 'The Majestic Network'),
StringStruct('OriginalFilename', 'BoltClam.exe'),
StringStruct('ProductName', 'BoltClam'),
StringStruct('ProductVersion', '1.0.0')]
)
]),
VarFileInfo([VarStruct('Translation', [1033, 1200])])
]
)
+113
View File
@@ -0,0 +1,113 @@
"""Generate the small raster assets used by the Qt stylesheet and Windows icon.
Run with the project venv from the repository root:
QT_QPA_PLATFORM=offscreen .venv/bin/python scripts/generate_assets.py
"""
from __future__ import annotations
import struct
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, Qt # noqa: E402
from PyQt6.QtGui import QColor, QImage, QPainter, QPainterPath, QPen, QPixmap # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from boltclam.ui import brand # noqa: E402
RESOURCES = ROOT / "resources"
def _png_bytes(pixmap: QPixmap) -> bytes:
data = QByteArray()
buffer = QBuffer(data)
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
pixmap.save(buffer, "PNG")
buffer.close()
return bytes(data)
def _check() -> None:
size = 18
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen(QColor("#201704"), 2.4, Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
path = QPainterPath(QPointF(4.2, 9.3))
path.lineTo(7.4, 12.5)
path.lineTo(13.8, 5.4)
painter.drawPath(path)
painter.end()
pixmap.save(str(RESOURCES / "check.png"), "PNG")
def _radio() -> None:
size = 17
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#201704"))
painter.drawEllipse(QRectF(5.0, 5.0, 7.0, 7.0))
painter.end()
pixmap.save(str(RESOURCES / "radio.png"), "PNG")
def _chevron() -> None:
size = 14
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(QColor("#8BA7B2"), 1.7, Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
path = QPainterPath(QPointF(3.2, 5.2))
path.lineTo(7.0, 9.0)
path.lineTo(10.8, 5.2)
painter.drawPath(path)
painter.end()
pixmap.save(str(RESOURCES / "chevron_down.png"), "PNG")
def _ico() -> None:
sizes = (16, 24, 32, 48, 64, 128, 256)
images: list[tuple[int, bytes]] = []
for size in sizes:
pixmap = brand.pixmap(size)
images.append((size, _png_bytes(pixmap)))
header = struct.pack("<HHH", 0, 1, len(images))
offset = 6 + 16 * len(images)
entries = b""
payload = b""
for size, data in images:
width = 0 if size >= 256 else size
height = 0 if size >= 256 else size
entries += struct.pack("<BBBBHHII", width, height, 0, 0, 1, 32,
len(data), offset)
offset += len(data)
payload += data
(RESOURCES / "boltclam.ico").write_bytes(header + entries + payload)
brand.pixmap(256).save(str(RESOURCES / "boltclam.png"), "PNG")
def main() -> int:
app = QApplication.instance() or QApplication(sys.argv)
RESOURCES.mkdir(parents=True, exist_ok=True)
_check()
_radio()
_chevron()
_ico()
print("assets written to", RESOURCES)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -0,0 +1,7 @@
"""BoltClam — a Windows desktop antivirus front-end for ClamAV."""
__version__ = "1.0.0"
__build_date__ = "2026-10-25"
APP_NAME = "BoltClam"
ORG_NAME = "The Majestic Network"
APP_ID = "com.themajesticnetwork.boltclam"
+4
View File
@@ -0,0 +1,4 @@
from boltclam.app import main
if __name__ == "__main__":
raise SystemExit(main())
+267
View File
@@ -0,0 +1,267 @@
"""HTTP client for the Bolt API's BoltClam endpoints.
Uses only the standard library so the Windows build needs no third-party
HTTP package beyond PyQt6. TLS verification is always on; the only allowed
non-HTTPS base is a loopback development URL.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import platform
import ssl
import uuid
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from . import __version__
logger = logging.getLogger(__name__)
class ApiError(Exception):
def __init__(self, message: str, *, status: int | None = None,
code: str | None = None, retry_after: int | None = None,
body: Any = None) -> None:
super().__init__(message)
self.status = status
self.code = code
self.retry_after = retry_after
self.body = body
class NetworkError(ApiError):
pass
def _local_base(base_url: str) -> bool:
host = (urlparse(base_url).hostname or "").lower()
return host in ("127.0.0.1", "localhost", "::1")
def _decode_body(raw: bytes) -> Any:
if not raw:
return None
try:
return json.loads(raw.decode("utf-8", "replace"))
except ValueError:
return raw[:2000].decode("utf-8", "replace")
class ApiClient:
def __init__(self, base_url: str, api_key: str = "", *,
device_name: str = "", install_id: str = "",
timeout: int = 30) -> None:
base = str(base_url or "").strip().rstrip("/")
parsed = urlparse(base)
if parsed.scheme not in ("https", "http") or not parsed.netloc:
raise ValueError("The Bolt API address must be an http(s) URL.")
if parsed.scheme != "https" and not _local_base(base):
raise ValueError("The Bolt API address must use HTTPS.")
self.base_url = base
self.api_key = api_key or ""
self.device_name = device_name or platform.node() or "Windows PC"
self.install_id = install_id or ""
self.timeout = timeout
# -- low level ---------------------------------------------------------
def _url(self, path: str) -> str:
return self.base_url + "/" + str(path).lstrip("/")
def _headers(self, *, auth: bool, extra: dict | None = None) -> dict[str, str]:
headers = {
"Accept": "application/json",
"User-Agent": f"BoltClam/{__version__} ({platform.system() or 'Windows'})",
"X-BoltClam-Client": "windows",
"X-BoltClam-Version": __version__,
}
if self.install_id:
headers["X-BoltClam-Install-Id"] = self.install_id
if auth and self.api_key:
headers["X-Desk-Api-Key"] = self.api_key
if extra:
headers.update({str(k): str(v) for k, v in extra.items()})
return headers
def _request(self, method: str, path: str, *, payload: Any = None,
auth: bool = True, timeout: int | None = None,
extra_headers: dict | None = None) -> Any:
data = None
headers = self._headers(auth=auth, extra=extra_headers)
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(self._url(path), data=data, headers=headers, method=method.upper())
context = ssl.create_default_context()
try:
with urlopen(request, timeout=timeout or self.timeout, context=context) as response:
raw = response.read()
content_type = response.headers.get("Content-Type", "")
except HTTPError as exc:
raw = exc.read() if exc.fp else b""
body = _decode_body(raw)
code = body.get("error") if isinstance(body, dict) else None
retry_after = None
if isinstance(body, dict):
try:
retry_after = int(body.get("retry_after")) if body.get("retry_after") is not None else None
except (TypeError, ValueError):
retry_after = None
message = {
401: "Your BoltClam session is no longer valid. Sign in again.",
402: "This Bolt account is out of credits.",
429: "Too many requests. Wait a moment and try again.",
502: "The Bolt service is temporarily unavailable.",
}.get(exc.code, f"Bolt API request failed ({exc.code}).")
raise ApiError(message, status=exc.code, code=code,
retry_after=retry_after, body=body) from None
except (URLError, TimeoutError, OSError) as exc:
reason = getattr(exc, "reason", exc)
raise NetworkError(f"Could not reach the Bolt service: {reason}") from None
if "application/json" in content_type or raw[:1] in (b"{", b"["):
body = _decode_body(raw)
else:
body = raw
if not 200 <= getattr(response, "status", 200) < 300:
raise ApiError("Bolt API request failed.", status=getattr(response, "status", None),
body=body)
return body if body is not None else {}
# -- endpoints ---------------------------------------------------------
def get_account(self) -> dict:
data = self._request("GET", "/boltclam/account")
if not isinstance(data, dict):
raise ApiError("The Bolt service returned an unexpected account response.")
return data
def get_updates(self, channel: str = "release", force: bool = False) -> dict:
suffix = "?channel=" + ("beta" if channel == "beta" else "release")
if force:
suffix += "&force=1"
data = self._request("GET", "/boltclam/updates" + suffix)
if not isinstance(data, dict):
raise ApiError("The Bolt service returned an unexpected update response.")
return data
def logout(self) -> None:
try:
self._request("POST", "/desk/auth/logout", payload={})
except ApiError as exc:
logger.warning("Logout request failed: %s", exc)
def download_asset(self, url: str, destination: Path,
progress: Callable[[int, int], None] | None = None,
timeout: int = 300) -> tuple[Path, str]:
parsed = urlparse(str(url or ""))
base = urlparse(self.base_url)
if parsed.scheme:
if parsed.scheme != base.scheme or parsed.netloc != base.netloc:
raise ApiError("Refusing to download an update from an unexpected host.")
full_url = str(url)
else:
full_url = self._url(str(url))
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(destination.name + ".part")
request = Request(full_url, headers=self._headers(auth=True), method="GET")
digest = hashlib.sha256()
try:
with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response:
total = int(response.headers.get("Content-Length") or 0)
received = 0
with open(temp_path, "wb") as handle:
while True:
chunk = response.read(1024 * 256)
if not chunk:
break
handle.write(chunk)
digest.update(chunk)
received += len(chunk)
if progress:
try:
progress(received, total)
except Exception:
pass
os.replace(temp_path, destination)
except HTTPError as exc:
temp_path.unlink(missing_ok=True)
body = _decode_body(exc.read() if exc.fp else b"")
code = body.get("error") if isinstance(body, dict) else None
raise ApiError(f"Update download failed ({exc.code}).", status=exc.code,
code=code, body=body) from None
except (URLError, TimeoutError, OSError) as exc:
temp_path.unlink(missing_ok=True)
raise NetworkError(f"Update download failed: {getattr(exc, 'reason', exc)}") from None
return destination, digest.hexdigest()
def upload_diagnostics(self, zip_path: Path, metadata: dict | None = None,
timeout: int = 180) -> dict:
zip_path = Path(zip_path)
if not zip_path.is_file():
raise ApiError("The diagnostics bundle does not exist.")
boundary = "----BoltClam" + uuid.uuid4().hex
body = bytearray()
fields = {"client": "boltclam", "app_version": __version__}
fields.update({k: str(v) for k, v in (metadata or {}).items() if v is not None})
for name, value in fields.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(str(value).encode("utf-8"))
body.extend(b"\r\n")
filename = zip_path.name.replace('"', "_")
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode())
body.extend(b"Content-Type: application/zip\r\n\r\n")
body.extend(zip_path.read_bytes())
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode())
headers = self._headers(auth=True, extra={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
})
request = Request(self._url("/boltclam/logs"), data=bytes(body),
headers=headers, method="POST")
try:
with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response:
raw = response.read()
except HTTPError as exc:
raw = exc.read() if exc.fp else b""
parsed = _decode_body(raw)
code = parsed.get("error") if isinstance(parsed, dict) else None
raise ApiError(f"Log upload failed ({exc.code}).", status=exc.code,
code=code, body=parsed) from None
except (URLError, TimeoutError, OSError) as exc:
raise NetworkError(f"Log upload failed: {getattr(exc, 'reason', exc)}") from None
parsed = _decode_body(raw)
if not isinstance(parsed, dict):
raise ApiError("The Bolt service returned an unexpected log-upload response.")
return parsed
# -- unauthenticated exchange -----------------------------------------
@classmethod
def exchange_code(cls, base_url: str, *, code: str, verifier: str,
redirect_uri: str, device_name: str, install_id: str) -> dict:
client = cls(base_url, install_id=install_id, device_name=device_name)
data = client._request("POST", "/desk/auth/oauth/exchange", auth=False, payload={
"code": code,
"code_verifier": verifier,
"redirect_uri": redirect_uri,
"client": "boltclam",
"device_name": device_name,
"install_id": install_id,
})
if not isinstance(data, dict) or not data.get("api_key"):
raise ApiError("Bolt did not return a device key.")
return data
+193
View File
@@ -0,0 +1,193 @@
"""Application bootstrap: login/restore, dependency wiring, and window lifecycle."""
from __future__ import annotations
import logging
import os
import sys
import time
from pathlib import Path
from PyQt6.QtCore import QEventLoop, QLockFile, QRectF, Qt, QTimer
from PyQt6.QtGui import QColor, QFont, QPainter, QPixmap
from PyQt6.QtWidgets import QApplication, QDialog, QMessageBox, QSplashScreen
from . import APP_ID, __build_date__, __version__, paths
from .api_client import ApiClient, ApiError, NetworkError
from .auth import AuthService, CredentialStore, Session, SessionExpired
from .clamav import ClamAVService
from .config import AccountCache, SettingsStore
from .context import AppContext
from .logging_setup import configure as configure_logging
from .quarantine import QuarantineService
from .storage import Storage
from .ui import brand, theme
from .ui.login_dialog import LoginDialog
from .ui.main_window import MainWindow
logger = logging.getLogger(__name__)
def _set_windows_app_id() -> None:
if os.name != "nt":
return
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID)
except Exception:
pass
def _splash_pixmap() -> QPixmap:
image = QPixmap(460, 250)
image.fill(Qt.GlobalColor.transparent)
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setBrush(QColor("#081820"))
painter.setPen(QColor("#1B3946"))
painter.drawRoundedRect(QRectF(1, 1, 458, 248), 22, 22)
brand.paint_mark(painter, QRectF(34, 34, 72, 72))
painter.setPen(QColor(theme.TEXT))
painter.setFont(theme.display_font(26, QFont.Weight.DemiBold))
painter.drawText(QRectF(124, 36, 300, 40), Qt.AlignmentFlag.AlignLeft,
Qt.AlignmentFlag.AlignVCenter, "BoltClam")
painter.setPen(QColor(theme.MUTED))
painter.setFont(theme.body_font(10.0))
painter.drawText(QRectF(126, 76, 300, 24), Qt.AlignmentFlag.AlignLeft,
Qt.AlignmentFlag.AlignVCenter, "ClamAV protection, without the noise.")
painter.setPen(QColor(theme.TEXT_SOFT))
painter.setFont(theme.body_font(10.5))
painter.drawText(QRectF(36, 154, 390, 26), Qt.AlignmentFlag.AlignLeft,
Qt.AlignmentFlag.AlignVCenter, "Starting protection…")
painter.setPen(QColor(theme.DIM))
painter.setFont(theme.mono_font(8.0))
painter.drawText(QRectF(36, 206, 390, 20), Qt.AlignmentFlag.AlignLeft,
Qt.AlignmentFlag.AlignVCenter, f"Version {__version__} · {__build_date__}")
painter.end()
return image
def _install_exception_hook() -> None:
def handle(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logger.critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback))
try:
QMessageBox.critical(
None, "BoltClam encountered an error",
f"{exc_type.__name__}: {exc_value}\n\nDetails were written to the BoltClam log.")
except Exception:
pass
sys.excepthook = handle
def _build_context(settings_store: SettingsStore, auth: AuthService,
settings, session: Session) -> AppContext:
storage = Storage()
quarantine = QuarantineService(storage)
clamav = ClamAVService(settings.clamav_path)
api = ApiClient(settings.api_base_url, session.api_key,
device_name=auth.device_name, install_id=settings.install_id)
return AppContext(
settings_store=settings_store,
settings=settings,
storage=storage,
quarantine=quarantine,
clamav=clamav,
auth=auth,
session_key=session.api_key,
account=session.account,
install_id=settings.install_id,
api=api,
offline=session.offline,
)
def _restore_session(auth: AuthService, stored: str | None) -> tuple[Session | None, str]:
if not stored:
return None, ""
try:
return auth.restore(stored), ""
except SessionExpired:
return None, "Your previous BoltClam session expired. Sign in again to continue."
except Exception as exc:
cached = auth.account_cache.load()
if cached:
logger.info("Starting offline with the last verified account: %s", exc)
return Session(stored, cached, offline=True), (
"Bolt is unreachable right now. BoltClam is using the last verified account "
"and local protection still works.")
logger.info("Could not restore the saved session: %s", exc)
return None, f"Could not restore your BoltClam session: {exc}"
def main() -> int:
_set_windows_app_id()
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
app = QApplication(sys.argv)
theme.setup_application(app)
app.setQuitOnLastWindowClosed(False)
app.setWindowIcon(brand.app_icon())
paths.ensure_dirs()
lock = QLockFile(str(paths.config_dir() / "boltclam.lock"))
if not lock.tryLock(150):
QMessageBox.information(
None, "BoltClam is already running",
"Only one BoltClam can watch downloads at a time. Open the copy in the "
"system tray, or quit it before starting another.")
return 0
settings_store = SettingsStore()
settings = settings_store.load()
env_api = os.environ.get("BOLTCLAM_API_URL", "").strip()
if env_api and env_api != settings.api_base_url:
settings = settings_store.update(api_base_url=env_api)
configure_logging(settings)
_install_exception_hook()
logger.info("BoltClam %s starting (%s)", __version__, paths.os_label())
splash = QSplashScreen(_splash_pixmap())
splash.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
splash.show()
app.processEvents()
credentials = CredentialStore()
account_cache = AccountCache()
auth = AuthService(
settings.api_base_url, install_id=settings.install_id,
credentials=credentials, account_cache=account_cache)
stored = auth.stored_key()
session, notice = _restore_session(auth, stored)
splash.close()
if session is None:
dialog = LoginDialog(auth, notice=notice)
if dialog.exec() != QDialog.DialogCode.Accepted or dialog.session is None:
logger.info("Sign-in cancelled at startup")
return 0
session = dialog.session
while True:
context = _build_context(settings_store, auth, settings, session)
window = MainWindow(context)
loop = QEventLoop()
window.finished.connect(loop.quit)
window.show()
loop.exec()
logged_out = window.logged_out()
window.deleteLater()
app.processEvents()
if not logged_out:
break
dialog = LoginDialog(auth, notice="You signed out. Sign in again to use BoltClam.")
if dialog.exec() != QDialog.DialogCode.Accepted or dialog.session is None:
break
session = dialog.session
settings = settings_store.load()
logger.info("BoltClam exiting")
return 0
+448
View File
@@ -0,0 +1,448 @@
"""BoltClam sign-in: loopback PKCE, DPAPI credential storage, session restore.
The desktop flow mirrors the iOS/Android app:
1. BoltClam generates a PKCE verifier/challenge and a random state, then
listens on ``http://127.0.0.1:<port>/cb``.
2. It opens the Bolt-hosted sign-in page in the default browser.
3. After the user signs in, Bolt redirects to the loopback listener with a
short-lived one-time code.
4. BoltClam posts that code + its verifier to ``/desk/auth/oauth/exchange``
and receives an ordinary ``bolt_live_...`` API key, which is stored with
Windows DPAPI (never in settings or logs).
"""
from __future__ import annotations
import base64
import ctypes
import hashlib
import html
import json
import logging
import os
import platform
import secrets
import socket
import threading
import time
import uuid
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import parse_qs, urlencode, urlparse
from .api_client import ApiClient, ApiError, NetworkError
from .config import AccountCache
from .paths import credentials_path
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 300
_ERROR_MESSAGES = {
"access_denied": "Sign-in was cancelled in the browser.",
"oauth_failed": "The sign-in provider could not complete the request.",
"unverified_email": "That account's email is not verified with the provider.",
"invalid_grant": "The sign-in code was not accepted. Please try again.",
}
class AuthError(Exception):
pass
class AuthCancelled(AuthError):
pass
class AuthTimeout(AuthError):
pass
class SessionExpired(AuthError):
pass
class CredentialError(AuthError):
pass
# ── PKCE ────────────────────────────────────────────────────────────────────
def pkce_pair() -> tuple[str, str]:
verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode()
return verifier, challenge
# ── Windows DPAPI ───────────────────────────────────────────────────────────
class _DATA_BLOB(ctypes.Structure):
_fields_ = [("cbData", ctypes.c_uint32),
("pbData", ctypes.POINTER(ctypes.c_ubyte))]
def _blob(data: bytes) -> tuple[_DATA_BLOB, ctypes.Array]:
buffer = ctypes.create_string_buffer(data)
blob = _DATA_BLOB(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)))
return blob, buffer
def _dpapi(data: bytes, protect: bool) -> bytes:
crypt32 = ctypes.windll.crypt32
kernel32 = ctypes.windll.kernel32
in_blob, in_buffer = _blob(data)
entropy_blob, entropy_buffer = _blob(b"BoltClam/v1")
out_blob = _DATA_BLOB()
if protect:
function = crypt32.CryptProtectData
function.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.c_wchar_p,
ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
ctypes.c_void_p, ctypes.c_uint32,
ctypes.POINTER(_DATA_BLOB)]
else:
function = crypt32.CryptUnprotectData
function.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.POINTER(ctypes.c_wchar_p),
ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
ctypes.c_void_p, ctypes.c_uint32,
ctypes.POINTER(_DATA_BLOB)]
function.restype = ctypes.c_int
if protect:
ok = function(ctypes.byref(in_blob), "BoltClam device key",
ctypes.byref(entropy_blob), None, None, 0x1, ctypes.byref(out_blob))
else:
description = ctypes.c_wchar_p()
ok = function(ctypes.byref(in_blob), ctypes.byref(description),
ctypes.byref(entropy_blob), None, None, 0x1, ctypes.byref(out_blob))
# Keep the input buffers alive until the call returns.
_ = (in_buffer, entropy_buffer)
if not ok:
raise ctypes.WinError()
try:
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
kernel32.LocalFree(out_blob.pbData)
class CredentialStore:
"""DPAPI-protected API key on Windows; a 0600 file on dev machines.
The fallback is deliberately *not* pretended to be secure: it exists so
running from source on Linux/macOS works. Windows always uses DPAPI.
"""
def __init__(self, path: Path | None = None) -> None:
self.path = Path(path) if path else credentials_path()
self._lock = threading.RLock()
@property
def is_secure(self) -> bool:
return os.name == "nt"
def save(self, api_key: str) -> None:
if not api_key:
raise CredentialError("No API key was supplied.")
raw = api_key.encode("utf-8")
if self.is_secure:
payload = {"version": 1, "protection": "dpapi",
"data": base64.b64encode(_dpapi(raw, True)).decode("ascii")}
else:
logger.warning("Credential store is using a local-file fallback (not Windows DPAPI).")
payload = {"version": 1, "protection": "plain",
"data": base64.b64encode(raw).decode("ascii")}
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_name(self.path.name + ".tmp")
temp.write_text(json.dumps(payload), encoding="utf-8")
try:
os.chmod(temp, 0o600)
except OSError:
pass
os.replace(temp, self.path)
def load(self) -> Optional[str]:
with self._lock:
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, ValueError) as exc:
raise CredentialError("Your saved BoltClam sign-in could not be read.") from exc
if not isinstance(payload, dict):
raise CredentialError("Your saved BoltClam sign-in is malformed.")
try:
raw = base64.b64decode(str(payload.get("data") or ""))
except (ValueError, TypeError) as exc:
raise CredentialError("Your saved BoltClam sign-in is malformed.") from exc
protection = payload.get("protection")
if protection == "dpapi":
if not self.is_secure:
raise CredentialError("This sign-in was saved with Windows DPAPI and cannot be read here.")
try:
return _dpapi(raw, False).decode("utf-8")
except OSError as exc:
raise CredentialError("Windows could not unlock your BoltClam sign-in.") from exc
if protection == "plain":
return raw.decode("utf-8", "replace")
raise CredentialError("Your saved BoltClam sign-in uses an unknown format.")
def delete(self) -> None:
with self._lock:
try:
self.path.unlink()
except FileNotFoundError:
pass
except OSError:
logger.warning("Could not delete the saved credential file", exc_info=True)
# ── loopback listener ───────────────────────────────────────────────────────
_CALLBACK_CSS = """
:root{color-scheme:dark}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#071018;
color:#eef7fb;font:15px/1.55 "Segoe UI",system-ui,sans-serif;padding:24px}
.card{max-width:420px;background:#0d1b26;border:1px solid #1d3444;border-radius:20px;
padding:32px;text-align:center;box-shadow:0 24px 70px rgba(0,0,0,.45)}
.mark{width:54px;height:54px;border-radius:16px;margin:0 auto 18px;display:grid;
place-items:center;background:rgba(57,224,200,.1);border:1px solid rgba(57,224,200,.35)}
h1{font-size:22px;margin:0 0 8px}p{color:#8fa9bb;margin:0}
"""
def _callback_page(title: str, message: str) -> bytes:
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><style>{_CALLBACK_CSS}</style></head>
<body><main class="card"><div class="mark"><svg width="28" height="28" viewBox="0 0 24 24"
fill="none" aria-hidden="true"><path d="M12 2.6 20 6v5.1c0 4.9-3.3 8.5-8 10.3-4.7-1.8-8-5.4-8-10.3V6l8-3.4Z"
stroke="#7af4ff" stroke-width="1.6"/><path d="m8.4 12.1 2.5 2.5 4.9-5.1" stroke="#7af4ff"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h1>{html.escape(title)}</h1><p>{html.escape(message)}</p></main></body></html>""".encode()
class LoopbackAuthReceiver:
"""A one-shot RFC 8252 loopback listener bound to 127.0.0.1."""
def __init__(self, state: str) -> None:
self.state = state
self.result: dict | None = None
self.event = threading.Event()
receiver = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/favicon.ico":
self.send_response(204)
self.end_headers()
return
if parsed.path != "/cb":
self.send_response(404)
self.end_headers()
return
query = parse_qs(parsed.query)
state = (query.get("state") or [""])[0]
if state != receiver.state:
body = _callback_page("Sign-in state did not match",
"Return to BoltClam and start sign-in again.")
self.send_response(400)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
error = (query.get("error") or [""])[0]
code = (query.get("code") or [""])[0]
receiver.result = {"error": error, "code": code, "state": state}
body = _callback_page(
"BoltClam is signed in" if code and not error else "Sign-in needs attention",
"You can close this tab and return to BoltClam."
if code and not error else "Return to BoltClam to try again.",
)
self.send_response(200 if code and not error else 400)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
self.wfile.write(body)
receiver.event.set()
def log_message(self, fmt: str, *args) -> None:
return
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self._server.daemon_threads = True
port = int(self._server.server_address[1])
if not (1024 <= port <= 65535):
self._server.server_close()
raise AuthError("Could not open a local sign-in port.")
self._thread = threading.Thread(target=self._server.serve_forever,
name="boltclam-auth-loopback", daemon=True)
@property
def redirect_uri(self) -> str:
return f"http://127.0.0.1:{int(self._server.server_address[1])}/cb"
def start(self) -> None:
self._thread.start()
def wait(self, timeout: float, cancel_event: threading.Event | None = None) -> dict:
deadline = time.monotonic() + max(1.0, timeout)
while time.monotonic() < deadline:
if cancel_event is not None and cancel_event.is_set():
raise AuthCancelled("Sign-in was cancelled.")
if self.event.wait(0.2):
return dict(self.result or {})
raise AuthTimeout("BoltClam did not receive the browser sign-in in time.")
def close(self) -> None:
try:
self._server.shutdown()
except Exception:
pass
try:
self._server.server_close()
except Exception:
pass
# ── auth service ────────────────────────────────────────────────────────────
@dataclass
class Session:
api_key: str
account: dict | None
offline: bool = False
def _default_device_name() -> str:
host = platform.node() or "Windows PC"
return f"BoltClam on {host}"[:200]
class AuthService:
def __init__(self, base_url: str, *, device_name: str = "", install_id: str = "",
credentials: CredentialStore | None = None,
account_cache: AccountCache | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.device_name = (device_name or _default_device_name())[:200]
self.install_id = install_id or uuid.uuid4().hex
self.credentials = credentials or CredentialStore()
self.account_cache = account_cache or AccountCache()
def stored_key(self) -> Optional[str]:
try:
return self.credentials.load()
except CredentialError as exc:
logger.warning("Stored credential could not be read: %s", exc)
return None
def restore(self, api_key: str | None = None) -> Session:
key = api_key or self.stored_key()
if not key:
raise SessionExpired("No saved sign-in.")
client = ApiClient(self.base_url, key, device_name=self.device_name,
install_id=self.install_id)
try:
account = client.get_account()
except ApiError as exc:
if exc.status == 401:
raise SessionExpired("Your BoltClam session has expired.") from None
if isinstance(exc, NetworkError):
cached = self.account_cache.load()
if cached:
logger.info("Bolt API unreachable; opening with the last verified account.")
return Session(key, cached, offline=True)
raise
self.account_cache.save(account)
return Session(key, account)
def browser_login(self, open_url: Callable[[str], bool | None],
timeout: float = _DEFAULT_TIMEOUT,
cancel_event: threading.Event | None = None) -> Session:
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(24)
receiver = LoopbackAuthReceiver(state)
try:
receiver.start()
query = urlencode({
"redirect_uri": receiver.redirect_uri,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"client": "boltclam",
"device_name": self.device_name,
})
url = f"{self.base_url}/desk/auth/boltclam/start?{query}"
logger.info("Opening BoltClam browser sign-in")
opened = open_url(url)
if opened is False:
raise AuthError("Could not open your browser for sign-in.")
callback = receiver.wait(timeout, cancel_event=cancel_event)
error = str(callback.get("error") or "")
code = str(callback.get("code") or "")
if error:
raise AuthError(_ERROR_MESSAGES.get(error, "Sign-in failed. Please try again."))
if not code:
raise AuthError("The browser did not return a sign-in code.")
try:
exchanged = ApiClient.exchange_code(
self.base_url, code=code, verifier=verifier,
redirect_uri=receiver.redirect_uri,
device_name=self.device_name, install_id=self.install_id,
)
except ApiError as exc:
raise AuthError(_ERROR_MESSAGES.get(str(exc.code or ""), str(exc))) from None
key = str(exchanged["api_key"])
account = self._fetch_account(key)
self.credentials.save(key)
if account:
self.account_cache.save(account)
return Session(key, account)
finally:
receiver.close()
def login_with_key(self, api_key: str) -> Session:
key = str(api_key or "").strip()
if not key:
raise AuthError("Enter a Bolt API key.")
client = ApiClient(self.base_url, key, device_name=self.device_name,
install_id=self.install_id)
try:
account = client.get_account()
except ApiError as exc:
if exc.status == 401:
raise AuthError("That API key was not accepted.") from None
raise
self.credentials.save(key)
self.account_cache.save(account)
return Session(key, account)
def _fetch_account(self, key: str) -> dict | None:
client = ApiClient(self.base_url, key, device_name=self.device_name,
install_id=self.install_id)
try:
return client.get_account()
except ApiError as exc:
# The exchange already proved the key works. A transient account
# fetch failure should not throw away a successful sign-in; the
# app will refresh the account after the first successful call.
if isinstance(exc, NetworkError):
logger.warning("Signed in, but the account summary was unreachable: %s", exc)
return None
if exc.status == 401:
raise AuthError("Bolt issued a key that it then rejected. Try again.") from None
logger.warning("Signed in, but the account summary failed: %s", exc)
return None
def logout(self, api_key: str | None = None) -> None:
key = api_key or self.stored_key()
if key:
ApiClient(self.base_url, key, device_name=self.device_name,
install_id=self.install_id).logout()
self.credentials.delete()
self.account_cache.clear()
+380
View File
@@ -0,0 +1,380 @@
"""ClamAV discovery, command construction, target selection and output parsing.
The app manages its own signature directory (``data/clamav-db``) so
``freshclam`` can update definitions without an administrator prompt. If
that directory is empty, the database shipped beside ``clamscan.exe`` is
used instead. Neither path is ever accepted from scan output or the network.
"""
from __future__ import annotations
import os
import re
import shlex
import shutil
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
from . import paths
from .models import ClamAVInfo
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_VERSION_RE = re.compile(r"ClamAV\s+([0-9][0-9A-Za-z.\-]*)(?:/([0-9]+)(?:/([^\r\n]+))?)?")
_QUOTES = ("'", '"')
_DANGEROUS_OPTIONS = ("--remove", "--move", "--copy")
class ClamAVError(Exception):
pass
def hidden_kwargs() -> dict:
"""subprocess kwargs that keep a GUI launch from flashing a console."""
if os.name != "nt":
return {}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
return {
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
"startupinfo": startupinfo,
}
def _candidate_names() -> list[str]:
return ["clamscan.exe", "clamscan"]
def find_clamscan(configured: str = "") -> tuple[Path | None, str]:
configured = os.path.expandvars(os.path.expanduser(str(configured or "").strip()))
if configured:
path = Path(configured)
if path.is_dir():
for name in _candidate_names():
candidate = path / name
if candidate.is_file():
return candidate, ""
return None, "No clamscan executable was found in that folder."
if path.is_file():
return path, ""
return None, "The configured ClamAV path does not exist."
candidates: list[Path] = []
env_path = os.environ.get("CLAMSCAN_PATH", "").strip()
if env_path:
candidates.append(Path(os.path.expandvars(os.path.expanduser(env_path))))
for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
base = os.environ.get(env_name)
if base:
candidates.append(Path(base) / "ClamAV" / "clamscan.exe")
candidates.append(Path(base) / "ClamAV" / "clamscan")
candidates.extend([Path(r"C:\ClamAV\clamscan.exe"),
Path(r"C:\Program Files\ClamAV\clamscan.exe")])
for name in _candidate_names():
found = shutil.which(name)
if found:
candidates.append(Path(found))
candidates.extend([Path("/usr/bin/clamscan"), Path("/usr/local/bin/clamscan")])
for candidate in candidates:
try:
if candidate.is_file():
return candidate, ""
except OSError:
continue
return None, "ClamAV was not found. Set its install folder in Settings."
def find_freshclam(clamscan: Path | None) -> tuple[Path | None, str]:
env_path = os.environ.get("FRESHCLAM_PATH", "").strip()
candidates: list[Path] = []
if env_path:
candidates.append(Path(os.path.expandvars(os.path.expanduser(env_path))))
if clamscan is not None:
candidates.append(clamscan.with_name("freshclam.exe"))
candidates.append(clamscan.with_name("freshclam"))
for name in ("freshclam.exe", "freshclam"):
found = shutil.which(name)
if found:
candidates.append(Path(found))
for candidate in candidates:
try:
if candidate.is_file():
return candidate, ""
except OSError:
continue
return None, "freshclam was not found next to clamscan."
def parse_version_output(text: str) -> tuple[str, str, str]:
match = _VERSION_RE.search(text or "")
if not match:
return "", "", ""
return match.group(1) or "", match.group(2) or "", (match.group(3) or "").strip()
def database_files(directory: Path | None) -> list[Path]:
if directory is None or not directory.is_dir():
return []
files: list[Path] = []
for name in ("main.cvd", "main.cld", "daily.cvd", "daily.cld",
"bytecode.cvd", "bytecode.cld"):
candidate = directory / name
if candidate.is_file():
files.append(candidate)
return files
def has_database(directory: Path | None) -> bool:
return bool(database_files(directory))
def database_age(directory: Path | None) -> tuple[str, float | None]:
files = database_files(directory)
if not files:
return "", None
try:
newest = max(files, key=lambda path: path.stat().st_mtime)
timestamp = newest.stat().st_mtime
except OSError:
return "", None
age = max(0.0, (time.time() - timestamp) / 86400.0)
date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
return date, age
def parse_extra_options(text: str) -> list[str]:
"""Split user-entered extra ClamAV options into an argv list.
Destructive options are refused because remediation belongs to the app's
quarantine/delete path, which records what happened. ``posix=False``
keeps Windows paths with backslashes intact; surrounding quotes are
stripped manually afterward.
"""
raw = str(text or "").strip()
if not raw:
return []
if "\x00" in raw:
raise ValueError("ClamAV extra options contain an invalid character.")
# shlex's POSIX mode understands quotes that wrap a whole value (including
# spaces), but it treats backslashes as escapes. Doubling them first keeps
# Windows paths intact while still letting POSIX quoting do its job.
protected = raw.replace("\\", "\\\\")
try:
tokens = shlex.split(protected, posix=True)
except ValueError as exc:
raise ValueError(f"ClamAV extra options could not be parsed: {exc}") from exc
result: list[str] = []
for token in tokens:
token = token.strip()
if not token:
continue
if len(token) >= 2 and token[0] == token[-1] and token[0] in _QUOTES:
token = token[1:-1]
if any(ch in token for ch in ("\r", "\n", "\x00")):
raise ValueError("ClamAV extra options must be a single line.")
name = token.split("=", 1)[0].lower()
if name in _DANGEROUS_OPTIONS or any(name.startswith(opt + "=") for opt in _DANGEROUS_OPTIONS):
raise ValueError(
f"{name} is managed by BoltClam; use the quarantine/delete settings instead.")
if "=" in token:
option, _, value = token.partition("=")
if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTES:
value = value[1:-1]
token = f"{option}={value}"
result.append(token)
return result
def build_scan_args(clamscan: Path, targets: Iterable[Path], *,
database_dir: Path | None = None,
quarantine_dir: Path | None = None,
temp_dir: Path | None = None,
extra_options: Iterable[str] = ()) -> list[str]:
args = [str(clamscan), "--recursive=yes", "--infected", "--verbose", "--stdout"]
if has_database(database_dir):
args.append(f"--database={database_dir}")
for excluded in (quarantine_dir, temp_dir):
if excluded:
text = str(excluded)
args.append(f"--exclude-dir={re.escape(text)}")
forward = text.replace("\\", "/")
if forward != text:
args.append(f"--exclude-dir={re.escape(forward)}")
args.extend(str(option) for option in extra_options)
args.extend(str(path) for path in targets)
return args
def build_freshclam_args(freshclam: Path, database_dir: Path) -> list[str]:
return [str(freshclam), "--foreground", "--stdout", f"--datadir={database_dir}"]
def scan_targets(scope: str, *, profiles: Iterable[Path] | None = None,
drives: Iterable[Path] | None = None) -> list[Path]:
roots = list(drives if drives is not None else paths.fixed_drive_roots()) \
if scope == "full" else \
list(profiles if profiles is not None else paths.windows_profile_dirs())
if not roots:
roots = [Path.home()]
result: list[Path] = []
seen: set[str] = set()
for root in roots:
try:
root = Path(root).expanduser()
key = os.path.normcase(str(root.resolve()))
if key in seen or not root.is_dir():
continue
seen.add(key)
result.append(root)
except OSError:
continue
return result
def parse_clamscan_line(line: str) -> dict | None:
"""Turn one clamscan output line into a small event dictionary.
Detection paths are parsed with ``rpartition(": ")`` so a Windows drive
letter and any colon in earlier components stay in the path.
"""
text = _ANSI_RE.sub("", str(line or "")).rstrip("\r\n")
if not text:
return None
stripped = text.strip()
if stripped.endswith(" FOUND"):
body = stripped[:-len(" FOUND")].rstrip()
path, separator, signature = body.rpartition(": ")
if separator and path:
return {"kind": "detection", "path": path, "signature": signature}
for prefix, key in (
("Known viruses:", "known_viruses"),
("Engine version:", "engine_version"),
("Scanned directories:", "scanned_directories"),
("Scanned files:", "scanned_files"),
("Infected files:", "infected_files"),
("Data scanned:", "data_scanned"),
("Data read:", "data_read"),
("Time:", "time"),
("Start Date:", "start_date"),
("End Date:", "end_date"),
):
if stripped.startswith(prefix):
value = stripped[len(prefix):].strip()
return {"kind": "summary", "key": key, "value": value}
if stripped.startswith("Scanning "):
return {"kind": "progress", "path": stripped[len("Scanning "):].strip()}
skipped_suffixes = (": Empty file", ": Size limit exceeded", ": Symbolic link",
": File name too long", ": Excluded")
for suffix in skipped_suffixes:
if stripped.endswith(suffix):
return {"kind": "skipped", "path": stripped[:-len(suffix)],
"reason": suffix[2:]}
error_markers = ("Access denied", "Can't open file", "Can't access file",
"No such file or directory", "Permission denied")
for marker in error_markers:
if marker in stripped:
return {"kind": "error", "message": stripped}
if stripped.startswith(("ERROR:", "LibClamAV Error:", "LibClamAV Error ")):
return {"kind": "error", "message": stripped}
if stripped.startswith(("WARNING:", "LibClamAV Warning:")):
return {"kind": "log", "message": stripped}
if stripped.endswith(": OK"):
return {"kind": "progress", "path": stripped[:-len(": OK")]}
return {"kind": "log", "message": stripped}
def summarize(events: Iterable[dict]) -> dict:
summary: dict[str, object] = {}
for event in events:
if event and event.get("kind") == "summary":
key = str(event.get("key") or "")
value = event.get("value")
if key in ("scanned_files", "infected_files", "scanned_directories",
"known_viruses"):
try:
summary[key] = int(str(value).split()[0])
except (ValueError, IndexError):
pass
elif key == "engine_version":
summary[key] = str(value)
return summary
class ClamAVService:
def __init__(self, configured_path: str = "") -> None:
self.configured_path = configured_path
self._info: ClamAVInfo | None = None
self._info_at = 0.0
def info(self, force: bool = False) -> ClamAVInfo:
if not force and self._info is not None and time.time() - self._info_at < 30:
return self._info
clamscan, error = find_clamscan(self.configured_path)
if clamscan is None:
info = ClamAVInfo(available=False, error=error)
self._info, self._info_at = info, time.time()
return info
try:
completed = subprocess.run(
[str(clamscan), "--version"], capture_output=True, text=True,
timeout=15, errors="replace", **hidden_kwargs(),
)
output = (completed.stdout or "") + "\n" + (completed.stderr or "")
engine, db_version, db_date = parse_version_output(output)
if completed.returncode != 0 and not engine:
info = ClamAVInfo(available=False, path=clamscan,
error="clamscan.exe could not run.")
else:
managed = paths.clamav_db_dir()
db_dir = managed if has_database(managed) else clamscan.parent
date, age = database_age(db_dir)
info = ClamAVInfo(
available=True, path=clamscan,
engine_version=engine or "ClamAV",
db_version=db_version,
db_path=db_dir if has_database(db_dir) else None,
db_date=db_date or date,
db_age_days=age,
)
except (OSError, subprocess.SubprocessError) as exc:
info = ClamAVInfo(available=False, path=clamscan,
error=f"clamscan.exe could not run: {exc}")
self._info, self._info_at = info, time.time()
return info
def invalidate(self) -> None:
self._info = None
self._info_at = 0.0
def freshclam(self) -> Path | None:
path, _error = find_freshclam(self.info().path)
return path
def scan_arguments(self, targets: list[Path], extra_options: Iterable[str] = (),
*, exclude_temp: bool = True) -> list[str]:
info = self.info()
if not info.available or info.path is None:
raise ClamAVError(info.error or "ClamAV is not available.")
managed = paths.clamav_db_dir()
database = managed if has_database(managed) else (
info.path.parent if has_database(info.path.parent) else None)
return build_scan_args(
info.path, targets, database_dir=database,
quarantine_dir=paths.quarantine_dir(),
temp_dir=paths.temp_dir() if exclude_temp else None,
extra_options=extra_options,
)
def version_summary(self) -> str:
return self.info().summary
+222
View File
@@ -0,0 +1,222 @@
"""Persistent BoltClam settings and the small non-secret account cache."""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from . import paths
logger = logging.getLogger(__name__)
UPDATE_CHANNELS = ("release", "beta")
SCAN_SCOPES = ("homes", "full")
AUTO_ACTIONS = ("quarantine", "delete", "notify")
DEFAULT_API_URL = "https://api.themajesticnetwork.com"
def _as_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("1", "true", "yes", "on"):
return True
if lowered in ("0", "false", "no", "off"):
return False
return default
def _clean_choice(value: Any, choices: tuple[str, ...], default: str) -> str:
text = str(value or "").strip().lower()
return text if text in choices else default
def _clean_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
cleaned: list[str] = []
for entry in value:
if not isinstance(entry, str):
continue
text = os.path.expandvars(os.path.expanduser(entry.strip()))
if text and text not in cleaned:
cleaned.append(text)
return cleaned[:30]
@dataclass
class AppSettings:
update_channel: str = "release"
clamav_path: str = ""
extra_options: str = ""
logging_enabled: bool = True
debug_logging: bool = False
send_logs: bool = False
scan_scope: str = "homes"
auto_action: str = "quarantine"
realtime_enabled: bool = True
notify_on_detection: bool = True
close_to_tray: bool = True
api_base_url: str = DEFAULT_API_URL
extra_watch_dirs: list[str] = field(default_factory=list)
last_log_upload: str = ""
last_update_check: float = 0.0
install_id: str = ""
def normalized(self) -> "AppSettings":
data = asdict(self)
data["update_channel"] = _clean_choice(data["update_channel"], UPDATE_CHANNELS, "release")
data["scan_scope"] = _clean_choice(data["scan_scope"], SCAN_SCOPES, "homes")
data["auto_action"] = _clean_choice(data["auto_action"], AUTO_ACTIONS, "quarantine")
for key in ("logging_enabled", "debug_logging", "send_logs", "realtime_enabled",
"notify_on_detection", "close_to_tray"):
data[key] = _as_bool(data.get(key), getattr(AppSettings(), key))
data["clamav_path"] = os.path.expandvars(os.path.expanduser(
str(data.get("clamav_path") or "").strip()))
data["extra_options"] = str(data.get("extra_options") or "").strip()[:4000]
data["api_base_url"] = (str(data.get("api_base_url") or DEFAULT_API_URL)
.strip().rstrip("/") or DEFAULT_API_URL)
data["extra_watch_dirs"] = _clean_list(data.get("extra_watch_dirs"))
try:
data["last_update_check"] = max(0.0, float(data.get("last_update_check") or 0.0))
except (TypeError, ValueError):
data["last_update_check"] = 0.0
data["last_log_upload"] = str(data.get("last_log_upload") or "")[:80]
data["install_id"] = str(data.get("install_id") or "").strip()[:64] or uuid.uuid4().hex
if data["debug_logging"] and not data["logging_enabled"]:
data["debug_logging"] = False
return AppSettings(**data)
def public_snapshot(self) -> dict:
"""Settings safe to include in a diagnostics bundle.
The API key is never in this object. Extra ClamAV options are
included because they explain scan behaviour, but token-shaped
strings are redacted by the diagnostics writer.
"""
data = asdict(self.normalized())
data.pop("install_id", None)
return data
class SettingsStore:
def __init__(self, path: Path | None = None) -> None:
self.path = Path(path) if path else paths.settings_path()
self._lock = threading.RLock()
self._settings: AppSettings | None = None
def load(self) -> AppSettings:
with self._lock:
if self._settings is not None:
return self._settings
data: dict[str, Any] = {}
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
data = raw
except FileNotFoundError:
pass
except (OSError, ValueError):
logger.warning("Settings file is unreadable; starting with defaults", exc_info=True)
try:
backup = self.path.with_suffix(
f".corrupt-{time.strftime('%Y%m%d-%H%M%S')}.json")
self.path.replace(backup)
except OSError:
pass
self._settings = AppSettings(**{k: v for k, v in data.items()
if k in AppSettings.__dataclass_fields__}).normalized()
if not self.path.exists():
self.save(self._settings)
return self._settings
def save(self, settings: AppSettings) -> AppSettings:
normalized = settings.normalized()
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(asdict(normalized), indent=2, sort_keys=True)
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(
"w", dir=str(self.path.parent), suffix=".tmp",
delete=False, encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
temp_path = handle.name
os.replace(temp_path, self.path)
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
self._settings = normalized
return normalized
def update(self, **changes: Any) -> AppSettings:
current = self.load()
data = asdict(current)
data.update(changes)
return self.save(AppSettings(**data))
class AccountCache:
"""Last verified account payload, used only when the API is unreachable.
This is deliberately a separate file from settings: it is not public
diagnostic data and it never contains a credential.
"""
def __init__(self, path: Path | None = None) -> None:
self.path = Path(path) if path else paths.account_cache_path()
self._lock = threading.RLock()
def load(self) -> dict | None:
with self._lock:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict):
return None
account = data.get("account")
return account if isinstance(account, dict) else None
def save(self, account: dict) -> None:
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"account": account}
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(
"w", dir=str(self.path.parent), suffix=".tmp",
delete=False, encoding="utf-8") as handle:
json.dump(payload, handle, indent=1)
temp_path = handle.name
os.replace(temp_path, self.path)
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
def clear(self) -> None:
with self._lock:
try:
self.path.unlink()
except OSError:
pass
+72
View File
@@ -0,0 +1,72 @@
"""The service bundle shared by every page in the main window."""
from __future__ import annotations
from dataclasses import dataclass, field
from .api_client import ApiClient
from .auth import AuthService
from .clamav import ClamAVService
from .config import AppSettings, SettingsStore
from .quarantine import QuarantineService
from .realtime import RealtimeProtection
from .storage import Storage
@dataclass
class AppContext:
settings_store: SettingsStore
settings: AppSettings
storage: Storage
quarantine: QuarantineService
clamav: ClamAVService
auth: AuthService
session_key: str
account: dict | None
install_id: str
api: ApiClient | None = None
realtime: RealtimeProtection | None = None
offline: bool = False
extra: dict = field(default_factory=dict)
def rebuild_api(self) -> ApiClient:
self.api = ApiClient(
self.settings.api_base_url, self.session_key,
device_name=self.auth.device_name, install_id=self.install_id)
return self.api
@property
def user(self) -> dict:
return (self.account or {}).get("user") or {}
@property
def subscription(self) -> dict:
return (self.account or {}).get("subscription") or {}
@property
def user_name(self) -> str:
user = self.user
return str(user.get("name") or user.get("username") or
(str(user.get("email") or "Bolt user").split("@")[0]))
@property
def user_email(self) -> str:
return str(self.user.get("email") or "")
@property
def plan_label(self) -> str:
subscription = self.subscription
label = str(subscription.get("label") or "Free")
status = str(subscription.get("status") or "none").lower()
if status in ("active", "trialing"):
return label
return "Free"
@property
def licensed(self) -> bool:
return bool((self.account or {}).get("features", {}).get("licensed"))
def update_settings(self, settings: AppSettings) -> None:
self.settings = settings
if self.realtime is not None:
self.realtime.set_settings(settings)
+111
View File
@@ -0,0 +1,111 @@
"""Logging setup and redaction shared by the app and diagnostics bundles."""
from __future__ import annotations
import logging
import re
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from . import paths
from .config import AppSettings
LOG_NAME = "boltclam.log"
_MAX_BYTES = 2_000_000
_BACKUPS = 3
_REDACTIONS = (
(re.compile(r"bolt_live_[A-Za-z0-9_-]+"), "bolt_live_[redacted]"),
(re.compile(r"(?i)(authorization\s*:\s*bearer\s+)\S+"), r"\1[redacted]"),
(re.compile(r"(?i)(x-desk-api-key\s*:\s*)\S+"), r"\1[redacted]"),
(re.compile(r"(?i)(password\s*[=:]\s*)\S+"), r"\1[redacted]"),
(re.compile(r"(?i)(api[_-]?key\s*[=:]\s*)\S+"), r"\1[redacted]"),
(re.compile(r"(?i)(identity_token\s*[=:]\s*)\S+"), r"\1[redacted]"),
)
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
def redact(text: str) -> str:
value = str(text or "")
for pattern, replacement in _REDACTIONS:
value = pattern.sub(replacement, value)
return value
def redact_emails(text: str) -> str:
return _EMAIL_RE.sub("[email]", redact(text))
class RedactionFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
try:
message = record.getMessage()
record.msg = redact(message)
record.args = ()
except Exception:
pass
return True
def log_path() -> Path:
return paths.logs_dir() / LOG_NAME
def configure(settings: AppSettings) -> None:
"""Install one rotating file handler, or remove it when logging is off."""
paths.ensure_dirs()
root = logging.getLogger()
for handler in list(root.handlers):
if getattr(handler, "_boltclam_handler", False):
root.removeHandler(handler)
try:
handler.close()
except Exception:
pass
level = logging.DEBUG if (settings.logging_enabled and settings.debug_logging) else logging.INFO
root.setLevel(level if settings.logging_enabled else logging.WARNING)
formatter = logging.Formatter(
"%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if settings.logging_enabled:
try:
file_handler = RotatingFileHandler(
log_path(), maxBytes=_MAX_BYTES, backupCount=_BACKUPS, encoding="utf-8")
file_handler.setFormatter(formatter)
file_handler.addFilter(RedactionFilter())
file_handler._boltclam_handler = True # type: ignore[attr-defined]
root.addHandler(file_handler)
except OSError:
# A GUI app must not fail to start because its log directory is
# read-only; diagnostics will simply be absent.
pass
if not getattr(sys, "frozen", False):
console = logging.StreamHandler()
console.setFormatter(formatter)
console.addFilter(RedactionFilter())
console._boltclam_handler = True # type: ignore[attr-defined]
root.addHandler(console)
for noisy in ("urllib3", "PyQt6", "PIL"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def tail_lines(limit: int = 250) -> list[str]:
try:
with open(log_path(), "r", encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
return [line.rstrip("\n") for line in lines[-limit:]]
except OSError:
return []
def rotated_logs() -> list[Path]:
base = log_path()
files = [base]
files.extend(base.with_name(f"{base.name}.{index}") for index in range(1, _BACKUPS + 1))
return [path for path in files if path.is_file()]
+184
View File
@@ -0,0 +1,184 @@
"""Opt-in diagnostics bundle creation and upload.
The bundle is intentionally narrow: app logs, redacted scan/quarantine
history, system summary and settings. It never includes the API key,
passwords, browser contents, or non-detection file contents. User-profile
prefixes are replaced with ``%USERPROFILE%`` before the zip is written.
"""
from __future__ import annotations
import json
import logging
import re
import shutil
import tempfile
import time
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from . import __build_date__, __version__, paths
from .api_client import ApiClient
from .config import AppSettings
from .logging_setup import redact, redact_emails, rotated_logs
from .models import ClamAVInfo
logger = logging.getLogger(__name__)
class DiagnosticsError(Exception):
pass
def _profile_prefixes() -> list[str]:
prefixes: list[str] = []
for profile in paths.windows_profile_dirs():
try:
prefixes.append(str(profile.resolve()))
except OSError:
prefixes.append(str(profile))
prefixes.extend([str(Path.home()), "C:\\Users\\Public", "/home", "/Users"])
return sorted({prefix for prefix in prefixes if prefix}, key=len, reverse=True)
def redact_paths(text: str) -> str:
value = str(text or "")
for prefix in _profile_prefixes():
value = re.sub(re.escape(prefix), "%USERPROFILE%", value, flags=re.IGNORECASE)
return value
def sanitize_text(text: str) -> str:
return redact_emails(redact(redact_paths(text)))
def _sanitize_value(value, key: str = ""):
if isinstance(value, str):
# Emails are preserved in the manifest (the consent screen says the
# account email is included); log text goes through sanitize_text(),
# which does redact them.
return redact(redact_paths(value))
if isinstance(value, list):
return [_sanitize_value(item, key) for item in value]
if isinstance(value, dict):
return {str(k): _sanitize_value(v, str(k)) for k, v in value.items()}
return value
def _read_log(path: Path, max_chars: int = 300_000) -> str:
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError:
return ""
if len(text) > max_chars:
text = "[earlier log lines omitted]\n" + text[-max_chars:]
return sanitize_text(text)
def _cleanup_old_bundles(days: int = 14) -> None:
cutoff = time.time() - days * 86400
try:
for entry in paths.diagnostics_dir().glob("BoltClam-diagnostics-*.zip"):
try:
if entry.stat().st_mtime < cutoff:
entry.unlink()
except OSError:
pass
except OSError:
pass
def build_bundle(settings: AppSettings, clamav: ClamAVInfo, *,
system_snapshot: dict | None = None,
account: dict | None = None,
realtime_roots: list[str] | None = None) -> Path:
paths.ensure_dirs()
_cleanup_old_bundles()
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
bundle_path = paths.diagnostics_dir() / f"BoltClam-diagnostics-{stamp}-{uuid.uuid4().hex[:6]}.zip"
temp_root = Path(tempfile.mkdtemp(prefix="boltclam-diagnostics-", dir=str(paths.temp_dir())))
try:
manifest = {
"app": "BoltClam",
"version": __version__,
"build_date": __build_date__,
"generated_at": datetime.now(timezone.utc).isoformat(
timespec="seconds").replace("+00:00", "Z"),
"os": paths.os_label(),
"hostname": paths.hostname(),
"is_admin": paths.is_admin(),
"account": {
"email": (account or {}).get("user", {}).get("email", ""),
"user_id": (account or {}).get("user", {}).get("id", ""),
"subscription": (account or {}).get("subscription", {}),
},
"clamav": {
"available": clamav.available,
"path": str(clamav.path) if clamav.path else "",
"engine": clamav.engine_version,
"db_version": clamav.db_version,
"db_date": clamav.db_date,
"db_age_days": clamav.db_age_days,
"error": clamav.error,
},
"settings": settings.public_snapshot(),
"realtime_watch_roots": realtime_roots or [],
"notes": [
"API keys, passwords and account tokens are redacted.",
"User profile prefixes are replaced with %USERPROFILE%.",
"Email addresses in logs are redacted; the account email is in this manifest.",
"No file contents are included.",
],
}
(temp_root / "manifest.json").write_text(
json.dumps(_sanitize_value(manifest), indent=1), encoding="utf-8")
logs = []
for path in rotated_logs():
text = _read_log(path)
if text:
logs.append(f"===== {path.name} =====\n{text}\n")
(temp_root / "boltclam-logs.txt").write_text(
sanitize_text("\n".join(logs) or "No app log was available."),
encoding="utf-8")
if system_snapshot:
(temp_root / "system-summary.json").write_text(
json.dumps(_sanitize_value(system_snapshot), indent=1), encoding="utf-8")
(temp_root / "README.txt").write_text(
"BoltClam diagnostics bundle\n"
"==========================\n\n"
"This archive was created by BoltClam after you enabled 'Send logs to Bolt'.\n"
"It contains application logs, settings, scan/quarantine summaries and a system\n"
"summary. API keys, passwords, tokens and file contents are not included.\n"
"User profile paths are replaced with %USERPROFILE%.\n",
encoding="utf-8")
with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(temp_root.iterdir()):
if path.is_file():
archive.write(path, arcname=path.name)
logger.info("Diagnostics bundle written to %s", bundle_path)
return bundle_path
except OSError as exc:
raise DiagnosticsError(f"Could not create the diagnostics bundle: {exc}") from exc
finally:
shutil.rmtree(temp_root, ignore_errors=True)
def upload_bundle(api: ApiClient, bundle_path: Path, settings: AppSettings,
*, note: str = "") -> dict:
try:
result = api.upload_diagnostics(bundle_path, metadata={
"channel": settings.update_channel,
"note": note[:400],
})
except Exception as exc:
raise DiagnosticsError(str(exc)) from exc
if not isinstance(result, dict) or not result.get("id"):
raise DiagnosticsError("The Bolt service did not confirm the upload.")
return result
+191
View File
@@ -0,0 +1,191 @@
"""Small typed records shared by the storage, scanner and UI layers."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
@dataclass
class Detection:
path: str
signature: str
action: str = "detected"
quarantine_id: str | None = None
size: int = 0
detected_at: str = ""
id: int | None = None
def __post_init__(self) -> None:
if not self.detected_at:
self.detected_at = now_iso()
@dataclass
class ScanRecord:
id: int | None
started_at: str
finished_at: str | None
scope: str
status: str
paths: list[str]
scanned_files: int = 0
infected_files: int = 0
errors: int = 0
engine_version: str = ""
db_version: str = ""
duration_seconds: float = 0.0
cancelled: bool = False
detections: list[Detection] = field(default_factory=list)
@property
def scope_label(self) -> str:
return "Full disk" if self.scope == "full" else "User homes"
@property
def threat_count(self) -> int:
return max(self.infected_files, len(self.detections))
@dataclass
class QuarantineItem:
id: str
original_path: str
quarantine_path: str
signature: str
sha256: str
size: int
created_at: str
status: str = "quarantined"
scan_id: int | None = None
@dataclass
class RealtimeEvent:
created_at: str
path: str
signature: str
action: str
detail: str = ""
id: int | None = None
@dataclass
class ClamAVInfo:
available: bool
path: Path | None = None
engine_version: str = ""
db_version: str = ""
db_path: Path | None = None
db_date: str = ""
db_age_days: float | None = None
error: str = ""
@property
def db_fresh(self) -> bool:
return self.db_age_days is not None and self.db_age_days <= 7
@property
def summary(self) -> str:
if not self.available:
return self.error or "ClamAV not found"
bits = [self.engine_version or "ClamAV"]
if self.db_version:
bits.append(f"signatures {self.db_version}")
if self.db_age_days is not None:
bits.append(f"{self.db_age_days:.0f}d old")
return " · ".join(bits)
@dataclass
class UpdateAsset:
id: str
name: str
size: int
content_type: str
download_url: str
@dataclass
class UpdateRelease:
id: str
tag: str
name: str
notes: str
prerelease: bool
published_at: str
html_url: str
assets: list[UpdateAsset] = field(default_factory=list)
@dataclass
class UpdateFeed:
channel: str
latest: UpdateRelease | None
releases: list[UpdateRelease]
checked_at: str
@classmethod
def from_api(cls, payload: dict) -> "UpdateFeed":
def release_from(item: dict) -> UpdateRelease:
assets = [
UpdateAsset(
id=str(asset.get("id") or ""),
name=str(asset.get("name") or "download"),
size=int(asset.get("size") or 0),
content_type=str(asset.get("content_type") or ""),
download_url=str(asset.get("download_url") or ""),
)
for asset in (item.get("assets") or [])
if isinstance(asset, dict)
]
return UpdateRelease(
id=str(item.get("id") or ""),
tag=str(item.get("tag") or ""),
name=str(item.get("name") or item.get("tag") or ""),
notes=str(item.get("notes") or ""),
prerelease=bool(item.get("prerelease")),
published_at=str(item.get("published_at") or item.get("created_at") or ""),
html_url=str(item.get("html_url") or ""),
assets=assets,
)
releases = [release_from(item) for item in (payload.get("releases") or [])
if isinstance(item, dict)]
latest_raw = payload.get("latest")
latest = release_from(latest_raw) if isinstance(latest_raw, dict) else (
releases[0] if releases else None)
return cls(
channel=str(payload.get("channel") or "release"),
latest=latest,
releases=releases,
checked_at=str(payload.get("checked_at") or now_iso()),
)
def display_name(path: str) -> str:
"""Basename for a Windows or POSIX path, independent of host OS."""
text = str(path or "")
name = Path(text.replace("\\", "/")).name
return name or text
def human_scope(scope: str) -> str:
return "Full disk" if scope == "full" else "User homes"
def human_action(action: str) -> str:
return {
"detected": "Detected",
"quarantined": "Quarantined",
"deleted": "Deleted",
"failed": "Action failed",
"ignored": "Ignored",
"allowed": "Allowed",
"blocked": "Blocked",
}.get(action, action.replace("_", " ").title())
+284
View File
@@ -0,0 +1,284 @@
"""Filesystem locations and Windows helpers used across BoltClam.
Everything is resolved per call so ``BOLTCLAM_DATA_DIR`` / ``BOLTCLAM_CONFIG_DIR``
can redirect tests and the portable build without importing state at startup.
"""
from __future__ import annotations
import ctypes
import os
import shutil
import sys
from pathlib import Path
APP_DIR_NAME = "BoltClam"
def is_windows() -> bool:
return os.name == "nt"
def _env_path(name: str) -> Path | None:
value = os.environ.get(name, "").strip()
return Path(os.path.expandvars(os.path.expanduser(value))) if value else None
def config_dir() -> Path:
override = _env_path("BOLTCLAM_CONFIG_DIR")
if override:
return override
if is_windows():
base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
else:
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
return base / APP_DIR_NAME
def data_dir() -> Path:
override = _env_path("BOLTCLAM_DATA_DIR")
if override:
return override
if is_windows():
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
else:
base = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
return base / APP_DIR_NAME
def logs_dir() -> Path:
return data_dir() / "logs"
def quarantine_dir() -> Path:
return data_dir() / "quarantine"
def updates_dir() -> Path:
return data_dir() / "updates"
def diagnostics_dir() -> Path:
return data_dir() / "diagnostics"
def temp_dir() -> Path:
return data_dir() / "tmp"
def clamav_db_dir() -> Path:
"""App-managed ClamAV signature database.
freshclam can write here without administrator rights, and clamscan is
pointed at it with ``--database``. When it is empty we fall back to the
database shipped beside clamscan.exe.
"""
return data_dir() / "clamav-db"
def db_path() -> Path:
return data_dir() / "boltclam.db"
def settings_path() -> Path:
return config_dir() / "settings.json"
def account_cache_path() -> Path:
return config_dir() / "account.json"
def credentials_path() -> Path:
return config_dir() / "credentials.dat"
def resource_dir() -> Path:
override = _env_path("BOLTCLAM_RESOURCES")
if override:
return override
# PyInstaller extracts datas into _MEIPASS. The portable runtime keeps
# resources next to the app package under runtime/resources.
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return Path(meipass) / "resources"
return Path(__file__).resolve().parents[2] / "resources"
def ensure_dirs() -> None:
for path in (config_dir(), data_dir(), logs_dir(), quarantine_dir(),
updates_dir(), diagnostics_dir(), temp_dir(), clamav_db_dir()):
path.mkdir(parents=True, exist_ok=True)
if not is_windows():
for path in (config_dir(), data_dir(), logs_dir(), quarantine_dir()):
try:
os.chmod(path, 0o700)
except OSError:
pass
def is_admin() -> bool:
if not is_windows():
try:
return os.geteuid() == 0
except AttributeError:
return False
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def os_label() -> str:
if is_windows():
version = sys.getwindowsversion()
return f"Windows {version.major}.{version.minor} (build {version.build})"
return f"{os.name} {sys.platform}"
def hostname() -> str:
import socket
try:
return socket.gethostname() or "Windows PC"
except Exception:
return "Windows PC"
def open_path(path: Path) -> bool:
"""Open a file/folder in the OS shell. Returns False instead of raising."""
try:
if is_windows():
os.startfile(str(path)) # type: ignore[attr-defined]
elif sys.platform == "darwin":
import subprocess
subprocess.Popen(["open", str(path)])
else:
import subprocess
subprocess.Popen(["xdg-open", str(path)])
return True
except Exception:
return False
def free_space(path: Path) -> int:
try:
return shutil.disk_usage(path).free
except OSError:
return 0
def windows_profile_dirs() -> list[Path]:
"""Every Windows user profile path we can discover.
Registry ``ProfileList`` is authoritative and includes profiles outside
C:\\Users. When it cannot be read (ordinary non-admin process), fall back
to the children of C:\\Users. Public is included deliberately: downloads
there belong to every local account.
"""
profiles: list[Path] = []
if is_windows():
try:
import winreg
key_path = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key:
index = 0
while True:
try:
subkey_name = winreg.EnumKey(key, index)
except OSError:
break
index += 1
try:
with winreg.OpenKey(key, subkey_name) as subkey:
value, _kind = winreg.QueryValueEx(subkey, "ProfileImagePath")
expanded = os.path.expandvars(str(value or ""))
if expanded:
profiles.append(Path(expanded))
except OSError:
continue
except Exception:
pass
users = Path(os.environ.get("SystemDrive", "C:") + "\\Users")
if users.is_dir():
try:
for child in users.iterdir():
if child.is_dir():
profiles.append(child)
except OSError:
pass
public = users / "Public"
if public.is_dir():
profiles.append(public)
else:
home = Path.home()
profiles.append(home)
if Path("/home").is_dir():
try:
for child in Path("/home").iterdir():
if child.is_dir():
profiles.append(child)
except OSError:
pass
home = Path.home()
profiles.append(home)
seen: set[str] = set()
result: list[Path] = []
for profile in profiles:
try:
resolved = profile.expanduser().resolve()
except OSError:
continue
key = os.path.normcase(str(resolved))
if key in seen or not resolved.is_dir():
continue
seen.add(key)
result.append(resolved)
return result
def browser_download_roots() -> list[Path]:
"""Default Downloads folders for every local profile we can read."""
roots: list[Path] = []
for profile in windows_profile_dirs():
downloads = profile / "Downloads"
if downloads.is_dir():
roots.append(downloads)
return roots
def fixed_drive_roots() -> list[Path]:
"""Roots of fixed local disks (Windows), or the filesystem root elsewhere."""
if not is_windows():
return [Path("/")] if Path("/").is_dir() else []
roots: list[Path] = []
try:
kernel32 = ctypes.windll.kernel32
mask = kernel32.GetLogicalDrives()
for index in range(26):
if not (mask & (1 << index)):
continue
root = f"{chr(ord('A') + index)}:\\"
# DRIVE_FIXED == 3; network/removable drives are skipped.
if kernel32.GetDriveTypeW(ctypes.c_wchar_p(root)) == 3:
path = Path(root)
if path.is_dir():
roots.append(path)
except Exception:
system_drive = os.environ.get("SystemDrive", "C:")
path = Path(system_drive + "\\")
if path.is_dir():
roots.append(path)
return roots
def is_under(path: Path, parent: Path) -> bool:
try:
path = path.resolve()
parent = parent.resolve()
except OSError:
return False
return path == parent or parent in path.parents
+240
View File
@@ -0,0 +1,240 @@
"""Quarantine: move a detected file somewhere inert, record it, restore or delete it.
A quarantine file is renamed with a ``.quarantine`` suffix (so Windows has
no executable association), made read-only where possible, and paired with
a JSON sidecar. The SQLite row is the source of truth for the UI; the
sidecar survives a database rebuild.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shutil
import stat
import threading
import time
from pathlib import Path
from . import paths
from .models import QuarantineItem, now_iso
from .storage import Storage, StorageError
logger = logging.getLogger(__name__)
class QuarantineError(Exception):
pass
def hash_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
while True:
chunk = handle.read(chunk_size)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def delete_file(path: Path) -> None:
"""Delete one file, clearing the Windows read-only bit if necessary.
Directories are refused: a detection names a file, and recursively
deleting a folder on the strength of one signature would be far more
destructive than the finding warrants.
"""
path = Path(path)
if path.is_symlink():
path.unlink()
return
if path.is_dir():
raise QuarantineError("Refusing to delete a directory.")
try:
path.unlink()
except PermissionError:
try:
os.chmod(path, stat.S_IWRITE | stat.S_IREAD)
path.unlink()
except OSError as exc:
raise QuarantineError(f"Could not delete {path.name}: {exc}") from exc
except FileNotFoundError:
return
except OSError as exc:
raise QuarantineError(f"Could not delete {path.name}: {exc}") from exc
def _hide_on_windows(path: Path) -> None:
if os.name != "nt":
try:
os.chmod(path, 0o600)
except OSError:
pass
return
try:
import ctypes
hidden = 0x2
attributes = ctypes.windll.kernel32.GetFileAttributesW(str(path))
if attributes != -1:
ctypes.windll.kernel32.SetFileAttributesW(str(path), attributes | hidden)
except Exception:
pass
class QuarantineService:
def __init__(self, storage: Storage, root: Path | None = None) -> None:
self.storage = storage
self.root = Path(root) if root else paths.quarantine_dir()
self.root.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
def _metadata_path(self, item_id: str) -> Path:
return self.root / f"{item_id}.json"
def _write_metadata(self, item: QuarantineItem, reason: str = "") -> None:
payload = {
"id": item.id,
"original_path": item.original_path,
"quarantine_path": item.quarantine_path,
"signature": item.signature,
"sha256": item.sha256,
"size": item.size,
"created_at": item.created_at,
"status": item.status,
"scan_id": item.scan_id,
"reason": reason,
}
temp = self._metadata_path(item.id).with_suffix(".json.tmp")
temp.write_text(json.dumps(payload, indent=1), encoding="utf-8")
os.replace(temp, self._metadata_path(item.id))
def _update_metadata_status(self, item: QuarantineItem) -> None:
metadata = self._metadata_path(item.id)
try:
payload = json.loads(metadata.read_text(encoding="utf-8"))
except (OSError, ValueError):
payload = {}
if not isinstance(payload, dict):
payload = {}
payload.update({
"id": item.id, "original_path": item.original_path,
"quarantine_path": item.quarantine_path, "signature": item.signature,
"sha256": item.sha256, "size": item.size, "created_at": item.created_at,
"status": item.status, "scan_id": item.scan_id,
})
try:
metadata.write_text(json.dumps(payload, indent=1), encoding="utf-8")
except OSError:
logger.warning("Could not update quarantine metadata for %s", item.id)
def quarantine_file(self, path: Path, signature: str, *,
scan_id: int | None = None, reason: str = "") -> QuarantineItem:
path = Path(path)
if path.is_symlink():
raise QuarantineError("Refusing to quarantine a symbolic link.")
if not path.is_file():
raise QuarantineError("The detected file no longer exists.")
try:
size = path.stat().st_size
digest = hash_file(path)
except OSError as exc:
raise QuarantineError(f"Could not read the detected file: {exc}") from exc
item_id = f"Q-{time.strftime('%Y%m%dT%H%M%S')}-{digest[:10]}"
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", path.name)[:80] or "detected"
destination = self.root / f"{item_id}-{safe_name}.quarantine"
with self._lock:
if destination.exists():
raise QuarantineError("A quarantine file with that name already exists.")
try:
shutil.move(str(path), str(destination))
except OSError as exc:
raise QuarantineError(f"Could not move the file into quarantine: {exc}") from exc
_hide_on_windows(destination)
item = QuarantineItem(
id=item_id,
original_path=str(path),
quarantine_path=str(destination),
signature=signature or "Unknown",
sha256=digest,
size=size,
created_at=now_iso(),
status="quarantined",
scan_id=scan_id,
)
try:
self.storage.add_quarantine(item)
except StorageError:
# The database is what the UI reads. If it cannot record the
# move, put the file back rather than leaving an untracked
# quarantined item.
try:
shutil.move(str(destination), str(path))
except OSError:
logger.exception("Quarantine rollback failed; file remains at %s", destination)
raise QuarantineError(
"The file was moved but could not be recorded; it was restored.")
try:
self._write_metadata(item, reason=reason)
except OSError:
# The SQLite row is authoritative; a missing sidecar is an
# inconvenience, not a reason to undo an otherwise successful
# quarantine.
logger.warning("Quarantine metadata could not be written for %s",
item.id, exc_info=True)
logger.info("Quarantined %s (%s)", path, item_id)
return item
def restore(self, item_id: str, destination: Path | None = None) -> Path:
item = self.storage.get_quarantine(item_id)
if item is None or item.status != "quarantined":
raise QuarantineError("That quarantined file is no longer available.")
source = Path(item.quarantine_path)
if not source.is_file():
raise QuarantineError("The quarantined file is missing from disk.")
target = Path(destination) if destination else Path(item.original_path)
with self._lock:
if target.exists():
raise QuarantineError(f"A file already exists at {target}.")
try:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(target))
except OSError as exc:
raise QuarantineError(f"Could not restore the file: {exc}") from exc
item.status = "restored"
self.storage.set_quarantine_status(item.id, "restored")
self._update_metadata_status(item)
logger.info("Restored %s to %s", item.id, target)
return target
def delete(self, item_id: str) -> None:
item = self.storage.get_quarantine(item_id)
if item is None:
raise QuarantineError("That quarantined file was not found.")
source = Path(item.quarantine_path)
with self._lock:
try:
delete_file(source)
except QuarantineError:
raise
item.status = "deleted"
self.storage.set_quarantine_status(item.id, "deleted")
self._update_metadata_status(item)
logger.info("Deleted quarantined item %s", item.id)
def delete_original(self, path: Path) -> None:
delete_file(Path(path))
def delete_missing_or_file(self, path: Path) -> None:
try:
delete_file(Path(path))
except QuarantineError:
raise
def open_folder(self) -> bool:
return paths.open_path(self.root)
+580
View File
@@ -0,0 +1,580 @@
"""Browser-download watcher and the real-time scan-before-open gate.
Watches the default Downloads folder of every readable Windows profile,
plus any custom download directory recorded by Chrome, Edge, Brave, Vivaldi,
Opera or Firefox. A completed download is copied while an exclusive handle
is held (where Windows allows it), scanned with ClamAV, and quarantined or
deleted before the handle is released.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
import uuid
from pathlib import Path
from typing import Iterable
from PyQt6.QtCore import QObject, QRunnable, QThread, QThreadPool, pyqtSignal
from . import paths
from .clamav import (
ClamAVService,
hidden_kwargs,
parse_clamscan_line,
parse_extra_options,
)
from .config import AppSettings
from .models import RealtimeEvent, now_iso
from .quarantine import QuarantineError, QuarantineService, delete_file
from .storage import Storage
from .winlock import LockError, copy_for_scan
logger = logging.getLogger(__name__)
_TEMP_SUFFIXES = (
".crdownload", ".part", ".partial", ".download", ".opdownload", ".tmp",
".temp", ".filepart", ".aria2", ".td", ".!qb", ".!ut", ".partial~",
)
_JS_ESCAPE = re.compile(r"\\(.)")
_FIREFOX_DIR_RE = re.compile(
r'user_pref\("browser\.download\.dir",\s*"((?:\\.|[^"\\])*)"\)\s*;')
def _js_unescape(value: str) -> str:
def replace(match: re.Match) -> str:
char = match.group(1)
return {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}.get(char, char)
return _JS_ESCAPE.sub(replace, value)
def _existing_dir(value: str | Path | None) -> Path | None:
if not value:
return None
text = os.path.expandvars(os.path.expanduser(str(value).strip().strip('"')))
if not text:
return None
try:
path = Path(text)
if path.is_dir() and os.access(path, os.R_OK | os.X_OK):
return path
except OSError:
return None
return None
def _chromium_preference_files() -> list[Path]:
files: list[Path] = []
local = os.environ.get("LOCALAPPDATA")
roaming = os.environ.get("APPDATA")
bases: list[Path] = []
if local:
bases.extend([
Path(local) / "Google" / "Chrome" / "User Data",
Path(local) / "Microsoft" / "Edge" / "User Data",
Path(local) / "BraveSoftware" / "Brave-Browser" / "User Data",
Path(local) / "Vivaldi" / "User Data",
Path(local) / "Chromium" / "User Data",
])
if roaming:
bases.append(Path(roaming) / "Opera Software" / "Opera Stable")
if os.name != "nt":
home = Path.home()
bases.extend([
home / ".config" / "google-chrome",
home / ".config" / "chromium",
home / ".config" / "microsoft-edge",
home / ".config" / "BraveSoftware" / "Brave-Browser",
])
for base in bases:
try:
direct = base / "Preferences"
if direct.is_file():
files.append(direct)
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and (child.name == "Default"
or child.name.startswith("Profile ")
or child.name == "Guest Profile"):
pref = child / "Preferences"
if pref.is_file():
files.append(pref)
except OSError:
continue
return files
def _chromium_download_dirs() -> list[Path]:
roots: list[Path] = []
home = str(Path.home())
for pref in _chromium_preference_files():
try:
data = json.loads(pref.read_text(encoding="utf-8", errors="replace"))
download = data.get("download") if isinstance(data, dict) else None
value = download.get("default_directory") if isinstance(download, dict) else None
if not value:
continue
text = str(value).replace("${user_home}", home)
resolved = _existing_dir(text)
if resolved:
roots.append(resolved)
except (OSError, ValueError):
continue
return roots
def _firefox_download_dirs() -> list[Path]:
roots: list[Path] = []
bases: list[Path] = []
roaming = os.environ.get("APPDATA")
if roaming:
bases.append(Path(roaming) / "Mozilla" / "Firefox" / "Profiles")
bases.append(Path.home() / ".mozilla" / "firefox")
for base in bases:
try:
if not base.is_dir():
continue
for profile in base.iterdir():
prefs = profile / "prefs.js"
if not profile.is_dir() or not prefs.is_file():
continue
text = prefs.read_text(encoding="utf-8", errors="replace")
for match in _FIREFOX_DIR_RE.finditer(text):
resolved = _existing_dir(_js_unescape(match.group(1)))
if resolved:
roots.append(resolved)
except OSError:
continue
return roots
def browser_download_roots(extra: Iterable[str] = ()) -> list[Path]:
"""Every download directory we can justify watching, deduplicated."""
roots: list[Path] = []
roots.extend(paths.browser_download_roots())
roots.extend(_chromium_download_dirs())
roots.extend(_firefox_download_dirs())
for entry in extra:
resolved = _existing_dir(entry)
if resolved:
roots.append(resolved)
result: list[Path] = []
seen: set[str] = set()
for root in roots:
try:
key = os.path.normcase(str(root.resolve()))
except OSError:
continue
if key in seen:
continue
seen.add(key)
result.append(root)
return result[:50]
def _excluded_roots() -> list[str]:
excluded = [paths.quarantine_dir(), paths.logs_dir(), paths.updates_dir(),
paths.diagnostics_dir(), paths.temp_dir(), paths.clamav_db_dir()]
result: list[str] = []
for root in excluded:
try:
result.append(os.path.normcase(str(root.resolve())) + os.sep)
except OSError:
continue
return result
def _is_temp_name(name: str) -> bool:
lowered = name.lower()
return lowered.endswith(_TEMP_SUFFIXES) or lowered.endswith("~")
def _iter_download_files(root: Path) -> Iterable[Path]:
"""Files directly in a download root and one level below it."""
try:
entries = list(os.scandir(root))
except OSError:
return
for entry in entries:
try:
if entry.name.startswith(".") or entry.name.startswith("$"):
continue
if entry.is_file(follow_symlinks=False):
yield Path(entry.path)
elif entry.is_dir(follow_symlinks=False) and entry.name.lower() not in (
"node_modules", "system volume information", "recycle.bin"):
try:
for child in os.scandir(entry.path):
if child.is_file(follow_symlinks=False) and not child.name.startswith("."):
yield Path(child.path)
except OSError:
continue
except OSError:
continue
def snapshot(roots: Iterable[Path]) -> dict[str, tuple[int, int, str]]:
excluded = _excluded_roots()
result: dict[str, tuple[int, int, str]] = {}
for root in roots:
for path in _iter_download_files(root):
if _is_temp_name(path.name):
continue
try:
key = os.path.normcase(str(path.resolve()))
except OSError:
continue
if any(key.startswith(prefix) for prefix in excluded):
continue
try:
stat = path.stat()
except OSError:
continue
if stat.st_size < 0:
continue
result[key] = (int(stat.st_size), int(stat.st_mtime_ns), str(path))
return result
class DownloadWatcher(QThread):
file_ready = pyqtSignal(str)
watch_status = pyqtSignal(str)
roots_changed = pyqtSignal(list)
def __init__(self, extra_dirs: Iterable[str] = (), interval: float = 1.5,
parent=None) -> None:
super().__init__(parent)
self.extra_dirs = list(extra_dirs)
self.interval = max(0.8, float(interval))
self._stop = None
def stop(self) -> None:
if self._stop is not None:
self._stop.set()
def run(self) -> None:
import threading
stop = threading.Event()
self._stop = stop
roots = browser_download_roots(self.extra_dirs)
self.roots_changed.emit([str(root) for root in roots])
initial = snapshot(roots)
known = set(initial)
candidates: dict[str, dict] = {}
last_refresh = time.monotonic()
warned_empty = False
logger.info("Real-time watcher started on %s folder(s)", len(roots))
while not stop.is_set():
now = time.monotonic()
if now - last_refresh >= 30:
refreshed = browser_download_roots(self.extra_dirs)
if {str(p) for p in refreshed} != {str(p) for p in roots}:
roots = refreshed
for path in snapshot(roots):
if path not in known and path not in candidates:
known.add(path)
self.roots_changed.emit([str(root) for root in roots])
last_refresh = now
current = snapshot(roots)
for path, (size, mtime, actual_path) in current.items():
signature = (size, mtime)
if path in known:
continue
candidate = candidates.get(path)
if candidate is None:
candidates[path] = {"signature": signature, "stable": 0}
elif candidate["signature"] == signature:
candidate["stable"] += 1
if candidate["stable"] >= 1:
candidates.pop(path, None)
known.add(path)
self.file_ready.emit(actual_path)
else:
candidate["signature"] = signature
candidate["stable"] = 0
for path in list(candidates):
if path not in current:
candidates.pop(path, None)
for path in list(known):
if path not in current:
known.discard(path)
if not roots and not warned_empty:
warned_empty = True
self.watch_status.emit("No browser download folders found")
elif roots:
self.watch_status.emit(f"Watching {len(roots)} download folder(s)")
stop.wait(self.interval)
logger.info("Real-time watcher stopped")
class RealtimeScanSignals(QObject):
finished = pyqtSignal(str, object)
alert = pyqtSignal(str, str, str)
class RealtimeScanTask(QRunnable):
def __init__(self, path: Path, settings: AppSettings, clamav: ClamAVService,
storage: Storage, quarantine: QuarantineService) -> None:
super().__init__()
self.path = Path(path)
self.settings = settings
self.clamav = clamav
self.storage = storage
self.quarantine = quarantine
self.signals = RealtimeScanSignals()
self.setAutoDelete(True)
def run(self) -> None:
temp_root = paths.temp_dir() / "realtime" / uuid.uuid4().hex
temp_root.mkdir(parents=True, exist_ok=True)
sample = temp_root / "download-sample.bin"
locker = None
exclusive = False
event: RealtimeEvent | None = None
try:
locker, exclusive = copy_for_scan(self.path, sample)
detections, scan_error = self._scan(sample)
detail = "Locked while scanning" if exclusive else "File was in use; scanned a copy"
if detections:
signature = detections[0]
action = self.settings.auto_action
if action == "delete":
if locker is not None and locker.mark_delete_on_close():
action = "deleted"
detail += "; deleted on close"
else:
if locker is not None:
locker.close()
locker = None
try:
delete_file(self.path)
action = "deleted"
except QuarantineError as exc:
action = "failed"
detail += f"; delete failed: {exc}"
elif action == "quarantine":
if locker is not None:
locker.close()
locker = None
try:
item = self.quarantine.quarantine_file(
self.path, signature, reason="Browser download")
action = "quarantined"
detail += f"; quarantine {item.id}"
except QuarantineError as exc:
action = "failed"
detail += f"; quarantine failed: {exc}"
else:
action = "detected"
detail += "; left in place (notify only)"
event = RealtimeEvent(
created_at=now_iso(), path=str(self.path), signature=signature,
action=action, detail=detail)
self.storage.add_realtime_event(event)
title = {
"quarantined": "Download quarantined",
"deleted": "Download deleted",
"detected": "Malware detected in a download",
"failed": "Detection needs your attention",
}.get(action, "Malware detected")
self.signals.alert.emit(
title, f"{self.path.name}\n{signature}\n{detail}", action)
elif scan_error:
event = RealtimeEvent(
created_at=now_iso(), path=str(self.path), signature="",
action="error", detail=f"{scan_error}; {detail}")
self.storage.add_realtime_event(event)
self.signals.alert.emit(
"Could not scan a download",
f"{self.path.name}\n{scan_error}", "error")
except (LockError, OSError) as exc:
logger.warning("Real-time scan could not read %s: %s", self.path, exc)
event = RealtimeEvent(
created_at=now_iso(), path=str(self.path), signature="",
action="error", detail=f"Could not read the download: {exc}")
try:
self.storage.add_realtime_event(event)
except Exception:
pass
self.signals.alert.emit("Could not scan a download",
f"{self.path.name}\n{exc}", "error")
except Exception as exc:
logger.exception("Real-time scan failed for %s", self.path)
event = RealtimeEvent(
created_at=now_iso(), path=str(self.path), signature="",
action="error", detail=f"Real-time scan failed: {exc}")
try:
self.storage.add_realtime_event(event)
except Exception:
pass
self.signals.alert.emit("Real-time protection error",
f"{self.path.name}\n{exc}", "error")
finally:
if locker is not None:
try:
locker.close()
except Exception:
pass
shutil.rmtree(temp_root, ignore_errors=True)
self.signals.finished.emit(str(self.path), event)
def _scan(self, sample: Path) -> tuple[list[str], str]:
info = self.clamav.info()
if not info.available:
return [], info.error or "ClamAV is not available"
try:
extra = parse_extra_options(self.settings.extra_options)
except ValueError:
extra = []
try:
args = self.clamav.scan_arguments([sample], extra, exclude_temp=False)
except Exception as exc:
return [], str(exc)
detections: list[str] = []
skipped = ""
try:
process = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1,
cwd=str(info.path.parent) if info.path else None,
env={**os.environ, "LC_ALL": "C", "LANG": "C"}, **hidden_kwargs(),
)
assert process.stdout is not None
deadline = time.monotonic() + 300
while True:
if time.monotonic() > deadline:
process.kill()
return detections, "Scan timed out"
line = process.stdout.readline()
if not line:
if process.poll() is not None:
break
continue
parsed = parse_clamscan_line(line)
if not parsed:
continue
if parsed.get("kind") == "detection":
detections.append(str(parsed.get("signature") or "Unknown"))
elif parsed.get("kind") == "skipped":
skipped = str(parsed.get("reason") or "skipped")
code = process.wait(timeout=20)
if code not in (0, 1) and not detections:
return detections, skipped or f"clamscan exited with code {code}"
if skipped and not detections:
return detections, skipped
return detections, ""
except (OSError, subprocess.SubprocessError) as exc:
return detections, str(exc)
class RealtimeProtection(QObject):
event_created = pyqtSignal(object)
status_changed = pyqtSignal(str)
alert = pyqtSignal(str, str, str)
def __init__(self, clamav: ClamAVService, storage: Storage,
quarantine: QuarantineService, settings: AppSettings,
parent=None) -> None:
super().__init__(parent)
self.clamav = clamav
self.storage = storage
self.quarantine = quarantine
self.settings = settings
self._watcher: DownloadWatcher | None = None
self._pool = QThreadPool(self)
self._pool.setMaxThreadCount(2)
self._inflight: set[str] = set()
self._tasks: dict[str, RealtimeScanTask] = {}
self._roots: list[str] = []
self._warned_missing = False
@property
def running(self) -> bool:
return bool(self._watcher is not None and self._watcher.isRunning())
@property
def watched_roots(self) -> list[str]:
return list(self._roots)
def set_settings(self, settings: AppSettings) -> None:
self.settings = settings
def start(self) -> None:
self.stop()
self._warned_missing = False
if not self.settings.realtime_enabled:
self.status_changed.emit("Real-time protection is off")
return
if not self.clamav.info().available:
self.status_changed.emit("Real-time protection needs ClamAV")
return
watcher = DownloadWatcher(self.settings.extra_watch_dirs)
watcher.file_ready.connect(self._on_file)
watcher.watch_status.connect(self.status_changed)
watcher.roots_changed.connect(self._on_roots)
self._watcher = watcher
watcher.start()
self.status_changed.emit("Starting real-time protection…")
def stop(self) -> None:
watcher = self._watcher
self._watcher = None
if watcher is not None:
watcher.stop()
watcher.wait(4000)
self._pool.waitForDone(5000)
def _on_roots(self, roots: list) -> None:
self._roots = [str(root) for root in roots]
def _on_file(self, path: str) -> None:
if not self.settings.realtime_enabled or not path:
return
if path in self._inflight:
return
try:
size = Path(path).stat().st_size
except OSError:
return
max_bytes = int(os.environ.get("BOLTCLAM_REALTIME_MAX_MB", "1024")) * 1024 * 1024
if size > max_bytes:
event = RealtimeEvent(
created_at=now_iso(), path=path, signature="",
action="skipped",
detail=f"File is larger than the {max_bytes // (1024 * 1024)} MB real-time limit")
try:
self.storage.add_realtime_event(event)
except Exception:
pass
self.event_created.emit(event)
self.alert.emit("Large download not scanned",
f"{Path(path).name}\nIt exceeds the real-time scan size limit.",
"warning")
return
if not self.clamav.info().available:
if not self._warned_missing:
self._warned_missing = True
self.alert.emit("Real-time protection is not scanning",
"ClamAV is not available. Set its install folder in Settings.",
"error")
return
self._inflight.add(path)
task = RealtimeScanTask(Path(path), self.settings, self.clamav,
self.storage, self.quarantine)
self._tasks[path] = task
task.signals.finished.connect(self._on_finished)
task.signals.alert.connect(self.alert)
self._pool.start(task)
def _on_finished(self, path: str, event) -> None:
self._inflight.discard(path)
self._tasks.pop(path, None)
if event is not None:
self.event_created.emit(event)
+347
View File
@@ -0,0 +1,347 @@
"""Background scan and definition-update workers.
The scan worker owns no UI objects. It streams clamscan output, records
every detection before attempting remediation, and always finishes the
database row even when the process fails or the user cancels.
"""
from __future__ import annotations
import logging
import os
import subprocess
import time
from pathlib import Path
from PyQt6.QtCore import QThread, pyqtSignal
from . import paths
from .clamav import (
ClamAVError,
ClamAVService,
build_freshclam_args,
hidden_kwargs,
parse_clamscan_line,
parse_extra_options,
scan_targets,
)
from .config import AppSettings
from .models import Detection, ScanRecord
from .quarantine import QuarantineError, QuarantineService
from .storage import Storage, StorageError
logger = logging.getLogger(__name__)
class ScanWorker(QThread):
started_scan = pyqtSignal(int, list)
progress = pyqtSignal(str, int)
detection_found = pyqtSignal(object)
log_line = pyqtSignal(str)
finished_scan = pyqtSignal(object)
failed = pyqtSignal(str)
status = pyqtSignal(str)
def __init__(self, clamav: ClamAVService, storage: Storage,
quarantine: QuarantineService, settings: AppSettings,
scope: str, parent=None) -> None:
super().__init__(parent)
self.clamav = clamav
self.storage = storage
self.quarantine = quarantine
self.settings = settings
self.scope = "full" if scope == "full" else "homes"
self._cancel = False
self._process: subprocess.Popen | None = None
self._last_progress = 0.0
def cancel(self) -> None:
self._cancel = True
process = self._process
if process is not None and process.poll() is None:
try:
process.terminate()
except OSError:
pass
def run(self) -> None: # noqa: C901 - one linear process lifecycle
info = self.clamav.info(force=True)
if not info.available or info.path is None:
self.failed.emit(info.error or "ClamAV is not configured.")
return
targets = scan_targets(self.scope)
if not targets:
self.failed.emit("No scan targets were found for that scope.")
return
try:
extra = parse_extra_options(self.settings.extra_options)
except ValueError as exc:
self.failed.emit(str(exc))
return
try:
args = self.clamav.scan_arguments(targets, extra)
except ClamAVError as exc:
self.failed.emit(str(exc))
return
scan_id = 0
started = time.monotonic()
summary: dict = {}
detections: list[Detection] = []
error_count = 0
cancelled = False
returncode: int | None = None
last_lines: list[str] = []
try:
scan_id = self.storage.create_scan(
self.scope, [str(target) for target in targets],
engine_version=info.engine_version, db_version=info.db_version)
self.started_scan.emit(scan_id, [str(target) for target in targets])
self.status.emit("Scanning")
logger.info("SCAN %s started (%s): %s", scan_id, self.scope,
", ".join(str(target) for target in targets))
self._process = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1,
cwd=str(info.path.parent), env={**os.environ, "LC_ALL": "C", "LANG": "C"},
**hidden_kwargs(),
)
assert self._process.stdout is not None
while True:
if self._cancel:
cancelled = True
try:
self._process.terminate()
except OSError:
pass
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
break
line = self._process.stdout.readline()
if not line:
if self._process.poll() is not None:
break
continue
last_lines.append(line.rstrip())
if len(last_lines) > 80:
last_lines.pop(0)
event = parse_clamscan_line(line)
if not event:
continue
kind = event.get("kind")
if kind == "detection":
detection = Detection(path=str(event["path"]),
signature=str(event["signature"]))
try:
detection.size = Path(detection.path).stat().st_size
except OSError:
detection.size = 0
detection.id = self.storage.add_detection(scan_id, detection)
self._remediate(detection, scan_id)
detections.append(detection)
self.detection_found.emit(detection)
self.progress.emit(detection.path, len(detections))
elif kind == "summary":
key = str(event.get("key") or "")
value = event.get("value")
summary[key] = value
if key in ("scanned_files", "infected_files", "engine_version"):
self.status.emit(self._summary_status(summary, len(detections)))
elif kind in ("error", "skipped"):
if kind == "error":
error_count += 1
message = str(event.get("message") or event.get("reason") or "")
if message:
self.log_line.emit(message)
elif kind == "progress":
now = time.monotonic()
if now - self._last_progress >= 0.12:
self._last_progress = now
self.progress.emit(str(event.get("path") or ""), len(detections))
elif kind == "log":
message = str(event.get("message") or "")
if message and ("error" in message.lower() or "warning" in message.lower()):
self.log_line.emit(message)
returncode = self._process.wait(timeout=20)
if cancelled:
status = "cancelled"
elif returncode in (0, 1):
status = "completed"
elif returncode == 2 and summary.get("scanned_files"):
status = "completed_with_errors"
else:
status = "failed"
scanned_files = _int_value(summary.get("scanned_files"))
infected_files = _int_value(summary.get("infected_files"))
if infected_files == 0 and detections:
infected_files = len(detections)
duration = time.monotonic() - started
if summary.get("engine_version"):
engine = str(summary["engine_version"]).split()[0]
self.storage.set_scan_engine(scan_id, engine, info.db_version)
self.storage.finish_scan(
scan_id, status=status, scanned_files=scanned_files,
infected_files=infected_files, errors=error_count,
duration_seconds=duration, cancelled=cancelled,
)
record = self.storage.get_scan(scan_id) or ScanRecord(
id=scan_id, started_at="", finished_at=None, scope=self.scope,
status=status, paths=[str(target) for target in targets],
scanned_files=scanned_files, infected_files=infected_files,
errors=error_count, duration_seconds=duration,
cancelled=cancelled, detections=detections,
)
if not record.detections:
record.detections = detections
logger.info("SCAN %s finished: %s, %s files, %s detections, %s errors",
scan_id, status, scanned_files, infected_files, error_count)
self.status.emit("Cancelled" if cancelled else "Finished")
self.finished_scan.emit(record)
except Exception as exc:
logger.exception("Scan %s failed", scan_id)
try:
if self._process is not None and self._process.poll() is None:
self._process.kill()
except Exception:
pass
if scan_id:
try:
self.storage.finish_scan(
scan_id, status="failed", scanned_files=_int_value(summary.get("scanned_files")),
infected_files=max(_int_value(summary.get("infected_files")), len(detections)),
errors=error_count + 1, duration_seconds=time.monotonic() - started,
cancelled=cancelled,
)
except StorageError:
logger.exception("Could not mark scan %s failed", scan_id)
detail = str(exc)
if last_lines:
detail += "\n" + "\n".join(last_lines[-5:])
self.failed.emit(detail)
finally:
self._process = None
def _remediate(self, detection: Detection, scan_id: int) -> None:
action = self.settings.auto_action
if action == "quarantine":
try:
item = self.quarantine.quarantine_file(
Path(detection.path), detection.signature,
scan_id=scan_id, reason="Full scan")
detection.action = "quarantined"
detection.quarantine_id = item.id
except (QuarantineError, OSError) as exc:
detection.action = "failed"
logger.warning("Quarantine failed for %s: %s", detection.path, exc)
self.log_line.emit(f"Quarantine failed: {exc}")
elif action == "delete":
try:
self.quarantine.delete_original(Path(detection.path))
detection.action = "deleted"
except (QuarantineError, OSError) as exc:
detection.action = "failed"
logger.warning("Delete failed for %s: %s", detection.path, exc)
self.log_line.emit(f"Delete failed: {exc}")
else:
detection.action = "detected"
if detection.id:
try:
self.storage.update_detection_action(
detection.id, detection.action, detection.quarantine_id)
except StorageError:
logger.exception("Could not record remediation action")
@staticmethod
def _summary_status(summary: dict, detection_count: int) -> str:
scanned = _int_value(summary.get("scanned_files"))
infected = max(_int_value(summary.get("infected_files")), detection_count)
if scanned:
return f"Scanned {scanned:,} files · {infected} detection(s)"
return "Scanning"
def _int_value(value) -> int:
try:
return int(str(value).split()[0])
except (TypeError, ValueError, IndexError):
return 0
class DefinitionWorker(QThread):
log_line = pyqtSignal(str)
finished_ok = pyqtSignal(str)
failed = pyqtSignal(str)
def __init__(self, clamav: ClamAVService, parent=None) -> None:
super().__init__(parent)
self.clamav = clamav
self._cancel = False
self._process: subprocess.Popen | None = None
def cancel(self) -> None:
self._cancel = True
if self._process is not None and self._process.poll() is None:
try:
self._process.terminate()
except OSError:
pass
def run(self) -> None:
info = self.clamav.info(force=True)
if not info.available:
self.failed.emit(info.error or "Install ClamAV before updating definitions.")
return
freshclam, error = self.clamav.freshclam(), ""
if freshclam is None:
from .clamav import find_freshclam
freshclam, error = find_freshclam(info.path)
if freshclam is None:
self.failed.emit(error or "freshclam was not found.")
return
db_dir = paths.clamav_db_dir()
db_dir.mkdir(parents=True, exist_ok=True)
args = build_freshclam_args(freshclam, db_dir)
last_lines: list[str] = []
try:
self._process = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1,
cwd=str(freshclam.parent), env={**os.environ, "LC_ALL": "C", "LANG": "C"},
**hidden_kwargs(),
)
assert self._process.stdout is not None
while True:
if self._cancel:
self._process.terminate()
self.failed.emit("Definition update was cancelled.")
return
line = self._process.stdout.readline()
if not line:
if self._process.poll() is not None:
break
continue
text = line.rstrip()
last_lines.append(text)
if len(last_lines) > 40:
last_lines.pop(0)
if text:
self.log_line.emit(text)
code = self._process.wait(timeout=20)
self.clamav.invalidate()
if code == 0:
info = self.clamav.info(force=True)
self.finished_ok.emit(f"Definitions updated. {info.summary}")
else:
self.failed.emit(
"freshclam exited with code "
f"{code}." + ("\n" + "\n".join(last_lines[-5:]) if last_lines else ""))
except Exception as exc:
logger.exception("Definition update failed")
self.failed.emit(str(exc))
finally:
self._process = None
+375
View File
@@ -0,0 +1,375 @@
"""Local SQLite history for scans, detections, quarantine and real-time events.
One connection per operation keeps this safe to call from scan threads, the
real-time worker and the Qt UI without sharing a sqlite object across
threads. WAL mode lets the UI read while a scan is writing.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterator
from . import paths
from .models import (
Detection,
QuarantineItem,
RealtimeEvent,
ScanRecord,
now_iso,
)
logger = logging.getLogger(__name__)
class StorageError(Exception):
pass
_SCHEMA = """
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
scope TEXT NOT NULL,
status TEXT NOT NULL,
paths TEXT NOT NULL DEFAULT '[]',
scanned_files INTEGER NOT NULL DEFAULT 0,
infected_files INTEGER NOT NULL DEFAULT 0,
errors INTEGER NOT NULL DEFAULT 0,
engine_version TEXT NOT NULL DEFAULT '',
db_version TEXT NOT NULL DEFAULT '',
duration_seconds REAL NOT NULL DEFAULT 0,
cancelled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS detections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
path TEXT NOT NULL,
signature TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'detected',
quarantine_id TEXT,
size INTEGER NOT NULL DEFAULT 0,
detected_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_detections_scan ON detections(scan_id);
CREATE INDEX IF NOT EXISTS idx_detections_action ON detections(action);
CREATE TABLE IF NOT EXISTS quarantine (
id TEXT PRIMARY KEY,
original_path TEXT NOT NULL,
quarantine_path TEXT NOT NULL,
signature TEXT NOT NULL,
sha256 TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'quarantined',
scan_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_quarantine_status ON quarantine(status);
CREATE INDEX IF NOT EXISTS idx_quarantine_sha ON quarantine(sha256);
CREATE TABLE IF NOT EXISTS realtime_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_realtime_created ON realtime_events(created_at);
"""
class Storage:
def __init__(self, path: Path | None = None) -> None:
self.path = Path(path) if path else paths.db_path()
self.path.parent.mkdir(parents=True, exist_ok=True)
self.initialize()
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
try:
connection = sqlite3.connect(str(self.path), timeout=30)
except sqlite3.Error as exc:
raise StorageError(f"Could not open the BoltClam database: {exc}") from exc
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA busy_timeout=30000")
connection.execute("PRAGMA foreign_keys=ON")
yield connection
connection.commit()
except sqlite3.Error as exc:
connection.rollback()
raise StorageError(f"BoltClam database error: {exc}") from exc
finally:
connection.close()
def initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.executescript(_SCHEMA)
# -- scans -------------------------------------------------------------
def create_scan(self, scope: str, paths_list: list[str], *,
engine_version: str = "", db_version: str = "") -> int:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO scans (started_at, scope, status, paths, engine_version, db_version) "
"VALUES (?, ?, 'running', ?, ?, ?)",
(now_iso(), scope, json.dumps(paths_list, ensure_ascii=False),
engine_version, db_version),
)
return int(cursor.lastrowid)
def finish_scan(self, scan_id: int, *, status: str, scanned_files: int,
infected_files: int, errors: int, duration_seconds: float,
cancelled: bool) -> None:
with self._connect() as connection:
connection.execute(
"UPDATE scans SET finished_at=?, status=?, scanned_files=?, infected_files=?, "
"errors=?, duration_seconds=?, cancelled=? WHERE id=?",
(now_iso(), status, int(scanned_files), int(infected_files), int(errors),
float(duration_seconds), 1 if cancelled else 0, int(scan_id)),
)
def set_scan_engine(self, scan_id: int, engine_version: str, db_version: str) -> None:
with self._connect() as connection:
connection.execute(
"UPDATE scans SET engine_version=?, db_version=? WHERE id=?",
(engine_version, db_version, int(scan_id)),
)
def _scan_from_row(self, row: sqlite3.Row) -> ScanRecord:
try:
paths_list = json.loads(row["paths"] or "[]")
except (TypeError, ValueError):
paths_list = []
return ScanRecord(
id=int(row["id"]),
started_at=str(row["started_at"]),
finished_at=row["finished_at"],
scope=str(row["scope"]),
status=str(row["status"]),
paths=[str(path) for path in paths_list if isinstance(path, str)],
scanned_files=int(row["scanned_files"] or 0),
infected_files=int(row["infected_files"] or 0),
errors=int(row["errors"] or 0),
engine_version=str(row["engine_version"] or ""),
db_version=str(row["db_version"] or ""),
duration_seconds=float(row["duration_seconds"] or 0),
cancelled=bool(row["cancelled"]),
)
def list_scans(self, limit: int = 100) -> list[ScanRecord]:
with self._connect() as connection:
rows = connection.execute(
"SELECT * FROM scans ORDER BY id DESC LIMIT ?", (max(1, int(limit)),)
).fetchall()
return [self._scan_from_row(row) for row in rows]
def get_scan(self, scan_id: int) -> ScanRecord | None:
with self._connect() as connection:
row = connection.execute("SELECT * FROM scans WHERE id=?", (int(scan_id),)).fetchone()
if row is None:
return None
record = self._scan_from_row(row)
detections = connection.execute(
"SELECT * FROM detections WHERE scan_id=? ORDER BY id", (int(scan_id),)
).fetchall()
record.detections = [
Detection(
id=int(item["id"]),
path=str(item["path"]),
signature=str(item["signature"]),
action=str(item["action"]),
quarantine_id=item["quarantine_id"],
size=int(item["size"] or 0),
detected_at=str(item["detected_at"]),
)
for item in detections
]
return record
def latest_scan(self) -> ScanRecord | None:
scans = self.list_scans(1)
return scans[0] if scans else None
# -- detections --------------------------------------------------------
def add_detection(self, scan_id: int, detection: Detection) -> int:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO detections (scan_id, path, signature, action, quarantine_id, size, "
"detected_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(int(scan_id), detection.path, detection.signature, detection.action,
detection.quarantine_id, int(detection.size), detection.detected_at or now_iso()),
)
return int(cursor.lastrowid)
def update_detection_action(self, detection_id: int, action: str,
quarantine_id: str | None = None) -> None:
with self._connect() as connection:
connection.execute(
"UPDATE detections SET action=?, quarantine_id=COALESCE(?, quarantine_id) WHERE id=?",
(action, quarantine_id, int(detection_id)),
)
def recent_detections(self, limit: int = 10) -> list[dict]:
with self._connect() as connection:
rows = connection.execute(
"SELECT d.*, s.started_at AS scan_started_at, s.scope AS scan_scope "
"FROM detections d LEFT JOIN scans s ON s.id=d.scan_id "
"ORDER BY d.id DESC LIMIT ?", (max(1, int(limit)),)
).fetchall()
return [dict(row) for row in rows]
# -- quarantine --------------------------------------------------------
def add_quarantine(self, item: QuarantineItem) -> None:
with self._connect() as connection:
connection.execute(
"INSERT OR REPLACE INTO quarantine "
"(id, original_path, quarantine_path, signature, sha256, size, created_at, "
"status, scan_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(item.id, item.original_path, item.quarantine_path, item.signature,
item.sha256, int(item.size), item.created_at, item.status, item.scan_id),
)
def _quarantine_from_row(self, row: sqlite3.Row) -> QuarantineItem:
return QuarantineItem(
id=str(row["id"]),
original_path=str(row["original_path"]),
quarantine_path=str(row["quarantine_path"]),
signature=str(row["signature"]),
sha256=str(row["sha256"]),
size=int(row["size"] or 0),
created_at=str(row["created_at"]),
status=str(row["status"]),
scan_id=row["scan_id"],
)
def list_quarantine(self, status: str | None = None, limit: int = 500) -> list[QuarantineItem]:
query = "SELECT * FROM quarantine"
params: list = []
if status:
query += " WHERE status=?"
params.append(status)
query += " ORDER BY created_at DESC LIMIT ?"
params.append(max(1, int(limit)))
with self._connect() as connection:
return [self._quarantine_from_row(row)
for row in connection.execute(query, params).fetchall()]
def get_quarantine(self, item_id: str) -> QuarantineItem | None:
with self._connect() as connection:
row = connection.execute("SELECT * FROM quarantine WHERE id=?",
(str(item_id),)).fetchone()
return self._quarantine_from_row(row) if row is not None else None
def set_quarantine_status(self, item_id: str, status: str) -> None:
with self._connect() as connection:
connection.execute("UPDATE quarantine SET status=? WHERE id=?",
(status, str(item_id)))
def quarantine_count(self, status: str = "quarantined") -> int:
with self._connect() as connection:
row = connection.execute("SELECT COUNT(*) AS n FROM quarantine WHERE status=?",
(status,)).fetchone()
return int(row["n"] if row else 0)
# -- real-time events --------------------------------------------------
def add_realtime_event(self, event: RealtimeEvent) -> int:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO realtime_events (created_at, path, signature, action, detail) "
"VALUES (?, ?, ?, ?, ?)",
(event.created_at or now_iso(), event.path, event.signature,
event.action, event.detail or ""),
)
return int(cursor.lastrowid)
def list_realtime_events(self, limit: int = 100) -> list[RealtimeEvent]:
with self._connect() as connection:
rows = connection.execute(
"SELECT * FROM realtime_events ORDER BY id DESC LIMIT ?",
(max(1, int(limit)),)
).fetchall()
return [
RealtimeEvent(
id=int(row["id"]), created_at=str(row["created_at"]),
path=str(row["path"]), signature=str(row["signature"]),
action=str(row["action"]), detail=str(row["detail"] or ""),
)
for row in rows
]
# -- dashboard aggregates ---------------------------------------------
def stats(self, days: int = 30) -> dict:
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, days))).isoformat(
timespec="seconds").replace("+00:00", "Z")
with self._connect() as connection:
latest_row = connection.execute(
"SELECT * FROM scans ORDER BY id DESC LIMIT 1").fetchone()
latest = self._scan_from_row(latest_row) if latest_row else None
active = connection.execute(
"SELECT COUNT(*) AS n FROM detections WHERE action IN ('detected','failed')"
).fetchone()
quarantined = connection.execute(
"SELECT COUNT(*) AS n FROM quarantine WHERE status='quarantined'"
).fetchone()
threats = connection.execute(
"SELECT COUNT(*) AS n FROM detections WHERE detected_at >= ?", (cutoff,)
).fetchone()
blocked = connection.execute(
"SELECT COUNT(*) AS n FROM realtime_events WHERE created_at >= ? "
"AND action IN ('quarantined','deleted','blocked')", (cutoff,)
).fetchone()
total_scans = connection.execute("SELECT COUNT(*) AS n FROM scans").fetchone()
return {
"latest_scan": latest,
"active_threats": int(active["n"] if active else 0),
"quarantined": int(quarantined["n"] if quarantined else 0),
"threats_30d": int(threats["n"] if threats else 0),
"blocked_30d": int(blocked["n"] if blocked else 0),
"scans": int(total_scans["n"] if total_scans else 0),
}
def diagnostics_snapshot(self, scan_limit: int = 50, event_limit: int = 100) -> dict:
scans = self.list_scans(scan_limit)
return {
"scans": [
{
"id": scan.id, "started_at": scan.started_at,
"finished_at": scan.finished_at, "scope": scan.scope,
"status": scan.status, "scanned_files": scan.scanned_files,
"infected_files": scan.infected_files, "errors": scan.errors,
"engine_version": scan.engine_version, "db_version": scan.db_version,
"duration_seconds": scan.duration_seconds, "cancelled": scan.cancelled,
}
for scan in scans
],
"quarantine": [
{
"id": item.id, "original_path": item.original_path,
"signature": item.signature, "sha256": item.sha256,
"size": item.size, "created_at": item.created_at,
"status": item.status, "scan_id": item.scan_id,
}
for item in self.list_quarantine(limit=200)
],
"realtime": [
{
"created_at": event.created_at, "path": event.path,
"signature": event.signature, "action": event.action,
"detail": event.detail,
}
for event in self.list_realtime_events(event_limit)
],
}
+448
View File
@@ -0,0 +1,448 @@
"""Security and network overview for the Homepage.
Windows-specific data comes from PowerShell (Defender, firewall, adapters)
and is cached briefly because those cmdlets are much slower than the rest of
the dashboard. Every external call is best-effort: "unknown" is a legitimate
state, never a fabricated "protected".
"""
from __future__ import annotations
import json
import logging
import os
import platform
import socket
import subprocess
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from . import paths
from .clamav import hidden_kwargs
from .config import AppSettings
from .models import ClamAVInfo
from .storage import Storage
logger = logging.getLogger(__name__)
_CACHE: dict[str, Any] = {"at": 0.0, "snapshot": None}
_CACHE_SECONDS = 45
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
def clear_cache() -> None:
_CACHE["at"] = 0.0
_CACHE["snapshot"] = None
def _powershell(command: str, timeout: int = 12) -> str:
if os.name != "nt":
return ""
try:
completed = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive",
"-ExecutionPolicy", "Bypass", "-Command", command],
capture_output=True, text=True, timeout=timeout, errors="replace",
**hidden_kwargs(),
)
return (completed.stdout or "").strip()
except (OSError, subprocess.SubprocessError) as exc:
logger.debug("PowerShell query failed: %s", exc)
return ""
def _extract_json(text: str) -> Any:
if not text:
return None
starts = [index for index in (text.find("{"), text.find("[")) if index >= 0]
if not starts:
return None
start = min(starts)
opener = text[start]
closer = "}" if opener == "{" else "]"
end = text.rfind(closer)
if end <= start:
return None
try:
return json.loads(text[start:end + 1])
except ValueError:
return None
def _as_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in ("true", "1", "yes"):
return True
if text in ("false", "0", "no"):
return False
return None
def _as_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _defender_firewall() -> dict:
command = (
"$ErrorActionPreference='SilentlyContinue';"
"$mp=Get-MpComputerStatus;"
"$profiles=@(Get-NetFirewallProfile);"
"$fw=@($profiles | Where-Object {$_.Enabled -eq $true} | ForEach-Object {$_.Name});"
"[pscustomobject]@{"
"antivirus_enabled=$mp.AntivirusEnabled;"
"realtime_enabled=$mp.RealTimeProtectionEnabled;"
"signature_age_days=$mp.AntivirusSignatureAge;"
"signature_version=$mp.AntivirusSignatureVersion;"
"firewall_enabled=($fw.Count -gt 0);"
"firewall_profiles=$fw"
"} | ConvertTo-Json -Compress"
)
data = _extract_json(_powershell(command))
if not isinstance(data, dict):
return {
"defender_available": False,
"antivirus_enabled": None,
"realtime_enabled": None,
"signature_age_days": None,
"signature_version": "",
"firewall_enabled": None,
"firewall_profiles": [],
}
return {
"defender_available": True,
"antivirus_enabled": _as_bool(data.get("antivirus_enabled")),
"realtime_enabled": _as_bool(data.get("realtime_enabled")),
"signature_age_days": _as_int(data.get("signature_age_days")),
"signature_version": str(data.get("signature_version") or ""),
"firewall_enabled": _as_bool(data.get("firewall_enabled")),
"firewall_profiles": [str(item) for item in (data.get("firewall_profiles") or [])],
}
def _network_details() -> dict:
mac = ""
try:
raw = uuid.getnode()
mac = ":".join(f"{(raw >> shift) & 0xff:02x}" for shift in range(40, -1, -8)).upper()
except Exception:
mac = ""
local_ips: list[str] = []
try:
hostname = socket.gethostname()
_name, _aliases, addresses = socket.gethostbyname_ex(hostname)
local_ips.extend(addresses)
except Exception:
pass
try:
local_ips.append(socket.gethostbyname(socket.gethostname()))
except Exception:
pass
if os.name == "nt":
command = (
"$ErrorActionPreference='SilentlyContinue';"
"$ips=@(Get-NetIPAddress -AddressFamily IPv4 | "
"Where-Object {$_.IPAddress -notlike '127.*' -and "
"$_.IPAddress -notlike '169.254.*'} | "
"Select-Object InterfaceAlias,IPAddress,PrefixLength);"
"$route=Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
"Sort-Object RouteMetric | Select-Object -First 1;"
"[pscustomobject]@{adapters=$ips;gateway=$route.NextHop;"
"interface=$route.InterfaceAlias} | ConvertTo-Json -Depth 3 -Compress"
)
data = _extract_json(_powershell(command))
if isinstance(data, dict):
adapters = data.get("adapters")
if isinstance(adapters, dict):
adapters = [adapters]
normalized = []
for adapter in adapters or []:
if not isinstance(adapter, dict):
continue
ip = str(adapter.get("IPAddress") or "").strip()
if ip:
local_ips.append(ip)
normalized.append({
"name": str(adapter.get("InterfaceAlias") or "Adapter"),
"ip": ip,
"prefix": _as_int(adapter.get("PrefixLength")),
})
return {
"adapters": normalized,
"local_ips": sorted(set(local_ips)),
"gateway": str(data.get("gateway") or ""),
"gateway_interface": str(data.get("interface") or ""),
"mac": mac,
}
return {
"adapters": [{"name": "Local interface", "ip": ip, "prefix": None}
for ip in sorted(set(local_ips))],
"local_ips": sorted(set(local_ips)),
"gateway": "",
"gateway_interface": "",
"mac": mac,
}
def _uptime_seconds() -> int | None:
if os.name == "nt":
try:
import ctypes
return int(ctypes.windll.kernel32.GetTickCount64() // 1000)
except Exception:
return None
try:
with open("/proc/uptime", "r", encoding="ascii") as handle:
return int(float(handle.read().split()[0]))
except (OSError, ValueError, IndexError):
return None
def human_uptime(seconds: int | None) -> str:
if seconds is None:
return "Unknown"
days, remainder = divmod(max(0, int(seconds)), 86400)
hours, remainder = divmod(remainder, 3600)
minutes = remainder // 60
if days:
return f"{days}d {hours}h"
if hours:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def _concern(severity: str, title: str, detail: str, action: str) -> dict:
return {"severity": severity, "title": title, "detail": detail, "action": action}
def _build_concerns(settings: AppSettings, clamav: ClamAVInfo, defender: dict,
stats: dict, is_admin: bool,
realtime_running: bool | None) -> list[dict]:
concerns: list[dict] = []
if not clamav.available:
concerns.append(_concern(
"critical", "ClamAV is not available",
clamav.error or "Install ClamAV and point BoltClam at its folder in Settings.",
"settings"))
else:
if not clamav.db_path:
concerns.append(_concern(
"critical", "Virus definitions are missing",
"Update ClamAV definitions before relying on a scan.", "update_definitions"))
elif clamav.db_age_days is not None and clamav.db_age_days > 30:
concerns.append(_concern(
"critical", f"Definitions are {clamav.db_age_days:.0f} days old",
"New malware families will be missed until the database is refreshed.",
"update_definitions"))
elif clamav.db_age_days is not None and clamav.db_age_days > 7:
concerns.append(_concern(
"warning", f"Definitions are {clamav.db_age_days:.0f} days old",
"Refresh the ClamAV database to cover recent threats.", "update_definitions"))
if not settings.realtime_enabled:
concerns.append(_concern(
"warning", "Real-time protection is off",
"New browser downloads can be opened without a scan.", "settings"))
elif clamav.available and realtime_running is False:
concerns.append(_concern(
"warning", "Real-time watcher is not running",
"Turn real-time protection off and on again, or restart BoltClam.", "settings"))
if defender.get("firewall_enabled") is False:
concerns.append(_concern(
"warning", "Windows Firewall is disabled",
"Enable at least one Windows Firewall profile.", "settings"))
if defender.get("realtime_enabled") is False and not (
settings.realtime_enabled and clamav.available):
concerns.append(_concern(
"warning", "No active real-time antivirus",
"Windows Defender real-time protection is off and BoltClam's watcher is not active.",
"settings"))
if int(stats.get("active_threats") or 0) > 0:
concerns.append(_concern(
"critical", f"{stats['active_threats']} detected file(s) still need action",
"Review the latest scan results and quarantine or delete them.", "scan"))
latest = stats.get("latest_scan")
if latest is None:
concerns.append(_concern(
"info", "No scan on record",
"Run a scan to establish a baseline for this PC.", "scan"))
else:
try:
started = datetime.fromisoformat(str(latest.started_at).replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - started).total_seconds() / 86400
if age_days > 14:
concerns.append(_concern(
"info", f"Last scan was {age_days:.0f} days ago",
"A weekly scan keeps the history useful.", "scan"))
except (TypeError, ValueError):
pass
if latest.errors > 0:
concerns.append(_concern(
"info", f"Last scan reported {latest.errors} access error(s)",
"Some files could not be read. Running as administrator improves coverage.",
"scan"))
if int(stats.get("quarantined") or 0) > 0:
concerns.append(_concern(
"info", f"{stats['quarantined']} item(s) in quarantine",
"Restore anything that was a false positive, or delete it permanently.",
"quarantine"))
if not is_admin and settings.scan_scope == "homes":
concerns.append(_concern(
"info", "Limited access to other user profiles",
"Run BoltClam as administrator to scan every user's home completely.",
"none"))
concerns.sort(key=lambda item: _SEVERITY_ORDER.get(item["severity"], 3))
return concerns
def _segments(settings: AppSettings, clamav: ClamAVInfo, defender: dict,
realtime_running: bool | None) -> list[dict]:
engine = ("good" if clamav.available else "bad",
"Engine", clamav.engine_version or "Not installed")
if not clamav.available:
definitions = ("bad", "Definitions", "Missing")
elif not clamav.db_path:
definitions = ("bad", "Definitions", "Missing")
elif clamav.db_age_days is not None and clamav.db_age_days > 30:
definitions = ("bad", "Definitions", f"{clamav.db_age_days:.0f} days old")
elif clamav.db_age_days is not None and clamav.db_age_days > 7:
definitions = ("warn", "Definitions", f"{clamav.db_age_days:.0f} days old")
else:
definitions = ("good", "Definitions", "Current")
if not settings.realtime_enabled:
realtime = ("warn", "Real-time", "Off")
elif not clamav.available:
realtime = ("bad", "Real-time", "No engine")
elif realtime_running is False:
realtime = ("warn", "Real-time", "Not running")
else:
realtime = ("good", "Real-time", "Watching downloads")
firewall = defender.get("firewall_enabled")
defender_rt = defender.get("realtime_enabled")
if firewall is False:
system = ("warn", "Firewall", "Disabled")
elif defender_rt is False and not (settings.realtime_enabled and clamav.available):
system = ("warn", "Defender", "Real-time off")
elif firewall is None and defender_rt is None:
system = ("unknown", "System", "Not reported")
else:
system = ("good", "System", "Firewall + Defender")
return [
{"key": "engine", "state": engine[0], "label": engine[1], "detail": engine[2]},
{"key": "definitions", "state": definitions[0],
"label": definitions[1], "detail": definitions[2]},
{"key": "realtime", "state": realtime[0],
"label": realtime[1], "detail": realtime[2]},
{"key": "system", "state": system[0], "label": system[1], "detail": system[2]},
]
def collect(settings: AppSettings, clamav: ClamAVInfo, storage: Storage, *,
realtime_running: bool | None = None,
force: bool = False) -> dict:
now = time.time()
if not force and _CACHE["snapshot"] is not None and now - _CACHE["at"] < _CACHE_SECONDS:
cached = dict(_CACHE["snapshot"])
# These two are live values the cached network/Defender probes do not
# need to re-query, but the dashboard should still update.
cached["realtime"]["enabled"] = settings.realtime_enabled
cached["realtime"]["running"] = realtime_running
return cached
stats = storage.stats()
stats_snapshot = dict(stats)
latest = stats_snapshot.get("latest_scan")
if latest is not None:
stats_snapshot["latest_scan"] = {
"id": latest.id,
"started_at": latest.started_at,
"finished_at": latest.finished_at,
"scope": latest.scope,
"status": latest.status,
"scanned_files": latest.scanned_files,
"infected_files": latest.infected_files,
"errors": latest.errors,
"duration_seconds": latest.duration_seconds,
"cancelled": latest.cancelled,
}
defender = _defender_firewall()
network = _network_details()
uptime = _uptime_seconds()
is_admin = paths.is_admin()
if os.name == "nt":
os_name = paths.os_label()
else:
os_name = f"{platform.system()} {platform.release()}".strip() or platform.platform()
concerns = _build_concerns(settings, clamav, defender, stats, is_admin,
realtime_running)
segments = _segments(settings, clamav, defender, realtime_running)
if not clamav.available:
overall = "setup"
headline = "ClamAV needs to be set up"
summary = "Point BoltClam at ClamAV to start scanning and protecting downloads."
elif int(stats.get("active_threats") or 0) > 0:
overall = "at_risk"
headline = "Detected files need your attention"
summary = f"{stats['active_threats']} file(s) are waiting for quarantine or deletion."
elif any(item["severity"] == "critical" for item in concerns):
overall = "at_risk"
headline = "Protection needs attention"
summary = concerns[0]["detail"] if concerns else "Review the concerns below."
elif any(item["severity"] == "warning" for item in concerns):
overall = "attention"
headline = "A few things need attention"
summary = concerns[0]["detail"] if concerns else "Review the concerns below."
else:
overall = "protected"
headline = "You're protected"
summary = "ClamAV definitions are current and BoltClam is watching new downloads."
snapshot = {
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
"overall": overall,
"headline": headline,
"summary": summary,
"hostname": paths.hostname(),
"os": os_name,
"uptime": human_uptime(uptime),
"uptime_seconds": uptime,
"is_admin": is_admin,
"network": network,
"defender": defender,
"firewall": {
"enabled": defender.get("firewall_enabled"),
"profiles": defender.get("firewall_profiles") or [],
},
"clamav": {
"available": clamav.available,
"engine": clamav.engine_version,
"db_version": clamav.db_version,
"db_date": clamav.db_date,
"db_age_days": clamav.db_age_days,
"db_path": str(clamav.db_path) if clamav.db_path else "",
"path": str(clamav.path) if clamav.path else "",
"summary": clamav.summary,
"error": clamav.error,
},
"realtime": {
"enabled": settings.realtime_enabled,
"running": realtime_running,
},
"concerns": concerns,
"segments": segments,
"stats": stats_snapshot,
}
_CACHE["snapshot"] = snapshot
_CACHE["at"] = now
return snapshot
View File
+585
View File
@@ -0,0 +1,585 @@
"""About & updates: account, subscription, ClamAV details and release feed."""
from __future__ import annotations
import logging
import time
from pathlib import Path
from PyQt6.QtCore import QSignalBlocker, QSize, Qt, QThread, pyqtSignal
from PyQt6.QtGui import QDesktopServices, QFont
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from .. import __build_date__, __version__, paths
from ..context import AppContext
from ..models import UpdateAsset, UpdateFeed, UpdateRelease
from ..updates import (
DownloadedUpdate,
UpdateError,
UpdateService,
choose_asset,
is_newer,
)
from . import brand, components, theme
from .threads import stop_thread
logger = logging.getLogger(__name__)
class UpdateCheckWorker(QThread):
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, context: AppContext, channel: str, parent=None) -> None:
super().__init__(parent)
self.context = context
self.channel = channel
def run(self) -> None:
try:
api = self.context.api or self.context.rebuild_api()
feed = UpdateService(api, self.channel).check(force=True)
self.succeeded.emit(feed)
except Exception as exc:
logger.warning("Update check failed: %s", exc)
self.failed.emit(str(exc))
class UpdateDownloadWorker(QThread):
progress = pyqtSignal(int, int)
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, context: AppContext, channel: str, release: UpdateRelease,
asset: UpdateAsset, parent=None) -> None:
super().__init__(parent)
self.context = context
self.channel = channel
self.release = release
self.asset = asset
def run(self) -> None:
try:
api = self.context.api or self.context.rebuild_api()
service = UpdateService(api, self.channel)
result = service.download(
self.release, self.asset,
progress=lambda done, total: self.progress.emit(done, total))
self.succeeded.emit(result)
except Exception as exc:
logger.warning("Update download failed: %s", exc)
self.failed.emit(str(exc))
class ReleaseItem(QWidget):
def __init__(self, release: UpdateRelease, parent=None) -> None:
super().__init__(parent)
self.setMinimumHeight(66)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 7, 10, 7)
layout.setSpacing(2)
top = QHBoxLayout()
tag = QLabel(release.tag or release.name or "Untagged")
tag.setFont(theme.body_font(9.3, QFont.Weight.DemiBold))
top.addWidget(tag, 1)
if release.prerelease:
top.addWidget(components.Pill("Beta", "warning"), 0)
layout.addLayout(top)
when = QLabel(theme.format_datetime(release.published_at))
when.setObjectName("Muted")
when.setFont(theme.body_font(8.4))
layout.addWidget(when)
def sizeHint(self):
return QSize(260, 78)
class AboutPage(QWidget):
toast_requested = pyqtSignal(str, str, str)
update_found = pyqtSignal(object)
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
self._check_worker: UpdateCheckWorker | None = None
self._download_worker: UpdateDownloadWorker | None = None
self._feed: UpdateFeed | None = None
self._selected: UpdateRelease | None = None
self._downloaded: DownloadedUpdate | None = None
self._build()
self.refresh()
# -- construction ------------------------------------------------------
def _build(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
outer.addWidget(scroll)
content = QWidget()
content.setObjectName("Content")
scroll.setWidget(content)
layout = QVBoxLayout(content)
layout.setContentsMargins(30, 24, 30, 30)
layout.setSpacing(16)
header = components.PageHeader(
"About & updates", "BoltClam's build, account, subscription, and release feed.")
self.check_button = QPushButton("Check for updates")
self.check_button.setObjectName("Secondary")
self.check_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.check_button.clicked.connect(lambda: self.check_updates(manual=True))
header.add_action(self.check_button)
layout.addWidget(header)
top = QHBoxLayout()
top.setSpacing(16)
top.addWidget(self._build_identity(), 1)
top.addWidget(self._build_update_card(), 1)
layout.addLayout(top)
releases = QHBoxLayout()
releases.setSpacing(16)
releases.addWidget(self._build_notes_card(), 2)
releases.addWidget(self._build_history_card(), 1)
layout.addLayout(releases, 1)
links = components.Card(margins=(16, 12, 16, 12))
link_row = QHBoxLayout()
link_row.setSpacing(18)
for label, url in (
("BoltClam repository", "https://git.themajesticnetwork.com/TheMajesticNetwork/BoltClam"),
("Bolt account", "https://bolt.themajesticnetwork.com/account"),
("ClamAV downloads", "https://www.clamav.net/downloads"),
):
link = QLabel(f'<a href="{url}" style="color:{theme.TEAL}; text-decoration:none;">{label}</a>')
link.setOpenExternalLinks(True)
link.setFont(theme.body_font(9.2, QFont.Weight.DemiBold))
link_row.addWidget(link)
link_row.addStretch(1)
links.body.addLayout(link_row)
note = QLabel(
"BoltClam is a desktop interface for ClamAV. ClamAV is licensed under GPLv2 and "
"installed separately; BoltClam does not bundle its binaries. Real-time protection "
"locks a download while ClamAV scans it in user mode — it is a strong speed bump, "
"not a kernel-level filter driver.")
note.setObjectName("Dim")
note.setWordWrap(True)
links.body.addWidget(note)
layout.addWidget(links)
layout.addStretch(1)
def _build_identity(self) -> QWidget:
card = components.Card()
top = QHBoxLayout()
top.setSpacing(13)
mark = QLabel()
mark.setPixmap(brand.pixmap(58))
mark.setFixedSize(58, 58)
top.addWidget(mark, 0)
text = QVBoxLayout()
text.setSpacing(2)
name = QLabel("BoltClam")
name.setFont(theme.display_font(21, QFont.Weight.Bold))
text.addWidget(name)
version = QLabel(theme.version_line())
version.setObjectName("Muted")
text.addWidget(version)
top.addLayout(text, 1)
self.licensed_pill = components.Pill("Free", "muted")
top.addWidget(self.licensed_pill, 0, Qt.AlignmentFlag.AlignTop)
card.body.addLayout(top)
self.rows = {}
for key, label, mono in (
("account", "Signed in as", False),
("email", "Email", False),
("plan", "BoltClam plan", False),
("subscription", "Subscription", False),
("clamav", "ClamAV", False),
("definitions", "Definitions", False),
("build", "Build", False)):
row = components.KeyValueRow(label, mono=mono)
self.rows[key] = row
card.body.addWidget(row)
return card
def _build_update_card(self) -> QWidget:
card = components.Card()
header = QHBoxLayout()
title = QLabel("Updates")
title.setObjectName("CardTitle")
header.addWidget(title, 1)
self.update_pill = components.Pill("Not checked", "muted")
header.addWidget(self.update_pill, 0)
card.body.addLayout(header)
self.update_headline = QLabel("Checking the release feed…")
self.update_headline.setFont(theme.display_font(14.5, QFont.Weight.DemiBold))
self.update_headline.setWordWrap(True)
card.body.addWidget(self.update_headline)
self.update_detail = QLabel("")
self.update_detail.setObjectName("Muted")
self.update_detail.setWordWrap(True)
card.body.addWidget(self.update_detail)
self.download_progress = QProgressBar()
self.download_progress.setRange(0, 100)
self.download_progress.setFixedHeight(6)
self.download_progress.hide()
card.body.addWidget(self.download_progress)
self.download_status = QLabel("")
self.download_status.setObjectName("Dim")
self.download_status.setWordWrap(True)
card.body.addWidget(self.download_status)
buttons = QHBoxLayout()
buttons.setSpacing(8)
self.download_button = QPushButton("Download update")
self.download_button.setObjectName("Primary")
self.download_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.download_button.clicked.connect(self.download_selected)
self.download_button.setEnabled(False)
buttons.addWidget(self.download_button)
self.install_button = QPushButton("Run installer")
self.install_button.setObjectName("Secondary")
self.install_button.clicked.connect(self.open_download)
self.install_button.hide()
buttons.addWidget(self.install_button)
buttons.addStretch(1)
card.body.addLayout(buttons)
card.body.addStretch(1)
channel = QLabel("Release channel is controlled in Settings.")
channel.setObjectName("Dim")
card.body.addWidget(channel)
return card
def _build_notes_card(self) -> QWidget:
card = components.Card()
header = QHBoxLayout()
self.notes_title = QLabel("Release notes")
self.notes_title.setObjectName("CardTitle")
header.addWidget(self.notes_title, 1)
self.notes_tag = components.Pill("—", "muted")
header.addWidget(self.notes_tag, 0)
card.body.addLayout(header)
self.notes_meta = QLabel("")
self.notes_meta.setObjectName("Muted")
card.body.addWidget(self.notes_meta)
self.notes_view = components.MarkdownView()
self.notes_view.setMinimumHeight(210)
card.body.addWidget(self.notes_view, 1)
self.notes_empty = QLabel(
"Check for updates to load release notes from the BoltClam repository.")
self.notes_empty.setObjectName("Muted")
self.notes_empty.setWordWrap(True)
card.body.addWidget(self.notes_empty)
return card
def _build_history_card(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"Release history", "Newest first from the Gitea release feed")[0])
self.history = QListWidget()
self.history.setSpacing(2)
self.history.currentItemChanged.connect(self._release_selected)
card.body.addWidget(self.history, 1)
return card
# -- data --------------------------------------------------------------
def refresh(self) -> None:
self._refresh_identity()
self._refresh_update_state()
if self._feed is None and self._check_worker is None:
last = self.context.settings.last_update_check
if last <= 0 or time.time() - last > 6 * 3600:
self.check_updates(manual=False)
def refresh_account(self) -> None:
self._refresh_identity()
def reload_settings(self) -> None:
self._refresh_identity()
if self._feed is not None and self._feed.channel != self.context.settings.update_channel:
self._feed = None
self.history.clear()
self.notes_view.clear()
self.notes_empty.show()
self._refresh_update_state()
if self._check_worker is None:
self.check_updates(manual=False)
else:
self._refresh_update_state()
def _refresh_identity(self) -> None:
self.rows["account"].set_value(self.context.user_name)
self.rows["email"].set_value(self.context.user_email or "—")
self.rows["plan"].set_value(self.context.plan_label)
subscription = self.context.subscription
status = str(subscription.get("status") or "none")
if status in ("active", "trialing"):
end = theme.format_datetime(subscription.get("current_period_end"))
self.rows["subscription"].set_value(
f"{subscription.get('label', 'Bolt')} · {status}"
+ (f" · renews {end}" if end != "—" else ""))
else:
self.rows["subscription"].set_value("No active Bolt subscription")
info = self.context.clamav.info()
self.rows["clamav"].set_value(info.summary if info.available else "Not configured")
if info.available and info.db_date:
self.rows["definitions"].set_value(
f"{info.db_version or 'database'} · {info.db_date}")
else:
self.rows["definitions"].set_value("Missing")
self.rows["build"].set_value(f"{__version__} · {__build_date__}")
self.licensed_pill.setText("Licensed" if self.context.licensed else "Free")
self.licensed_pill.set_severity("good" if self.context.licensed else "muted")
if self._feed is None:
channel = "Beta" if self.context.settings.update_channel == "beta" else "Release"
self.update_detail.setText(
f"Channel: {channel}. Current version: {__version__}.")
def _refresh_update_state(self) -> None:
if self._feed is None:
return
latest = self._feed.latest
if latest is None:
self.update_pill.setText("No releases")
self.update_pill.set_severity("muted")
self.update_headline.setText("No releases have been published yet")
self.update_detail.setText(
f"Channel: {self._feed.channel}. A release will appear here when it is tagged.")
self.download_button.setEnabled(False)
self.update_found.emit(None)
return
newer = is_newer(latest.tag, __version__)
self.update_pill.setText("Update available" if newer else "Up to date")
self.update_pill.set_severity("warning" if newer else "good")
self.update_headline.setText(
f"{latest.name or latest.tag} is ready" if newer else
f"You're on the latest {('beta' if latest.prerelease else 'release')}")
asset = choose_asset(latest)
asset_text = f" · {asset.name}" if asset else " · no Windows asset attached"
self.update_detail.setText(
f"{latest.tag} · published {theme.format_datetime(latest.published_at)}"
f"{asset_text}")
self.download_button.setEnabled(bool(asset))
self._select_release(latest)
self.update_found.emit(latest if newer else None)
def _select_release(self, release: UpdateRelease) -> None:
self._selected = release
self._downloaded = None
self.install_button.hide()
self.download_button.setText("Download update")
self.notes_title.setText(release.name or release.tag or "Release notes")
self.notes_tag.setText(release.tag or "—")
self.notes_tag.set_severity("warning" if release.prerelease else "good")
self.notes_meta.setText(
f"Published {theme.format_datetime(release.published_at)}"
+ (" · beta" if release.prerelease else ""))
notes = release.notes.strip()
self.notes_view.set_markdown(notes or "This release has no notes.")
self.notes_view.show()
self.notes_empty.hide()
asset = choose_asset(release)
self.download_button.setEnabled(bool(asset))
if asset:
self.download_status.setText(
f"Asset: {asset.name} · {theme.format_size(asset.size)}")
else:
self.download_status.setText("No Windows .exe or .zip asset is attached to this release.")
self._sync_history_selection(release)
def _sync_history_selection(self, release: UpdateRelease) -> None:
if not hasattr(self, "history"):
return
for row in range(self.history.count()):
item = self.history.item(row)
if item is not None and str(item.data(Qt.ItemDataRole.UserRole) or "") == release.id:
if self.history.currentRow() != row:
blocker = QSignalBlocker(self.history)
self.history.setCurrentRow(row)
del blocker
return
def _populate_history(self, feed: UpdateFeed) -> None:
blocker = QSignalBlocker(self.history)
self.history.clear()
for release in feed.releases:
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, release.id)
widget = ReleaseItem(release)
item.setSizeHint(QSize(260, 82))
self.history.addItem(item)
self.history.setItemWidget(item, widget)
del blocker
if feed.latest is not None:
self._sync_history_selection(feed.latest)
def _release_selected(self, current: QListWidgetItem | None, previous=None) -> None:
if current is None or self._feed is None:
return
release_id = str(current.data(Qt.ItemDataRole.UserRole) or "")
for release in self._feed.releases:
if release.id == release_id:
self._select_release(release)
return
# -- update check ------------------------------------------------------
def check_updates(self, manual: bool = True) -> None:
if self._check_worker is not None and self._check_worker.isRunning():
return
if self.context.api is None:
try:
self.context.rebuild_api()
except Exception as exc:
self.update_pill.setText("Unavailable")
self.update_pill.set_severity("muted")
self.update_headline.setText("Cannot reach the Bolt update service")
self.update_detail.setText(str(exc))
return
self.check_button.setEnabled(False)
self.check_button.setText("Checking…")
self.update_pill.setText("Checking")
self.update_pill.set_severity("info")
worker = UpdateCheckWorker(
self.context, self.context.settings.update_channel, self)
worker.succeeded.connect(self._check_succeeded)
worker.failed.connect(lambda message: self._check_failed(message, manual))
worker.finished.connect(self._check_finished)
worker.finished.connect(worker.deleteLater)
self._check_worker = worker
worker.start()
def _check_succeeded(self, feed: UpdateFeed) -> None:
self._feed = feed
self._refresh_update_state()
self._populate_history(feed)
try:
self.context.settings = self.context.settings_store.update(
last_update_check=time.time())
except Exception:
logger.exception("Could not save the update-check timestamp")
if feed.latest is None:
self.update_detail.setText(f"Channel: {feed.channel}. No releases yet.")
def _check_failed(self, message: str, manual: bool) -> None:
self.update_pill.setText("Check failed")
self.update_pill.set_severity("critical" if manual else "muted")
self.update_headline.setText("Could not check for updates")
self.update_detail.setText(message)
self.download_button.setEnabled(False)
if manual:
self.toast_requested.emit("Update check failed", message, "critical")
def _check_finished(self) -> None:
self._check_worker = None
self.check_button.setEnabled(True)
self.check_button.setText("Check for updates")
# -- download ----------------------------------------------------------
def download_selected(self) -> None:
release = self._selected or (self._feed.latest if self._feed else None)
if release is None:
return
asset = choose_asset(release)
if asset is None:
self.toast_requested.emit(
"No Windows asset", "That release does not include a .exe or .zip asset.", "warning")
return
answer = QMessageBox.question(
self, "Download update?",
f"Download {asset.name} ({theme.format_size(asset.size)}) for {release.tag}?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes)
if answer != QMessageBox.StandardButton.Yes:
return
self.download_button.setEnabled(False)
self.check_button.setEnabled(False)
self.download_progress.setValue(0)
self.download_progress.show()
self.download_status.setText("Starting download…")
worker = UpdateDownloadWorker(
self.context, self.context.settings.update_channel, release, asset, self)
worker.progress.connect(self._download_progress)
worker.succeeded.connect(self._download_succeeded)
worker.failed.connect(self._download_failed)
worker.finished.connect(self._download_finished)
worker.finished.connect(worker.deleteLater)
self._download_worker = worker
worker.start()
def _download_progress(self, done: int, total: int) -> None:
if total > 0:
self.download_progress.setRange(0, 100)
self.download_progress.setValue(int(done * 100 / total))
self.download_status.setText(
f"Downloading {theme.format_size(done)} of {theme.format_size(total)}…")
else:
self.download_progress.setRange(0, 0)
self.download_status.setText(f"Downloading {theme.format_size(done)}…")
def _download_succeeded(self, result: DownloadedUpdate) -> None:
self._downloaded = result
self.download_progress.setRange(0, 100)
self.download_progress.setValue(100)
verified = "checksum verified" if result.verified else "no checksum asset published"
self.download_status.setText(
f"Saved to {result.path} · SHA-256 {result.sha256[:16]}… · {verified}")
self.toast_requested.emit(
"Update downloaded", f"{result.path.name} is ready to install.", "good")
self.download_button.setText("Download again")
self.download_button.setEnabled(True)
self.install_button.setText(
"Run installer" if result.path.suffix.lower() in (".exe", ".msi") else "Show download")
self.install_button.show()
def _download_failed(self, message: str) -> None:
self.download_progress.hide()
self.download_status.setText(message)
self.download_button.setEnabled(True)
self.toast_requested.emit("Update download failed", message, "critical")
def _download_finished(self) -> None:
self._download_worker = None
self.check_button.setEnabled(True)
if not self._downloaded:
self.download_progress.hide()
def open_download(self) -> None:
if self._downloaded is None:
return
path = self._downloaded.path
if path.suffix.lower() in (".exe", ".msi"):
answer = QMessageBox.question(
self, "Run the installer?",
f"Run {path.name}?\n\nOnly continue if the release came from the "
"BoltClam repository and the checksum was verified.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No)
if answer != QMessageBox.StandardButton.Yes:
return
target = path if path.suffix.lower() in (".exe", ".msi") else path.parent
if not paths.open_path(target):
self.toast_requested.emit(
"Could not open the download", str(target), "warning")
def stop(self) -> None:
for worker in (self._check_worker, self._download_worker):
stop_thread(worker, wait_ms=3500)
self._check_worker = None
self._download_worker = None
+110
View File
@@ -0,0 +1,110 @@
"""The BoltClam mark: a scalloped shell with a bolt at its heart."""
from __future__ import annotations
import math
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtGui import (
QBrush,
QColor,
QIcon,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QPixmap,
QPolygonF,
)
def paint_mark(painter: QPainter, rect: QRectF, *, rounded: bool = True) -> None:
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
size = min(rect.width(), rect.height())
square = QRectF(rect.center().x() - size / 2, rect.center().y() - size / 2, size, size)
if rounded:
background = QLinearGradient(square.topLeft(), square.bottomRight())
background.setColorAt(0.0, QColor("#123542"))
background.setColorAt(0.55, QColor("#0B202B"))
background.setColorAt(1.0, QColor("#08151D"))
painter.setBrush(QBrush(background))
painter.setPen(QPen(QColor(89, 216, 201, 70), max(1.0, size * 0.012)))
painter.drawRoundedRect(square.adjusted(1, 1, -1, -1), size * 0.24, size * 0.24)
# Scallop shell: ribs fan upward from the hinge near the bottom.
cx = square.center().x()
hinge = QPointF(cx, square.top() + size * 0.80)
spread = size * 0.36
top = square.top() + size * 0.25
ribs = 7
for index in range(ribs):
fraction = index / (ribs - 1)
tip_x = cx - spread + fraction * spread * 2
tip = QPointF(tip_x, top + abs(fraction - 0.5) * size * 0.11)
control_a = QPointF(hinge.x() + (tip_x - cx) * 0.10, hinge.y() - size * 0.18)
control_b = QPointF(tip_x + (cx - tip_x) * 0.25, top + size * 0.12)
path = QPainterPath(hinge)
path.cubicTo(control_a, control_b, tip)
alpha = 95 + int(70 * (1 - abs(fraction - 0.5) * 1.4))
painter.setPen(QPen(QColor(122, 244, 255, max(40, alpha)),
max(1.0, size * 0.014 * (1.25 - abs(fraction - 0.5))),
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(path)
# Hinge band.
painter.setPen(QPen(QColor(89, 216, 201, 140), max(1.0, size * 0.018),
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawArc(QRectF(cx - spread * 0.7, hinge.y() - size * 0.04,
spread * 1.4, size * 0.12), 200 * 16, 140 * 16)
# Bolt at the heart of the shell.
bolt = QPolygonF([
QPointF(cx + size * 0.045, square.top() + size * 0.31),
QPointF(cx - size * 0.115, square.top() + size * 0.535),
QPointF(cx - size * 0.010, square.top() + size * 0.535),
QPointF(cx - size * 0.060, square.top() + size * 0.705),
QPointF(cx + size * 0.120, square.top() + size * 0.470),
QPointF(cx + size * 0.012, square.top() + size * 0.470),
])
bolt_gradient = QLinearGradient(QPointF(cx, square.top() + size * 0.30),
QPointF(cx, square.top() + size * 0.72))
bolt_gradient.setColorAt(0.0, QColor("#FFE3A6"))
bolt_gradient.setColorAt(1.0, QColor("#F2B84B"))
painter.setBrush(QBrush(bolt_gradient))
painter.setPen(QPen(QColor(255, 227, 166, 180), max(0.8, size * 0.008)))
painter.drawPolygon(bolt)
painter.restore()
def pixmap(size: int = 256) -> QPixmap:
image = QPixmap(size, size)
image.fill(Qt.GlobalColor.transparent)
painter = QPainter(image)
paint_mark(painter, QRectF(0, 0, size, size))
painter.end()
return image
def app_icon() -> QIcon:
icon = QIcon()
for size in (16, 24, 32, 48, 64, 128, 256):
icon.addPixmap(pixmap(size))
return icon
def bolt_path(rect: QRectF) -> QPainterPath:
cx = rect.center().x()
top = rect.top()
size = min(rect.width(), rect.height())
path = QPainterPath()
path.moveTo(cx + size * 0.07, top + size * 0.05)
path.lineTo(cx - size * 0.23, top + size * 0.53)
path.lineTo(cx - size * 0.01, top + size * 0.53)
path.lineTo(cx - size * 0.11, top + size * 0.95)
path.lineTo(cx + size * 0.25, top + size * 0.42)
path.lineTo(cx + size * 0.02, top + size * 0.42)
path.closeSubpath()
return path
+790
View File
@@ -0,0 +1,790 @@
"""Reusable painted and composed widgets for the BoltClam interface."""
from __future__ import annotations
import math
from typing import Iterable
from PyQt6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, pyqtSignal
from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
QFontMetrics,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QPolygonF,
QRadialGradient,
)
from PyQt6.QtWidgets import (
QAbstractButton,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QSizePolicy,
QTextBrowser,
QVBoxLayout,
QWidget,
)
from . import brand, theme
class Card(QFrame):
def __init__(self, parent: QWidget | None = None, *,
margins: tuple[int, int, int, int] = (18, 18, 18, 18),
spacing: int = 12) -> None:
super().__init__(parent)
self.setObjectName("Card")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.body = QVBoxLayout(self)
self.body.setContentsMargins(*margins)
self.body.setSpacing(spacing)
def card_header(title: str, subtitle: str | None = None,
right: QWidget | None = None) -> tuple[QWidget, QVBoxLayout]:
holder = QWidget()
layout = QHBoxLayout(holder)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
text_box = QVBoxLayout()
text_box.setContentsMargins(0, 0, 0, 0)
text_box.setSpacing(2)
title_label = QLabel(title)
title_label.setObjectName("CardTitle")
title_label.setWordWrap(True)
text_box.addWidget(title_label)
if subtitle:
subtitle_label = QLabel(subtitle)
subtitle_label.setObjectName("Muted")
subtitle_label.setWordWrap(True)
subtitle_label.setFont(theme.body_font(9.2))
text_box.addWidget(subtitle_label)
layout.addLayout(text_box, 1)
if right is not None:
layout.addWidget(right, 0, Qt.AlignmentFlag.AlignTop)
return holder, text_box
class Pill(QLabel):
def __init__(self, text: str = "", severity: str = "info", parent=None) -> None:
super().__init__(text, parent)
self.setObjectName("Pill")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.set_severity(severity)
def set_severity(self, severity: str) -> None:
self.setProperty("severity", severity or "info")
self.style().unpolish(self)
self.style().polish(self)
class SeverityDot(QWidget):
def __init__(self, severity: str = "info", size: int = 9, parent=None) -> None:
super().__init__(parent)
self._severity = severity
self._size = size
self.setFixedSize(size, size)
def set_severity(self, severity: str) -> None:
self._severity = severity
self.update()
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
color = QColor(theme.severity_color(self._severity))
glow = QColor(color)
glow.setAlpha(55)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(glow)
painter.drawEllipse(QRectF(0, 0, self._size, self._size))
painter.setBrush(color)
inset = self._size * 0.22
painter.drawEllipse(QRectF(inset, inset, self._size - inset * 2,
self._size - inset * 2))
class Avatar(QWidget):
def __init__(self, name: str = "", size: int = 40, parent=None) -> None:
super().__init__(parent)
self._name = name or "?"
self._size = size
self.setFixedSize(size, size)
def set_name(self, name: str) -> None:
self._name = name or "?"
self.update()
def initials(self) -> str:
parts = [part for part in str(self._name).replace("@", " ").split() if part]
if not parts:
return "?"
if len(parts) == 1:
return parts[0][:1].upper()
return (parts[0][0] + parts[-1][0]).upper()
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
rect = QRectF(1, 1, self._size - 2, self._size - 2)
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
gradient.setColorAt(0, QColor("#1C4A55"))
gradient.setColorAt(1, QColor("#0E2630"))
painter.setBrush(gradient)
painter.setPen(QPen(QColor(89, 216, 201, 90), 1))
painter.drawEllipse(rect)
painter.setPen(QColor(theme.AMBER_BRIGHT))
font = theme.display_font(self._size * 0.30, QFont.Weight.DemiBold)
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.initials())
class ProtectionDial(QWidget):
"""The homepage hero: a scalloped shell whose four arcs encode state."""
_LABELS = ("Engine", "Definitions", "Real-time", "System")
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setMinimumSize(248, 248)
self.setMaximumSize(286, 286)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self._overall = "setup"
self._segments: list[dict] = []
self._headline = "Checking"
self.setToolTip("ClamAV engine, signature database, real-time watcher and system firewall")
def sizeHint(self) -> QSize:
return QSize(270, 270)
def set_snapshot(self, snapshot: dict | None) -> None:
self._overall = str((snapshot or {}).get("overall") or "setup")
self._segments = list((snapshot or {}).get("segments") or [])
self._headline = str((snapshot or {}).get("headline") or "Checking")
self.update()
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
side = min(self.width(), self.height())
rect = QRectF((self.width() - side) / 2 + 8, (self.height() - side) / 2 + 8,
side - 16, side - 16)
center = rect.center()
radius = rect.width() / 2
overall = QColor(theme.overall_color(self._overall))
glow = QRadialGradient(center, radius)
glow.setColorAt(0.0, QColor(overall.red(), overall.green(), overall.blue(), 36))
glow.setColorAt(0.62, QColor(overall.red(), overall.green(), overall.blue(), 8))
glow.setColorAt(1.0, QColor(0, 0, 0, 0))
painter.setBrush(glow)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(rect)
disk = QRadialGradient(center - QPointF(radius * 0.2, radius * 0.25), radius * 1.15)
disk.setColorAt(0.0, QColor("#13313D"))
disk.setColorAt(0.7, QColor("#0B1F29"))
disk.setColorAt(1.0, QColor("#08151D"))
painter.setBrush(disk)
painter.setPen(QPen(QColor(27, 57, 70), 1))
painter.drawEllipse(rect)
arc_rect = rect.adjusted(18, 18, -18, -18)
track_pen = QPen(QColor(28, 59, 71), 9)
track_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(track_pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawArc(arc_rect, 210 * 16, -300 * 16)
states = [str(item.get("state") or "unknown") for item in self._segments]
states += ["unknown"] * (4 - len(states))
for index in range(4):
color = QColor(theme.severity_color(states[index]))
if self._overall == "setup":
color = QColor(theme.DIM)
pen = QPen(color, 9)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
start = 210 - index * 75 + 2
span = -71
painter.drawArc(arc_rect, int(start * 16), int(span * 16))
# Shell ribs inside the gauge.
painter.save()
painter.setClipPath(self._ellipse_path(rect.adjusted(10, 10, -10, -10)))
hinge = QPointF(center.x(), center.y() + radius * 0.52)
top = center.y() - radius * 0.60
spread = radius * 0.57
for index in range(9):
fraction = index / 8
tip_x = center.x() - spread + fraction * spread * 2
tip = QPointF(tip_x, top + abs(fraction - 0.5) * radius * 0.18)
path = QPainterPath(hinge)
path.cubicTo(QPointF(center.x() + (tip_x - center.x()) * 0.08,
hinge.y() - radius * 0.28),
QPointF(tip_x + (center.x() - tip_x) * 0.22, top + radius * 0.22),
tip)
painter.setPen(QPen(QColor(122, 244, 255, 20), 1.2))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(path)
painter.restore()
core_radius = radius * 0.40
core_rect = QRectF(center.x() - core_radius, center.y() - core_radius,
core_radius * 2, core_radius * 2)
core = QRadialGradient(center - QPointF(core_radius * 0.25, core_radius * 0.3),
core_radius * 1.6)
core.setColorAt(0.0, QColor("#143743"))
core.setColorAt(1.0, QColor("#0A1C25"))
painter.setBrush(core)
painter.setPen(QPen(QColor(overall.red(), overall.green(), overall.blue(), 130), 1.4))
painter.drawEllipse(core_rect)
# Shield outline with the amber bolt inside.
shield = QPainterPath()
left = center.x() - core_radius * 0.46
right = center.x() + core_radius * 0.46
top_y = center.y() - core_radius * 0.55
bottom_y = center.y() + core_radius * 0.62
shield.moveTo(center.x(), top_y)
shield.lineTo(right, top_y + core_radius * 0.14)
shield.lineTo(right, center.y() + core_radius * 0.10)
shield.cubicTo(right, center.y() + core_radius * 0.48,
center.x() + core_radius * 0.18, bottom_y,
center.x(), bottom_y)
shield.cubicTo(center.x() - core_radius * 0.18, bottom_y,
left, center.y() + core_radius * 0.48,
left, center.y() + core_radius * 0.10)
shield.lineTo(left, top_y + core_radius * 0.14)
shield.closeSubpath()
painter.setPen(QPen(QColor(overall.red(), overall.green(), overall.blue(), 190), 1.6))
painter.setBrush(QColor(overall.red(), overall.green(), overall.blue(), 14))
painter.drawPath(shield)
bolt_rect = QRectF(center.x() - core_radius * 0.44, top_y + core_radius * 0.08,
core_radius * 0.88, core_radius * 0.92)
painter.setBrush(QColor(theme.AMBER))
painter.setPen(QPen(QColor(theme.AMBER_BRIGHT), 0.8))
painter.drawPath(brand.bolt_path(bolt_rect))
# The hero copy already states the status; a second caption here
# collided with the shell's lower arc at 1360px.
@staticmethod
def _ellipse_path(rect: QRectF) -> QPainterPath:
path = QPainterPath()
path.addEllipse(rect)
return path
class NavButton(QAbstractButton):
_ICONS = ("home", "scan", "updates", "settings")
def __init__(self, icon_name: str, text: str, parent=None) -> None:
super().__init__(parent)
self.icon_name = icon_name
self.setText(text)
self.setCheckable(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
self.setMinimumHeight(46)
self._hover = False
def sizeHint(self) -> QSize:
return QSize(196, 46)
def enterEvent(self, event) -> None:
self._hover = True
self.update()
super().enterEvent(event)
def leaveEvent(self, event) -> None:
self._hover = False
self.update()
super().leaveEvent(event)
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
rect = QRectF(0, 2, self.width(), self.height() - 4)
checked = self.isChecked()
if checked:
painter.setBrush(QColor(242, 184, 75, 20))
painter.setPen(QPen(QColor(242, 184, 75, 65), 1))
painter.drawRoundedRect(rect, 10, 10)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(theme.AMBER))
painter.drawRoundedRect(QRectF(0, 13, 3, self.height() - 26), 1.5, 1.5)
elif self._hover:
painter.setBrush(QColor(255, 255, 255, 8))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(rect, 10, 10)
color = QColor(theme.AMBER_BRIGHT if checked else theme.MUTED)
icon_rect = QRectF(18, (self.height() - 18) / 2, 18, 18)
_draw_nav_icon(painter, self.icon_name, icon_rect, color)
painter.setPen(QColor(theme.TEXT if checked else theme.TEXT_SOFT))
font = theme.body_font(10.4, QFont.Weight.DemiBold if checked else QFont.Weight.Normal)
painter.setFont(font)
painter.drawText(QRectF(46, 0, self.width() - 52, self.height()),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
self.text())
def _draw_nav_icon(painter: QPainter, name: str, rect: QRectF, color: QColor) -> None:
painter.save()
pen = QPen(color, 1.6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap,
Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
if name == "home":
path = QPainterPath()
path.moveTo(rect.left() + 1, rect.top() + rect.height() * 0.45)
path.lineTo(rect.center().x(), rect.top() + 1)
path.lineTo(rect.right() - 1, rect.top() + rect.height() * 0.45)
painter.drawPath(path)
painter.drawRoundedRect(QRectF(rect.left() + 3, rect.top() + rect.height() * 0.44,
rect.width() - 6, rect.height() * 0.48), 2, 2)
painter.drawLine(QPointF(rect.center().x(), rect.bottom() - 3),
QPointF(rect.center().x(), rect.bottom() - 8))
elif name == "scan":
inset = 2.0
length = 5.0
for x, y, dx, dy in (
(rect.left(), rect.top(), 1, 1),
(rect.right(), rect.top(), -1, 1),
(rect.left(), rect.bottom(), 1, -1),
(rect.right(), rect.bottom(), -1, -1)):
path = QPainterPath(QPointF(x + dx * inset, y + dy * (inset + length)))
path.lineTo(x + dx * inset, y + dy * inset)
path.lineTo(x + dx * (inset + length), y + dy * inset)
painter.drawPath(path)
painter.drawEllipse(QRectF(rect.center().x() - 2.2, rect.center().y() - 2.2, 4.4, 4.4))
painter.drawLine(QPointF(rect.center().x(), rect.top() + 6),
QPointF(rect.center().x(), rect.top() + 1))
painter.drawLine(QPointF(rect.center().x(), rect.bottom() - 1),
QPointF(rect.center().x(), rect.bottom() - 6))
elif name == "updates":
painter.drawArc(QRectF(rect.left() + 2, rect.top() + 2,
rect.width() - 4, rect.height() - 4), 40 * 16, 280 * 16)
arrow = QPolygonF([
QPointF(rect.right() - 3, rect.top() + 3),
QPointF(rect.right() - 3, rect.top() + 9),
QPointF(rect.right() - 9, rect.top() + 3),
])
painter.setBrush(color)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPolygon(arrow)
elif name == "settings":
for index, y in enumerate((rect.top() + 4, rect.center().y(), rect.bottom() - 4)):
painter.drawLine(QPointF(rect.left() + 1, y), QPointF(rect.right() - 1, y))
knob_x = rect.left() + rect.width() * (0.34 if index == 0 else 0.66 if index == 1 else 0.46)
painter.setBrush(QColor(theme.BG))
painter.setPen(QPen(color, 1.5))
painter.drawEllipse(QPointF(knob_x, y), 2.3, 2.3)
painter.restore()
class ChoiceTile(QRadioButton):
def __init__(self, title: str, description: str, parent=None) -> None:
super().__init__(parent)
self.setObjectName("ChoiceTile")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
layout = QVBoxLayout(self)
layout.setContentsMargins(36, 12, 14, 12)
layout.setSpacing(3)
title_label = QLabel(title)
title_label.setFont(theme.body_font(10.3, QFont.Weight.DemiBold))
title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
desc_label = QLabel(description)
desc_label.setObjectName("Muted")
desc_label.setFont(theme.body_font(8.8))
desc_label.setWordWrap(True)
desc_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
layout.addWidget(title_label)
layout.addWidget(desc_label)
self._title_label = title_label
self._desc_label = desc_label
self.setMinimumHeight(82)
def sizeHint(self) -> QSize:
return QSize(250, 82)
class ScanButton(QAbstractButton):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setFixedSize(184, 184)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
self._scanning = False
self._hover = False
self._angle = 0
self._timer = QTimer(self)
self._timer.setInterval(45)
self._timer.timeout.connect(self._tick)
def sizeHint(self) -> QSize:
return QSize(184, 184)
def set_scanning(self, scanning: bool) -> None:
self._scanning = bool(scanning)
if scanning:
if not self._timer.isActive():
self._timer.start()
self.setCursor(Qt.CursorShape.BusyCursor)
else:
self._timer.stop()
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.update()
def enterEvent(self, event) -> None:
self._hover = True
self.update()
super().enterEvent(event)
def leaveEvent(self, event) -> None:
self._hover = False
self.update()
super().leaveEvent(event)
def _tick(self) -> None:
self._angle = (self._angle + 12) % 360
self.update()
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
side = min(self.width(), self.height())
rect = QRectF((self.width() - side) / 2 + 6, (self.height() - side) / 2 + 6,
side - 12, side - 12)
center = rect.center()
radius = rect.width() / 2
glow = QRadialGradient(center, radius)
glow.setColorAt(0, QColor(242, 184, 75, 40 if self.isEnabled() else 10))
glow.setColorAt(1, QColor(242, 184, 75, 0))
painter.setBrush(glow)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(rect)
painter.setBrush(QColor("#0B1E27"))
painter.setPen(QPen(QColor(theme.BORDER), 1))
painter.drawEllipse(rect.adjusted(10, 10, -10, -10))
ring = rect.adjusted(20, 20, -20, -20)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(33, 66, 79), 7))
painter.drawArc(ring, 0, 360 * 16)
if self._scanning:
pen = QPen(QColor(theme.AMBER), 7)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawArc(ring, -self._angle * 16, -86 * 16)
else:
pen = QPen(QColor(theme.TEAL if self.isEnabled() else theme.DIM), 3)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
for index in range(4):
painter.drawArc(ring, (index * 90 + 14) * 16, 54 * 16)
core = QRectF(center.x() - 57, center.y() - 57, 114, 114)
gradient = QLinearGradient(core.topLeft(), core.bottomRight())
if self.isEnabled():
gradient.setColorAt(0, QColor("#FFD37A" if self._hover else "#F6C35B"))
gradient.setColorAt(1, QColor("#E7A635"))
else:
gradient.setColorAt(0, QColor("#3A3A30"))
gradient.setColorAt(1, QColor("#2A2A22"))
painter.setBrush(gradient)
painter.setPen(QPen(QColor(255, 227, 166, 130 if self.isEnabled() else 30), 1))
painter.drawEllipse(core)
icon_rect = QRectF(center.x() - 21, center.y() - 34, 42, 42)
painter.setPen(QPen(QColor("#2A1A04" if self.isEnabled() else "#686250"), 1.7))
painter.setBrush(Qt.BrushStyle.NoBrush)
shield = QPainterPath()
shield.moveTo(icon_rect.center().x(), icon_rect.top())
shield.lineTo(icon_rect.right(), icon_rect.top() + icon_rect.height() * 0.16)
shield.lineTo(icon_rect.right(), icon_rect.top() + icon_rect.height() * 0.48)
shield.cubicTo(icon_rect.right(), icon_rect.top() + icon_rect.height() * 0.82,
icon_rect.center().x() + icon_rect.width() * 0.18, icon_rect.bottom(),
icon_rect.center().x(), icon_rect.bottom())
shield.cubicTo(icon_rect.center().x() - icon_rect.width() * 0.18, icon_rect.bottom(),
icon_rect.left(), icon_rect.top() + icon_rect.height() * 0.82,
icon_rect.left(), icon_rect.top() + icon_rect.height() * 0.48)
shield.lineTo(icon_rect.left(), icon_rect.top() + icon_rect.height() * 0.16)
shield.closeSubpath()
painter.drawPath(shield)
bolt_rect = QRectF(center.x() - 16, icon_rect.top() + 7, 32, 30)
painter.setBrush(QColor("#2A1A04" if self.isEnabled() else "#686250"))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPath(brand.bolt_path(bolt_rect))
painter.setPen(QColor("#241704" if self.isEnabled() else "#807A68"))
painter.setFont(theme.body_font(11.2, QFont.Weight.Bold))
painter.drawText(QRectF(core.left(), core.center().y() + 20, core.width(), 24),
Qt.AlignmentFlag.AlignCenter,
"Scanning…" if self._scanning else "Start scan")
class StatTile(QFrame):
def __init__(self, label: str, value: str = "—", note: str = "",
accent: str = theme.TEAL, parent=None) -> None:
super().__init__(parent)
self.setObjectName("Card")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 14)
layout.setSpacing(4)
top = QHBoxLayout()
top.setSpacing(7)
dot = SeverityDot("good", 7)
dot.setStyleSheet(f"background: transparent;")
top.addWidget(dot)
label_widget = QLabel(label)
label_widget.setObjectName("StatLabel")
top.addWidget(label_widget, 1)
layout.addLayout(top)
self.value_label = QLabel(value)
self.value_label.setObjectName("StatValue")
layout.addWidget(self.value_label)
self.note_label = QLabel(note)
self.note_label.setObjectName("Dim")
self.note_label.setWordWrap(True)
layout.addWidget(self.note_label)
self._dot = dot
self.set_accent(accent)
def set_accent(self, severity: str) -> None:
self._dot.set_severity(severity)
def set_value(self, value: str, note: str | None = None) -> None:
self.value_label.setText(value)
if note is not None:
self.note_label.setText(note)
class ConcernRow(QFrame):
action_requested = pyqtSignal(str)
_ACTION_LABELS = {
"settings": "Open settings",
"scan": "Review scan",
"update_definitions": "Update",
"quarantine": "Review",
"updates": "Check updates",
}
def __init__(self, concern: dict, parent=None) -> None:
super().__init__(parent)
self.setObjectName("ConcernRow")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._action = str(concern.get("action") or "none")
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 9, 2, 9)
layout.setSpacing(11)
dot = SeverityDot(str(concern.get("severity") or "info"), 9)
layout.addWidget(dot, 0, Qt.AlignmentFlag.AlignTop)
text_box = QVBoxLayout()
text_box.setContentsMargins(0, 0, 0, 0)
text_box.setSpacing(2)
title = QLabel(str(concern.get("title") or "Security note"))
title.setFont(theme.body_font(10.2, QFont.Weight.DemiBold))
title.setWordWrap(True)
detail = QLabel(str(concern.get("detail") or ""))
detail.setObjectName("Muted")
detail.setFont(theme.body_font(8.9))
detail.setWordWrap(True)
text_box.addWidget(title)
text_box.addWidget(detail)
layout.addLayout(text_box, 1)
label = self._ACTION_LABELS.get(self._action)
if label:
button = QPushButton(label)
button.setObjectName("Link")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda: self.action_requested.emit(self._action))
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignVCenter)
class PageHeader(QWidget):
def __init__(self, title: str, subtitle: str = "", parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(18)
text_box = QVBoxLayout()
text_box.setContentsMargins(0, 0, 0, 0)
text_box.setSpacing(4)
self.title_label = QLabel(title)
self.title_label.setObjectName("PageTitle")
self.subtitle_label = QLabel(subtitle)
self.subtitle_label.setObjectName("PageSubtitle")
self.subtitle_label.setWordWrap(True)
text_box.addWidget(self.title_label)
text_box.addWidget(self.subtitle_label)
layout.addLayout(text_box, 1)
self.actions = QHBoxLayout()
self.actions.setSpacing(8)
layout.addLayout(self.actions, 0)
def set_subtitle(self, text: str) -> None:
self.subtitle_label.setText(text)
def add_action(self, widget: QWidget) -> None:
self.actions.addWidget(widget, 0, Qt.AlignmentFlag.AlignVCenter)
class KeyValueRow(QWidget):
def __init__(self, label: str, value: str = "—", *, mono: bool = False,
parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 3, 0, 3)
layout.setSpacing(12)
self.label = QLabel(label)
self.label.setObjectName("Muted")
self.label.setFont(theme.body_font(8.9))
layout.addWidget(self.label, 0)
self.value_label = QLabel(value)
self.value_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.value_label.setWordWrap(True)
if mono:
self.value_label.setObjectName("Mono")
else:
self.value_label.setFont(theme.body_font(9.4, QFont.Weight.DemiBold))
layout.addWidget(self.value_label, 1)
def set_value(self, value: str) -> None:
self.value_label.setText(value or "—")
class EmptyState(QWidget):
action_clicked = pyqtSignal()
def __init__(self, title: str, body: str, action: str | None = None, parent=None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 30, 20, 30)
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label = QLabel(title)
title_label.setFont(theme.display_font(15, QFont.Weight.DemiBold))
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
body_label = QLabel(body)
body_label.setObjectName("Muted")
body_label.setWordWrap(True)
body_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
body_label.setMaximumWidth(420)
layout.addWidget(title_label)
layout.addWidget(body_label, 0, Qt.AlignmentFlag.AlignHCenter)
if action:
button = QPushButton(action)
button.setObjectName("Secondary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(self.action_clicked)
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignHCenter)
class MarkdownView(QTextBrowser):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setOpenExternalLinks(True)
self.setReadOnly(True)
self.setFrameShape(QFrame.Shape.NoFrame)
self.setFont(theme.body_font(9.4))
self.document().setDefaultStyleSheet(
f"h1,h2,h3{{font-family:'{theme.FONT_DISPLAY}';color:{theme.TEXT};}}"
f"a{{color:{theme.TEAL};}} code{{font-family:'{theme.FONT_MONO}';"
f"background:{theme.BG_DEEP};color:{theme.AMBER_BRIGHT};}}"
f"li{{margin-bottom:4px;}}"
)
def set_markdown(self, text: str) -> None:
self.setMarkdown(str(text or ""))
class ElidedLabel(QLabel):
def __init__(self, text: str = "", parent=None) -> None:
super().__init__(parent)
self._full_text = text
self.setToolTip(text)
self.setText(text)
def setText(self, text: str) -> None: # noqa: N802
self._full_text = str(text or "")
self.setToolTip(self._full_text)
self._elide()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._elide()
def _elide(self) -> None:
metrics = QFontMetrics(self.font())
available = max(20, self.width())
super().setText(metrics.elidedText(self._full_text, Qt.TextElideMode.ElideMiddle,
available))
class Toast(QFrame):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("Toast")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setFixedWidth(370)
layout = QHBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
self.dot = SeverityDot("info", 9)
layout.addWidget(self.dot, 0, Qt.AlignmentFlag.AlignTop)
text_box = QVBoxLayout()
text_box.setContentsMargins(0, 0, 0, 0)
text_box.setSpacing(2)
self.title_label = QLabel("")
self.title_label.setObjectName("ToastTitle")
self.body_label = QLabel("")
self.body_label.setObjectName("ToastBody")
self.body_label.setWordWrap(True)
text_box.addWidget(self.title_label)
text_box.addWidget(self.body_label)
layout.addLayout(text_box, 1)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
self.hide()
def show_message(self, title: str, body: str = "", severity: str = "info",
duration_ms: int = 7000) -> None:
self.title_label.setText(title)
self.body_label.setText(body)
self.dot.set_severity(severity)
self.adjustSize()
parent = self.parentWidget()
if parent is not None:
x = max(20, parent.width() - self.width() - 26)
y = max(20, parent.height() - self.height() - 26)
self.move(x, y)
self.raise_()
self.show()
self._timer.start(max(2500, duration_ms))
def mousePressEvent(self, event) -> None:
self.hide()
super().mousePressEvent(event)
class Divider(QFrame):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("Divider")
self.setFixedHeight(1)
+570
View File
@@ -0,0 +1,570 @@
"""Homepage: the shell gauge, security concerns, network and account summary."""
from __future__ import annotations
import logging
from pathlib import Path
from PyQt6.QtCore import QThread, Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..context import AppContext
from ..models import display_name, human_action
from ..system_info import collect
from . import components, theme
from .threads import stop_thread
logger = logging.getLogger(__name__)
class SystemInfoWorker(QThread):
finished_snapshot = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
def run(self) -> None:
try:
realtime_running = None
if self.context.realtime is not None:
realtime_running = self.context.realtime.running
snapshot = collect(
self.context.settings, self.context.clamav.info(), self.context.storage,
realtime_running=realtime_running, force=True)
self.finished_snapshot.emit(snapshot)
except Exception as exc:
logger.exception("System overview failed")
self.failed.emit(str(exc))
class SegmentChip(QFrame):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("SegmentChip")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(8)
self.dot = components.SeverityDot("unknown", 8)
layout.addWidget(self.dot, 0, Qt.AlignmentFlag.AlignTop)
text_box = QVBoxLayout()
text_box.setSpacing(1)
self.label = QLabel("—")
self.label.setFont(theme.body_font(8.8, QFont.Weight.DemiBold))
self.detail = QLabel("")
self.detail.setObjectName("Dim")
self.detail.setFont(theme.body_font(8.0))
self.detail.setWordWrap(True)
text_box.addWidget(self.label)
text_box.addWidget(self.detail)
layout.addLayout(text_box, 1)
def set_segment(self, segment: dict) -> None:
self.dot.set_severity(str(segment.get("state") or "unknown"))
self.label.setText(str(segment.get("label") or "—"))
self.detail.setText(str(segment.get("detail") or ""))
class ActivityRow(QWidget):
def __init__(self, severity: str, title: str, subtitle: str, when: str,
parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 7, 0, 7)
layout.setSpacing(10)
layout.addWidget(components.SeverityDot(severity, 8), 0, Qt.AlignmentFlag.AlignTop)
text_box = QVBoxLayout()
text_box.setSpacing(1)
title_label = QLabel(title)
title_label.setFont(theme.body_font(9.5, QFont.Weight.DemiBold))
title_label.setWordWrap(False)
subtitle_label = components.ElidedLabel(subtitle)
subtitle_label.setObjectName("Muted")
subtitle_label.setFont(theme.body_font(8.5))
text_box.addWidget(title_label)
text_box.addWidget(subtitle_label)
layout.addLayout(text_box, 1)
time_label = QLabel(when)
time_label.setObjectName("Dim")
time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
layout.addWidget(time_label, 0)
class HomePage(QWidget):
scan_requested = pyqtSignal()
settings_requested = pyqtSignal()
quarantine_requested = pyqtSignal()
updates_requested = pyqtSignal()
definitions_requested = pyqtSignal()
snapshot_changed = pyqtSignal(object)
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
self._worker: SystemInfoWorker | None = None
self._snapshot: dict | None = None
self._segments: list[SegmentChip] = []
self._build()
self.refresh()
# -- construction ------------------------------------------------------
def _build(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
outer.addWidget(scroll)
content = QWidget()
content.setObjectName("Content")
scroll.setWidget(content)
layout = QVBoxLayout(content)
layout.setContentsMargins(30, 24, 30, 30)
layout.setSpacing(16)
header = components.PageHeader(
"Homepage", "A live view of this PC's protection, network, and account.")
refresh_button = QPushButton("Refresh")
refresh_button.setObjectName("Secondary")
refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
refresh_button.clicked.connect(lambda: self.refresh(force=True))
header.add_action(refresh_button)
layout.addWidget(header)
self.update_banner = QFrame()
self.update_banner.setObjectName("Card")
self.update_banner.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.update_banner.setStyleSheet(
f"QFrame#Card {{ background: rgba(242,184,75,.08); border-color: rgba(242,184,75,.3); }}")
banner_layout = QHBoxLayout(self.update_banner)
banner_layout.setContentsMargins(16, 11, 16, 11)
banner_layout.setSpacing(12)
self.update_banner_text = QLabel("")
self.update_banner_text.setFont(theme.body_font(9.6, QFont.Weight.DemiBold))
banner_layout.addWidget(self.update_banner_text, 1)
update_button = QPushButton("View update")
update_button.setObjectName("Link")
update_button.clicked.connect(self.updates_requested)
banner_layout.addWidget(update_button)
dismiss = QPushButton("Dismiss")
dismiss.setObjectName("Ghost")
dismiss.clicked.connect(self.update_banner.hide)
banner_layout.addWidget(dismiss)
self.update_banner.hide()
layout.addWidget(self.update_banner)
layout.addWidget(self._build_hero())
layout.addLayout(self._build_stats())
middle = QHBoxLayout()
middle.setSpacing(16)
left = QVBoxLayout()
left.setSpacing(16)
left.addWidget(self._build_concerns())
left.addWidget(self._build_activity())
right = QVBoxLayout()
right.setSpacing(16)
right.addWidget(self._build_network())
right.addWidget(self._build_account())
right.addStretch(1)
middle.addLayout(left, 2)
middle.addLayout(right, 1)
layout.addLayout(middle, 1)
self.engine_footer = QLabel("")
self.engine_footer.setObjectName("Dim")
layout.addWidget(self.engine_footer)
def _build_hero(self) -> QWidget:
hero = QFrame()
hero.setObjectName("Hero")
hero.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(hero)
layout.setContentsMargins(26, 22, 26, 22)
layout.setSpacing(26)
self.dial = components.ProtectionDial()
layout.addWidget(self.dial, 0, Qt.AlignmentFlag.AlignVCenter)
text = QVBoxLayout()
text.setSpacing(8)
top = QHBoxLayout()
top.setSpacing(10)
self.status_pill = components.Pill("Checking", "muted")
top.addWidget(self.status_pill, 0)
self.checked_label = QLabel("Reading system status…")
self.checked_label.setObjectName("Dim")
top.addWidget(self.checked_label, 0)
top.addStretch(1)
text.addLayout(top)
self.hero_title = QLabel("Checking your protection")
self.hero_title.setObjectName("HeroTitle")
self.hero_title.setWordWrap(True)
text.addWidget(self.hero_title)
self.hero_summary = QLabel(
"BoltClam is reading the ClamAV engine, Windows security, and your last scan.")
self.hero_summary.setObjectName("HeroSummary")
self.hero_summary.setWordWrap(True)
self.hero_summary.setMaximumWidth(620)
text.addWidget(self.hero_summary)
segment_grid = QGridLayout()
segment_grid.setHorizontalSpacing(8)
segment_grid.setVerticalSpacing(7)
for index in range(4):
chip = SegmentChip()
self._segments.append(chip)
segment_grid.addWidget(chip, index // 2, index % 2)
text.addLayout(segment_grid)
actions = QHBoxLayout()
actions.setSpacing(9)
scan_button = QPushButton("Run a scan")
scan_button.setObjectName("Primary")
scan_button.setCursor(Qt.CursorShape.PointingHandCursor)
scan_button.clicked.connect(self.scan_requested)
actions.addWidget(scan_button)
settings_button = QPushButton("Open settings")
settings_button.setObjectName("Secondary")
settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
settings_button.clicked.connect(self.settings_requested)
actions.addWidget(settings_button)
actions.addStretch(1)
text.addLayout(actions)
layout.addLayout(text, 1)
return hero
def _build_stats(self) -> QHBoxLayout:
row = QHBoxLayout()
row.setSpacing(12)
self.stat_last_scan = components.StatTile("Last scan", "Never", "No scan recorded")
self.stat_threats = components.StatTile("Active threats", "0", "Nothing needs action")
self.stat_quarantine = components.StatTile("Quarantine", "0", "No files held")
self.stat_blocked = components.StatTile("Blocked downloads", "0", "Last 30 days")
for tile in (self.stat_last_scan, self.stat_threats,
self.stat_quarantine, self.stat_blocked):
tile.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
row.addWidget(tile, 1)
return row
def _build_concerns(self) -> QWidget:
card = components.Card()
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title_box = QVBoxLayout()
title_box.setSpacing(1)
title = QLabel("Security concerns")
title.setObjectName("CardTitle")
subtitle = QLabel("What needs attention on this PC")
subtitle.setObjectName("Muted")
subtitle.setFont(theme.body_font(8.8))
title_box.addWidget(title)
title_box.addWidget(subtitle)
header_layout.addLayout(title_box, 1)
self.concern_pill = components.Pill("Checking", "muted")
header_layout.addWidget(self.concern_pill, 0, Qt.AlignmentFlag.AlignTop)
card.body.addWidget(header)
self.concerns_layout = QVBoxLayout()
self.concerns_layout.setSpacing(0)
card.body.addLayout(self.concerns_layout)
self._concern_loading = QLabel("Reading Windows security status…")
self._concern_loading.setObjectName("Muted")
self.concerns_layout.addWidget(self._concern_loading)
return card
def _build_activity(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"Recent activity", "Detections and download decisions from this PC")[0])
self.activity_layout = QVBoxLayout()
self.activity_layout.setSpacing(0)
card.body.addLayout(self.activity_layout)
self._activity_loading = QLabel("Loading activity…")
self._activity_loading.setObjectName("Muted")
self.activity_layout.addWidget(self._activity_loading)
return card
def _build_network(self) -> QWidget:
card = components.Card()
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("Network")
title.setObjectName("CardTitle")
header_layout.addWidget(title, 1)
self.admin_pill = components.Pill("Checking", "muted")
header_layout.addWidget(self.admin_pill, 0)
card.body.addWidget(header)
self.network_rows = {}
for key, label in (
("hostname", "Hostname"), ("ip", "IPv4 address"), ("gateway", "Gateway"),
("adapter", "Adapter"), ("os", "Operating system"), ("uptime", "Uptime"),
):
row = components.KeyValueRow(label)
self.network_rows[key] = row
card.body.addWidget(row)
self.network_rows["ip"].value_label.setObjectName("Mono")
return card
def _build_account(self) -> QWidget:
card = components.Card()
header = QHBoxLayout()
header.setSpacing(11)
self.account_avatar = components.Avatar(self.context.user_name, 38)
header.addWidget(self.account_avatar, 0)
text_box = QVBoxLayout()
text_box.setSpacing(1)
self.account_name = QLabel(self.context.user_name)
self.account_name.setFont(theme.body_font(10.5, QFont.Weight.DemiBold))
self.account_email = components.ElidedLabel(self.context.user_email)
self.account_email.setObjectName("Muted")
self.account_email.setFont(theme.body_font(8.5))
text_box.addWidget(self.account_name)
text_box.addWidget(self.account_email)
header.addLayout(text_box, 1)
card.body.addLayout(header)
plan_row = QHBoxLayout()
plan_label = QLabel("Plan")
plan_label.setObjectName("Muted")
plan_label.setFont(theme.body_font(8.8))
plan_row.addWidget(plan_label, 0)
plan_row.addStretch(1)
self.plan_pill = components.Pill(self.context.plan_label, "info")
plan_row.addWidget(self.plan_pill, 0)
card.body.addLayout(plan_row)
self.subscription_label = QLabel("")
self.subscription_label.setObjectName("Dim")
self.subscription_label.setWordWrap(True)
card.body.addWidget(self.subscription_label)
manage = QLabel(
f'<a href="https://bolt.themajesticnetwork.com/account" '
f'style="color:{theme.TEAL}; text-decoration:none;">Manage Bolt account</a>')
manage.setOpenExternalLinks(True)
manage.setFont(theme.body_font(9.0, QFont.Weight.DemiBold))
card.body.addWidget(manage)
return card
# -- refresh -----------------------------------------------------------
def refresh(self, force: bool = False) -> None:
self._refresh_stats()
self._refresh_account()
self._refresh_activity()
if self._worker is not None and self._worker.isRunning():
return
worker = SystemInfoWorker(self.context, self)
worker.finished_snapshot.connect(self._apply_snapshot)
worker.failed.connect(self._system_failed)
worker.finished.connect(self._worker_finished)
worker.finished.connect(worker.deleteLater)
self._worker = worker
worker.start()
def _refresh_stats(self) -> None:
stats = self.context.storage.stats()
latest = stats.get("latest_scan")
if latest is None:
self.stat_last_scan.set_value("Never", "No scan recorded")
self.stat_last_scan.set_accent("muted")
else:
self.stat_last_scan.set_value(
theme.relative_time(latest.started_at),
f"{latest.scope_label} · {latest.status.replace('_', ' ')}")
self.stat_last_scan.set_accent("good" if latest.status == "completed" else "warning")
active = int(stats.get("active_threats") or 0)
self.stat_threats.set_value(str(active), "Needs quarantine or deletion" if active else
"Nothing needs action")
self.stat_threats.set_accent("critical" if active else "good")
quarantined = int(stats.get("quarantined") or 0)
self.stat_quarantine.set_value(str(quarantined),
"Review in Scan & results" if quarantined else "No files held")
self.stat_quarantine.set_accent("warning" if quarantined else "good")
blocked = int(stats.get("blocked_30d") or 0)
self.stat_blocked.set_value(str(blocked), "Last 30 days")
self.stat_blocked.set_accent("good" if blocked == 0 else "info")
def _refresh_account(self) -> None:
self.account_avatar.set_name(self.context.user_name)
self.account_name.setText(self.context.user_name)
self.account_email.setText(self.context.user_email or "Signed in with Bolt")
self.plan_pill.setText(self.context.plan_label)
self.plan_pill.set_severity("good" if self.context.licensed else "muted")
subscription = self.context.subscription
status = str(subscription.get("status") or "none")
if status in ("active", "trialing"):
period_end = theme.format_datetime(subscription.get("current_period_end"))
self.subscription_label.setText(
f"{subscription.get('label', 'Bolt')} · {status}"
+ (f" · renews {period_end}" if period_end != "—" else ""))
else:
self.subscription_label.setText("No active Bolt subscription.")
def _refresh_activity(self) -> None:
_clear_layout(self.activity_layout)
events: list[tuple[str, str, str, str]] = []
for detection in self.context.storage.recent_detections(5):
action = str(detection.get("action") or "detected")
severity = "critical" if action in ("detected", "failed") else (
"good" if action in ("quarantined", "deleted") else "info")
events.append((
str(detection.get("detected_at") or ""),
str(detection.get("signature") or "Detection"),
f"{display_name(str(detection.get('path') or ''))} · {human_action(action)}",
severity,
))
for event in self.context.storage.list_realtime_events(5):
severity = "critical" if event.action in ("detected", "failed") else (
"good" if event.action in ("quarantined", "deleted", "blocked") else "info")
title = event.signature or {
"quarantined": "Download quarantined",
"deleted": "Download deleted",
"detected": "Download needs attention",
"error": "Download scan failed",
"skipped": "Large download not scanned",
"allowed": "Download scanned",
}.get(event.action, "Download activity")
events.append((event.created_at, title,
f"{display_name(event.path)} · {human_action(event.action)}",
severity))
events.sort(key=lambda item: item[0], reverse=True)
if not events:
empty = QLabel("No activity yet. Scans and browser-download decisions show up here.")
empty.setObjectName("Muted")
empty.setWordWrap(True)
self.activity_layout.addWidget(empty)
return
for when, title, subtitle, severity in events[:6]:
self.activity_layout.addWidget(
ActivityRow(severity, title, subtitle, theme.relative_time(when)))
def _apply_snapshot(self, snapshot: dict) -> None:
self._snapshot = snapshot
self.dial.set_snapshot(snapshot)
overall = str(snapshot.get("overall") or "setup")
severity = {"protected": "good", "attention": "warning",
"at_risk": "critical", "setup": "info"}.get(overall, "muted")
label = {"protected": "Protected", "attention": "Needs attention",
"at_risk": "At risk", "setup": "Setup needed"}.get(overall, "Checking")
self.status_pill.setText(label)
self.status_pill.set_severity(severity)
self.hero_title.setText(str(snapshot.get("headline") or "Protection status"))
self.hero_summary.setText(str(snapshot.get("summary") or ""))
checked = theme.relative_time(snapshot.get("checked_at"))
self.checked_label.setText(f"Checked {checked}")
segments = snapshot.get("segments") or []
for index, chip in enumerate(self._segments):
chip.set_segment(segments[index] if index < len(segments) else {})
self._render_concerns(snapshot.get("concerns") or [])
self._render_network(snapshot)
clamav = snapshot.get("clamav") or {}
engine = str(clamav.get("summary") or "ClamAV not configured")
self.engine_footer.setText(
f"Engine: {engine} · Last checked {theme.relative_time(snapshot.get('checked_at'))}" )
self.snapshot_changed.emit(snapshot)
def _render_concerns(self, concerns: list[dict]) -> None:
_clear_layout(self.concerns_layout)
count = len(concerns)
critical = sum(1 for item in concerns if item.get("severity") == "critical")
warning = sum(1 for item in concerns if item.get("severity") == "warning")
if critical:
self.concern_pill.setText(f"{critical} critical")
self.concern_pill.set_severity("critical")
elif warning:
self.concern_pill.setText(f"{warning} to review")
self.concern_pill.set_severity("warning")
elif count:
self.concern_pill.setText(f"{count} note(s)")
self.concern_pill.set_severity("info")
else:
self.concern_pill.setText("All clear")
self.concern_pill.set_severity("good")
if not concerns:
row = QHBoxLayout()
row.setContentsMargins(2, 10, 2, 8)
row.addWidget(components.SeverityDot("good", 9), 0)
label = QLabel("Nothing needs your attention. Definitions are current and "
"no detected files are waiting for action.")
label.setObjectName("Muted")
label.setWordWrap(True)
row.addWidget(label, 1)
holder = QWidget()
holder.setLayout(row)
self.concerns_layout.addWidget(holder)
return
for concern in concerns:
row = components.ConcernRow(concern)
row.action_requested.connect(self._on_concern_action)
self.concerns_layout.addWidget(row)
def _render_network(self, snapshot: dict) -> None:
network = snapshot.get("network") or {}
adapters = network.get("adapters") or []
ips = network.get("local_ips") or []
self.network_rows["hostname"].set_value(str(snapshot.get("hostname") or "—"))
self.network_rows["ip"].set_value(", ".join(ips[:2]) if ips else "No IPv4 address")
self.network_rows["gateway"].set_value(str(network.get("gateway") or "Not reported"))
adapter_names = ", ".join(str(item.get("name") or "") for item in adapters[:2])
self.network_rows["adapter"].set_value(adapter_names or "Not reported")
self.network_rows["os"].set_value(str(snapshot.get("os") or "—"))
self.network_rows["uptime"].set_value(str(snapshot.get("uptime") or "Unknown"))
self.admin_pill.setText("Administrator" if snapshot.get("is_admin") else "Standard user")
self.admin_pill.set_severity("good" if snapshot.get("is_admin") else "muted")
def _on_concern_action(self, action: str) -> None:
if action == "settings":
self.settings_requested.emit()
elif action == "scan":
self.scan_requested.emit()
elif action == "quarantine":
self.quarantine_requested.emit()
elif action == "updates":
self.updates_requested.emit()
elif action == "update_definitions":
self.definitions_requested.emit()
def _system_failed(self, message: str) -> None:
self._concern_loading.setText(f"System status unavailable: {message}")
self.checked_label.setText("Some system details could not be read")
def _worker_finished(self) -> None:
self._worker = None
def refresh_activity(self) -> None:
self._refresh_stats()
self._refresh_activity()
def refresh_account(self) -> None:
self._refresh_account()
def show_update(self, release_name: str, tag: str) -> None:
self.update_banner_text.setText(f"{release_name or tag} is available. {tag}")
self.update_banner.show()
def hide_update(self) -> None:
self.update_banner.hide()
def stop(self) -> None:
stop_thread(self._worker, wait_ms=5000)
self._worker = None
def _clear_layout(layout) -> None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
child = item.layout()
if widget is not None:
widget.deleteLater()
elif child is not None:
_clear_layout(child)
+348
View File
@@ -0,0 +1,348 @@
"""BoltClam's sign-in dialog and its background auth workers."""
from __future__ import annotations
import logging
import threading
from PyQt6.QtCore import QThread, Qt, QUrl, pyqtSignal
from PyQt6.QtGui import QDesktopServices, QFont, QPixmap
from PyQt6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
)
from .. import paths
from ..auth import AuthCancelled, AuthError, AuthService, AuthTimeout, Session
from . import brand, theme
from .threads import stop_thread
logger = logging.getLogger(__name__)
class BrowserLoginWorker(QThread):
url_ready = pyqtSignal(str)
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, auth: AuthService, parent=None) -> None:
super().__init__(parent)
self.auth = auth
self._cancel = threading.Event()
def cancel(self) -> None:
self._cancel.set()
def run(self) -> None:
try:
session = self.auth.browser_login(
self._open_url, cancel_event=self._cancel)
self.succeeded.emit(session)
except AuthCancelled:
self.failed.emit("Sign-in was cancelled.")
except AuthTimeout as exc:
self.failed.emit(str(exc))
except AuthError as exc:
self.failed.emit(str(exc))
except Exception as exc:
logger.exception("Browser sign-in failed")
self.failed.emit(f"Sign-in failed: {exc}")
def _open_url(self, url: str) -> bool:
self.url_ready.emit(url)
return True
class KeyLoginWorker(QThread):
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, auth: AuthService, api_key: str, parent=None) -> None:
super().__init__(parent)
self.auth = auth
self.api_key = api_key
def run(self) -> None:
try:
session: Session = self.auth.login_with_key(self.api_key)
self.succeeded.emit(session)
except AuthError as exc:
self.failed.emit(str(exc))
except Exception as exc:
logger.exception("API-key sign-in failed")
self.failed.emit(f"Sign-in failed: {exc}")
class LoginDialog(QDialog):
def __init__(self, auth: AuthService, *, notice: str = "", parent=None) -> None:
super().__init__(parent)
self.auth = auth
self.session: Session | None = None
self._worker: QThread | None = None
self._notice = notice
self.setWindowTitle("BoltClam — Sign in")
self.setWindowIcon(brand.app_icon())
self.setModal(True)
self.setMinimumSize(830, 550)
self.resize(880, 570)
self._build()
self._start_state()
# -- construction ------------------------------------------------------
def _build(self) -> None:
root = QHBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
root.setSpacing(16)
left = QFrame()
left.setObjectName("LoginBrand")
left.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
left.setFixedWidth(332)
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(30, 30, 30, 26)
left_layout.setSpacing(12)
mark = QLabel()
mark.setPixmap(brand.pixmap(72))
mark.setFixedSize(72, 72)
left_layout.addWidget(mark)
brand_name = QLabel("BoltClam")
brand_name.setObjectName("BrandName")
brand_name.setFont(theme.display_font(21, QFont.Weight.Bold))
left_layout.addWidget(brand_name)
tagline = QLabel("ClamAV protection, without the noise.")
tagline.setObjectName("Muted")
tagline.setWordWrap(True)
left_layout.addWidget(tagline)
left_layout.addSpacing(24)
headline = QLabel("A calmer way to know your PC is clean.")
headline.setFont(theme.display_font(22, QFont.Weight.DemiBold))
headline.setWordWrap(True)
left_layout.addWidget(headline)
left_layout.addSpacing(8)
for title, detail in (
("ClamAV under the hood", "Full-disk and user-home scans with a managed signature database."),
("Downloads held for a scan", "New browser downloads are locked while ClamAV checks them."),
("Quarantine, not guesswork", "Every finding is recorded and reversible until you delete it."),
):
row = QHBoxLayout()
row.setSpacing(9)
dot = QLabel("•")
dot.setStyleSheet(f"color: {theme.AMBER}; font-size: 16px;")
dot.setFixedWidth(12)
row.addWidget(dot, 0, Qt.AlignmentFlag.AlignTop)
text_box = QVBoxLayout()
text_box.setSpacing(1)
label = QLabel(title)
label.setFont(theme.body_font(9.8, QFont.Weight.DemiBold))
desc = QLabel(detail)
desc.setObjectName("Muted")
desc.setFont(theme.body_font(8.7))
desc.setWordWrap(True)
text_box.addWidget(label)
text_box.addWidget(desc)
row.addLayout(text_box, 1)
left_layout.addLayout(row)
left_layout.addSpacing(4)
left_layout.addStretch(1)
footer = QLabel("The Majestic Network · Bolt")
footer.setObjectName("Dim")
left_layout.addWidget(footer)
root.addWidget(left)
right = QFrame()
right.setObjectName("LoginForm")
right.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(34, 32, 34, 26)
right_layout.setSpacing(9)
title = QLabel("Sign in to BoltClam")
title.setFont(theme.display_font(23, QFont.Weight.DemiBold))
right_layout.addWidget(title)
subtitle = QLabel("Use your Bolt account. Your password is sent only to Bolt's API — "
"BoltClam receives a device key instead.")
subtitle.setObjectName("Muted")
subtitle.setWordWrap(True)
right_layout.addWidget(subtitle)
right_layout.addSpacing(14)
if self._notice:
notice = QLabel(self._notice)
notice.setWordWrap(True)
notice.setStyleSheet(
f"background: rgba(242,184,75,.1); border: 1px solid rgba(242,184,75,.35);"
f"border-radius: 9px; padding: 9px 11px; color: {theme.AMBER_BRIGHT};")
right_layout.addWidget(notice)
self.error_label = QLabel("")
self.error_label.setWordWrap(True)
self.error_label.setStyleSheet(f"color: {theme.DANGER}; font-size: 12.5px;")
self.error_label.hide()
right_layout.addWidget(self.error_label)
self.browser_button = QPushButton("Continue in browser")
self.browser_button.setObjectName("Primary")
self.browser_button.setMinimumHeight(42)
self.browser_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.browser_button.clicked.connect(self._browser_login)
right_layout.addWidget(self.browser_button)
self.key_toggle = QPushButton("Use an API key instead")
self.key_toggle.setObjectName("Link")
self.key_toggle.setCursor(Qt.CursorShape.PointingHandCursor)
self.key_toggle.clicked.connect(self._toggle_key)
right_layout.addWidget(self.key_toggle, 0, Qt.AlignmentFlag.AlignHCenter)
self.key_box = QWidget()
key_layout = QVBoxLayout(self.key_box)
key_layout.setContentsMargins(0, 4, 0, 0)
key_layout.setSpacing(7)
key_label = QLabel("Bolt API key")
key_label.setObjectName("Muted")
key_layout.addWidget(key_label)
self.key_input = QLineEdit()
self.key_input.setPlaceholderText("bolt_live_…")
self.key_input.setEchoMode(QLineEdit.EchoMode.Password)
self.key_input.returnPressed.connect(self._key_login)
key_layout.addWidget(self.key_input)
self.key_button = QPushButton("Connect with key")
self.key_button.setObjectName("Secondary")
self.key_button.clicked.connect(self._key_login)
key_layout.addWidget(self.key_button)
self.key_box.hide()
right_layout.addWidget(self.key_box)
status_row = QHBoxLayout()
status_row.setSpacing(9)
self.busy = QProgressBar()
self.busy.setRange(0, 0)
self.busy.setFixedHeight(5)
self.busy.setFixedWidth(58)
self.busy.hide()
self.status_label = QLabel("")
self.status_label.setObjectName("Muted")
self.status_label.setWordWrap(True)
status_row.addWidget(self.busy, 0)
status_row.addWidget(self.status_label, 1)
right_layout.addLayout(status_row)
right_layout.addStretch(1)
actions = QHBoxLayout()
create = QLabel(
f'<a href="https://bolt.themajesticnetwork.com/account" '
f'style="color:{theme.TEAL}; text-decoration:none;">Create or manage your account</a>')
create.setOpenExternalLinks(True)
create.setObjectName("Dim")
actions.addWidget(create, 1)
quit_button = QPushButton("Quit")
quit_button.setObjectName("Ghost")
quit_button.clicked.connect(self.reject)
actions.addWidget(quit_button)
right_layout.addLayout(actions)
root.addWidget(right, 1)
# -- state -------------------------------------------------------------
def _start_state(self) -> None:
self._set_busy(False)
self.status_label.setText("BoltClam opens Bolt's sign-in page in your default browser.")
self.browser_button.setFocus()
def _set_busy(self, busy: bool, status: str = "") -> None:
self.browser_button.setEnabled(not busy)
self.key_toggle.setEnabled(not busy)
self.key_button.setEnabled(not busy)
self.key_input.setEnabled(not busy)
if busy:
self.busy.show()
self.status_label.setText(status)
else:
self.busy.hide()
self.error_label.setVisible(bool(self.error_label.text()) and not busy)
def _show_error(self, message: str) -> None:
self.error_label.setText(message)
self.error_label.show()
self._set_busy(False)
self.status_label.setText("You can try again, or use an API key.")
self.browser_button.setFocus()
def _toggle_key(self) -> None:
visible = not self.key_box.isVisible()
self.key_box.setVisible(visible)
if visible:
self.key_input.setFocus()
self.key_toggle.setText("Use browser sign-in instead")
else:
self.key_toggle.setText("Use an API key instead")
# -- actions -----------------------------------------------------------
def _browser_login(self) -> None:
if self._worker is not None and self._worker.isRunning():
return
self.error_label.hide()
self._set_busy(True, "Waiting for you to finish sign-in in the browser…")
worker = BrowserLoginWorker(self.auth, self)
worker.url_ready.connect(self._open_browser)
worker.succeeded.connect(self._on_success)
worker.failed.connect(self._on_failure)
worker.finished.connect(worker.deleteLater)
self._worker = worker
worker.start()
def _open_browser(self, url: str) -> None:
if not QDesktopServices.openUrl(QUrl(url)):
if isinstance(self._worker, BrowserLoginWorker):
self._worker.cancel()
self._show_error("BoltClam could not open your default browser. "
"Use an API key below, or open the sign-in page manually.")
def _key_login(self) -> None:
if self._worker is not None and self._worker.isRunning():
return
key = self.key_input.text().strip()
if not key:
self._show_error("Enter your Bolt API key.")
return
self.error_label.hide()
self._set_busy(True, "Checking your API key…")
worker = KeyLoginWorker(self.auth, key, self)
worker.succeeded.connect(self._on_success)
worker.failed.connect(self._on_failure)
worker.finished.connect(worker.deleteLater)
self._worker = worker
worker.start()
def _on_success(self, session: Session) -> None:
self.session = session
self._worker = None
self._set_busy(False)
self.accept()
def _on_failure(self, message: str) -> None:
self._worker = None
self._show_error(message)
# -- lifecycle ---------------------------------------------------------
def reject(self) -> None:
worker = self._worker
if worker is not None and worker.isRunning():
if isinstance(worker, BrowserLoginWorker):
worker.cancel()
stop_thread(worker, wait_ms=2500)
super().reject()
def closeEvent(self, event) -> None:
self.reject()
event.accept()
+512
View File
@@ -0,0 +1,512 @@
"""Main window: navigation rail, page stack, tray, and service lifecycle."""
from __future__ import annotations
import dataclasses
import logging
from pathlib import Path
from PyQt6.QtCore import QSize, Qt, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QAction, QCloseEvent, QFont, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QApplication,
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMenu,
QPushButton,
QStackedWidget,
QSystemTrayIcon,
QVBoxLayout,
QWidget,
)
from .. import __version__
from ..api_client import ApiError, NetworkError
from ..context import AppContext
from ..realtime import RealtimeProtection
from . import brand, components, theme
from .threads import stop_thread
from .about_page import AboutPage
from .home_page import HomePage
from .scan_page import ScanPage
from .settings_page import SettingsPage
logger = logging.getLogger(__name__)
class AccountRefreshWorker(QThread):
succeeded = pyqtSignal(object)
expired = pyqtSignal()
failed = pyqtSignal(str)
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
def run(self) -> None:
try:
api = self.context.api or self.context.rebuild_api()
account = api.get_account()
self.succeeded.emit(account)
except ApiError as exc:
if exc.status == 401:
self.expired.emit()
else:
self.failed.emit(str(exc))
except Exception as exc:
logger.exception("Account refresh failed")
self.failed.emit(str(exc))
class LogoutWorker(QThread):
done = pyqtSignal()
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
def run(self) -> None:
try:
self.context.auth.logout(self.context.session_key)
except Exception:
logger.exception("Logout request failed")
self.context.auth.credentials.delete()
self.context.auth.account_cache.clear()
finally:
self.done.emit()
class MainWindow(QWidget):
finished = pyqtSignal()
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
self._allow_close = False
self._cleaned = False
self._logged_out = False
self._account_worker: AccountRefreshWorker | None = None
self._logout_worker: LogoutWorker | None = None
self._last_update_tag = ""
self._tray_notice_shown = False
self._applied_settings = dataclasses.replace(context.settings)
self.setWindowTitle("BoltClam")
self.setWindowIcon(brand.app_icon())
self.setMinimumSize(1180, 780)
self.resize(1380, 880)
self._build()
self._build_tray()
self._start_realtime()
self._install_shortcuts()
QTimer.singleShot(700, self._refresh_account)
# -- construction ------------------------------------------------------
def _build(self) -> None:
root_layout = QHBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
root_layout.addWidget(self._build_sidebar())
self.stack = QStackedWidget()
self.stack.setObjectName("Content")
self.home_page = HomePage(self.context)
self.scan_page = ScanPage(self.context)
self.about_page = AboutPage(self.context)
self.settings_page = SettingsPage(self.context)
for page in (self.home_page, self.scan_page, self.about_page, self.settings_page):
self.stack.addWidget(page)
root_layout.addWidget(self.stack, 1)
self.toast = components.Toast(self)
self._connect_pages()
self._navigate(0)
def _build_sidebar(self) -> QWidget:
sidebar = QFrame()
sidebar.setObjectName("Sidebar")
sidebar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
sidebar.setFixedWidth(238)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(16, 20, 16, 16)
layout.setSpacing(9)
brand_row = QHBoxLayout()
brand_row.setSpacing(10)
mark = QLabel()
mark.setPixmap(brand.pixmap(40))
mark.setFixedSize(40, 40)
brand_row.addWidget(mark, 0)
brand_text = QVBoxLayout()
brand_text.setSpacing(0)
name = QLabel("BoltClam")
name.setObjectName("BrandName")
tag = QLabel("The Majestic Network")
tag.setObjectName("BrandTag")
brand_text.addWidget(name)
brand_text.addWidget(tag)
brand_row.addLayout(brand_text, 1)
layout.addLayout(brand_row)
layout.addSpacing(18)
self.nav_group = QButtonGroup(self)
self.nav_group.setExclusive(True)
self.nav_buttons = []
for index, (icon, label) in enumerate((
("home", "Homepage"),
("scan", "Scan & results"),
("updates", "About & updates"),
("settings", "Settings"))):
button = components.NavButton(icon, label)
self.nav_group.addButton(button, index)
self.nav_buttons.append(button)
layout.addWidget(button)
self.nav_group.idClicked.connect(self._navigate)
layout.addStretch(1)
status_card = components.Card(margins=(13, 11, 13, 11), spacing=4)
status_row = QHBoxLayout()
status_row.setSpacing(7)
self.side_pill = components.Pill("Starting", "muted")
status_row.addWidget(self.side_pill, 0)
status_row.addStretch(1)
status_card.body.addLayout(status_row)
self.side_detail = QLabel("Reading protection status…")
self.side_detail.setObjectName("Muted")
self.side_detail.setFont(theme.body_font(8.6))
self.side_detail.setWordWrap(True)
status_card.body.addWidget(self.side_detail)
self.side_realtime = QLabel("Real-time watcher starting")
self.side_realtime.setObjectName("Dim")
self.side_realtime.setFont(theme.body_font(8.2))
self.side_realtime.setWordWrap(True)
status_card.body.addWidget(self.side_realtime)
layout.addWidget(status_card)
layout.addSpacing(6)
account_card = components.Card(margins=(13, 11, 13, 11), spacing=6)
account_row = QHBoxLayout()
account_row.setSpacing(9)
self.side_avatar = components.Avatar(self.context.user_name, 34)
account_row.addWidget(self.side_avatar, 0)
account_text = QVBoxLayout()
account_text.setSpacing(0)
self.side_name = components.ElidedLabel(self.context.user_name)
self.side_name.setFont(theme.body_font(9.6, QFont.Weight.DemiBold))
self.side_email = components.ElidedLabel(self.context.user_email or "Bolt account")
self.side_email.setObjectName("Muted")
self.side_email.setFont(theme.body_font(8.0))
account_text.addWidget(self.side_name)
account_text.addWidget(self.side_email)
account_row.addLayout(account_text, 1)
account_card.body.addLayout(account_row)
self.side_plan = components.Pill(self.context.plan_label, "info")
account_card.body.addWidget(self.side_plan, 0, Qt.AlignmentFlag.AlignLeft)
layout.addWidget(account_card)
version = QLabel(f"v{__version__}")
version.setObjectName("Dim")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version)
return sidebar
def _connect_pages(self) -> None:
home = self.home_page
home.scan_requested.connect(self._scan_from_home)
home.settings_requested.connect(lambda: self._navigate(3))
home.quarantine_requested.connect(self._show_quarantine)
home.updates_requested.connect(lambda: self._navigate(2))
home.definitions_requested.connect(self._update_definitions)
home.snapshot_changed.connect(self._home_snapshot)
scan = self.scan_page
scan.scan_finished.connect(self._scan_finished)
scan.settings_requested.connect(lambda: self._navigate(3))
scan.toast_requested.connect(self._toast)
scan.definitions_updated.connect(lambda: self.home_page.refresh(force=True))
about = self.about_page
about.toast_requested.connect(self._toast)
about.update_found.connect(self._update_found)
settings = self.settings_page
settings.toast_requested.connect(self._toast)
settings.settings_saved.connect(self._settings_saved)
settings.sign_out_requested.connect(self._logout)
settings.definitions_requested.connect(self._update_definitions)
def _build_tray(self) -> None:
self.tray: QSystemTrayIcon | None = None
self._tray_menu: QMenu | None = None
if not QSystemTrayIcon.isSystemTrayAvailable():
return
tray = QSystemTrayIcon(brand.app_icon(), self)
tray.setToolTip("BoltClam — ClamAV protection")
menu = QMenu(self)
open_action = QAction("Open BoltClam", self)
open_action.triggered.connect(self._show_window)
scan_action = QAction("Run a scan", self)
scan_action.triggered.connect(self._scan_from_tray)
quit_action = QAction("Quit", self)
quit_action.triggered.connect(self._quit)
menu.addAction(open_action)
menu.addAction(scan_action)
menu.addSeparator()
menu.addAction(quit_action)
tray.setContextMenu(menu)
tray.activated.connect(self._tray_activated)
tray.messageClicked.connect(self._show_window)
tray.show()
self.tray = tray
self._tray_menu = menu
def _install_shortcuts(self) -> None:
for index in range(4):
shortcut = QShortcut(QKeySequence(f"Ctrl+{index + 1}"), self)
shortcut.activated.connect(lambda index=index: self._navigate(index))
quit_shortcut = QShortcut(QKeySequence("Ctrl+Q"), self)
quit_shortcut.activated.connect(self._quit)
scan_shortcut = QShortcut(QKeySequence("Ctrl+R"), self)
scan_shortcut.activated.connect(self._scan_from_home)
# -- real-time ---------------------------------------------------------
def _start_realtime(self) -> None:
realtime = RealtimeProtection(
self.context.clamav, self.context.storage, self.context.quarantine,
self.context.settings, self)
self.context.realtime = realtime
realtime.status_changed.connect(self._realtime_status)
realtime.event_created.connect(self._realtime_event)
realtime.alert.connect(self._realtime_alert)
realtime.start()
def _restart_realtime(self) -> None:
realtime = self.context.realtime
if realtime is None:
return
realtime.set_settings(self.context.settings)
realtime.stop()
realtime.start()
self.settings_page.refresh_account()
def _realtime_status(self, text: str) -> None:
self.side_realtime.setText(text)
def _realtime_event(self, event) -> None:
self.home_page.refresh_activity()
self.scan_page.refresh_quarantine()
def _realtime_alert(self, title: str, body: str, severity: str) -> None:
if not self.context.settings.notify_on_detection:
return
self._notify(title, body, severity)
# -- navigation --------------------------------------------------------
def _navigate(self, index: int) -> None:
index = max(0, min(3, int(index)))
self.stack.setCurrentIndex(index)
if 0 <= index < len(self.nav_buttons):
self.nav_buttons[index].setChecked(True)
if index == 0:
self.home_page.refresh()
elif index == 1:
self.scan_page.refresh()
elif index == 2:
self.about_page.refresh()
def _show_quarantine(self) -> None:
self._navigate(1)
self.scan_page.tabs.setCurrentIndex(1)
self.scan_page.refresh_quarantine()
def _scan_from_home(self) -> None:
self._navigate(1)
QTimer.singleShot(120, self.scan_page.start_scan)
def _scan_from_tray(self) -> None:
self._show_window()
self._scan_from_home()
def _update_definitions(self) -> None:
self._navigate(1)
QTimer.singleShot(120, self.scan_page.update_definitions)
# -- events ------------------------------------------------------------
def _home_snapshot(self, snapshot: dict) -> None:
overall = str(snapshot.get("overall") or "setup")
severity = {"protected": "good", "attention": "warning",
"at_risk": "critical", "setup": "info"}.get(overall, "muted")
label = {"protected": "Protected", "attention": "Needs attention",
"at_risk": "At risk", "setup": "Setup needed"}.get(overall, "Checking")
self.side_pill.setText(label)
self.side_pill.set_severity(severity)
concerns = snapshot.get("concerns") or []
self.side_detail.setText(
str(concerns[0].get("title")) if concerns else "No action needed")
def _scan_finished(self) -> None:
self.home_page.refresh(force=True)
self.settings_page.refresh_account()
def _update_found(self, release) -> None:
if release is None:
self.home_page.hide_update()
self._last_update_tag = ""
return
self.home_page.show_update(release.name, release.tag)
if release.tag and release.tag != self._last_update_tag:
self._last_update_tag = release.tag
self._notify("BoltClam update available",
f"{release.name or release.tag} is ready in About & updates.",
"info")
def _refresh_account(self) -> None:
if self._account_worker is not None and self._account_worker.isRunning():
return
worker = AccountRefreshWorker(self.context, self)
worker.succeeded.connect(self._account_succeeded)
worker.expired.connect(self._account_expired)
worker.failed.connect(self._account_failed)
worker.finished.connect(self._account_finished)
worker.finished.connect(worker.deleteLater)
self._account_worker = worker
worker.start()
def _account_succeeded(self, account: dict) -> None:
self.context.account = account
self.context.offline = False
try:
self.context.auth.account_cache.save(account)
except Exception:
logger.exception("Could not cache the account summary")
self.home_page.refresh_account()
self.settings_page.refresh_account()
self.about_page.refresh_account()
self._update_sidebar_account()
def _account_failed(self, message: str) -> None:
if self.context.account is None:
self._toast("Account details unavailable", message, "warning")
logger.info("Account refresh failed: %s", message)
def _account_expired(self) -> None:
self._toast("Your BoltClam session expired", "Sign in again to continue.", "critical")
self._logout(expired=True)
def _account_finished(self) -> None:
self._account_worker = None
def _update_sidebar_account(self) -> None:
self.side_avatar.set_name(self.context.user_name)
self.side_name.setText(self.context.user_name)
self.side_email.setText(self.context.user_email or "Bolt account")
self.side_plan.setText(self.context.plan_label)
self.side_plan.set_severity("good" if self.context.licensed else "muted")
def _settings_saved(self, settings) -> None:
previous = self._applied_settings
realtime_fields = ("realtime_enabled", "extra_watch_dirs", "clamav_path",
"auto_action")
realtime_changed = any(getattr(previous, field) != getattr(settings, field)
for field in realtime_fields)
self._applied_settings = dataclasses.replace(settings)
self.scan_page.reload_settings()
self.about_page.reload_settings()
self.home_page.refresh(force=True)
self.settings_page.reload_settings()
self.settings_page.refresh_account()
if realtime_changed:
QTimer.singleShot(0, self._restart_realtime)
def _logout(self, expired: bool = False) -> None:
if self._logout_worker is not None and self._logout_worker.isRunning():
return
if not expired:
self._toast("Signing out", "Revoking this PC's BoltClam key…", "info")
worker = LogoutWorker(self.context, self)
worker.done.connect(self._logout_done)
worker.finished.connect(worker.deleteLater)
self._logout_worker = worker
worker.start()
def _logout_done(self) -> None:
self._logout_worker = None
self._logged_out = True
self._allow_close = True
self.close()
def _toast(self, title: str, body: str, severity: str = "info") -> None:
self.toast.show_message(title, body, severity)
if severity in ("critical", "error") and self.tray is not None:
self._notify(title, body, severity)
def _notify(self, title: str, body: str, severity: str = "info") -> None:
if self.tray is not None and self.tray.isVisible():
icon = QSystemTrayIcon.MessageIcon.Warning
if severity in ("good", "info"):
icon = QSystemTrayIcon.MessageIcon.Information
elif severity in ("critical", "error"):
icon = QSystemTrayIcon.MessageIcon.Critical
self.tray.showMessage(title, body, icon, 7000)
if not self.isVisible():
QApplication.alert(self, 2500)
elif severity in ("critical", "error"):
QApplication.alert(self, 2000)
def _tray_activated(self, reason) -> None:
if reason in (QSystemTrayIcon.ActivationReason.Trigger,
QSystemTrayIcon.ActivationReason.DoubleClick):
self._show_window()
def _show_window(self) -> None:
self.showNormal()
self.raise_()
self.activateWindow()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
if hasattr(self, "toast") and self.toast.isVisible():
self.toast.move(max(20, self.width() - self.toast.width() - 26),
max(20, self.height() - self.toast.height() - 26))
# -- lifecycle ---------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None:
if not self._allow_close and self.context.settings.close_to_tray and self.tray is not None:
self.hide()
if not self._tray_notice_shown:
self._tray_notice_shown = True
self.tray.showMessage(
"BoltClam is still protecting this PC",
"Real-time download scanning stays active in the tray. Use Quit to stop it.",
QSystemTrayIcon.MessageIcon.Information, 6000)
event.ignore()
return
self._cleanup()
event.accept()
def _cleanup(self) -> None:
if self._cleaned:
return
self._cleaned = True
try:
if self.context.realtime is not None:
self.context.realtime.stop()
self.home_page.stop()
self.scan_page.stop()
self.about_page.stop()
self.settings_page.stop()
stop_thread(self._account_worker, wait_ms=3500)
stop_thread(self._logout_worker, wait_ms=3500)
if self.tray is not None:
self.tray.hide()
finally:
self.finished.emit()
def _quit(self) -> None:
self._allow_close = True
self.close()
def logged_out(self) -> bool:
return self._logged_out
+912
View File
@@ -0,0 +1,912 @@
"""Scan & results: scope choice, live scan console, history and quarantine."""
from __future__ import annotations
import logging
from pathlib import Path
from PyQt6.QtCore import QSignalBlocker, QSize, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QFont
from PyQt6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QComboBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QProgressBar,
QPushButton,
QRadioButton,
QSizePolicy,
QSplitter,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ..context import AppContext
from ..models import Detection, display_name, human_action, human_scope
from ..quarantine import QuarantineError, delete_file
from ..scanner import DefinitionWorker, ScanWorker
from . import components, theme
from .threads import stop_thread
logger = logging.getLogger(__name__)
class ScanHistoryItem(QWidget):
def __init__(self, scan, parent=None) -> None:
super().__init__(parent)
self.setMinimumHeight(64)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 7, 10, 7)
layout.setSpacing(3)
top = QHBoxLayout()
top.setSpacing(8)
when = QLabel(theme.format_datetime(scan.started_at))
when.setFont(theme.body_font(9.3, QFont.Weight.DemiBold))
top.addWidget(when, 1)
status = components.Pill(scan.status.replace("_", " ").title(),
"good" if scan.status == "completed" else
"warning" if scan.status == "completed_with_errors" else
"critical" if scan.status == "failed" else "muted")
top.addWidget(status, 0)
layout.addLayout(top)
bottom = QHBoxLayout()
bottom.setSpacing(8)
scope = QLabel(human_scope(scan.scope))
scope.setObjectName("Muted")
scope.setFont(theme.body_font(8.5))
bottom.addWidget(scope)
bottom.addStretch(1)
threats = QLabel(f"{scan.threat_count} threat(s)" if scan.threat_count else "Clean")
threats.setFont(theme.body_font(8.5, QFont.Weight.DemiBold))
threats.setStyleSheet(f"color: {theme.DANGER if scan.threat_count else theme.TEAL};")
bottom.addWidget(threats)
layout.addLayout(bottom)
def sizeHint(self):
return QSize(260, 68)
class PillsTab(QWidget):
"""Two pill-shaped tabs over a stack, styled entirely by BoltClam."""
def __init__(self, parent=None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._buttons: list[QPushButton] = []
self._group = QButtonGroup(self)
self._group.setExclusive(True)
bar = QHBoxLayout()
bar.setContentsMargins(0, 0, 0, 0)
bar.setSpacing(4)
self._bar = bar
self._bar_holder = QWidget()
self._bar_holder.setLayout(bar)
layout.addWidget(self._bar_holder)
layout.addSpacing(7)
layout.addWidget(components.Divider())
self._stack = QStackedWidget()
layout.addWidget(self._stack, 1)
self._group.idClicked.connect(self.setCurrentIndex)
self._bar.addStretch(1)
def add_widget(self, widget: QWidget, label: str) -> None:
index = self._stack.count()
button = QPushButton(label)
button.setObjectName("TabPill")
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
self._group.addButton(button, index)
self._buttons.append(button)
self._bar.insertWidget(self._bar.count() - 1, button)
self._stack.addWidget(widget)
if index == 0:
button.setChecked(True)
def setCurrentIndex(self, index: int) -> None: # noqa: N802
index = max(0, min(self._stack.count() - 1, int(index)))
self._stack.setCurrentIndex(index)
if 0 <= index < len(self._buttons):
self._buttons[index].setChecked(True)
def currentIndex(self) -> int: # noqa: N802
return self._stack.currentIndex()
class ScanPage(QWidget):
scan_finished = pyqtSignal()
settings_requested = pyqtSignal()
toast_requested = pyqtSignal(str, str, str)
definitions_updated = pyqtSignal()
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
self._worker: ScanWorker | None = None
self._definition_worker: DefinitionWorker | None = None
self._current_scan_id: int | None = None
self._build()
self.reload_settings()
self.refresh()
# -- construction ------------------------------------------------------
def _build(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(30, 24, 30, 30)
outer.setSpacing(15)
header = components.PageHeader(
"Scan & results", "Scan every user's home or the full disk, then act on what ClamAV finds.")
self.definitions_button = QPushButton("Update definitions")
self.definitions_button.setObjectName("Secondary")
self.definitions_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.definitions_button.clicked.connect(self.update_definitions)
header.add_action(self.definitions_button)
outer.addWidget(header)
top = QHBoxLayout()
top.setSpacing(15)
top.addWidget(self._build_console(), 2)
top.addWidget(self._build_scope_card(), 1)
outer.addLayout(top)
self.tabs = PillsTab()
self.tabs.add_widget(self._build_results_tab(), "Results")
self.tabs.add_widget(self._build_quarantine_tab(), "Quarantine")
outer.addWidget(self.tabs, 1)
def _build_console(self) -> QWidget:
card = QFrame()
card.setObjectName("ScanConsole")
card.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(card)
layout.setContentsMargins(22, 20, 22, 20)
layout.setSpacing(22)
self.scan_button = components.ScanButton()
self.scan_button.clicked.connect(self.start_scan)
layout.addWidget(self.scan_button, 0, Qt.AlignmentFlag.AlignVCenter)
right = QVBoxLayout()
right.setSpacing(6)
self.scan_state = QLabel("Ready to scan")
self.scan_state.setFont(theme.display_font(20, QFont.Weight.DemiBold))
right.addWidget(self.scan_state)
self.scan_detail = QLabel(
"Choose a scope on the right. User-home scans cover every profile; full-disk "
"scans cover every fixed drive.")
self.scan_detail.setObjectName("Muted")
self.scan_detail.setWordWrap(True)
right.addWidget(self.scan_detail)
self.progress = QProgressBar()
self.progress.setRange(0, 0)
self.progress.setFixedHeight(6)
self.progress.hide()
right.addWidget(self.progress)
self.current_file = components.ElidedLabel("")
self.current_file.setObjectName("PathLabel")
self.current_file.setFont(theme.mono_font(8.7))
self.current_file.hide()
right.addWidget(self.current_file)
self.last_scan_label = QLabel("")
self.last_scan_label.setObjectName("Dim")
right.addWidget(self.last_scan_label)
self.engine_line = QLabel("")
self.engine_line.setObjectName("Muted")
self.engine_line.setFont(theme.body_font(8.7))
self.engine_line.setWordWrap(True)
right.addWidget(self.engine_line)
right.addStretch(1)
right.addWidget(components.Divider())
actions = QHBoxLayout()
actions.setSpacing(8)
self.start_button = QPushButton("Start scan")
self.start_button.setObjectName("Primary")
self.start_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.start_button.clicked.connect(self.start_scan)
actions.addWidget(self.start_button)
self.cancel_button = QPushButton("Cancel scan")
self.cancel_button.setObjectName("Danger")
self.cancel_button.clicked.connect(self.cancel_scan)
self.cancel_button.hide()
actions.addWidget(self.cancel_button)
actions.addStretch(1)
self.scan_scope_label = QLabel("")
self.scan_scope_label.setObjectName("Dim")
actions.addWidget(self.scan_scope_label)
right.addLayout(actions)
layout.addLayout(right, 1)
return card
def _build_scope_card(self) -> QWidget:
card = components.Card(margins=(16, 16, 16, 16))
title = QLabel("Scan scope")
title.setObjectName("CardTitle")
card.body.addWidget(title)
subtitle = QLabel("What BoltClam hands to ClamAV.")
subtitle.setObjectName("Muted")
subtitle.setFont(theme.body_font(8.7))
card.body.addWidget(subtitle)
self.homes_radio = components.ChoiceTile(
"User homes", "Every profile under C:\\Users, including the current user and Public.")
self.full_radio = components.ChoiceTile(
"Full disk", "Every fixed local drive. More complete, and potentially hours long.")
card.body.addWidget(self.homes_radio)
card.body.addWidget(self.full_radio)
self.homes_radio.toggled.connect(self._scope_changed)
card.body.addSpacing(4)
auto_label = QLabel("When something is found")
auto_label.setObjectName("Muted")
auto_label.setFont(theme.body_font(8.8, QFont.Weight.DemiBold))
card.body.addWidget(auto_label)
self.auto_combo = QComboBox()
self.auto_combo.addItem("Quarantine (recommended)", "quarantine")
self.auto_combo.addItem("Delete permanently", "delete")
self.auto_combo.addItem("Notify only", "notify")
self.auto_combo.currentIndexChanged.connect(self._auto_action_changed)
card.body.addWidget(self.auto_combo)
self.auto_note = QLabel("")
self.auto_note.setObjectName("Dim")
self.auto_note.setWordWrap(True)
card.body.addWidget(self.auto_note)
card.body.addStretch(1)
return card
def _build_results_tab(self) -> QWidget:
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False)
left = components.Card(margins=(12, 12, 12, 12))
header = QLabel("Scan history")
header.setObjectName("CardTitle")
left.body.addWidget(header)
self.history = QListWidget()
self.history.setSpacing(2)
self.history.currentItemChanged.connect(self._history_selected)
left.body.addWidget(self.history, 1)
splitter.addWidget(left)
right = components.Card(margins=(16, 14, 16, 14))
self.detail_stack = QStackedWidget()
self.detail_empty = components.EmptyState(
"No scan selected",
"Run a scan or choose one from the history to see detections and remediation.",
"Run a scan")
self.detail_empty.action_clicked.connect(self.start_scan)
self.detail_stack.addWidget(self.detail_empty)
details = QWidget()
details_layout = QVBoxLayout(details)
details_layout.setContentsMargins(0, 0, 0, 0)
details_layout.setSpacing(9)
top = QHBoxLayout()
self.detail_title = QLabel("Scan details")
self.detail_title.setObjectName("CardTitle")
top.addWidget(self.detail_title, 1)
self.detail_status = components.Pill("—", "muted")
top.addWidget(self.detail_status, 0)
details_layout.addLayout(top)
self.detail_meta = QLabel("")
self.detail_meta.setObjectName("Muted")
self.detail_meta.setWordWrap(True)
details_layout.addWidget(self.detail_meta)
self.detail_summary = QLabel("")
self.detail_summary.setObjectName("Dim")
details_layout.addWidget(self.detail_summary)
self.detection_table = QTableWidget(0, 3)
self.detection_table.setHorizontalHeaderLabels(["File", "Signature", "Action"])
self.detection_table.verticalHeader().setVisible(False)
self.detection_table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows)
self.detection_table.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection)
self.detection_table.setEditTriggers(
QAbstractItemView.EditTrigger.NoEditTriggers)
self.detection_table.setShowGrid(False)
self.detection_table.setAlternatingRowColors(False)
self.detection_table.verticalHeader().setDefaultSectionSize(36)
header_view = self.detection_table.horizontalHeader()
header_view.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header_view.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
header_view.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.detection_table.itemSelectionChanged.connect(self._update_detection_buttons)
details_layout.addWidget(self.detection_table, 1)
buttons = QHBoxLayout()
buttons.setSpacing(8)
self.quarantine_button = QPushButton("Quarantine selected")
self.quarantine_button.setObjectName("Secondary")
self.quarantine_button.clicked.connect(self.quarantine_selected)
self.delete_button = QPushButton("Delete selected")
self.delete_button.setObjectName("Danger")
self.delete_button.clicked.connect(self.delete_selected)
self.ignore_button = QPushButton("Ignore")
self.ignore_button.setObjectName("Ghost")
self.ignore_button.clicked.connect(self.ignore_selected)
buttons.addWidget(self.quarantine_button)
buttons.addWidget(self.delete_button)
buttons.addWidget(self.ignore_button)
buttons.addStretch(1)
details_layout.addLayout(buttons)
self.detail_stack.addWidget(details)
right.body.addWidget(self.detail_stack, 1)
splitter.addWidget(right)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 3)
splitter.setSizes([300, 760])
return splitter
def _build_quarantine_tab(self) -> QWidget:
card = components.Card(margins=(16, 14, 16, 14))
top = QHBoxLayout()
title = QLabel("Quarantine")
title.setObjectName("CardTitle")
top.addWidget(title, 1)
open_button = QPushButton("Open folder")
open_button.setObjectName("Ghost")
open_button.clicked.connect(self.open_quarantine_folder)
top.addWidget(open_button)
card.body.addLayout(top)
subtitle = QLabel("Held files are renamed so they cannot be opened normally. "
"Restore only if you trust the file.")
subtitle.setObjectName("Muted")
subtitle.setWordWrap(True)
card.body.addWidget(subtitle)
self.quarantine_stack = QStackedWidget()
empty = components.EmptyState(
"Quarantine is empty",
"Files that BoltClam quarantines will appear here, with restore and delete options.")
self.quarantine_stack.addWidget(empty)
table_holder = QWidget()
holder_layout = QVBoxLayout(table_holder)
holder_layout.setContentsMargins(0, 0, 0, 0)
holder_layout.setSpacing(8)
self.quarantine_table = QTableWidget(0, 4)
self.quarantine_table.setHorizontalHeaderLabels(
["Original file", "Signature", "Quarantined", "Status"])
self.quarantine_table.verticalHeader().setVisible(False)
self.quarantine_table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows)
self.quarantine_table.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection)
self.quarantine_table.setEditTriggers(
QAbstractItemView.EditTrigger.NoEditTriggers)
self.quarantine_table.setShowGrid(False)
self.quarantine_table.verticalHeader().setDefaultSectionSize(36)
qheader = self.quarantine_table.horizontalHeader()
qheader.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
qheader.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
qheader.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
qheader.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.quarantine_table.itemSelectionChanged.connect(self._update_quarantine_buttons)
holder_layout.addWidget(self.quarantine_table, 1)
buttons = QHBoxLayout()
self.restore_button = QPushButton("Restore selected")
self.restore_button.setObjectName("Secondary")
self.restore_button.clicked.connect(self.restore_selected)
self.purge_button = QPushButton("Delete permanently")
self.purge_button.setObjectName("Danger")
self.purge_button.clicked.connect(self.delete_quarantined_selected)
buttons.addWidget(self.restore_button)
buttons.addWidget(self.purge_button)
buttons.addStretch(1)
holder_layout.addLayout(buttons)
self.quarantine_stack.addWidget(table_holder)
card.body.addWidget(self.quarantine_stack, 1)
return card
# -- settings ----------------------------------------------------------
def reload_settings(self) -> None:
settings = self.context.settings
with QSignalBlocker(self.homes_radio), QSignalBlocker(self.full_radio), \
QSignalBlocker(self.auto_combo):
(self.full_radio if settings.scan_scope == "full" else self.homes_radio).setChecked(True)
index = self.auto_combo.findData(settings.auto_action)
self.auto_combo.setCurrentIndex(index if index >= 0 else 0)
self._update_auto_note(settings.auto_action)
self._update_scope_note()
def _scope_changed(self, checked: bool) -> None:
if not checked:
return
scope = "full" if self.full_radio.isChecked() else "homes"
try:
self.context.settings = self.context.settings_store.update(scan_scope=scope)
if self.context.realtime is not None:
self.context.realtime.set_settings(self.context.settings)
except Exception:
logger.exception("Could not save scan scope")
self._update_scope_note()
def _auto_action_changed(self) -> None:
value = self.auto_combo.currentData() or "quarantine"
try:
self.context.settings = self.context.settings_store.update(auto_action=value)
if self.context.realtime is not None:
self.context.realtime.set_settings(self.context.settings)
except Exception:
logger.exception("Could not save auto action")
self._update_auto_note(value)
def _update_auto_note(self, action: str) -> None:
if action == "delete":
self.auto_note.setText("Delete removes the original immediately. Quarantine is safer.")
self.auto_note.setStyleSheet(f"color: {theme.DANGER};")
elif action == "notify":
self.auto_note.setText("Notify leaves the file in place and marks it for review.")
self.auto_note.setStyleSheet(f"color: {theme.AMBER};")
else:
self.auto_note.setText("Files are moved to quarantine and can be restored from the Quarantine tab.")
self.auto_note.setStyleSheet(f"color: {theme.DIM};")
def _update_scope_note(self) -> None:
if not hasattr(self, "scan_scope_label"):
return
scope = "full" if self.full_radio.isChecked() else "homes"
self.scan_scope_label.setText(f"Scope: {human_scope(scope)}")
self._refresh_engine_line()
def _refresh_engine_line(self) -> None:
if not hasattr(self, "engine_line"):
return
info = self.context.clamav.info()
if not info.available:
self.engine_line.setText("ClamAV is not configured. Set its folder in Settings.")
self.engine_line.setStyleSheet(f"color: {theme.AMBER};")
elif info.db_path:
self.engine_line.setText(
f"Engine {info.engine_version or 'ClamAV'} · signatures "
f"{info.db_version or 'unknown'} · definitions {info.db_date or 'unknown'}")
self.engine_line.setStyleSheet(f"color: {theme.MUTED};")
else:
self.engine_line.setText(
f"Engine {info.engine_version or 'ClamAV'} · no signature database yet — "
"run Update definitions.")
self.engine_line.setStyleSheet(f"color: {theme.AMBER};")
# -- scan lifecycle ----------------------------------------------------
def start_scan(self) -> None:
if self._worker is not None and self._worker.isRunning():
return
if not self.context.clamav.info().available:
answer = QMessageBox.question(
self, "ClamAV is not configured",
"BoltClam needs ClamAV's clamscan.exe before it can scan.\n\nOpen Settings now?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes)
if answer == QMessageBox.StandardButton.Yes:
self.settings_requested.emit()
return
scope = "full" if self.full_radio.isChecked() else "homes"
try:
self.context.settings = self.context.settings_store.update(scan_scope=scope)
except Exception:
logger.exception("Could not save scan scope")
self._set_scanning(True, scope)
worker = ScanWorker(self.context.clamav, self.context.storage,
self.context.quarantine, self.context.settings, scope, self)
worker.started_scan.connect(self._on_scan_started)
worker.progress.connect(self._on_progress)
worker.detection_found.connect(self._on_detection)
worker.log_line.connect(self._on_log)
worker.status.connect(self._on_status)
worker.finished_scan.connect(self._on_finished)
worker.failed.connect(self._on_failed)
worker.finished.connect(self._worker_finished)
worker.finished.connect(worker.deleteLater)
self._worker = worker
worker.start()
def cancel_scan(self) -> None:
if self._worker is not None and self._worker.isRunning():
self._worker.cancel()
self.scan_state.setText("Stopping scan…")
self.cancel_button.setEnabled(False)
def _set_scanning(self, scanning: bool, scope: str | None = None) -> None:
self.scan_button.set_scanning(scanning)
self.scan_button.setEnabled(not scanning)
self.start_button.setEnabled(not scanning)
self.cancel_button.setVisible(scanning)
self.cancel_button.setEnabled(scanning)
self.homes_radio.setEnabled(not scanning)
self.full_radio.setEnabled(not scanning)
self.auto_combo.setEnabled(not scanning)
self.definitions_button.setEnabled(not scanning)
if scanning:
self.progress.show()
self.current_file.show()
self.scan_state.setText("Scanning")
self.scan_detail.setText("ClamAV is reading files. You can keep using BoltClam.")
if scope:
self.scan_scope_label.setText(f"Scope: {human_scope(scope)}")
else:
self.progress.hide()
self.current_file.hide()
self.scan_state.setText("Ready to scan")
self.scan_detail.setText(
"Choose a scope on the right. User-home scans cover every profile; "
"full-disk scans cover every fixed drive.")
def _on_scan_started(self, scan_id: int, targets: list) -> None:
self._current_scan_id = scan_id
self.scan_detail.setText(f"Scanning {len(targets)} root(s) · scan #{scan_id}")
self.current_file.setText("Waiting for the first file…")
def _on_progress(self, path: str, detections: int) -> None:
if path:
self.current_file.setText(path)
if detections:
self.scan_detail.setText(
f"{detections} detection(s) so far. Remediation follows your "
f"'{self.context.settings.auto_action}' setting.")
def _on_detection(self, detection: Detection) -> None:
self.scan_detail.setText(
f"Found {detection.signature} in {display_name(detection.path)} — "
f"{human_action(detection.action)}")
def _on_log(self, message: str) -> None:
if message:
self.current_file.setText(message[:300])
def _on_status(self, text: str) -> None:
if self._worker is not None and self._worker.isRunning():
self.scan_detail.setText(text)
def _on_finished(self, record) -> None:
self._set_scanning(False)
self.refresh_history(select_id=record.id)
if record.infected_files:
self.toast_requested.emit(
"Scan finished with detections",
f"{record.infected_files} threat(s) in {record.scanned_files:,} files. Review the results.",
"critical")
else:
self.toast_requested.emit(
"Scan finished",
f"{record.scanned_files:,} files scanned. No threats found.",
"good")
self.last_scan_label.setText(
f"Last scan {theme.relative_time(record.started_at)} · "
f"{record.scanned_files:,} files · {record.threat_count} threat(s)")
self.scan_finished.emit()
def _on_failed(self, message: str) -> None:
self._set_scanning(False)
self.scan_detail.setText(message)
self.toast_requested.emit("Scan could not finish", message, "critical")
def _worker_finished(self) -> None:
self._worker = None
# -- history / details -------------------------------------------------
def refresh(self) -> None:
self.refresh_history()
self.refresh_quarantine()
latest = self.context.storage.latest_scan()
if latest:
self.last_scan_label.setText(
f"Last scan {theme.relative_time(latest.started_at)} · "
f"{latest.scanned_files:,} files · {latest.threat_count} threat(s)")
else:
self.last_scan_label.setText("No scan has been run yet.")
self._refresh_engine_line()
def refresh_history(self, select_id: int | None = None) -> None:
scans = self.context.storage.list_scans(100)
current = select_id
if current is None and self._current_scan_id is not None:
current = self._current_scan_id
blocker = QSignalBlocker(self.history)
self.history.clear()
target_row = -1
for row, scan in enumerate(scans):
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, scan.id)
widget = ScanHistoryItem(scan)
item.setSizeHint(QSize(260, 72))
self.history.addItem(item)
self.history.setItemWidget(item, widget)
if current is not None and scan.id == current:
target_row = row
del blocker
if target_row >= 0:
self.history.setCurrentRow(target_row)
elif scans and select_id is None:
self.history.setCurrentRow(0)
elif not scans:
self._show_detail_empty()
self._current_scan_id = None
def _history_selected(self, current: QListWidgetItem | None, previous=None) -> None:
if current is None:
return
scan_id = current.data(Qt.ItemDataRole.UserRole)
record = self.context.storage.get_scan(int(scan_id)) if scan_id is not None else None
if record is None:
self._show_detail_empty()
return
self._current_scan_id = record.id
self.detail_stack.setCurrentIndex(1)
self.detail_title.setText(f"Scan #{record.id}")
severity = "good" if record.status == "completed" else (
"warning" if record.status == "completed_with_errors" else
"critical" if record.status == "failed" else "muted")
self.detail_status.setText(record.status.replace("_", " ").title())
self.detail_status.set_severity(severity)
self.detail_meta.setText(
f"{theme.format_datetime(record.started_at)} · {record.scope_label} · "
f"{len(record.paths)} root(s)")
self.detail_summary.setText(
f"{record.scanned_files:,} files scanned · {record.threat_count} threat(s) · "
f"{record.errors} error(s) · {theme.format_duration(record.duration_seconds)}")
self._fill_detections(record.detections)
def _fill_detections(self, detections: list[Detection]) -> None:
self.detection_table.setRowCount(0)
for detection in detections:
row = self.detection_table.rowCount()
self.detection_table.insertRow(row)
path_item = QTableWidgetItem(display_name(detection.path))
path_item.setToolTip(detection.path)
path_item.setData(Qt.ItemDataRole.UserRole, detection)
self.detection_table.setItem(row, 0, path_item)
signature_item = QTableWidgetItem(detection.signature)
signature_item.setToolTip(detection.signature)
self.detection_table.setItem(row, 1, signature_item)
action_item = QTableWidgetItem(human_action(detection.action))
action_item.setForeground(QColor(theme.status_color(detection.action)))
self.detection_table.setItem(row, 2, action_item)
self.detection_table.clearSelection()
self._update_detection_buttons()
def _show_detail_empty(self) -> None:
self.detection_table.setRowCount(0)
self.detail_stack.setCurrentIndex(0)
def _selected_detections(self) -> list[Detection]:
rows = self.detection_table.selectionModel().selectedRows()
result: list[Detection] = []
for index in rows:
item = self.detection_table.item(index.row(), 0)
detection = item.data(Qt.ItemDataRole.UserRole) if item else None
if isinstance(detection, Detection):
result.append(detection)
return result
def _update_detection_buttons(self) -> None:
selections = self._selected_detections()
actionable = [item for item in selections
if item.action not in ("quarantined", "deleted", "ignored")]
self.quarantine_button.setEnabled(bool(actionable))
self.delete_button.setEnabled(bool(actionable))
self.ignore_button.setEnabled(bool(actionable))
# -- detection actions -------------------------------------------------
def _record_detection_action(self, detection: Detection, action: str,
quarantine_id: str | None = None) -> None:
if detection.id is None:
return
try:
self.context.storage.update_detection_action(detection.id, action, quarantine_id)
except Exception:
logger.exception("Could not update detection action")
detection.action = action
if quarantine_id:
detection.quarantine_id = quarantine_id
def quarantine_selected(self) -> None:
selections = [item for item in self._selected_detections()
if item.action not in ("quarantined", "deleted", "ignored")]
if not selections:
return
failures = 0
for detection in selections:
try:
item = self.context.quarantine.quarantine_file(
Path(detection.path), detection.signature,
scan_id=self._current_scan_id, reason="Manual from scan results")
self._record_detection_action(detection, "quarantined", item.id)
except (QuarantineError, OSError) as exc:
failures += 1
self._record_detection_action(detection, "failed")
logger.warning("Manual quarantine failed for %s: %s", detection.path, exc)
self._history_selected(self.history.currentItem())
self.refresh_quarantine()
self.refresh_history(select_id=self._current_scan_id)
self.toast_requested.emit(
"Quarantine complete" if not failures else "Some files could not be quarantined",
f"{len(selections) - failures} quarantined, {failures} failed.",
"good" if not failures else "critical")
def delete_selected(self) -> None:
selections = [item for item in self._selected_detections()
if item.action not in ("quarantined", "deleted", "ignored")]
if not selections:
return
answer = QMessageBox.warning(
self, "Delete detected files?",
f"Permanently delete {len(selections)} detected file(s)?\n\n"
"This cannot be undone. Quarantine is reversible and is the safer choice.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer != QMessageBox.StandardButton.Yes:
return
failures = 0
for detection in selections:
try:
delete_file(Path(detection.path))
self._record_detection_action(detection, "deleted")
except (QuarantineError, OSError) as exc:
failures += 1
self._record_detection_action(detection, "failed")
logger.warning("Manual delete failed for %s: %s", detection.path, exc)
self.refresh_history(select_id=self._current_scan_id)
self.toast_requested.emit(
"Files deleted" if not failures else "Some files could not be deleted",
f"{len(selections) - failures} deleted, {failures} failed.",
"good" if not failures else "critical")
def ignore_selected(self) -> None:
selections = self._selected_detections()
for detection in selections:
self._record_detection_action(detection, "ignored")
self.refresh_history(select_id=self._current_scan_id)
self.toast_requested.emit("Detection ignored",
f"{len(selections)} detection(s) marked ignored.", "warning")
# -- quarantine --------------------------------------------------------
def refresh_quarantine(self) -> None:
items = self.context.storage.list_quarantine(status="quarantined")
self.quarantine_table.setRowCount(0)
for item in items:
row = self.quarantine_table.rowCount()
self.quarantine_table.insertRow(row)
original = QTableWidgetItem(display_name(item.original_path))
original.setToolTip(item.original_path)
original.setData(Qt.ItemDataRole.UserRole, item.id)
self.quarantine_table.setItem(row, 0, original)
self.quarantine_table.setItem(row, 1, QTableWidgetItem(item.signature))
self.quarantine_table.setItem(row, 2, QTableWidgetItem(
theme.format_datetime(item.created_at)))
status_item = QTableWidgetItem(item.status.title())
status_item.setForeground(QColor(theme.TEAL))
self.quarantine_table.setItem(row, 3, status_item)
self.quarantine_stack.setCurrentIndex(1 if items else 0)
self.quarantine_table.clearSelection()
self._update_quarantine_buttons()
def _selected_quarantine(self) -> list[str]:
rows = self.quarantine_table.selectionModel().selectedRows()
result = []
for index in rows:
item = self.quarantine_table.item(index.row(), 0)
if item is not None and item.data(Qt.ItemDataRole.UserRole):
result.append(str(item.data(Qt.ItemDataRole.UserRole)))
return result
def _update_quarantine_buttons(self) -> None:
selected = bool(self._selected_quarantine())
self.restore_button.setEnabled(selected)
self.purge_button.setEnabled(selected)
def restore_selected(self) -> None:
ids = self._selected_quarantine()
if not ids:
return
answer = QMessageBox.warning(
self, "Restore quarantined files?",
f"Restore {len(ids)} file(s) to their original locations?\n\n"
"Only restore files you trust. A detected file can be malicious.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer != QMessageBox.StandardButton.Yes:
return
failures = 0
for item_id in ids:
try:
self.context.quarantine.restore(item_id)
except QuarantineError as exc:
failures += 1
logger.warning("Restore failed for %s: %s", item_id, exc)
self.refresh_quarantine()
self.toast_requested.emit(
"Files restored" if not failures else "Some files could not be restored",
f"{len(ids) - failures} restored, {failures} failed.",
"good" if not failures else "critical")
def delete_quarantined_selected(self) -> None:
ids = self._selected_quarantine()
if not ids:
return
answer = QMessageBox.warning(
self, "Delete quarantined files?",
f"Permanently delete {len(ids)} quarantined file(s)? This cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer != QMessageBox.StandardButton.Yes:
return
failures = 0
for item_id in ids:
try:
self.context.quarantine.delete(item_id)
except QuarantineError as exc:
failures += 1
logger.warning("Quarantine delete failed for %s: %s", item_id, exc)
self.refresh_quarantine()
self.toast_requested.emit(
"Quarantined files deleted" if not failures else "Some files could not be deleted",
f"{len(ids) - failures} deleted, {failures} failed.",
"good" if not failures else "critical")
def open_quarantine_folder(self) -> None:
if not self.context.quarantine.open_folder():
self.toast_requested.emit("Could not open folder",
"The quarantine folder could not be opened.", "warning")
# -- definitions -------------------------------------------------------
def update_definitions(self) -> None:
if self._definition_worker is not None and self._definition_worker.isRunning():
return
if self._worker is not None and self._worker.isRunning():
return
self.definitions_button.setEnabled(False)
self.start_button.setEnabled(False)
self.progress.show()
self.progress.setRange(0, 0)
self.current_file.show()
self.current_file.setText("Starting freshclam…")
self.scan_state.setText("Updating definitions")
self.scan_detail.setText("BoltClam is downloading the latest ClamAV signatures.")
worker = DefinitionWorker(self.context.clamav, self)
worker.log_line.connect(self._on_log)
worker.finished_ok.connect(self._definitions_ok)
worker.failed.connect(self._definitions_failed)
worker.finished.connect(self._definition_finished)
worker.finished.connect(worker.deleteLater)
self._definition_worker = worker
worker.start()
def _definitions_ok(self, message: str) -> None:
self.context.clamav.invalidate()
self._refresh_engine_line()
self.scan_state.setText("Definitions updated")
self.scan_detail.setText(message)
self.toast_requested.emit("Definitions updated", message, "good")
self.definitions_updated.emit()
def _definitions_failed(self, message: str) -> None:
self.scan_state.setText("Definition update failed")
self.scan_detail.setText(message)
self.toast_requested.emit("Definition update failed", message, "critical")
def _definition_finished(self) -> None:
self._definition_worker = None
self.definitions_button.setEnabled(True)
self.start_button.setEnabled(True)
self.progress.hide()
self.current_file.hide()
self.progress.setRange(0, 0)
def stop(self) -> None:
if self._worker is not None and self._worker.isRunning():
self._worker.cancel()
stop_thread(self._worker, wait_ms=5000)
if self._definition_worker is not None and self._definition_worker.isRunning():
self._definition_worker.cancel()
stop_thread(self._definition_worker, wait_ms=4000)
self._worker = None
self._definition_worker = None
+651
View File
@@ -0,0 +1,651 @@
"""Settings: every persisted option, validation, logging and log upload."""
from __future__ import annotations
import dataclasses
import logging
import os
import time
from pathlib import Path
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from .. import paths
from ..clamav import ClamAVService, find_clamscan, parse_extra_options
from ..config import AppSettings
from ..context import AppContext
from ..logging_setup import configure as configure_logging
from ..logs import DiagnosticsError, build_bundle, upload_bundle
from ..system_info import collect
from . import components, theme
from .threads import stop_thread
logger = logging.getLogger(__name__)
class DiagnosticsWorker(QThread):
succeeded = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self, context: AppContext, settings: AppSettings, parent=None) -> None:
super().__init__(parent)
self.context = context
self.settings = settings
def run(self) -> None:
try:
snapshot = collect(
self.settings, self.context.clamav.info(), self.context.storage,
realtime_running=(self.context.realtime.running
if self.context.realtime else None),
force=True)
bundle = build_bundle(
self.settings, self.context.clamav.info(),
system_snapshot=snapshot, account=self.context.account,
realtime_roots=(self.context.realtime.watched_roots
if self.context.realtime else []))
api = self.context.api or self.context.rebuild_api()
result = upload_bundle(api, bundle, self.settings)
self.succeeded.emit(result)
except DiagnosticsError as exc:
self.failed.emit(str(exc))
except Exception as exc:
logger.exception("Diagnostics upload failed")
self.failed.emit(str(exc))
class SettingsPage(QWidget):
settings_saved = pyqtSignal(object)
sign_out_requested = pyqtSignal()
toast_requested = pyqtSignal(str, str, str)
definitions_requested = pyqtSignal()
def __init__(self, context: AppContext, parent=None) -> None:
super().__init__(parent)
self.context = context
self._loading = False
self._dirty = False
self._diagnostics_worker: DiagnosticsWorker | None = None
self._build()
self.reload_settings()
# -- construction ------------------------------------------------------
def _build(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
outer.addWidget(scroll, 1)
content = QWidget()
content.setObjectName("Content")
scroll.setWidget(content)
self.content_layout = QVBoxLayout(content)
self.content_layout.setContentsMargins(30, 24, 30, 22)
self.content_layout.setSpacing(15)
header = components.PageHeader(
"Settings", "Engine, protection, logging and account preferences.")
self.content_layout.addWidget(header)
self.content_layout.addWidget(self._build_update_section())
self.content_layout.addWidget(self._build_clamav_section())
self.content_layout.addWidget(self._build_protection_section())
self.content_layout.addWidget(self._build_scan_options())
self.content_layout.addWidget(self._build_logging_section())
self.content_layout.addWidget(self._build_account_section())
self.content_layout.addStretch(1)
outer.addWidget(self._build_save_bar())
def _build_update_section(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"Updates", "Release receives stable builds. Beta includes prereleases.")[0])
row = QHBoxLayout()
label = QLabel("Update channel")
label.setObjectName("Muted")
row.addWidget(label, 1)
self.channel_combo = QComboBox()
self.channel_combo.addItem("Release", "release")
self.channel_combo.addItem("Beta (includes prereleases)", "beta")
self.channel_combo.setMinimumWidth(250)
self.channel_combo.currentIndexChanged.connect(self._mark_dirty)
row.addWidget(self.channel_combo, 0)
card.body.addLayout(row)
return card
def _build_clamav_section(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"ClamAV engine", "BoltClam calls clamscan.exe from this location.")[0])
row = QHBoxLayout()
row.setSpacing(8)
self.clamav_path = QLineEdit()
self.clamav_path.setPlaceholderText(r"C:\Program Files\ClamAV")
self.clamav_path.textChanged.connect(self._mark_dirty)
row.addWidget(self.clamav_path, 1)
browse = QPushButton("Browse")
browse.setObjectName("Secondary")
browse.clicked.connect(self.browse_clamav)
row.addWidget(browse)
detect = QPushButton("Auto-detect")
detect.setObjectName("Ghost")
detect.clicked.connect(self.detect_clamav)
row.addWidget(detect)
card.body.addLayout(row)
status_row = QHBoxLayout()
self.clamav_status = QLabel("Not checked")
self.clamav_status.setObjectName("Muted")
self.clamav_status.setWordWrap(True)
status_row.addWidget(self.clamav_status, 1)
verify = QPushButton("Verify")
verify.setObjectName("Secondary")
verify.clicked.connect(self.verify_clamav)
status_row.addWidget(verify, 0)
card.body.addLayout(status_row)
divider = components.Divider()
card.body.addWidget(divider)
defs_row = QHBoxLayout()
self.definitions_status = QLabel("")
self.definitions_status.setObjectName("Muted")
self.definitions_status.setWordWrap(True)
defs_row.addWidget(self.definitions_status, 1)
update_defs = QPushButton("Update definitions")
update_defs.setObjectName("Secondary")
update_defs.clicked.connect(self.definitions_requested)
defs_row.addWidget(update_defs, 0)
card.body.addLayout(defs_row)
note = QLabel(
"BoltClam keeps freshclam signatures in its own app-data folder so updates do not "
"need administrator access. ClamAV itself is installed separately.")
note.setObjectName("Dim")
note.setWordWrap(True)
card.body.addWidget(note)
return card
def _build_protection_section(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"Protection", "Real-time browser downloads and automatic remediation.")[0])
self.realtime_checkbox = QCheckBox("Watch browser downloads and scan new files")
self.realtime_checkbox.toggled.connect(self._realtime_toggled)
card.body.addWidget(self.realtime_checkbox)
self.notify_checkbox = QCheckBox("Show a notification when a download is blocked")
self.notify_checkbox.toggled.connect(self._mark_dirty)
card.body.addWidget(self.notify_checkbox)
self.watched_label = QLabel("")
self.watched_label.setObjectName("Dim")
self.watched_label.setWordWrap(True)
card.body.addWidget(self.watched_label)
row = QHBoxLayout()
label = QLabel("When ClamAV finds something")
label.setObjectName("Muted")
row.addWidget(label, 1)
self.auto_combo = QComboBox()
self.auto_combo.addItem("Quarantine (recommended)", "quarantine")
self.auto_combo.addItem("Delete permanently", "delete")
self.auto_combo.addItem("Notify only", "notify")
self.auto_combo.setMinimumWidth(240)
self.auto_combo.currentIndexChanged.connect(self._auto_changed)
row.addWidget(self.auto_combo, 0)
card.body.addLayout(row)
self.auto_note = QLabel("")
self.auto_note.setObjectName("Dim")
self.auto_note.setWordWrap(True)
card.body.addWidget(self.auto_note)
dirs_label = QLabel("Additional download folders")
dirs_label.setObjectName("Muted")
card.body.addWidget(dirs_label)
self.extra_dirs = QLineEdit()
self.extra_dirs.setPlaceholderText("Optional; separate multiple paths with ;")
self.extra_dirs.textChanged.connect(self._mark_dirty)
card.body.addWidget(self.extra_dirs)
note = QLabel(
"BoltClam reads each browser's configured download folder (Chrome, Edge, Brave, "
"Firefox, Vivaldi, Opera) plus every user's Downloads folder. The watcher waits "
"for a download to settle, holds the file exclusively while ClamAV scans a copy, "
"then applies the setting above.")
note.setObjectName("Dim")
note.setWordWrap(True)
card.body.addWidget(note)
return card
def _build_scan_options(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"ClamAV extra options", "Advanced flags passed to clamscan. Leave blank for defaults.")[0])
self.extra_options = QPlainTextEdit()
self.extra_options.setObjectName("MonoInput")
self.extra_options.setPlaceholderText("--max-filesize=200M\n--exclude-dir=regex")
self.extra_options.setFixedHeight(92)
self.extra_options.textChanged.connect(self._mark_dirty)
card.body.addWidget(self.extra_options)
self.extra_options_error = QLabel("")
self.extra_options_error.setStyleSheet(f"color: {theme.DANGER}; font-size: 12px;")
self.extra_options_error.setWordWrap(True)
self.extra_options_error.hide()
card.body.addWidget(self.extra_options_error)
row = QHBoxLayout()
note = QLabel("Destructive options such as --remove and --move are refused; BoltClam "
"handles remediation and records every action.")
note.setObjectName("Dim")
note.setWordWrap(True)
row.addWidget(note, 1)
reset = QPushButton("Reset")
reset.setObjectName("Ghost")
reset.clicked.connect(lambda: self.extra_options.setPlainText(""))
row.addWidget(reset, 0)
card.body.addLayout(row)
return card
def _build_logging_section(self) -> QWidget:
card = components.Card()
card.body.addWidget(components.card_header(
"Logging & diagnostics", "Logs stay on this PC unless you explicitly send them.")[0])
self.logging_checkbox = QCheckBox("Enable application logging")
self.logging_checkbox.toggled.connect(self._logging_toggled)
card.body.addWidget(self.logging_checkbox)
self.debug_checkbox = QCheckBox("Include debug-level detail")
self.debug_checkbox.toggled.connect(self._mark_dirty)
card.body.addWidget(self.debug_checkbox)
logs_row = QHBoxLayout()
self.logs_location = QLabel(str(paths.logs_dir()))
self.logs_location.setObjectName("Mono")
logs_row.addWidget(self.logs_location, 1)
open_logs = QPushButton("Open logs folder")
open_logs.setObjectName("Ghost")
open_logs.clicked.connect(self.open_logs)
logs_row.addWidget(open_logs, 0)
card.body.addLayout(logs_row)
divider = components.Divider()
card.body.addWidget(divider)
self.send_logs_checkbox = QCheckBox(
"Send logs to Bolt's systems for analysis when I choose")
self.send_logs_checkbox.toggled.connect(self._send_logs_toggled)
card.body.addWidget(self.send_logs_checkbox)
privacy = QLabel(
"A diagnostics bundle includes app logs, scan/quarantine summaries, settings, "
"system summary and your account email. API keys, passwords and file contents are "
"never included; user-profile paths are replaced with %USERPROFILE%.")
privacy.setObjectName("Dim")
privacy.setWordWrap(True)
card.body.addWidget(privacy)
bottom = QHBoxLayout()
self.last_upload_label = QLabel("")
self.last_upload_label.setObjectName("Dim")
bottom.addWidget(self.last_upload_label, 1)
self.send_now_button = QPushButton("Send diagnostics now")
self.send_now_button.setObjectName("Secondary")
self.send_now_button.clicked.connect(self.send_logs_now)
bottom.addWidget(self.send_now_button, 0)
card.body.addLayout(bottom)
return card
def _build_account_section(self) -> QWidget:
card = components.Card()
row = QHBoxLayout()
row.setSpacing(12)
self.account_avatar = components.Avatar(self.context.user_name, 40)
row.addWidget(self.account_avatar, 0)
text_box = QVBoxLayout()
text_box.setSpacing(1)
self.account_name = QLabel(self.context.user_name)
self.account_name.setFont(theme.body_font(10.5, QFont.Weight.DemiBold))
self.account_email = QLabel(self.context.user_email)
self.account_email.setObjectName("Muted")
text_box.addWidget(self.account_name)
text_box.addWidget(self.account_email)
row.addLayout(text_box, 1)
self.account_plan = components.Pill(self.context.plan_label, "info")
row.addWidget(self.account_plan, 0)
sign_out = QPushButton("Sign out")
sign_out.setObjectName("Danger")
sign_out.clicked.connect(self.sign_out)
row.addWidget(sign_out, 0)
card.body.addLayout(row)
manage = QLabel(
f'<a href="https://bolt.themajesticnetwork.com/account" '
f'style="color:{theme.TEAL}; text-decoration:none;">Manage your Bolt account and subscription</a>')
manage.setOpenExternalLinks(True)
manage.setFont(theme.body_font(9.2, QFont.Weight.DemiBold))
card.body.addWidget(manage)
return card
def _build_save_bar(self) -> QWidget:
bar = QFrame()
bar.setStyleSheet(
f"QFrame {{ background: {theme.SIDEBAR}; border-top: 1px solid {theme.BORDER}; }}")
layout = QHBoxLayout(bar)
layout.setContentsMargins(30, 11, 30, 11)
layout.setSpacing(9)
self.unsaved_label = QLabel("")
self.unsaved_label.setStyleSheet(f"color: {theme.AMBER}; font-size: 12px;")
layout.addWidget(self.unsaved_label, 1)
self.discard_button = QPushButton("Discard")
self.discard_button.setObjectName("Secondary")
self.discard_button.clicked.connect(self.reload_settings)
self.discard_button.setEnabled(False)
layout.addWidget(self.discard_button)
self.save_button = QPushButton("Save changes")
self.save_button.setObjectName("Primary")
self.save_button.clicked.connect(self.save)
self.save_button.setEnabled(False)
layout.addWidget(self.save_button)
return bar
# -- state -------------------------------------------------------------
def reload_settings(self) -> None:
settings = self.context.settings_store.load()
self.context.update_settings(settings)
self._loading = True
try:
index = self.channel_combo.findData(settings.update_channel)
self.channel_combo.setCurrentIndex(index if index >= 0 else 0)
self.clamav_path.setText(settings.clamav_path)
self.realtime_checkbox.setChecked(settings.realtime_enabled)
self.notify_checkbox.setChecked(settings.notify_on_detection)
auto_index = self.auto_combo.findData(settings.auto_action)
self.auto_combo.setCurrentIndex(auto_index if auto_index >= 0 else 0)
self.extra_dirs.setText("; ".join(settings.extra_watch_dirs))
self.extra_options.setPlainText(settings.extra_options)
self.logging_checkbox.setChecked(settings.logging_enabled)
self.debug_checkbox.setChecked(settings.debug_logging)
self.debug_checkbox.setEnabled(settings.logging_enabled)
self.send_logs_checkbox.setChecked(settings.send_logs)
self.send_now_button.setEnabled(settings.send_logs)
self.last_upload_label.setText(
f"Last sent: {settings.last_log_upload}" if settings.last_log_upload
else "Diagnostics have not been sent.")
self.extra_options_error.hide()
self._update_auto_note(settings.auto_action)
self._refresh_clamav_status()
self._refresh_account()
self._refresh_watched()
finally:
self._loading = False
self._set_dirty(False)
def _refresh_clamav_status(self) -> None:
configured = self.clamav_path.text().strip()
if configured:
found, error = find_clamscan(configured)
if found is None:
self.clamav_status.setText(error)
self.clamav_status.setStyleSheet(f"color: {theme.DANGER};")
self.definitions_status.setText("Definitions unavailable until ClamAV is found.")
return
info = self.context.clamav.info()
if info.available:
self.clamav_status.setText(
f"Ready · {info.summary}" + (f" · {info.path}" if info.path else ""))
self.clamav_status.setStyleSheet(f"color: {theme.TEAL};")
if info.db_path:
self.definitions_status.setText(
f"Definitions: {info.db_version or 'present'} · updated {info.db_date or 'unknown'}"
+ (f" · {info.db_age_days:.0f} days old" if info.db_age_days is not None else ""))
else:
self.definitions_status.setText(
"Definitions: not found. Use Update definitions to download them.")
else:
self.clamav_status.setText(info.error or "ClamAV is not configured.")
self.clamav_status.setStyleSheet(f"color: {theme.AMBER};")
self.definitions_status.setText(
"Install ClamAV, then set its folder here or use Auto-detect.")
def _refresh_account(self) -> None:
self.account_avatar.set_name(self.context.user_name)
self.account_name.setText(self.context.user_name)
self.account_email.setText(self.context.user_email or "Signed in with Bolt")
self.account_plan.setText(self.context.plan_label)
self.account_plan.set_severity("good" if self.context.licensed else "muted")
def refresh_account(self) -> None:
self._refresh_account()
def _refresh_watched(self) -> None:
realtime = self.context.realtime
if realtime is None:
self.watched_label.setText("Real-time watcher is not initialized.")
return
roots = realtime.watched_roots
if not self.context.settings.realtime_enabled:
self.watched_label.setText("Real-time protection is off.")
elif realtime.running:
self.watched_label.setText(
f"Watching {len(roots)} folder(s): " + ", ".join(roots[:4])
+ ("…" if len(roots) > 4 else ""))
else:
self.watched_label.setText("Real-time watcher is not running.")
def _logging_toggled(self, checked: bool) -> None:
self.debug_checkbox.setEnabled(checked)
if not checked:
self.debug_checkbox.setChecked(False)
self._mark_dirty()
def _realtime_toggled(self, checked: bool) -> None:
self.notify_checkbox.setEnabled(checked)
self._mark_dirty()
def _send_logs_toggled(self, checked: bool) -> None:
self.send_now_button.setEnabled(checked)
self._mark_dirty()
def _auto_changed(self) -> None:
self._update_auto_note(self.auto_combo.currentData() or "quarantine")
self._mark_dirty()
def _update_auto_note(self, action: str) -> None:
if action == "delete":
self.auto_note.setText("Delete removes the original immediately. Quarantine is safer.")
self.auto_note.setStyleSheet(f"color: {theme.DANGER};")
elif action == "notify":
self.auto_note.setText("Notify leaves the file in place and marks it for review.")
self.auto_note.setStyleSheet(f"color: {theme.AMBER};")
else:
self.auto_note.setText(
"Files are moved to quarantine and can be restored from Scan & results.")
self.auto_note.setStyleSheet(f"color: {theme.DIM};")
def _mark_dirty(self, *args) -> None:
if self._loading:
return
self._set_dirty(True)
def _set_dirty(self, dirty: bool) -> None:
self._dirty = bool(dirty)
self.save_button.setEnabled(self._dirty)
self.discard_button.setEnabled(self._dirty)
self.unsaved_label.setText("Unsaved changes" if self._dirty else "")
# -- actions -----------------------------------------------------------
def browse_clamav(self) -> None:
start = self.clamav_path.text().strip() or str(Path.home())
filename, _ = QFileDialog.getOpenFileName(
self, "Select clamscan.exe", start,
"ClamAV executable (clamscan.exe clamscan);;All files (*)")
if filename:
self.clamav_path.setText(filename)
def detect_clamav(self) -> None:
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
service = ClamAVService("")
info = service.info(force=True)
finally:
QApplication.restoreOverrideCursor()
if info.available and info.path:
self.clamav_path.setText(str(info.path))
self.clamav_status.setText(f"Detected · {info.summary}")
self.clamav_status.setStyleSheet(f"color: {theme.TEAL};")
else:
self.clamav_status.setText(
"ClamAV was not found in the usual locations. Install it, then browse to clamscan.exe.")
self.clamav_status.setStyleSheet(f"color: {theme.AMBER};")
def verify_clamav(self) -> None:
path = self.clamav_path.text().strip()
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
service = ClamAVService(path)
info = service.info(force=True)
finally:
QApplication.restoreOverrideCursor()
if info.available:
self.clamav_status.setText(f"Verified · {info.summary}")
self.clamav_status.setStyleSheet(f"color: {theme.TEAL};")
self.toast_requested.emit("ClamAV verified", info.summary, "good")
self._refresh_clamav_status()
else:
self.clamav_status.setText(info.error or "ClamAV could not be started.")
self.clamav_status.setStyleSheet(f"color: {theme.DANGER};")
self.toast_requested.emit(
"ClamAV verification failed", info.error or "clamscan.exe could not run.", "critical")
def open_logs(self) -> None:
paths.ensure_dirs()
if not paths.open_path(paths.logs_dir()):
self.toast_requested.emit("Could not open logs", str(paths.logs_dir()), "warning")
def save(self) -> bool:
settings, error = self._collect_settings()
if error:
self.extra_options_error.setText(error)
self.extra_options_error.show()
self.toast_requested.emit("Settings not saved", error, "critical")
return False
assert settings is not None
try:
saved = self.context.settings_store.save(settings)
except OSError as exc:
self.toast_requested.emit("Settings not saved", str(exc), "critical")
return False
self.context.update_settings(saved)
self.context.clamav.configured_path = saved.clamav_path
self.context.clamav.invalidate()
configure_logging(saved)
self._loading = True
try:
self._update_auto_note(saved.auto_action)
self._refresh_clamav_status()
finally:
self._loading = False
self.extra_options_error.hide()
self._set_dirty(False)
self.settings_saved.emit(saved)
self.toast_requested.emit("Settings saved", "Your changes are active.", "good")
return True
def _collect_settings(self) -> tuple[AppSettings | None, str | None]:
configured = self.clamav_path.text().strip()
if configured:
found, error = find_clamscan(configured)
if found is None:
return None, error
try:
parse_extra_options(self.extra_options.toPlainText())
except ValueError as exc:
return None, str(exc)
dirs = [entry.strip() for entry in
self.extra_dirs.text().replace("\n", ";").split(";") if entry.strip()]
settings = dataclasses.replace(
self.context.settings,
update_channel=self.channel_combo.currentData() or "release",
clamav_path=configured,
extra_options=self.extra_options.toPlainText().strip(),
logging_enabled=self.logging_checkbox.isChecked(),
debug_logging=(self.debug_checkbox.isChecked()
and self.logging_checkbox.isChecked()),
send_logs=self.send_logs_checkbox.isChecked(),
auto_action=self.auto_combo.currentData() or "quarantine",
realtime_enabled=self.realtime_checkbox.isChecked(),
notify_on_detection=self.notify_checkbox.isChecked(),
extra_watch_dirs=dirs,
).normalized()
return settings, None
def send_logs_now(self) -> None:
if not self.send_logs_checkbox.isChecked():
QMessageBox.information(
self, "Enable diagnostics first",
"Tick 'Send logs to Bolt's systems for analysis' before sending.")
return
if self._dirty:
answer = QMessageBox.question(
self, "Save settings first?",
"Bolt sends the saved settings and logs. Save your changes before uploading?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes)
if answer == QMessageBox.StandardButton.Yes and not self.save():
return
if self._diagnostics_worker is not None and self._diagnostics_worker.isRunning():
return
self.send_now_button.setEnabled(False)
self.send_now_button.setText("Preparing bundle…")
worker = DiagnosticsWorker(self.context, self.context.settings, self)
worker.succeeded.connect(self._diagnostics_succeeded)
worker.failed.connect(self._diagnostics_failed)
worker.finished.connect(self._diagnostics_finished)
worker.finished.connect(worker.deleteLater)
self._diagnostics_worker = worker
worker.start()
def _diagnostics_succeeded(self, result: dict) -> None:
stamp = time.strftime("%Y-%m-%d %H:%M")
try:
self.context.settings = self.context.settings_store.update(
last_log_upload=stamp)
except Exception:
logger.exception("Could not record the diagnostics upload time")
self.last_upload_label.setText(f"Last sent: {stamp}")
log_id = result.get("id") if isinstance(result, dict) else ""
self.toast_requested.emit(
"Diagnostics sent", f"Bolt received the bundle{f' ({log_id})' if log_id else ''}.",
"good")
def _diagnostics_failed(self, message: str) -> None:
self.toast_requested.emit("Diagnostics upload failed", message, "critical")
def _diagnostics_finished(self) -> None:
self._diagnostics_worker = None
self.send_now_button.setText("Send diagnostics now")
self.send_now_button.setEnabled(self.send_logs_checkbox.isChecked())
def sign_out(self) -> None:
answer = QMessageBox.warning(
self, "Sign out of BoltClam?",
"This revokes this PC's BoltClam key. Your account and other devices are not affected.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if answer == QMessageBox.StandardButton.Yes:
self.sign_out_requested.emit()
def showEvent(self, event) -> None:
self._refresh_clamav_status()
self._refresh_account()
self._refresh_watched()
super().showEvent(event)
def stop(self) -> None:
stop_thread(self._diagnostics_worker, wait_ms=3500)
self._diagnostics_worker = None
+637
View File
@@ -0,0 +1,637 @@
"""Visual system: colors, fonts, formatting and the application stylesheet."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtGui import QColor, QFont, QFontDatabase
from PyQt6.QtWidgets import QApplication
from .. import __build_date__, __version__
from .. import paths
# Named palette — "abyssal teal with one warm bolt". Deliberately not the
# near-black + acid-green default: depth comes from layered sea-blues and a
# pearl-white text tone, while amber is reserved for actions and warnings.
BG = "#07131A"
BG_DEEP = "#050E14"
SIDEBAR = "#091A22"
SURFACE = "#0C202A"
SURFACE_RAISED = "#102A35"
SURFACE_HOVER = "#14323E"
BORDER = "#1B3946"
BORDER_SOFT = "#16303B"
TEXT = "#EDF6F4"
TEXT_SOFT = "#C6D9D8"
MUTED = "#8BA7B2"
DIM = "#5F7C88"
AMBER = "#F2B84B"
AMBER_BRIGHT = "#FFD37A"
AMBER_DARK = "#A97218"
TEAL = "#59D8C9"
TEAL_DARK = "#1F8F83"
DANGER = "#FF6B5E"
DANGER_DARK = "#B53C33"
WARNING = "#F2B84B"
INFO = "#7AA8FF"
SUCCESS = "#59D8C9"
FONT_BODY = "Atkinson Hyperlegible"
FONT_DISPLAY = "Bricolage Grotesque"
FONT_MONO = "JetBrains Mono"
FALLBACK_BODY = "Segoe UI"
def load_fonts() -> None:
folder = paths.resource_dir() / "fonts"
if folder.is_dir():
for font_file in sorted(folder.glob("*.ttf")):
QFontDatabase.addApplicationFont(str(font_file))
def body_font(size: float = 10.5, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
font = QFont(FONT_BODY, 10)
font.setPointSizeF(size)
font.setWeight(weight)
return font
def display_font(size: float = 24, weight: QFont.Weight = QFont.Weight.DemiBold) -> QFont:
font = QFont(FONT_DISPLAY, 10)
font.setPointSizeF(size)
font.setWeight(weight)
font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
return font
def mono_font(size: float = 9.5) -> QFont:
font = QFont(FONT_MONO, 10)
font.setPointSizeF(size)
return font
def setup_application(app: QApplication) -> None:
load_fonts()
app.setStyle("Fusion")
app.setFont(body_font(10.5))
app.setApplicationName("BoltClam")
app.setApplicationDisplayName("BoltClam")
app.setApplicationVersion(__version__)
app.setOrganizationName("The Majestic Network")
app.setStyleSheet(stylesheet())
def _resource_url(name: str) -> str:
path = (paths.resource_dir() / name).resolve()
return path.as_posix()
def stylesheet() -> str:
check = _resource_url("check.png")
radio = _resource_url("radio.png")
chevron = _resource_url("chevron_down.png")
return f"""
* {{
outline: none;
}}
QWidget {{
color: {TEXT};
font-family: "{FONT_BODY}", "{FALLBACK_BODY}", sans-serif;
font-size: 14px;
}}
QMainWindow, QWidget#Root, QWidget#Content {{
background: {BG};
}}
QFrame#Sidebar {{
background: {SIDEBAR};
border-right: 1px solid {BORDER_SOFT};
}}
QFrame#Card, QFrame#Hero, QFrame#ScanConsole, QFrame#SettingsSection {{
background: {SURFACE};
border: 1px solid {BORDER_SOFT};
border-radius: 14px;
}}
QFrame#Hero {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #0E2733, stop:0.55 #0C202A, stop:1 #0A1B24);
border: 1px solid {BORDER};
border-radius: 20px;
}}
QFrame#SidebarRule {{
background: {BORDER_SOFT};
max-height: 1px;
border: none;
}}
QLabel {{
background: transparent;
}}
QLabel#PageTitle {{
font-family: "{FONT_DISPLAY}";
font-size: 30px;
font-weight: 600;
color: {TEXT};
}}
QLabel#PageSubtitle {{
color: {MUTED};
font-size: 14px;
}}
QLabel#HeroTitle {{
font-family: "{FONT_DISPLAY}";
font-size: 27px;
font-weight: 600;
color: {TEXT};
}}
QLabel#HeroSummary {{
color: {TEXT_SOFT};
font-size: 14px;
}}
QLabel#CardTitle {{
font-family: "{FONT_DISPLAY}";
font-size: 17px;
font-weight: 600;
color: {TEXT};
}}
QLabel#CardEyebrow {{
color: {MUTED};
font-size: 12px;
font-weight: 700;
}}
QLabel#StatValue {{
font-family: "{FONT_DISPLAY}";
font-size: 28px;
font-weight: 600;
color: {TEXT};
}}
QLabel#StatLabel {{
color: {MUTED};
font-size: 12px;
}}
QLabel#Muted {{
color: {MUTED};
}}
QLabel#Dim {{
color: {DIM};
font-size: 12px;
}}
QLabel#Mono, QLabel#PathLabel {{
font-family: "{FONT_MONO}";
font-size: 12px;
color: {TEXT_SOFT};
}}
QLabel#DangerText {{ color: {DANGER}; }}
QLabel#WarningText {{ color: {WARNING}; }}
QLabel#SuccessText {{ color: {SUCCESS}; }}
QLabel#BrandName {{
font-family: "{FONT_DISPLAY}";
font-size: 20px;
font-weight: 700;
color: {TEXT};
}}
QLabel#BrandTag {{
color: {MUTED};
font-size: 10.5px;
}}
QLabel#Pill {{
border-radius: 9px;
padding: 3px 9px;
font-size: 11px;
font-weight: 700;
background: rgba(89,216,201,0.12);
color: {TEAL};
}}
QLabel#Pill[severity="warning"] {{
background: rgba(242,184,75,0.13);
color: {AMBER};
}}
QLabel#Pill[severity="critical"] {{
background: rgba(255,107,94,0.14);
color: {DANGER};
}}
QLabel#Pill[severity="info"] {{
background: rgba(122,168,255,0.12);
color: {INFO};
}}
QLabel#Pill[severity="muted"] {{
background: rgba(139,167,178,0.1);
color: {MUTED};
}}
QLabel#Pill[severity="good"] {{
background: rgba(89,216,201,0.12);
color: {TEAL};
}}
QPushButton {{
font-family: "{FONT_BODY}";
font-size: 13.5px;
border-radius: 9px;
padding: 9px 15px;
background: transparent;
color: {TEXT};
}}
QPushButton#Primary {{
background: {AMBER};
color: #1B1304;
font-weight: 700;
border: 1px solid {AMBER_BRIGHT};
}}
QPushButton#Primary:hover {{ background: {AMBER_BRIGHT}; }}
QPushButton#Primary:pressed {{ background: {AMBER_DARK}; }}
QPushButton#Primary:disabled {{ background: #4A412D; color: #9A8C6D; border-color: #4A412D; }}
QPushButton#Secondary {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
color: {TEXT};
font-weight: 600;
}}
QPushButton#Secondary:hover {{ background: {SURFACE_HOVER}; border-color: #2A5060; }}
QPushButton#Ghost {{
color: {MUTED};
border: 1px solid transparent;
padding: 8px 10px;
}}
QPushButton#Ghost:hover {{ color: {TEXT}; background: {SURFACE_RAISED}; }}
QPushButton#Danger {{
background: rgba(255,107,94,0.12);
border: 1px solid rgba(255,107,94,0.35);
color: #FFB3AC;
font-weight: 600;
}}
QPushButton#Danger:hover {{ background: rgba(255,107,94,0.2); }}
QPushButton#Secondary:disabled {{
color: {DIM};
background: #0A1A22;
border-color: {BORDER_SOFT};
}}
QPushButton#Danger:disabled {{
color: #6E4A47;
background: rgba(255,107,94,0.04);
border-color: rgba(255,107,94,0.14);
}}
QPushButton#Ghost:disabled, QPushButton#Link:disabled {{
color: #40555E;
}}
QPushButton#Link {{
color: {TEAL};
font-weight: 700;
padding: 2px 0;
border: none;
text-align: left;
}}
QPushButton#Link:hover {{ color: #8CF5E8; }}
QPushButton#IconButton {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
border-radius: 8px;
padding: 6px;
min-width: 30px;
min-height: 30px;
}}
QPushButton#IconButton:hover {{ background: {SURFACE_HOVER}; }}
QPushButton:focus {{
border: 1px solid {AMBER};
}}
QLineEdit, QPlainTextEdit, QTextEdit, QComboBox {{
background: {BG_DEEP};
border: 1px solid {BORDER};
border-radius: 9px;
padding: 9px 11px;
color: {TEXT};
selection-background-color: {AMBER};
selection-color: #1B1304;
}}
QLineEdit:hover, QPlainTextEdit:hover, QComboBox:hover {{ border-color: #285163; }}
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border-color: {AMBER}; }}
QLineEdit:disabled, QPlainTextEdit:disabled, QComboBox:disabled {{
color: {DIM}; background: #08151C;
}}
QPlainTextEdit#MonoInput {{
font-family: "{FONT_MONO}";
font-size: 12px;
}}
QComboBox::drop-down {{
border: none;
width: 30px;
}}
QComboBox::down-arrow {{
image: url("{chevron}");
width: 12px;
height: 12px;
}}
QComboBox QAbstractItemView {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
selection-background-color: {SURFACE_HOVER};
selection-color: {TEXT};
padding: 4px;
}}
QCheckBox, QRadioButton {{
color: {TEXT_SOFT};
spacing: 9px;
background: transparent;
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
border-radius: 5px;
border: 1px solid #2A4B5A;
background: {BG_DEEP};
}}
QCheckBox::indicator:hover {{ border-color: {AMBER}; }}
QCheckBox::indicator:checked {{
background: {AMBER};
border-color: {AMBER_BRIGHT};
image: url("{check}");
}}
QRadioButton::indicator {{
width: 17px;
height: 17px;
border-radius: 9px;
border: 1px solid #2A4B5A;
background: {BG_DEEP};
}}
QRadioButton::indicator:checked {{
background: {AMBER};
border-color: {AMBER_BRIGHT};
image: url("{radio}");
}}
QRadioButton#ChoiceTile {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
border-radius: 11px;
padding: 12px;
}}
QRadioButton#ChoiceTile:hover {{ border-color: #2D5668; }}
QRadioButton#ChoiceTile:checked {{
border-color: {AMBER};
background: rgba(242,184,75,0.07);
}}
QFrame#SegmentChip {{
background: rgba(8,22,30,0.55);
border: 1px solid {BORDER_SOFT};
border-radius: 9px;
}}
QFrame#ConcernRow {{
border-bottom: 1px solid {BORDER_SOFT};
}}
QFrame#ConcernRow:hover {{
background: rgba(255,255,255,0.02);
}}
QTabWidget {{
background: transparent;
}}
QTabWidget::pane {{
border: none;
background: transparent;
}}
QTabWidget::tab-bar {{
background: transparent;
}}
QTabBar {{
background: transparent;
qproperty-drawBase: 0;
}}
QTabBar::tab {{
background: transparent;
color: {MUTED};
padding: 8px 14px;
margin-right: 4px;
border: 1px solid transparent;
border-radius: 8px;
font-weight: 600;
}}
QTabBar::tab:selected {{
background-color: {SURFACE_RAISED};
border: 1px solid {BORDER};
color: {TEXT};
}}
QTabBar::tab:hover {{ color: {TEXT}; }}
QPushButton#TabPill {{
background: transparent;
border: 1px solid transparent;
color: {MUTED};
padding: 7px 15px;
border-radius: 8px;
font-weight: 600;
}}
QPushButton#TabPill:hover {{ color: {TEXT}; }}
QPushButton#TabPill:checked {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
color: {TEXT};
}}
QTableWidget, QTableView {{
background: transparent;
alternate-background-color: transparent;
border: none;
gridline-color: transparent;
selection-background-color: rgba(242,184,75,0.13);
selection-color: {TEXT};
}}
QTableWidget::item {{ padding: 8px 6px; border-bottom: 1px solid {BORDER_SOFT}; }}
QHeaderView {{
background: transparent;
border: none;
}}
QHeaderView::section {{
background-color: {SURFACE};
color: {MUTED};
border: none;
border-bottom: 1px solid {BORDER};
padding: 7px 6px;
font-size: 11px;
font-weight: 700;
}}
QTableWidget QHeaderView::section {{
background-color: {SURFACE};
}}
QTableCornerButton::section {{ background: {SURFACE}; border: none; }}
QListWidget {{
background: transparent;
border: none;
outline: none;
}}
QListWidget::item {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER_SOFT};
border-radius: 10px;
margin: 0 0 7px 0;
padding: 10px;
}}
QListWidget::item:selected {{
border-color: {AMBER};
background: rgba(242,184,75,0.08);
color: {TEXT};
}}
QListWidget::item:hover {{ border-color: #2A5060; }}
QScrollArea {{
border: none;
background: transparent;
}}
QScrollBar:vertical {{
background: transparent;
width: 10px;
margin: 2px;
}}
QScrollBar::handle:vertical {{
background: #23414E;
min-height: 30px;
border-radius: 4px;
}}
QScrollBar::handle:vertical:hover {{ background: #2F5667; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }}
QScrollBar:horizontal {{
background: transparent;
height: 10px;
}}
QScrollBar::handle:horizontal {{ background: #23414E; border-radius: 4px; }}
QProgressBar {{
background: {BG_DEEP};
border: 1px solid {BORDER};
border-radius: 4px;
height: 7px;
text-align: center;
color: transparent;
}}
QProgressBar::chunk {{
background: {AMBER};
border-radius: 3px;
}}
QSplitter::handle {{ background: transparent; width: 10px; }}
QToolTip {{
background: {SURFACE_RAISED};
color: {TEXT};
border: 1px solid {BORDER};
padding: 6px 8px;
border-radius: 6px;
}}
QMenu {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
border-radius: 8px;
padding: 5px;
}}
QMenu::item {{ padding: 7px 22px 7px 12px; border-radius: 5px; }}
QMenu::item:selected {{ background: {SURFACE_HOVER}; }}
QTextBrowser {{
background: transparent;
border: none;
color: {TEXT_SOFT};
selection-background-color: {AMBER};
selection-color: #1B1304;
}}
QDialog {{ background: {BG}; }}
QFrame#LoginBrand {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #0D2B38, stop:0.6 #08202B, stop:1 #07131A);
border: 1px solid {BORDER};
border-radius: 18px;
}}
QFrame#LoginForm {{
background: {SURFACE};
border: 1px solid {BORDER};
border-radius: 18px;
}}
QFrame#Toast {{
background: {SURFACE_RAISED};
border: 1px solid {BORDER};
border-radius: 11px;
}}
QLabel#ToastTitle {{ font-weight: 700; color: {TEXT}; }}
QLabel#ToastBody {{ color: {MUTED}; font-size: 12.5px; }}
QFrame#Divider {{ background: {BORDER_SOFT}; max-height: 1px; border: none; }}
"""
def overall_color(overall: str) -> str:
return {
"protected": TEAL,
"attention": AMBER,
"at_risk": DANGER,
"setup": INFO,
}.get(overall, MUTED)
def severity_color(severity: str) -> str:
return {
"critical": DANGER,
"warning": AMBER,
"info": INFO,
"good": TEAL,
"warn": AMBER,
"bad": DANGER,
"unknown": DIM,
}.get(str(severity or "").lower(), MUTED)
def status_color(status: str) -> str:
return {
"completed": TEAL,
"running": AMBER,
"cancelled": DIM,
"failed": DANGER,
"completed_with_errors": AMBER,
"quarantined": TEAL,
"deleted": TEAL,
"restored": INFO,
"detected": DANGER,
}.get(str(status or "").lower(), MUTED)
def parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def relative_time(value: str | None) -> str:
parsed = parse_iso(value)
if parsed is None:
return "Never"
delta = (datetime.now(timezone.utc) - parsed).total_seconds()
if delta < 45:
return "just now"
if delta < 3600:
return f"{int(delta // 60)}m ago"
if delta < 86400:
return f"{int(delta // 3600)}h ago"
if delta < 86400 * 7:
return f"{int(delta // 86400)}d ago"
return parsed.astimezone().strftime("%d %b %Y")
def format_datetime(value: str | None) -> str:
parsed = parse_iso(value)
if parsed is None:
return "—"
return parsed.astimezone().strftime("%d %b %Y · %H:%M")
def format_size(size: int | float | None) -> str:
value = float(size or 0)
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
value /= 1024
return f"{value:.1f} TB"
def format_duration(seconds: float | int | None) -> str:
total = int(float(seconds or 0))
if total < 60:
return f"{total}s"
minutes, sec = divmod(total, 60)
if minutes < 60:
return f"{minutes}m {sec}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h {minutes}m"
def version_line() -> str:
return f"Version {__version__} · build {__build_date__}"
+26
View File
@@ -0,0 +1,26 @@
"""Bounded QThread shutdown for window close."""
from __future__ import annotations
from PyQt6.QtCore import QThread
def stop_thread(worker: QThread | None, wait_ms: int = 4000) -> None:
"""Let a worker finish, then terminate it as a last resort.
QThread must never be destroyed while still running; Qt aborts the
process. Network and PowerShell calls can outlive a window close, so
request interruption, wait, and only then terminate. This runs during
application shutdown, never on a normal user action.
"""
if worker is None:
return
try:
if worker.isRunning():
worker.requestInterruption()
if not worker.wait(max(250, int(wait_ms))):
worker.terminate()
worker.wait(1000)
except RuntimeError:
# The C++ object was already deleted by deleteLater().
pass
+171
View File
@@ -0,0 +1,171 @@
"""Release checks, version comparison, asset selection and checksum verification."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from . import __version__, paths
from .api_client import ApiClient
from .models import UpdateAsset, UpdateFeed, UpdateRelease
logger = logging.getLogger(__name__)
_VERSION_RE = re.compile(r"^[vV]?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$")
class UpdateError(Exception):
pass
def parse_version(value: str) -> tuple[tuple[int, ...], tuple] | None:
match = _VERSION_RE.match(str(value or "").strip())
if not match:
return None
numbers = tuple(int(part) for part in match.group(1).split("."))
prerelease_raw = match.group(2)
if not prerelease_raw:
prerelease: tuple = ()
else:
parts: list[tuple[int, object]] = []
for token in prerelease_raw.split("."):
if token.isdigit():
parts.append((0, int(token)))
else:
parts.append((1, token))
prerelease = tuple(parts)
return numbers, prerelease
def _compare_numbers(left: tuple[int, ...], right: tuple[int, ...]) -> int:
length = max(len(left), len(right))
left_padded = left + (0,) * (length - len(left))
right_padded = right + (0,) * (length - len(right))
return (left_padded > right_padded) - (left_padded < right_padded)
def is_newer(candidate: str, current: str = __version__) -> bool:
parsed_candidate = parse_version(candidate)
if parsed_candidate is None:
return False
parsed_current = parse_version(current)
if parsed_current is None:
return True
candidate_numbers, candidate_pre = parsed_candidate
current_numbers, current_pre = parsed_current
numbers = _compare_numbers(candidate_numbers, current_numbers)
if numbers != 0:
return numbers > 0
# Same numeric version: a stable release outranks its prereleases.
if not candidate_pre and current_pre:
return True
if candidate_pre and not current_pre:
return False
return candidate_pre > current_pre
def choose_asset(release: UpdateRelease) -> UpdateAsset | None:
assets = [asset for asset in release.assets if asset.download_url]
if not assets:
return None
def score(asset: UpdateAsset) -> tuple:
name = asset.name.lower()
executable = name.endswith(".exe")
setup = "setup" in name or "installer" in name
windows = "windows" in name or "win" in name
archive = name.endswith(".zip")
return (
0 if (executable and setup) else
1 if (executable and windows) else
2 if executable else
3 if (archive and windows) else
4 if archive else 9,
len(name),
)
usable = [asset for asset in assets
if asset.name.lower().endswith((".exe", ".zip", ".msi"))]
return min(usable, key=score) if usable else None
def checksum_asset(release: UpdateRelease) -> UpdateAsset | None:
wanted = {"sha256sums.txt", "sha256sum.txt", "checksums.txt",
"sha256sums", "checksums.sha256"}
for asset in release.assets:
if asset.name.lower() in wanted:
return asset
return None
def parse_checksums(text: str) -> dict[str, str]:
result: dict[str, str] = {}
for line in str(text or "").splitlines():
match = re.match(r"^\s*([0-9a-fA-F]{64})\s+\*?(.+?)\s*$", line)
if match:
result[Path(match.group(2)).name] = match.group(1).lower()
return result
def _safe_name(name: str) -> str:
base = Path(str(name or "download")).name
base = re.sub(r"[^A-Za-z0-9._() +-]", "_", base)[:180]
return base or "download"
@dataclass
class DownloadedUpdate:
path: Path
sha256: str
verified: bool
expected_sha256: str = ""
class UpdateService:
def __init__(self, api: ApiClient, channel: str = "release") -> None:
self.api = api
self.channel = "beta" if channel == "beta" else "release"
def check(self, force: bool = False) -> UpdateFeed:
payload = self.api.get_updates(self.channel, force=force)
return UpdateFeed.from_api(payload)
def update_available(self, feed: UpdateFeed) -> bool:
return bool(feed.latest and is_newer(feed.latest.tag, __version__))
def download(self, release: UpdateRelease, asset: UpdateAsset,
progress: Callable[[int, int], None] | None = None) -> DownloadedUpdate:
target_dir = paths.updates_dir() / _safe_name(release.tag or release.id or "release")
target = target_dir / _safe_name(asset.name)
try:
path, digest = self.api.download_asset(asset.download_url, target, progress)
except Exception as exc:
raise UpdateError(f"The update could not be downloaded: {exc}") from exc
expected = ""
checksum_file = checksum_asset(release)
if checksum_file is not None:
temp_checksum = target_dir / "SHA256SUMS.txt"
try:
checksum_path, _hash = self.api.download_asset(
checksum_file.download_url, temp_checksum)
text = checksum_path.read_text(encoding="utf-8", errors="replace")
expected = parse_checksums(text).get(asset.name, "")
except Exception as exc:
logger.warning("Could not download the checksum file: %s", exc)
finally:
try:
temp_checksum.unlink()
except OSError:
pass
if expected and digest.lower() != expected.lower():
try:
path.unlink()
except OSError:
pass
raise UpdateError("The downloaded update did not match its SHA-256 checksum.")
return DownloadedUpdate(path=path, sha256=digest, verified=bool(expected),
expected_sha256=expected)
+197
View File
@@ -0,0 +1,197 @@
"""Best-effort file locking for the real-time download gate.
Windows has no user-mode API that lets an ordinary process intercept every
``CreateFile`` (that is a kernel minifilter). What this module *can* do is
hold the download file open with ``dwShareMode = 0`` while ClamAV scans a
copy, which makes Windows refuse other opens for that period. If the file
was already open elsewhere, we fall back to scanning a copy and say so in
the event detail. This is a meaningful speed bump, not a guarantee against
a kernel-level bypass.
"""
from __future__ import annotations
import ctypes
import logging
import os
import shutil
from ctypes import wintypes
from pathlib import Path
logger = logging.getLogger(__name__)
class LockError(Exception):
pass
if os.name == "nt":
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_CreateFileW = _kernel32.CreateFileW
_CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD,
wintypes.HANDLE]
_CreateFileW.restype = wintypes.HANDLE
_ReadFile = _kernel32.ReadFile
_ReadFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
_ReadFile.restype = wintypes.BOOL
_CloseHandle = _kernel32.CloseHandle
_CloseHandle.argtypes = [wintypes.HANDLE]
_CloseHandle.restype = wintypes.BOOL
_SetFileInformationByHandle = _kernel32.SetFileInformationByHandle
_SetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.c_int,
wintypes.LPVOID, wintypes.DWORD]
_SetFileInformationByHandle.restype = wintypes.BOOL
_GENERIC_READ = 0x80000000
_DELETE = 0x00010000
_OPEN_EXISTING = 3
_FILE_ATTRIBUTE_NORMAL = 0x80
_FILE_DISPOSITION_INFO_CLASS = 4
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
class _FILE_DISPOSITION_INFO(ctypes.Structure):
_fields_ = [("DeleteFile", wintypes.BOOL)]
else:
_kernel32 = None
_FILE_DISPOSITION_INFO = None
class ExclusiveFile:
"""Open a file (share-deny on Windows) and copy it while held."""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._handle = None
self._file = None
self._delete_access = False
self._delete_on_close = False
self._open = False
@property
def exclusive(self) -> bool:
return bool(self._open and os.name == "nt")
def open(self) -> None:
if self._open:
return
if os.name == "nt":
handle = _CreateFileW(
str(self.path), _GENERIC_READ | _DELETE, 0, None, _OPEN_EXISTING,
_FILE_ATTRIBUTE_NORMAL, None,
)
if handle == _INVALID_HANDLE_VALUE:
handle = _CreateFileW(
str(self.path), _GENERIC_READ, 0, None, _OPEN_EXISTING,
_FILE_ATTRIBUTE_NORMAL, None,
)
self._delete_access = False
else:
self._delete_access = True
if handle == _INVALID_HANDLE_VALUE:
error = ctypes.get_last_error()
raise LockError(f"Could not open {self.path.name} exclusively (error {error}).")
self._handle = handle
else:
try:
self._file = open(self.path, "rb")
except OSError as exc:
raise LockError(f"Could not open {self.path.name}: {exc}") from exc
self._open = True
def copy_to(self, destination: Path) -> None:
if not self._open:
self.open()
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
try:
if os.name == "nt":
buffer = ctypes.create_string_buffer(1024 * 1024)
read = wintypes.DWORD()
with open(destination, "wb") as output:
while True:
ok = _ReadFile(self._handle, buffer, len(buffer),
ctypes.byref(read), None)
if not ok:
raise LockError(
f"Could not read {self.path.name} (error "
f"{ctypes.get_last_error()}).")
if read.value == 0:
break
output.write(buffer.raw[:read.value])
else:
with open(destination, "wb") as output:
shutil.copyfileobj(self._file, output, length=1024 * 1024)
except OSError as exc:
raise LockError(f"Could not copy {self.path.name}: {exc}") from exc
def mark_delete_on_close(self) -> bool:
if os.name != "nt" or not self._delete_access or self._handle is None:
return False
info = _FILE_DISPOSITION_INFO(True)
ok = _SetFileInformationByHandle(
self._handle, _FILE_DISPOSITION_INFO_CLASS, ctypes.byref(info),
ctypes.sizeof(info),
)
if not ok:
logger.warning("Could not mark %s delete-on-close (error %s)",
self.path, ctypes.get_last_error())
return False
self._delete_on_close = True
return True
def close(self) -> None:
if not self._open:
return
if os.name == "nt":
if self._handle is not None:
_CloseHandle(self._handle)
self._handle = None
else:
if self._file is not None:
self._file.close()
self._file = None
self._open = False
def __enter__(self) -> "ExclusiveFile":
self.open()
return self
def __exit__(self, exc_type, exc, traceback) -> None:
self.close()
def copy_for_scan(path: Path, destination: Path, attempts: int = 8,
delay: float = 0.4) -> tuple[ExclusiveFile, bool]:
"""Copy *path* while holding it where possible.
Returns ``(locker, exclusive)``. The caller must keep ``locker`` open
until the scan finishes, then close it. If the file cannot be opened
exclusively (another process is still writing or reading it), this
retries briefly and then copies without the lock.
"""
import time
last_error: Exception | None = None
for _ in range(max(1, attempts)):
locker = ExclusiveFile(path)
try:
locker.open()
locker.copy_to(destination)
return locker, locker.exclusive
except LockError as exc:
last_error = exc
try:
locker.close()
except Exception:
pass
time.sleep(delay)
# Last resort: a plain copy. The watcher already waited for the size to
# stop changing, so this is still normally a complete file.
try:
shutil.copy2(path, destination)
except OSError as exc:
raise LockError(f"Could not copy {path.name}: {exc}") from exc
logger.warning("Real-time scan could not exclusively lock %s: %s", path, last_error)
return ExclusiveFile(path), False
View File
+32
View File
@@ -0,0 +1,32 @@
"""Test bootstrap: never touch the real BoltClam profile or Downloads."""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import pytest # noqa: E402
from boltclam import paths # noqa: E402
@pytest.fixture
def isolated_env(tmp_path: Path, monkeypatch) -> Path:
monkeypatch.setenv("BOLTCLAM_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("BOLTCLAM_DATA_DIR", str(tmp_path / "data"))
paths.ensure_dirs()
return tmp_path
@pytest.fixture(scope="session")
def qapp():
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
return app
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import base64
import hashlib
import http.server
import json
import socket
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from boltclam.auth import (
AuthError,
AuthService,
CredentialStore,
LoopbackAuthReceiver,
pkce_pair,
)
from boltclam.config import AccountCache
ACCOUNT = {
"user": {"id": "1", "email": "aaron@example.com", "name": "Aaron",
"username": "aaron"},
"subscription": {"tier": "premium", "label": "Premium", "status": "active"},
"balance": {"weekly_remaining": 100, "topup": 0, "allowed": True},
"features": {"licensed": True},
}
class _Handler(http.server.BaseHTTPRequestHandler):
def _json(self, status: int, payload: dict) -> None:
data = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None: # noqa: N802
if self.path.startswith("/boltclam/account"):
key = self.headers.get("X-Desk-Api-Key", "")
if key in ("bolt_live_good", "bolt_live_new"):
self._json(200, ACCOUNT)
else:
self._json(401, {"error": "invalid_credentials"})
return
self._json(404, {"error": "not_found"})
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if self.path == "/desk/auth/oauth/exchange":
payload = json.loads(raw or b"{}")
self.server.exchanges.append(payload) # type: ignore[attr-defined]
self._json(200, {"api_key": "bolt_live_new", "user": ACCOUNT["user"]})
return
if self.path == "/desk/auth/logout":
self._json(200, {})
return
self._json(404, {"error": "not_found"})
def log_message(self, fmt: str, *args) -> None:
return
@pytest.fixture
def web_server():
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
server.exchanges = [] # type: ignore[attr-defined]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _auth(tmp_path, base_url) -> AuthService:
return AuthService(
base_url, install_id="test-install",
credentials=CredentialStore(tmp_path / "credentials.dat"),
account_cache=AccountCache(tmp_path / "account.json"))
def test_pkce_pair_matches_s256():
verifier, challenge = pkce_pair()
expected = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
assert challenge == expected
assert 43 <= len(verifier) <= 128
def test_credential_store_fallback_round_trip(tmp_path):
store = CredentialStore(tmp_path / "credentials.dat")
store.save("bolt_live_secret")
assert store.load() == "bolt_live_secret"
store.delete()
assert store.load() is None
def test_key_login_restore_and_logout(tmp_path, web_server):
base = f"http://127.0.0.1:{web_server.server_address[1]}"
auth = _auth(tmp_path, base)
session = auth.login_with_key("bolt_live_good")
assert session.api_key == "bolt_live_good"
assert session.account["user"]["email"] == "aaron@example.com"
assert auth.stored_key() == "bolt_live_good"
restored = auth.restore()
assert restored.account["subscription"]["tier"] == "premium"
assert restored.offline is False
auth.logout()
assert auth.stored_key() is None
assert auth.account_cache.load() is None
def test_key_login_rejects_a_bad_key(tmp_path, web_server):
base = f"http://127.0.0.1:{web_server.server_address[1]}"
auth = _auth(tmp_path, base)
with pytest.raises(AuthError):
auth.login_with_key("bolt_live_wrong")
def test_browser_login_completes_the_loopback_pkce_handoff(tmp_path, web_server):
base = f"http://127.0.0.1:{web_server.server_address[1]}"
auth = _auth(tmp_path, base)
opened: dict[str, str] = {}
def open_url(url: str) -> bool:
opened["url"] = url
parsed = urlparse(url)
query = parse_qs(parsed.query)
redirect_uri = query["redirect_uri"][0]
state = query["state"][0]
def callback() -> None:
time.sleep(0.2)
with urllib.request.urlopen(
f"{redirect_uri}?code=one-time&state={state}", timeout=5) as response:
response.read()
threading.Thread(target=callback, daemon=True).start()
return True
session = auth.browser_login(open_url, timeout=10)
assert session.api_key == "bolt_live_new"
assert auth.stored_key() == "bolt_live_new"
exchange = web_server.exchanges[-1] # type: ignore[attr-defined]
assert exchange["client"] == "boltclam"
assert exchange["code"] == "one-time"
assert exchange["code_verifier"]
assert exchange["redirect_uri"].startswith("http://127.0.0.1:")
assert "device_name=BoltClam" in opened["url"]
def test_loopback_rejects_the_wrong_state():
receiver = LoopbackAuthReceiver("expected-state")
receiver.start()
try:
try:
urllib.request.urlopen(
f"{receiver.redirect_uri}?code=bad&state=wrong", timeout=5).read()
assert False, "expected a 400"
except urllib.error.HTTPError as exc:
assert exc.code == 400
assert receiver.event.is_set() is False
with urllib.request.urlopen(
f"{receiver.redirect_uri}?code=good&state=expected-state",
timeout=5) as response:
assert response.status == 200
result = receiver.wait(2)
assert result["code"] == "good"
finally:
receiver.close()
def test_restore_offline_uses_the_cached_account(tmp_path):
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
credential_path = tmp_path / "credentials.dat"
CredentialStore(credential_path).save("bolt_live_cached")
cache = AccountCache(tmp_path / "account.json")
cache.save(ACCOUNT)
auth = AuthService(
f"http://127.0.0.1:{port}", install_id="offline",
credentials=CredentialStore(credential_path), account_cache=cache)
session = auth.restore()
assert session.offline is True
assert session.account["user"]["email"] == "aaron@example.com"
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import os
import stat
import time
from pathlib import Path
import pytest
from boltclam.clamav import (
ClamAVService,
build_freshclam_args,
build_scan_args,
database_age,
database_files,
find_clamscan,
parse_clamscan_line,
parse_extra_options,
parse_version_output,
scan_targets,
)
def _script(path: Path, body: str = "") -> Path:
path.write_text("#!/bin/sh\n" + body, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
return path
def test_parse_version_output():
assert parse_version_output("ClamAV 1.4.3/27452/Wed Oct 22 07:17:23 2026") == (
"1.4.3", "27452", "Wed Oct 22 07:17:23 2026")
assert parse_version_output("ClamAV 1.4.0") == ("1.4.0", "", "")
assert parse_version_output("nonsense") == ("", "", "")
def test_parse_extra_options_handles_windows_quotes():
options = parse_extra_options(
'--max-filesize=200M --exclude-dir="C:\\Program Files\\Skip" '
'--database="C:\\My DB"')
assert options == [
"--max-filesize=200M",
"--exclude-dir=C:\\Program Files\\Skip",
"--database=C:\\My DB",
]
def test_parse_extra_options_refuses_destructive_flags():
for text in ("--remove=yes", "--move=C:\\Quarantine", "--copy=/tmp"):
with pytest.raises(ValueError):
parse_extra_options(text)
def test_parse_extra_options_rejects_malformed_quoting():
with pytest.raises(ValueError):
parse_extra_options('--exclude-dir="unterminated')
def test_build_scan_args_uses_managed_database_and_excludes(tmp_path):
db = tmp_path / "db"
db.mkdir()
(db / "main.cvd").write_bytes(b"x")
quarantine = tmp_path / "quarantine"
temp = tmp_path / "temp"
args = build_scan_args(
Path("/usr/bin/clamscan"), [tmp_path / "target"],
database_dir=db, quarantine_dir=quarantine, temp_dir=temp,
extra_options=["--max-filesize=200M"])
assert args[0].endswith("clamscan")
assert "--recursive=yes" in args
assert "--infected" in args
assert f"--database={db}" in args
assert any(arg.startswith("--exclude-dir=") for arg in args)
assert "--max-filesize=200M" in args
assert args[-1] == str(tmp_path / "target")
def test_build_scan_args_omits_database_when_no_signatures(tmp_path):
args = build_scan_args(Path("/usr/bin/clamscan"), [tmp_path])
assert not any(arg.startswith("--database=") for arg in args)
def test_parse_clamscan_line_detection_windows_path():
event = parse_clamscan_line(
r"C:\Users\aaron\Downloads\invoice.pdf.exe: Win.Trojan.Agent-123 FOUND")
assert event == {
"kind": "detection",
"path": r"C:\Users\aaron\Downloads\invoice.pdf.exe",
"signature": "Win.Trojan.Agent-123",
}
def test_parse_clamscan_line_summary_progress_and_errors():
assert parse_clamscan_line("Scanned files: 18234")["key"] == "scanned_files"
assert parse_clamscan_line("Scanned files: 18234")["value"] == "18234"
assert parse_clamscan_line("Engine version: 1.4.3")["value"] == "1.4.3"
assert parse_clamscan_line("Scanning /home/a/file")["kind"] == "progress"
assert parse_clamscan_line("/home/a/file: Access denied")["kind"] == "error"
assert parse_clamscan_line("/home/a/file: Size limit exceeded")["kind"] == "skipped"
assert parse_clamscan_line("") is None
def test_scan_targets_deduplicates_injected_profiles(tmp_path):
one = tmp_path / "one"
two = tmp_path / "two"
one.mkdir()
two.mkdir()
targets = scan_targets("homes", profiles=[one, two, one, Path("/does/not/exist")])
assert targets == [one.resolve(), two.resolve()]
def test_scan_targets_full_uses_injected_drives(tmp_path):
drive = tmp_path / "drive"
drive.mkdir()
assert scan_targets("full", drives=[drive]) == [drive.resolve()]
def test_database_detection_and_age(tmp_path):
db = tmp_path / "db"
db.mkdir()
assert database_files(db) == []
main = db / "main.cvd"
main.write_bytes(b"signatures")
old = time.time() - 3 * 86400
os.utime(main, (old, old))
assert database_files(db) == [main]
date, age = database_age(db)
assert date
assert 2.8 < age < 3.2
def test_clamav_service_reads_a_fake_engine(tmp_path):
fake = _script(tmp_path / "clamscan", 'echo "ClamAV 1.4.3/27452/Wed Oct 22 07:17:23 2026"\n')
service = ClamAVService(str(fake))
info = service.info(force=True)
assert info.available is True
assert info.engine_version == "1.4.3"
assert info.db_version == "27452"
assert info.path == fake
assert service.info(force=True).summary.startswith("1.4.3")
def test_find_clamscan_accepts_a_file_or_directory(tmp_path):
fake = _script(tmp_path / "clamscan")
found, error = find_clamscan(str(fake))
assert found == fake and error == ""
found, error = find_clamscan(str(tmp_path))
assert found == fake and error == ""
missing, error = find_clamscan(str(tmp_path / "missing"))
assert missing is None and "does not exist" in error
def test_freshclam_arguments_target_managed_database(tmp_path):
args = build_freshclam_args(Path("/usr/bin/freshclam"), tmp_path / "db")
assert "--foreground" in args
assert "--stdout" in args
assert f"--datadir={tmp_path / 'db'}" in args
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from boltclam.config import AccountCache, AppSettings, SettingsStore
def test_defaults_are_normalized_and_install_id_is_generated(isolated_env):
settings = SettingsStore().load()
assert settings.update_channel == "release"
assert settings.scan_scope == "homes"
assert settings.auto_action == "quarantine"
assert settings.install_id
assert len(settings.install_id) == 32
def test_invalid_values_fall_back_to_defaults(isolated_env):
settings = AppSettings(
update_channel="nightly",
scan_scope="somewhere",
auto_action="explode",
debug_logging=True,
logging_enabled=False,
api_base_url="",
).normalized()
assert settings.update_channel == "release"
assert settings.scan_scope == "homes"
assert settings.auto_action == "quarantine"
assert settings.debug_logging is False
assert settings.api_base_url == "https://api.themajesticnetwork.com"
assert settings.install_id
def test_round_trip_and_atomic_save(isolated_env):
store = SettingsStore()
settings = store.load()
saved = store.update(update_channel="beta", extra_options="--max-filesize=200M",
extra_watch_dirs=["~/Downloads/Test", ""])
reloaded = SettingsStore().load()
assert reloaded.update_channel == "beta"
assert reloaded.extra_options == "--max-filesize=200M"
assert len(reloaded.extra_watch_dirs) == 1
assert saved.install_id == reloaded.install_id
assert json.loads(store.path.read_text())["update_channel"] == "beta"
def test_corrupt_settings_are_backed_up_and_defaults_load(isolated_env):
store = SettingsStore()
store.path.parent.mkdir(parents=True, exist_ok=True)
store.path.write_text("{not json", encoding="utf-8")
loaded = SettingsStore().load()
assert loaded.update_channel == "release"
backups = list(store.path.parent.glob("settings.corrupt-*.json"))
assert len(backups) == 1
def test_public_snapshot_does_not_expose_install_id(isolated_env):
settings = SettingsStore().load()
public = settings.public_snapshot()
assert "install_id" not in public
assert public["update_channel"] == "release"
def test_account_cache_round_trip_and_clear(isolated_env):
cache = AccountCache()
account = {"user": {"email": "a@example.com"}, "subscription": {"tier": "premium"}}
cache.save(account)
assert cache.load() == account
cache.clear()
assert cache.load() is None
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import zipfile
from pathlib import Path
from boltclam import paths
from boltclam.config import SettingsStore
from boltclam.logging_setup import redact
from boltclam.logs import build_bundle, redact_paths, sanitize_text, upload_bundle
from boltclam.models import ClamAVInfo, Detection
from boltclam.storage import Storage
from boltclam.system_info import collect
def test_redaction_covers_tokens_and_emails():
text = "key=bolt_live_abcdef password=hunter2 user aaron@example.com"
redacted = sanitize_text(text)
assert "bolt_live_abcdef" not in redacted
assert "hunter2" not in redacted
assert "aaron@example.com" not in redacted
assert "[email]" in redacted or "[redacted]" in redacted
def test_path_redaction_uses_userprofile(isolated_env):
sample = str(Path.home() / "Downloads" / "bad.exe")
assert "%USERPROFILE%" in redact_paths(sample)
assert "Downloads" in redact_paths(sample)
def test_bundle_contains_redacted_logs_and_scan_summary(isolated_env):
settings = SettingsStore().load()
storage = Storage()
scan_id = storage.create_scan("homes", [str(Path.home() / "Downloads")])
detection = Detection(path=str(Path.home() / "Downloads" / "bad.exe"),
signature="Win.Test", size=10)
detection.id = storage.add_detection(scan_id, detection)
storage.finish_scan(scan_id, status="completed", scanned_files=10,
infected_files=1, errors=0, duration_seconds=2, cancelled=False)
paths.logs_dir().mkdir(parents=True, exist_ok=True)
(paths.logs_dir() / "boltclam.log").write_text(
f"token=bolt_live_supersecret user aaron@example.com path={Path.home()}/x\n",
encoding="utf-8")
info = ClamAVInfo(available=True, path=Path("/fake/clamscan"),
engine_version="1.4.3", db_version="27452",
db_path=Path("/fake/db"), db_date="2026-10-22",
db_age_days=1.0)
snapshot = collect(settings, info, storage, realtime_running=False, force=True)
bundle = build_bundle(settings, info, system_snapshot=snapshot,
account={"user": {"email": "aaron@example.com"}},
realtime_roots=[str(Path.home() / "Downloads")])
assert bundle.is_file()
with zipfile.ZipFile(bundle) as archive:
names = set(archive.namelist())
assert {"manifest.json", "boltclam-logs.txt", "system-summary.json",
"README.txt"} <= names
logs = archive.read("boltclam-logs.txt").decode()
assert "bolt_live_supersecret" not in logs
assert "aaron@example.com" not in logs
assert "%USERPROFILE%" in logs
manifest = json.loads(archive.read("manifest.json"))
assert manifest["app"] == "BoltClam"
assert manifest["account"]["email"] == "aaron@example.com"
summary = json.loads(archive.read("system-summary.json"))
assert summary["stats"]["latest_scan"]["id"] == scan_id
class FakeApi:
def upload_diagnostics(self, path, metadata=None):
return {"id": "42/test.zip", "bytes": Path(path).stat().st_size}
def test_upload_bundle_returns_server_id(isolated_env):
settings = SettingsStore().load()
storage = Storage()
info = ClamAVInfo(available=False, error="not installed")
bundle = build_bundle(settings, info)
result = upload_bundle(FakeApi(), bundle, settings)
assert result["id"] == "42/test.zip"
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
import pytest
from boltclam.quarantine import QuarantineError, QuarantineService, delete_file, hash_file
from boltclam.storage import Storage
def _service(tmp_path) -> QuarantineService:
storage = Storage(tmp_path / "history.db")
return QuarantineService(storage, tmp_path / "quarantine")
def test_quarantine_moves_records_and_writes_metadata(tmp_path):
service = _service(tmp_path)
source = tmp_path / "malware.exe"
source.write_bytes(b"malware bytes")
digest = hash_file(source)
item = service.quarantine_file(source, "Win.Test", scan_id=7, reason="Unit test")
assert not source.exists()
destination = Path(item.quarantine_path)
assert destination.is_file()
assert destination.name.endswith(".quarantine")
assert item.sha256 == digest
assert service.storage.get_quarantine(item.id).signature == "Win.Test"
metadata = json.loads((service.root / f"{item.id}.json").read_text())
assert metadata["original_path"] == str(source)
assert metadata["reason"] == "Unit test"
def test_restore_to_original_or_chosen_path(tmp_path):
service = _service(tmp_path)
source = tmp_path / "bad.exe"
source.write_bytes(b"x")
item = service.quarantine_file(source, "Sig")
target = service.restore(item.id)
assert target.read_bytes() == b"x"
assert service.storage.get_quarantine(item.id).status == "restored"
source.write_bytes(b"y")
item2 = service.quarantine_file(source, "Sig")
chosen = tmp_path / "restored" / "safe.exe"
restored = service.restore(item2.id, chosen)
assert restored == chosen
assert chosen.read_bytes() == b"y"
def test_delete_quarantined_item_and_missing_original(tmp_path):
service = _service(tmp_path)
source = tmp_path / "bad.exe"
source.write_bytes(b"x")
item = service.quarantine_file(source, "Sig")
service.delete(item.id)
assert not Path(item.quarantine_path).exists()
assert service.storage.get_quarantine(item.id).status == "deleted"
with pytest.raises(QuarantineError):
service.restore(item.id)
def test_refuses_symlink_and_directory(tmp_path):
service = _service(tmp_path)
directory = tmp_path / "folder"
directory.mkdir()
with pytest.raises(QuarantineError):
service.quarantine_file(directory, "Sig")
real = tmp_path / "real.exe"
real.write_bytes(b"x")
link = tmp_path / "link.exe"
try:
link.symlink_to(real)
except OSError:
pytest.skip("symlinks unavailable")
with pytest.raises(QuarantineError):
service.quarantine_file(link, "Sig")
with pytest.raises(QuarantineError):
delete_file(directory)
def test_delete_file_clears_readonly_bit(tmp_path):
target = tmp_path / "readonly.exe"
target.write_bytes(b"x")
os.chmod(target, stat.S_IREAD)
delete_file(target)
assert not target.exists()
def test_restore_refuses_to_overwrite(tmp_path):
service = _service(tmp_path)
source = tmp_path / "bad.exe"
source.write_bytes(b"x")
item = service.quarantine_file(source, "Sig")
source.write_bytes(b"replacement")
with pytest.raises(QuarantineError):
service.restore(item.id)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from pathlib import Path
from boltclam import realtime
from boltclam.realtime import browser_download_roots, snapshot
from boltclam.winlock import copy_for_scan
def test_snapshot_finds_final_files_and_skips_partials(tmp_path):
root = tmp_path / "Downloads"
root.mkdir()
(root / "old.exe").write_bytes(b"abc")
(root / "new.crdownload").write_bytes(b"partial")
(root / ".hidden").write_bytes(b"hidden")
sub = root / "Sub"
sub.mkdir()
(sub / "inside.zip").write_bytes(b"zip")
found = snapshot([root])
names = {Path(value[2]).name for value in found.values()}
assert names == {"old.exe", "inside.zip"}
def test_chromium_download_preference_is_watched(tmp_path, monkeypatch):
local = tmp_path / "Local"
roaming = tmp_path / "Roaming"
monkeypatch.setenv("LOCALAPPDATA", str(local))
monkeypatch.setenv("APPDATA", str(roaming))
download = tmp_path / "Custom Downloads"
download.mkdir()
profile = local / "Google" / "Chrome" / "User Data" / "Default"
profile.mkdir(parents=True)
(profile / "Preferences").write_text(json.dumps({
"download": {"default_directory": str(download)}}), encoding="utf-8")
monkeypatch.setattr(realtime.paths, "browser_download_roots", lambda: [])
roots = browser_download_roots()
assert download.resolve() in [path.resolve() for path in roots]
def test_firefox_download_preference_is_watched(tmp_path, monkeypatch):
local = tmp_path / "Local"
roaming = tmp_path / "Roaming"
monkeypatch.setenv("LOCALAPPDATA", str(local))
monkeypatch.setenv("APPDATA", str(roaming))
download = tmp_path / "Firefox Downloads"
download.mkdir()
profile = roaming / "Mozilla" / "Firefox" / "Profiles" / "abc.default"
profile.mkdir(parents=True)
escaped = str(download).replace("\\", "\\\\")
(profile / "prefs.js").write_text(
f'user_pref("browser.download.dir", "{escaped}");\n', encoding="utf-8")
monkeypatch.setattr(realtime.paths, "browser_download_roots", lambda: [])
roots = browser_download_roots()
assert download.resolve() in [path.resolve() for path in roots]
def test_copy_for_scan_preserves_bytes(tmp_path):
source = tmp_path / "download.exe"
source.write_bytes(b"sample bytes")
destination = tmp_path / "copy.bin"
locker, exclusive = copy_for_scan(source, destination, attempts=1, delay=0)
try:
assert destination.read_bytes() == b"sample bytes"
assert isinstance(exclusive, bool)
finally:
locker.close()
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import json
from boltclam.models import Detection, QuarantineItem, RealtimeEvent
from boltclam.storage import Storage
def _storage(tmp_path) -> Storage:
return Storage(tmp_path / "history.db")
def test_scan_and_detection_lifecycle(tmp_path):
storage = _storage(tmp_path)
scan_id = storage.create_scan("homes", [r"C:\Users"], engine_version="1.4.3",
db_version="27452")
assert scan_id == 1
detection = Detection(path=r"C:\Users\a\bad.exe", signature="Win.Test", size=42)
detection.id = storage.add_detection(scan_id, detection)
storage.finish_scan(scan_id, status="completed", scanned_files=100,
infected_files=1, errors=2, duration_seconds=12.5,
cancelled=False)
record = storage.get_scan(scan_id)
assert record is not None
assert record.scope == "homes"
assert record.scope_label == "User homes"
assert record.scanned_files == 100
assert record.errors == 2
assert record.paths == [r"C:\Users"]
assert record.detections[0].signature == "Win.Test"
stats = storage.stats()
assert stats["active_threats"] == 1
assert stats["latest_scan"].id == scan_id
storage.update_detection_action(detection.id, "quarantined", "Q-1")
assert storage.stats()["active_threats"] == 0
assert storage.recent_detections()[0]["action"] == "quarantined"
def test_quarantine_rows_and_counts(tmp_path):
storage = _storage(tmp_path)
item = QuarantineItem(
id="Q-1", original_path=r"C:\bad.exe", quarantine_path=str(tmp_path / "q.bin"),
signature="Win.Test", sha256="a" * 64, size=10, created_at="2026-10-25T10:00:00Z",
scan_id=1)
storage.add_quarantine(item)
assert storage.quarantine_count() == 1
assert storage.get_quarantine("Q-1").signature == "Win.Test"
storage.set_quarantine_status("Q-1", "restored")
assert storage.quarantine_count() == 0
assert storage.list_quarantine()[0].status == "restored"
def test_realtime_events_and_diagnostics_are_serializable(tmp_path):
storage = _storage(tmp_path)
storage.add_realtime_event(RealtimeEvent(
created_at="2026-10-25T10:00:00Z", path=r"C:\Users\a\bad.exe",
signature="Win.Test", action="quarantined", detail="Locked"))
events = storage.list_realtime_events()
assert events[0].action == "quarantined"
snapshot = storage.diagnostics_snapshot()
json.dumps(snapshot)
assert snapshot["realtime"][0]["signature"] == "Win.Test"
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import time
from PyQt6.QtCore import Qt
from boltclam.api_client import ApiClient
from boltclam.auth import AuthService, CredentialStore
from boltclam.clamav import ClamAVService
from boltclam.config import AccountCache, SettingsStore
from boltclam.context import AppContext
from boltclam.quarantine import QuarantineService
from boltclam.storage import Storage
from boltclam.ui.login_dialog import LoginDialog
from boltclam.ui.main_window import MainWindow
ACCOUNT = {
"user": {"id": "1", "email": "aaron@example.com", "name": "Aaron",
"username": "aaron"},
"subscription": {"tier": "premium", "label": "Premium", "status": "active",
"current_period_end": "2026-12-01T00:00:00Z"},
"balance": {"weekly_remaining": 100, "topup": 0, "allowed": True},
"features": {"licensed": True},
}
class FakeApi:
def get_account(self):
return ACCOUNT
def get_updates(self, *args, **kwargs):
raise RuntimeError("offline")
def _context(tmp_path) -> AppContext:
store = SettingsStore()
store.save(store.load())
settings = store.update(last_update_check=time.time(), logging_enabled=False,
realtime_enabled=False)
storage = Storage()
quarantine = QuarantineService(storage, tmp_path / "quarantine")
auth = AuthService(
"http://127.0.0.1:9", install_id=settings.install_id,
credentials=CredentialStore(tmp_path / "credentials.dat"),
account_cache=AccountCache(tmp_path / "account.json"))
return AppContext(
settings_store=store, settings=settings, storage=storage,
quarantine=quarantine, clamav=ClamAVService(settings.clamav_path),
auth=auth, session_key="bolt_live_fake", account=ACCOUNT,
install_id=settings.install_id, api=FakeApi())
def test_main_window_builds_four_pages_and_switches_tabs(qapp, isolated_env):
context = _context(isolated_env)
window = MainWindow(context)
try:
window.show()
qapp.processEvents()
assert window.stack.count() == 4
assert [button.text() for button in window.nav_buttons] == [
"Homepage", "Scan & results", "About & updates", "Settings"]
assert window.scan_page.tabs.currentIndex() == 0
window.scan_page.tabs.setCurrentIndex(1)
assert window.scan_page.tabs.currentIndex() == 1
window._navigate(3)
assert window.stack.currentIndex() == 3
assert window.settings_page.channel_combo.currentData() == "release"
assert window.about_page.rows["account"].value_label.text() == "Aaron"
finally:
window._allow_close = True
window.close()
qapp.processEvents()
def test_login_dialog_builds_without_starting_a_worker(qapp, isolated_env):
auth = AuthService(
"http://127.0.0.1:9", install_id="test",
credentials=CredentialStore(isolated_env / "credentials.dat"),
account_cache=AccountCache(isolated_env / "account.json"))
dialog = LoginDialog(auth, notice="Test notice")
try:
assert "BoltClam" in dialog.windowTitle()
assert dialog.browser_button.isEnabled()
assert dialog.session is None
finally:
dialog.reject()
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from boltclam.models import UpdateAsset, UpdateFeed, UpdateRelease
from boltclam.updates import (
UpdateError,
UpdateService,
choose_asset,
is_newer,
parse_checksums,
parse_version,
)
def test_version_parsing_and_comparison():
assert parse_version("v1.2.3") == ((1, 2, 3), ())
assert parse_version("1.2.3-beta.1")
assert is_newer("v1.2.0", "1.1.9") is True
assert is_newer("v1.0.0", "1.0.0") is False
assert is_newer("v1.0.0", "1.0.0-beta.2") is True
assert is_newer("v1.0.0-beta.2", "1.0.0") is False
assert is_newer("v1.0.0-beta.2", "1.0.0-beta.1") is True
assert is_newer("not-a-version", "1.0.0") is False
def _release(tag="v1.2.0", prerelease=False, assets=None) -> UpdateRelease:
return UpdateRelease(id="2", tag=tag, name="Test", notes="notes",
prerelease=prerelease, published_at="2026-10-20T00:00:00Z",
html_url="https://git.example", assets=assets or [])
def test_choose_asset_prefers_a_setup_executable():
release = _release(assets=[
UpdateAsset("1", "BoltClam-1.2.0.zip", 5, "application/zip", "u1"),
UpdateAsset("2", "BoltClam-1.2.0-Setup.exe", 5, "application/octet-stream", "u2"),
])
assert choose_asset(release).name == "BoltClam-1.2.0-Setup.exe"
def test_choose_asset_falls_back_to_zip():
release = _release(assets=[
UpdateAsset("1", "notes.txt", 5, "text/plain", "u1"),
UpdateAsset("2", "BoltClam-windows.zip", 5, "application/zip", "u2"),
])
assert choose_asset(release).name == "BoltClam-windows.zip"
assert choose_asset(_release(assets=[])) is None
def test_feed_from_api_preserves_release_fields():
feed = UpdateFeed.from_api({
"channel": "beta",
"latest": {"id": "3", "tag": "v2.0.0-beta", "name": "Beta",
"notes": "notes", "prerelease": True,
"published_at": "2026-10-22T00:00:00Z",
"assets": [{"id": "9", "name": "a.exe", "size": 12,
"content_type": "x", "download_url": "u"}]},
"releases": [],
"checked_at": "2026-10-25T00:00:00Z",
})
assert feed.channel == "beta"
assert feed.latest.tag == "v2.0.0-beta"
assert feed.latest.assets[0].name == "a.exe"
def test_parse_checksums():
digest = "a" * 64
assert parse_checksums(f"{digest} BoltClam-Setup.exe\n") == {
"BoltClam-Setup.exe": digest}
class FakeApi:
def __init__(self, checksum_text: str | None = None, payload: bytes = b"payload"):
self.checksum_text = checksum_text
self.payload = payload
def download_asset(self, url, destination, progress=None):
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
data = self.checksum_text.encode() if self.checksum_text and "SUM" in url else self.payload
destination.write_bytes(data)
return destination, hashlib.sha256(data).hexdigest()
def _download_release(checksum: str) -> UpdateRelease:
asset = UpdateAsset("1", "BoltClam-Setup.exe", 7, "application/octet-stream",
"https://api.example/asset")
checksum_asset = UpdateAsset("2", "SHA256SUMS.txt", 100, "text/plain",
"https://api.example/SUM")
return _release(assets=[asset, checksum_asset])
def test_download_verifies_a_matching_checksum(isolated_env):
payload = b"payload"
digest = hashlib.sha256(payload).hexdigest()
release = _download_release(digest)
service = UpdateService(FakeApi(f"{digest} BoltClam-Setup.exe\n", payload))
result = service.download(release, release.assets[0])
assert result.path.read_bytes() == payload
assert result.verified is True
assert result.expected_sha256 == digest
def test_download_rejects_a_bad_checksum(isolated_env):
release = _download_release("b" * 64)
service = UpdateService(FakeApi(f"{'b' * 64} BoltClam-Setup.exe\n", b"payload"))
with pytest.raises(UpdateError):
service.download(release, release.assets[0])
def test_check_returns_the_feed(isolated_env):
class Api:
def get_updates(self, channel, force=False):
return {"channel": channel, "latest": None, "releases": []}
feed = UpdateService(Api(), "beta").check()
assert feed.channel == "beta"
assert feed.latest is None