BoltClam 1.0.0: ClamAV desktop protection with Bolt sign-in

Adds the PyQt6 Windows client (Homepage, Scan & results, About & updates, Settings), ClamAV scanning and quarantine, browser-download watch with an exclusive scan gate, Bolt loopback/PKCE sign-in with DPAPI key storage, Gitea release feed with checksum verification, opt-in diagnostics, and Windows/portable build tooling.

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