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:
@@ -0,0 +1,5 @@
|
|||||||
|
*.exe binary
|
||||||
|
*.ico binary
|
||||||
|
*.png binary
|
||||||
|
*.ttf binary
|
||||||
|
*.zip binary
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_windows.ps1" %*
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,78 @@
|
|||||||
|
# Packaging
|
||||||
|
|
||||||
|
## Windows-native build
|
||||||
|
|
||||||
|
On Windows 10/11 with Python 3.11 x64:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build_windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script creates `.venv-build`, installs PyInstaller, builds
|
||||||
|
`dist\BoltClam\BoltClam.exe`, zips it, and runs Inno Setup when it is
|
||||||
|
installed. `-OneFile` builds a single executable instead.
|
||||||
|
|
||||||
|
## Portable cross-build (Linux/macOS)
|
||||||
|
|
||||||
|
`build_portable_windows.py` downloads the official Python embeddable
|
||||||
|
distribution and Windows PyQt6 wheels, copies `src/boltclam` into `app/`,
|
||||||
|
adds `resources/`, and places a tiny launcher at the root. It does not run
|
||||||
|
the Windows app on the build host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python packaging/build_portable_windows.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The build uses the checked-in `BoltClam-launcher.exe` unless `--zig` (or a
|
||||||
|
`zig` on `PATH`) is available, in which case it compiles `launcher.c` from
|
||||||
|
source. The prebuilt launcher is a 176,640-byte GUI-subsystem PE32+ binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
SHA-256 95cdb1e0a652f0b2e3870220cf1ec79c1e2972bc56e307bcb1381b2b9591a24b
|
||||||
|
```
|
||||||
|
|
||||||
|
To rebuild it with Zig 0.14.1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zig cc -target x86_64-windows-gnu -O2 -Wl,--subsystem,windows \
|
||||||
|
packaging/launcher.c -o packaging/BoltClam-launcher.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
The output portable package is written to
|
||||||
|
`dist/BoltClam-1.0.0-windows-x64-portable.zip` with `dist/SHA256SUMS.txt`.
|
||||||
|
It is unsigned; the release checksum is the integrity signal.
|
||||||
|
|
||||||
|
## Layout of the portable package
|
||||||
|
|
||||||
|
```
|
||||||
|
BoltClam/
|
||||||
|
BoltClam.exe GUI launcher
|
||||||
|
pythonw.exe embedded Python 3.11.9
|
||||||
|
python311.dll
|
||||||
|
python311.zip stdlib
|
||||||
|
python311._pth
|
||||||
|
app/boltclam/ app source
|
||||||
|
resources/ icon, fonts, QSS rasters
|
||||||
|
Lib/site-packages/ Windows PyQt6 6.11 wheels
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the whole folder together. The launcher refuses to start if
|
||||||
|
`pythonw.exe` or `app/` is missing rather than showing a silent failure.
|
||||||
|
If the app exits before a window appears, run `python.exe -m boltclam` from
|
||||||
|
the extracted folder in a command prompt to see the import/startup error.
|
||||||
|
|
||||||
|
## Release upload
|
||||||
|
|
||||||
|
The `Windows release` workflow runs `build_windows.ps1` and uploads the
|
||||||
|
installer/zip to a Gitea release. On a machine without the workflow, after
|
||||||
|
building:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GITEA_TOKEN = "…"
|
||||||
|
.\packaging\publish_release.ps1 -Tag v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`publish_release.ps1` creates (or reuses) the release and uploads
|
||||||
|
`dist/BoltClam-*.exe`, `dist/BoltClam-*.zip`, and `dist/SHA256SUMS.txt`.
|
||||||
|
Gitea's asset endpoint expects multipart form data, so the script uses
|
||||||
|
`curl.exe -F attachment=@…`.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the portable Windows BoltClam package from a Linux/macOS host.
|
||||||
|
|
||||||
|
The result is a folder and zip containing:
|
||||||
|
|
||||||
|
BoltClam.exe tiny GUI launcher (packaging/launcher.c)
|
||||||
|
pythonw.exe official Windows embeddable Python 3.11
|
||||||
|
python311._pth points at app/ and the Windows PyQt6 wheels
|
||||||
|
app/boltclam/ BoltClam source
|
||||||
|
resources/ fonts and icon assets
|
||||||
|
Lib/site-packages/ Windows PyQt6 wheels
|
||||||
|
|
||||||
|
This is the no-Windows-machine path. On a Windows build host, prefer
|
||||||
|
`build_windows.ps1` / PyInstaller for a branded one-file EXE and Inno Setup
|
||||||
|
installer. This script downloads public build inputs and never uses a
|
||||||
|
platform-specific Python interpreter to run the Windows app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD = ROOT / "build" / "portable"
|
||||||
|
CACHE = ROOT / "build" / "cache"
|
||||||
|
DIST = ROOT / "dist"
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
PYTHON_VERSION = "3.11.9"
|
||||||
|
PYTHON_URL = (f"https://www.python.org/ftp/python/{PYTHON_VERSION}/"
|
||||||
|
f"python-{PYTHON_VERSION}-embed-amd64.zip")
|
||||||
|
PYTHON_SHA256 = "009d6bf7e3b2ddca3d784fa09f90fe54336d5b60f0e0f305c37f400bf83cfd3b"
|
||||||
|
PYQT_PACKAGES = ("PyQt6==6.11.0", "PyQt6-Qt6==6.11.2", "PyQt6-sip==13.13.0")
|
||||||
|
ZIG_VERSION = "0.14.1"
|
||||||
|
ZIG_URL = f"https://ziglang.org/download/{ZIG_VERSION}/zig-x86_64-linux-{ZIG_VERSION}.tar.xz"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def download(url: str, destination: Path, expected: str = "") -> Path:
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if destination.is_file() and (not expected or _sha256(destination) == expected):
|
||||||
|
return destination
|
||||||
|
print(f"Downloading {url}")
|
||||||
|
temp = destination.with_suffix(destination.suffix + ".part")
|
||||||
|
with urllib.request.urlopen(url, timeout=120) as response, open(temp, "wb") as handle:
|
||||||
|
shutil.copyfileobj(response, handle)
|
||||||
|
if expected and _sha256(temp) != expected:
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(f"Checksum mismatch for {url}")
|
||||||
|
os.replace(temp, destination)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def find_zig(explicit: str) -> Path | None:
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit)
|
||||||
|
found = shutil.which("zig")
|
||||||
|
if found:
|
||||||
|
return Path(found)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_zig() -> Path:
|
||||||
|
archive = download(ZIG_URL, CACHE / f"zig-{ZIG_VERSION}.tar.xz")
|
||||||
|
target = CACHE / f"zig-x86_64-linux-{ZIG_VERSION}"
|
||||||
|
if not (target / "zig").is_file():
|
||||||
|
print("Extracting Zig")
|
||||||
|
with tarfile.open(archive, "r:xz") as tar:
|
||||||
|
tar.extractall(CACHE)
|
||||||
|
return target / "zig"
|
||||||
|
|
||||||
|
|
||||||
|
def build(use_zig: str = "", keep_build: bool = True) -> Path:
|
||||||
|
if BUILD.exists():
|
||||||
|
shutil.rmtree(BUILD)
|
||||||
|
BUILD.mkdir(parents=True)
|
||||||
|
CACHE.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
python_zip = download(PYTHON_URL, CACHE / f"python-{PYTHON_VERSION}-embed-amd64.zip",
|
||||||
|
PYTHON_SHA256)
|
||||||
|
with zipfile.ZipFile(python_zip) as archive:
|
||||||
|
archive.extractall(BUILD)
|
||||||
|
|
||||||
|
app = BUILD / "app"
|
||||||
|
app.mkdir()
|
||||||
|
shutil.copytree(ROOT / "src" / "boltclam", app / "boltclam",
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
|
||||||
|
# paths.resource_dir() resolves to parents[2]/resources, i.e. the
|
||||||
|
# portable root, for app/boltclam/paths.py. Keep resources there.
|
||||||
|
shutil.copytree(ROOT / "resources", BUILD / "resources")
|
||||||
|
|
||||||
|
site_packages = BUILD / "Lib" / "site-packages"
|
||||||
|
site_packages.mkdir(parents=True)
|
||||||
|
print("Installing Windows PyQt6 wheels")
|
||||||
|
subprocess.check_call([
|
||||||
|
sys.executable, "-m", "pip", "install", "--quiet", "--target", str(site_packages),
|
||||||
|
"--platform", "win_amd64", "--python-version", "3.11",
|
||||||
|
"--implementation", "cp", "--abi", "cp311", "--only-binary=:all:",
|
||||||
|
*PYQT_PACKAGES,
|
||||||
|
])
|
||||||
|
(BUILD / "python311._pth").write_text(
|
||||||
|
"python311.zip\n.\nLib\\site-packages\napp\nimport site\n", encoding="utf-8")
|
||||||
|
|
||||||
|
zig = find_zig(use_zig)
|
||||||
|
launcher_source = ROOT / "packaging" / "launcher.c"
|
||||||
|
launcher_binary = ROOT / "packaging" / "BoltClam-launcher.exe"
|
||||||
|
if zig is None and launcher_binary.is_file():
|
||||||
|
print("Using the checked-in portable launcher")
|
||||||
|
shutil.copy2(launcher_binary, BUILD / "BoltClam.exe")
|
||||||
|
else:
|
||||||
|
if zig is None:
|
||||||
|
zig = download_zig()
|
||||||
|
print(f"Compiling launcher with {zig}")
|
||||||
|
subprocess.check_call([
|
||||||
|
str(zig), "cc", "-target", "x86_64-windows-gnu", "-O2",
|
||||||
|
"-Wl,--subsystem,windows", str(launcher_source),
|
||||||
|
"-o", str(BUILD / "BoltClam.exe"),
|
||||||
|
])
|
||||||
|
|
||||||
|
(BUILD / "README.txt").write_text(
|
||||||
|
"BoltClam portable for Windows x64\r\n"
|
||||||
|
"=================================\r\n\r\n"
|
||||||
|
"Double-click BoltClam.exe. Sign in with your Bolt account when the\r\n"
|
||||||
|
"browser opens. ClamAV is installed separately: install it from\r\n"
|
||||||
|
"https://www.clamav.net/downloads, then open Settings and point BoltClam\r\n"
|
||||||
|
"at clamscan.exe (Auto-detect usually finds it).\r\n\r\n"
|
||||||
|
"The first definition update downloads ClamAV signatures into\r\n"
|
||||||
|
"%LOCALAPPDATA%\\BoltClam\\clamav-db. No administrator rights are needed\r\n"
|
||||||
|
"for BoltClam's own data.\r\n\r\n"
|
||||||
|
"This build is unsigned. Windows SmartScreen may warn about a new\r\n"
|
||||||
|
"publisher; use the checksum published with the release.\r\n\r\n"
|
||||||
|
"Keep the whole folder together. BoltClam.exe is only a launcher for\r\n"
|
||||||
|
"pythonw.exe and app/.\r\n\r\n"
|
||||||
|
"Troubleshooting: if BoltClam.exe does nothing, open a command prompt in\r\n"
|
||||||
|
"this folder and run `python.exe -m boltclam` to see the startup error.\r\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
DIST.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_path = DIST / f"BoltClam-{VERSION}-windows-x64-portable.zip"
|
||||||
|
print(f"Writing {zip_path}")
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||||
|
for path in sorted(BUILD.rglob("*")):
|
||||||
|
if path.is_file():
|
||||||
|
archive.write(path, arcname="BoltClam/" + path.relative_to(BUILD).as_posix())
|
||||||
|
digest = _sha256(zip_path)
|
||||||
|
(DIST / "SHA256SUMS.txt").write_text(f"{digest} {zip_path.name}\n", encoding="utf-8")
|
||||||
|
print(f"{zip_path} ({zip_path.stat().st_size / (1024 * 1024):.1f} MiB)")
|
||||||
|
print(f"SHA-256 {digest}")
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--zig", default="", help="path to a Zig executable")
|
||||||
|
args = parser.parse_args()
|
||||||
|
build(use_zig=args.zig)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* BoltClam portable Windows launcher.
|
||||||
|
*
|
||||||
|
* This is a tiny GUI-subsystem bootstrap for the portable package. It finds
|
||||||
|
* the runtime beside itself, starts pythonw.exe with the bundled app, and
|
||||||
|
* exits immediately. It contains no product logic; the Qt application lives
|
||||||
|
* in app/boltclam.
|
||||||
|
*
|
||||||
|
* Build (from the repository root):
|
||||||
|
* zig cc -target x86_64-windows-gnu -O2 -mwindows packaging/launcher.c \
|
||||||
|
* -o build/portable/BoltClam.exe
|
||||||
|
*/
|
||||||
|
#define UNICODE
|
||||||
|
#define _UNICODE
|
||||||
|
#include <windows.h>
|
||||||
|
#include <wchar.h>
|
||||||
|
|
||||||
|
static void fail(const wchar_t *message) {
|
||||||
|
MessageBoxW(NULL, message, L"BoltClam", MB_OK | MB_ICONERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR command_line, int show) {
|
||||||
|
(void)instance;
|
||||||
|
(void)previous;
|
||||||
|
(void)command_line;
|
||||||
|
(void)show;
|
||||||
|
|
||||||
|
wchar_t exe[MAX_PATH];
|
||||||
|
DWORD length = GetModuleFileNameW(NULL, exe, MAX_PATH);
|
||||||
|
if (length == 0 || length >= MAX_PATH) {
|
||||||
|
fail(L"BoltClam could not locate its own executable.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
wchar_t *slash = wcsrchr(exe, L'\\');
|
||||||
|
if (slash == NULL) {
|
||||||
|
fail(L"BoltClam could not locate its runtime folder.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
*slash = L'\0';
|
||||||
|
|
||||||
|
wchar_t pythonw[MAX_PATH * 2];
|
||||||
|
_snwprintf(pythonw, MAX_PATH * 2, L"%s\\pythonw.exe", exe);
|
||||||
|
if (GetFileAttributesW(pythonw) == INVALID_FILE_ATTRIBUTES) {
|
||||||
|
fail(L"BoltClam's runtime is incomplete: pythonw.exe is missing.\n\n"
|
||||||
|
L"Extract the whole portable folder, not just BoltClam.exe.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
wchar_t appdir[MAX_PATH * 2];
|
||||||
|
_snwprintf(appdir, MAX_PATH * 2, L"%s\\app", exe);
|
||||||
|
if (GetFileAttributesW(appdir) == INVALID_FILE_ATTRIBUTES) {
|
||||||
|
fail(L"BoltClam's runtime is incomplete: the app folder is missing.\n\n"
|
||||||
|
L"Extract the whole portable folder, not just BoltClam.exe.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
SetCurrentDirectoryW(appdir);
|
||||||
|
|
||||||
|
wchar_t cmdline[MAX_PATH * 4];
|
||||||
|
_snwprintf(cmdline, MAX_PATH * 4, L"\"%s\" -m boltclam", pythonw);
|
||||||
|
|
||||||
|
STARTUPINFOW startup;
|
||||||
|
PROCESS_INFORMATION process;
|
||||||
|
ZeroMemory(&startup, sizeof(startup));
|
||||||
|
ZeroMemory(&process, sizeof(process));
|
||||||
|
startup.cb = sizeof(startup);
|
||||||
|
if (!CreateProcessW(pythonw, cmdline, NULL, NULL, FALSE,
|
||||||
|
CREATE_UNICODE_ENVIRONMENT, NULL, appdir,
|
||||||
|
&startup, &process)) {
|
||||||
|
wchar_t error[512];
|
||||||
|
_snwprintf(error, 512,
|
||||||
|
L"BoltClam could not start its runtime (error %lu).",
|
||||||
|
(unsigned long)GetLastError());
|
||||||
|
fail(error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
CloseHandle(process.hProcess);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#requires -Version 5.1
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Tag = "",
|
||||||
|
[string]$Token = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$repo = $env:GITHUB_REPOSITORY
|
||||||
|
if (-not $repo) { $repo = "TheMajesticNetwork/BoltClam" }
|
||||||
|
$server = $env:GITHUB_SERVER_URL
|
||||||
|
if (-not $server) { $server = "https://git.themajesticnetwork.com" }
|
||||||
|
$server = $server.TrimEnd("/")
|
||||||
|
$api = "$server/api/v1/repos/$repo"
|
||||||
|
|
||||||
|
if (-not $Tag) {
|
||||||
|
$Tag = $env:GITHUB_REF_NAME
|
||||||
|
}
|
||||||
|
if (-not $Tag) { $Tag = "v1.0.0" }
|
||||||
|
if (-not $Token) { $Token = $env:GITEA_TOKEN }
|
||||||
|
if (-not $Token) { $Token = $env:GITHUB_TOKEN }
|
||||||
|
if (-not $Token) {
|
||||||
|
throw "Set GITEA_TOKEN (a Gitea token with repo write access) before publishing."
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
Authorization = "token $Token"
|
||||||
|
Accept = "application/json"
|
||||||
|
}
|
||||||
|
$releaseBody = @{
|
||||||
|
tag_name = $Tag
|
||||||
|
name = "BoltClam $Tag"
|
||||||
|
body = "BoltClam $Tag`n`nSee SHA256SUMS.txt for checksums."
|
||||||
|
draft = $false
|
||||||
|
prerelease = ($Tag -match "-")
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Method Post -Uri "$api/releases" `
|
||||||
|
-Headers $headers -ContentType "application/json" -Body $releaseBody
|
||||||
|
Write-Host "Created release $($release.tag_name)"
|
||||||
|
} catch {
|
||||||
|
$status = $_.Exception.Response.StatusCode.value__
|
||||||
|
if ($status -ne 409) { throw }
|
||||||
|
$release = Invoke-RestMethod -Uri "$api/releases/tags/$Tag" -Headers $headers
|
||||||
|
Write-Host "Release $Tag already exists; adding assets"
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = @()
|
||||||
|
try {
|
||||||
|
$existing = @(Invoke-RestMethod -Uri "$api/releases/$($release.id)/assets" -Headers $headers)
|
||||||
|
} catch {
|
||||||
|
$existing = @()
|
||||||
|
}
|
||||||
|
foreach ($asset in $existing) {
|
||||||
|
if ($asset.name -match '^BoltClam-.*\.(exe|zip)$' -or $asset.name -eq "SHA256SUMS.txt") {
|
||||||
|
Invoke-RestMethod -Method Delete `
|
||||||
|
-Uri "$api/releases/$($release.id)/assets/$($asset.id)" -Headers $headers | Out-Null
|
||||||
|
Write-Host "Removed old asset $($asset.name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = Get-ChildItem -Path (Join-Path $Root "dist") -File | Where-Object {
|
||||||
|
$_.Name -match '^BoltClam-.*\.(exe|zip)$' -or $_.Name -eq "SHA256SUMS.txt"
|
||||||
|
}
|
||||||
|
if (-not $files) { throw "No release artifacts found in dist." }
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$name = [uri]::EscapeDataString($file.Name)
|
||||||
|
# Gitea's attachment endpoint is multipart/form-data, unlike GitHub's
|
||||||
|
# binary upload. Windows 10/11 ships curl.exe; use it for the file field.
|
||||||
|
& curl.exe --fail --silent --show-error -X POST `
|
||||||
|
-H "Authorization: token $Token" `
|
||||||
|
-F "attachment=@$($file.FullName)" `
|
||||||
|
"$api/releases/$($release.id)/assets?name=$name"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Upload failed for $($file.Name)."
|
||||||
|
}
|
||||||
|
Write-Host "Uploaded $($file.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Release published: $($release.html_url)"
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=7.4
|
||||||
|
pyinstaller>=6.6
|
||||||
@@ -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.
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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])])
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from boltclam.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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__}"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user