commit addd00364d5053ca7850577982ccc75003a27c59 Author: TheMajesticMagician Date: Sun Oct 25 16:15:50 2026 -0600 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a9e147d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.exe binary +*.ico binary +*.png binary +*.ttf binary +*.zip binary diff --git a/.gitea/workflows/windows-release.yml b/.gitea/workflows/windows-release.yml new file mode 100644 index 0000000..e951d97 --- /dev/null +++ b/.gitea/workflows/windows-release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4178a3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/BoltClam.spec b/BoltClam.spec new file mode 100644 index 0000000..5f40f42 --- /dev/null +++ b/BoltClam.spec @@ -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", + ) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..66f1c28 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..512c22e --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# BoltClam + +BoltClam is a Windows desktop front-end for **ClamAV**, built with PyQt6 and +signed in with a Bolt account. It scans every user's home or the full disk, +quarantines or deletes what ClamAV finds, watches browser downloads, and +receives update notifications from the private Gitea release feed. + +![BoltClam icon](resources/boltclam.png) + +## What it does + +- **Homepage** — a live protection summary: a shell gauge for engine, + definitions, real-time protection and system firewall; security concerns; + network details; recent detections; the signed-in user and Bolt plan. +- **Scan & results** — user-home or full-disk scans, live progress, scan + history, per-detection actions, and a quarantine manager with restore. +- **About & updates** — app version, account and subscription, ClamAV + version/definitions, release notes, beta/release channels, and + checksum-verified downloads. +- **Settings** — update channel, ClamAV install location, extra clamscan + options, real-time protection, automatic response, logging/debug, and + opt-in diagnostics upload to Bolt. +- **Browser download protection** — watches the configured download folder + of Chrome, Edge, Brave, Firefox, Vivaldi and Opera (plus every readable + user's Downloads folder), waits for the file to settle, holds it with an + exclusive Windows handle while ClamAV scans a copy, then quarantines or + deletes it according to the setting. +- **Bolt sign-in** — opens a Bolt-hosted sign-in page in the default + browser, receives a one-time code on a loopback listener, and exchanges it + through PKCE for an ordinary `bolt_live_...` API key. The key is stored + with Windows DPAPI; the password is never seen or stored by BoltClam. + +## Requirements + +- Windows 10 or 11, x64. +- [ClamAV for Windows](https://www.clamav.net/downloads) installed separately. + BoltClam does not bundle ClamAV binaries. +- A Bolt account (the same account used by the iOS/Android/Boltville clients). +- Internet access for sign-in, updates, and the first signature download. +- Administrator rights are optional but improve coverage of other users' + profiles and protected files. + +## Install + +### Portable build + +1. Download `BoltClam-1.0.0-windows-x64-portable.zip` and extract the whole + folder (not just `BoltClam.exe`). +2. Run `BoltClam.exe`. +3. Sign in with your Bolt account in the browser. +4. Install ClamAV, open **Settings → ClamAV engine**, choose + `clamscan.exe` (or use **Auto-detect**), and click **Update definitions**. +5. Run a scan from **Scan & results**. + +The portable build is assembled by +`packaging/build_portable_windows.py` from official Python Embeddable, +PyQt6 wheels, and the tiny `packaging/launcher.c` bootstrap. It is not signed; +Windows SmartScreen may warn about an unknown publisher. Compare the SHA-256 +in `SHA256SUMS.txt` with the release. + +### Installer / PyInstaller + +On a Windows build machine with Python 3.11 x64: + +```powershell +.\build_windows.ps1 +``` + +This creates a one-folder build under `dist\BoltClam\`, a zip, and — when +Inno Setup 6 is installed — `dist\BoltClam-1.0.0-Setup.exe`. +Use `.\build_windows.ps1 -OneFile` for a single-file `BoltClam.exe`. +`build_windows.bat` is a double-click wrapper. + +## Updates + +Release notifications are read from: + + + +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//assets/` | 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). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..ee70b1b --- /dev/null +++ b/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. diff --git a/build_windows.bat b/build_windows.bat new file mode 100644 index 0000000..7f9b93b --- /dev/null +++ b/build_windows.bat @@ -0,0 +1,3 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_windows.ps1" %* diff --git a/build_windows.ps1 b/build_windows.ps1 new file mode 100644 index 0000000..035e9f6 --- /dev/null +++ b/build_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" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..589b84d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..8e3f91e --- /dev/null +++ b/docs/SECURITY.md @@ -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. diff --git a/installer/BoltClam.iss b/installer/BoltClam.iss new file mode 100644 index 0000000..898d24e --- /dev/null +++ b/installer/BoltClam.iss @@ -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 diff --git a/packaging/BoltClam-launcher.exe b/packaging/BoltClam-launcher.exe new file mode 100755 index 0000000..0bdcd6b Binary files /dev/null and b/packaging/BoltClam-launcher.exe differ diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..f5038f0 --- /dev/null +++ b/packaging/README.md @@ -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=@…`. diff --git a/packaging/build_portable_windows.py b/packaging/build_portable_windows.py new file mode 100644 index 0000000..f6874c2 --- /dev/null +++ b/packaging/build_portable_windows.py @@ -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()) diff --git a/packaging/launcher.c b/packaging/launcher.c new file mode 100644 index 0000000..66a38f3 --- /dev/null +++ b/packaging/launcher.c @@ -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 +#include + +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; +} diff --git a/packaging/publish_release.ps1 b/packaging/publish_release.ps1 new file mode 100644 index 0000000..451c5d2 --- /dev/null +++ b/packaging/publish_release.ps1 @@ -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)" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3376dc3 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a3cff69 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=7.4 +pyinstaller>=6.6 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e026139 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyQt6>=6.6,<7 diff --git a/resources/boltclam.ico b/resources/boltclam.ico new file mode 100644 index 0000000..6f90543 Binary files /dev/null and b/resources/boltclam.ico differ diff --git a/resources/boltclam.png b/resources/boltclam.png new file mode 100644 index 0000000..5336c19 Binary files /dev/null and b/resources/boltclam.png differ diff --git a/resources/check.png b/resources/check.png new file mode 100644 index 0000000..d49e751 Binary files /dev/null and b/resources/check.png differ diff --git a/resources/chevron_down.png b/resources/chevron_down.png new file mode 100644 index 0000000..62bef54 Binary files /dev/null and b/resources/chevron_down.png differ diff --git a/resources/fonts/AtkinsonHyperlegible-Bold.ttf b/resources/fonts/AtkinsonHyperlegible-Bold.ttf new file mode 100644 index 0000000..ca3f613 Binary files /dev/null and b/resources/fonts/AtkinsonHyperlegible-Bold.ttf differ diff --git a/resources/fonts/AtkinsonHyperlegible-Regular.ttf b/resources/fonts/AtkinsonHyperlegible-Regular.ttf new file mode 100644 index 0000000..b3cc420 Binary files /dev/null and b/resources/fonts/AtkinsonHyperlegible-Regular.ttf differ diff --git a/resources/fonts/BricolageGrotesque.ttf b/resources/fonts/BricolageGrotesque.ttf new file mode 100644 index 0000000..9a4ca27 Binary files /dev/null and b/resources/fonts/BricolageGrotesque.ttf differ diff --git a/resources/fonts/JetBrainsMono-Medium.ttf b/resources/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..dc2e5d0 Binary files /dev/null and b/resources/fonts/JetBrainsMono-Medium.ttf differ diff --git a/resources/fonts/JetBrainsMono-Regular.ttf b/resources/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/resources/fonts/JetBrainsMono-Regular.ttf differ diff --git a/resources/fonts/OFL-Atkinson.txt b/resources/fonts/OFL-Atkinson.txt new file mode 100644 index 0000000..2befc89 --- /dev/null +++ b/resources/fonts/OFL-Atkinson.txt @@ -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. diff --git a/resources/fonts/OFL-Bricolage.txt b/resources/fonts/OFL-Bricolage.txt new file mode 100644 index 0000000..a397658 --- /dev/null +++ b/resources/fonts/OFL-Bricolage.txt @@ -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. diff --git a/resources/fonts/OFL-JetBrainsMono.txt b/resources/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/resources/fonts/OFL-JetBrainsMono.txt @@ -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. diff --git a/resources/radio.png b/resources/radio.png new file mode 100644 index 0000000..10a28e1 Binary files /dev/null and b/resources/radio.png differ diff --git a/resources/version_info.txt b/resources/version_info.txt new file mode 100644 index 0000000..12d08c7 --- /dev/null +++ b/resources/version_info.txt @@ -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])]) + ] +) diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py new file mode 100644 index 0000000..e2c1432 --- /dev/null +++ b/scripts/generate_assets.py @@ -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("= 256 else size + height = 0 if size >= 256 else size + entries += struct.pack(" 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()) diff --git a/src/boltclam/__init__.py b/src/boltclam/__init__.py new file mode 100644 index 0000000..be38a5a --- /dev/null +++ b/src/boltclam/__init__.py @@ -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" diff --git a/src/boltclam/__main__.py b/src/boltclam/__main__.py new file mode 100644 index 0000000..e4f017d --- /dev/null +++ b/src/boltclam/__main__.py @@ -0,0 +1,4 @@ +from boltclam.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/boltclam/api_client.py b/src/boltclam/api_client.py new file mode 100644 index 0000000..f90d41a --- /dev/null +++ b/src/boltclam/api_client.py @@ -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 diff --git a/src/boltclam/app.py b/src/boltclam/app.py new file mode 100644 index 0000000..0c18282 --- /dev/null +++ b/src/boltclam/app.py @@ -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 diff --git a/src/boltclam/auth.py b/src/boltclam/auth.py new file mode 100644 index 0000000..614b9ae --- /dev/null +++ b/src/boltclam/auth.py @@ -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:/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""" + +{html.escape(title)} +
+

{html.escape(title)}

{html.escape(message)}

""".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() diff --git a/src/boltclam/clamav.py b/src/boltclam/clamav.py new file mode 100644 index 0000000..0befee1 --- /dev/null +++ b/src/boltclam/clamav.py @@ -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 diff --git a/src/boltclam/config.py b/src/boltclam/config.py new file mode 100644 index 0000000..91e4b1f --- /dev/null +++ b/src/boltclam/config.py @@ -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 diff --git a/src/boltclam/context.py b/src/boltclam/context.py new file mode 100644 index 0000000..b245c30 --- /dev/null +++ b/src/boltclam/context.py @@ -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) diff --git a/src/boltclam/logging_setup.py b/src/boltclam/logging_setup.py new file mode 100644 index 0000000..7ced0a3 --- /dev/null +++ b/src/boltclam/logging_setup.py @@ -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()] diff --git a/src/boltclam/logs.py b/src/boltclam/logs.py new file mode 100644 index 0000000..12dd59d --- /dev/null +++ b/src/boltclam/logs.py @@ -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 diff --git a/src/boltclam/models.py b/src/boltclam/models.py new file mode 100644 index 0000000..18633e6 --- /dev/null +++ b/src/boltclam/models.py @@ -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()) diff --git a/src/boltclam/paths.py b/src/boltclam/paths.py new file mode 100644 index 0000000..bd2396a --- /dev/null +++ b/src/boltclam/paths.py @@ -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 diff --git a/src/boltclam/quarantine.py b/src/boltclam/quarantine.py new file mode 100644 index 0000000..c2efb04 --- /dev/null +++ b/src/boltclam/quarantine.py @@ -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) diff --git a/src/boltclam/realtime.py b/src/boltclam/realtime.py new file mode 100644 index 0000000..262e4c9 --- /dev/null +++ b/src/boltclam/realtime.py @@ -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) diff --git a/src/boltclam/scanner.py b/src/boltclam/scanner.py new file mode 100644 index 0000000..442e8c5 --- /dev/null +++ b/src/boltclam/scanner.py @@ -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 diff --git a/src/boltclam/storage.py b/src/boltclam/storage.py new file mode 100644 index 0000000..17fbd47 --- /dev/null +++ b/src/boltclam/storage.py @@ -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) + ], + } diff --git a/src/boltclam/system_info.py b/src/boltclam/system_info.py new file mode 100644 index 0000000..46b9414 --- /dev/null +++ b/src/boltclam/system_info.py @@ -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 diff --git a/src/boltclam/ui/__init__.py b/src/boltclam/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/boltclam/ui/about_page.py b/src/boltclam/ui/about_page.py new file mode 100644 index 0000000..7b80ac1 --- /dev/null +++ b/src/boltclam/ui/about_page.py @@ -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'{label}') + 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 diff --git a/src/boltclam/ui/brand.py b/src/boltclam/ui/brand.py new file mode 100644 index 0000000..dc12a8f --- /dev/null +++ b/src/boltclam/ui/brand.py @@ -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 diff --git a/src/boltclam/ui/components.py b/src/boltclam/ui/components.py new file mode 100644 index 0000000..44a9ba2 --- /dev/null +++ b/src/boltclam/ui/components.py @@ -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) diff --git a/src/boltclam/ui/home_page.py b/src/boltclam/ui/home_page.py new file mode 100644 index 0000000..4025ad8 --- /dev/null +++ b/src/boltclam/ui/home_page.py @@ -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'Manage Bolt account') + 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) diff --git a/src/boltclam/ui/login_dialog.py b/src/boltclam/ui/login_dialog.py new file mode 100644 index 0000000..fceb57d --- /dev/null +++ b/src/boltclam/ui/login_dialog.py @@ -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'Create or manage your account') + 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() diff --git a/src/boltclam/ui/main_window.py b/src/boltclam/ui/main_window.py new file mode 100644 index 0000000..74e9152 --- /dev/null +++ b/src/boltclam/ui/main_window.py @@ -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 diff --git a/src/boltclam/ui/scan_page.py b/src/boltclam/ui/scan_page.py new file mode 100644 index 0000000..fbdfe11 --- /dev/null +++ b/src/boltclam/ui/scan_page.py @@ -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 diff --git a/src/boltclam/ui/settings_page.py b/src/boltclam/ui/settings_page.py new file mode 100644 index 0000000..0086525 --- /dev/null +++ b/src/boltclam/ui/settings_page.py @@ -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'Manage your Bolt account and subscription') + 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 diff --git a/src/boltclam/ui/theme.py b/src/boltclam/ui/theme.py new file mode 100644 index 0000000..ab17581 --- /dev/null +++ b/src/boltclam/ui/theme.py @@ -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__}" diff --git a/src/boltclam/ui/threads.py b/src/boltclam/ui/threads.py new file mode 100644 index 0000000..7779fa6 --- /dev/null +++ b/src/boltclam/ui/threads.py @@ -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 diff --git a/src/boltclam/updates.py b/src/boltclam/updates.py new file mode 100644 index 0000000..2b9180a --- /dev/null +++ b/src/boltclam/updates.py @@ -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) diff --git a/src/boltclam/winlock.py b/src/boltclam/winlock.py new file mode 100644 index 0000000..596ecb0 --- /dev/null +++ b/src/boltclam/winlock.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2c580f0 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..dd0b8cb --- /dev/null +++ b/tests/test_auth.py @@ -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" diff --git a/tests/test_clamav.py b/tests/test_clamav.py new file mode 100644 index 0000000..f248264 --- /dev/null +++ b/tests/test_clamav.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3fb4224 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..597d86d --- /dev/null +++ b/tests/test_logs.py @@ -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" diff --git a/tests/test_quarantine.py b/tests/test_quarantine.py new file mode 100644 index 0000000..87a5bb4 --- /dev/null +++ b/tests/test_quarantine.py @@ -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) diff --git a/tests/test_realtime.py b/tests/test_realtime.py new file mode 100644 index 0000000..5060ae3 --- /dev/null +++ b/tests/test_realtime.py @@ -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() diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..b577ce9 --- /dev/null +++ b/tests/test_storage.py @@ -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" diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py new file mode 100644 index 0000000..6cf0eb2 --- /dev/null +++ b/tests/test_ui_smoke.py @@ -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() diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..6378531 --- /dev/null +++ b/tests/test_updates.py @@ -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