BoltClam 1.0.0: ClamAV desktop protection with Bolt sign-in
Adds the PyQt6 Windows client (Homepage, Scan & results, About & updates, Settings), ClamAV scanning and quarantine, browser-download watch with an exclusive scan gate, Bolt loopback/PKCE sign-in with DPAPI key storage, Gitea release feed with checksum verification, opt-in diagnostics, and Windows/portable build tooling. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""BoltClam — a Windows desktop antivirus front-end for ClamAV."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__build_date__ = "2026-10-25"
|
||||
APP_NAME = "BoltClam"
|
||||
ORG_NAME = "The Majestic Network"
|
||||
APP_ID = "com.themajesticnetwork.boltclam"
|
||||
@@ -0,0 +1,4 @@
|
||||
from boltclam.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,267 @@
|
||||
"""HTTP client for the Bolt API's BoltClam endpoints.
|
||||
|
||||
Uses only the standard library so the Windows build needs no third-party
|
||||
HTTP package beyond PyQt6. TLS verification is always on; the only allowed
|
||||
non-HTTPS base is a loopback development URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import ssl
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, message: str, *, status: int | None = None,
|
||||
code: str | None = None, retry_after: int | None = None,
|
||||
body: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.retry_after = retry_after
|
||||
self.body = body
|
||||
|
||||
|
||||
class NetworkError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
def _local_base(base_url: str) -> bool:
|
||||
host = (urlparse(base_url).hostname or "").lower()
|
||||
return host in ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
|
||||
def _decode_body(raw: bytes) -> Any:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8", "replace"))
|
||||
except ValueError:
|
||||
return raw[:2000].decode("utf-8", "replace")
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str, api_key: str = "", *,
|
||||
device_name: str = "", install_id: str = "",
|
||||
timeout: int = 30) -> None:
|
||||
base = str(base_url or "").strip().rstrip("/")
|
||||
parsed = urlparse(base)
|
||||
if parsed.scheme not in ("https", "http") or not parsed.netloc:
|
||||
raise ValueError("The Bolt API address must be an http(s) URL.")
|
||||
if parsed.scheme != "https" and not _local_base(base):
|
||||
raise ValueError("The Bolt API address must use HTTPS.")
|
||||
self.base_url = base
|
||||
self.api_key = api_key or ""
|
||||
self.device_name = device_name or platform.node() or "Windows PC"
|
||||
self.install_id = install_id or ""
|
||||
self.timeout = timeout
|
||||
|
||||
# -- low level ---------------------------------------------------------
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return self.base_url + "/" + str(path).lstrip("/")
|
||||
|
||||
def _headers(self, *, auth: bool, extra: dict | None = None) -> dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"BoltClam/{__version__} ({platform.system() or 'Windows'})",
|
||||
"X-BoltClam-Client": "windows",
|
||||
"X-BoltClam-Version": __version__,
|
||||
}
|
||||
if self.install_id:
|
||||
headers["X-BoltClam-Install-Id"] = self.install_id
|
||||
if auth and self.api_key:
|
||||
headers["X-Desk-Api-Key"] = self.api_key
|
||||
if extra:
|
||||
headers.update({str(k): str(v) for k, v in extra.items()})
|
||||
return headers
|
||||
|
||||
def _request(self, method: str, path: str, *, payload: Any = None,
|
||||
auth: bool = True, timeout: int | None = None,
|
||||
extra_headers: dict | None = None) -> Any:
|
||||
data = None
|
||||
headers = self._headers(auth=auth, extra=extra_headers)
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self._url(path), data=data, headers=headers, method=method.upper())
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
with urlopen(request, timeout=timeout or self.timeout, context=context) as response:
|
||||
raw = response.read()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
except HTTPError as exc:
|
||||
raw = exc.read() if exc.fp else b""
|
||||
body = _decode_body(raw)
|
||||
code = body.get("error") if isinstance(body, dict) else None
|
||||
retry_after = None
|
||||
if isinstance(body, dict):
|
||||
try:
|
||||
retry_after = int(body.get("retry_after")) if body.get("retry_after") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
retry_after = None
|
||||
message = {
|
||||
401: "Your BoltClam session is no longer valid. Sign in again.",
|
||||
402: "This Bolt account is out of credits.",
|
||||
429: "Too many requests. Wait a moment and try again.",
|
||||
502: "The Bolt service is temporarily unavailable.",
|
||||
}.get(exc.code, f"Bolt API request failed ({exc.code}).")
|
||||
raise ApiError(message, status=exc.code, code=code,
|
||||
retry_after=retry_after, body=body) from None
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
raise NetworkError(f"Could not reach the Bolt service: {reason}") from None
|
||||
|
||||
if "application/json" in content_type or raw[:1] in (b"{", b"["):
|
||||
body = _decode_body(raw)
|
||||
else:
|
||||
body = raw
|
||||
if not 200 <= getattr(response, "status", 200) < 300:
|
||||
raise ApiError("Bolt API request failed.", status=getattr(response, "status", None),
|
||||
body=body)
|
||||
return body if body is not None else {}
|
||||
|
||||
# -- endpoints ---------------------------------------------------------
|
||||
|
||||
def get_account(self) -> dict:
|
||||
data = self._request("GET", "/boltclam/account")
|
||||
if not isinstance(data, dict):
|
||||
raise ApiError("The Bolt service returned an unexpected account response.")
|
||||
return data
|
||||
|
||||
def get_updates(self, channel: str = "release", force: bool = False) -> dict:
|
||||
suffix = "?channel=" + ("beta" if channel == "beta" else "release")
|
||||
if force:
|
||||
suffix += "&force=1"
|
||||
data = self._request("GET", "/boltclam/updates" + suffix)
|
||||
if not isinstance(data, dict):
|
||||
raise ApiError("The Bolt service returned an unexpected update response.")
|
||||
return data
|
||||
|
||||
def logout(self) -> None:
|
||||
try:
|
||||
self._request("POST", "/desk/auth/logout", payload={})
|
||||
except ApiError as exc:
|
||||
logger.warning("Logout request failed: %s", exc)
|
||||
|
||||
def download_asset(self, url: str, destination: Path,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
timeout: int = 300) -> tuple[Path, str]:
|
||||
parsed = urlparse(str(url or ""))
|
||||
base = urlparse(self.base_url)
|
||||
if parsed.scheme:
|
||||
if parsed.scheme != base.scheme or parsed.netloc != base.netloc:
|
||||
raise ApiError("Refusing to download an update from an unexpected host.")
|
||||
full_url = str(url)
|
||||
else:
|
||||
full_url = self._url(str(url))
|
||||
destination = Path(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = destination.with_name(destination.name + ".part")
|
||||
request = Request(full_url, headers=self._headers(auth=True), method="GET")
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response:
|
||||
total = int(response.headers.get("Content-Length") or 0)
|
||||
received = 0
|
||||
with open(temp_path, "wb") as handle:
|
||||
while True:
|
||||
chunk = response.read(1024 * 256)
|
||||
if not chunk:
|
||||
break
|
||||
handle.write(chunk)
|
||||
digest.update(chunk)
|
||||
received += len(chunk)
|
||||
if progress:
|
||||
try:
|
||||
progress(received, total)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(temp_path, destination)
|
||||
except HTTPError as exc:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
body = _decode_body(exc.read() if exc.fp else b"")
|
||||
code = body.get("error") if isinstance(body, dict) else None
|
||||
raise ApiError(f"Update download failed ({exc.code}).", status=exc.code,
|
||||
code=code, body=body) from None
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise NetworkError(f"Update download failed: {getattr(exc, 'reason', exc)}") from None
|
||||
return destination, digest.hexdigest()
|
||||
|
||||
def upload_diagnostics(self, zip_path: Path, metadata: dict | None = None,
|
||||
timeout: int = 180) -> dict:
|
||||
zip_path = Path(zip_path)
|
||||
if not zip_path.is_file():
|
||||
raise ApiError("The diagnostics bundle does not exist.")
|
||||
boundary = "----BoltClam" + uuid.uuid4().hex
|
||||
body = bytearray()
|
||||
fields = {"client": "boltclam", "app_version": __version__}
|
||||
fields.update({k: str(v) for k, v in (metadata or {}).items() if v is not None})
|
||||
for name, value in fields.items():
|
||||
body.extend(f"--{boundary}\r\n".encode())
|
||||
body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
||||
body.extend(str(value).encode("utf-8"))
|
||||
body.extend(b"\r\n")
|
||||
filename = zip_path.name.replace('"', "_")
|
||||
body.extend(f"--{boundary}\r\n".encode())
|
||||
body.extend(
|
||||
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode())
|
||||
body.extend(b"Content-Type: application/zip\r\n\r\n")
|
||||
body.extend(zip_path.read_bytes())
|
||||
body.extend(b"\r\n")
|
||||
body.extend(f"--{boundary}--\r\n".encode())
|
||||
|
||||
headers = self._headers(auth=True, extra={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(len(body)),
|
||||
})
|
||||
request = Request(self._url("/boltclam/logs"), data=bytes(body),
|
||||
headers=headers, method="POST")
|
||||
try:
|
||||
with urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response:
|
||||
raw = response.read()
|
||||
except HTTPError as exc:
|
||||
raw = exc.read() if exc.fp else b""
|
||||
parsed = _decode_body(raw)
|
||||
code = parsed.get("error") if isinstance(parsed, dict) else None
|
||||
raise ApiError(f"Log upload failed ({exc.code}).", status=exc.code,
|
||||
code=code, body=parsed) from None
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
raise NetworkError(f"Log upload failed: {getattr(exc, 'reason', exc)}") from None
|
||||
parsed = _decode_body(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ApiError("The Bolt service returned an unexpected log-upload response.")
|
||||
return parsed
|
||||
|
||||
# -- unauthenticated exchange -----------------------------------------
|
||||
|
||||
@classmethod
|
||||
def exchange_code(cls, base_url: str, *, code: str, verifier: str,
|
||||
redirect_uri: str, device_name: str, install_id: str) -> dict:
|
||||
client = cls(base_url, install_id=install_id, device_name=device_name)
|
||||
data = client._request("POST", "/desk/auth/oauth/exchange", auth=False, payload={
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client": "boltclam",
|
||||
"device_name": device_name,
|
||||
"install_id": install_id,
|
||||
})
|
||||
if not isinstance(data, dict) or not data.get("api_key"):
|
||||
raise ApiError("Bolt did not return a device key.")
|
||||
return data
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Application bootstrap: login/restore, dependency wiring, and window lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QEventLoop, QLockFile, QRectF, Qt, QTimer
|
||||
from PyQt6.QtGui import QColor, QFont, QPainter, QPixmap
|
||||
from PyQt6.QtWidgets import QApplication, QDialog, QMessageBox, QSplashScreen
|
||||
|
||||
from . import APP_ID, __build_date__, __version__, paths
|
||||
from .api_client import ApiClient, ApiError, NetworkError
|
||||
from .auth import AuthService, CredentialStore, Session, SessionExpired
|
||||
from .clamav import ClamAVService
|
||||
from .config import AccountCache, SettingsStore
|
||||
from .context import AppContext
|
||||
from .logging_setup import configure as configure_logging
|
||||
from .quarantine import QuarantineService
|
||||
from .storage import Storage
|
||||
from .ui import brand, theme
|
||||
from .ui.login_dialog import LoginDialog
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _set_windows_app_id() -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _splash_pixmap() -> QPixmap:
|
||||
image = QPixmap(460, 250)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(image)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setBrush(QColor("#081820"))
|
||||
painter.setPen(QColor("#1B3946"))
|
||||
painter.drawRoundedRect(QRectF(1, 1, 458, 248), 22, 22)
|
||||
brand.paint_mark(painter, QRectF(34, 34, 72, 72))
|
||||
painter.setPen(QColor(theme.TEXT))
|
||||
painter.setFont(theme.display_font(26, QFont.Weight.DemiBold))
|
||||
painter.drawText(QRectF(124, 36, 300, 40), Qt.AlignmentFlag.AlignLeft,
|
||||
Qt.AlignmentFlag.AlignVCenter, "BoltClam")
|
||||
painter.setPen(QColor(theme.MUTED))
|
||||
painter.setFont(theme.body_font(10.0))
|
||||
painter.drawText(QRectF(126, 76, 300, 24), Qt.AlignmentFlag.AlignLeft,
|
||||
Qt.AlignmentFlag.AlignVCenter, "ClamAV protection, without the noise.")
|
||||
painter.setPen(QColor(theme.TEXT_SOFT))
|
||||
painter.setFont(theme.body_font(10.5))
|
||||
painter.drawText(QRectF(36, 154, 390, 26), Qt.AlignmentFlag.AlignLeft,
|
||||
Qt.AlignmentFlag.AlignVCenter, "Starting protection…")
|
||||
painter.setPen(QColor(theme.DIM))
|
||||
painter.setFont(theme.mono_font(8.0))
|
||||
painter.drawText(QRectF(36, 206, 390, 20), Qt.AlignmentFlag.AlignLeft,
|
||||
Qt.AlignmentFlag.AlignVCenter, f"Version {__version__} · {__build_date__}")
|
||||
painter.end()
|
||||
return image
|
||||
|
||||
|
||||
def _install_exception_hook() -> None:
|
||||
def handle(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
logger.critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback))
|
||||
try:
|
||||
QMessageBox.critical(
|
||||
None, "BoltClam encountered an error",
|
||||
f"{exc_type.__name__}: {exc_value}\n\nDetails were written to the BoltClam log.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.excepthook = handle
|
||||
|
||||
|
||||
def _build_context(settings_store: SettingsStore, auth: AuthService,
|
||||
settings, session: Session) -> AppContext:
|
||||
storage = Storage()
|
||||
quarantine = QuarantineService(storage)
|
||||
clamav = ClamAVService(settings.clamav_path)
|
||||
api = ApiClient(settings.api_base_url, session.api_key,
|
||||
device_name=auth.device_name, install_id=settings.install_id)
|
||||
return AppContext(
|
||||
settings_store=settings_store,
|
||||
settings=settings,
|
||||
storage=storage,
|
||||
quarantine=quarantine,
|
||||
clamav=clamav,
|
||||
auth=auth,
|
||||
session_key=session.api_key,
|
||||
account=session.account,
|
||||
install_id=settings.install_id,
|
||||
api=api,
|
||||
offline=session.offline,
|
||||
)
|
||||
|
||||
|
||||
def _restore_session(auth: AuthService, stored: str | None) -> tuple[Session | None, str]:
|
||||
if not stored:
|
||||
return None, ""
|
||||
try:
|
||||
return auth.restore(stored), ""
|
||||
except SessionExpired:
|
||||
return None, "Your previous BoltClam session expired. Sign in again to continue."
|
||||
except Exception as exc:
|
||||
cached = auth.account_cache.load()
|
||||
if cached:
|
||||
logger.info("Starting offline with the last verified account: %s", exc)
|
||||
return Session(stored, cached, offline=True), (
|
||||
"Bolt is unreachable right now. BoltClam is using the last verified account "
|
||||
"and local protection still works.")
|
||||
logger.info("Could not restore the saved session: %s", exc)
|
||||
return None, f"Could not restore your BoltClam session: {exc}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_set_windows_app_id()
|
||||
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
|
||||
app = QApplication(sys.argv)
|
||||
theme.setup_application(app)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
app.setWindowIcon(brand.app_icon())
|
||||
paths.ensure_dirs()
|
||||
|
||||
lock = QLockFile(str(paths.config_dir() / "boltclam.lock"))
|
||||
if not lock.tryLock(150):
|
||||
QMessageBox.information(
|
||||
None, "BoltClam is already running",
|
||||
"Only one BoltClam can watch downloads at a time. Open the copy in the "
|
||||
"system tray, or quit it before starting another.")
|
||||
return 0
|
||||
|
||||
settings_store = SettingsStore()
|
||||
settings = settings_store.load()
|
||||
env_api = os.environ.get("BOLTCLAM_API_URL", "").strip()
|
||||
if env_api and env_api != settings.api_base_url:
|
||||
settings = settings_store.update(api_base_url=env_api)
|
||||
configure_logging(settings)
|
||||
_install_exception_hook()
|
||||
logger.info("BoltClam %s starting (%s)", __version__, paths.os_label())
|
||||
|
||||
splash = QSplashScreen(_splash_pixmap())
|
||||
splash.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
splash.show()
|
||||
app.processEvents()
|
||||
|
||||
credentials = CredentialStore()
|
||||
account_cache = AccountCache()
|
||||
auth = AuthService(
|
||||
settings.api_base_url, install_id=settings.install_id,
|
||||
credentials=credentials, account_cache=account_cache)
|
||||
stored = auth.stored_key()
|
||||
session, notice = _restore_session(auth, stored)
|
||||
splash.close()
|
||||
|
||||
if session is None:
|
||||
dialog = LoginDialog(auth, notice=notice)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted or dialog.session is None:
|
||||
logger.info("Sign-in cancelled at startup")
|
||||
return 0
|
||||
session = dialog.session
|
||||
|
||||
while True:
|
||||
context = _build_context(settings_store, auth, settings, session)
|
||||
window = MainWindow(context)
|
||||
loop = QEventLoop()
|
||||
window.finished.connect(loop.quit)
|
||||
window.show()
|
||||
loop.exec()
|
||||
logged_out = window.logged_out()
|
||||
window.deleteLater()
|
||||
app.processEvents()
|
||||
if not logged_out:
|
||||
break
|
||||
dialog = LoginDialog(auth, notice="You signed out. Sign in again to use BoltClam.")
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted or dialog.session is None:
|
||||
break
|
||||
session = dialog.session
|
||||
settings = settings_store.load()
|
||||
|
||||
logger.info("BoltClam exiting")
|
||||
return 0
|
||||
@@ -0,0 +1,448 @@
|
||||
"""BoltClam sign-in: loopback PKCE, DPAPI credential storage, session restore.
|
||||
|
||||
The desktop flow mirrors the iOS/Android app:
|
||||
|
||||
1. BoltClam generates a PKCE verifier/challenge and a random state, then
|
||||
listens on ``http://127.0.0.1:<port>/cb``.
|
||||
2. It opens the Bolt-hosted sign-in page in the default browser.
|
||||
3. After the user signs in, Bolt redirects to the loopback listener with a
|
||||
short-lived one-time code.
|
||||
4. BoltClam posts that code + its verifier to ``/desk/auth/oauth/exchange``
|
||||
and receives an ordinary ``bolt_live_...`` API key, which is stored with
|
||||
Windows DPAPI (never in settings or logs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
from .api_client import ApiClient, ApiError, NetworkError
|
||||
from .config import AccountCache
|
||||
from .paths import credentials_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 300
|
||||
_ERROR_MESSAGES = {
|
||||
"access_denied": "Sign-in was cancelled in the browser.",
|
||||
"oauth_failed": "The sign-in provider could not complete the request.",
|
||||
"unverified_email": "That account's email is not verified with the provider.",
|
||||
"invalid_grant": "The sign-in code was not accepted. Please try again.",
|
||||
}
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthCancelled(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class AuthTimeout(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class SessionExpired(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
# ── PKCE ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def pkce_pair() -> tuple[str, str]:
|
||||
verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode()
|
||||
challenge = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode()
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
# ── Windows DPAPI ───────────────────────────────────────────────────────────
|
||||
|
||||
class _DATA_BLOB(ctypes.Structure):
|
||||
_fields_ = [("cbData", ctypes.c_uint32),
|
||||
("pbData", ctypes.POINTER(ctypes.c_ubyte))]
|
||||
|
||||
|
||||
def _blob(data: bytes) -> tuple[_DATA_BLOB, ctypes.Array]:
|
||||
buffer = ctypes.create_string_buffer(data)
|
||||
blob = _DATA_BLOB(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)))
|
||||
return blob, buffer
|
||||
|
||||
|
||||
def _dpapi(data: bytes, protect: bool) -> bytes:
|
||||
crypt32 = ctypes.windll.crypt32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
in_blob, in_buffer = _blob(data)
|
||||
entropy_blob, entropy_buffer = _blob(b"BoltClam/v1")
|
||||
out_blob = _DATA_BLOB()
|
||||
if protect:
|
||||
function = crypt32.CryptProtectData
|
||||
function.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.c_wchar_p,
|
||||
ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.POINTER(_DATA_BLOB)]
|
||||
else:
|
||||
function = crypt32.CryptUnprotectData
|
||||
function.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.POINTER(ctypes.c_wchar_p),
|
||||
ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_uint32,
|
||||
ctypes.POINTER(_DATA_BLOB)]
|
||||
function.restype = ctypes.c_int
|
||||
if protect:
|
||||
ok = function(ctypes.byref(in_blob), "BoltClam device key",
|
||||
ctypes.byref(entropy_blob), None, None, 0x1, ctypes.byref(out_blob))
|
||||
else:
|
||||
description = ctypes.c_wchar_p()
|
||||
ok = function(ctypes.byref(in_blob), ctypes.byref(description),
|
||||
ctypes.byref(entropy_blob), None, None, 0x1, ctypes.byref(out_blob))
|
||||
# Keep the input buffers alive until the call returns.
|
||||
_ = (in_buffer, entropy_buffer)
|
||||
if not ok:
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
return ctypes.string_at(out_blob.pbData, out_blob.cbData)
|
||||
finally:
|
||||
kernel32.LocalFree(out_blob.pbData)
|
||||
|
||||
|
||||
class CredentialStore:
|
||||
"""DPAPI-protected API key on Windows; a 0600 file on dev machines.
|
||||
|
||||
The fallback is deliberately *not* pretended to be secure: it exists so
|
||||
running from source on Linux/macOS works. Windows always uses DPAPI.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = Path(path) if path else credentials_path()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def is_secure(self) -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
def save(self, api_key: str) -> None:
|
||||
if not api_key:
|
||||
raise CredentialError("No API key was supplied.")
|
||||
raw = api_key.encode("utf-8")
|
||||
if self.is_secure:
|
||||
payload = {"version": 1, "protection": "dpapi",
|
||||
"data": base64.b64encode(_dpapi(raw, True)).decode("ascii")}
|
||||
else:
|
||||
logger.warning("Credential store is using a local-file fallback (not Windows DPAPI).")
|
||||
payload = {"version": 1, "protection": "plain",
|
||||
"data": base64.b64encode(raw).decode("ascii")}
|
||||
with self._lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_name(self.path.name + ".tmp")
|
||||
temp.write_text(json.dumps(payload), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(temp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(temp, self.path)
|
||||
|
||||
def load(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
try:
|
||||
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError) as exc:
|
||||
raise CredentialError("Your saved BoltClam sign-in could not be read.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise CredentialError("Your saved BoltClam sign-in is malformed.")
|
||||
try:
|
||||
raw = base64.b64decode(str(payload.get("data") or ""))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise CredentialError("Your saved BoltClam sign-in is malformed.") from exc
|
||||
protection = payload.get("protection")
|
||||
if protection == "dpapi":
|
||||
if not self.is_secure:
|
||||
raise CredentialError("This sign-in was saved with Windows DPAPI and cannot be read here.")
|
||||
try:
|
||||
return _dpapi(raw, False).decode("utf-8")
|
||||
except OSError as exc:
|
||||
raise CredentialError("Windows could not unlock your BoltClam sign-in.") from exc
|
||||
if protection == "plain":
|
||||
return raw.decode("utf-8", "replace")
|
||||
raise CredentialError("Your saved BoltClam sign-in uses an unknown format.")
|
||||
|
||||
def delete(self) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self.path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logger.warning("Could not delete the saved credential file", exc_info=True)
|
||||
|
||||
|
||||
# ── loopback listener ───────────────────────────────────────────────────────
|
||||
|
||||
_CALLBACK_CSS = """
|
||||
:root{color-scheme:dark}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#071018;
|
||||
color:#eef7fb;font:15px/1.55 "Segoe UI",system-ui,sans-serif;padding:24px}
|
||||
.card{max-width:420px;background:#0d1b26;border:1px solid #1d3444;border-radius:20px;
|
||||
padding:32px;text-align:center;box-shadow:0 24px 70px rgba(0,0,0,.45)}
|
||||
.mark{width:54px;height:54px;border-radius:16px;margin:0 auto 18px;display:grid;
|
||||
place-items:center;background:rgba(57,224,200,.1);border:1px solid rgba(57,224,200,.35)}
|
||||
h1{font-size:22px;margin:0 0 8px}p{color:#8fa9bb;margin:0}
|
||||
"""
|
||||
|
||||
|
||||
def _callback_page(title: str, message: str) -> bytes:
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{html.escape(title)}</title><style>{_CALLBACK_CSS}</style></head>
|
||||
<body><main class="card"><div class="mark"><svg width="28" height="28" viewBox="0 0 24 24"
|
||||
fill="none" aria-hidden="true"><path d="M12 2.6 20 6v5.1c0 4.9-3.3 8.5-8 10.3-4.7-1.8-8-5.4-8-10.3V6l8-3.4Z"
|
||||
stroke="#7af4ff" stroke-width="1.6"/><path d="m8.4 12.1 2.5 2.5 4.9-5.1" stroke="#7af4ff"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h1>{html.escape(title)}</h1><p>{html.escape(message)}</p></main></body></html>""".encode()
|
||||
|
||||
|
||||
class LoopbackAuthReceiver:
|
||||
"""A one-shot RFC 8252 loopback listener bound to 127.0.0.1."""
|
||||
|
||||
def __init__(self, state: str) -> None:
|
||||
self.state = state
|
||||
self.result: dict | None = None
|
||||
self.event = threading.Event()
|
||||
receiver = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/favicon.ico":
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if parsed.path != "/cb":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = parse_qs(parsed.query)
|
||||
state = (query.get("state") or [""])[0]
|
||||
if state != receiver.state:
|
||||
body = _callback_page("Sign-in state did not match",
|
||||
"Return to BoltClam and start sign-in again.")
|
||||
self.send_response(400)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
error = (query.get("error") or [""])[0]
|
||||
code = (query.get("code") or [""])[0]
|
||||
receiver.result = {"error": error, "code": code, "state": state}
|
||||
body = _callback_page(
|
||||
"BoltClam is signed in" if code and not error else "Sign-in needs attention",
|
||||
"You can close this tab and return to BoltClam."
|
||||
if code and not error else "Return to BoltClam to try again.",
|
||||
)
|
||||
self.send_response(200 if code and not error else 400)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
receiver.event.set()
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
return
|
||||
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self._server.daemon_threads = True
|
||||
port = int(self._server.server_address[1])
|
||||
if not (1024 <= port <= 65535):
|
||||
self._server.server_close()
|
||||
raise AuthError("Could not open a local sign-in port.")
|
||||
self._thread = threading.Thread(target=self._server.serve_forever,
|
||||
name="boltclam-auth-loopback", daemon=True)
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return f"http://127.0.0.1:{int(self._server.server_address[1])}/cb"
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def wait(self, timeout: float, cancel_event: threading.Event | None = None) -> dict:
|
||||
deadline = time.monotonic() + max(1.0, timeout)
|
||||
while time.monotonic() < deadline:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise AuthCancelled("Sign-in was cancelled.")
|
||||
if self.event.wait(0.2):
|
||||
return dict(self.result or {})
|
||||
raise AuthTimeout("BoltClam did not receive the browser sign-in in time.")
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._server.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── auth service ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
api_key: str
|
||||
account: dict | None
|
||||
offline: bool = False
|
||||
|
||||
|
||||
def _default_device_name() -> str:
|
||||
host = platform.node() or "Windows PC"
|
||||
return f"BoltClam on {host}"[:200]
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, base_url: str, *, device_name: str = "", install_id: str = "",
|
||||
credentials: CredentialStore | None = None,
|
||||
account_cache: AccountCache | None = None) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.device_name = (device_name or _default_device_name())[:200]
|
||||
self.install_id = install_id or uuid.uuid4().hex
|
||||
self.credentials = credentials or CredentialStore()
|
||||
self.account_cache = account_cache or AccountCache()
|
||||
|
||||
def stored_key(self) -> Optional[str]:
|
||||
try:
|
||||
return self.credentials.load()
|
||||
except CredentialError as exc:
|
||||
logger.warning("Stored credential could not be read: %s", exc)
|
||||
return None
|
||||
|
||||
def restore(self, api_key: str | None = None) -> Session:
|
||||
key = api_key or self.stored_key()
|
||||
if not key:
|
||||
raise SessionExpired("No saved sign-in.")
|
||||
client = ApiClient(self.base_url, key, device_name=self.device_name,
|
||||
install_id=self.install_id)
|
||||
try:
|
||||
account = client.get_account()
|
||||
except ApiError as exc:
|
||||
if exc.status == 401:
|
||||
raise SessionExpired("Your BoltClam session has expired.") from None
|
||||
if isinstance(exc, NetworkError):
|
||||
cached = self.account_cache.load()
|
||||
if cached:
|
||||
logger.info("Bolt API unreachable; opening with the last verified account.")
|
||||
return Session(key, cached, offline=True)
|
||||
raise
|
||||
self.account_cache.save(account)
|
||||
return Session(key, account)
|
||||
|
||||
def browser_login(self, open_url: Callable[[str], bool | None],
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
cancel_event: threading.Event | None = None) -> Session:
|
||||
verifier, challenge = pkce_pair()
|
||||
state = secrets.token_urlsafe(24)
|
||||
receiver = LoopbackAuthReceiver(state)
|
||||
try:
|
||||
receiver.start()
|
||||
query = urlencode({
|
||||
"redirect_uri": receiver.redirect_uri,
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"client": "boltclam",
|
||||
"device_name": self.device_name,
|
||||
})
|
||||
url = f"{self.base_url}/desk/auth/boltclam/start?{query}"
|
||||
logger.info("Opening BoltClam browser sign-in")
|
||||
opened = open_url(url)
|
||||
if opened is False:
|
||||
raise AuthError("Could not open your browser for sign-in.")
|
||||
callback = receiver.wait(timeout, cancel_event=cancel_event)
|
||||
error = str(callback.get("error") or "")
|
||||
code = str(callback.get("code") or "")
|
||||
if error:
|
||||
raise AuthError(_ERROR_MESSAGES.get(error, "Sign-in failed. Please try again."))
|
||||
if not code:
|
||||
raise AuthError("The browser did not return a sign-in code.")
|
||||
try:
|
||||
exchanged = ApiClient.exchange_code(
|
||||
self.base_url, code=code, verifier=verifier,
|
||||
redirect_uri=receiver.redirect_uri,
|
||||
device_name=self.device_name, install_id=self.install_id,
|
||||
)
|
||||
except ApiError as exc:
|
||||
raise AuthError(_ERROR_MESSAGES.get(str(exc.code or ""), str(exc))) from None
|
||||
key = str(exchanged["api_key"])
|
||||
account = self._fetch_account(key)
|
||||
self.credentials.save(key)
|
||||
if account:
|
||||
self.account_cache.save(account)
|
||||
return Session(key, account)
|
||||
finally:
|
||||
receiver.close()
|
||||
|
||||
def login_with_key(self, api_key: str) -> Session:
|
||||
key = str(api_key or "").strip()
|
||||
if not key:
|
||||
raise AuthError("Enter a Bolt API key.")
|
||||
client = ApiClient(self.base_url, key, device_name=self.device_name,
|
||||
install_id=self.install_id)
|
||||
try:
|
||||
account = client.get_account()
|
||||
except ApiError as exc:
|
||||
if exc.status == 401:
|
||||
raise AuthError("That API key was not accepted.") from None
|
||||
raise
|
||||
self.credentials.save(key)
|
||||
self.account_cache.save(account)
|
||||
return Session(key, account)
|
||||
|
||||
def _fetch_account(self, key: str) -> dict | None:
|
||||
client = ApiClient(self.base_url, key, device_name=self.device_name,
|
||||
install_id=self.install_id)
|
||||
try:
|
||||
return client.get_account()
|
||||
except ApiError as exc:
|
||||
# The exchange already proved the key works. A transient account
|
||||
# fetch failure should not throw away a successful sign-in; the
|
||||
# app will refresh the account after the first successful call.
|
||||
if isinstance(exc, NetworkError):
|
||||
logger.warning("Signed in, but the account summary was unreachable: %s", exc)
|
||||
return None
|
||||
if exc.status == 401:
|
||||
raise AuthError("Bolt issued a key that it then rejected. Try again.") from None
|
||||
logger.warning("Signed in, but the account summary failed: %s", exc)
|
||||
return None
|
||||
|
||||
def logout(self, api_key: str | None = None) -> None:
|
||||
key = api_key or self.stored_key()
|
||||
if key:
|
||||
ApiClient(self.base_url, key, device_name=self.device_name,
|
||||
install_id=self.install_id).logout()
|
||||
self.credentials.delete()
|
||||
self.account_cache.clear()
|
||||
@@ -0,0 +1,380 @@
|
||||
"""ClamAV discovery, command construction, target selection and output parsing.
|
||||
|
||||
The app manages its own signature directory (``data/clamav-db``) so
|
||||
``freshclam`` can update definitions without an administrator prompt. If
|
||||
that directory is empty, the database shipped beside ``clamscan.exe`` is
|
||||
used instead. Neither path is ever accepted from scan output or the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from . import paths
|
||||
from .models import ClamAVInfo
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
_VERSION_RE = re.compile(r"ClamAV\s+([0-9][0-9A-Za-z.\-]*)(?:/([0-9]+)(?:/([^\r\n]+))?)?")
|
||||
_QUOTES = ("'", '"')
|
||||
_DANGEROUS_OPTIONS = ("--remove", "--move", "--copy")
|
||||
|
||||
|
||||
class ClamAVError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def hidden_kwargs() -> dict:
|
||||
"""subprocess kwargs that keep a GUI launch from flashing a console."""
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
def _candidate_names() -> list[str]:
|
||||
return ["clamscan.exe", "clamscan"]
|
||||
|
||||
|
||||
def find_clamscan(configured: str = "") -> tuple[Path | None, str]:
|
||||
configured = os.path.expandvars(os.path.expanduser(str(configured or "").strip()))
|
||||
if configured:
|
||||
path = Path(configured)
|
||||
if path.is_dir():
|
||||
for name in _candidate_names():
|
||||
candidate = path / name
|
||||
if candidate.is_file():
|
||||
return candidate, ""
|
||||
return None, "No clamscan executable was found in that folder."
|
||||
if path.is_file():
|
||||
return path, ""
|
||||
return None, "The configured ClamAV path does not exist."
|
||||
|
||||
candidates: list[Path] = []
|
||||
env_path = os.environ.get("CLAMSCAN_PATH", "").strip()
|
||||
if env_path:
|
||||
candidates.append(Path(os.path.expandvars(os.path.expanduser(env_path))))
|
||||
for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
|
||||
base = os.environ.get(env_name)
|
||||
if base:
|
||||
candidates.append(Path(base) / "ClamAV" / "clamscan.exe")
|
||||
candidates.append(Path(base) / "ClamAV" / "clamscan")
|
||||
candidates.extend([Path(r"C:\ClamAV\clamscan.exe"),
|
||||
Path(r"C:\Program Files\ClamAV\clamscan.exe")])
|
||||
for name in _candidate_names():
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
candidates.append(Path(found))
|
||||
candidates.extend([Path("/usr/bin/clamscan"), Path("/usr/local/bin/clamscan")])
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if candidate.is_file():
|
||||
return candidate, ""
|
||||
except OSError:
|
||||
continue
|
||||
return None, "ClamAV was not found. Set its install folder in Settings."
|
||||
|
||||
|
||||
def find_freshclam(clamscan: Path | None) -> tuple[Path | None, str]:
|
||||
env_path = os.environ.get("FRESHCLAM_PATH", "").strip()
|
||||
candidates: list[Path] = []
|
||||
if env_path:
|
||||
candidates.append(Path(os.path.expandvars(os.path.expanduser(env_path))))
|
||||
if clamscan is not None:
|
||||
candidates.append(clamscan.with_name("freshclam.exe"))
|
||||
candidates.append(clamscan.with_name("freshclam"))
|
||||
for name in ("freshclam.exe", "freshclam"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
candidates.append(Path(found))
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if candidate.is_file():
|
||||
return candidate, ""
|
||||
except OSError:
|
||||
continue
|
||||
return None, "freshclam was not found next to clamscan."
|
||||
|
||||
|
||||
def parse_version_output(text: str) -> tuple[str, str, str]:
|
||||
match = _VERSION_RE.search(text or "")
|
||||
if not match:
|
||||
return "", "", ""
|
||||
return match.group(1) or "", match.group(2) or "", (match.group(3) or "").strip()
|
||||
|
||||
|
||||
def database_files(directory: Path | None) -> list[Path]:
|
||||
if directory is None or not directory.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for name in ("main.cvd", "main.cld", "daily.cvd", "daily.cld",
|
||||
"bytecode.cvd", "bytecode.cld"):
|
||||
candidate = directory / name
|
||||
if candidate.is_file():
|
||||
files.append(candidate)
|
||||
return files
|
||||
|
||||
|
||||
def has_database(directory: Path | None) -> bool:
|
||||
return bool(database_files(directory))
|
||||
|
||||
|
||||
def database_age(directory: Path | None) -> tuple[str, float | None]:
|
||||
files = database_files(directory)
|
||||
if not files:
|
||||
return "", None
|
||||
try:
|
||||
newest = max(files, key=lambda path: path.stat().st_mtime)
|
||||
timestamp = newest.stat().st_mtime
|
||||
except OSError:
|
||||
return "", None
|
||||
age = max(0.0, (time.time() - timestamp) / 86400.0)
|
||||
date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
return date, age
|
||||
|
||||
|
||||
def parse_extra_options(text: str) -> list[str]:
|
||||
"""Split user-entered extra ClamAV options into an argv list.
|
||||
|
||||
Destructive options are refused because remediation belongs to the app's
|
||||
quarantine/delete path, which records what happened. ``posix=False``
|
||||
keeps Windows paths with backslashes intact; surrounding quotes are
|
||||
stripped manually afterward.
|
||||
"""
|
||||
raw = str(text or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
if "\x00" in raw:
|
||||
raise ValueError("ClamAV extra options contain an invalid character.")
|
||||
# shlex's POSIX mode understands quotes that wrap a whole value (including
|
||||
# spaces), but it treats backslashes as escapes. Doubling them first keeps
|
||||
# Windows paths intact while still letting POSIX quoting do its job.
|
||||
protected = raw.replace("\\", "\\\\")
|
||||
try:
|
||||
tokens = shlex.split(protected, posix=True)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"ClamAV extra options could not be parsed: {exc}") from exc
|
||||
result: list[str] = []
|
||||
for token in tokens:
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
if len(token) >= 2 and token[0] == token[-1] and token[0] in _QUOTES:
|
||||
token = token[1:-1]
|
||||
if any(ch in token for ch in ("\r", "\n", "\x00")):
|
||||
raise ValueError("ClamAV extra options must be a single line.")
|
||||
name = token.split("=", 1)[0].lower()
|
||||
if name in _DANGEROUS_OPTIONS or any(name.startswith(opt + "=") for opt in _DANGEROUS_OPTIONS):
|
||||
raise ValueError(
|
||||
f"{name} is managed by BoltClam; use the quarantine/delete settings instead.")
|
||||
if "=" in token:
|
||||
option, _, value = token.partition("=")
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTES:
|
||||
value = value[1:-1]
|
||||
token = f"{option}={value}"
|
||||
result.append(token)
|
||||
return result
|
||||
|
||||
|
||||
def build_scan_args(clamscan: Path, targets: Iterable[Path], *,
|
||||
database_dir: Path | None = None,
|
||||
quarantine_dir: Path | None = None,
|
||||
temp_dir: Path | None = None,
|
||||
extra_options: Iterable[str] = ()) -> list[str]:
|
||||
args = [str(clamscan), "--recursive=yes", "--infected", "--verbose", "--stdout"]
|
||||
if has_database(database_dir):
|
||||
args.append(f"--database={database_dir}")
|
||||
for excluded in (quarantine_dir, temp_dir):
|
||||
if excluded:
|
||||
text = str(excluded)
|
||||
args.append(f"--exclude-dir={re.escape(text)}")
|
||||
forward = text.replace("\\", "/")
|
||||
if forward != text:
|
||||
args.append(f"--exclude-dir={re.escape(forward)}")
|
||||
args.extend(str(option) for option in extra_options)
|
||||
args.extend(str(path) for path in targets)
|
||||
return args
|
||||
|
||||
|
||||
def build_freshclam_args(freshclam: Path, database_dir: Path) -> list[str]:
|
||||
return [str(freshclam), "--foreground", "--stdout", f"--datadir={database_dir}"]
|
||||
|
||||
|
||||
def scan_targets(scope: str, *, profiles: Iterable[Path] | None = None,
|
||||
drives: Iterable[Path] | None = None) -> list[Path]:
|
||||
roots = list(drives if drives is not None else paths.fixed_drive_roots()) \
|
||||
if scope == "full" else \
|
||||
list(profiles if profiles is not None else paths.windows_profile_dirs())
|
||||
if not roots:
|
||||
roots = [Path.home()]
|
||||
result: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
try:
|
||||
root = Path(root).expanduser()
|
||||
key = os.path.normcase(str(root.resolve()))
|
||||
if key in seen or not root.is_dir():
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(root)
|
||||
except OSError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def parse_clamscan_line(line: str) -> dict | None:
|
||||
"""Turn one clamscan output line into a small event dictionary.
|
||||
|
||||
Detection paths are parsed with ``rpartition(": ")`` so a Windows drive
|
||||
letter and any colon in earlier components stay in the path.
|
||||
"""
|
||||
text = _ANSI_RE.sub("", str(line or "")).rstrip("\r\n")
|
||||
if not text:
|
||||
return None
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.endswith(" FOUND"):
|
||||
body = stripped[:-len(" FOUND")].rstrip()
|
||||
path, separator, signature = body.rpartition(": ")
|
||||
if separator and path:
|
||||
return {"kind": "detection", "path": path, "signature": signature}
|
||||
|
||||
for prefix, key in (
|
||||
("Known viruses:", "known_viruses"),
|
||||
("Engine version:", "engine_version"),
|
||||
("Scanned directories:", "scanned_directories"),
|
||||
("Scanned files:", "scanned_files"),
|
||||
("Infected files:", "infected_files"),
|
||||
("Data scanned:", "data_scanned"),
|
||||
("Data read:", "data_read"),
|
||||
("Time:", "time"),
|
||||
("Start Date:", "start_date"),
|
||||
("End Date:", "end_date"),
|
||||
):
|
||||
if stripped.startswith(prefix):
|
||||
value = stripped[len(prefix):].strip()
|
||||
return {"kind": "summary", "key": key, "value": value}
|
||||
|
||||
if stripped.startswith("Scanning "):
|
||||
return {"kind": "progress", "path": stripped[len("Scanning "):].strip()}
|
||||
|
||||
skipped_suffixes = (": Empty file", ": Size limit exceeded", ": Symbolic link",
|
||||
": File name too long", ": Excluded")
|
||||
for suffix in skipped_suffixes:
|
||||
if stripped.endswith(suffix):
|
||||
return {"kind": "skipped", "path": stripped[:-len(suffix)],
|
||||
"reason": suffix[2:]}
|
||||
|
||||
error_markers = ("Access denied", "Can't open file", "Can't access file",
|
||||
"No such file or directory", "Permission denied")
|
||||
for marker in error_markers:
|
||||
if marker in stripped:
|
||||
return {"kind": "error", "message": stripped}
|
||||
|
||||
if stripped.startswith(("ERROR:", "LibClamAV Error:", "LibClamAV Error ")):
|
||||
return {"kind": "error", "message": stripped}
|
||||
if stripped.startswith(("WARNING:", "LibClamAV Warning:")):
|
||||
return {"kind": "log", "message": stripped}
|
||||
if stripped.endswith(": OK"):
|
||||
return {"kind": "progress", "path": stripped[:-len(": OK")]}
|
||||
return {"kind": "log", "message": stripped}
|
||||
|
||||
|
||||
def summarize(events: Iterable[dict]) -> dict:
|
||||
summary: dict[str, object] = {}
|
||||
for event in events:
|
||||
if event and event.get("kind") == "summary":
|
||||
key = str(event.get("key") or "")
|
||||
value = event.get("value")
|
||||
if key in ("scanned_files", "infected_files", "scanned_directories",
|
||||
"known_viruses"):
|
||||
try:
|
||||
summary[key] = int(str(value).split()[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif key == "engine_version":
|
||||
summary[key] = str(value)
|
||||
return summary
|
||||
|
||||
|
||||
class ClamAVService:
|
||||
def __init__(self, configured_path: str = "") -> None:
|
||||
self.configured_path = configured_path
|
||||
self._info: ClamAVInfo | None = None
|
||||
self._info_at = 0.0
|
||||
|
||||
def info(self, force: bool = False) -> ClamAVInfo:
|
||||
if not force and self._info is not None and time.time() - self._info_at < 30:
|
||||
return self._info
|
||||
clamscan, error = find_clamscan(self.configured_path)
|
||||
if clamscan is None:
|
||||
info = ClamAVInfo(available=False, error=error)
|
||||
self._info, self._info_at = info, time.time()
|
||||
return info
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(clamscan), "--version"], capture_output=True, text=True,
|
||||
timeout=15, errors="replace", **hidden_kwargs(),
|
||||
)
|
||||
output = (completed.stdout or "") + "\n" + (completed.stderr or "")
|
||||
engine, db_version, db_date = parse_version_output(output)
|
||||
if completed.returncode != 0 and not engine:
|
||||
info = ClamAVInfo(available=False, path=clamscan,
|
||||
error="clamscan.exe could not run.")
|
||||
else:
|
||||
managed = paths.clamav_db_dir()
|
||||
db_dir = managed if has_database(managed) else clamscan.parent
|
||||
date, age = database_age(db_dir)
|
||||
info = ClamAVInfo(
|
||||
available=True, path=clamscan,
|
||||
engine_version=engine or "ClamAV",
|
||||
db_version=db_version,
|
||||
db_path=db_dir if has_database(db_dir) else None,
|
||||
db_date=db_date or date,
|
||||
db_age_days=age,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
info = ClamAVInfo(available=False, path=clamscan,
|
||||
error=f"clamscan.exe could not run: {exc}")
|
||||
self._info, self._info_at = info, time.time()
|
||||
return info
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self._info = None
|
||||
self._info_at = 0.0
|
||||
|
||||
def freshclam(self) -> Path | None:
|
||||
path, _error = find_freshclam(self.info().path)
|
||||
return path
|
||||
|
||||
def scan_arguments(self, targets: list[Path], extra_options: Iterable[str] = (),
|
||||
*, exclude_temp: bool = True) -> list[str]:
|
||||
info = self.info()
|
||||
if not info.available or info.path is None:
|
||||
raise ClamAVError(info.error or "ClamAV is not available.")
|
||||
managed = paths.clamav_db_dir()
|
||||
database = managed if has_database(managed) else (
|
||||
info.path.parent if has_database(info.path.parent) else None)
|
||||
return build_scan_args(
|
||||
info.path, targets, database_dir=database,
|
||||
quarantine_dir=paths.quarantine_dir(),
|
||||
temp_dir=paths.temp_dir() if exclude_temp else None,
|
||||
extra_options=extra_options,
|
||||
)
|
||||
|
||||
def version_summary(self) -> str:
|
||||
return self.info().summary
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Persistent BoltClam settings and the small non-secret account cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_CHANNELS = ("release", "beta")
|
||||
SCAN_SCOPES = ("homes", "full")
|
||||
AUTO_ACTIONS = ("quarantine", "delete", "notify")
|
||||
DEFAULT_API_URL = "https://api.themajesticnetwork.com"
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if lowered in ("0", "false", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _clean_choice(value: Any, choices: tuple[str, ...], default: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return text if text in choices else default
|
||||
|
||||
|
||||
def _clean_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
cleaned: list[str] = []
|
||||
for entry in value:
|
||||
if not isinstance(entry, str):
|
||||
continue
|
||||
text = os.path.expandvars(os.path.expanduser(entry.strip()))
|
||||
if text and text not in cleaned:
|
||||
cleaned.append(text)
|
||||
return cleaned[:30]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppSettings:
|
||||
update_channel: str = "release"
|
||||
clamav_path: str = ""
|
||||
extra_options: str = ""
|
||||
logging_enabled: bool = True
|
||||
debug_logging: bool = False
|
||||
send_logs: bool = False
|
||||
scan_scope: str = "homes"
|
||||
auto_action: str = "quarantine"
|
||||
realtime_enabled: bool = True
|
||||
notify_on_detection: bool = True
|
||||
close_to_tray: bool = True
|
||||
api_base_url: str = DEFAULT_API_URL
|
||||
extra_watch_dirs: list[str] = field(default_factory=list)
|
||||
last_log_upload: str = ""
|
||||
last_update_check: float = 0.0
|
||||
install_id: str = ""
|
||||
|
||||
def normalized(self) -> "AppSettings":
|
||||
data = asdict(self)
|
||||
data["update_channel"] = _clean_choice(data["update_channel"], UPDATE_CHANNELS, "release")
|
||||
data["scan_scope"] = _clean_choice(data["scan_scope"], SCAN_SCOPES, "homes")
|
||||
data["auto_action"] = _clean_choice(data["auto_action"], AUTO_ACTIONS, "quarantine")
|
||||
for key in ("logging_enabled", "debug_logging", "send_logs", "realtime_enabled",
|
||||
"notify_on_detection", "close_to_tray"):
|
||||
data[key] = _as_bool(data.get(key), getattr(AppSettings(), key))
|
||||
data["clamav_path"] = os.path.expandvars(os.path.expanduser(
|
||||
str(data.get("clamav_path") or "").strip()))
|
||||
data["extra_options"] = str(data.get("extra_options") or "").strip()[:4000]
|
||||
data["api_base_url"] = (str(data.get("api_base_url") or DEFAULT_API_URL)
|
||||
.strip().rstrip("/") or DEFAULT_API_URL)
|
||||
data["extra_watch_dirs"] = _clean_list(data.get("extra_watch_dirs"))
|
||||
try:
|
||||
data["last_update_check"] = max(0.0, float(data.get("last_update_check") or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
data["last_update_check"] = 0.0
|
||||
data["last_log_upload"] = str(data.get("last_log_upload") or "")[:80]
|
||||
data["install_id"] = str(data.get("install_id") or "").strip()[:64] or uuid.uuid4().hex
|
||||
if data["debug_logging"] and not data["logging_enabled"]:
|
||||
data["debug_logging"] = False
|
||||
return AppSettings(**data)
|
||||
|
||||
def public_snapshot(self) -> dict:
|
||||
"""Settings safe to include in a diagnostics bundle.
|
||||
|
||||
The API key is never in this object. Extra ClamAV options are
|
||||
included because they explain scan behaviour, but token-shaped
|
||||
strings are redacted by the diagnostics writer.
|
||||
"""
|
||||
data = asdict(self.normalized())
|
||||
data.pop("install_id", None)
|
||||
return data
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = Path(path) if path else paths.settings_path()
|
||||
self._lock = threading.RLock()
|
||||
self._settings: AppSettings | None = None
|
||||
|
||||
def load(self) -> AppSettings:
|
||||
with self._lock:
|
||||
if self._settings is not None:
|
||||
return self._settings
|
||||
data: dict[str, Any] = {}
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
data = raw
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError):
|
||||
logger.warning("Settings file is unreadable; starting with defaults", exc_info=True)
|
||||
try:
|
||||
backup = self.path.with_suffix(
|
||||
f".corrupt-{time.strftime('%Y%m%d-%H%M%S')}.json")
|
||||
self.path.replace(backup)
|
||||
except OSError:
|
||||
pass
|
||||
self._settings = AppSettings(**{k: v for k, v in data.items()
|
||||
if k in AppSettings.__dataclass_fields__}).normalized()
|
||||
if not self.path.exists():
|
||||
self.save(self._settings)
|
||||
return self._settings
|
||||
|
||||
def save(self, settings: AppSettings) -> AppSettings:
|
||||
normalized = settings.normalized()
|
||||
with self._lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps(asdict(normalized), indent=2, sort_keys=True)
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", dir=str(self.path.parent), suffix=".tmp",
|
||||
delete=False, encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temp_path = handle.name
|
||||
os.replace(temp_path, self.path)
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
self._settings = normalized
|
||||
return normalized
|
||||
|
||||
def update(self, **changes: Any) -> AppSettings:
|
||||
current = self.load()
|
||||
data = asdict(current)
|
||||
data.update(changes)
|
||||
return self.save(AppSettings(**data))
|
||||
|
||||
|
||||
class AccountCache:
|
||||
"""Last verified account payload, used only when the API is unreachable.
|
||||
|
||||
This is deliberately a separate file from settings: it is not public
|
||||
diagnostic data and it never contains a credential.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = Path(path) if path else paths.account_cache_path()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def load(self) -> dict | None:
|
||||
with self._lock:
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
account = data.get("account")
|
||||
return account if isinstance(account, dict) else None
|
||||
|
||||
def save(self, account: dict) -> None:
|
||||
with self._lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"account": account}
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", dir=str(self.path.parent), suffix=".tmp",
|
||||
delete=False, encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=1)
|
||||
temp_path = handle.name
|
||||
os.replace(temp_path, self.path)
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self.path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,72 @@
|
||||
"""The service bundle shared by every page in the main window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .api_client import ApiClient
|
||||
from .auth import AuthService
|
||||
from .clamav import ClamAVService
|
||||
from .config import AppSettings, SettingsStore
|
||||
from .quarantine import QuarantineService
|
||||
from .realtime import RealtimeProtection
|
||||
from .storage import Storage
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
settings_store: SettingsStore
|
||||
settings: AppSettings
|
||||
storage: Storage
|
||||
quarantine: QuarantineService
|
||||
clamav: ClamAVService
|
||||
auth: AuthService
|
||||
session_key: str
|
||||
account: dict | None
|
||||
install_id: str
|
||||
api: ApiClient | None = None
|
||||
realtime: RealtimeProtection | None = None
|
||||
offline: bool = False
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
def rebuild_api(self) -> ApiClient:
|
||||
self.api = ApiClient(
|
||||
self.settings.api_base_url, self.session_key,
|
||||
device_name=self.auth.device_name, install_id=self.install_id)
|
||||
return self.api
|
||||
|
||||
@property
|
||||
def user(self) -> dict:
|
||||
return (self.account or {}).get("user") or {}
|
||||
|
||||
@property
|
||||
def subscription(self) -> dict:
|
||||
return (self.account or {}).get("subscription") or {}
|
||||
|
||||
@property
|
||||
def user_name(self) -> str:
|
||||
user = self.user
|
||||
return str(user.get("name") or user.get("username") or
|
||||
(str(user.get("email") or "Bolt user").split("@")[0]))
|
||||
|
||||
@property
|
||||
def user_email(self) -> str:
|
||||
return str(self.user.get("email") or "")
|
||||
|
||||
@property
|
||||
def plan_label(self) -> str:
|
||||
subscription = self.subscription
|
||||
label = str(subscription.get("label") or "Free")
|
||||
status = str(subscription.get("status") or "none").lower()
|
||||
if status in ("active", "trialing"):
|
||||
return label
|
||||
return "Free"
|
||||
|
||||
@property
|
||||
def licensed(self) -> bool:
|
||||
return bool((self.account or {}).get("features", {}).get("licensed"))
|
||||
|
||||
def update_settings(self, settings: AppSettings) -> None:
|
||||
self.settings = settings
|
||||
if self.realtime is not None:
|
||||
self.realtime.set_settings(settings)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Logging setup and redaction shared by the app and diagnostics bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from . import paths
|
||||
from .config import AppSettings
|
||||
|
||||
LOG_NAME = "boltclam.log"
|
||||
_MAX_BYTES = 2_000_000
|
||||
_BACKUPS = 3
|
||||
|
||||
_REDACTIONS = (
|
||||
(re.compile(r"bolt_live_[A-Za-z0-9_-]+"), "bolt_live_[redacted]"),
|
||||
(re.compile(r"(?i)(authorization\s*:\s*bearer\s+)\S+"), r"\1[redacted]"),
|
||||
(re.compile(r"(?i)(x-desk-api-key\s*:\s*)\S+"), r"\1[redacted]"),
|
||||
(re.compile(r"(?i)(password\s*[=:]\s*)\S+"), r"\1[redacted]"),
|
||||
(re.compile(r"(?i)(api[_-]?key\s*[=:]\s*)\S+"), r"\1[redacted]"),
|
||||
(re.compile(r"(?i)(identity_token\s*[=:]\s*)\S+"), r"\1[redacted]"),
|
||||
)
|
||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
value = str(text or "")
|
||||
for pattern, replacement in _REDACTIONS:
|
||||
value = pattern.sub(replacement, value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_emails(text: str) -> str:
|
||||
return _EMAIL_RE.sub("[email]", redact(text))
|
||||
|
||||
|
||||
class RedactionFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
message = record.getMessage()
|
||||
record.msg = redact(message)
|
||||
record.args = ()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def log_path() -> Path:
|
||||
return paths.logs_dir() / LOG_NAME
|
||||
|
||||
|
||||
def configure(settings: AppSettings) -> None:
|
||||
"""Install one rotating file handler, or remove it when logging is off."""
|
||||
paths.ensure_dirs()
|
||||
root = logging.getLogger()
|
||||
for handler in list(root.handlers):
|
||||
if getattr(handler, "_boltclam_handler", False):
|
||||
root.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
level = logging.DEBUG if (settings.logging_enabled and settings.debug_logging) else logging.INFO
|
||||
root.setLevel(level if settings.logging_enabled else logging.WARNING)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
if settings.logging_enabled:
|
||||
try:
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path(), maxBytes=_MAX_BYTES, backupCount=_BACKUPS, encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.addFilter(RedactionFilter())
|
||||
file_handler._boltclam_handler = True # type: ignore[attr-defined]
|
||||
root.addHandler(file_handler)
|
||||
except OSError:
|
||||
# A GUI app must not fail to start because its log directory is
|
||||
# read-only; diagnostics will simply be absent.
|
||||
pass
|
||||
|
||||
if not getattr(sys, "frozen", False):
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
console.addFilter(RedactionFilter())
|
||||
console._boltclam_handler = True # type: ignore[attr-defined]
|
||||
root.addHandler(console)
|
||||
|
||||
for noisy in ("urllib3", "PyQt6", "PIL"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def tail_lines(limit: int = 250) -> list[str]:
|
||||
try:
|
||||
with open(log_path(), "r", encoding="utf-8", errors="replace") as handle:
|
||||
lines = handle.readlines()
|
||||
return [line.rstrip("\n") for line in lines[-limit:]]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def rotated_logs() -> list[Path]:
|
||||
base = log_path()
|
||||
files = [base]
|
||||
files.extend(base.with_name(f"{base.name}.{index}") for index in range(1, _BACKUPS + 1))
|
||||
return [path for path in files if path.is_file()]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Opt-in diagnostics bundle creation and upload.
|
||||
|
||||
The bundle is intentionally narrow: app logs, redacted scan/quarantine
|
||||
history, system summary and settings. It never includes the API key,
|
||||
passwords, browser contents, or non-detection file contents. User-profile
|
||||
prefixes are replaced with ``%USERPROFILE%`` before the zip is written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from . import __build_date__, __version__, paths
|
||||
from .api_client import ApiClient
|
||||
from .config import AppSettings
|
||||
from .logging_setup import redact, redact_emails, rotated_logs
|
||||
from .models import ClamAVInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiagnosticsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _profile_prefixes() -> list[str]:
|
||||
prefixes: list[str] = []
|
||||
for profile in paths.windows_profile_dirs():
|
||||
try:
|
||||
prefixes.append(str(profile.resolve()))
|
||||
except OSError:
|
||||
prefixes.append(str(profile))
|
||||
prefixes.extend([str(Path.home()), "C:\\Users\\Public", "/home", "/Users"])
|
||||
return sorted({prefix for prefix in prefixes if prefix}, key=len, reverse=True)
|
||||
|
||||
|
||||
def redact_paths(text: str) -> str:
|
||||
value = str(text or "")
|
||||
for prefix in _profile_prefixes():
|
||||
value = re.sub(re.escape(prefix), "%USERPROFILE%", value, flags=re.IGNORECASE)
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_text(text: str) -> str:
|
||||
return redact_emails(redact(redact_paths(text)))
|
||||
|
||||
|
||||
def _sanitize_value(value, key: str = ""):
|
||||
if isinstance(value, str):
|
||||
# Emails are preserved in the manifest (the consent screen says the
|
||||
# account email is included); log text goes through sanitize_text(),
|
||||
# which does redact them.
|
||||
return redact(redact_paths(value))
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_value(item, key) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _sanitize_value(v, str(k)) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _read_log(path: Path, max_chars: int = 300_000) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
except OSError:
|
||||
return ""
|
||||
if len(text) > max_chars:
|
||||
text = "[earlier log lines omitted]\n" + text[-max_chars:]
|
||||
return sanitize_text(text)
|
||||
|
||||
|
||||
def _cleanup_old_bundles(days: int = 14) -> None:
|
||||
cutoff = time.time() - days * 86400
|
||||
try:
|
||||
for entry in paths.diagnostics_dir().glob("BoltClam-diagnostics-*.zip"):
|
||||
try:
|
||||
if entry.stat().st_mtime < cutoff:
|
||||
entry.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def build_bundle(settings: AppSettings, clamav: ClamAVInfo, *,
|
||||
system_snapshot: dict | None = None,
|
||||
account: dict | None = None,
|
||||
realtime_roots: list[str] | None = None) -> Path:
|
||||
paths.ensure_dirs()
|
||||
_cleanup_old_bundles()
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
bundle_path = paths.diagnostics_dir() / f"BoltClam-diagnostics-{stamp}-{uuid.uuid4().hex[:6]}.zip"
|
||||
temp_root = Path(tempfile.mkdtemp(prefix="boltclam-diagnostics-", dir=str(paths.temp_dir())))
|
||||
try:
|
||||
manifest = {
|
||||
"app": "BoltClam",
|
||||
"version": __version__,
|
||||
"build_date": __build_date__,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(
|
||||
timespec="seconds").replace("+00:00", "Z"),
|
||||
"os": paths.os_label(),
|
||||
"hostname": paths.hostname(),
|
||||
"is_admin": paths.is_admin(),
|
||||
"account": {
|
||||
"email": (account or {}).get("user", {}).get("email", ""),
|
||||
"user_id": (account or {}).get("user", {}).get("id", ""),
|
||||
"subscription": (account or {}).get("subscription", {}),
|
||||
},
|
||||
"clamav": {
|
||||
"available": clamav.available,
|
||||
"path": str(clamav.path) if clamav.path else "",
|
||||
"engine": clamav.engine_version,
|
||||
"db_version": clamav.db_version,
|
||||
"db_date": clamav.db_date,
|
||||
"db_age_days": clamav.db_age_days,
|
||||
"error": clamav.error,
|
||||
},
|
||||
"settings": settings.public_snapshot(),
|
||||
"realtime_watch_roots": realtime_roots or [],
|
||||
"notes": [
|
||||
"API keys, passwords and account tokens are redacted.",
|
||||
"User profile prefixes are replaced with %USERPROFILE%.",
|
||||
"Email addresses in logs are redacted; the account email is in this manifest.",
|
||||
"No file contents are included.",
|
||||
],
|
||||
}
|
||||
(temp_root / "manifest.json").write_text(
|
||||
json.dumps(_sanitize_value(manifest), indent=1), encoding="utf-8")
|
||||
|
||||
logs = []
|
||||
for path in rotated_logs():
|
||||
text = _read_log(path)
|
||||
if text:
|
||||
logs.append(f"===== {path.name} =====\n{text}\n")
|
||||
(temp_root / "boltclam-logs.txt").write_text(
|
||||
sanitize_text("\n".join(logs) or "No app log was available."),
|
||||
encoding="utf-8")
|
||||
|
||||
if system_snapshot:
|
||||
(temp_root / "system-summary.json").write_text(
|
||||
json.dumps(_sanitize_value(system_snapshot), indent=1), encoding="utf-8")
|
||||
|
||||
(temp_root / "README.txt").write_text(
|
||||
"BoltClam diagnostics bundle\n"
|
||||
"==========================\n\n"
|
||||
"This archive was created by BoltClam after you enabled 'Send logs to Bolt'.\n"
|
||||
"It contains application logs, settings, scan/quarantine summaries and a system\n"
|
||||
"summary. API keys, passwords, tokens and file contents are not included.\n"
|
||||
"User profile paths are replaced with %USERPROFILE%.\n",
|
||||
encoding="utf-8")
|
||||
|
||||
with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(temp_root.iterdir()):
|
||||
if path.is_file():
|
||||
archive.write(path, arcname=path.name)
|
||||
logger.info("Diagnostics bundle written to %s", bundle_path)
|
||||
return bundle_path
|
||||
except OSError as exc:
|
||||
raise DiagnosticsError(f"Could not create the diagnostics bundle: {exc}") from exc
|
||||
finally:
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
|
||||
|
||||
def upload_bundle(api: ApiClient, bundle_path: Path, settings: AppSettings,
|
||||
*, note: str = "") -> dict:
|
||||
try:
|
||||
result = api.upload_diagnostics(bundle_path, metadata={
|
||||
"channel": settings.update_channel,
|
||||
"note": note[:400],
|
||||
})
|
||||
except Exception as exc:
|
||||
raise DiagnosticsError(str(exc)) from exc
|
||||
if not isinstance(result, dict) or not result.get("id"):
|
||||
raise DiagnosticsError("The Bolt service did not confirm the upload.")
|
||||
return result
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Small typed records shared by the storage, scanner and UI layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Detection:
|
||||
path: str
|
||||
signature: str
|
||||
action: str = "detected"
|
||||
quarantine_id: str | None = None
|
||||
size: int = 0
|
||||
detected_at: str = ""
|
||||
id: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.detected_at:
|
||||
self.detected_at = now_iso()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanRecord:
|
||||
id: int | None
|
||||
started_at: str
|
||||
finished_at: str | None
|
||||
scope: str
|
||||
status: str
|
||||
paths: list[str]
|
||||
scanned_files: int = 0
|
||||
infected_files: int = 0
|
||||
errors: int = 0
|
||||
engine_version: str = ""
|
||||
db_version: str = ""
|
||||
duration_seconds: float = 0.0
|
||||
cancelled: bool = False
|
||||
detections: list[Detection] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def scope_label(self) -> str:
|
||||
return "Full disk" if self.scope == "full" else "User homes"
|
||||
|
||||
@property
|
||||
def threat_count(self) -> int:
|
||||
return max(self.infected_files, len(self.detections))
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineItem:
|
||||
id: str
|
||||
original_path: str
|
||||
quarantine_path: str
|
||||
signature: str
|
||||
sha256: str
|
||||
size: int
|
||||
created_at: str
|
||||
status: str = "quarantined"
|
||||
scan_id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealtimeEvent:
|
||||
created_at: str
|
||||
path: str
|
||||
signature: str
|
||||
action: str
|
||||
detail: str = ""
|
||||
id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClamAVInfo:
|
||||
available: bool
|
||||
path: Path | None = None
|
||||
engine_version: str = ""
|
||||
db_version: str = ""
|
||||
db_path: Path | None = None
|
||||
db_date: str = ""
|
||||
db_age_days: float | None = None
|
||||
error: str = ""
|
||||
|
||||
@property
|
||||
def db_fresh(self) -> bool:
|
||||
return self.db_age_days is not None and self.db_age_days <= 7
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
if not self.available:
|
||||
return self.error or "ClamAV not found"
|
||||
bits = [self.engine_version or "ClamAV"]
|
||||
if self.db_version:
|
||||
bits.append(f"signatures {self.db_version}")
|
||||
if self.db_age_days is not None:
|
||||
bits.append(f"{self.db_age_days:.0f}d old")
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateAsset:
|
||||
id: str
|
||||
name: str
|
||||
size: int
|
||||
content_type: str
|
||||
download_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateRelease:
|
||||
id: str
|
||||
tag: str
|
||||
name: str
|
||||
notes: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
html_url: str
|
||||
assets: list[UpdateAsset] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateFeed:
|
||||
channel: str
|
||||
latest: UpdateRelease | None
|
||||
releases: list[UpdateRelease]
|
||||
checked_at: str
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict) -> "UpdateFeed":
|
||||
def release_from(item: dict) -> UpdateRelease:
|
||||
assets = [
|
||||
UpdateAsset(
|
||||
id=str(asset.get("id") or ""),
|
||||
name=str(asset.get("name") or "download"),
|
||||
size=int(asset.get("size") or 0),
|
||||
content_type=str(asset.get("content_type") or ""),
|
||||
download_url=str(asset.get("download_url") or ""),
|
||||
)
|
||||
for asset in (item.get("assets") or [])
|
||||
if isinstance(asset, dict)
|
||||
]
|
||||
return UpdateRelease(
|
||||
id=str(item.get("id") or ""),
|
||||
tag=str(item.get("tag") or ""),
|
||||
name=str(item.get("name") or item.get("tag") or ""),
|
||||
notes=str(item.get("notes") or ""),
|
||||
prerelease=bool(item.get("prerelease")),
|
||||
published_at=str(item.get("published_at") or item.get("created_at") or ""),
|
||||
html_url=str(item.get("html_url") or ""),
|
||||
assets=assets,
|
||||
)
|
||||
|
||||
releases = [release_from(item) for item in (payload.get("releases") or [])
|
||||
if isinstance(item, dict)]
|
||||
latest_raw = payload.get("latest")
|
||||
latest = release_from(latest_raw) if isinstance(latest_raw, dict) else (
|
||||
releases[0] if releases else None)
|
||||
return cls(
|
||||
channel=str(payload.get("channel") or "release"),
|
||||
latest=latest,
|
||||
releases=releases,
|
||||
checked_at=str(payload.get("checked_at") or now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def display_name(path: str) -> str:
|
||||
"""Basename for a Windows or POSIX path, independent of host OS."""
|
||||
text = str(path or "")
|
||||
name = Path(text.replace("\\", "/")).name
|
||||
return name or text
|
||||
|
||||
|
||||
def human_scope(scope: str) -> str:
|
||||
return "Full disk" if scope == "full" else "User homes"
|
||||
|
||||
|
||||
def human_action(action: str) -> str:
|
||||
return {
|
||||
"detected": "Detected",
|
||||
"quarantined": "Quarantined",
|
||||
"deleted": "Deleted",
|
||||
"failed": "Action failed",
|
||||
"ignored": "Ignored",
|
||||
"allowed": "Allowed",
|
||||
"blocked": "Blocked",
|
||||
}.get(action, action.replace("_", " ").title())
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Filesystem locations and Windows helpers used across BoltClam.
|
||||
|
||||
Everything is resolved per call so ``BOLTCLAM_DATA_DIR`` / ``BOLTCLAM_CONFIG_DIR``
|
||||
can redirect tests and the portable build without importing state at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_DIR_NAME = "BoltClam"
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def _env_path(name: str) -> Path | None:
|
||||
value = os.environ.get(name, "").strip()
|
||||
return Path(os.path.expandvars(os.path.expanduser(value))) if value else None
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
override = _env_path("BOLTCLAM_CONFIG_DIR")
|
||||
if override:
|
||||
return override
|
||||
if is_windows():
|
||||
base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
return base / APP_DIR_NAME
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
override = _env_path("BOLTCLAM_DATA_DIR")
|
||||
if override:
|
||||
return override
|
||||
if is_windows():
|
||||
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
|
||||
return base / APP_DIR_NAME
|
||||
|
||||
|
||||
def logs_dir() -> Path:
|
||||
return data_dir() / "logs"
|
||||
|
||||
|
||||
def quarantine_dir() -> Path:
|
||||
return data_dir() / "quarantine"
|
||||
|
||||
|
||||
def updates_dir() -> Path:
|
||||
return data_dir() / "updates"
|
||||
|
||||
|
||||
def diagnostics_dir() -> Path:
|
||||
return data_dir() / "diagnostics"
|
||||
|
||||
|
||||
def temp_dir() -> Path:
|
||||
return data_dir() / "tmp"
|
||||
|
||||
|
||||
def clamav_db_dir() -> Path:
|
||||
"""App-managed ClamAV signature database.
|
||||
|
||||
freshclam can write here without administrator rights, and clamscan is
|
||||
pointed at it with ``--database``. When it is empty we fall back to the
|
||||
database shipped beside clamscan.exe.
|
||||
"""
|
||||
return data_dir() / "clamav-db"
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
return data_dir() / "boltclam.db"
|
||||
|
||||
|
||||
def settings_path() -> Path:
|
||||
return config_dir() / "settings.json"
|
||||
|
||||
|
||||
def account_cache_path() -> Path:
|
||||
return config_dir() / "account.json"
|
||||
|
||||
|
||||
def credentials_path() -> Path:
|
||||
return config_dir() / "credentials.dat"
|
||||
|
||||
|
||||
def resource_dir() -> Path:
|
||||
override = _env_path("BOLTCLAM_RESOURCES")
|
||||
if override:
|
||||
return override
|
||||
# PyInstaller extracts datas into _MEIPASS. The portable runtime keeps
|
||||
# resources next to the app package under runtime/resources.
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
return Path(meipass) / "resources"
|
||||
return Path(__file__).resolve().parents[2] / "resources"
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for path in (config_dir(), data_dir(), logs_dir(), quarantine_dir(),
|
||||
updates_dir(), diagnostics_dir(), temp_dir(), clamav_db_dir()):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if not is_windows():
|
||||
for path in (config_dir(), data_dir(), logs_dir(), quarantine_dir()):
|
||||
try:
|
||||
os.chmod(path, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
if not is_windows():
|
||||
try:
|
||||
return os.geteuid() == 0
|
||||
except AttributeError:
|
||||
return False
|
||||
try:
|
||||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def os_label() -> str:
|
||||
if is_windows():
|
||||
version = sys.getwindowsversion()
|
||||
return f"Windows {version.major}.{version.minor} (build {version.build})"
|
||||
return f"{os.name} {sys.platform}"
|
||||
|
||||
|
||||
def hostname() -> str:
|
||||
import socket
|
||||
|
||||
try:
|
||||
return socket.gethostname() or "Windows PC"
|
||||
except Exception:
|
||||
return "Windows PC"
|
||||
|
||||
|
||||
def open_path(path: Path) -> bool:
|
||||
"""Open a file/folder in the OS shell. Returns False instead of raising."""
|
||||
try:
|
||||
if is_windows():
|
||||
os.startfile(str(path)) # type: ignore[attr-defined]
|
||||
elif sys.platform == "darwin":
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen(["open", str(path)])
|
||||
else:
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def free_space(path: Path) -> int:
|
||||
try:
|
||||
return shutil.disk_usage(path).free
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def windows_profile_dirs() -> list[Path]:
|
||||
"""Every Windows user profile path we can discover.
|
||||
|
||||
Registry ``ProfileList`` is authoritative and includes profiles outside
|
||||
C:\\Users. When it cannot be read (ordinary non-admin process), fall back
|
||||
to the children of C:\\Users. Public is included deliberately: downloads
|
||||
there belong to every local account.
|
||||
"""
|
||||
profiles: list[Path] = []
|
||||
if is_windows():
|
||||
try:
|
||||
import winreg
|
||||
|
||||
key_path = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key:
|
||||
index = 0
|
||||
while True:
|
||||
try:
|
||||
subkey_name = winreg.EnumKey(key, index)
|
||||
except OSError:
|
||||
break
|
||||
index += 1
|
||||
try:
|
||||
with winreg.OpenKey(key, subkey_name) as subkey:
|
||||
value, _kind = winreg.QueryValueEx(subkey, "ProfileImagePath")
|
||||
expanded = os.path.expandvars(str(value or ""))
|
||||
if expanded:
|
||||
profiles.append(Path(expanded))
|
||||
except OSError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
users = Path(os.environ.get("SystemDrive", "C:") + "\\Users")
|
||||
if users.is_dir():
|
||||
try:
|
||||
for child in users.iterdir():
|
||||
if child.is_dir():
|
||||
profiles.append(child)
|
||||
except OSError:
|
||||
pass
|
||||
public = users / "Public"
|
||||
if public.is_dir():
|
||||
profiles.append(public)
|
||||
else:
|
||||
home = Path.home()
|
||||
profiles.append(home)
|
||||
if Path("/home").is_dir():
|
||||
try:
|
||||
for child in Path("/home").iterdir():
|
||||
if child.is_dir():
|
||||
profiles.append(child)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
home = Path.home()
|
||||
profiles.append(home)
|
||||
seen: set[str] = set()
|
||||
result: list[Path] = []
|
||||
for profile in profiles:
|
||||
try:
|
||||
resolved = profile.expanduser().resolve()
|
||||
except OSError:
|
||||
continue
|
||||
key = os.path.normcase(str(resolved))
|
||||
if key in seen or not resolved.is_dir():
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(resolved)
|
||||
return result
|
||||
|
||||
|
||||
def browser_download_roots() -> list[Path]:
|
||||
"""Default Downloads folders for every local profile we can read."""
|
||||
roots: list[Path] = []
|
||||
for profile in windows_profile_dirs():
|
||||
downloads = profile / "Downloads"
|
||||
if downloads.is_dir():
|
||||
roots.append(downloads)
|
||||
return roots
|
||||
|
||||
|
||||
def fixed_drive_roots() -> list[Path]:
|
||||
"""Roots of fixed local disks (Windows), or the filesystem root elsewhere."""
|
||||
if not is_windows():
|
||||
return [Path("/")] if Path("/").is_dir() else []
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
mask = kernel32.GetLogicalDrives()
|
||||
for index in range(26):
|
||||
if not (mask & (1 << index)):
|
||||
continue
|
||||
root = f"{chr(ord('A') + index)}:\\"
|
||||
# DRIVE_FIXED == 3; network/removable drives are skipped.
|
||||
if kernel32.GetDriveTypeW(ctypes.c_wchar_p(root)) == 3:
|
||||
path = Path(root)
|
||||
if path.is_dir():
|
||||
roots.append(path)
|
||||
except Exception:
|
||||
system_drive = os.environ.get("SystemDrive", "C:")
|
||||
path = Path(system_drive + "\\")
|
||||
if path.is_dir():
|
||||
roots.append(path)
|
||||
return roots
|
||||
|
||||
|
||||
def is_under(path: Path, parent: Path) -> bool:
|
||||
try:
|
||||
path = path.resolve()
|
||||
parent = parent.resolve()
|
||||
except OSError:
|
||||
return False
|
||||
return path == parent or parent in path.parents
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Quarantine: move a detected file somewhere inert, record it, restore or delete it.
|
||||
|
||||
A quarantine file is renamed with a ``.quarantine`` suffix (so Windows has
|
||||
no executable association), made read-only where possible, and paired with
|
||||
a JSON sidecar. The SQLite row is the source of truth for the UI; the
|
||||
sidecar survives a database rebuild.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import paths
|
||||
from .models import QuarantineItem, now_iso
|
||||
from .storage import Storage, StorageError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuarantineError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def hash_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def delete_file(path: Path) -> None:
|
||||
"""Delete one file, clearing the Windows read-only bit if necessary.
|
||||
|
||||
Directories are refused: a detection names a file, and recursively
|
||||
deleting a folder on the strength of one signature would be far more
|
||||
destructive than the finding warrants.
|
||||
"""
|
||||
path = Path(path)
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
return
|
||||
if path.is_dir():
|
||||
raise QuarantineError("Refusing to delete a directory.")
|
||||
try:
|
||||
path.unlink()
|
||||
except PermissionError:
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE | stat.S_IREAD)
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
raise QuarantineError(f"Could not delete {path.name}: {exc}") from exc
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as exc:
|
||||
raise QuarantineError(f"Could not delete {path.name}: {exc}") from exc
|
||||
|
||||
|
||||
def _hide_on_windows(path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
hidden = 0x2
|
||||
attributes = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
||||
if attributes != -1:
|
||||
ctypes.windll.kernel32.SetFileAttributesW(str(path), attributes | hidden)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class QuarantineService:
|
||||
def __init__(self, storage: Storage, root: Path | None = None) -> None:
|
||||
self.storage = storage
|
||||
self.root = Path(root) if root else paths.quarantine_dir()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _metadata_path(self, item_id: str) -> Path:
|
||||
return self.root / f"{item_id}.json"
|
||||
|
||||
def _write_metadata(self, item: QuarantineItem, reason: str = "") -> None:
|
||||
payload = {
|
||||
"id": item.id,
|
||||
"original_path": item.original_path,
|
||||
"quarantine_path": item.quarantine_path,
|
||||
"signature": item.signature,
|
||||
"sha256": item.sha256,
|
||||
"size": item.size,
|
||||
"created_at": item.created_at,
|
||||
"status": item.status,
|
||||
"scan_id": item.scan_id,
|
||||
"reason": reason,
|
||||
}
|
||||
temp = self._metadata_path(item.id).with_suffix(".json.tmp")
|
||||
temp.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
||||
os.replace(temp, self._metadata_path(item.id))
|
||||
|
||||
def _update_metadata_status(self, item: QuarantineItem) -> None:
|
||||
metadata = self._metadata_path(item.id)
|
||||
try:
|
||||
payload = json.loads(metadata.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
payload.update({
|
||||
"id": item.id, "original_path": item.original_path,
|
||||
"quarantine_path": item.quarantine_path, "signature": item.signature,
|
||||
"sha256": item.sha256, "size": item.size, "created_at": item.created_at,
|
||||
"status": item.status, "scan_id": item.scan_id,
|
||||
})
|
||||
try:
|
||||
metadata.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
||||
except OSError:
|
||||
logger.warning("Could not update quarantine metadata for %s", item.id)
|
||||
|
||||
def quarantine_file(self, path: Path, signature: str, *,
|
||||
scan_id: int | None = None, reason: str = "") -> QuarantineItem:
|
||||
path = Path(path)
|
||||
if path.is_symlink():
|
||||
raise QuarantineError("Refusing to quarantine a symbolic link.")
|
||||
if not path.is_file():
|
||||
raise QuarantineError("The detected file no longer exists.")
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
digest = hash_file(path)
|
||||
except OSError as exc:
|
||||
raise QuarantineError(f"Could not read the detected file: {exc}") from exc
|
||||
|
||||
item_id = f"Q-{time.strftime('%Y%m%dT%H%M%S')}-{digest[:10]}"
|
||||
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", path.name)[:80] or "detected"
|
||||
destination = self.root / f"{item_id}-{safe_name}.quarantine"
|
||||
with self._lock:
|
||||
if destination.exists():
|
||||
raise QuarantineError("A quarantine file with that name already exists.")
|
||||
try:
|
||||
shutil.move(str(path), str(destination))
|
||||
except OSError as exc:
|
||||
raise QuarantineError(f"Could not move the file into quarantine: {exc}") from exc
|
||||
_hide_on_windows(destination)
|
||||
item = QuarantineItem(
|
||||
id=item_id,
|
||||
original_path=str(path),
|
||||
quarantine_path=str(destination),
|
||||
signature=signature or "Unknown",
|
||||
sha256=digest,
|
||||
size=size,
|
||||
created_at=now_iso(),
|
||||
status="quarantined",
|
||||
scan_id=scan_id,
|
||||
)
|
||||
try:
|
||||
self.storage.add_quarantine(item)
|
||||
except StorageError:
|
||||
# The database is what the UI reads. If it cannot record the
|
||||
# move, put the file back rather than leaving an untracked
|
||||
# quarantined item.
|
||||
try:
|
||||
shutil.move(str(destination), str(path))
|
||||
except OSError:
|
||||
logger.exception("Quarantine rollback failed; file remains at %s", destination)
|
||||
raise QuarantineError(
|
||||
"The file was moved but could not be recorded; it was restored.")
|
||||
try:
|
||||
self._write_metadata(item, reason=reason)
|
||||
except OSError:
|
||||
# The SQLite row is authoritative; a missing sidecar is an
|
||||
# inconvenience, not a reason to undo an otherwise successful
|
||||
# quarantine.
|
||||
logger.warning("Quarantine metadata could not be written for %s",
|
||||
item.id, exc_info=True)
|
||||
logger.info("Quarantined %s (%s)", path, item_id)
|
||||
return item
|
||||
|
||||
def restore(self, item_id: str, destination: Path | None = None) -> Path:
|
||||
item = self.storage.get_quarantine(item_id)
|
||||
if item is None or item.status != "quarantined":
|
||||
raise QuarantineError("That quarantined file is no longer available.")
|
||||
source = Path(item.quarantine_path)
|
||||
if not source.is_file():
|
||||
raise QuarantineError("The quarantined file is missing from disk.")
|
||||
target = Path(destination) if destination else Path(item.original_path)
|
||||
with self._lock:
|
||||
if target.exists():
|
||||
raise QuarantineError(f"A file already exists at {target}.")
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(source), str(target))
|
||||
except OSError as exc:
|
||||
raise QuarantineError(f"Could not restore the file: {exc}") from exc
|
||||
item.status = "restored"
|
||||
self.storage.set_quarantine_status(item.id, "restored")
|
||||
self._update_metadata_status(item)
|
||||
logger.info("Restored %s to %s", item.id, target)
|
||||
return target
|
||||
|
||||
def delete(self, item_id: str) -> None:
|
||||
item = self.storage.get_quarantine(item_id)
|
||||
if item is None:
|
||||
raise QuarantineError("That quarantined file was not found.")
|
||||
source = Path(item.quarantine_path)
|
||||
with self._lock:
|
||||
try:
|
||||
delete_file(source)
|
||||
except QuarantineError:
|
||||
raise
|
||||
item.status = "deleted"
|
||||
self.storage.set_quarantine_status(item.id, "deleted")
|
||||
self._update_metadata_status(item)
|
||||
logger.info("Deleted quarantined item %s", item.id)
|
||||
|
||||
def delete_original(self, path: Path) -> None:
|
||||
delete_file(Path(path))
|
||||
|
||||
def delete_missing_or_file(self, path: Path) -> None:
|
||||
try:
|
||||
delete_file(Path(path))
|
||||
except QuarantineError:
|
||||
raise
|
||||
|
||||
def open_folder(self) -> bool:
|
||||
return paths.open_path(self.root)
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Browser-download watcher and the real-time scan-before-open gate.
|
||||
|
||||
Watches the default Downloads folder of every readable Windows profile,
|
||||
plus any custom download directory recorded by Chrome, Edge, Brave, Vivaldi,
|
||||
Opera or Firefox. A completed download is copied while an exclusive handle
|
||||
is held (where Windows allows it), scanned with ClamAV, and quarantined or
|
||||
deleted before the handle is released.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from PyQt6.QtCore import QObject, QRunnable, QThread, QThreadPool, pyqtSignal
|
||||
|
||||
from . import paths
|
||||
from .clamav import (
|
||||
ClamAVService,
|
||||
hidden_kwargs,
|
||||
parse_clamscan_line,
|
||||
parse_extra_options,
|
||||
)
|
||||
from .config import AppSettings
|
||||
from .models import RealtimeEvent, now_iso
|
||||
from .quarantine import QuarantineError, QuarantineService, delete_file
|
||||
from .storage import Storage
|
||||
from .winlock import LockError, copy_for_scan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMP_SUFFIXES = (
|
||||
".crdownload", ".part", ".partial", ".download", ".opdownload", ".tmp",
|
||||
".temp", ".filepart", ".aria2", ".td", ".!qb", ".!ut", ".partial~",
|
||||
)
|
||||
_JS_ESCAPE = re.compile(r"\\(.)")
|
||||
_FIREFOX_DIR_RE = re.compile(
|
||||
r'user_pref\("browser\.download\.dir",\s*"((?:\\.|[^"\\])*)"\)\s*;')
|
||||
|
||||
|
||||
def _js_unescape(value: str) -> str:
|
||||
def replace(match: re.Match) -> str:
|
||||
char = match.group(1)
|
||||
return {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}.get(char, char)
|
||||
|
||||
return _JS_ESCAPE.sub(replace, value)
|
||||
|
||||
|
||||
def _existing_dir(value: str | Path | None) -> Path | None:
|
||||
if not value:
|
||||
return None
|
||||
text = os.path.expandvars(os.path.expanduser(str(value).strip().strip('"')))
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
path = Path(text)
|
||||
if path.is_dir() and os.access(path, os.R_OK | os.X_OK):
|
||||
return path
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _chromium_preference_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
roaming = os.environ.get("APPDATA")
|
||||
bases: list[Path] = []
|
||||
if local:
|
||||
bases.extend([
|
||||
Path(local) / "Google" / "Chrome" / "User Data",
|
||||
Path(local) / "Microsoft" / "Edge" / "User Data",
|
||||
Path(local) / "BraveSoftware" / "Brave-Browser" / "User Data",
|
||||
Path(local) / "Vivaldi" / "User Data",
|
||||
Path(local) / "Chromium" / "User Data",
|
||||
])
|
||||
if roaming:
|
||||
bases.append(Path(roaming) / "Opera Software" / "Opera Stable")
|
||||
if os.name != "nt":
|
||||
home = Path.home()
|
||||
bases.extend([
|
||||
home / ".config" / "google-chrome",
|
||||
home / ".config" / "chromium",
|
||||
home / ".config" / "microsoft-edge",
|
||||
home / ".config" / "BraveSoftware" / "Brave-Browser",
|
||||
])
|
||||
for base in bases:
|
||||
try:
|
||||
direct = base / "Preferences"
|
||||
if direct.is_file():
|
||||
files.append(direct)
|
||||
if base.is_dir():
|
||||
for child in base.iterdir():
|
||||
if child.is_dir() and (child.name == "Default"
|
||||
or child.name.startswith("Profile ")
|
||||
or child.name == "Guest Profile"):
|
||||
pref = child / "Preferences"
|
||||
if pref.is_file():
|
||||
files.append(pref)
|
||||
except OSError:
|
||||
continue
|
||||
return files
|
||||
|
||||
|
||||
def _chromium_download_dirs() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
home = str(Path.home())
|
||||
for pref in _chromium_preference_files():
|
||||
try:
|
||||
data = json.loads(pref.read_text(encoding="utf-8", errors="replace"))
|
||||
download = data.get("download") if isinstance(data, dict) else None
|
||||
value = download.get("default_directory") if isinstance(download, dict) else None
|
||||
if not value:
|
||||
continue
|
||||
text = str(value).replace("${user_home}", home)
|
||||
resolved = _existing_dir(text)
|
||||
if resolved:
|
||||
roots.append(resolved)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def _firefox_download_dirs() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
bases: list[Path] = []
|
||||
roaming = os.environ.get("APPDATA")
|
||||
if roaming:
|
||||
bases.append(Path(roaming) / "Mozilla" / "Firefox" / "Profiles")
|
||||
bases.append(Path.home() / ".mozilla" / "firefox")
|
||||
for base in bases:
|
||||
try:
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for profile in base.iterdir():
|
||||
prefs = profile / "prefs.js"
|
||||
if not profile.is_dir() or not prefs.is_file():
|
||||
continue
|
||||
text = prefs.read_text(encoding="utf-8", errors="replace")
|
||||
for match in _FIREFOX_DIR_RE.finditer(text):
|
||||
resolved = _existing_dir(_js_unescape(match.group(1)))
|
||||
if resolved:
|
||||
roots.append(resolved)
|
||||
except OSError:
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def browser_download_roots(extra: Iterable[str] = ()) -> list[Path]:
|
||||
"""Every download directory we can justify watching, deduplicated."""
|
||||
roots: list[Path] = []
|
||||
roots.extend(paths.browser_download_roots())
|
||||
roots.extend(_chromium_download_dirs())
|
||||
roots.extend(_firefox_download_dirs())
|
||||
for entry in extra:
|
||||
resolved = _existing_dir(entry)
|
||||
if resolved:
|
||||
roots.append(resolved)
|
||||
result: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
try:
|
||||
key = os.path.normcase(str(root.resolve()))
|
||||
except OSError:
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(root)
|
||||
return result[:50]
|
||||
|
||||
|
||||
def _excluded_roots() -> list[str]:
|
||||
excluded = [paths.quarantine_dir(), paths.logs_dir(), paths.updates_dir(),
|
||||
paths.diagnostics_dir(), paths.temp_dir(), paths.clamav_db_dir()]
|
||||
result: list[str] = []
|
||||
for root in excluded:
|
||||
try:
|
||||
result.append(os.path.normcase(str(root.resolve())) + os.sep)
|
||||
except OSError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def _is_temp_name(name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered.endswith(_TEMP_SUFFIXES) or lowered.endswith("~")
|
||||
|
||||
|
||||
def _iter_download_files(root: Path) -> Iterable[Path]:
|
||||
"""Files directly in a download root and one level below it."""
|
||||
try:
|
||||
entries = list(os.scandir(root))
|
||||
except OSError:
|
||||
return
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.name.startswith(".") or entry.name.startswith("$"):
|
||||
continue
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
yield Path(entry.path)
|
||||
elif entry.is_dir(follow_symlinks=False) and entry.name.lower() not in (
|
||||
"node_modules", "system volume information", "recycle.bin"):
|
||||
try:
|
||||
for child in os.scandir(entry.path):
|
||||
if child.is_file(follow_symlinks=False) and not child.name.startswith("."):
|
||||
yield Path(child.path)
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def snapshot(roots: Iterable[Path]) -> dict[str, tuple[int, int, str]]:
|
||||
excluded = _excluded_roots()
|
||||
result: dict[str, tuple[int, int, str]] = {}
|
||||
for root in roots:
|
||||
for path in _iter_download_files(root):
|
||||
if _is_temp_name(path.name):
|
||||
continue
|
||||
try:
|
||||
key = os.path.normcase(str(path.resolve()))
|
||||
except OSError:
|
||||
continue
|
||||
if any(key.startswith(prefix) for prefix in excluded):
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if stat.st_size < 0:
|
||||
continue
|
||||
result[key] = (int(stat.st_size), int(stat.st_mtime_ns), str(path))
|
||||
return result
|
||||
|
||||
|
||||
class DownloadWatcher(QThread):
|
||||
file_ready = pyqtSignal(str)
|
||||
watch_status = pyqtSignal(str)
|
||||
roots_changed = pyqtSignal(list)
|
||||
|
||||
def __init__(self, extra_dirs: Iterable[str] = (), interval: float = 1.5,
|
||||
parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.extra_dirs = list(extra_dirs)
|
||||
self.interval = max(0.8, float(interval))
|
||||
self._stop = None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._stop is not None:
|
||||
self._stop.set()
|
||||
|
||||
def run(self) -> None:
|
||||
import threading
|
||||
|
||||
stop = threading.Event()
|
||||
self._stop = stop
|
||||
roots = browser_download_roots(self.extra_dirs)
|
||||
self.roots_changed.emit([str(root) for root in roots])
|
||||
initial = snapshot(roots)
|
||||
known = set(initial)
|
||||
candidates: dict[str, dict] = {}
|
||||
last_refresh = time.monotonic()
|
||||
warned_empty = False
|
||||
logger.info("Real-time watcher started on %s folder(s)", len(roots))
|
||||
while not stop.is_set():
|
||||
now = time.monotonic()
|
||||
if now - last_refresh >= 30:
|
||||
refreshed = browser_download_roots(self.extra_dirs)
|
||||
if {str(p) for p in refreshed} != {str(p) for p in roots}:
|
||||
roots = refreshed
|
||||
for path in snapshot(roots):
|
||||
if path not in known and path not in candidates:
|
||||
known.add(path)
|
||||
self.roots_changed.emit([str(root) for root in roots])
|
||||
last_refresh = now
|
||||
current = snapshot(roots)
|
||||
for path, (size, mtime, actual_path) in current.items():
|
||||
signature = (size, mtime)
|
||||
if path in known:
|
||||
continue
|
||||
candidate = candidates.get(path)
|
||||
if candidate is None:
|
||||
candidates[path] = {"signature": signature, "stable": 0}
|
||||
elif candidate["signature"] == signature:
|
||||
candidate["stable"] += 1
|
||||
if candidate["stable"] >= 1:
|
||||
candidates.pop(path, None)
|
||||
known.add(path)
|
||||
self.file_ready.emit(actual_path)
|
||||
else:
|
||||
candidate["signature"] = signature
|
||||
candidate["stable"] = 0
|
||||
for path in list(candidates):
|
||||
if path not in current:
|
||||
candidates.pop(path, None)
|
||||
for path in list(known):
|
||||
if path not in current:
|
||||
known.discard(path)
|
||||
if not roots and not warned_empty:
|
||||
warned_empty = True
|
||||
self.watch_status.emit("No browser download folders found")
|
||||
elif roots:
|
||||
self.watch_status.emit(f"Watching {len(roots)} download folder(s)")
|
||||
stop.wait(self.interval)
|
||||
logger.info("Real-time watcher stopped")
|
||||
|
||||
|
||||
class RealtimeScanSignals(QObject):
|
||||
finished = pyqtSignal(str, object)
|
||||
alert = pyqtSignal(str, str, str)
|
||||
|
||||
|
||||
class RealtimeScanTask(QRunnable):
|
||||
def __init__(self, path: Path, settings: AppSettings, clamav: ClamAVService,
|
||||
storage: Storage, quarantine: QuarantineService) -> None:
|
||||
super().__init__()
|
||||
self.path = Path(path)
|
||||
self.settings = settings
|
||||
self.clamav = clamav
|
||||
self.storage = storage
|
||||
self.quarantine = quarantine
|
||||
self.signals = RealtimeScanSignals()
|
||||
self.setAutoDelete(True)
|
||||
|
||||
def run(self) -> None:
|
||||
temp_root = paths.temp_dir() / "realtime" / uuid.uuid4().hex
|
||||
temp_root.mkdir(parents=True, exist_ok=True)
|
||||
sample = temp_root / "download-sample.bin"
|
||||
locker = None
|
||||
exclusive = False
|
||||
event: RealtimeEvent | None = None
|
||||
try:
|
||||
locker, exclusive = copy_for_scan(self.path, sample)
|
||||
detections, scan_error = self._scan(sample)
|
||||
detail = "Locked while scanning" if exclusive else "File was in use; scanned a copy"
|
||||
if detections:
|
||||
signature = detections[0]
|
||||
action = self.settings.auto_action
|
||||
if action == "delete":
|
||||
if locker is not None and locker.mark_delete_on_close():
|
||||
action = "deleted"
|
||||
detail += "; deleted on close"
|
||||
else:
|
||||
if locker is not None:
|
||||
locker.close()
|
||||
locker = None
|
||||
try:
|
||||
delete_file(self.path)
|
||||
action = "deleted"
|
||||
except QuarantineError as exc:
|
||||
action = "failed"
|
||||
detail += f"; delete failed: {exc}"
|
||||
elif action == "quarantine":
|
||||
if locker is not None:
|
||||
locker.close()
|
||||
locker = None
|
||||
try:
|
||||
item = self.quarantine.quarantine_file(
|
||||
self.path, signature, reason="Browser download")
|
||||
action = "quarantined"
|
||||
detail += f"; quarantine {item.id}"
|
||||
except QuarantineError as exc:
|
||||
action = "failed"
|
||||
detail += f"; quarantine failed: {exc}"
|
||||
else:
|
||||
action = "detected"
|
||||
detail += "; left in place (notify only)"
|
||||
event = RealtimeEvent(
|
||||
created_at=now_iso(), path=str(self.path), signature=signature,
|
||||
action=action, detail=detail)
|
||||
self.storage.add_realtime_event(event)
|
||||
title = {
|
||||
"quarantined": "Download quarantined",
|
||||
"deleted": "Download deleted",
|
||||
"detected": "Malware detected in a download",
|
||||
"failed": "Detection needs your attention",
|
||||
}.get(action, "Malware detected")
|
||||
self.signals.alert.emit(
|
||||
title, f"{self.path.name}\n{signature}\n{detail}", action)
|
||||
elif scan_error:
|
||||
event = RealtimeEvent(
|
||||
created_at=now_iso(), path=str(self.path), signature="",
|
||||
action="error", detail=f"{scan_error}; {detail}")
|
||||
self.storage.add_realtime_event(event)
|
||||
self.signals.alert.emit(
|
||||
"Could not scan a download",
|
||||
f"{self.path.name}\n{scan_error}", "error")
|
||||
except (LockError, OSError) as exc:
|
||||
logger.warning("Real-time scan could not read %s: %s", self.path, exc)
|
||||
event = RealtimeEvent(
|
||||
created_at=now_iso(), path=str(self.path), signature="",
|
||||
action="error", detail=f"Could not read the download: {exc}")
|
||||
try:
|
||||
self.storage.add_realtime_event(event)
|
||||
except Exception:
|
||||
pass
|
||||
self.signals.alert.emit("Could not scan a download",
|
||||
f"{self.path.name}\n{exc}", "error")
|
||||
except Exception as exc:
|
||||
logger.exception("Real-time scan failed for %s", self.path)
|
||||
event = RealtimeEvent(
|
||||
created_at=now_iso(), path=str(self.path), signature="",
|
||||
action="error", detail=f"Real-time scan failed: {exc}")
|
||||
try:
|
||||
self.storage.add_realtime_event(event)
|
||||
except Exception:
|
||||
pass
|
||||
self.signals.alert.emit("Real-time protection error",
|
||||
f"{self.path.name}\n{exc}", "error")
|
||||
finally:
|
||||
if locker is not None:
|
||||
try:
|
||||
locker.close()
|
||||
except Exception:
|
||||
pass
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
self.signals.finished.emit(str(self.path), event)
|
||||
|
||||
def _scan(self, sample: Path) -> tuple[list[str], str]:
|
||||
info = self.clamav.info()
|
||||
if not info.available:
|
||||
return [], info.error or "ClamAV is not available"
|
||||
try:
|
||||
extra = parse_extra_options(self.settings.extra_options)
|
||||
except ValueError:
|
||||
extra = []
|
||||
try:
|
||||
args = self.clamav.scan_arguments([sample], extra, exclude_temp=False)
|
||||
except Exception as exc:
|
||||
return [], str(exc)
|
||||
detections: list[str] = []
|
||||
skipped = ""
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
||||
cwd=str(info.path.parent) if info.path else None,
|
||||
env={**os.environ, "LC_ALL": "C", "LANG": "C"}, **hidden_kwargs(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
deadline = time.monotonic() + 300
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
process.kill()
|
||||
return detections, "Scan timed out"
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
parsed = parse_clamscan_line(line)
|
||||
if not parsed:
|
||||
continue
|
||||
if parsed.get("kind") == "detection":
|
||||
detections.append(str(parsed.get("signature") or "Unknown"))
|
||||
elif parsed.get("kind") == "skipped":
|
||||
skipped = str(parsed.get("reason") or "skipped")
|
||||
code = process.wait(timeout=20)
|
||||
if code not in (0, 1) and not detections:
|
||||
return detections, skipped or f"clamscan exited with code {code}"
|
||||
if skipped and not detections:
|
||||
return detections, skipped
|
||||
return detections, ""
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return detections, str(exc)
|
||||
|
||||
|
||||
class RealtimeProtection(QObject):
|
||||
event_created = pyqtSignal(object)
|
||||
status_changed = pyqtSignal(str)
|
||||
alert = pyqtSignal(str, str, str)
|
||||
|
||||
def __init__(self, clamav: ClamAVService, storage: Storage,
|
||||
quarantine: QuarantineService, settings: AppSettings,
|
||||
parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.clamav = clamav
|
||||
self.storage = storage
|
||||
self.quarantine = quarantine
|
||||
self.settings = settings
|
||||
self._watcher: DownloadWatcher | None = None
|
||||
self._pool = QThreadPool(self)
|
||||
self._pool.setMaxThreadCount(2)
|
||||
self._inflight: set[str] = set()
|
||||
self._tasks: dict[str, RealtimeScanTask] = {}
|
||||
self._roots: list[str] = []
|
||||
self._warned_missing = False
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return bool(self._watcher is not None and self._watcher.isRunning())
|
||||
|
||||
@property
|
||||
def watched_roots(self) -> list[str]:
|
||||
return list(self._roots)
|
||||
|
||||
def set_settings(self, settings: AppSettings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def start(self) -> None:
|
||||
self.stop()
|
||||
self._warned_missing = False
|
||||
if not self.settings.realtime_enabled:
|
||||
self.status_changed.emit("Real-time protection is off")
|
||||
return
|
||||
if not self.clamav.info().available:
|
||||
self.status_changed.emit("Real-time protection needs ClamAV")
|
||||
return
|
||||
watcher = DownloadWatcher(self.settings.extra_watch_dirs)
|
||||
watcher.file_ready.connect(self._on_file)
|
||||
watcher.watch_status.connect(self.status_changed)
|
||||
watcher.roots_changed.connect(self._on_roots)
|
||||
self._watcher = watcher
|
||||
watcher.start()
|
||||
self.status_changed.emit("Starting real-time protection…")
|
||||
|
||||
def stop(self) -> None:
|
||||
watcher = self._watcher
|
||||
self._watcher = None
|
||||
if watcher is not None:
|
||||
watcher.stop()
|
||||
watcher.wait(4000)
|
||||
self._pool.waitForDone(5000)
|
||||
|
||||
def _on_roots(self, roots: list) -> None:
|
||||
self._roots = [str(root) for root in roots]
|
||||
|
||||
def _on_file(self, path: str) -> None:
|
||||
if not self.settings.realtime_enabled or not path:
|
||||
return
|
||||
if path in self._inflight:
|
||||
return
|
||||
try:
|
||||
size = Path(path).stat().st_size
|
||||
except OSError:
|
||||
return
|
||||
max_bytes = int(os.environ.get("BOLTCLAM_REALTIME_MAX_MB", "1024")) * 1024 * 1024
|
||||
if size > max_bytes:
|
||||
event = RealtimeEvent(
|
||||
created_at=now_iso(), path=path, signature="",
|
||||
action="skipped",
|
||||
detail=f"File is larger than the {max_bytes // (1024 * 1024)} MB real-time limit")
|
||||
try:
|
||||
self.storage.add_realtime_event(event)
|
||||
except Exception:
|
||||
pass
|
||||
self.event_created.emit(event)
|
||||
self.alert.emit("Large download not scanned",
|
||||
f"{Path(path).name}\nIt exceeds the real-time scan size limit.",
|
||||
"warning")
|
||||
return
|
||||
if not self.clamav.info().available:
|
||||
if not self._warned_missing:
|
||||
self._warned_missing = True
|
||||
self.alert.emit("Real-time protection is not scanning",
|
||||
"ClamAV is not available. Set its install folder in Settings.",
|
||||
"error")
|
||||
return
|
||||
self._inflight.add(path)
|
||||
task = RealtimeScanTask(Path(path), self.settings, self.clamav,
|
||||
self.storage, self.quarantine)
|
||||
self._tasks[path] = task
|
||||
task.signals.finished.connect(self._on_finished)
|
||||
task.signals.alert.connect(self.alert)
|
||||
self._pool.start(task)
|
||||
|
||||
def _on_finished(self, path: str, event) -> None:
|
||||
self._inflight.discard(path)
|
||||
self._tasks.pop(path, None)
|
||||
if event is not None:
|
||||
self.event_created.emit(event)
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Background scan and definition-update workers.
|
||||
|
||||
The scan worker owns no UI objects. It streams clamscan output, records
|
||||
every detection before attempting remediation, and always finishes the
|
||||
database row even when the process fails or the user cancels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from . import paths
|
||||
from .clamav import (
|
||||
ClamAVError,
|
||||
ClamAVService,
|
||||
build_freshclam_args,
|
||||
hidden_kwargs,
|
||||
parse_clamscan_line,
|
||||
parse_extra_options,
|
||||
scan_targets,
|
||||
)
|
||||
from .config import AppSettings
|
||||
from .models import Detection, ScanRecord
|
||||
from .quarantine import QuarantineError, QuarantineService
|
||||
from .storage import Storage, StorageError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScanWorker(QThread):
|
||||
started_scan = pyqtSignal(int, list)
|
||||
progress = pyqtSignal(str, int)
|
||||
detection_found = pyqtSignal(object)
|
||||
log_line = pyqtSignal(str)
|
||||
finished_scan = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
status = pyqtSignal(str)
|
||||
|
||||
def __init__(self, clamav: ClamAVService, storage: Storage,
|
||||
quarantine: QuarantineService, settings: AppSettings,
|
||||
scope: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.clamav = clamav
|
||||
self.storage = storage
|
||||
self.quarantine = quarantine
|
||||
self.settings = settings
|
||||
self.scope = "full" if scope == "full" else "homes"
|
||||
self._cancel = False
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._last_progress = 0.0
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancel = True
|
||||
process = self._process
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def run(self) -> None: # noqa: C901 - one linear process lifecycle
|
||||
info = self.clamav.info(force=True)
|
||||
if not info.available or info.path is None:
|
||||
self.failed.emit(info.error or "ClamAV is not configured.")
|
||||
return
|
||||
targets = scan_targets(self.scope)
|
||||
if not targets:
|
||||
self.failed.emit("No scan targets were found for that scope.")
|
||||
return
|
||||
try:
|
||||
extra = parse_extra_options(self.settings.extra_options)
|
||||
except ValueError as exc:
|
||||
self.failed.emit(str(exc))
|
||||
return
|
||||
try:
|
||||
args = self.clamav.scan_arguments(targets, extra)
|
||||
except ClamAVError as exc:
|
||||
self.failed.emit(str(exc))
|
||||
return
|
||||
|
||||
scan_id = 0
|
||||
started = time.monotonic()
|
||||
summary: dict = {}
|
||||
detections: list[Detection] = []
|
||||
error_count = 0
|
||||
cancelled = False
|
||||
returncode: int | None = None
|
||||
last_lines: list[str] = []
|
||||
|
||||
try:
|
||||
scan_id = self.storage.create_scan(
|
||||
self.scope, [str(target) for target in targets],
|
||||
engine_version=info.engine_version, db_version=info.db_version)
|
||||
self.started_scan.emit(scan_id, [str(target) for target in targets])
|
||||
self.status.emit("Scanning")
|
||||
logger.info("SCAN %s started (%s): %s", scan_id, self.scope,
|
||||
", ".join(str(target) for target in targets))
|
||||
self._process = subprocess.Popen(
|
||||
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
||||
cwd=str(info.path.parent), env={**os.environ, "LC_ALL": "C", "LANG": "C"},
|
||||
**hidden_kwargs(),
|
||||
)
|
||||
assert self._process.stdout is not None
|
||||
while True:
|
||||
if self._cancel:
|
||||
cancelled = True
|
||||
try:
|
||||
self._process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self._process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
break
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
if self._process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
last_lines.append(line.rstrip())
|
||||
if len(last_lines) > 80:
|
||||
last_lines.pop(0)
|
||||
event = parse_clamscan_line(line)
|
||||
if not event:
|
||||
continue
|
||||
kind = event.get("kind")
|
||||
if kind == "detection":
|
||||
detection = Detection(path=str(event["path"]),
|
||||
signature=str(event["signature"]))
|
||||
try:
|
||||
detection.size = Path(detection.path).stat().st_size
|
||||
except OSError:
|
||||
detection.size = 0
|
||||
detection.id = self.storage.add_detection(scan_id, detection)
|
||||
self._remediate(detection, scan_id)
|
||||
detections.append(detection)
|
||||
self.detection_found.emit(detection)
|
||||
self.progress.emit(detection.path, len(detections))
|
||||
elif kind == "summary":
|
||||
key = str(event.get("key") or "")
|
||||
value = event.get("value")
|
||||
summary[key] = value
|
||||
if key in ("scanned_files", "infected_files", "engine_version"):
|
||||
self.status.emit(self._summary_status(summary, len(detections)))
|
||||
elif kind in ("error", "skipped"):
|
||||
if kind == "error":
|
||||
error_count += 1
|
||||
message = str(event.get("message") or event.get("reason") or "")
|
||||
if message:
|
||||
self.log_line.emit(message)
|
||||
elif kind == "progress":
|
||||
now = time.monotonic()
|
||||
if now - self._last_progress >= 0.12:
|
||||
self._last_progress = now
|
||||
self.progress.emit(str(event.get("path") or ""), len(detections))
|
||||
elif kind == "log":
|
||||
message = str(event.get("message") or "")
|
||||
if message and ("error" in message.lower() or "warning" in message.lower()):
|
||||
self.log_line.emit(message)
|
||||
|
||||
returncode = self._process.wait(timeout=20)
|
||||
if cancelled:
|
||||
status = "cancelled"
|
||||
elif returncode in (0, 1):
|
||||
status = "completed"
|
||||
elif returncode == 2 and summary.get("scanned_files"):
|
||||
status = "completed_with_errors"
|
||||
else:
|
||||
status = "failed"
|
||||
scanned_files = _int_value(summary.get("scanned_files"))
|
||||
infected_files = _int_value(summary.get("infected_files"))
|
||||
if infected_files == 0 and detections:
|
||||
infected_files = len(detections)
|
||||
duration = time.monotonic() - started
|
||||
if summary.get("engine_version"):
|
||||
engine = str(summary["engine_version"]).split()[0]
|
||||
self.storage.set_scan_engine(scan_id, engine, info.db_version)
|
||||
self.storage.finish_scan(
|
||||
scan_id, status=status, scanned_files=scanned_files,
|
||||
infected_files=infected_files, errors=error_count,
|
||||
duration_seconds=duration, cancelled=cancelled,
|
||||
)
|
||||
record = self.storage.get_scan(scan_id) or ScanRecord(
|
||||
id=scan_id, started_at="", finished_at=None, scope=self.scope,
|
||||
status=status, paths=[str(target) for target in targets],
|
||||
scanned_files=scanned_files, infected_files=infected_files,
|
||||
errors=error_count, duration_seconds=duration,
|
||||
cancelled=cancelled, detections=detections,
|
||||
)
|
||||
if not record.detections:
|
||||
record.detections = detections
|
||||
logger.info("SCAN %s finished: %s, %s files, %s detections, %s errors",
|
||||
scan_id, status, scanned_files, infected_files, error_count)
|
||||
self.status.emit("Cancelled" if cancelled else "Finished")
|
||||
self.finished_scan.emit(record)
|
||||
except Exception as exc:
|
||||
logger.exception("Scan %s failed", scan_id)
|
||||
try:
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
self._process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if scan_id:
|
||||
try:
|
||||
self.storage.finish_scan(
|
||||
scan_id, status="failed", scanned_files=_int_value(summary.get("scanned_files")),
|
||||
infected_files=max(_int_value(summary.get("infected_files")), len(detections)),
|
||||
errors=error_count + 1, duration_seconds=time.monotonic() - started,
|
||||
cancelled=cancelled,
|
||||
)
|
||||
except StorageError:
|
||||
logger.exception("Could not mark scan %s failed", scan_id)
|
||||
detail = str(exc)
|
||||
if last_lines:
|
||||
detail += "\n" + "\n".join(last_lines[-5:])
|
||||
self.failed.emit(detail)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
def _remediate(self, detection: Detection, scan_id: int) -> None:
|
||||
action = self.settings.auto_action
|
||||
if action == "quarantine":
|
||||
try:
|
||||
item = self.quarantine.quarantine_file(
|
||||
Path(detection.path), detection.signature,
|
||||
scan_id=scan_id, reason="Full scan")
|
||||
detection.action = "quarantined"
|
||||
detection.quarantine_id = item.id
|
||||
except (QuarantineError, OSError) as exc:
|
||||
detection.action = "failed"
|
||||
logger.warning("Quarantine failed for %s: %s", detection.path, exc)
|
||||
self.log_line.emit(f"Quarantine failed: {exc}")
|
||||
elif action == "delete":
|
||||
try:
|
||||
self.quarantine.delete_original(Path(detection.path))
|
||||
detection.action = "deleted"
|
||||
except (QuarantineError, OSError) as exc:
|
||||
detection.action = "failed"
|
||||
logger.warning("Delete failed for %s: %s", detection.path, exc)
|
||||
self.log_line.emit(f"Delete failed: {exc}")
|
||||
else:
|
||||
detection.action = "detected"
|
||||
if detection.id:
|
||||
try:
|
||||
self.storage.update_detection_action(
|
||||
detection.id, detection.action, detection.quarantine_id)
|
||||
except StorageError:
|
||||
logger.exception("Could not record remediation action")
|
||||
|
||||
@staticmethod
|
||||
def _summary_status(summary: dict, detection_count: int) -> str:
|
||||
scanned = _int_value(summary.get("scanned_files"))
|
||||
infected = max(_int_value(summary.get("infected_files")), detection_count)
|
||||
if scanned:
|
||||
return f"Scanned {scanned:,} files · {infected} detection(s)"
|
||||
return "Scanning"
|
||||
|
||||
|
||||
def _int_value(value) -> int:
|
||||
try:
|
||||
return int(str(value).split()[0])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
|
||||
class DefinitionWorker(QThread):
|
||||
log_line = pyqtSignal(str)
|
||||
finished_ok = pyqtSignal(str)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, clamav: ClamAVService, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.clamav = clamav
|
||||
self._cancel = False
|
||||
self._process: subprocess.Popen | None = None
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancel = True
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
try:
|
||||
self._process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def run(self) -> None:
|
||||
info = self.clamav.info(force=True)
|
||||
if not info.available:
|
||||
self.failed.emit(info.error or "Install ClamAV before updating definitions.")
|
||||
return
|
||||
freshclam, error = self.clamav.freshclam(), ""
|
||||
if freshclam is None:
|
||||
from .clamav import find_freshclam
|
||||
|
||||
freshclam, error = find_freshclam(info.path)
|
||||
if freshclam is None:
|
||||
self.failed.emit(error or "freshclam was not found.")
|
||||
return
|
||||
db_dir = paths.clamav_db_dir()
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
args = build_freshclam_args(freshclam, db_dir)
|
||||
last_lines: list[str] = []
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
||||
cwd=str(freshclam.parent), env={**os.environ, "LC_ALL": "C", "LANG": "C"},
|
||||
**hidden_kwargs(),
|
||||
)
|
||||
assert self._process.stdout is not None
|
||||
while True:
|
||||
if self._cancel:
|
||||
self._process.terminate()
|
||||
self.failed.emit("Definition update was cancelled.")
|
||||
return
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
if self._process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
text = line.rstrip()
|
||||
last_lines.append(text)
|
||||
if len(last_lines) > 40:
|
||||
last_lines.pop(0)
|
||||
if text:
|
||||
self.log_line.emit(text)
|
||||
code = self._process.wait(timeout=20)
|
||||
self.clamav.invalidate()
|
||||
if code == 0:
|
||||
info = self.clamav.info(force=True)
|
||||
self.finished_ok.emit(f"Definitions updated. {info.summary}")
|
||||
else:
|
||||
self.failed.emit(
|
||||
"freshclam exited with code "
|
||||
f"{code}." + ("\n" + "\n".join(last_lines[-5:]) if last_lines else ""))
|
||||
except Exception as exc:
|
||||
logger.exception("Definition update failed")
|
||||
self.failed.emit(str(exc))
|
||||
finally:
|
||||
self._process = None
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Local SQLite history for scans, detections, quarantine and real-time events.
|
||||
|
||||
One connection per operation keeps this safe to call from scan threads, the
|
||||
real-time worker and the Qt UI without sharing a sqlite object across
|
||||
threads. WAL mode lets the UI read while a scan is writing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from . import paths
|
||||
from .models import (
|
||||
Detection,
|
||||
QuarantineItem,
|
||||
RealtimeEvent,
|
||||
ScanRecord,
|
||||
now_iso,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StorageError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
scope TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
paths TEXT NOT NULL DEFAULT '[]',
|
||||
scanned_files INTEGER NOT NULL DEFAULT 0,
|
||||
infected_files INTEGER NOT NULL DEFAULT 0,
|
||||
errors INTEGER NOT NULL DEFAULT 0,
|
||||
engine_version TEXT NOT NULL DEFAULT '',
|
||||
db_version TEXT NOT NULL DEFAULT '',
|
||||
duration_seconds REAL NOT NULL DEFAULT 0,
|
||||
cancelled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS detections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
signature TEXT NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT 'detected',
|
||||
quarantine_id TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
detected_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_detections_scan ON detections(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_detections_action ON detections(action);
|
||||
CREATE TABLE IF NOT EXISTS quarantine (
|
||||
id TEXT PRIMARY KEY,
|
||||
original_path TEXT NOT NULL,
|
||||
quarantine_path TEXT NOT NULL,
|
||||
signature TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'quarantined',
|
||||
scan_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_quarantine_status ON quarantine(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_quarantine_sha ON quarantine(sha256);
|
||||
CREATE TABLE IF NOT EXISTS realtime_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
signature TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_created ON realtime_events(created_at);
|
||||
"""
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = Path(path) if path else paths.db_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.initialize()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||
try:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30)
|
||||
except sqlite3.Error as exc:
|
||||
raise StorageError(f"Could not open the BoltClam database: {exc}") from exc
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
yield connection
|
||||
connection.commit()
|
||||
except sqlite3.Error as exc:
|
||||
connection.rollback()
|
||||
raise StorageError(f"BoltClam database error: {exc}") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.executescript(_SCHEMA)
|
||||
|
||||
# -- scans -------------------------------------------------------------
|
||||
|
||||
def create_scan(self, scope: str, paths_list: list[str], *,
|
||||
engine_version: str = "", db_version: str = "") -> int:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO scans (started_at, scope, status, paths, engine_version, db_version) "
|
||||
"VALUES (?, ?, 'running', ?, ?, ?)",
|
||||
(now_iso(), scope, json.dumps(paths_list, ensure_ascii=False),
|
||||
engine_version, db_version),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def finish_scan(self, scan_id: int, *, status: str, scanned_files: int,
|
||||
infected_files: int, errors: int, duration_seconds: float,
|
||||
cancelled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE scans SET finished_at=?, status=?, scanned_files=?, infected_files=?, "
|
||||
"errors=?, duration_seconds=?, cancelled=? WHERE id=?",
|
||||
(now_iso(), status, int(scanned_files), int(infected_files), int(errors),
|
||||
float(duration_seconds), 1 if cancelled else 0, int(scan_id)),
|
||||
)
|
||||
|
||||
def set_scan_engine(self, scan_id: int, engine_version: str, db_version: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE scans SET engine_version=?, db_version=? WHERE id=?",
|
||||
(engine_version, db_version, int(scan_id)),
|
||||
)
|
||||
|
||||
def _scan_from_row(self, row: sqlite3.Row) -> ScanRecord:
|
||||
try:
|
||||
paths_list = json.loads(row["paths"] or "[]")
|
||||
except (TypeError, ValueError):
|
||||
paths_list = []
|
||||
return ScanRecord(
|
||||
id=int(row["id"]),
|
||||
started_at=str(row["started_at"]),
|
||||
finished_at=row["finished_at"],
|
||||
scope=str(row["scope"]),
|
||||
status=str(row["status"]),
|
||||
paths=[str(path) for path in paths_list if isinstance(path, str)],
|
||||
scanned_files=int(row["scanned_files"] or 0),
|
||||
infected_files=int(row["infected_files"] or 0),
|
||||
errors=int(row["errors"] or 0),
|
||||
engine_version=str(row["engine_version"] or ""),
|
||||
db_version=str(row["db_version"] or ""),
|
||||
duration_seconds=float(row["duration_seconds"] or 0),
|
||||
cancelled=bool(row["cancelled"]),
|
||||
)
|
||||
|
||||
def list_scans(self, limit: int = 100) -> list[ScanRecord]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM scans ORDER BY id DESC LIMIT ?", (max(1, int(limit)),)
|
||||
).fetchall()
|
||||
return [self._scan_from_row(row) for row in rows]
|
||||
|
||||
def get_scan(self, scan_id: int) -> ScanRecord | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM scans WHERE id=?", (int(scan_id),)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
record = self._scan_from_row(row)
|
||||
detections = connection.execute(
|
||||
"SELECT * FROM detections WHERE scan_id=? ORDER BY id", (int(scan_id),)
|
||||
).fetchall()
|
||||
record.detections = [
|
||||
Detection(
|
||||
id=int(item["id"]),
|
||||
path=str(item["path"]),
|
||||
signature=str(item["signature"]),
|
||||
action=str(item["action"]),
|
||||
quarantine_id=item["quarantine_id"],
|
||||
size=int(item["size"] or 0),
|
||||
detected_at=str(item["detected_at"]),
|
||||
)
|
||||
for item in detections
|
||||
]
|
||||
return record
|
||||
|
||||
def latest_scan(self) -> ScanRecord | None:
|
||||
scans = self.list_scans(1)
|
||||
return scans[0] if scans else None
|
||||
|
||||
# -- detections --------------------------------------------------------
|
||||
|
||||
def add_detection(self, scan_id: int, detection: Detection) -> int:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO detections (scan_id, path, signature, action, quarantine_id, size, "
|
||||
"detected_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(int(scan_id), detection.path, detection.signature, detection.action,
|
||||
detection.quarantine_id, int(detection.size), detection.detected_at or now_iso()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def update_detection_action(self, detection_id: int, action: str,
|
||||
quarantine_id: str | None = None) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE detections SET action=?, quarantine_id=COALESCE(?, quarantine_id) WHERE id=?",
|
||||
(action, quarantine_id, int(detection_id)),
|
||||
)
|
||||
|
||||
def recent_detections(self, limit: int = 10) -> list[dict]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT d.*, s.started_at AS scan_started_at, s.scope AS scan_scope "
|
||||
"FROM detections d LEFT JOIN scans s ON s.id=d.scan_id "
|
||||
"ORDER BY d.id DESC LIMIT ?", (max(1, int(limit)),)
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
# -- quarantine --------------------------------------------------------
|
||||
|
||||
def add_quarantine(self, item: QuarantineItem) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO quarantine "
|
||||
"(id, original_path, quarantine_path, signature, sha256, size, created_at, "
|
||||
"status, scan_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(item.id, item.original_path, item.quarantine_path, item.signature,
|
||||
item.sha256, int(item.size), item.created_at, item.status, item.scan_id),
|
||||
)
|
||||
|
||||
def _quarantine_from_row(self, row: sqlite3.Row) -> QuarantineItem:
|
||||
return QuarantineItem(
|
||||
id=str(row["id"]),
|
||||
original_path=str(row["original_path"]),
|
||||
quarantine_path=str(row["quarantine_path"]),
|
||||
signature=str(row["signature"]),
|
||||
sha256=str(row["sha256"]),
|
||||
size=int(row["size"] or 0),
|
||||
created_at=str(row["created_at"]),
|
||||
status=str(row["status"]),
|
||||
scan_id=row["scan_id"],
|
||||
)
|
||||
|
||||
def list_quarantine(self, status: str | None = None, limit: int = 500) -> list[QuarantineItem]:
|
||||
query = "SELECT * FROM quarantine"
|
||||
params: list = []
|
||||
if status:
|
||||
query += " WHERE status=?"
|
||||
params.append(status)
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(max(1, int(limit)))
|
||||
with self._connect() as connection:
|
||||
return [self._quarantine_from_row(row)
|
||||
for row in connection.execute(query, params).fetchall()]
|
||||
|
||||
def get_quarantine(self, item_id: str) -> QuarantineItem | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM quarantine WHERE id=?",
|
||||
(str(item_id),)).fetchone()
|
||||
return self._quarantine_from_row(row) if row is not None else None
|
||||
|
||||
def set_quarantine_status(self, item_id: str, status: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("UPDATE quarantine SET status=? WHERE id=?",
|
||||
(status, str(item_id)))
|
||||
|
||||
def quarantine_count(self, status: str = "quarantined") -> int:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT COUNT(*) AS n FROM quarantine WHERE status=?",
|
||||
(status,)).fetchone()
|
||||
return int(row["n"] if row else 0)
|
||||
|
||||
# -- real-time events --------------------------------------------------
|
||||
|
||||
def add_realtime_event(self, event: RealtimeEvent) -> int:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO realtime_events (created_at, path, signature, action, detail) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(event.created_at or now_iso(), event.path, event.signature,
|
||||
event.action, event.detail or ""),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def list_realtime_events(self, limit: int = 100) -> list[RealtimeEvent]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM realtime_events ORDER BY id DESC LIMIT ?",
|
||||
(max(1, int(limit)),)
|
||||
).fetchall()
|
||||
return [
|
||||
RealtimeEvent(
|
||||
id=int(row["id"]), created_at=str(row["created_at"]),
|
||||
path=str(row["path"]), signature=str(row["signature"]),
|
||||
action=str(row["action"]), detail=str(row["detail"] or ""),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# -- dashboard aggregates ---------------------------------------------
|
||||
|
||||
def stats(self, days: int = 30) -> dict:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, days))).isoformat(
|
||||
timespec="seconds").replace("+00:00", "Z")
|
||||
with self._connect() as connection:
|
||||
latest_row = connection.execute(
|
||||
"SELECT * FROM scans ORDER BY id DESC LIMIT 1").fetchone()
|
||||
latest = self._scan_from_row(latest_row) if latest_row else None
|
||||
active = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM detections WHERE action IN ('detected','failed')"
|
||||
).fetchone()
|
||||
quarantined = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM quarantine WHERE status='quarantined'"
|
||||
).fetchone()
|
||||
threats = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM detections WHERE detected_at >= ?", (cutoff,)
|
||||
).fetchone()
|
||||
blocked = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM realtime_events WHERE created_at >= ? "
|
||||
"AND action IN ('quarantined','deleted','blocked')", (cutoff,)
|
||||
).fetchone()
|
||||
total_scans = connection.execute("SELECT COUNT(*) AS n FROM scans").fetchone()
|
||||
return {
|
||||
"latest_scan": latest,
|
||||
"active_threats": int(active["n"] if active else 0),
|
||||
"quarantined": int(quarantined["n"] if quarantined else 0),
|
||||
"threats_30d": int(threats["n"] if threats else 0),
|
||||
"blocked_30d": int(blocked["n"] if blocked else 0),
|
||||
"scans": int(total_scans["n"] if total_scans else 0),
|
||||
}
|
||||
|
||||
def diagnostics_snapshot(self, scan_limit: int = 50, event_limit: int = 100) -> dict:
|
||||
scans = self.list_scans(scan_limit)
|
||||
return {
|
||||
"scans": [
|
||||
{
|
||||
"id": scan.id, "started_at": scan.started_at,
|
||||
"finished_at": scan.finished_at, "scope": scan.scope,
|
||||
"status": scan.status, "scanned_files": scan.scanned_files,
|
||||
"infected_files": scan.infected_files, "errors": scan.errors,
|
||||
"engine_version": scan.engine_version, "db_version": scan.db_version,
|
||||
"duration_seconds": scan.duration_seconds, "cancelled": scan.cancelled,
|
||||
}
|
||||
for scan in scans
|
||||
],
|
||||
"quarantine": [
|
||||
{
|
||||
"id": item.id, "original_path": item.original_path,
|
||||
"signature": item.signature, "sha256": item.sha256,
|
||||
"size": item.size, "created_at": item.created_at,
|
||||
"status": item.status, "scan_id": item.scan_id,
|
||||
}
|
||||
for item in self.list_quarantine(limit=200)
|
||||
],
|
||||
"realtime": [
|
||||
{
|
||||
"created_at": event.created_at, "path": event.path,
|
||||
"signature": event.signature, "action": event.action,
|
||||
"detail": event.detail,
|
||||
}
|
||||
for event in self.list_realtime_events(event_limit)
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Security and network overview for the Homepage.
|
||||
|
||||
Windows-specific data comes from PowerShell (Defender, firewall, adapters)
|
||||
and is cached briefly because those cmdlets are much slower than the rest of
|
||||
the dashboard. Every external call is best-effort: "unknown" is a legitimate
|
||||
state, never a fabricated "protected".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import paths
|
||||
from .clamav import hidden_kwargs
|
||||
from .config import AppSettings
|
||||
from .models import ClamAVInfo
|
||||
from .storage import Storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE: dict[str, Any] = {"at": 0.0, "snapshot": None}
|
||||
_CACHE_SECONDS = 45
|
||||
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_CACHE["at"] = 0.0
|
||||
_CACHE["snapshot"] = None
|
||||
|
||||
|
||||
def _powershell(command: str, timeout: int = 12) -> str:
|
||||
if os.name != "nt":
|
||||
return ""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive",
|
||||
"-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
capture_output=True, text=True, timeout=timeout, errors="replace",
|
||||
**hidden_kwargs(),
|
||||
)
|
||||
return (completed.stdout or "").strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.debug("PowerShell query failed: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_json(text: str) -> Any:
|
||||
if not text:
|
||||
return None
|
||||
starts = [index for index in (text.find("{"), text.find("[")) if index >= 0]
|
||||
if not starts:
|
||||
return None
|
||||
start = min(starts)
|
||||
opener = text[start]
|
||||
closer = "}" if opener == "{" else "]"
|
||||
end = text.rfind(closer)
|
||||
if end <= start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text[start:end + 1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool | None:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
text = str(value).strip().lower()
|
||||
if text in ("true", "1", "yes"):
|
||||
return True
|
||||
if text in ("false", "0", "no"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _defender_firewall() -> dict:
|
||||
command = (
|
||||
"$ErrorActionPreference='SilentlyContinue';"
|
||||
"$mp=Get-MpComputerStatus;"
|
||||
"$profiles=@(Get-NetFirewallProfile);"
|
||||
"$fw=@($profiles | Where-Object {$_.Enabled -eq $true} | ForEach-Object {$_.Name});"
|
||||
"[pscustomobject]@{"
|
||||
"antivirus_enabled=$mp.AntivirusEnabled;"
|
||||
"realtime_enabled=$mp.RealTimeProtectionEnabled;"
|
||||
"signature_age_days=$mp.AntivirusSignatureAge;"
|
||||
"signature_version=$mp.AntivirusSignatureVersion;"
|
||||
"firewall_enabled=($fw.Count -gt 0);"
|
||||
"firewall_profiles=$fw"
|
||||
"} | ConvertTo-Json -Compress"
|
||||
)
|
||||
data = _extract_json(_powershell(command))
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"defender_available": False,
|
||||
"antivirus_enabled": None,
|
||||
"realtime_enabled": None,
|
||||
"signature_age_days": None,
|
||||
"signature_version": "",
|
||||
"firewall_enabled": None,
|
||||
"firewall_profiles": [],
|
||||
}
|
||||
return {
|
||||
"defender_available": True,
|
||||
"antivirus_enabled": _as_bool(data.get("antivirus_enabled")),
|
||||
"realtime_enabled": _as_bool(data.get("realtime_enabled")),
|
||||
"signature_age_days": _as_int(data.get("signature_age_days")),
|
||||
"signature_version": str(data.get("signature_version") or ""),
|
||||
"firewall_enabled": _as_bool(data.get("firewall_enabled")),
|
||||
"firewall_profiles": [str(item) for item in (data.get("firewall_profiles") or [])],
|
||||
}
|
||||
|
||||
|
||||
def _network_details() -> dict:
|
||||
mac = ""
|
||||
try:
|
||||
raw = uuid.getnode()
|
||||
mac = ":".join(f"{(raw >> shift) & 0xff:02x}" for shift in range(40, -1, -8)).upper()
|
||||
except Exception:
|
||||
mac = ""
|
||||
local_ips: list[str] = []
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
_name, _aliases, addresses = socket.gethostbyname_ex(hostname)
|
||||
local_ips.extend(addresses)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
local_ips.append(socket.gethostbyname(socket.gethostname()))
|
||||
except Exception:
|
||||
pass
|
||||
if os.name == "nt":
|
||||
command = (
|
||||
"$ErrorActionPreference='SilentlyContinue';"
|
||||
"$ips=@(Get-NetIPAddress -AddressFamily IPv4 | "
|
||||
"Where-Object {$_.IPAddress -notlike '127.*' -and "
|
||||
"$_.IPAddress -notlike '169.254.*'} | "
|
||||
"Select-Object InterfaceAlias,IPAddress,PrefixLength);"
|
||||
"$route=Get-NetRoute -DestinationPrefix '0.0.0.0/0' | "
|
||||
"Sort-Object RouteMetric | Select-Object -First 1;"
|
||||
"[pscustomobject]@{adapters=$ips;gateway=$route.NextHop;"
|
||||
"interface=$route.InterfaceAlias} | ConvertTo-Json -Depth 3 -Compress"
|
||||
)
|
||||
data = _extract_json(_powershell(command))
|
||||
if isinstance(data, dict):
|
||||
adapters = data.get("adapters")
|
||||
if isinstance(adapters, dict):
|
||||
adapters = [adapters]
|
||||
normalized = []
|
||||
for adapter in adapters or []:
|
||||
if not isinstance(adapter, dict):
|
||||
continue
|
||||
ip = str(adapter.get("IPAddress") or "").strip()
|
||||
if ip:
|
||||
local_ips.append(ip)
|
||||
normalized.append({
|
||||
"name": str(adapter.get("InterfaceAlias") or "Adapter"),
|
||||
"ip": ip,
|
||||
"prefix": _as_int(adapter.get("PrefixLength")),
|
||||
})
|
||||
return {
|
||||
"adapters": normalized,
|
||||
"local_ips": sorted(set(local_ips)),
|
||||
"gateway": str(data.get("gateway") or ""),
|
||||
"gateway_interface": str(data.get("interface") or ""),
|
||||
"mac": mac,
|
||||
}
|
||||
return {
|
||||
"adapters": [{"name": "Local interface", "ip": ip, "prefix": None}
|
||||
for ip in sorted(set(local_ips))],
|
||||
"local_ips": sorted(set(local_ips)),
|
||||
"gateway": "",
|
||||
"gateway_interface": "",
|
||||
"mac": mac,
|
||||
}
|
||||
|
||||
|
||||
def _uptime_seconds() -> int | None:
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
return int(ctypes.windll.kernel32.GetTickCount64() // 1000)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
with open("/proc/uptime", "r", encoding="ascii") as handle:
|
||||
return int(float(handle.read().split()[0]))
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def human_uptime(seconds: int | None) -> str:
|
||||
if seconds is None:
|
||||
return "Unknown"
|
||||
days, remainder = divmod(max(0, int(seconds)), 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes = remainder // 60
|
||||
if days:
|
||||
return f"{days}d {hours}h"
|
||||
if hours:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def _concern(severity: str, title: str, detail: str, action: str) -> dict:
|
||||
return {"severity": severity, "title": title, "detail": detail, "action": action}
|
||||
|
||||
|
||||
def _build_concerns(settings: AppSettings, clamav: ClamAVInfo, defender: dict,
|
||||
stats: dict, is_admin: bool,
|
||||
realtime_running: bool | None) -> list[dict]:
|
||||
concerns: list[dict] = []
|
||||
if not clamav.available:
|
||||
concerns.append(_concern(
|
||||
"critical", "ClamAV is not available",
|
||||
clamav.error or "Install ClamAV and point BoltClam at its folder in Settings.",
|
||||
"settings"))
|
||||
else:
|
||||
if not clamav.db_path:
|
||||
concerns.append(_concern(
|
||||
"critical", "Virus definitions are missing",
|
||||
"Update ClamAV definitions before relying on a scan.", "update_definitions"))
|
||||
elif clamav.db_age_days is not None and clamav.db_age_days > 30:
|
||||
concerns.append(_concern(
|
||||
"critical", f"Definitions are {clamav.db_age_days:.0f} days old",
|
||||
"New malware families will be missed until the database is refreshed.",
|
||||
"update_definitions"))
|
||||
elif clamav.db_age_days is not None and clamav.db_age_days > 7:
|
||||
concerns.append(_concern(
|
||||
"warning", f"Definitions are {clamav.db_age_days:.0f} days old",
|
||||
"Refresh the ClamAV database to cover recent threats.", "update_definitions"))
|
||||
if not settings.realtime_enabled:
|
||||
concerns.append(_concern(
|
||||
"warning", "Real-time protection is off",
|
||||
"New browser downloads can be opened without a scan.", "settings"))
|
||||
elif clamav.available and realtime_running is False:
|
||||
concerns.append(_concern(
|
||||
"warning", "Real-time watcher is not running",
|
||||
"Turn real-time protection off and on again, or restart BoltClam.", "settings"))
|
||||
if defender.get("firewall_enabled") is False:
|
||||
concerns.append(_concern(
|
||||
"warning", "Windows Firewall is disabled",
|
||||
"Enable at least one Windows Firewall profile.", "settings"))
|
||||
if defender.get("realtime_enabled") is False and not (
|
||||
settings.realtime_enabled and clamav.available):
|
||||
concerns.append(_concern(
|
||||
"warning", "No active real-time antivirus",
|
||||
"Windows Defender real-time protection is off and BoltClam's watcher is not active.",
|
||||
"settings"))
|
||||
if int(stats.get("active_threats") or 0) > 0:
|
||||
concerns.append(_concern(
|
||||
"critical", f"{stats['active_threats']} detected file(s) still need action",
|
||||
"Review the latest scan results and quarantine or delete them.", "scan"))
|
||||
latest = stats.get("latest_scan")
|
||||
if latest is None:
|
||||
concerns.append(_concern(
|
||||
"info", "No scan on record",
|
||||
"Run a scan to establish a baseline for this PC.", "scan"))
|
||||
else:
|
||||
try:
|
||||
started = datetime.fromisoformat(str(latest.started_at).replace("Z", "+00:00"))
|
||||
age_days = (datetime.now(timezone.utc) - started).total_seconds() / 86400
|
||||
if age_days > 14:
|
||||
concerns.append(_concern(
|
||||
"info", f"Last scan was {age_days:.0f} days ago",
|
||||
"A weekly scan keeps the history useful.", "scan"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if latest.errors > 0:
|
||||
concerns.append(_concern(
|
||||
"info", f"Last scan reported {latest.errors} access error(s)",
|
||||
"Some files could not be read. Running as administrator improves coverage.",
|
||||
"scan"))
|
||||
if int(stats.get("quarantined") or 0) > 0:
|
||||
concerns.append(_concern(
|
||||
"info", f"{stats['quarantined']} item(s) in quarantine",
|
||||
"Restore anything that was a false positive, or delete it permanently.",
|
||||
"quarantine"))
|
||||
if not is_admin and settings.scan_scope == "homes":
|
||||
concerns.append(_concern(
|
||||
"info", "Limited access to other user profiles",
|
||||
"Run BoltClam as administrator to scan every user's home completely.",
|
||||
"none"))
|
||||
concerns.sort(key=lambda item: _SEVERITY_ORDER.get(item["severity"], 3))
|
||||
return concerns
|
||||
|
||||
|
||||
def _segments(settings: AppSettings, clamav: ClamAVInfo, defender: dict,
|
||||
realtime_running: bool | None) -> list[dict]:
|
||||
engine = ("good" if clamav.available else "bad",
|
||||
"Engine", clamav.engine_version or "Not installed")
|
||||
if not clamav.available:
|
||||
definitions = ("bad", "Definitions", "Missing")
|
||||
elif not clamav.db_path:
|
||||
definitions = ("bad", "Definitions", "Missing")
|
||||
elif clamav.db_age_days is not None and clamav.db_age_days > 30:
|
||||
definitions = ("bad", "Definitions", f"{clamav.db_age_days:.0f} days old")
|
||||
elif clamav.db_age_days is not None and clamav.db_age_days > 7:
|
||||
definitions = ("warn", "Definitions", f"{clamav.db_age_days:.0f} days old")
|
||||
else:
|
||||
definitions = ("good", "Definitions", "Current")
|
||||
if not settings.realtime_enabled:
|
||||
realtime = ("warn", "Real-time", "Off")
|
||||
elif not clamav.available:
|
||||
realtime = ("bad", "Real-time", "No engine")
|
||||
elif realtime_running is False:
|
||||
realtime = ("warn", "Real-time", "Not running")
|
||||
else:
|
||||
realtime = ("good", "Real-time", "Watching downloads")
|
||||
firewall = defender.get("firewall_enabled")
|
||||
defender_rt = defender.get("realtime_enabled")
|
||||
if firewall is False:
|
||||
system = ("warn", "Firewall", "Disabled")
|
||||
elif defender_rt is False and not (settings.realtime_enabled and clamav.available):
|
||||
system = ("warn", "Defender", "Real-time off")
|
||||
elif firewall is None and defender_rt is None:
|
||||
system = ("unknown", "System", "Not reported")
|
||||
else:
|
||||
system = ("good", "System", "Firewall + Defender")
|
||||
return [
|
||||
{"key": "engine", "state": engine[0], "label": engine[1], "detail": engine[2]},
|
||||
{"key": "definitions", "state": definitions[0],
|
||||
"label": definitions[1], "detail": definitions[2]},
|
||||
{"key": "realtime", "state": realtime[0],
|
||||
"label": realtime[1], "detail": realtime[2]},
|
||||
{"key": "system", "state": system[0], "label": system[1], "detail": system[2]},
|
||||
]
|
||||
|
||||
|
||||
def collect(settings: AppSettings, clamav: ClamAVInfo, storage: Storage, *,
|
||||
realtime_running: bool | None = None,
|
||||
force: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if not force and _CACHE["snapshot"] is not None and now - _CACHE["at"] < _CACHE_SECONDS:
|
||||
cached = dict(_CACHE["snapshot"])
|
||||
# These two are live values the cached network/Defender probes do not
|
||||
# need to re-query, but the dashboard should still update.
|
||||
cached["realtime"]["enabled"] = settings.realtime_enabled
|
||||
cached["realtime"]["running"] = realtime_running
|
||||
return cached
|
||||
|
||||
stats = storage.stats()
|
||||
stats_snapshot = dict(stats)
|
||||
latest = stats_snapshot.get("latest_scan")
|
||||
if latest is not None:
|
||||
stats_snapshot["latest_scan"] = {
|
||||
"id": latest.id,
|
||||
"started_at": latest.started_at,
|
||||
"finished_at": latest.finished_at,
|
||||
"scope": latest.scope,
|
||||
"status": latest.status,
|
||||
"scanned_files": latest.scanned_files,
|
||||
"infected_files": latest.infected_files,
|
||||
"errors": latest.errors,
|
||||
"duration_seconds": latest.duration_seconds,
|
||||
"cancelled": latest.cancelled,
|
||||
}
|
||||
defender = _defender_firewall()
|
||||
network = _network_details()
|
||||
uptime = _uptime_seconds()
|
||||
is_admin = paths.is_admin()
|
||||
if os.name == "nt":
|
||||
os_name = paths.os_label()
|
||||
else:
|
||||
os_name = f"{platform.system()} {platform.release()}".strip() or platform.platform()
|
||||
concerns = _build_concerns(settings, clamav, defender, stats, is_admin,
|
||||
realtime_running)
|
||||
segments = _segments(settings, clamav, defender, realtime_running)
|
||||
if not clamav.available:
|
||||
overall = "setup"
|
||||
headline = "ClamAV needs to be set up"
|
||||
summary = "Point BoltClam at ClamAV to start scanning and protecting downloads."
|
||||
elif int(stats.get("active_threats") or 0) > 0:
|
||||
overall = "at_risk"
|
||||
headline = "Detected files need your attention"
|
||||
summary = f"{stats['active_threats']} file(s) are waiting for quarantine or deletion."
|
||||
elif any(item["severity"] == "critical" for item in concerns):
|
||||
overall = "at_risk"
|
||||
headline = "Protection needs attention"
|
||||
summary = concerns[0]["detail"] if concerns else "Review the concerns below."
|
||||
elif any(item["severity"] == "warning" for item in concerns):
|
||||
overall = "attention"
|
||||
headline = "A few things need attention"
|
||||
summary = concerns[0]["detail"] if concerns else "Review the concerns below."
|
||||
else:
|
||||
overall = "protected"
|
||||
headline = "You're protected"
|
||||
summary = "ClamAV definitions are current and BoltClam is watching new downloads."
|
||||
|
||||
snapshot = {
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
"overall": overall,
|
||||
"headline": headline,
|
||||
"summary": summary,
|
||||
"hostname": paths.hostname(),
|
||||
"os": os_name,
|
||||
"uptime": human_uptime(uptime),
|
||||
"uptime_seconds": uptime,
|
||||
"is_admin": is_admin,
|
||||
"network": network,
|
||||
"defender": defender,
|
||||
"firewall": {
|
||||
"enabled": defender.get("firewall_enabled"),
|
||||
"profiles": defender.get("firewall_profiles") or [],
|
||||
},
|
||||
"clamav": {
|
||||
"available": clamav.available,
|
||||
"engine": clamav.engine_version,
|
||||
"db_version": clamav.db_version,
|
||||
"db_date": clamav.db_date,
|
||||
"db_age_days": clamav.db_age_days,
|
||||
"db_path": str(clamav.db_path) if clamav.db_path else "",
|
||||
"path": str(clamav.path) if clamav.path else "",
|
||||
"summary": clamav.summary,
|
||||
"error": clamav.error,
|
||||
},
|
||||
"realtime": {
|
||||
"enabled": settings.realtime_enabled,
|
||||
"running": realtime_running,
|
||||
},
|
||||
"concerns": concerns,
|
||||
"segments": segments,
|
||||
"stats": stats_snapshot,
|
||||
}
|
||||
_CACHE["snapshot"] = snapshot
|
||||
_CACHE["at"] = now
|
||||
return snapshot
|
||||
@@ -0,0 +1,585 @@
|
||||
"""About & updates: account, subscription, ClamAV details and release feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker, QSize, Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QDesktopServices, QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __build_date__, __version__, paths
|
||||
from ..context import AppContext
|
||||
from ..models import UpdateAsset, UpdateFeed, UpdateRelease
|
||||
from ..updates import (
|
||||
DownloadedUpdate,
|
||||
UpdateError,
|
||||
UpdateService,
|
||||
choose_asset,
|
||||
is_newer,
|
||||
)
|
||||
from . import brand, components, theme
|
||||
from .threads import stop_thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateCheckWorker(QThread):
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, context: AppContext, channel: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self.channel = channel
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
api = self.context.api or self.context.rebuild_api()
|
||||
feed = UpdateService(api, self.channel).check(force=True)
|
||||
self.succeeded.emit(feed)
|
||||
except Exception as exc:
|
||||
logger.warning("Update check failed: %s", exc)
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class UpdateDownloadWorker(QThread):
|
||||
progress = pyqtSignal(int, int)
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, context: AppContext, channel: str, release: UpdateRelease,
|
||||
asset: UpdateAsset, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self.channel = channel
|
||||
self.release = release
|
||||
self.asset = asset
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
api = self.context.api or self.context.rebuild_api()
|
||||
service = UpdateService(api, self.channel)
|
||||
result = service.download(
|
||||
self.release, self.asset,
|
||||
progress=lambda done, total: self.progress.emit(done, total))
|
||||
self.succeeded.emit(result)
|
||||
except Exception as exc:
|
||||
logger.warning("Update download failed: %s", exc)
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class ReleaseItem(QWidget):
|
||||
def __init__(self, release: UpdateRelease, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumHeight(66)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 7, 10, 7)
|
||||
layout.setSpacing(2)
|
||||
top = QHBoxLayout()
|
||||
tag = QLabel(release.tag or release.name or "Untagged")
|
||||
tag.setFont(theme.body_font(9.3, QFont.Weight.DemiBold))
|
||||
top.addWidget(tag, 1)
|
||||
if release.prerelease:
|
||||
top.addWidget(components.Pill("Beta", "warning"), 0)
|
||||
layout.addLayout(top)
|
||||
when = QLabel(theme.format_datetime(release.published_at))
|
||||
when.setObjectName("Muted")
|
||||
when.setFont(theme.body_font(8.4))
|
||||
layout.addWidget(when)
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(260, 78)
|
||||
|
||||
|
||||
class AboutPage(QWidget):
|
||||
toast_requested = pyqtSignal(str, str, str)
|
||||
update_found = pyqtSignal(object)
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self._check_worker: UpdateCheckWorker | None = None
|
||||
self._download_worker: UpdateDownloadWorker | None = None
|
||||
self._feed: UpdateFeed | None = None
|
||||
self._selected: UpdateRelease | None = None
|
||||
self._downloaded: DownloadedUpdate | None = None
|
||||
self._build()
|
||||
self.refresh()
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
outer.addWidget(scroll)
|
||||
content = QWidget()
|
||||
content.setObjectName("Content")
|
||||
scroll.setWidget(content)
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(30, 24, 30, 30)
|
||||
layout.setSpacing(16)
|
||||
|
||||
header = components.PageHeader(
|
||||
"About & updates", "BoltClam's build, account, subscription, and release feed.")
|
||||
self.check_button = QPushButton("Check for updates")
|
||||
self.check_button.setObjectName("Secondary")
|
||||
self.check_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.check_button.clicked.connect(lambda: self.check_updates(manual=True))
|
||||
header.add_action(self.check_button)
|
||||
layout.addWidget(header)
|
||||
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(16)
|
||||
top.addWidget(self._build_identity(), 1)
|
||||
top.addWidget(self._build_update_card(), 1)
|
||||
layout.addLayout(top)
|
||||
|
||||
releases = QHBoxLayout()
|
||||
releases.setSpacing(16)
|
||||
releases.addWidget(self._build_notes_card(), 2)
|
||||
releases.addWidget(self._build_history_card(), 1)
|
||||
layout.addLayout(releases, 1)
|
||||
|
||||
links = components.Card(margins=(16, 12, 16, 12))
|
||||
link_row = QHBoxLayout()
|
||||
link_row.setSpacing(18)
|
||||
for label, url in (
|
||||
("BoltClam repository", "https://git.themajesticnetwork.com/TheMajesticNetwork/BoltClam"),
|
||||
("Bolt account", "https://bolt.themajesticnetwork.com/account"),
|
||||
("ClamAV downloads", "https://www.clamav.net/downloads"),
|
||||
):
|
||||
link = QLabel(f'<a href="{url}" style="color:{theme.TEAL}; text-decoration:none;">{label}</a>')
|
||||
link.setOpenExternalLinks(True)
|
||||
link.setFont(theme.body_font(9.2, QFont.Weight.DemiBold))
|
||||
link_row.addWidget(link)
|
||||
link_row.addStretch(1)
|
||||
links.body.addLayout(link_row)
|
||||
note = QLabel(
|
||||
"BoltClam is a desktop interface for ClamAV. ClamAV is licensed under GPLv2 and "
|
||||
"installed separately; BoltClam does not bundle its binaries. Real-time protection "
|
||||
"locks a download while ClamAV scans it in user mode — it is a strong speed bump, "
|
||||
"not a kernel-level filter driver.")
|
||||
note.setObjectName("Dim")
|
||||
note.setWordWrap(True)
|
||||
links.body.addWidget(note)
|
||||
layout.addWidget(links)
|
||||
layout.addStretch(1)
|
||||
|
||||
def _build_identity(self) -> QWidget:
|
||||
card = components.Card()
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(13)
|
||||
mark = QLabel()
|
||||
mark.setPixmap(brand.pixmap(58))
|
||||
mark.setFixedSize(58, 58)
|
||||
top.addWidget(mark, 0)
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(2)
|
||||
name = QLabel("BoltClam")
|
||||
name.setFont(theme.display_font(21, QFont.Weight.Bold))
|
||||
text.addWidget(name)
|
||||
version = QLabel(theme.version_line())
|
||||
version.setObjectName("Muted")
|
||||
text.addWidget(version)
|
||||
top.addLayout(text, 1)
|
||||
self.licensed_pill = components.Pill("Free", "muted")
|
||||
top.addWidget(self.licensed_pill, 0, Qt.AlignmentFlag.AlignTop)
|
||||
card.body.addLayout(top)
|
||||
self.rows = {}
|
||||
for key, label, mono in (
|
||||
("account", "Signed in as", False),
|
||||
("email", "Email", False),
|
||||
("plan", "BoltClam plan", False),
|
||||
("subscription", "Subscription", False),
|
||||
("clamav", "ClamAV", False),
|
||||
("definitions", "Definitions", False),
|
||||
("build", "Build", False)):
|
||||
row = components.KeyValueRow(label, mono=mono)
|
||||
self.rows[key] = row
|
||||
card.body.addWidget(row)
|
||||
return card
|
||||
|
||||
def _build_update_card(self) -> QWidget:
|
||||
card = components.Card()
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("Updates")
|
||||
title.setObjectName("CardTitle")
|
||||
header.addWidget(title, 1)
|
||||
self.update_pill = components.Pill("Not checked", "muted")
|
||||
header.addWidget(self.update_pill, 0)
|
||||
card.body.addLayout(header)
|
||||
self.update_headline = QLabel("Checking the release feed…")
|
||||
self.update_headline.setFont(theme.display_font(14.5, QFont.Weight.DemiBold))
|
||||
self.update_headline.setWordWrap(True)
|
||||
card.body.addWidget(self.update_headline)
|
||||
self.update_detail = QLabel("")
|
||||
self.update_detail.setObjectName("Muted")
|
||||
self.update_detail.setWordWrap(True)
|
||||
card.body.addWidget(self.update_detail)
|
||||
self.download_progress = QProgressBar()
|
||||
self.download_progress.setRange(0, 100)
|
||||
self.download_progress.setFixedHeight(6)
|
||||
self.download_progress.hide()
|
||||
card.body.addWidget(self.download_progress)
|
||||
self.download_status = QLabel("")
|
||||
self.download_status.setObjectName("Dim")
|
||||
self.download_status.setWordWrap(True)
|
||||
card.body.addWidget(self.download_status)
|
||||
buttons = QHBoxLayout()
|
||||
buttons.setSpacing(8)
|
||||
self.download_button = QPushButton("Download update")
|
||||
self.download_button.setObjectName("Primary")
|
||||
self.download_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.download_button.clicked.connect(self.download_selected)
|
||||
self.download_button.setEnabled(False)
|
||||
buttons.addWidget(self.download_button)
|
||||
self.install_button = QPushButton("Run installer")
|
||||
self.install_button.setObjectName("Secondary")
|
||||
self.install_button.clicked.connect(self.open_download)
|
||||
self.install_button.hide()
|
||||
buttons.addWidget(self.install_button)
|
||||
buttons.addStretch(1)
|
||||
card.body.addLayout(buttons)
|
||||
card.body.addStretch(1)
|
||||
channel = QLabel("Release channel is controlled in Settings.")
|
||||
channel.setObjectName("Dim")
|
||||
card.body.addWidget(channel)
|
||||
return card
|
||||
|
||||
def _build_notes_card(self) -> QWidget:
|
||||
card = components.Card()
|
||||
header = QHBoxLayout()
|
||||
self.notes_title = QLabel("Release notes")
|
||||
self.notes_title.setObjectName("CardTitle")
|
||||
header.addWidget(self.notes_title, 1)
|
||||
self.notes_tag = components.Pill("—", "muted")
|
||||
header.addWidget(self.notes_tag, 0)
|
||||
card.body.addLayout(header)
|
||||
self.notes_meta = QLabel("")
|
||||
self.notes_meta.setObjectName("Muted")
|
||||
card.body.addWidget(self.notes_meta)
|
||||
self.notes_view = components.MarkdownView()
|
||||
self.notes_view.setMinimumHeight(210)
|
||||
card.body.addWidget(self.notes_view, 1)
|
||||
self.notes_empty = QLabel(
|
||||
"Check for updates to load release notes from the BoltClam repository.")
|
||||
self.notes_empty.setObjectName("Muted")
|
||||
self.notes_empty.setWordWrap(True)
|
||||
card.body.addWidget(self.notes_empty)
|
||||
return card
|
||||
|
||||
def _build_history_card(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"Release history", "Newest first from the Gitea release feed")[0])
|
||||
self.history = QListWidget()
|
||||
self.history.setSpacing(2)
|
||||
self.history.currentItemChanged.connect(self._release_selected)
|
||||
card.body.addWidget(self.history, 1)
|
||||
return card
|
||||
|
||||
# -- data --------------------------------------------------------------
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._refresh_identity()
|
||||
self._refresh_update_state()
|
||||
if self._feed is None and self._check_worker is None:
|
||||
last = self.context.settings.last_update_check
|
||||
if last <= 0 or time.time() - last > 6 * 3600:
|
||||
self.check_updates(manual=False)
|
||||
|
||||
def refresh_account(self) -> None:
|
||||
self._refresh_identity()
|
||||
|
||||
def reload_settings(self) -> None:
|
||||
self._refresh_identity()
|
||||
if self._feed is not None and self._feed.channel != self.context.settings.update_channel:
|
||||
self._feed = None
|
||||
self.history.clear()
|
||||
self.notes_view.clear()
|
||||
self.notes_empty.show()
|
||||
self._refresh_update_state()
|
||||
if self._check_worker is None:
|
||||
self.check_updates(manual=False)
|
||||
else:
|
||||
self._refresh_update_state()
|
||||
|
||||
def _refresh_identity(self) -> None:
|
||||
self.rows["account"].set_value(self.context.user_name)
|
||||
self.rows["email"].set_value(self.context.user_email or "—")
|
||||
self.rows["plan"].set_value(self.context.plan_label)
|
||||
subscription = self.context.subscription
|
||||
status = str(subscription.get("status") or "none")
|
||||
if status in ("active", "trialing"):
|
||||
end = theme.format_datetime(subscription.get("current_period_end"))
|
||||
self.rows["subscription"].set_value(
|
||||
f"{subscription.get('label', 'Bolt')} · {status}"
|
||||
+ (f" · renews {end}" if end != "—" else ""))
|
||||
else:
|
||||
self.rows["subscription"].set_value("No active Bolt subscription")
|
||||
info = self.context.clamav.info()
|
||||
self.rows["clamav"].set_value(info.summary if info.available else "Not configured")
|
||||
if info.available and info.db_date:
|
||||
self.rows["definitions"].set_value(
|
||||
f"{info.db_version or 'database'} · {info.db_date}")
|
||||
else:
|
||||
self.rows["definitions"].set_value("Missing")
|
||||
self.rows["build"].set_value(f"{__version__} · {__build_date__}")
|
||||
self.licensed_pill.setText("Licensed" if self.context.licensed else "Free")
|
||||
self.licensed_pill.set_severity("good" if self.context.licensed else "muted")
|
||||
if self._feed is None:
|
||||
channel = "Beta" if self.context.settings.update_channel == "beta" else "Release"
|
||||
self.update_detail.setText(
|
||||
f"Channel: {channel}. Current version: {__version__}.")
|
||||
|
||||
def _refresh_update_state(self) -> None:
|
||||
if self._feed is None:
|
||||
return
|
||||
latest = self._feed.latest
|
||||
if latest is None:
|
||||
self.update_pill.setText("No releases")
|
||||
self.update_pill.set_severity("muted")
|
||||
self.update_headline.setText("No releases have been published yet")
|
||||
self.update_detail.setText(
|
||||
f"Channel: {self._feed.channel}. A release will appear here when it is tagged.")
|
||||
self.download_button.setEnabled(False)
|
||||
self.update_found.emit(None)
|
||||
return
|
||||
newer = is_newer(latest.tag, __version__)
|
||||
self.update_pill.setText("Update available" if newer else "Up to date")
|
||||
self.update_pill.set_severity("warning" if newer else "good")
|
||||
self.update_headline.setText(
|
||||
f"{latest.name or latest.tag} is ready" if newer else
|
||||
f"You're on the latest {('beta' if latest.prerelease else 'release')}")
|
||||
asset = choose_asset(latest)
|
||||
asset_text = f" · {asset.name}" if asset else " · no Windows asset attached"
|
||||
self.update_detail.setText(
|
||||
f"{latest.tag} · published {theme.format_datetime(latest.published_at)}"
|
||||
f"{asset_text}")
|
||||
self.download_button.setEnabled(bool(asset))
|
||||
self._select_release(latest)
|
||||
self.update_found.emit(latest if newer else None)
|
||||
|
||||
def _select_release(self, release: UpdateRelease) -> None:
|
||||
self._selected = release
|
||||
self._downloaded = None
|
||||
self.install_button.hide()
|
||||
self.download_button.setText("Download update")
|
||||
self.notes_title.setText(release.name or release.tag or "Release notes")
|
||||
self.notes_tag.setText(release.tag or "—")
|
||||
self.notes_tag.set_severity("warning" if release.prerelease else "good")
|
||||
self.notes_meta.setText(
|
||||
f"Published {theme.format_datetime(release.published_at)}"
|
||||
+ (" · beta" if release.prerelease else ""))
|
||||
notes = release.notes.strip()
|
||||
self.notes_view.set_markdown(notes or "This release has no notes.")
|
||||
self.notes_view.show()
|
||||
self.notes_empty.hide()
|
||||
asset = choose_asset(release)
|
||||
self.download_button.setEnabled(bool(asset))
|
||||
if asset:
|
||||
self.download_status.setText(
|
||||
f"Asset: {asset.name} · {theme.format_size(asset.size)}")
|
||||
else:
|
||||
self.download_status.setText("No Windows .exe or .zip asset is attached to this release.")
|
||||
self._sync_history_selection(release)
|
||||
|
||||
def _sync_history_selection(self, release: UpdateRelease) -> None:
|
||||
if not hasattr(self, "history"):
|
||||
return
|
||||
for row in range(self.history.count()):
|
||||
item = self.history.item(row)
|
||||
if item is not None and str(item.data(Qt.ItemDataRole.UserRole) or "") == release.id:
|
||||
if self.history.currentRow() != row:
|
||||
blocker = QSignalBlocker(self.history)
|
||||
self.history.setCurrentRow(row)
|
||||
del blocker
|
||||
return
|
||||
|
||||
def _populate_history(self, feed: UpdateFeed) -> None:
|
||||
blocker = QSignalBlocker(self.history)
|
||||
self.history.clear()
|
||||
for release in feed.releases:
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.ItemDataRole.UserRole, release.id)
|
||||
widget = ReleaseItem(release)
|
||||
item.setSizeHint(QSize(260, 82))
|
||||
self.history.addItem(item)
|
||||
self.history.setItemWidget(item, widget)
|
||||
del blocker
|
||||
if feed.latest is not None:
|
||||
self._sync_history_selection(feed.latest)
|
||||
|
||||
def _release_selected(self, current: QListWidgetItem | None, previous=None) -> None:
|
||||
if current is None or self._feed is None:
|
||||
return
|
||||
release_id = str(current.data(Qt.ItemDataRole.UserRole) or "")
|
||||
for release in self._feed.releases:
|
||||
if release.id == release_id:
|
||||
self._select_release(release)
|
||||
return
|
||||
|
||||
# -- update check ------------------------------------------------------
|
||||
|
||||
def check_updates(self, manual: bool = True) -> None:
|
||||
if self._check_worker is not None and self._check_worker.isRunning():
|
||||
return
|
||||
if self.context.api is None:
|
||||
try:
|
||||
self.context.rebuild_api()
|
||||
except Exception as exc:
|
||||
self.update_pill.setText("Unavailable")
|
||||
self.update_pill.set_severity("muted")
|
||||
self.update_headline.setText("Cannot reach the Bolt update service")
|
||||
self.update_detail.setText(str(exc))
|
||||
return
|
||||
self.check_button.setEnabled(False)
|
||||
self.check_button.setText("Checking…")
|
||||
self.update_pill.setText("Checking")
|
||||
self.update_pill.set_severity("info")
|
||||
worker = UpdateCheckWorker(
|
||||
self.context, self.context.settings.update_channel, self)
|
||||
worker.succeeded.connect(self._check_succeeded)
|
||||
worker.failed.connect(lambda message: self._check_failed(message, manual))
|
||||
worker.finished.connect(self._check_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._check_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _check_succeeded(self, feed: UpdateFeed) -> None:
|
||||
self._feed = feed
|
||||
self._refresh_update_state()
|
||||
self._populate_history(feed)
|
||||
try:
|
||||
self.context.settings = self.context.settings_store.update(
|
||||
last_update_check=time.time())
|
||||
except Exception:
|
||||
logger.exception("Could not save the update-check timestamp")
|
||||
if feed.latest is None:
|
||||
self.update_detail.setText(f"Channel: {feed.channel}. No releases yet.")
|
||||
|
||||
def _check_failed(self, message: str, manual: bool) -> None:
|
||||
self.update_pill.setText("Check failed")
|
||||
self.update_pill.set_severity("critical" if manual else "muted")
|
||||
self.update_headline.setText("Could not check for updates")
|
||||
self.update_detail.setText(message)
|
||||
self.download_button.setEnabled(False)
|
||||
if manual:
|
||||
self.toast_requested.emit("Update check failed", message, "critical")
|
||||
|
||||
def _check_finished(self) -> None:
|
||||
self._check_worker = None
|
||||
self.check_button.setEnabled(True)
|
||||
self.check_button.setText("Check for updates")
|
||||
|
||||
# -- download ----------------------------------------------------------
|
||||
|
||||
def download_selected(self) -> None:
|
||||
release = self._selected or (self._feed.latest if self._feed else None)
|
||||
if release is None:
|
||||
return
|
||||
asset = choose_asset(release)
|
||||
if asset is None:
|
||||
self.toast_requested.emit(
|
||||
"No Windows asset", "That release does not include a .exe or .zip asset.", "warning")
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self, "Download update?",
|
||||
f"Download {asset.name} ({theme.format_size(asset.size)}) for {release.tag}?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.download_button.setEnabled(False)
|
||||
self.check_button.setEnabled(False)
|
||||
self.download_progress.setValue(0)
|
||||
self.download_progress.show()
|
||||
self.download_status.setText("Starting download…")
|
||||
worker = UpdateDownloadWorker(
|
||||
self.context, self.context.settings.update_channel, release, asset, self)
|
||||
worker.progress.connect(self._download_progress)
|
||||
worker.succeeded.connect(self._download_succeeded)
|
||||
worker.failed.connect(self._download_failed)
|
||||
worker.finished.connect(self._download_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._download_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _download_progress(self, done: int, total: int) -> None:
|
||||
if total > 0:
|
||||
self.download_progress.setRange(0, 100)
|
||||
self.download_progress.setValue(int(done * 100 / total))
|
||||
self.download_status.setText(
|
||||
f"Downloading {theme.format_size(done)} of {theme.format_size(total)}…")
|
||||
else:
|
||||
self.download_progress.setRange(0, 0)
|
||||
self.download_status.setText(f"Downloading {theme.format_size(done)}…")
|
||||
|
||||
def _download_succeeded(self, result: DownloadedUpdate) -> None:
|
||||
self._downloaded = result
|
||||
self.download_progress.setRange(0, 100)
|
||||
self.download_progress.setValue(100)
|
||||
verified = "checksum verified" if result.verified else "no checksum asset published"
|
||||
self.download_status.setText(
|
||||
f"Saved to {result.path} · SHA-256 {result.sha256[:16]}… · {verified}")
|
||||
self.toast_requested.emit(
|
||||
"Update downloaded", f"{result.path.name} is ready to install.", "good")
|
||||
self.download_button.setText("Download again")
|
||||
self.download_button.setEnabled(True)
|
||||
self.install_button.setText(
|
||||
"Run installer" if result.path.suffix.lower() in (".exe", ".msi") else "Show download")
|
||||
self.install_button.show()
|
||||
|
||||
def _download_failed(self, message: str) -> None:
|
||||
self.download_progress.hide()
|
||||
self.download_status.setText(message)
|
||||
self.download_button.setEnabled(True)
|
||||
self.toast_requested.emit("Update download failed", message, "critical")
|
||||
|
||||
def _download_finished(self) -> None:
|
||||
self._download_worker = None
|
||||
self.check_button.setEnabled(True)
|
||||
if not self._downloaded:
|
||||
self.download_progress.hide()
|
||||
|
||||
def open_download(self) -> None:
|
||||
if self._downloaded is None:
|
||||
return
|
||||
path = self._downloaded.path
|
||||
if path.suffix.lower() in (".exe", ".msi"):
|
||||
answer = QMessageBox.question(
|
||||
self, "Run the installer?",
|
||||
f"Run {path.name}?\n\nOnly continue if the release came from the "
|
||||
"BoltClam repository and the checksum was verified.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
target = path if path.suffix.lower() in (".exe", ".msi") else path.parent
|
||||
if not paths.open_path(target):
|
||||
self.toast_requested.emit(
|
||||
"Could not open the download", str(target), "warning")
|
||||
|
||||
def stop(self) -> None:
|
||||
for worker in (self._check_worker, self._download_worker):
|
||||
stop_thread(worker, wait_ms=3500)
|
||||
self._check_worker = None
|
||||
self._download_worker = None
|
||||
@@ -0,0 +1,110 @@
|
||||
"""The BoltClam mark: a scalloped shell with a bolt at its heart."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF, Qt
|
||||
from PyQt6.QtGui import (
|
||||
QBrush,
|
||||
QColor,
|
||||
QIcon,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QPolygonF,
|
||||
)
|
||||
|
||||
|
||||
def paint_mark(painter: QPainter, rect: QRectF, *, rounded: bool = True) -> None:
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
size = min(rect.width(), rect.height())
|
||||
square = QRectF(rect.center().x() - size / 2, rect.center().y() - size / 2, size, size)
|
||||
|
||||
if rounded:
|
||||
background = QLinearGradient(square.topLeft(), square.bottomRight())
|
||||
background.setColorAt(0.0, QColor("#123542"))
|
||||
background.setColorAt(0.55, QColor("#0B202B"))
|
||||
background.setColorAt(1.0, QColor("#08151D"))
|
||||
painter.setBrush(QBrush(background))
|
||||
painter.setPen(QPen(QColor(89, 216, 201, 70), max(1.0, size * 0.012)))
|
||||
painter.drawRoundedRect(square.adjusted(1, 1, -1, -1), size * 0.24, size * 0.24)
|
||||
|
||||
# Scallop shell: ribs fan upward from the hinge near the bottom.
|
||||
cx = square.center().x()
|
||||
hinge = QPointF(cx, square.top() + size * 0.80)
|
||||
spread = size * 0.36
|
||||
top = square.top() + size * 0.25
|
||||
ribs = 7
|
||||
for index in range(ribs):
|
||||
fraction = index / (ribs - 1)
|
||||
tip_x = cx - spread + fraction * spread * 2
|
||||
tip = QPointF(tip_x, top + abs(fraction - 0.5) * size * 0.11)
|
||||
control_a = QPointF(hinge.x() + (tip_x - cx) * 0.10, hinge.y() - size * 0.18)
|
||||
control_b = QPointF(tip_x + (cx - tip_x) * 0.25, top + size * 0.12)
|
||||
path = QPainterPath(hinge)
|
||||
path.cubicTo(control_a, control_b, tip)
|
||||
alpha = 95 + int(70 * (1 - abs(fraction - 0.5) * 1.4))
|
||||
painter.setPen(QPen(QColor(122, 244, 255, max(40, alpha)),
|
||||
max(1.0, size * 0.014 * (1.25 - abs(fraction - 0.5))),
|
||||
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawPath(path)
|
||||
|
||||
# Hinge band.
|
||||
painter.setPen(QPen(QColor(89, 216, 201, 140), max(1.0, size * 0.018),
|
||||
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||
painter.drawArc(QRectF(cx - spread * 0.7, hinge.y() - size * 0.04,
|
||||
spread * 1.4, size * 0.12), 200 * 16, 140 * 16)
|
||||
|
||||
# Bolt at the heart of the shell.
|
||||
bolt = QPolygonF([
|
||||
QPointF(cx + size * 0.045, square.top() + size * 0.31),
|
||||
QPointF(cx - size * 0.115, square.top() + size * 0.535),
|
||||
QPointF(cx - size * 0.010, square.top() + size * 0.535),
|
||||
QPointF(cx - size * 0.060, square.top() + size * 0.705),
|
||||
QPointF(cx + size * 0.120, square.top() + size * 0.470),
|
||||
QPointF(cx + size * 0.012, square.top() + size * 0.470),
|
||||
])
|
||||
bolt_gradient = QLinearGradient(QPointF(cx, square.top() + size * 0.30),
|
||||
QPointF(cx, square.top() + size * 0.72))
|
||||
bolt_gradient.setColorAt(0.0, QColor("#FFE3A6"))
|
||||
bolt_gradient.setColorAt(1.0, QColor("#F2B84B"))
|
||||
painter.setBrush(QBrush(bolt_gradient))
|
||||
painter.setPen(QPen(QColor(255, 227, 166, 180), max(0.8, size * 0.008)))
|
||||
painter.drawPolygon(bolt)
|
||||
painter.restore()
|
||||
|
||||
|
||||
def pixmap(size: int = 256) -> QPixmap:
|
||||
image = QPixmap(size, size)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(image)
|
||||
paint_mark(painter, QRectF(0, 0, size, size))
|
||||
painter.end()
|
||||
return image
|
||||
|
||||
|
||||
def app_icon() -> QIcon:
|
||||
icon = QIcon()
|
||||
for size in (16, 24, 32, 48, 64, 128, 256):
|
||||
icon.addPixmap(pixmap(size))
|
||||
return icon
|
||||
|
||||
|
||||
def bolt_path(rect: QRectF) -> QPainterPath:
|
||||
cx = rect.center().x()
|
||||
top = rect.top()
|
||||
size = min(rect.width(), rect.height())
|
||||
path = QPainterPath()
|
||||
path.moveTo(cx + size * 0.07, top + size * 0.05)
|
||||
path.lineTo(cx - size * 0.23, top + size * 0.53)
|
||||
path.lineTo(cx - size * 0.01, top + size * 0.53)
|
||||
path.lineTo(cx - size * 0.11, top + size * 0.95)
|
||||
path.lineTo(cx + size * 0.25, top + size * 0.42)
|
||||
path.lineTo(cx + size * 0.02, top + size * 0.42)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
@@ -0,0 +1,790 @@
|
||||
"""Reusable painted and composed widgets for the BoltClam interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Iterable
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import (
|
||||
QBrush,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QPolygonF,
|
||||
QRadialGradient,
|
||||
)
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import brand, theme
|
||||
|
||||
|
||||
class Card(QFrame):
|
||||
def __init__(self, parent: QWidget | None = None, *,
|
||||
margins: tuple[int, int, int, int] = (18, 18, 18, 18),
|
||||
spacing: int = 12) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Card")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.body = QVBoxLayout(self)
|
||||
self.body.setContentsMargins(*margins)
|
||||
self.body.setSpacing(spacing)
|
||||
|
||||
|
||||
def card_header(title: str, subtitle: str | None = None,
|
||||
right: QWidget | None = None) -> tuple[QWidget, QVBoxLayout]:
|
||||
holder = QWidget()
|
||||
layout = QHBoxLayout(holder)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setContentsMargins(0, 0, 0, 0)
|
||||
text_box.setSpacing(2)
|
||||
title_label = QLabel(title)
|
||||
title_label.setObjectName("CardTitle")
|
||||
title_label.setWordWrap(True)
|
||||
text_box.addWidget(title_label)
|
||||
if subtitle:
|
||||
subtitle_label = QLabel(subtitle)
|
||||
subtitle_label.setObjectName("Muted")
|
||||
subtitle_label.setWordWrap(True)
|
||||
subtitle_label.setFont(theme.body_font(9.2))
|
||||
text_box.addWidget(subtitle_label)
|
||||
layout.addLayout(text_box, 1)
|
||||
if right is not None:
|
||||
layout.addWidget(right, 0, Qt.AlignmentFlag.AlignTop)
|
||||
return holder, text_box
|
||||
|
||||
|
||||
class Pill(QLabel):
|
||||
def __init__(self, text: str = "", severity: str = "info", parent=None) -> None:
|
||||
super().__init__(text, parent)
|
||||
self.setObjectName("Pill")
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.set_severity(severity)
|
||||
|
||||
def set_severity(self, severity: str) -> None:
|
||||
self.setProperty("severity", severity or "info")
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
|
||||
class SeverityDot(QWidget):
|
||||
def __init__(self, severity: str = "info", size: int = 9, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._severity = severity
|
||||
self._size = size
|
||||
self.setFixedSize(size, size)
|
||||
|
||||
def set_severity(self, severity: str) -> None:
|
||||
self._severity = severity
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
color = QColor(theme.severity_color(self._severity))
|
||||
glow = QColor(color)
|
||||
glow.setAlpha(55)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(glow)
|
||||
painter.drawEllipse(QRectF(0, 0, self._size, self._size))
|
||||
painter.setBrush(color)
|
||||
inset = self._size * 0.22
|
||||
painter.drawEllipse(QRectF(inset, inset, self._size - inset * 2,
|
||||
self._size - inset * 2))
|
||||
|
||||
|
||||
class Avatar(QWidget):
|
||||
def __init__(self, name: str = "", size: int = 40, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._name = name or "?"
|
||||
self._size = size
|
||||
self.setFixedSize(size, size)
|
||||
|
||||
def set_name(self, name: str) -> None:
|
||||
self._name = name or "?"
|
||||
self.update()
|
||||
|
||||
def initials(self) -> str:
|
||||
parts = [part for part in str(self._name).replace("@", " ").split() if part]
|
||||
if not parts:
|
||||
return "?"
|
||||
if len(parts) == 1:
|
||||
return parts[0][:1].upper()
|
||||
return (parts[0][0] + parts[-1][0]).upper()
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
rect = QRectF(1, 1, self._size - 2, self._size - 2)
|
||||
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
||||
gradient.setColorAt(0, QColor("#1C4A55"))
|
||||
gradient.setColorAt(1, QColor("#0E2630"))
|
||||
painter.setBrush(gradient)
|
||||
painter.setPen(QPen(QColor(89, 216, 201, 90), 1))
|
||||
painter.drawEllipse(rect)
|
||||
painter.setPen(QColor(theme.AMBER_BRIGHT))
|
||||
font = theme.display_font(self._size * 0.30, QFont.Weight.DemiBold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.initials())
|
||||
|
||||
|
||||
class ProtectionDial(QWidget):
|
||||
"""The homepage hero: a scalloped shell whose four arcs encode state."""
|
||||
|
||||
_LABELS = ("Engine", "Definitions", "Real-time", "System")
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumSize(248, 248)
|
||||
self.setMaximumSize(286, 286)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self._overall = "setup"
|
||||
self._segments: list[dict] = []
|
||||
self._headline = "Checking"
|
||||
self.setToolTip("ClamAV engine, signature database, real-time watcher and system firewall")
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(270, 270)
|
||||
|
||||
def set_snapshot(self, snapshot: dict | None) -> None:
|
||||
self._overall = str((snapshot or {}).get("overall") or "setup")
|
||||
self._segments = list((snapshot or {}).get("segments") or [])
|
||||
self._headline = str((snapshot or {}).get("headline") or "Checking")
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
side = min(self.width(), self.height())
|
||||
rect = QRectF((self.width() - side) / 2 + 8, (self.height() - side) / 2 + 8,
|
||||
side - 16, side - 16)
|
||||
center = rect.center()
|
||||
radius = rect.width() / 2
|
||||
overall = QColor(theme.overall_color(self._overall))
|
||||
|
||||
glow = QRadialGradient(center, radius)
|
||||
glow.setColorAt(0.0, QColor(overall.red(), overall.green(), overall.blue(), 36))
|
||||
glow.setColorAt(0.62, QColor(overall.red(), overall.green(), overall.blue(), 8))
|
||||
glow.setColorAt(1.0, QColor(0, 0, 0, 0))
|
||||
painter.setBrush(glow)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(rect)
|
||||
|
||||
disk = QRadialGradient(center - QPointF(radius * 0.2, radius * 0.25), radius * 1.15)
|
||||
disk.setColorAt(0.0, QColor("#13313D"))
|
||||
disk.setColorAt(0.7, QColor("#0B1F29"))
|
||||
disk.setColorAt(1.0, QColor("#08151D"))
|
||||
painter.setBrush(disk)
|
||||
painter.setPen(QPen(QColor(27, 57, 70), 1))
|
||||
painter.drawEllipse(rect)
|
||||
|
||||
arc_rect = rect.adjusted(18, 18, -18, -18)
|
||||
track_pen = QPen(QColor(28, 59, 71), 9)
|
||||
track_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(track_pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawArc(arc_rect, 210 * 16, -300 * 16)
|
||||
|
||||
states = [str(item.get("state") or "unknown") for item in self._segments]
|
||||
states += ["unknown"] * (4 - len(states))
|
||||
for index in range(4):
|
||||
color = QColor(theme.severity_color(states[index]))
|
||||
if self._overall == "setup":
|
||||
color = QColor(theme.DIM)
|
||||
pen = QPen(color, 9)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
start = 210 - index * 75 + 2
|
||||
span = -71
|
||||
painter.drawArc(arc_rect, int(start * 16), int(span * 16))
|
||||
|
||||
# Shell ribs inside the gauge.
|
||||
painter.save()
|
||||
painter.setClipPath(self._ellipse_path(rect.adjusted(10, 10, -10, -10)))
|
||||
hinge = QPointF(center.x(), center.y() + radius * 0.52)
|
||||
top = center.y() - radius * 0.60
|
||||
spread = radius * 0.57
|
||||
for index in range(9):
|
||||
fraction = index / 8
|
||||
tip_x = center.x() - spread + fraction * spread * 2
|
||||
tip = QPointF(tip_x, top + abs(fraction - 0.5) * radius * 0.18)
|
||||
path = QPainterPath(hinge)
|
||||
path.cubicTo(QPointF(center.x() + (tip_x - center.x()) * 0.08,
|
||||
hinge.y() - radius * 0.28),
|
||||
QPointF(tip_x + (center.x() - tip_x) * 0.22, top + radius * 0.22),
|
||||
tip)
|
||||
painter.setPen(QPen(QColor(122, 244, 255, 20), 1.2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawPath(path)
|
||||
painter.restore()
|
||||
|
||||
core_radius = radius * 0.40
|
||||
core_rect = QRectF(center.x() - core_radius, center.y() - core_radius,
|
||||
core_radius * 2, core_radius * 2)
|
||||
core = QRadialGradient(center - QPointF(core_radius * 0.25, core_radius * 0.3),
|
||||
core_radius * 1.6)
|
||||
core.setColorAt(0.0, QColor("#143743"))
|
||||
core.setColorAt(1.0, QColor("#0A1C25"))
|
||||
painter.setBrush(core)
|
||||
painter.setPen(QPen(QColor(overall.red(), overall.green(), overall.blue(), 130), 1.4))
|
||||
painter.drawEllipse(core_rect)
|
||||
|
||||
# Shield outline with the amber bolt inside.
|
||||
shield = QPainterPath()
|
||||
left = center.x() - core_radius * 0.46
|
||||
right = center.x() + core_radius * 0.46
|
||||
top_y = center.y() - core_radius * 0.55
|
||||
bottom_y = center.y() + core_radius * 0.62
|
||||
shield.moveTo(center.x(), top_y)
|
||||
shield.lineTo(right, top_y + core_radius * 0.14)
|
||||
shield.lineTo(right, center.y() + core_radius * 0.10)
|
||||
shield.cubicTo(right, center.y() + core_radius * 0.48,
|
||||
center.x() + core_radius * 0.18, bottom_y,
|
||||
center.x(), bottom_y)
|
||||
shield.cubicTo(center.x() - core_radius * 0.18, bottom_y,
|
||||
left, center.y() + core_radius * 0.48,
|
||||
left, center.y() + core_radius * 0.10)
|
||||
shield.lineTo(left, top_y + core_radius * 0.14)
|
||||
shield.closeSubpath()
|
||||
painter.setPen(QPen(QColor(overall.red(), overall.green(), overall.blue(), 190), 1.6))
|
||||
painter.setBrush(QColor(overall.red(), overall.green(), overall.blue(), 14))
|
||||
painter.drawPath(shield)
|
||||
bolt_rect = QRectF(center.x() - core_radius * 0.44, top_y + core_radius * 0.08,
|
||||
core_radius * 0.88, core_radius * 0.92)
|
||||
painter.setBrush(QColor(theme.AMBER))
|
||||
painter.setPen(QPen(QColor(theme.AMBER_BRIGHT), 0.8))
|
||||
painter.drawPath(brand.bolt_path(bolt_rect))
|
||||
|
||||
# The hero copy already states the status; a second caption here
|
||||
# collided with the shell's lower arc at 1360px.
|
||||
|
||||
@staticmethod
|
||||
def _ellipse_path(rect: QRectF) -> QPainterPath:
|
||||
path = QPainterPath()
|
||||
path.addEllipse(rect)
|
||||
return path
|
||||
|
||||
|
||||
class NavButton(QAbstractButton):
|
||||
_ICONS = ("home", "scan", "updates", "settings")
|
||||
|
||||
def __init__(self, icon_name: str, text: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.icon_name = icon_name
|
||||
self.setText(text)
|
||||
self.setCheckable(True)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
|
||||
self.setMinimumHeight(46)
|
||||
self._hover = False
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(196, 46)
|
||||
|
||||
def enterEvent(self, event) -> None:
|
||||
self._hover = True
|
||||
self.update()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event) -> None:
|
||||
self._hover = False
|
||||
self.update()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
rect = QRectF(0, 2, self.width(), self.height() - 4)
|
||||
checked = self.isChecked()
|
||||
if checked:
|
||||
painter.setBrush(QColor(242, 184, 75, 20))
|
||||
painter.setPen(QPen(QColor(242, 184, 75, 65), 1))
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(theme.AMBER))
|
||||
painter.drawRoundedRect(QRectF(0, 13, 3, self.height() - 26), 1.5, 1.5)
|
||||
elif self._hover:
|
||||
painter.setBrush(QColor(255, 255, 255, 8))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
|
||||
color = QColor(theme.AMBER_BRIGHT if checked else theme.MUTED)
|
||||
icon_rect = QRectF(18, (self.height() - 18) / 2, 18, 18)
|
||||
_draw_nav_icon(painter, self.icon_name, icon_rect, color)
|
||||
painter.setPen(QColor(theme.TEXT if checked else theme.TEXT_SOFT))
|
||||
font = theme.body_font(10.4, QFont.Weight.DemiBold if checked else QFont.Weight.Normal)
|
||||
painter.setFont(font)
|
||||
painter.drawText(QRectF(46, 0, self.width() - 52, self.height()),
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
||||
self.text())
|
||||
|
||||
|
||||
def _draw_nav_icon(painter: QPainter, name: str, rect: QRectF, color: QColor) -> None:
|
||||
painter.save()
|
||||
pen = QPen(color, 1.6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap,
|
||||
Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
if name == "home":
|
||||
path = QPainterPath()
|
||||
path.moveTo(rect.left() + 1, rect.top() + rect.height() * 0.45)
|
||||
path.lineTo(rect.center().x(), rect.top() + 1)
|
||||
path.lineTo(rect.right() - 1, rect.top() + rect.height() * 0.45)
|
||||
painter.drawPath(path)
|
||||
painter.drawRoundedRect(QRectF(rect.left() + 3, rect.top() + rect.height() * 0.44,
|
||||
rect.width() - 6, rect.height() * 0.48), 2, 2)
|
||||
painter.drawLine(QPointF(rect.center().x(), rect.bottom() - 3),
|
||||
QPointF(rect.center().x(), rect.bottom() - 8))
|
||||
elif name == "scan":
|
||||
inset = 2.0
|
||||
length = 5.0
|
||||
for x, y, dx, dy in (
|
||||
(rect.left(), rect.top(), 1, 1),
|
||||
(rect.right(), rect.top(), -1, 1),
|
||||
(rect.left(), rect.bottom(), 1, -1),
|
||||
(rect.right(), rect.bottom(), -1, -1)):
|
||||
path = QPainterPath(QPointF(x + dx * inset, y + dy * (inset + length)))
|
||||
path.lineTo(x + dx * inset, y + dy * inset)
|
||||
path.lineTo(x + dx * (inset + length), y + dy * inset)
|
||||
painter.drawPath(path)
|
||||
painter.drawEllipse(QRectF(rect.center().x() - 2.2, rect.center().y() - 2.2, 4.4, 4.4))
|
||||
painter.drawLine(QPointF(rect.center().x(), rect.top() + 6),
|
||||
QPointF(rect.center().x(), rect.top() + 1))
|
||||
painter.drawLine(QPointF(rect.center().x(), rect.bottom() - 1),
|
||||
QPointF(rect.center().x(), rect.bottom() - 6))
|
||||
elif name == "updates":
|
||||
painter.drawArc(QRectF(rect.left() + 2, rect.top() + 2,
|
||||
rect.width() - 4, rect.height() - 4), 40 * 16, 280 * 16)
|
||||
arrow = QPolygonF([
|
||||
QPointF(rect.right() - 3, rect.top() + 3),
|
||||
QPointF(rect.right() - 3, rect.top() + 9),
|
||||
QPointF(rect.right() - 9, rect.top() + 3),
|
||||
])
|
||||
painter.setBrush(color)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawPolygon(arrow)
|
||||
elif name == "settings":
|
||||
for index, y in enumerate((rect.top() + 4, rect.center().y(), rect.bottom() - 4)):
|
||||
painter.drawLine(QPointF(rect.left() + 1, y), QPointF(rect.right() - 1, y))
|
||||
knob_x = rect.left() + rect.width() * (0.34 if index == 0 else 0.66 if index == 1 else 0.46)
|
||||
painter.setBrush(QColor(theme.BG))
|
||||
painter.setPen(QPen(color, 1.5))
|
||||
painter.drawEllipse(QPointF(knob_x, y), 2.3, 2.3)
|
||||
painter.restore()
|
||||
|
||||
|
||||
class ChoiceTile(QRadioButton):
|
||||
def __init__(self, title: str, description: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("ChoiceTile")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(36, 12, 14, 12)
|
||||
layout.setSpacing(3)
|
||||
title_label = QLabel(title)
|
||||
title_label.setFont(theme.body_font(10.3, QFont.Weight.DemiBold))
|
||||
title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setObjectName("Muted")
|
||||
desc_label.setFont(theme.body_font(8.8))
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
layout.addWidget(title_label)
|
||||
layout.addWidget(desc_label)
|
||||
self._title_label = title_label
|
||||
self._desc_label = desc_label
|
||||
self.setMinimumHeight(82)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(250, 82)
|
||||
|
||||
|
||||
class ScanButton(QAbstractButton):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(184, 184)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
|
||||
self._scanning = False
|
||||
self._hover = False
|
||||
self._angle = 0
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(45)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(184, 184)
|
||||
|
||||
def set_scanning(self, scanning: bool) -> None:
|
||||
self._scanning = bool(scanning)
|
||||
if scanning:
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
self.setCursor(Qt.CursorShape.BusyCursor)
|
||||
else:
|
||||
self._timer.stop()
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.update()
|
||||
|
||||
def enterEvent(self, event) -> None:
|
||||
self._hover = True
|
||||
self.update()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event) -> None:
|
||||
self._hover = False
|
||||
self.update()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _tick(self) -> None:
|
||||
self._angle = (self._angle + 12) % 360
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
side = min(self.width(), self.height())
|
||||
rect = QRectF((self.width() - side) / 2 + 6, (self.height() - side) / 2 + 6,
|
||||
side - 12, side - 12)
|
||||
center = rect.center()
|
||||
radius = rect.width() / 2
|
||||
|
||||
glow = QRadialGradient(center, radius)
|
||||
glow.setColorAt(0, QColor(242, 184, 75, 40 if self.isEnabled() else 10))
|
||||
glow.setColorAt(1, QColor(242, 184, 75, 0))
|
||||
painter.setBrush(glow)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(rect)
|
||||
|
||||
painter.setBrush(QColor("#0B1E27"))
|
||||
painter.setPen(QPen(QColor(theme.BORDER), 1))
|
||||
painter.drawEllipse(rect.adjusted(10, 10, -10, -10))
|
||||
|
||||
ring = rect.adjusted(20, 20, -20, -20)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor(33, 66, 79), 7))
|
||||
painter.drawArc(ring, 0, 360 * 16)
|
||||
if self._scanning:
|
||||
pen = QPen(QColor(theme.AMBER), 7)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.drawArc(ring, -self._angle * 16, -86 * 16)
|
||||
else:
|
||||
pen = QPen(QColor(theme.TEAL if self.isEnabled() else theme.DIM), 3)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
for index in range(4):
|
||||
painter.drawArc(ring, (index * 90 + 14) * 16, 54 * 16)
|
||||
|
||||
core = QRectF(center.x() - 57, center.y() - 57, 114, 114)
|
||||
gradient = QLinearGradient(core.topLeft(), core.bottomRight())
|
||||
if self.isEnabled():
|
||||
gradient.setColorAt(0, QColor("#FFD37A" if self._hover else "#F6C35B"))
|
||||
gradient.setColorAt(1, QColor("#E7A635"))
|
||||
else:
|
||||
gradient.setColorAt(0, QColor("#3A3A30"))
|
||||
gradient.setColorAt(1, QColor("#2A2A22"))
|
||||
painter.setBrush(gradient)
|
||||
painter.setPen(QPen(QColor(255, 227, 166, 130 if self.isEnabled() else 30), 1))
|
||||
painter.drawEllipse(core)
|
||||
|
||||
icon_rect = QRectF(center.x() - 21, center.y() - 34, 42, 42)
|
||||
painter.setPen(QPen(QColor("#2A1A04" if self.isEnabled() else "#686250"), 1.7))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
shield = QPainterPath()
|
||||
shield.moveTo(icon_rect.center().x(), icon_rect.top())
|
||||
shield.lineTo(icon_rect.right(), icon_rect.top() + icon_rect.height() * 0.16)
|
||||
shield.lineTo(icon_rect.right(), icon_rect.top() + icon_rect.height() * 0.48)
|
||||
shield.cubicTo(icon_rect.right(), icon_rect.top() + icon_rect.height() * 0.82,
|
||||
icon_rect.center().x() + icon_rect.width() * 0.18, icon_rect.bottom(),
|
||||
icon_rect.center().x(), icon_rect.bottom())
|
||||
shield.cubicTo(icon_rect.center().x() - icon_rect.width() * 0.18, icon_rect.bottom(),
|
||||
icon_rect.left(), icon_rect.top() + icon_rect.height() * 0.82,
|
||||
icon_rect.left(), icon_rect.top() + icon_rect.height() * 0.48)
|
||||
shield.lineTo(icon_rect.left(), icon_rect.top() + icon_rect.height() * 0.16)
|
||||
shield.closeSubpath()
|
||||
painter.drawPath(shield)
|
||||
bolt_rect = QRectF(center.x() - 16, icon_rect.top() + 7, 32, 30)
|
||||
painter.setBrush(QColor("#2A1A04" if self.isEnabled() else "#686250"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawPath(brand.bolt_path(bolt_rect))
|
||||
|
||||
painter.setPen(QColor("#241704" if self.isEnabled() else "#807A68"))
|
||||
painter.setFont(theme.body_font(11.2, QFont.Weight.Bold))
|
||||
painter.drawText(QRectF(core.left(), core.center().y() + 20, core.width(), 24),
|
||||
Qt.AlignmentFlag.AlignCenter,
|
||||
"Scanning…" if self._scanning else "Start scan")
|
||||
|
||||
|
||||
class StatTile(QFrame):
|
||||
def __init__(self, label: str, value: str = "—", note: str = "",
|
||||
accent: str = theme.TEAL, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Card")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 14, 16, 14)
|
||||
layout.setSpacing(4)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(7)
|
||||
dot = SeverityDot("good", 7)
|
||||
dot.setStyleSheet(f"background: transparent;")
|
||||
top.addWidget(dot)
|
||||
label_widget = QLabel(label)
|
||||
label_widget.setObjectName("StatLabel")
|
||||
top.addWidget(label_widget, 1)
|
||||
layout.addLayout(top)
|
||||
self.value_label = QLabel(value)
|
||||
self.value_label.setObjectName("StatValue")
|
||||
layout.addWidget(self.value_label)
|
||||
self.note_label = QLabel(note)
|
||||
self.note_label.setObjectName("Dim")
|
||||
self.note_label.setWordWrap(True)
|
||||
layout.addWidget(self.note_label)
|
||||
self._dot = dot
|
||||
self.set_accent(accent)
|
||||
|
||||
def set_accent(self, severity: str) -> None:
|
||||
self._dot.set_severity(severity)
|
||||
|
||||
def set_value(self, value: str, note: str | None = None) -> None:
|
||||
self.value_label.setText(value)
|
||||
if note is not None:
|
||||
self.note_label.setText(note)
|
||||
|
||||
|
||||
class ConcernRow(QFrame):
|
||||
action_requested = pyqtSignal(str)
|
||||
|
||||
_ACTION_LABELS = {
|
||||
"settings": "Open settings",
|
||||
"scan": "Review scan",
|
||||
"update_definitions": "Update",
|
||||
"quarantine": "Review",
|
||||
"updates": "Check updates",
|
||||
}
|
||||
|
||||
def __init__(self, concern: dict, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("ConcernRow")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self._action = str(concern.get("action") or "none")
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 9, 2, 9)
|
||||
layout.setSpacing(11)
|
||||
dot = SeverityDot(str(concern.get("severity") or "info"), 9)
|
||||
layout.addWidget(dot, 0, Qt.AlignmentFlag.AlignTop)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setContentsMargins(0, 0, 0, 0)
|
||||
text_box.setSpacing(2)
|
||||
title = QLabel(str(concern.get("title") or "Security note"))
|
||||
title.setFont(theme.body_font(10.2, QFont.Weight.DemiBold))
|
||||
title.setWordWrap(True)
|
||||
detail = QLabel(str(concern.get("detail") or ""))
|
||||
detail.setObjectName("Muted")
|
||||
detail.setFont(theme.body_font(8.9))
|
||||
detail.setWordWrap(True)
|
||||
text_box.addWidget(title)
|
||||
text_box.addWidget(detail)
|
||||
layout.addLayout(text_box, 1)
|
||||
label = self._ACTION_LABELS.get(self._action)
|
||||
if label:
|
||||
button = QPushButton(label)
|
||||
button.setObjectName("Link")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda: self.action_requested.emit(self._action))
|
||||
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
|
||||
class PageHeader(QWidget):
|
||||
def __init__(self, title: str, subtitle: str = "", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(18)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setContentsMargins(0, 0, 0, 0)
|
||||
text_box.setSpacing(4)
|
||||
self.title_label = QLabel(title)
|
||||
self.title_label.setObjectName("PageTitle")
|
||||
self.subtitle_label = QLabel(subtitle)
|
||||
self.subtitle_label.setObjectName("PageSubtitle")
|
||||
self.subtitle_label.setWordWrap(True)
|
||||
text_box.addWidget(self.title_label)
|
||||
text_box.addWidget(self.subtitle_label)
|
||||
layout.addLayout(text_box, 1)
|
||||
self.actions = QHBoxLayout()
|
||||
self.actions.setSpacing(8)
|
||||
layout.addLayout(self.actions, 0)
|
||||
|
||||
def set_subtitle(self, text: str) -> None:
|
||||
self.subtitle_label.setText(text)
|
||||
|
||||
def add_action(self, widget: QWidget) -> None:
|
||||
self.actions.addWidget(widget, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
|
||||
class KeyValueRow(QWidget):
|
||||
def __init__(self, label: str, value: str = "—", *, mono: bool = False,
|
||||
parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 3, 0, 3)
|
||||
layout.setSpacing(12)
|
||||
self.label = QLabel(label)
|
||||
self.label.setObjectName("Muted")
|
||||
self.label.setFont(theme.body_font(8.9))
|
||||
layout.addWidget(self.label, 0)
|
||||
self.value_label = QLabel(value)
|
||||
self.value_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.value_label.setWordWrap(True)
|
||||
if mono:
|
||||
self.value_label.setObjectName("Mono")
|
||||
else:
|
||||
self.value_label.setFont(theme.body_font(9.4, QFont.Weight.DemiBold))
|
||||
layout.addWidget(self.value_label, 1)
|
||||
|
||||
def set_value(self, value: str) -> None:
|
||||
self.value_label.setText(value or "—")
|
||||
|
||||
|
||||
class EmptyState(QWidget):
|
||||
action_clicked = pyqtSignal()
|
||||
|
||||
def __init__(self, title: str, body: str, action: str | None = None, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 30, 20, 30)
|
||||
layout.setSpacing(8)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title_label = QLabel(title)
|
||||
title_label.setFont(theme.display_font(15, QFont.Weight.DemiBold))
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
body_label = QLabel(body)
|
||||
body_label.setObjectName("Muted")
|
||||
body_label.setWordWrap(True)
|
||||
body_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
body_label.setMaximumWidth(420)
|
||||
layout.addWidget(title_label)
|
||||
layout.addWidget(body_label, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
if action:
|
||||
button = QPushButton(action)
|
||||
button.setObjectName("Secondary")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(self.action_clicked)
|
||||
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
|
||||
class MarkdownView(QTextBrowser):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setOpenExternalLinks(True)
|
||||
self.setReadOnly(True)
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.setFont(theme.body_font(9.4))
|
||||
self.document().setDefaultStyleSheet(
|
||||
f"h1,h2,h3{{font-family:'{theme.FONT_DISPLAY}';color:{theme.TEXT};}}"
|
||||
f"a{{color:{theme.TEAL};}} code{{font-family:'{theme.FONT_MONO}';"
|
||||
f"background:{theme.BG_DEEP};color:{theme.AMBER_BRIGHT};}}"
|
||||
f"li{{margin-bottom:4px;}}"
|
||||
)
|
||||
|
||||
def set_markdown(self, text: str) -> None:
|
||||
self.setMarkdown(str(text or ""))
|
||||
|
||||
|
||||
class ElidedLabel(QLabel):
|
||||
def __init__(self, text: str = "", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._full_text = text
|
||||
self.setToolTip(text)
|
||||
self.setText(text)
|
||||
|
||||
def setText(self, text: str) -> None: # noqa: N802
|
||||
self._full_text = str(text or "")
|
||||
self.setToolTip(self._full_text)
|
||||
self._elide()
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._elide()
|
||||
|
||||
def _elide(self) -> None:
|
||||
metrics = QFontMetrics(self.font())
|
||||
available = max(20, self.width())
|
||||
super().setText(metrics.elidedText(self._full_text, Qt.TextElideMode.ElideMiddle,
|
||||
available))
|
||||
|
||||
|
||||
class Toast(QFrame):
|
||||
def __init__(self, parent: QWidget) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Toast")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.setFixedWidth(370)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(10)
|
||||
self.dot = SeverityDot("info", 9)
|
||||
layout.addWidget(self.dot, 0, Qt.AlignmentFlag.AlignTop)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setContentsMargins(0, 0, 0, 0)
|
||||
text_box.setSpacing(2)
|
||||
self.title_label = QLabel("")
|
||||
self.title_label.setObjectName("ToastTitle")
|
||||
self.body_label = QLabel("")
|
||||
self.body_label.setObjectName("ToastBody")
|
||||
self.body_label.setWordWrap(True)
|
||||
text_box.addWidget(self.title_label)
|
||||
text_box.addWidget(self.body_label)
|
||||
layout.addLayout(text_box, 1)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self.hide)
|
||||
self.hide()
|
||||
|
||||
def show_message(self, title: str, body: str = "", severity: str = "info",
|
||||
duration_ms: int = 7000) -> None:
|
||||
self.title_label.setText(title)
|
||||
self.body_label.setText(body)
|
||||
self.dot.set_severity(severity)
|
||||
self.adjustSize()
|
||||
parent = self.parentWidget()
|
||||
if parent is not None:
|
||||
x = max(20, parent.width() - self.width() - 26)
|
||||
y = max(20, parent.height() - self.height() - 26)
|
||||
self.move(x, y)
|
||||
self.raise_()
|
||||
self.show()
|
||||
self._timer.start(max(2500, duration_ms))
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
self.hide()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class Divider(QFrame):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Divider")
|
||||
self.setFixedHeight(1)
|
||||
@@ -0,0 +1,570 @@
|
||||
"""Homepage: the shell gauge, security concerns, network and account summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QThread, Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..context import AppContext
|
||||
from ..models import display_name, human_action
|
||||
from ..system_info import collect
|
||||
from . import components, theme
|
||||
from .threads import stop_thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemInfoWorker(QThread):
|
||||
finished_snapshot = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
realtime_running = None
|
||||
if self.context.realtime is not None:
|
||||
realtime_running = self.context.realtime.running
|
||||
snapshot = collect(
|
||||
self.context.settings, self.context.clamav.info(), self.context.storage,
|
||||
realtime_running=realtime_running, force=True)
|
||||
self.finished_snapshot.emit(snapshot)
|
||||
except Exception as exc:
|
||||
logger.exception("System overview failed")
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class SegmentChip(QFrame):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SegmentChip")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
layout.setSpacing(8)
|
||||
self.dot = components.SeverityDot("unknown", 8)
|
||||
layout.addWidget(self.dot, 0, Qt.AlignmentFlag.AlignTop)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(1)
|
||||
self.label = QLabel("—")
|
||||
self.label.setFont(theme.body_font(8.8, QFont.Weight.DemiBold))
|
||||
self.detail = QLabel("")
|
||||
self.detail.setObjectName("Dim")
|
||||
self.detail.setFont(theme.body_font(8.0))
|
||||
self.detail.setWordWrap(True)
|
||||
text_box.addWidget(self.label)
|
||||
text_box.addWidget(self.detail)
|
||||
layout.addLayout(text_box, 1)
|
||||
|
||||
def set_segment(self, segment: dict) -> None:
|
||||
self.dot.set_severity(str(segment.get("state") or "unknown"))
|
||||
self.label.setText(str(segment.get("label") or "—"))
|
||||
self.detail.setText(str(segment.get("detail") or ""))
|
||||
|
||||
|
||||
class ActivityRow(QWidget):
|
||||
def __init__(self, severity: str, title: str, subtitle: str, when: str,
|
||||
parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 7, 0, 7)
|
||||
layout.setSpacing(10)
|
||||
layout.addWidget(components.SeverityDot(severity, 8), 0, Qt.AlignmentFlag.AlignTop)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(1)
|
||||
title_label = QLabel(title)
|
||||
title_label.setFont(theme.body_font(9.5, QFont.Weight.DemiBold))
|
||||
title_label.setWordWrap(False)
|
||||
subtitle_label = components.ElidedLabel(subtitle)
|
||||
subtitle_label.setObjectName("Muted")
|
||||
subtitle_label.setFont(theme.body_font(8.5))
|
||||
text_box.addWidget(title_label)
|
||||
text_box.addWidget(subtitle_label)
|
||||
layout.addLayout(text_box, 1)
|
||||
time_label = QLabel(when)
|
||||
time_label.setObjectName("Dim")
|
||||
time_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
|
||||
layout.addWidget(time_label, 0)
|
||||
|
||||
|
||||
class HomePage(QWidget):
|
||||
scan_requested = pyqtSignal()
|
||||
settings_requested = pyqtSignal()
|
||||
quarantine_requested = pyqtSignal()
|
||||
updates_requested = pyqtSignal()
|
||||
definitions_requested = pyqtSignal()
|
||||
snapshot_changed = pyqtSignal(object)
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self._worker: SystemInfoWorker | None = None
|
||||
self._snapshot: dict | None = None
|
||||
self._segments: list[SegmentChip] = []
|
||||
self._build()
|
||||
self.refresh()
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
outer.addWidget(scroll)
|
||||
content = QWidget()
|
||||
content.setObjectName("Content")
|
||||
scroll.setWidget(content)
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(30, 24, 30, 30)
|
||||
layout.setSpacing(16)
|
||||
|
||||
header = components.PageHeader(
|
||||
"Homepage", "A live view of this PC's protection, network, and account.")
|
||||
refresh_button = QPushButton("Refresh")
|
||||
refresh_button.setObjectName("Secondary")
|
||||
refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
refresh_button.clicked.connect(lambda: self.refresh(force=True))
|
||||
header.add_action(refresh_button)
|
||||
layout.addWidget(header)
|
||||
|
||||
self.update_banner = QFrame()
|
||||
self.update_banner.setObjectName("Card")
|
||||
self.update_banner.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.update_banner.setStyleSheet(
|
||||
f"QFrame#Card {{ background: rgba(242,184,75,.08); border-color: rgba(242,184,75,.3); }}")
|
||||
banner_layout = QHBoxLayout(self.update_banner)
|
||||
banner_layout.setContentsMargins(16, 11, 16, 11)
|
||||
banner_layout.setSpacing(12)
|
||||
self.update_banner_text = QLabel("")
|
||||
self.update_banner_text.setFont(theme.body_font(9.6, QFont.Weight.DemiBold))
|
||||
banner_layout.addWidget(self.update_banner_text, 1)
|
||||
update_button = QPushButton("View update")
|
||||
update_button.setObjectName("Link")
|
||||
update_button.clicked.connect(self.updates_requested)
|
||||
banner_layout.addWidget(update_button)
|
||||
dismiss = QPushButton("Dismiss")
|
||||
dismiss.setObjectName("Ghost")
|
||||
dismiss.clicked.connect(self.update_banner.hide)
|
||||
banner_layout.addWidget(dismiss)
|
||||
self.update_banner.hide()
|
||||
layout.addWidget(self.update_banner)
|
||||
|
||||
layout.addWidget(self._build_hero())
|
||||
layout.addLayout(self._build_stats())
|
||||
middle = QHBoxLayout()
|
||||
middle.setSpacing(16)
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(16)
|
||||
left.addWidget(self._build_concerns())
|
||||
left.addWidget(self._build_activity())
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(16)
|
||||
right.addWidget(self._build_network())
|
||||
right.addWidget(self._build_account())
|
||||
right.addStretch(1)
|
||||
middle.addLayout(left, 2)
|
||||
middle.addLayout(right, 1)
|
||||
layout.addLayout(middle, 1)
|
||||
self.engine_footer = QLabel("")
|
||||
self.engine_footer.setObjectName("Dim")
|
||||
layout.addWidget(self.engine_footer)
|
||||
|
||||
def _build_hero(self) -> QWidget:
|
||||
hero = QFrame()
|
||||
hero.setObjectName("Hero")
|
||||
hero.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
layout = QHBoxLayout(hero)
|
||||
layout.setContentsMargins(26, 22, 26, 22)
|
||||
layout.setSpacing(26)
|
||||
self.dial = components.ProtectionDial()
|
||||
layout.addWidget(self.dial, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(8)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(10)
|
||||
self.status_pill = components.Pill("Checking", "muted")
|
||||
top.addWidget(self.status_pill, 0)
|
||||
self.checked_label = QLabel("Reading system status…")
|
||||
self.checked_label.setObjectName("Dim")
|
||||
top.addWidget(self.checked_label, 0)
|
||||
top.addStretch(1)
|
||||
text.addLayout(top)
|
||||
self.hero_title = QLabel("Checking your protection")
|
||||
self.hero_title.setObjectName("HeroTitle")
|
||||
self.hero_title.setWordWrap(True)
|
||||
text.addWidget(self.hero_title)
|
||||
self.hero_summary = QLabel(
|
||||
"BoltClam is reading the ClamAV engine, Windows security, and your last scan.")
|
||||
self.hero_summary.setObjectName("HeroSummary")
|
||||
self.hero_summary.setWordWrap(True)
|
||||
self.hero_summary.setMaximumWidth(620)
|
||||
text.addWidget(self.hero_summary)
|
||||
segment_grid = QGridLayout()
|
||||
segment_grid.setHorizontalSpacing(8)
|
||||
segment_grid.setVerticalSpacing(7)
|
||||
for index in range(4):
|
||||
chip = SegmentChip()
|
||||
self._segments.append(chip)
|
||||
segment_grid.addWidget(chip, index // 2, index % 2)
|
||||
text.addLayout(segment_grid)
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(9)
|
||||
scan_button = QPushButton("Run a scan")
|
||||
scan_button.setObjectName("Primary")
|
||||
scan_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
scan_button.clicked.connect(self.scan_requested)
|
||||
actions.addWidget(scan_button)
|
||||
settings_button = QPushButton("Open settings")
|
||||
settings_button.setObjectName("Secondary")
|
||||
settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
settings_button.clicked.connect(self.settings_requested)
|
||||
actions.addWidget(settings_button)
|
||||
actions.addStretch(1)
|
||||
text.addLayout(actions)
|
||||
layout.addLayout(text, 1)
|
||||
return hero
|
||||
|
||||
def _build_stats(self) -> QHBoxLayout:
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(12)
|
||||
self.stat_last_scan = components.StatTile("Last scan", "Never", "No scan recorded")
|
||||
self.stat_threats = components.StatTile("Active threats", "0", "Nothing needs action")
|
||||
self.stat_quarantine = components.StatTile("Quarantine", "0", "No files held")
|
||||
self.stat_blocked = components.StatTile("Blocked downloads", "0", "Last 30 days")
|
||||
for tile in (self.stat_last_scan, self.stat_threats,
|
||||
self.stat_quarantine, self.stat_blocked):
|
||||
tile.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
row.addWidget(tile, 1)
|
||||
return row
|
||||
|
||||
def _build_concerns(self) -> QWidget:
|
||||
card = components.Card()
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
title_box = QVBoxLayout()
|
||||
title_box.setSpacing(1)
|
||||
title = QLabel("Security concerns")
|
||||
title.setObjectName("CardTitle")
|
||||
subtitle = QLabel("What needs attention on this PC")
|
||||
subtitle.setObjectName("Muted")
|
||||
subtitle.setFont(theme.body_font(8.8))
|
||||
title_box.addWidget(title)
|
||||
title_box.addWidget(subtitle)
|
||||
header_layout.addLayout(title_box, 1)
|
||||
self.concern_pill = components.Pill("Checking", "muted")
|
||||
header_layout.addWidget(self.concern_pill, 0, Qt.AlignmentFlag.AlignTop)
|
||||
card.body.addWidget(header)
|
||||
self.concerns_layout = QVBoxLayout()
|
||||
self.concerns_layout.setSpacing(0)
|
||||
card.body.addLayout(self.concerns_layout)
|
||||
self._concern_loading = QLabel("Reading Windows security status…")
|
||||
self._concern_loading.setObjectName("Muted")
|
||||
self.concerns_layout.addWidget(self._concern_loading)
|
||||
return card
|
||||
|
||||
def _build_activity(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"Recent activity", "Detections and download decisions from this PC")[0])
|
||||
self.activity_layout = QVBoxLayout()
|
||||
self.activity_layout.setSpacing(0)
|
||||
card.body.addLayout(self.activity_layout)
|
||||
self._activity_loading = QLabel("Loading activity…")
|
||||
self._activity_loading.setObjectName("Muted")
|
||||
self.activity_layout.addWidget(self._activity_loading)
|
||||
return card
|
||||
|
||||
def _build_network(self) -> QWidget:
|
||||
card = components.Card()
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
title = QLabel("Network")
|
||||
title.setObjectName("CardTitle")
|
||||
header_layout.addWidget(title, 1)
|
||||
self.admin_pill = components.Pill("Checking", "muted")
|
||||
header_layout.addWidget(self.admin_pill, 0)
|
||||
card.body.addWidget(header)
|
||||
self.network_rows = {}
|
||||
for key, label in (
|
||||
("hostname", "Hostname"), ("ip", "IPv4 address"), ("gateway", "Gateway"),
|
||||
("adapter", "Adapter"), ("os", "Operating system"), ("uptime", "Uptime"),
|
||||
):
|
||||
row = components.KeyValueRow(label)
|
||||
self.network_rows[key] = row
|
||||
card.body.addWidget(row)
|
||||
self.network_rows["ip"].value_label.setObjectName("Mono")
|
||||
return card
|
||||
|
||||
def _build_account(self) -> QWidget:
|
||||
card = components.Card()
|
||||
header = QHBoxLayout()
|
||||
header.setSpacing(11)
|
||||
self.account_avatar = components.Avatar(self.context.user_name, 38)
|
||||
header.addWidget(self.account_avatar, 0)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(1)
|
||||
self.account_name = QLabel(self.context.user_name)
|
||||
self.account_name.setFont(theme.body_font(10.5, QFont.Weight.DemiBold))
|
||||
self.account_email = components.ElidedLabel(self.context.user_email)
|
||||
self.account_email.setObjectName("Muted")
|
||||
self.account_email.setFont(theme.body_font(8.5))
|
||||
text_box.addWidget(self.account_name)
|
||||
text_box.addWidget(self.account_email)
|
||||
header.addLayout(text_box, 1)
|
||||
card.body.addLayout(header)
|
||||
plan_row = QHBoxLayout()
|
||||
plan_label = QLabel("Plan")
|
||||
plan_label.setObjectName("Muted")
|
||||
plan_label.setFont(theme.body_font(8.8))
|
||||
plan_row.addWidget(plan_label, 0)
|
||||
plan_row.addStretch(1)
|
||||
self.plan_pill = components.Pill(self.context.plan_label, "info")
|
||||
plan_row.addWidget(self.plan_pill, 0)
|
||||
card.body.addLayout(plan_row)
|
||||
self.subscription_label = QLabel("")
|
||||
self.subscription_label.setObjectName("Dim")
|
||||
self.subscription_label.setWordWrap(True)
|
||||
card.body.addWidget(self.subscription_label)
|
||||
manage = QLabel(
|
||||
f'<a href="https://bolt.themajesticnetwork.com/account" '
|
||||
f'style="color:{theme.TEAL}; text-decoration:none;">Manage Bolt account</a>')
|
||||
manage.setOpenExternalLinks(True)
|
||||
manage.setFont(theme.body_font(9.0, QFont.Weight.DemiBold))
|
||||
card.body.addWidget(manage)
|
||||
return card
|
||||
|
||||
# -- refresh -----------------------------------------------------------
|
||||
|
||||
def refresh(self, force: bool = False) -> None:
|
||||
self._refresh_stats()
|
||||
self._refresh_account()
|
||||
self._refresh_activity()
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
return
|
||||
worker = SystemInfoWorker(self.context, self)
|
||||
worker.finished_snapshot.connect(self._apply_snapshot)
|
||||
worker.failed.connect(self._system_failed)
|
||||
worker.finished.connect(self._worker_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._worker = worker
|
||||
worker.start()
|
||||
|
||||
def _refresh_stats(self) -> None:
|
||||
stats = self.context.storage.stats()
|
||||
latest = stats.get("latest_scan")
|
||||
if latest is None:
|
||||
self.stat_last_scan.set_value("Never", "No scan recorded")
|
||||
self.stat_last_scan.set_accent("muted")
|
||||
else:
|
||||
self.stat_last_scan.set_value(
|
||||
theme.relative_time(latest.started_at),
|
||||
f"{latest.scope_label} · {latest.status.replace('_', ' ')}")
|
||||
self.stat_last_scan.set_accent("good" if latest.status == "completed" else "warning")
|
||||
active = int(stats.get("active_threats") or 0)
|
||||
self.stat_threats.set_value(str(active), "Needs quarantine or deletion" if active else
|
||||
"Nothing needs action")
|
||||
self.stat_threats.set_accent("critical" if active else "good")
|
||||
quarantined = int(stats.get("quarantined") or 0)
|
||||
self.stat_quarantine.set_value(str(quarantined),
|
||||
"Review in Scan & results" if quarantined else "No files held")
|
||||
self.stat_quarantine.set_accent("warning" if quarantined else "good")
|
||||
blocked = int(stats.get("blocked_30d") or 0)
|
||||
self.stat_blocked.set_value(str(blocked), "Last 30 days")
|
||||
self.stat_blocked.set_accent("good" if blocked == 0 else "info")
|
||||
|
||||
def _refresh_account(self) -> None:
|
||||
self.account_avatar.set_name(self.context.user_name)
|
||||
self.account_name.setText(self.context.user_name)
|
||||
self.account_email.setText(self.context.user_email or "Signed in with Bolt")
|
||||
self.plan_pill.setText(self.context.plan_label)
|
||||
self.plan_pill.set_severity("good" if self.context.licensed else "muted")
|
||||
subscription = self.context.subscription
|
||||
status = str(subscription.get("status") or "none")
|
||||
if status in ("active", "trialing"):
|
||||
period_end = theme.format_datetime(subscription.get("current_period_end"))
|
||||
self.subscription_label.setText(
|
||||
f"{subscription.get('label', 'Bolt')} · {status}"
|
||||
+ (f" · renews {period_end}" if period_end != "—" else ""))
|
||||
else:
|
||||
self.subscription_label.setText("No active Bolt subscription.")
|
||||
|
||||
def _refresh_activity(self) -> None:
|
||||
_clear_layout(self.activity_layout)
|
||||
events: list[tuple[str, str, str, str]] = []
|
||||
for detection in self.context.storage.recent_detections(5):
|
||||
action = str(detection.get("action") or "detected")
|
||||
severity = "critical" if action in ("detected", "failed") else (
|
||||
"good" if action in ("quarantined", "deleted") else "info")
|
||||
events.append((
|
||||
str(detection.get("detected_at") or ""),
|
||||
str(detection.get("signature") or "Detection"),
|
||||
f"{display_name(str(detection.get('path') or ''))} · {human_action(action)}",
|
||||
severity,
|
||||
))
|
||||
for event in self.context.storage.list_realtime_events(5):
|
||||
severity = "critical" if event.action in ("detected", "failed") else (
|
||||
"good" if event.action in ("quarantined", "deleted", "blocked") else "info")
|
||||
title = event.signature or {
|
||||
"quarantined": "Download quarantined",
|
||||
"deleted": "Download deleted",
|
||||
"detected": "Download needs attention",
|
||||
"error": "Download scan failed",
|
||||
"skipped": "Large download not scanned",
|
||||
"allowed": "Download scanned",
|
||||
}.get(event.action, "Download activity")
|
||||
events.append((event.created_at, title,
|
||||
f"{display_name(event.path)} · {human_action(event.action)}",
|
||||
severity))
|
||||
events.sort(key=lambda item: item[0], reverse=True)
|
||||
if not events:
|
||||
empty = QLabel("No activity yet. Scans and browser-download decisions show up here.")
|
||||
empty.setObjectName("Muted")
|
||||
empty.setWordWrap(True)
|
||||
self.activity_layout.addWidget(empty)
|
||||
return
|
||||
for when, title, subtitle, severity in events[:6]:
|
||||
self.activity_layout.addWidget(
|
||||
ActivityRow(severity, title, subtitle, theme.relative_time(when)))
|
||||
|
||||
def _apply_snapshot(self, snapshot: dict) -> None:
|
||||
self._snapshot = snapshot
|
||||
self.dial.set_snapshot(snapshot)
|
||||
overall = str(snapshot.get("overall") or "setup")
|
||||
severity = {"protected": "good", "attention": "warning",
|
||||
"at_risk": "critical", "setup": "info"}.get(overall, "muted")
|
||||
label = {"protected": "Protected", "attention": "Needs attention",
|
||||
"at_risk": "At risk", "setup": "Setup needed"}.get(overall, "Checking")
|
||||
self.status_pill.setText(label)
|
||||
self.status_pill.set_severity(severity)
|
||||
self.hero_title.setText(str(snapshot.get("headline") or "Protection status"))
|
||||
self.hero_summary.setText(str(snapshot.get("summary") or ""))
|
||||
checked = theme.relative_time(snapshot.get("checked_at"))
|
||||
self.checked_label.setText(f"Checked {checked}")
|
||||
segments = snapshot.get("segments") or []
|
||||
for index, chip in enumerate(self._segments):
|
||||
chip.set_segment(segments[index] if index < len(segments) else {})
|
||||
self._render_concerns(snapshot.get("concerns") or [])
|
||||
self._render_network(snapshot)
|
||||
clamav = snapshot.get("clamav") or {}
|
||||
engine = str(clamav.get("summary") or "ClamAV not configured")
|
||||
self.engine_footer.setText(
|
||||
f"Engine: {engine} · Last checked {theme.relative_time(snapshot.get('checked_at'))}" )
|
||||
self.snapshot_changed.emit(snapshot)
|
||||
|
||||
def _render_concerns(self, concerns: list[dict]) -> None:
|
||||
_clear_layout(self.concerns_layout)
|
||||
count = len(concerns)
|
||||
critical = sum(1 for item in concerns if item.get("severity") == "critical")
|
||||
warning = sum(1 for item in concerns if item.get("severity") == "warning")
|
||||
if critical:
|
||||
self.concern_pill.setText(f"{critical} critical")
|
||||
self.concern_pill.set_severity("critical")
|
||||
elif warning:
|
||||
self.concern_pill.setText(f"{warning} to review")
|
||||
self.concern_pill.set_severity("warning")
|
||||
elif count:
|
||||
self.concern_pill.setText(f"{count} note(s)")
|
||||
self.concern_pill.set_severity("info")
|
||||
else:
|
||||
self.concern_pill.setText("All clear")
|
||||
self.concern_pill.set_severity("good")
|
||||
if not concerns:
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(2, 10, 2, 8)
|
||||
row.addWidget(components.SeverityDot("good", 9), 0)
|
||||
label = QLabel("Nothing needs your attention. Definitions are current and "
|
||||
"no detected files are waiting for action.")
|
||||
label.setObjectName("Muted")
|
||||
label.setWordWrap(True)
|
||||
row.addWidget(label, 1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(row)
|
||||
self.concerns_layout.addWidget(holder)
|
||||
return
|
||||
for concern in concerns:
|
||||
row = components.ConcernRow(concern)
|
||||
row.action_requested.connect(self._on_concern_action)
|
||||
self.concerns_layout.addWidget(row)
|
||||
|
||||
def _render_network(self, snapshot: dict) -> None:
|
||||
network = snapshot.get("network") or {}
|
||||
adapters = network.get("adapters") or []
|
||||
ips = network.get("local_ips") or []
|
||||
self.network_rows["hostname"].set_value(str(snapshot.get("hostname") or "—"))
|
||||
self.network_rows["ip"].set_value(", ".join(ips[:2]) if ips else "No IPv4 address")
|
||||
self.network_rows["gateway"].set_value(str(network.get("gateway") or "Not reported"))
|
||||
adapter_names = ", ".join(str(item.get("name") or "") for item in adapters[:2])
|
||||
self.network_rows["adapter"].set_value(adapter_names or "Not reported")
|
||||
self.network_rows["os"].set_value(str(snapshot.get("os") or "—"))
|
||||
self.network_rows["uptime"].set_value(str(snapshot.get("uptime") or "Unknown"))
|
||||
self.admin_pill.setText("Administrator" if snapshot.get("is_admin") else "Standard user")
|
||||
self.admin_pill.set_severity("good" if snapshot.get("is_admin") else "muted")
|
||||
|
||||
def _on_concern_action(self, action: str) -> None:
|
||||
if action == "settings":
|
||||
self.settings_requested.emit()
|
||||
elif action == "scan":
|
||||
self.scan_requested.emit()
|
||||
elif action == "quarantine":
|
||||
self.quarantine_requested.emit()
|
||||
elif action == "updates":
|
||||
self.updates_requested.emit()
|
||||
elif action == "update_definitions":
|
||||
self.definitions_requested.emit()
|
||||
|
||||
def _system_failed(self, message: str) -> None:
|
||||
self._concern_loading.setText(f"System status unavailable: {message}")
|
||||
self.checked_label.setText("Some system details could not be read")
|
||||
|
||||
def _worker_finished(self) -> None:
|
||||
self._worker = None
|
||||
|
||||
def refresh_activity(self) -> None:
|
||||
self._refresh_stats()
|
||||
self._refresh_activity()
|
||||
|
||||
def refresh_account(self) -> None:
|
||||
self._refresh_account()
|
||||
|
||||
def show_update(self, release_name: str, tag: str) -> None:
|
||||
self.update_banner_text.setText(f"{release_name or tag} is available. {tag}")
|
||||
self.update_banner.show()
|
||||
|
||||
def hide_update(self) -> None:
|
||||
self.update_banner.hide()
|
||||
|
||||
def stop(self) -> None:
|
||||
stop_thread(self._worker, wait_ms=5000)
|
||||
self._worker = None
|
||||
|
||||
|
||||
def _clear_layout(layout) -> None:
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
child = item.layout()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
elif child is not None:
|
||||
_clear_layout(child)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""BoltClam's sign-in dialog and its background auth workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from PyQt6.QtCore import QThread, Qt, QUrl, pyqtSignal
|
||||
from PyQt6.QtGui import QDesktopServices, QFont, QPixmap
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import paths
|
||||
from ..auth import AuthCancelled, AuthError, AuthService, AuthTimeout, Session
|
||||
from . import brand, theme
|
||||
from .threads import stop_thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserLoginWorker(QThread):
|
||||
url_ready = pyqtSignal(str)
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, auth: AuthService, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.auth = auth
|
||||
self._cancel = threading.Event()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancel.set()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
session = self.auth.browser_login(
|
||||
self._open_url, cancel_event=self._cancel)
|
||||
self.succeeded.emit(session)
|
||||
except AuthCancelled:
|
||||
self.failed.emit("Sign-in was cancelled.")
|
||||
except AuthTimeout as exc:
|
||||
self.failed.emit(str(exc))
|
||||
except AuthError as exc:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Browser sign-in failed")
|
||||
self.failed.emit(f"Sign-in failed: {exc}")
|
||||
|
||||
def _open_url(self, url: str) -> bool:
|
||||
self.url_ready.emit(url)
|
||||
return True
|
||||
|
||||
|
||||
class KeyLoginWorker(QThread):
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, auth: AuthService, api_key: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.auth = auth
|
||||
self.api_key = api_key
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
session: Session = self.auth.login_with_key(self.api_key)
|
||||
self.succeeded.emit(session)
|
||||
except AuthError as exc:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("API-key sign-in failed")
|
||||
self.failed.emit(f"Sign-in failed: {exc}")
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
def __init__(self, auth: AuthService, *, notice: str = "", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.auth = auth
|
||||
self.session: Session | None = None
|
||||
self._worker: QThread | None = None
|
||||
self._notice = notice
|
||||
self.setWindowTitle("BoltClam — Sign in")
|
||||
self.setWindowIcon(brand.app_icon())
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(830, 550)
|
||||
self.resize(880, 570)
|
||||
self._build()
|
||||
self._start_state()
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(18, 18, 18, 18)
|
||||
root.setSpacing(16)
|
||||
|
||||
left = QFrame()
|
||||
left.setObjectName("LoginBrand")
|
||||
left.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
left.setFixedWidth(332)
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(30, 30, 30, 26)
|
||||
left_layout.setSpacing(12)
|
||||
|
||||
mark = QLabel()
|
||||
mark.setPixmap(brand.pixmap(72))
|
||||
mark.setFixedSize(72, 72)
|
||||
left_layout.addWidget(mark)
|
||||
brand_name = QLabel("BoltClam")
|
||||
brand_name.setObjectName("BrandName")
|
||||
brand_name.setFont(theme.display_font(21, QFont.Weight.Bold))
|
||||
left_layout.addWidget(brand_name)
|
||||
tagline = QLabel("ClamAV protection, without the noise.")
|
||||
tagline.setObjectName("Muted")
|
||||
tagline.setWordWrap(True)
|
||||
left_layout.addWidget(tagline)
|
||||
left_layout.addSpacing(24)
|
||||
|
||||
headline = QLabel("A calmer way to know your PC is clean.")
|
||||
headline.setFont(theme.display_font(22, QFont.Weight.DemiBold))
|
||||
headline.setWordWrap(True)
|
||||
left_layout.addWidget(headline)
|
||||
left_layout.addSpacing(8)
|
||||
for title, detail in (
|
||||
("ClamAV under the hood", "Full-disk and user-home scans with a managed signature database."),
|
||||
("Downloads held for a scan", "New browser downloads are locked while ClamAV checks them."),
|
||||
("Quarantine, not guesswork", "Every finding is recorded and reversible until you delete it."),
|
||||
):
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(9)
|
||||
dot = QLabel("•")
|
||||
dot.setStyleSheet(f"color: {theme.AMBER}; font-size: 16px;")
|
||||
dot.setFixedWidth(12)
|
||||
row.addWidget(dot, 0, Qt.AlignmentFlag.AlignTop)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(1)
|
||||
label = QLabel(title)
|
||||
label.setFont(theme.body_font(9.8, QFont.Weight.DemiBold))
|
||||
desc = QLabel(detail)
|
||||
desc.setObjectName("Muted")
|
||||
desc.setFont(theme.body_font(8.7))
|
||||
desc.setWordWrap(True)
|
||||
text_box.addWidget(label)
|
||||
text_box.addWidget(desc)
|
||||
row.addLayout(text_box, 1)
|
||||
left_layout.addLayout(row)
|
||||
left_layout.addSpacing(4)
|
||||
left_layout.addStretch(1)
|
||||
footer = QLabel("The Majestic Network · Bolt")
|
||||
footer.setObjectName("Dim")
|
||||
left_layout.addWidget(footer)
|
||||
root.addWidget(left)
|
||||
|
||||
right = QFrame()
|
||||
right.setObjectName("LoginForm")
|
||||
right.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(34, 32, 34, 26)
|
||||
right_layout.setSpacing(9)
|
||||
title = QLabel("Sign in to BoltClam")
|
||||
title.setFont(theme.display_font(23, QFont.Weight.DemiBold))
|
||||
right_layout.addWidget(title)
|
||||
subtitle = QLabel("Use your Bolt account. Your password is sent only to Bolt's API — "
|
||||
"BoltClam receives a device key instead.")
|
||||
subtitle.setObjectName("Muted")
|
||||
subtitle.setWordWrap(True)
|
||||
right_layout.addWidget(subtitle)
|
||||
right_layout.addSpacing(14)
|
||||
|
||||
if self._notice:
|
||||
notice = QLabel(self._notice)
|
||||
notice.setWordWrap(True)
|
||||
notice.setStyleSheet(
|
||||
f"background: rgba(242,184,75,.1); border: 1px solid rgba(242,184,75,.35);"
|
||||
f"border-radius: 9px; padding: 9px 11px; color: {theme.AMBER_BRIGHT};")
|
||||
right_layout.addWidget(notice)
|
||||
|
||||
self.error_label = QLabel("")
|
||||
self.error_label.setWordWrap(True)
|
||||
self.error_label.setStyleSheet(f"color: {theme.DANGER}; font-size: 12.5px;")
|
||||
self.error_label.hide()
|
||||
right_layout.addWidget(self.error_label)
|
||||
|
||||
self.browser_button = QPushButton("Continue in browser")
|
||||
self.browser_button.setObjectName("Primary")
|
||||
self.browser_button.setMinimumHeight(42)
|
||||
self.browser_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.browser_button.clicked.connect(self._browser_login)
|
||||
right_layout.addWidget(self.browser_button)
|
||||
|
||||
self.key_toggle = QPushButton("Use an API key instead")
|
||||
self.key_toggle.setObjectName("Link")
|
||||
self.key_toggle.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.key_toggle.clicked.connect(self._toggle_key)
|
||||
right_layout.addWidget(self.key_toggle, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
self.key_box = QWidget()
|
||||
key_layout = QVBoxLayout(self.key_box)
|
||||
key_layout.setContentsMargins(0, 4, 0, 0)
|
||||
key_layout.setSpacing(7)
|
||||
key_label = QLabel("Bolt API key")
|
||||
key_label.setObjectName("Muted")
|
||||
key_layout.addWidget(key_label)
|
||||
self.key_input = QLineEdit()
|
||||
self.key_input.setPlaceholderText("bolt_live_…")
|
||||
self.key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.key_input.returnPressed.connect(self._key_login)
|
||||
key_layout.addWidget(self.key_input)
|
||||
self.key_button = QPushButton("Connect with key")
|
||||
self.key_button.setObjectName("Secondary")
|
||||
self.key_button.clicked.connect(self._key_login)
|
||||
key_layout.addWidget(self.key_button)
|
||||
self.key_box.hide()
|
||||
right_layout.addWidget(self.key_box)
|
||||
|
||||
status_row = QHBoxLayout()
|
||||
status_row.setSpacing(9)
|
||||
self.busy = QProgressBar()
|
||||
self.busy.setRange(0, 0)
|
||||
self.busy.setFixedHeight(5)
|
||||
self.busy.setFixedWidth(58)
|
||||
self.busy.hide()
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setObjectName("Muted")
|
||||
self.status_label.setWordWrap(True)
|
||||
status_row.addWidget(self.busy, 0)
|
||||
status_row.addWidget(self.status_label, 1)
|
||||
right_layout.addLayout(status_row)
|
||||
|
||||
right_layout.addStretch(1)
|
||||
actions = QHBoxLayout()
|
||||
create = QLabel(
|
||||
f'<a href="https://bolt.themajesticnetwork.com/account" '
|
||||
f'style="color:{theme.TEAL}; text-decoration:none;">Create or manage your account</a>')
|
||||
create.setOpenExternalLinks(True)
|
||||
create.setObjectName("Dim")
|
||||
actions.addWidget(create, 1)
|
||||
quit_button = QPushButton("Quit")
|
||||
quit_button.setObjectName("Ghost")
|
||||
quit_button.clicked.connect(self.reject)
|
||||
actions.addWidget(quit_button)
|
||||
right_layout.addLayout(actions)
|
||||
root.addWidget(right, 1)
|
||||
|
||||
# -- state -------------------------------------------------------------
|
||||
|
||||
def _start_state(self) -> None:
|
||||
self._set_busy(False)
|
||||
self.status_label.setText("BoltClam opens Bolt's sign-in page in your default browser.")
|
||||
self.browser_button.setFocus()
|
||||
|
||||
def _set_busy(self, busy: bool, status: str = "") -> None:
|
||||
self.browser_button.setEnabled(not busy)
|
||||
self.key_toggle.setEnabled(not busy)
|
||||
self.key_button.setEnabled(not busy)
|
||||
self.key_input.setEnabled(not busy)
|
||||
if busy:
|
||||
self.busy.show()
|
||||
self.status_label.setText(status)
|
||||
else:
|
||||
self.busy.hide()
|
||||
self.error_label.setVisible(bool(self.error_label.text()) and not busy)
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
self.error_label.setText(message)
|
||||
self.error_label.show()
|
||||
self._set_busy(False)
|
||||
self.status_label.setText("You can try again, or use an API key.")
|
||||
self.browser_button.setFocus()
|
||||
|
||||
def _toggle_key(self) -> None:
|
||||
visible = not self.key_box.isVisible()
|
||||
self.key_box.setVisible(visible)
|
||||
if visible:
|
||||
self.key_input.setFocus()
|
||||
self.key_toggle.setText("Use browser sign-in instead")
|
||||
else:
|
||||
self.key_toggle.setText("Use an API key instead")
|
||||
|
||||
# -- actions -----------------------------------------------------------
|
||||
|
||||
def _browser_login(self) -> None:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
return
|
||||
self.error_label.hide()
|
||||
self._set_busy(True, "Waiting for you to finish sign-in in the browser…")
|
||||
worker = BrowserLoginWorker(self.auth, self)
|
||||
worker.url_ready.connect(self._open_browser)
|
||||
worker.succeeded.connect(self._on_success)
|
||||
worker.failed.connect(self._on_failure)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._worker = worker
|
||||
worker.start()
|
||||
|
||||
def _open_browser(self, url: str) -> None:
|
||||
if not QDesktopServices.openUrl(QUrl(url)):
|
||||
if isinstance(self._worker, BrowserLoginWorker):
|
||||
self._worker.cancel()
|
||||
self._show_error("BoltClam could not open your default browser. "
|
||||
"Use an API key below, or open the sign-in page manually.")
|
||||
|
||||
def _key_login(self) -> None:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
return
|
||||
key = self.key_input.text().strip()
|
||||
if not key:
|
||||
self._show_error("Enter your Bolt API key.")
|
||||
return
|
||||
self.error_label.hide()
|
||||
self._set_busy(True, "Checking your API key…")
|
||||
worker = KeyLoginWorker(self.auth, key, self)
|
||||
worker.succeeded.connect(self._on_success)
|
||||
worker.failed.connect(self._on_failure)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_success(self, session: Session) -> None:
|
||||
self.session = session
|
||||
self._worker = None
|
||||
self._set_busy(False)
|
||||
self.accept()
|
||||
|
||||
def _on_failure(self, message: str) -> None:
|
||||
self._worker = None
|
||||
self._show_error(message)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def reject(self) -> None:
|
||||
worker = self._worker
|
||||
if worker is not None and worker.isRunning():
|
||||
if isinstance(worker, BrowserLoginWorker):
|
||||
worker.cancel()
|
||||
stop_thread(worker, wait_ms=2500)
|
||||
super().reject()
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self.reject()
|
||||
event.accept()
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Main window: navigation rail, page stack, tray, and service lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QSize, Qt, QThread, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QAction, QCloseEvent, QFont, QKeySequence, QShortcut
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..api_client import ApiError, NetworkError
|
||||
from ..context import AppContext
|
||||
from ..realtime import RealtimeProtection
|
||||
from . import brand, components, theme
|
||||
from .threads import stop_thread
|
||||
from .about_page import AboutPage
|
||||
from .home_page import HomePage
|
||||
from .scan_page import ScanPage
|
||||
from .settings_page import SettingsPage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountRefreshWorker(QThread):
|
||||
succeeded = pyqtSignal(object)
|
||||
expired = pyqtSignal()
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
api = self.context.api or self.context.rebuild_api()
|
||||
account = api.get_account()
|
||||
self.succeeded.emit(account)
|
||||
except ApiError as exc:
|
||||
if exc.status == 401:
|
||||
self.expired.emit()
|
||||
else:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Account refresh failed")
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class LogoutWorker(QThread):
|
||||
done = pyqtSignal()
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.context.auth.logout(self.context.session_key)
|
||||
except Exception:
|
||||
logger.exception("Logout request failed")
|
||||
self.context.auth.credentials.delete()
|
||||
self.context.auth.account_cache.clear()
|
||||
finally:
|
||||
self.done.emit()
|
||||
|
||||
|
||||
class MainWindow(QWidget):
|
||||
finished = pyqtSignal()
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self._allow_close = False
|
||||
self._cleaned = False
|
||||
self._logged_out = False
|
||||
self._account_worker: AccountRefreshWorker | None = None
|
||||
self._logout_worker: LogoutWorker | None = None
|
||||
self._last_update_tag = ""
|
||||
self._tray_notice_shown = False
|
||||
self._applied_settings = dataclasses.replace(context.settings)
|
||||
self.setWindowTitle("BoltClam")
|
||||
self.setWindowIcon(brand.app_icon())
|
||||
self.setMinimumSize(1180, 780)
|
||||
self.resize(1380, 880)
|
||||
self._build()
|
||||
self._build_tray()
|
||||
self._start_realtime()
|
||||
self._install_shortcuts()
|
||||
QTimer.singleShot(700, self._refresh_account)
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
root_layout = QHBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
root_layout.addWidget(self._build_sidebar())
|
||||
self.stack = QStackedWidget()
|
||||
self.stack.setObjectName("Content")
|
||||
self.home_page = HomePage(self.context)
|
||||
self.scan_page = ScanPage(self.context)
|
||||
self.about_page = AboutPage(self.context)
|
||||
self.settings_page = SettingsPage(self.context)
|
||||
for page in (self.home_page, self.scan_page, self.about_page, self.settings_page):
|
||||
self.stack.addWidget(page)
|
||||
root_layout.addWidget(self.stack, 1)
|
||||
self.toast = components.Toast(self)
|
||||
self._connect_pages()
|
||||
self._navigate(0)
|
||||
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
sidebar = QFrame()
|
||||
sidebar.setObjectName("Sidebar")
|
||||
sidebar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
sidebar.setFixedWidth(238)
|
||||
layout = QVBoxLayout(sidebar)
|
||||
layout.setContentsMargins(16, 20, 16, 16)
|
||||
layout.setSpacing(9)
|
||||
brand_row = QHBoxLayout()
|
||||
brand_row.setSpacing(10)
|
||||
mark = QLabel()
|
||||
mark.setPixmap(brand.pixmap(40))
|
||||
mark.setFixedSize(40, 40)
|
||||
brand_row.addWidget(mark, 0)
|
||||
brand_text = QVBoxLayout()
|
||||
brand_text.setSpacing(0)
|
||||
name = QLabel("BoltClam")
|
||||
name.setObjectName("BrandName")
|
||||
tag = QLabel("The Majestic Network")
|
||||
tag.setObjectName("BrandTag")
|
||||
brand_text.addWidget(name)
|
||||
brand_text.addWidget(tag)
|
||||
brand_row.addLayout(brand_text, 1)
|
||||
layout.addLayout(brand_row)
|
||||
layout.addSpacing(18)
|
||||
|
||||
self.nav_group = QButtonGroup(self)
|
||||
self.nav_group.setExclusive(True)
|
||||
self.nav_buttons = []
|
||||
for index, (icon, label) in enumerate((
|
||||
("home", "Homepage"),
|
||||
("scan", "Scan & results"),
|
||||
("updates", "About & updates"),
|
||||
("settings", "Settings"))):
|
||||
button = components.NavButton(icon, label)
|
||||
self.nav_group.addButton(button, index)
|
||||
self.nav_buttons.append(button)
|
||||
layout.addWidget(button)
|
||||
self.nav_group.idClicked.connect(self._navigate)
|
||||
layout.addStretch(1)
|
||||
|
||||
status_card = components.Card(margins=(13, 11, 13, 11), spacing=4)
|
||||
status_row = QHBoxLayout()
|
||||
status_row.setSpacing(7)
|
||||
self.side_pill = components.Pill("Starting", "muted")
|
||||
status_row.addWidget(self.side_pill, 0)
|
||||
status_row.addStretch(1)
|
||||
status_card.body.addLayout(status_row)
|
||||
self.side_detail = QLabel("Reading protection status…")
|
||||
self.side_detail.setObjectName("Muted")
|
||||
self.side_detail.setFont(theme.body_font(8.6))
|
||||
self.side_detail.setWordWrap(True)
|
||||
status_card.body.addWidget(self.side_detail)
|
||||
self.side_realtime = QLabel("Real-time watcher starting")
|
||||
self.side_realtime.setObjectName("Dim")
|
||||
self.side_realtime.setFont(theme.body_font(8.2))
|
||||
self.side_realtime.setWordWrap(True)
|
||||
status_card.body.addWidget(self.side_realtime)
|
||||
layout.addWidget(status_card)
|
||||
layout.addSpacing(6)
|
||||
|
||||
account_card = components.Card(margins=(13, 11, 13, 11), spacing=6)
|
||||
account_row = QHBoxLayout()
|
||||
account_row.setSpacing(9)
|
||||
self.side_avatar = components.Avatar(self.context.user_name, 34)
|
||||
account_row.addWidget(self.side_avatar, 0)
|
||||
account_text = QVBoxLayout()
|
||||
account_text.setSpacing(0)
|
||||
self.side_name = components.ElidedLabel(self.context.user_name)
|
||||
self.side_name.setFont(theme.body_font(9.6, QFont.Weight.DemiBold))
|
||||
self.side_email = components.ElidedLabel(self.context.user_email or "Bolt account")
|
||||
self.side_email.setObjectName("Muted")
|
||||
self.side_email.setFont(theme.body_font(8.0))
|
||||
account_text.addWidget(self.side_name)
|
||||
account_text.addWidget(self.side_email)
|
||||
account_row.addLayout(account_text, 1)
|
||||
account_card.body.addLayout(account_row)
|
||||
self.side_plan = components.Pill(self.context.plan_label, "info")
|
||||
account_card.body.addWidget(self.side_plan, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
layout.addWidget(account_card)
|
||||
version = QLabel(f"v{__version__}")
|
||||
version.setObjectName("Dim")
|
||||
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version)
|
||||
return sidebar
|
||||
|
||||
def _connect_pages(self) -> None:
|
||||
home = self.home_page
|
||||
home.scan_requested.connect(self._scan_from_home)
|
||||
home.settings_requested.connect(lambda: self._navigate(3))
|
||||
home.quarantine_requested.connect(self._show_quarantine)
|
||||
home.updates_requested.connect(lambda: self._navigate(2))
|
||||
home.definitions_requested.connect(self._update_definitions)
|
||||
home.snapshot_changed.connect(self._home_snapshot)
|
||||
|
||||
scan = self.scan_page
|
||||
scan.scan_finished.connect(self._scan_finished)
|
||||
scan.settings_requested.connect(lambda: self._navigate(3))
|
||||
scan.toast_requested.connect(self._toast)
|
||||
scan.definitions_updated.connect(lambda: self.home_page.refresh(force=True))
|
||||
|
||||
about = self.about_page
|
||||
about.toast_requested.connect(self._toast)
|
||||
about.update_found.connect(self._update_found)
|
||||
|
||||
settings = self.settings_page
|
||||
settings.toast_requested.connect(self._toast)
|
||||
settings.settings_saved.connect(self._settings_saved)
|
||||
settings.sign_out_requested.connect(self._logout)
|
||||
settings.definitions_requested.connect(self._update_definitions)
|
||||
|
||||
def _build_tray(self) -> None:
|
||||
self.tray: QSystemTrayIcon | None = None
|
||||
self._tray_menu: QMenu | None = None
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
return
|
||||
tray = QSystemTrayIcon(brand.app_icon(), self)
|
||||
tray.setToolTip("BoltClam — ClamAV protection")
|
||||
menu = QMenu(self)
|
||||
open_action = QAction("Open BoltClam", self)
|
||||
open_action.triggered.connect(self._show_window)
|
||||
scan_action = QAction("Run a scan", self)
|
||||
scan_action.triggered.connect(self._scan_from_tray)
|
||||
quit_action = QAction("Quit", self)
|
||||
quit_action.triggered.connect(self._quit)
|
||||
menu.addAction(open_action)
|
||||
menu.addAction(scan_action)
|
||||
menu.addSeparator()
|
||||
menu.addAction(quit_action)
|
||||
tray.setContextMenu(menu)
|
||||
tray.activated.connect(self._tray_activated)
|
||||
tray.messageClicked.connect(self._show_window)
|
||||
tray.show()
|
||||
self.tray = tray
|
||||
self._tray_menu = menu
|
||||
|
||||
def _install_shortcuts(self) -> None:
|
||||
for index in range(4):
|
||||
shortcut = QShortcut(QKeySequence(f"Ctrl+{index + 1}"), self)
|
||||
shortcut.activated.connect(lambda index=index: self._navigate(index))
|
||||
quit_shortcut = QShortcut(QKeySequence("Ctrl+Q"), self)
|
||||
quit_shortcut.activated.connect(self._quit)
|
||||
scan_shortcut = QShortcut(QKeySequence("Ctrl+R"), self)
|
||||
scan_shortcut.activated.connect(self._scan_from_home)
|
||||
|
||||
# -- real-time ---------------------------------------------------------
|
||||
|
||||
def _start_realtime(self) -> None:
|
||||
realtime = RealtimeProtection(
|
||||
self.context.clamav, self.context.storage, self.context.quarantine,
|
||||
self.context.settings, self)
|
||||
self.context.realtime = realtime
|
||||
realtime.status_changed.connect(self._realtime_status)
|
||||
realtime.event_created.connect(self._realtime_event)
|
||||
realtime.alert.connect(self._realtime_alert)
|
||||
realtime.start()
|
||||
|
||||
def _restart_realtime(self) -> None:
|
||||
realtime = self.context.realtime
|
||||
if realtime is None:
|
||||
return
|
||||
realtime.set_settings(self.context.settings)
|
||||
realtime.stop()
|
||||
realtime.start()
|
||||
self.settings_page.refresh_account()
|
||||
|
||||
def _realtime_status(self, text: str) -> None:
|
||||
self.side_realtime.setText(text)
|
||||
|
||||
def _realtime_event(self, event) -> None:
|
||||
self.home_page.refresh_activity()
|
||||
self.scan_page.refresh_quarantine()
|
||||
|
||||
def _realtime_alert(self, title: str, body: str, severity: str) -> None:
|
||||
if not self.context.settings.notify_on_detection:
|
||||
return
|
||||
self._notify(title, body, severity)
|
||||
|
||||
# -- navigation --------------------------------------------------------
|
||||
|
||||
def _navigate(self, index: int) -> None:
|
||||
index = max(0, min(3, int(index)))
|
||||
self.stack.setCurrentIndex(index)
|
||||
if 0 <= index < len(self.nav_buttons):
|
||||
self.nav_buttons[index].setChecked(True)
|
||||
if index == 0:
|
||||
self.home_page.refresh()
|
||||
elif index == 1:
|
||||
self.scan_page.refresh()
|
||||
elif index == 2:
|
||||
self.about_page.refresh()
|
||||
|
||||
def _show_quarantine(self) -> None:
|
||||
self._navigate(1)
|
||||
self.scan_page.tabs.setCurrentIndex(1)
|
||||
self.scan_page.refresh_quarantine()
|
||||
|
||||
def _scan_from_home(self) -> None:
|
||||
self._navigate(1)
|
||||
QTimer.singleShot(120, self.scan_page.start_scan)
|
||||
|
||||
def _scan_from_tray(self) -> None:
|
||||
self._show_window()
|
||||
self._scan_from_home()
|
||||
|
||||
def _update_definitions(self) -> None:
|
||||
self._navigate(1)
|
||||
QTimer.singleShot(120, self.scan_page.update_definitions)
|
||||
|
||||
# -- events ------------------------------------------------------------
|
||||
|
||||
def _home_snapshot(self, snapshot: dict) -> None:
|
||||
overall = str(snapshot.get("overall") or "setup")
|
||||
severity = {"protected": "good", "attention": "warning",
|
||||
"at_risk": "critical", "setup": "info"}.get(overall, "muted")
|
||||
label = {"protected": "Protected", "attention": "Needs attention",
|
||||
"at_risk": "At risk", "setup": "Setup needed"}.get(overall, "Checking")
|
||||
self.side_pill.setText(label)
|
||||
self.side_pill.set_severity(severity)
|
||||
concerns = snapshot.get("concerns") or []
|
||||
self.side_detail.setText(
|
||||
str(concerns[0].get("title")) if concerns else "No action needed")
|
||||
|
||||
def _scan_finished(self) -> None:
|
||||
self.home_page.refresh(force=True)
|
||||
self.settings_page.refresh_account()
|
||||
|
||||
def _update_found(self, release) -> None:
|
||||
if release is None:
|
||||
self.home_page.hide_update()
|
||||
self._last_update_tag = ""
|
||||
return
|
||||
self.home_page.show_update(release.name, release.tag)
|
||||
if release.tag and release.tag != self._last_update_tag:
|
||||
self._last_update_tag = release.tag
|
||||
self._notify("BoltClam update available",
|
||||
f"{release.name or release.tag} is ready in About & updates.",
|
||||
"info")
|
||||
|
||||
def _refresh_account(self) -> None:
|
||||
if self._account_worker is not None and self._account_worker.isRunning():
|
||||
return
|
||||
worker = AccountRefreshWorker(self.context, self)
|
||||
worker.succeeded.connect(self._account_succeeded)
|
||||
worker.expired.connect(self._account_expired)
|
||||
worker.failed.connect(self._account_failed)
|
||||
worker.finished.connect(self._account_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._account_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _account_succeeded(self, account: dict) -> None:
|
||||
self.context.account = account
|
||||
self.context.offline = False
|
||||
try:
|
||||
self.context.auth.account_cache.save(account)
|
||||
except Exception:
|
||||
logger.exception("Could not cache the account summary")
|
||||
self.home_page.refresh_account()
|
||||
self.settings_page.refresh_account()
|
||||
self.about_page.refresh_account()
|
||||
self._update_sidebar_account()
|
||||
|
||||
def _account_failed(self, message: str) -> None:
|
||||
if self.context.account is None:
|
||||
self._toast("Account details unavailable", message, "warning")
|
||||
logger.info("Account refresh failed: %s", message)
|
||||
|
||||
def _account_expired(self) -> None:
|
||||
self._toast("Your BoltClam session expired", "Sign in again to continue.", "critical")
|
||||
self._logout(expired=True)
|
||||
|
||||
def _account_finished(self) -> None:
|
||||
self._account_worker = None
|
||||
|
||||
def _update_sidebar_account(self) -> None:
|
||||
self.side_avatar.set_name(self.context.user_name)
|
||||
self.side_name.setText(self.context.user_name)
|
||||
self.side_email.setText(self.context.user_email or "Bolt account")
|
||||
self.side_plan.setText(self.context.plan_label)
|
||||
self.side_plan.set_severity("good" if self.context.licensed else "muted")
|
||||
|
||||
def _settings_saved(self, settings) -> None:
|
||||
previous = self._applied_settings
|
||||
realtime_fields = ("realtime_enabled", "extra_watch_dirs", "clamav_path",
|
||||
"auto_action")
|
||||
realtime_changed = any(getattr(previous, field) != getattr(settings, field)
|
||||
for field in realtime_fields)
|
||||
self._applied_settings = dataclasses.replace(settings)
|
||||
self.scan_page.reload_settings()
|
||||
self.about_page.reload_settings()
|
||||
self.home_page.refresh(force=True)
|
||||
self.settings_page.reload_settings()
|
||||
self.settings_page.refresh_account()
|
||||
if realtime_changed:
|
||||
QTimer.singleShot(0, self._restart_realtime)
|
||||
|
||||
def _logout(self, expired: bool = False) -> None:
|
||||
if self._logout_worker is not None and self._logout_worker.isRunning():
|
||||
return
|
||||
if not expired:
|
||||
self._toast("Signing out", "Revoking this PC's BoltClam key…", "info")
|
||||
worker = LogoutWorker(self.context, self)
|
||||
worker.done.connect(self._logout_done)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._logout_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _logout_done(self) -> None:
|
||||
self._logout_worker = None
|
||||
self._logged_out = True
|
||||
self._allow_close = True
|
||||
self.close()
|
||||
|
||||
def _toast(self, title: str, body: str, severity: str = "info") -> None:
|
||||
self.toast.show_message(title, body, severity)
|
||||
if severity in ("critical", "error") and self.tray is not None:
|
||||
self._notify(title, body, severity)
|
||||
|
||||
def _notify(self, title: str, body: str, severity: str = "info") -> None:
|
||||
if self.tray is not None and self.tray.isVisible():
|
||||
icon = QSystemTrayIcon.MessageIcon.Warning
|
||||
if severity in ("good", "info"):
|
||||
icon = QSystemTrayIcon.MessageIcon.Information
|
||||
elif severity in ("critical", "error"):
|
||||
icon = QSystemTrayIcon.MessageIcon.Critical
|
||||
self.tray.showMessage(title, body, icon, 7000)
|
||||
if not self.isVisible():
|
||||
QApplication.alert(self, 2500)
|
||||
elif severity in ("critical", "error"):
|
||||
QApplication.alert(self, 2000)
|
||||
|
||||
def _tray_activated(self, reason) -> None:
|
||||
if reason in (QSystemTrayIcon.ActivationReason.Trigger,
|
||||
QSystemTrayIcon.ActivationReason.DoubleClick):
|
||||
self._show_window()
|
||||
|
||||
def _show_window(self) -> None:
|
||||
self.showNormal()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "toast") and self.toast.isVisible():
|
||||
self.toast.move(max(20, self.width() - self.toast.width() - 26),
|
||||
max(20, self.height() - self.toast.height() - 26))
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
if not self._allow_close and self.context.settings.close_to_tray and self.tray is not None:
|
||||
self.hide()
|
||||
if not self._tray_notice_shown:
|
||||
self._tray_notice_shown = True
|
||||
self.tray.showMessage(
|
||||
"BoltClam is still protecting this PC",
|
||||
"Real-time download scanning stays active in the tray. Use Quit to stop it.",
|
||||
QSystemTrayIcon.MessageIcon.Information, 6000)
|
||||
event.ignore()
|
||||
return
|
||||
self._cleanup()
|
||||
event.accept()
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if self._cleaned:
|
||||
return
|
||||
self._cleaned = True
|
||||
try:
|
||||
if self.context.realtime is not None:
|
||||
self.context.realtime.stop()
|
||||
self.home_page.stop()
|
||||
self.scan_page.stop()
|
||||
self.about_page.stop()
|
||||
self.settings_page.stop()
|
||||
stop_thread(self._account_worker, wait_ms=3500)
|
||||
stop_thread(self._logout_worker, wait_ms=3500)
|
||||
if self.tray is not None:
|
||||
self.tray.hide()
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
def _quit(self) -> None:
|
||||
self._allow_close = True
|
||||
self.close()
|
||||
|
||||
def logged_out(self) -> bool:
|
||||
return self._logged_out
|
||||
@@ -0,0 +1,912 @@
|
||||
"""Scan & results: scope choice, live scan console, history and quarantine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker, QSize, Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..context import AppContext
|
||||
from ..models import Detection, display_name, human_action, human_scope
|
||||
from ..quarantine import QuarantineError, delete_file
|
||||
from ..scanner import DefinitionWorker, ScanWorker
|
||||
from . import components, theme
|
||||
from .threads import stop_thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScanHistoryItem(QWidget):
|
||||
def __init__(self, scan, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumHeight(64)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 7, 10, 7)
|
||||
layout.setSpacing(3)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(8)
|
||||
when = QLabel(theme.format_datetime(scan.started_at))
|
||||
when.setFont(theme.body_font(9.3, QFont.Weight.DemiBold))
|
||||
top.addWidget(when, 1)
|
||||
status = components.Pill(scan.status.replace("_", " ").title(),
|
||||
"good" if scan.status == "completed" else
|
||||
"warning" if scan.status == "completed_with_errors" else
|
||||
"critical" if scan.status == "failed" else "muted")
|
||||
top.addWidget(status, 0)
|
||||
layout.addLayout(top)
|
||||
bottom = QHBoxLayout()
|
||||
bottom.setSpacing(8)
|
||||
scope = QLabel(human_scope(scan.scope))
|
||||
scope.setObjectName("Muted")
|
||||
scope.setFont(theme.body_font(8.5))
|
||||
bottom.addWidget(scope)
|
||||
bottom.addStretch(1)
|
||||
threats = QLabel(f"{scan.threat_count} threat(s)" if scan.threat_count else "Clean")
|
||||
threats.setFont(theme.body_font(8.5, QFont.Weight.DemiBold))
|
||||
threats.setStyleSheet(f"color: {theme.DANGER if scan.threat_count else theme.TEAL};")
|
||||
bottom.addWidget(threats)
|
||||
layout.addLayout(bottom)
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(260, 68)
|
||||
|
||||
|
||||
class PillsTab(QWidget):
|
||||
"""Two pill-shaped tabs over a stack, styled entirely by BoltClam."""
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self._buttons: list[QPushButton] = []
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
bar = QHBoxLayout()
|
||||
bar.setContentsMargins(0, 0, 0, 0)
|
||||
bar.setSpacing(4)
|
||||
self._bar = bar
|
||||
self._bar_holder = QWidget()
|
||||
self._bar_holder.setLayout(bar)
|
||||
layout.addWidget(self._bar_holder)
|
||||
layout.addSpacing(7)
|
||||
layout.addWidget(components.Divider())
|
||||
self._stack = QStackedWidget()
|
||||
layout.addWidget(self._stack, 1)
|
||||
self._group.idClicked.connect(self.setCurrentIndex)
|
||||
self._bar.addStretch(1)
|
||||
|
||||
def add_widget(self, widget: QWidget, label: str) -> None:
|
||||
index = self._stack.count()
|
||||
button = QPushButton(label)
|
||||
button.setObjectName("TabPill")
|
||||
button.setCheckable(True)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._group.addButton(button, index)
|
||||
self._buttons.append(button)
|
||||
self._bar.insertWidget(self._bar.count() - 1, button)
|
||||
self._stack.addWidget(widget)
|
||||
if index == 0:
|
||||
button.setChecked(True)
|
||||
|
||||
def setCurrentIndex(self, index: int) -> None: # noqa: N802
|
||||
index = max(0, min(self._stack.count() - 1, int(index)))
|
||||
self._stack.setCurrentIndex(index)
|
||||
if 0 <= index < len(self._buttons):
|
||||
self._buttons[index].setChecked(True)
|
||||
|
||||
def currentIndex(self) -> int: # noqa: N802
|
||||
return self._stack.currentIndex()
|
||||
|
||||
|
||||
class ScanPage(QWidget):
|
||||
scan_finished = pyqtSignal()
|
||||
settings_requested = pyqtSignal()
|
||||
toast_requested = pyqtSignal(str, str, str)
|
||||
definitions_updated = pyqtSignal()
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self._worker: ScanWorker | None = None
|
||||
self._definition_worker: DefinitionWorker | None = None
|
||||
self._current_scan_id: int | None = None
|
||||
self._build()
|
||||
self.reload_settings()
|
||||
self.refresh()
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(30, 24, 30, 30)
|
||||
outer.setSpacing(15)
|
||||
|
||||
header = components.PageHeader(
|
||||
"Scan & results", "Scan every user's home or the full disk, then act on what ClamAV finds.")
|
||||
self.definitions_button = QPushButton("Update definitions")
|
||||
self.definitions_button.setObjectName("Secondary")
|
||||
self.definitions_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.definitions_button.clicked.connect(self.update_definitions)
|
||||
header.add_action(self.definitions_button)
|
||||
outer.addWidget(header)
|
||||
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(15)
|
||||
top.addWidget(self._build_console(), 2)
|
||||
top.addWidget(self._build_scope_card(), 1)
|
||||
outer.addLayout(top)
|
||||
|
||||
self.tabs = PillsTab()
|
||||
self.tabs.add_widget(self._build_results_tab(), "Results")
|
||||
self.tabs.add_widget(self._build_quarantine_tab(), "Quarantine")
|
||||
outer.addWidget(self.tabs, 1)
|
||||
|
||||
def _build_console(self) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setObjectName("ScanConsole")
|
||||
card.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
layout = QHBoxLayout(card)
|
||||
layout.setContentsMargins(22, 20, 22, 20)
|
||||
layout.setSpacing(22)
|
||||
self.scan_button = components.ScanButton()
|
||||
self.scan_button.clicked.connect(self.start_scan)
|
||||
layout.addWidget(self.scan_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(6)
|
||||
self.scan_state = QLabel("Ready to scan")
|
||||
self.scan_state.setFont(theme.display_font(20, QFont.Weight.DemiBold))
|
||||
right.addWidget(self.scan_state)
|
||||
self.scan_detail = QLabel(
|
||||
"Choose a scope on the right. User-home scans cover every profile; full-disk "
|
||||
"scans cover every fixed drive.")
|
||||
self.scan_detail.setObjectName("Muted")
|
||||
self.scan_detail.setWordWrap(True)
|
||||
right.addWidget(self.scan_detail)
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0)
|
||||
self.progress.setFixedHeight(6)
|
||||
self.progress.hide()
|
||||
right.addWidget(self.progress)
|
||||
self.current_file = components.ElidedLabel("")
|
||||
self.current_file.setObjectName("PathLabel")
|
||||
self.current_file.setFont(theme.mono_font(8.7))
|
||||
self.current_file.hide()
|
||||
right.addWidget(self.current_file)
|
||||
self.last_scan_label = QLabel("")
|
||||
self.last_scan_label.setObjectName("Dim")
|
||||
right.addWidget(self.last_scan_label)
|
||||
self.engine_line = QLabel("")
|
||||
self.engine_line.setObjectName("Muted")
|
||||
self.engine_line.setFont(theme.body_font(8.7))
|
||||
self.engine_line.setWordWrap(True)
|
||||
right.addWidget(self.engine_line)
|
||||
right.addStretch(1)
|
||||
right.addWidget(components.Divider())
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(8)
|
||||
self.start_button = QPushButton("Start scan")
|
||||
self.start_button.setObjectName("Primary")
|
||||
self.start_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.start_button.clicked.connect(self.start_scan)
|
||||
actions.addWidget(self.start_button)
|
||||
self.cancel_button = QPushButton("Cancel scan")
|
||||
self.cancel_button.setObjectName("Danger")
|
||||
self.cancel_button.clicked.connect(self.cancel_scan)
|
||||
self.cancel_button.hide()
|
||||
actions.addWidget(self.cancel_button)
|
||||
actions.addStretch(1)
|
||||
self.scan_scope_label = QLabel("")
|
||||
self.scan_scope_label.setObjectName("Dim")
|
||||
actions.addWidget(self.scan_scope_label)
|
||||
right.addLayout(actions)
|
||||
layout.addLayout(right, 1)
|
||||
return card
|
||||
|
||||
def _build_scope_card(self) -> QWidget:
|
||||
card = components.Card(margins=(16, 16, 16, 16))
|
||||
title = QLabel("Scan scope")
|
||||
title.setObjectName("CardTitle")
|
||||
card.body.addWidget(title)
|
||||
subtitle = QLabel("What BoltClam hands to ClamAV.")
|
||||
subtitle.setObjectName("Muted")
|
||||
subtitle.setFont(theme.body_font(8.7))
|
||||
card.body.addWidget(subtitle)
|
||||
self.homes_radio = components.ChoiceTile(
|
||||
"User homes", "Every profile under C:\\Users, including the current user and Public.")
|
||||
self.full_radio = components.ChoiceTile(
|
||||
"Full disk", "Every fixed local drive. More complete, and potentially hours long.")
|
||||
card.body.addWidget(self.homes_radio)
|
||||
card.body.addWidget(self.full_radio)
|
||||
self.homes_radio.toggled.connect(self._scope_changed)
|
||||
card.body.addSpacing(4)
|
||||
auto_label = QLabel("When something is found")
|
||||
auto_label.setObjectName("Muted")
|
||||
auto_label.setFont(theme.body_font(8.8, QFont.Weight.DemiBold))
|
||||
card.body.addWidget(auto_label)
|
||||
self.auto_combo = QComboBox()
|
||||
self.auto_combo.addItem("Quarantine (recommended)", "quarantine")
|
||||
self.auto_combo.addItem("Delete permanently", "delete")
|
||||
self.auto_combo.addItem("Notify only", "notify")
|
||||
self.auto_combo.currentIndexChanged.connect(self._auto_action_changed)
|
||||
card.body.addWidget(self.auto_combo)
|
||||
self.auto_note = QLabel("")
|
||||
self.auto_note.setObjectName("Dim")
|
||||
self.auto_note.setWordWrap(True)
|
||||
card.body.addWidget(self.auto_note)
|
||||
card.body.addStretch(1)
|
||||
return card
|
||||
|
||||
def _build_results_tab(self) -> QWidget:
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
left = components.Card(margins=(12, 12, 12, 12))
|
||||
header = QLabel("Scan history")
|
||||
header.setObjectName("CardTitle")
|
||||
left.body.addWidget(header)
|
||||
self.history = QListWidget()
|
||||
self.history.setSpacing(2)
|
||||
self.history.currentItemChanged.connect(self._history_selected)
|
||||
left.body.addWidget(self.history, 1)
|
||||
splitter.addWidget(left)
|
||||
|
||||
right = components.Card(margins=(16, 14, 16, 14))
|
||||
self.detail_stack = QStackedWidget()
|
||||
self.detail_empty = components.EmptyState(
|
||||
"No scan selected",
|
||||
"Run a scan or choose one from the history to see detections and remediation.",
|
||||
"Run a scan")
|
||||
self.detail_empty.action_clicked.connect(self.start_scan)
|
||||
self.detail_stack.addWidget(self.detail_empty)
|
||||
details = QWidget()
|
||||
details_layout = QVBoxLayout(details)
|
||||
details_layout.setContentsMargins(0, 0, 0, 0)
|
||||
details_layout.setSpacing(9)
|
||||
top = QHBoxLayout()
|
||||
self.detail_title = QLabel("Scan details")
|
||||
self.detail_title.setObjectName("CardTitle")
|
||||
top.addWidget(self.detail_title, 1)
|
||||
self.detail_status = components.Pill("—", "muted")
|
||||
top.addWidget(self.detail_status, 0)
|
||||
details_layout.addLayout(top)
|
||||
self.detail_meta = QLabel("")
|
||||
self.detail_meta.setObjectName("Muted")
|
||||
self.detail_meta.setWordWrap(True)
|
||||
details_layout.addWidget(self.detail_meta)
|
||||
self.detail_summary = QLabel("")
|
||||
self.detail_summary.setObjectName("Dim")
|
||||
details_layout.addWidget(self.detail_summary)
|
||||
self.detection_table = QTableWidget(0, 3)
|
||||
self.detection_table.setHorizontalHeaderLabels(["File", "Signature", "Action"])
|
||||
self.detection_table.verticalHeader().setVisible(False)
|
||||
self.detection_table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.detection_table.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.detection_table.setEditTriggers(
|
||||
QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.detection_table.setShowGrid(False)
|
||||
self.detection_table.setAlternatingRowColors(False)
|
||||
self.detection_table.verticalHeader().setDefaultSectionSize(36)
|
||||
header_view = self.detection_table.horizontalHeader()
|
||||
header_view.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
header_view.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header_view.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.detection_table.itemSelectionChanged.connect(self._update_detection_buttons)
|
||||
details_layout.addWidget(self.detection_table, 1)
|
||||
buttons = QHBoxLayout()
|
||||
buttons.setSpacing(8)
|
||||
self.quarantine_button = QPushButton("Quarantine selected")
|
||||
self.quarantine_button.setObjectName("Secondary")
|
||||
self.quarantine_button.clicked.connect(self.quarantine_selected)
|
||||
self.delete_button = QPushButton("Delete selected")
|
||||
self.delete_button.setObjectName("Danger")
|
||||
self.delete_button.clicked.connect(self.delete_selected)
|
||||
self.ignore_button = QPushButton("Ignore")
|
||||
self.ignore_button.setObjectName("Ghost")
|
||||
self.ignore_button.clicked.connect(self.ignore_selected)
|
||||
buttons.addWidget(self.quarantine_button)
|
||||
buttons.addWidget(self.delete_button)
|
||||
buttons.addWidget(self.ignore_button)
|
||||
buttons.addStretch(1)
|
||||
details_layout.addLayout(buttons)
|
||||
self.detail_stack.addWidget(details)
|
||||
right.body.addWidget(self.detail_stack, 1)
|
||||
splitter.addWidget(right)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 3)
|
||||
splitter.setSizes([300, 760])
|
||||
return splitter
|
||||
|
||||
def _build_quarantine_tab(self) -> QWidget:
|
||||
card = components.Card(margins=(16, 14, 16, 14))
|
||||
top = QHBoxLayout()
|
||||
title = QLabel("Quarantine")
|
||||
title.setObjectName("CardTitle")
|
||||
top.addWidget(title, 1)
|
||||
open_button = QPushButton("Open folder")
|
||||
open_button.setObjectName("Ghost")
|
||||
open_button.clicked.connect(self.open_quarantine_folder)
|
||||
top.addWidget(open_button)
|
||||
card.body.addLayout(top)
|
||||
subtitle = QLabel("Held files are renamed so they cannot be opened normally. "
|
||||
"Restore only if you trust the file.")
|
||||
subtitle.setObjectName("Muted")
|
||||
subtitle.setWordWrap(True)
|
||||
card.body.addWidget(subtitle)
|
||||
self.quarantine_stack = QStackedWidget()
|
||||
empty = components.EmptyState(
|
||||
"Quarantine is empty",
|
||||
"Files that BoltClam quarantines will appear here, with restore and delete options.")
|
||||
self.quarantine_stack.addWidget(empty)
|
||||
table_holder = QWidget()
|
||||
holder_layout = QVBoxLayout(table_holder)
|
||||
holder_layout.setContentsMargins(0, 0, 0, 0)
|
||||
holder_layout.setSpacing(8)
|
||||
self.quarantine_table = QTableWidget(0, 4)
|
||||
self.quarantine_table.setHorizontalHeaderLabels(
|
||||
["Original file", "Signature", "Quarantined", "Status"])
|
||||
self.quarantine_table.verticalHeader().setVisible(False)
|
||||
self.quarantine_table.setSelectionBehavior(
|
||||
QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.quarantine_table.setSelectionMode(
|
||||
QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.quarantine_table.setEditTriggers(
|
||||
QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.quarantine_table.setShowGrid(False)
|
||||
self.quarantine_table.verticalHeader().setDefaultSectionSize(36)
|
||||
qheader = self.quarantine_table.horizontalHeader()
|
||||
qheader.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
qheader.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
qheader.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
qheader.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.quarantine_table.itemSelectionChanged.connect(self._update_quarantine_buttons)
|
||||
holder_layout.addWidget(self.quarantine_table, 1)
|
||||
buttons = QHBoxLayout()
|
||||
self.restore_button = QPushButton("Restore selected")
|
||||
self.restore_button.setObjectName("Secondary")
|
||||
self.restore_button.clicked.connect(self.restore_selected)
|
||||
self.purge_button = QPushButton("Delete permanently")
|
||||
self.purge_button.setObjectName("Danger")
|
||||
self.purge_button.clicked.connect(self.delete_quarantined_selected)
|
||||
buttons.addWidget(self.restore_button)
|
||||
buttons.addWidget(self.purge_button)
|
||||
buttons.addStretch(1)
|
||||
holder_layout.addLayout(buttons)
|
||||
self.quarantine_stack.addWidget(table_holder)
|
||||
card.body.addWidget(self.quarantine_stack, 1)
|
||||
return card
|
||||
|
||||
# -- settings ----------------------------------------------------------
|
||||
|
||||
def reload_settings(self) -> None:
|
||||
settings = self.context.settings
|
||||
with QSignalBlocker(self.homes_radio), QSignalBlocker(self.full_radio), \
|
||||
QSignalBlocker(self.auto_combo):
|
||||
(self.full_radio if settings.scan_scope == "full" else self.homes_radio).setChecked(True)
|
||||
index = self.auto_combo.findData(settings.auto_action)
|
||||
self.auto_combo.setCurrentIndex(index if index >= 0 else 0)
|
||||
self._update_auto_note(settings.auto_action)
|
||||
self._update_scope_note()
|
||||
|
||||
def _scope_changed(self, checked: bool) -> None:
|
||||
if not checked:
|
||||
return
|
||||
scope = "full" if self.full_radio.isChecked() else "homes"
|
||||
try:
|
||||
self.context.settings = self.context.settings_store.update(scan_scope=scope)
|
||||
if self.context.realtime is not None:
|
||||
self.context.realtime.set_settings(self.context.settings)
|
||||
except Exception:
|
||||
logger.exception("Could not save scan scope")
|
||||
self._update_scope_note()
|
||||
|
||||
def _auto_action_changed(self) -> None:
|
||||
value = self.auto_combo.currentData() or "quarantine"
|
||||
try:
|
||||
self.context.settings = self.context.settings_store.update(auto_action=value)
|
||||
if self.context.realtime is not None:
|
||||
self.context.realtime.set_settings(self.context.settings)
|
||||
except Exception:
|
||||
logger.exception("Could not save auto action")
|
||||
self._update_auto_note(value)
|
||||
|
||||
def _update_auto_note(self, action: str) -> None:
|
||||
if action == "delete":
|
||||
self.auto_note.setText("Delete removes the original immediately. Quarantine is safer.")
|
||||
self.auto_note.setStyleSheet(f"color: {theme.DANGER};")
|
||||
elif action == "notify":
|
||||
self.auto_note.setText("Notify leaves the file in place and marks it for review.")
|
||||
self.auto_note.setStyleSheet(f"color: {theme.AMBER};")
|
||||
else:
|
||||
self.auto_note.setText("Files are moved to quarantine and can be restored from the Quarantine tab.")
|
||||
self.auto_note.setStyleSheet(f"color: {theme.DIM};")
|
||||
|
||||
def _update_scope_note(self) -> None:
|
||||
if not hasattr(self, "scan_scope_label"):
|
||||
return
|
||||
scope = "full" if self.full_radio.isChecked() else "homes"
|
||||
self.scan_scope_label.setText(f"Scope: {human_scope(scope)}")
|
||||
self._refresh_engine_line()
|
||||
|
||||
def _refresh_engine_line(self) -> None:
|
||||
if not hasattr(self, "engine_line"):
|
||||
return
|
||||
info = self.context.clamav.info()
|
||||
if not info.available:
|
||||
self.engine_line.setText("ClamAV is not configured. Set its folder in Settings.")
|
||||
self.engine_line.setStyleSheet(f"color: {theme.AMBER};")
|
||||
elif info.db_path:
|
||||
self.engine_line.setText(
|
||||
f"Engine {info.engine_version or 'ClamAV'} · signatures "
|
||||
f"{info.db_version or 'unknown'} · definitions {info.db_date or 'unknown'}")
|
||||
self.engine_line.setStyleSheet(f"color: {theme.MUTED};")
|
||||
else:
|
||||
self.engine_line.setText(
|
||||
f"Engine {info.engine_version or 'ClamAV'} · no signature database yet — "
|
||||
"run Update definitions.")
|
||||
self.engine_line.setStyleSheet(f"color: {theme.AMBER};")
|
||||
|
||||
# -- scan lifecycle ----------------------------------------------------
|
||||
|
||||
def start_scan(self) -> None:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
return
|
||||
if not self.context.clamav.info().available:
|
||||
answer = QMessageBox.question(
|
||||
self, "ClamAV is not configured",
|
||||
"BoltClam needs ClamAV's clamscan.exe before it can scan.\n\nOpen Settings now?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes)
|
||||
if answer == QMessageBox.StandardButton.Yes:
|
||||
self.settings_requested.emit()
|
||||
return
|
||||
scope = "full" if self.full_radio.isChecked() else "homes"
|
||||
try:
|
||||
self.context.settings = self.context.settings_store.update(scan_scope=scope)
|
||||
except Exception:
|
||||
logger.exception("Could not save scan scope")
|
||||
self._set_scanning(True, scope)
|
||||
worker = ScanWorker(self.context.clamav, self.context.storage,
|
||||
self.context.quarantine, self.context.settings, scope, self)
|
||||
worker.started_scan.connect(self._on_scan_started)
|
||||
worker.progress.connect(self._on_progress)
|
||||
worker.detection_found.connect(self._on_detection)
|
||||
worker.log_line.connect(self._on_log)
|
||||
worker.status.connect(self._on_status)
|
||||
worker.finished_scan.connect(self._on_finished)
|
||||
worker.failed.connect(self._on_failed)
|
||||
worker.finished.connect(self._worker_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._worker = worker
|
||||
worker.start()
|
||||
|
||||
def cancel_scan(self) -> None:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._worker.cancel()
|
||||
self.scan_state.setText("Stopping scan…")
|
||||
self.cancel_button.setEnabled(False)
|
||||
|
||||
def _set_scanning(self, scanning: bool, scope: str | None = None) -> None:
|
||||
self.scan_button.set_scanning(scanning)
|
||||
self.scan_button.setEnabled(not scanning)
|
||||
self.start_button.setEnabled(not scanning)
|
||||
self.cancel_button.setVisible(scanning)
|
||||
self.cancel_button.setEnabled(scanning)
|
||||
self.homes_radio.setEnabled(not scanning)
|
||||
self.full_radio.setEnabled(not scanning)
|
||||
self.auto_combo.setEnabled(not scanning)
|
||||
self.definitions_button.setEnabled(not scanning)
|
||||
if scanning:
|
||||
self.progress.show()
|
||||
self.current_file.show()
|
||||
self.scan_state.setText("Scanning")
|
||||
self.scan_detail.setText("ClamAV is reading files. You can keep using BoltClam.")
|
||||
if scope:
|
||||
self.scan_scope_label.setText(f"Scope: {human_scope(scope)}")
|
||||
else:
|
||||
self.progress.hide()
|
||||
self.current_file.hide()
|
||||
self.scan_state.setText("Ready to scan")
|
||||
self.scan_detail.setText(
|
||||
"Choose a scope on the right. User-home scans cover every profile; "
|
||||
"full-disk scans cover every fixed drive.")
|
||||
|
||||
def _on_scan_started(self, scan_id: int, targets: list) -> None:
|
||||
self._current_scan_id = scan_id
|
||||
self.scan_detail.setText(f"Scanning {len(targets)} root(s) · scan #{scan_id}")
|
||||
self.current_file.setText("Waiting for the first file…")
|
||||
|
||||
def _on_progress(self, path: str, detections: int) -> None:
|
||||
if path:
|
||||
self.current_file.setText(path)
|
||||
if detections:
|
||||
self.scan_detail.setText(
|
||||
f"{detections} detection(s) so far. Remediation follows your "
|
||||
f"'{self.context.settings.auto_action}' setting.")
|
||||
|
||||
def _on_detection(self, detection: Detection) -> None:
|
||||
self.scan_detail.setText(
|
||||
f"Found {detection.signature} in {display_name(detection.path)} — "
|
||||
f"{human_action(detection.action)}")
|
||||
|
||||
def _on_log(self, message: str) -> None:
|
||||
if message:
|
||||
self.current_file.setText(message[:300])
|
||||
|
||||
def _on_status(self, text: str) -> None:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self.scan_detail.setText(text)
|
||||
|
||||
def _on_finished(self, record) -> None:
|
||||
self._set_scanning(False)
|
||||
self.refresh_history(select_id=record.id)
|
||||
if record.infected_files:
|
||||
self.toast_requested.emit(
|
||||
"Scan finished with detections",
|
||||
f"{record.infected_files} threat(s) in {record.scanned_files:,} files. Review the results.",
|
||||
"critical")
|
||||
else:
|
||||
self.toast_requested.emit(
|
||||
"Scan finished",
|
||||
f"{record.scanned_files:,} files scanned. No threats found.",
|
||||
"good")
|
||||
self.last_scan_label.setText(
|
||||
f"Last scan {theme.relative_time(record.started_at)} · "
|
||||
f"{record.scanned_files:,} files · {record.threat_count} threat(s)")
|
||||
self.scan_finished.emit()
|
||||
|
||||
def _on_failed(self, message: str) -> None:
|
||||
self._set_scanning(False)
|
||||
self.scan_detail.setText(message)
|
||||
self.toast_requested.emit("Scan could not finish", message, "critical")
|
||||
|
||||
def _worker_finished(self) -> None:
|
||||
self._worker = None
|
||||
|
||||
# -- history / details -------------------------------------------------
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refresh_history()
|
||||
self.refresh_quarantine()
|
||||
latest = self.context.storage.latest_scan()
|
||||
if latest:
|
||||
self.last_scan_label.setText(
|
||||
f"Last scan {theme.relative_time(latest.started_at)} · "
|
||||
f"{latest.scanned_files:,} files · {latest.threat_count} threat(s)")
|
||||
else:
|
||||
self.last_scan_label.setText("No scan has been run yet.")
|
||||
self._refresh_engine_line()
|
||||
|
||||
def refresh_history(self, select_id: int | None = None) -> None:
|
||||
scans = self.context.storage.list_scans(100)
|
||||
current = select_id
|
||||
if current is None and self._current_scan_id is not None:
|
||||
current = self._current_scan_id
|
||||
blocker = QSignalBlocker(self.history)
|
||||
self.history.clear()
|
||||
target_row = -1
|
||||
for row, scan in enumerate(scans):
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.ItemDataRole.UserRole, scan.id)
|
||||
widget = ScanHistoryItem(scan)
|
||||
item.setSizeHint(QSize(260, 72))
|
||||
self.history.addItem(item)
|
||||
self.history.setItemWidget(item, widget)
|
||||
if current is not None and scan.id == current:
|
||||
target_row = row
|
||||
del blocker
|
||||
if target_row >= 0:
|
||||
self.history.setCurrentRow(target_row)
|
||||
elif scans and select_id is None:
|
||||
self.history.setCurrentRow(0)
|
||||
elif not scans:
|
||||
self._show_detail_empty()
|
||||
self._current_scan_id = None
|
||||
|
||||
def _history_selected(self, current: QListWidgetItem | None, previous=None) -> None:
|
||||
if current is None:
|
||||
return
|
||||
scan_id = current.data(Qt.ItemDataRole.UserRole)
|
||||
record = self.context.storage.get_scan(int(scan_id)) if scan_id is not None else None
|
||||
if record is None:
|
||||
self._show_detail_empty()
|
||||
return
|
||||
self._current_scan_id = record.id
|
||||
self.detail_stack.setCurrentIndex(1)
|
||||
self.detail_title.setText(f"Scan #{record.id}")
|
||||
severity = "good" if record.status == "completed" else (
|
||||
"warning" if record.status == "completed_with_errors" else
|
||||
"critical" if record.status == "failed" else "muted")
|
||||
self.detail_status.setText(record.status.replace("_", " ").title())
|
||||
self.detail_status.set_severity(severity)
|
||||
self.detail_meta.setText(
|
||||
f"{theme.format_datetime(record.started_at)} · {record.scope_label} · "
|
||||
f"{len(record.paths)} root(s)")
|
||||
self.detail_summary.setText(
|
||||
f"{record.scanned_files:,} files scanned · {record.threat_count} threat(s) · "
|
||||
f"{record.errors} error(s) · {theme.format_duration(record.duration_seconds)}")
|
||||
self._fill_detections(record.detections)
|
||||
|
||||
def _fill_detections(self, detections: list[Detection]) -> None:
|
||||
self.detection_table.setRowCount(0)
|
||||
for detection in detections:
|
||||
row = self.detection_table.rowCount()
|
||||
self.detection_table.insertRow(row)
|
||||
path_item = QTableWidgetItem(display_name(detection.path))
|
||||
path_item.setToolTip(detection.path)
|
||||
path_item.setData(Qt.ItemDataRole.UserRole, detection)
|
||||
self.detection_table.setItem(row, 0, path_item)
|
||||
signature_item = QTableWidgetItem(detection.signature)
|
||||
signature_item.setToolTip(detection.signature)
|
||||
self.detection_table.setItem(row, 1, signature_item)
|
||||
action_item = QTableWidgetItem(human_action(detection.action))
|
||||
action_item.setForeground(QColor(theme.status_color(detection.action)))
|
||||
self.detection_table.setItem(row, 2, action_item)
|
||||
self.detection_table.clearSelection()
|
||||
self._update_detection_buttons()
|
||||
|
||||
def _show_detail_empty(self) -> None:
|
||||
self.detection_table.setRowCount(0)
|
||||
self.detail_stack.setCurrentIndex(0)
|
||||
|
||||
def _selected_detections(self) -> list[Detection]:
|
||||
rows = self.detection_table.selectionModel().selectedRows()
|
||||
result: list[Detection] = []
|
||||
for index in rows:
|
||||
item = self.detection_table.item(index.row(), 0)
|
||||
detection = item.data(Qt.ItemDataRole.UserRole) if item else None
|
||||
if isinstance(detection, Detection):
|
||||
result.append(detection)
|
||||
return result
|
||||
|
||||
def _update_detection_buttons(self) -> None:
|
||||
selections = self._selected_detections()
|
||||
actionable = [item for item in selections
|
||||
if item.action not in ("quarantined", "deleted", "ignored")]
|
||||
self.quarantine_button.setEnabled(bool(actionable))
|
||||
self.delete_button.setEnabled(bool(actionable))
|
||||
self.ignore_button.setEnabled(bool(actionable))
|
||||
|
||||
# -- detection actions -------------------------------------------------
|
||||
|
||||
def _record_detection_action(self, detection: Detection, action: str,
|
||||
quarantine_id: str | None = None) -> None:
|
||||
if detection.id is None:
|
||||
return
|
||||
try:
|
||||
self.context.storage.update_detection_action(detection.id, action, quarantine_id)
|
||||
except Exception:
|
||||
logger.exception("Could not update detection action")
|
||||
detection.action = action
|
||||
if quarantine_id:
|
||||
detection.quarantine_id = quarantine_id
|
||||
|
||||
def quarantine_selected(self) -> None:
|
||||
selections = [item for item in self._selected_detections()
|
||||
if item.action not in ("quarantined", "deleted", "ignored")]
|
||||
if not selections:
|
||||
return
|
||||
failures = 0
|
||||
for detection in selections:
|
||||
try:
|
||||
item = self.context.quarantine.quarantine_file(
|
||||
Path(detection.path), detection.signature,
|
||||
scan_id=self._current_scan_id, reason="Manual from scan results")
|
||||
self._record_detection_action(detection, "quarantined", item.id)
|
||||
except (QuarantineError, OSError) as exc:
|
||||
failures += 1
|
||||
self._record_detection_action(detection, "failed")
|
||||
logger.warning("Manual quarantine failed for %s: %s", detection.path, exc)
|
||||
self._history_selected(self.history.currentItem())
|
||||
self.refresh_quarantine()
|
||||
self.refresh_history(select_id=self._current_scan_id)
|
||||
self.toast_requested.emit(
|
||||
"Quarantine complete" if not failures else "Some files could not be quarantined",
|
||||
f"{len(selections) - failures} quarantined, {failures} failed.",
|
||||
"good" if not failures else "critical")
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
selections = [item for item in self._selected_detections()
|
||||
if item.action not in ("quarantined", "deleted", "ignored")]
|
||||
if not selections:
|
||||
return
|
||||
answer = QMessageBox.warning(
|
||||
self, "Delete detected files?",
|
||||
f"Permanently delete {len(selections)} detected file(s)?\n\n"
|
||||
"This cannot be undone. Quarantine is reversible and is the safer choice.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
failures = 0
|
||||
for detection in selections:
|
||||
try:
|
||||
delete_file(Path(detection.path))
|
||||
self._record_detection_action(detection, "deleted")
|
||||
except (QuarantineError, OSError) as exc:
|
||||
failures += 1
|
||||
self._record_detection_action(detection, "failed")
|
||||
logger.warning("Manual delete failed for %s: %s", detection.path, exc)
|
||||
self.refresh_history(select_id=self._current_scan_id)
|
||||
self.toast_requested.emit(
|
||||
"Files deleted" if not failures else "Some files could not be deleted",
|
||||
f"{len(selections) - failures} deleted, {failures} failed.",
|
||||
"good" if not failures else "critical")
|
||||
|
||||
def ignore_selected(self) -> None:
|
||||
selections = self._selected_detections()
|
||||
for detection in selections:
|
||||
self._record_detection_action(detection, "ignored")
|
||||
self.refresh_history(select_id=self._current_scan_id)
|
||||
self.toast_requested.emit("Detection ignored",
|
||||
f"{len(selections)} detection(s) marked ignored.", "warning")
|
||||
|
||||
# -- quarantine --------------------------------------------------------
|
||||
|
||||
def refresh_quarantine(self) -> None:
|
||||
items = self.context.storage.list_quarantine(status="quarantined")
|
||||
self.quarantine_table.setRowCount(0)
|
||||
for item in items:
|
||||
row = self.quarantine_table.rowCount()
|
||||
self.quarantine_table.insertRow(row)
|
||||
original = QTableWidgetItem(display_name(item.original_path))
|
||||
original.setToolTip(item.original_path)
|
||||
original.setData(Qt.ItemDataRole.UserRole, item.id)
|
||||
self.quarantine_table.setItem(row, 0, original)
|
||||
self.quarantine_table.setItem(row, 1, QTableWidgetItem(item.signature))
|
||||
self.quarantine_table.setItem(row, 2, QTableWidgetItem(
|
||||
theme.format_datetime(item.created_at)))
|
||||
status_item = QTableWidgetItem(item.status.title())
|
||||
status_item.setForeground(QColor(theme.TEAL))
|
||||
self.quarantine_table.setItem(row, 3, status_item)
|
||||
self.quarantine_stack.setCurrentIndex(1 if items else 0)
|
||||
self.quarantine_table.clearSelection()
|
||||
self._update_quarantine_buttons()
|
||||
|
||||
def _selected_quarantine(self) -> list[str]:
|
||||
rows = self.quarantine_table.selectionModel().selectedRows()
|
||||
result = []
|
||||
for index in rows:
|
||||
item = self.quarantine_table.item(index.row(), 0)
|
||||
if item is not None and item.data(Qt.ItemDataRole.UserRole):
|
||||
result.append(str(item.data(Qt.ItemDataRole.UserRole)))
|
||||
return result
|
||||
|
||||
def _update_quarantine_buttons(self) -> None:
|
||||
selected = bool(self._selected_quarantine())
|
||||
self.restore_button.setEnabled(selected)
|
||||
self.purge_button.setEnabled(selected)
|
||||
|
||||
def restore_selected(self) -> None:
|
||||
ids = self._selected_quarantine()
|
||||
if not ids:
|
||||
return
|
||||
answer = QMessageBox.warning(
|
||||
self, "Restore quarantined files?",
|
||||
f"Restore {len(ids)} file(s) to their original locations?\n\n"
|
||||
"Only restore files you trust. A detected file can be malicious.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
failures = 0
|
||||
for item_id in ids:
|
||||
try:
|
||||
self.context.quarantine.restore(item_id)
|
||||
except QuarantineError as exc:
|
||||
failures += 1
|
||||
logger.warning("Restore failed for %s: %s", item_id, exc)
|
||||
self.refresh_quarantine()
|
||||
self.toast_requested.emit(
|
||||
"Files restored" if not failures else "Some files could not be restored",
|
||||
f"{len(ids) - failures} restored, {failures} failed.",
|
||||
"good" if not failures else "critical")
|
||||
|
||||
def delete_quarantined_selected(self) -> None:
|
||||
ids = self._selected_quarantine()
|
||||
if not ids:
|
||||
return
|
||||
answer = QMessageBox.warning(
|
||||
self, "Delete quarantined files?",
|
||||
f"Permanently delete {len(ids)} quarantined file(s)? This cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
failures = 0
|
||||
for item_id in ids:
|
||||
try:
|
||||
self.context.quarantine.delete(item_id)
|
||||
except QuarantineError as exc:
|
||||
failures += 1
|
||||
logger.warning("Quarantine delete failed for %s: %s", item_id, exc)
|
||||
self.refresh_quarantine()
|
||||
self.toast_requested.emit(
|
||||
"Quarantined files deleted" if not failures else "Some files could not be deleted",
|
||||
f"{len(ids) - failures} deleted, {failures} failed.",
|
||||
"good" if not failures else "critical")
|
||||
|
||||
def open_quarantine_folder(self) -> None:
|
||||
if not self.context.quarantine.open_folder():
|
||||
self.toast_requested.emit("Could not open folder",
|
||||
"The quarantine folder could not be opened.", "warning")
|
||||
|
||||
# -- definitions -------------------------------------------------------
|
||||
|
||||
def update_definitions(self) -> None:
|
||||
if self._definition_worker is not None and self._definition_worker.isRunning():
|
||||
return
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
return
|
||||
self.definitions_button.setEnabled(False)
|
||||
self.start_button.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.progress.setRange(0, 0)
|
||||
self.current_file.show()
|
||||
self.current_file.setText("Starting freshclam…")
|
||||
self.scan_state.setText("Updating definitions")
|
||||
self.scan_detail.setText("BoltClam is downloading the latest ClamAV signatures.")
|
||||
worker = DefinitionWorker(self.context.clamav, self)
|
||||
worker.log_line.connect(self._on_log)
|
||||
worker.finished_ok.connect(self._definitions_ok)
|
||||
worker.failed.connect(self._definitions_failed)
|
||||
worker.finished.connect(self._definition_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._definition_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _definitions_ok(self, message: str) -> None:
|
||||
self.context.clamav.invalidate()
|
||||
self._refresh_engine_line()
|
||||
self.scan_state.setText("Definitions updated")
|
||||
self.scan_detail.setText(message)
|
||||
self.toast_requested.emit("Definitions updated", message, "good")
|
||||
self.definitions_updated.emit()
|
||||
|
||||
def _definitions_failed(self, message: str) -> None:
|
||||
self.scan_state.setText("Definition update failed")
|
||||
self.scan_detail.setText(message)
|
||||
self.toast_requested.emit("Definition update failed", message, "critical")
|
||||
|
||||
def _definition_finished(self) -> None:
|
||||
self._definition_worker = None
|
||||
self.definitions_button.setEnabled(True)
|
||||
self.start_button.setEnabled(True)
|
||||
self.progress.hide()
|
||||
self.current_file.hide()
|
||||
self.progress.setRange(0, 0)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._worker.cancel()
|
||||
stop_thread(self._worker, wait_ms=5000)
|
||||
if self._definition_worker is not None and self._definition_worker.isRunning():
|
||||
self._definition_worker.cancel()
|
||||
stop_thread(self._definition_worker, wait_ms=4000)
|
||||
self._worker = None
|
||||
self._definition_worker = None
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Settings: every persisted option, validation, logging and log upload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import paths
|
||||
from ..clamav import ClamAVService, find_clamscan, parse_extra_options
|
||||
from ..config import AppSettings
|
||||
from ..context import AppContext
|
||||
from ..logging_setup import configure as configure_logging
|
||||
from ..logs import DiagnosticsError, build_bundle, upload_bundle
|
||||
from ..system_info import collect
|
||||
from . import components, theme
|
||||
from .threads import stop_thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiagnosticsWorker(QThread):
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, context: AppContext, settings: AppSettings, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self.settings = settings
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
snapshot = collect(
|
||||
self.settings, self.context.clamav.info(), self.context.storage,
|
||||
realtime_running=(self.context.realtime.running
|
||||
if self.context.realtime else None),
|
||||
force=True)
|
||||
bundle = build_bundle(
|
||||
self.settings, self.context.clamav.info(),
|
||||
system_snapshot=snapshot, account=self.context.account,
|
||||
realtime_roots=(self.context.realtime.watched_roots
|
||||
if self.context.realtime else []))
|
||||
api = self.context.api or self.context.rebuild_api()
|
||||
result = upload_bundle(api, bundle, self.settings)
|
||||
self.succeeded.emit(result)
|
||||
except DiagnosticsError as exc:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Diagnostics upload failed")
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class SettingsPage(QWidget):
|
||||
settings_saved = pyqtSignal(object)
|
||||
sign_out_requested = pyqtSignal()
|
||||
toast_requested = pyqtSignal(str, str, str)
|
||||
definitions_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, context: AppContext, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self._loading = False
|
||||
self._dirty = False
|
||||
self._diagnostics_worker: DiagnosticsWorker | None = None
|
||||
self._build()
|
||||
self.reload_settings()
|
||||
|
||||
# -- construction ------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
outer.addWidget(scroll, 1)
|
||||
content = QWidget()
|
||||
content.setObjectName("Content")
|
||||
scroll.setWidget(content)
|
||||
self.content_layout = QVBoxLayout(content)
|
||||
self.content_layout.setContentsMargins(30, 24, 30, 22)
|
||||
self.content_layout.setSpacing(15)
|
||||
header = components.PageHeader(
|
||||
"Settings", "Engine, protection, logging and account preferences.")
|
||||
self.content_layout.addWidget(header)
|
||||
|
||||
self.content_layout.addWidget(self._build_update_section())
|
||||
self.content_layout.addWidget(self._build_clamav_section())
|
||||
self.content_layout.addWidget(self._build_protection_section())
|
||||
self.content_layout.addWidget(self._build_scan_options())
|
||||
self.content_layout.addWidget(self._build_logging_section())
|
||||
self.content_layout.addWidget(self._build_account_section())
|
||||
self.content_layout.addStretch(1)
|
||||
outer.addWidget(self._build_save_bar())
|
||||
|
||||
def _build_update_section(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"Updates", "Release receives stable builds. Beta includes prereleases.")[0])
|
||||
row = QHBoxLayout()
|
||||
label = QLabel("Update channel")
|
||||
label.setObjectName("Muted")
|
||||
row.addWidget(label, 1)
|
||||
self.channel_combo = QComboBox()
|
||||
self.channel_combo.addItem("Release", "release")
|
||||
self.channel_combo.addItem("Beta (includes prereleases)", "beta")
|
||||
self.channel_combo.setMinimumWidth(250)
|
||||
self.channel_combo.currentIndexChanged.connect(self._mark_dirty)
|
||||
row.addWidget(self.channel_combo, 0)
|
||||
card.body.addLayout(row)
|
||||
return card
|
||||
|
||||
def _build_clamav_section(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"ClamAV engine", "BoltClam calls clamscan.exe from this location.")[0])
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(8)
|
||||
self.clamav_path = QLineEdit()
|
||||
self.clamav_path.setPlaceholderText(r"C:\Program Files\ClamAV")
|
||||
self.clamav_path.textChanged.connect(self._mark_dirty)
|
||||
row.addWidget(self.clamav_path, 1)
|
||||
browse = QPushButton("Browse")
|
||||
browse.setObjectName("Secondary")
|
||||
browse.clicked.connect(self.browse_clamav)
|
||||
row.addWidget(browse)
|
||||
detect = QPushButton("Auto-detect")
|
||||
detect.setObjectName("Ghost")
|
||||
detect.clicked.connect(self.detect_clamav)
|
||||
row.addWidget(detect)
|
||||
card.body.addLayout(row)
|
||||
status_row = QHBoxLayout()
|
||||
self.clamav_status = QLabel("Not checked")
|
||||
self.clamav_status.setObjectName("Muted")
|
||||
self.clamav_status.setWordWrap(True)
|
||||
status_row.addWidget(self.clamav_status, 1)
|
||||
verify = QPushButton("Verify")
|
||||
verify.setObjectName("Secondary")
|
||||
verify.clicked.connect(self.verify_clamav)
|
||||
status_row.addWidget(verify, 0)
|
||||
card.body.addLayout(status_row)
|
||||
divider = components.Divider()
|
||||
card.body.addWidget(divider)
|
||||
defs_row = QHBoxLayout()
|
||||
self.definitions_status = QLabel("")
|
||||
self.definitions_status.setObjectName("Muted")
|
||||
self.definitions_status.setWordWrap(True)
|
||||
defs_row.addWidget(self.definitions_status, 1)
|
||||
update_defs = QPushButton("Update definitions")
|
||||
update_defs.setObjectName("Secondary")
|
||||
update_defs.clicked.connect(self.definitions_requested)
|
||||
defs_row.addWidget(update_defs, 0)
|
||||
card.body.addLayout(defs_row)
|
||||
note = QLabel(
|
||||
"BoltClam keeps freshclam signatures in its own app-data folder so updates do not "
|
||||
"need administrator access. ClamAV itself is installed separately.")
|
||||
note.setObjectName("Dim")
|
||||
note.setWordWrap(True)
|
||||
card.body.addWidget(note)
|
||||
return card
|
||||
|
||||
def _build_protection_section(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"Protection", "Real-time browser downloads and automatic remediation.")[0])
|
||||
self.realtime_checkbox = QCheckBox("Watch browser downloads and scan new files")
|
||||
self.realtime_checkbox.toggled.connect(self._realtime_toggled)
|
||||
card.body.addWidget(self.realtime_checkbox)
|
||||
self.notify_checkbox = QCheckBox("Show a notification when a download is blocked")
|
||||
self.notify_checkbox.toggled.connect(self._mark_dirty)
|
||||
card.body.addWidget(self.notify_checkbox)
|
||||
self.watched_label = QLabel("")
|
||||
self.watched_label.setObjectName("Dim")
|
||||
self.watched_label.setWordWrap(True)
|
||||
card.body.addWidget(self.watched_label)
|
||||
row = QHBoxLayout()
|
||||
label = QLabel("When ClamAV finds something")
|
||||
label.setObjectName("Muted")
|
||||
row.addWidget(label, 1)
|
||||
self.auto_combo = QComboBox()
|
||||
self.auto_combo.addItem("Quarantine (recommended)", "quarantine")
|
||||
self.auto_combo.addItem("Delete permanently", "delete")
|
||||
self.auto_combo.addItem("Notify only", "notify")
|
||||
self.auto_combo.setMinimumWidth(240)
|
||||
self.auto_combo.currentIndexChanged.connect(self._auto_changed)
|
||||
row.addWidget(self.auto_combo, 0)
|
||||
card.body.addLayout(row)
|
||||
self.auto_note = QLabel("")
|
||||
self.auto_note.setObjectName("Dim")
|
||||
self.auto_note.setWordWrap(True)
|
||||
card.body.addWidget(self.auto_note)
|
||||
dirs_label = QLabel("Additional download folders")
|
||||
dirs_label.setObjectName("Muted")
|
||||
card.body.addWidget(dirs_label)
|
||||
self.extra_dirs = QLineEdit()
|
||||
self.extra_dirs.setPlaceholderText("Optional; separate multiple paths with ;")
|
||||
self.extra_dirs.textChanged.connect(self._mark_dirty)
|
||||
card.body.addWidget(self.extra_dirs)
|
||||
note = QLabel(
|
||||
"BoltClam reads each browser's configured download folder (Chrome, Edge, Brave, "
|
||||
"Firefox, Vivaldi, Opera) plus every user's Downloads folder. The watcher waits "
|
||||
"for a download to settle, holds the file exclusively while ClamAV scans a copy, "
|
||||
"then applies the setting above.")
|
||||
note.setObjectName("Dim")
|
||||
note.setWordWrap(True)
|
||||
card.body.addWidget(note)
|
||||
return card
|
||||
|
||||
def _build_scan_options(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"ClamAV extra options", "Advanced flags passed to clamscan. Leave blank for defaults.")[0])
|
||||
self.extra_options = QPlainTextEdit()
|
||||
self.extra_options.setObjectName("MonoInput")
|
||||
self.extra_options.setPlaceholderText("--max-filesize=200M\n--exclude-dir=regex")
|
||||
self.extra_options.setFixedHeight(92)
|
||||
self.extra_options.textChanged.connect(self._mark_dirty)
|
||||
card.body.addWidget(self.extra_options)
|
||||
self.extra_options_error = QLabel("")
|
||||
self.extra_options_error.setStyleSheet(f"color: {theme.DANGER}; font-size: 12px;")
|
||||
self.extra_options_error.setWordWrap(True)
|
||||
self.extra_options_error.hide()
|
||||
card.body.addWidget(self.extra_options_error)
|
||||
row = QHBoxLayout()
|
||||
note = QLabel("Destructive options such as --remove and --move are refused; BoltClam "
|
||||
"handles remediation and records every action.")
|
||||
note.setObjectName("Dim")
|
||||
note.setWordWrap(True)
|
||||
row.addWidget(note, 1)
|
||||
reset = QPushButton("Reset")
|
||||
reset.setObjectName("Ghost")
|
||||
reset.clicked.connect(lambda: self.extra_options.setPlainText(""))
|
||||
row.addWidget(reset, 0)
|
||||
card.body.addLayout(row)
|
||||
return card
|
||||
|
||||
def _build_logging_section(self) -> QWidget:
|
||||
card = components.Card()
|
||||
card.body.addWidget(components.card_header(
|
||||
"Logging & diagnostics", "Logs stay on this PC unless you explicitly send them.")[0])
|
||||
self.logging_checkbox = QCheckBox("Enable application logging")
|
||||
self.logging_checkbox.toggled.connect(self._logging_toggled)
|
||||
card.body.addWidget(self.logging_checkbox)
|
||||
self.debug_checkbox = QCheckBox("Include debug-level detail")
|
||||
self.debug_checkbox.toggled.connect(self._mark_dirty)
|
||||
card.body.addWidget(self.debug_checkbox)
|
||||
logs_row = QHBoxLayout()
|
||||
self.logs_location = QLabel(str(paths.logs_dir()))
|
||||
self.logs_location.setObjectName("Mono")
|
||||
logs_row.addWidget(self.logs_location, 1)
|
||||
open_logs = QPushButton("Open logs folder")
|
||||
open_logs.setObjectName("Ghost")
|
||||
open_logs.clicked.connect(self.open_logs)
|
||||
logs_row.addWidget(open_logs, 0)
|
||||
card.body.addLayout(logs_row)
|
||||
divider = components.Divider()
|
||||
card.body.addWidget(divider)
|
||||
self.send_logs_checkbox = QCheckBox(
|
||||
"Send logs to Bolt's systems for analysis when I choose")
|
||||
self.send_logs_checkbox.toggled.connect(self._send_logs_toggled)
|
||||
card.body.addWidget(self.send_logs_checkbox)
|
||||
privacy = QLabel(
|
||||
"A diagnostics bundle includes app logs, scan/quarantine summaries, settings, "
|
||||
"system summary and your account email. API keys, passwords and file contents are "
|
||||
"never included; user-profile paths are replaced with %USERPROFILE%.")
|
||||
privacy.setObjectName("Dim")
|
||||
privacy.setWordWrap(True)
|
||||
card.body.addWidget(privacy)
|
||||
bottom = QHBoxLayout()
|
||||
self.last_upload_label = QLabel("")
|
||||
self.last_upload_label.setObjectName("Dim")
|
||||
bottom.addWidget(self.last_upload_label, 1)
|
||||
self.send_now_button = QPushButton("Send diagnostics now")
|
||||
self.send_now_button.setObjectName("Secondary")
|
||||
self.send_now_button.clicked.connect(self.send_logs_now)
|
||||
bottom.addWidget(self.send_now_button, 0)
|
||||
card.body.addLayout(bottom)
|
||||
return card
|
||||
|
||||
def _build_account_section(self) -> QWidget:
|
||||
card = components.Card()
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(12)
|
||||
self.account_avatar = components.Avatar(self.context.user_name, 40)
|
||||
row.addWidget(self.account_avatar, 0)
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(1)
|
||||
self.account_name = QLabel(self.context.user_name)
|
||||
self.account_name.setFont(theme.body_font(10.5, QFont.Weight.DemiBold))
|
||||
self.account_email = QLabel(self.context.user_email)
|
||||
self.account_email.setObjectName("Muted")
|
||||
text_box.addWidget(self.account_name)
|
||||
text_box.addWidget(self.account_email)
|
||||
row.addLayout(text_box, 1)
|
||||
self.account_plan = components.Pill(self.context.plan_label, "info")
|
||||
row.addWidget(self.account_plan, 0)
|
||||
sign_out = QPushButton("Sign out")
|
||||
sign_out.setObjectName("Danger")
|
||||
sign_out.clicked.connect(self.sign_out)
|
||||
row.addWidget(sign_out, 0)
|
||||
card.body.addLayout(row)
|
||||
manage = QLabel(
|
||||
f'<a href="https://bolt.themajesticnetwork.com/account" '
|
||||
f'style="color:{theme.TEAL}; text-decoration:none;">Manage your Bolt account and subscription</a>')
|
||||
manage.setOpenExternalLinks(True)
|
||||
manage.setFont(theme.body_font(9.2, QFont.Weight.DemiBold))
|
||||
card.body.addWidget(manage)
|
||||
return card
|
||||
|
||||
def _build_save_bar(self) -> QWidget:
|
||||
bar = QFrame()
|
||||
bar.setStyleSheet(
|
||||
f"QFrame {{ background: {theme.SIDEBAR}; border-top: 1px solid {theme.BORDER}; }}")
|
||||
layout = QHBoxLayout(bar)
|
||||
layout.setContentsMargins(30, 11, 30, 11)
|
||||
layout.setSpacing(9)
|
||||
self.unsaved_label = QLabel("")
|
||||
self.unsaved_label.setStyleSheet(f"color: {theme.AMBER}; font-size: 12px;")
|
||||
layout.addWidget(self.unsaved_label, 1)
|
||||
self.discard_button = QPushButton("Discard")
|
||||
self.discard_button.setObjectName("Secondary")
|
||||
self.discard_button.clicked.connect(self.reload_settings)
|
||||
self.discard_button.setEnabled(False)
|
||||
layout.addWidget(self.discard_button)
|
||||
self.save_button = QPushButton("Save changes")
|
||||
self.save_button.setObjectName("Primary")
|
||||
self.save_button.clicked.connect(self.save)
|
||||
self.save_button.setEnabled(False)
|
||||
layout.addWidget(self.save_button)
|
||||
return bar
|
||||
|
||||
# -- state -------------------------------------------------------------
|
||||
|
||||
def reload_settings(self) -> None:
|
||||
settings = self.context.settings_store.load()
|
||||
self.context.update_settings(settings)
|
||||
self._loading = True
|
||||
try:
|
||||
index = self.channel_combo.findData(settings.update_channel)
|
||||
self.channel_combo.setCurrentIndex(index if index >= 0 else 0)
|
||||
self.clamav_path.setText(settings.clamav_path)
|
||||
self.realtime_checkbox.setChecked(settings.realtime_enabled)
|
||||
self.notify_checkbox.setChecked(settings.notify_on_detection)
|
||||
auto_index = self.auto_combo.findData(settings.auto_action)
|
||||
self.auto_combo.setCurrentIndex(auto_index if auto_index >= 0 else 0)
|
||||
self.extra_dirs.setText("; ".join(settings.extra_watch_dirs))
|
||||
self.extra_options.setPlainText(settings.extra_options)
|
||||
self.logging_checkbox.setChecked(settings.logging_enabled)
|
||||
self.debug_checkbox.setChecked(settings.debug_logging)
|
||||
self.debug_checkbox.setEnabled(settings.logging_enabled)
|
||||
self.send_logs_checkbox.setChecked(settings.send_logs)
|
||||
self.send_now_button.setEnabled(settings.send_logs)
|
||||
self.last_upload_label.setText(
|
||||
f"Last sent: {settings.last_log_upload}" if settings.last_log_upload
|
||||
else "Diagnostics have not been sent.")
|
||||
self.extra_options_error.hide()
|
||||
self._update_auto_note(settings.auto_action)
|
||||
self._refresh_clamav_status()
|
||||
self._refresh_account()
|
||||
self._refresh_watched()
|
||||
finally:
|
||||
self._loading = False
|
||||
self._set_dirty(False)
|
||||
|
||||
def _refresh_clamav_status(self) -> None:
|
||||
configured = self.clamav_path.text().strip()
|
||||
if configured:
|
||||
found, error = find_clamscan(configured)
|
||||
if found is None:
|
||||
self.clamav_status.setText(error)
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.DANGER};")
|
||||
self.definitions_status.setText("Definitions unavailable until ClamAV is found.")
|
||||
return
|
||||
info = self.context.clamav.info()
|
||||
if info.available:
|
||||
self.clamav_status.setText(
|
||||
f"Ready · {info.summary}" + (f" · {info.path}" if info.path else ""))
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.TEAL};")
|
||||
if info.db_path:
|
||||
self.definitions_status.setText(
|
||||
f"Definitions: {info.db_version or 'present'} · updated {info.db_date or 'unknown'}"
|
||||
+ (f" · {info.db_age_days:.0f} days old" if info.db_age_days is not None else ""))
|
||||
else:
|
||||
self.definitions_status.setText(
|
||||
"Definitions: not found. Use Update definitions to download them.")
|
||||
else:
|
||||
self.clamav_status.setText(info.error or "ClamAV is not configured.")
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.AMBER};")
|
||||
self.definitions_status.setText(
|
||||
"Install ClamAV, then set its folder here or use Auto-detect.")
|
||||
|
||||
def _refresh_account(self) -> None:
|
||||
self.account_avatar.set_name(self.context.user_name)
|
||||
self.account_name.setText(self.context.user_name)
|
||||
self.account_email.setText(self.context.user_email or "Signed in with Bolt")
|
||||
self.account_plan.setText(self.context.plan_label)
|
||||
self.account_plan.set_severity("good" if self.context.licensed else "muted")
|
||||
|
||||
def refresh_account(self) -> None:
|
||||
self._refresh_account()
|
||||
|
||||
def _refresh_watched(self) -> None:
|
||||
realtime = self.context.realtime
|
||||
if realtime is None:
|
||||
self.watched_label.setText("Real-time watcher is not initialized.")
|
||||
return
|
||||
roots = realtime.watched_roots
|
||||
if not self.context.settings.realtime_enabled:
|
||||
self.watched_label.setText("Real-time protection is off.")
|
||||
elif realtime.running:
|
||||
self.watched_label.setText(
|
||||
f"Watching {len(roots)} folder(s): " + ", ".join(roots[:4])
|
||||
+ ("…" if len(roots) > 4 else ""))
|
||||
else:
|
||||
self.watched_label.setText("Real-time watcher is not running.")
|
||||
|
||||
def _logging_toggled(self, checked: bool) -> None:
|
||||
self.debug_checkbox.setEnabled(checked)
|
||||
if not checked:
|
||||
self.debug_checkbox.setChecked(False)
|
||||
self._mark_dirty()
|
||||
|
||||
def _realtime_toggled(self, checked: bool) -> None:
|
||||
self.notify_checkbox.setEnabled(checked)
|
||||
self._mark_dirty()
|
||||
|
||||
def _send_logs_toggled(self, checked: bool) -> None:
|
||||
self.send_now_button.setEnabled(checked)
|
||||
self._mark_dirty()
|
||||
|
||||
def _auto_changed(self) -> None:
|
||||
self._update_auto_note(self.auto_combo.currentData() or "quarantine")
|
||||
self._mark_dirty()
|
||||
|
||||
def _update_auto_note(self, action: str) -> None:
|
||||
if action == "delete":
|
||||
self.auto_note.setText("Delete removes the original immediately. Quarantine is safer.")
|
||||
self.auto_note.setStyleSheet(f"color: {theme.DANGER};")
|
||||
elif action == "notify":
|
||||
self.auto_note.setText("Notify leaves the file in place and marks it for review.")
|
||||
self.auto_note.setStyleSheet(f"color: {theme.AMBER};")
|
||||
else:
|
||||
self.auto_note.setText(
|
||||
"Files are moved to quarantine and can be restored from Scan & results.")
|
||||
self.auto_note.setStyleSheet(f"color: {theme.DIM};")
|
||||
|
||||
def _mark_dirty(self, *args) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
self._set_dirty(True)
|
||||
|
||||
def _set_dirty(self, dirty: bool) -> None:
|
||||
self._dirty = bool(dirty)
|
||||
self.save_button.setEnabled(self._dirty)
|
||||
self.discard_button.setEnabled(self._dirty)
|
||||
self.unsaved_label.setText("Unsaved changes" if self._dirty else "")
|
||||
|
||||
# -- actions -----------------------------------------------------------
|
||||
|
||||
def browse_clamav(self) -> None:
|
||||
start = self.clamav_path.text().strip() or str(Path.home())
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select clamscan.exe", start,
|
||||
"ClamAV executable (clamscan.exe clamscan);;All files (*)")
|
||||
if filename:
|
||||
self.clamav_path.setText(filename)
|
||||
|
||||
def detect_clamav(self) -> None:
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
try:
|
||||
service = ClamAVService("")
|
||||
info = service.info(force=True)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
if info.available and info.path:
|
||||
self.clamav_path.setText(str(info.path))
|
||||
self.clamav_status.setText(f"Detected · {info.summary}")
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.TEAL};")
|
||||
else:
|
||||
self.clamav_status.setText(
|
||||
"ClamAV was not found in the usual locations. Install it, then browse to clamscan.exe.")
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.AMBER};")
|
||||
|
||||
def verify_clamav(self) -> None:
|
||||
path = self.clamav_path.text().strip()
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||
try:
|
||||
service = ClamAVService(path)
|
||||
info = service.info(force=True)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
if info.available:
|
||||
self.clamav_status.setText(f"Verified · {info.summary}")
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.TEAL};")
|
||||
self.toast_requested.emit("ClamAV verified", info.summary, "good")
|
||||
self._refresh_clamav_status()
|
||||
else:
|
||||
self.clamav_status.setText(info.error or "ClamAV could not be started.")
|
||||
self.clamav_status.setStyleSheet(f"color: {theme.DANGER};")
|
||||
self.toast_requested.emit(
|
||||
"ClamAV verification failed", info.error or "clamscan.exe could not run.", "critical")
|
||||
|
||||
def open_logs(self) -> None:
|
||||
paths.ensure_dirs()
|
||||
if not paths.open_path(paths.logs_dir()):
|
||||
self.toast_requested.emit("Could not open logs", str(paths.logs_dir()), "warning")
|
||||
|
||||
def save(self) -> bool:
|
||||
settings, error = self._collect_settings()
|
||||
if error:
|
||||
self.extra_options_error.setText(error)
|
||||
self.extra_options_error.show()
|
||||
self.toast_requested.emit("Settings not saved", error, "critical")
|
||||
return False
|
||||
assert settings is not None
|
||||
try:
|
||||
saved = self.context.settings_store.save(settings)
|
||||
except OSError as exc:
|
||||
self.toast_requested.emit("Settings not saved", str(exc), "critical")
|
||||
return False
|
||||
self.context.update_settings(saved)
|
||||
self.context.clamav.configured_path = saved.clamav_path
|
||||
self.context.clamav.invalidate()
|
||||
configure_logging(saved)
|
||||
self._loading = True
|
||||
try:
|
||||
self._update_auto_note(saved.auto_action)
|
||||
self._refresh_clamav_status()
|
||||
finally:
|
||||
self._loading = False
|
||||
self.extra_options_error.hide()
|
||||
self._set_dirty(False)
|
||||
self.settings_saved.emit(saved)
|
||||
self.toast_requested.emit("Settings saved", "Your changes are active.", "good")
|
||||
return True
|
||||
|
||||
def _collect_settings(self) -> tuple[AppSettings | None, str | None]:
|
||||
configured = self.clamav_path.text().strip()
|
||||
if configured:
|
||||
found, error = find_clamscan(configured)
|
||||
if found is None:
|
||||
return None, error
|
||||
try:
|
||||
parse_extra_options(self.extra_options.toPlainText())
|
||||
except ValueError as exc:
|
||||
return None, str(exc)
|
||||
dirs = [entry.strip() for entry in
|
||||
self.extra_dirs.text().replace("\n", ";").split(";") if entry.strip()]
|
||||
settings = dataclasses.replace(
|
||||
self.context.settings,
|
||||
update_channel=self.channel_combo.currentData() or "release",
|
||||
clamav_path=configured,
|
||||
extra_options=self.extra_options.toPlainText().strip(),
|
||||
logging_enabled=self.logging_checkbox.isChecked(),
|
||||
debug_logging=(self.debug_checkbox.isChecked()
|
||||
and self.logging_checkbox.isChecked()),
|
||||
send_logs=self.send_logs_checkbox.isChecked(),
|
||||
auto_action=self.auto_combo.currentData() or "quarantine",
|
||||
realtime_enabled=self.realtime_checkbox.isChecked(),
|
||||
notify_on_detection=self.notify_checkbox.isChecked(),
|
||||
extra_watch_dirs=dirs,
|
||||
).normalized()
|
||||
return settings, None
|
||||
|
||||
def send_logs_now(self) -> None:
|
||||
if not self.send_logs_checkbox.isChecked():
|
||||
QMessageBox.information(
|
||||
self, "Enable diagnostics first",
|
||||
"Tick 'Send logs to Bolt's systems for analysis' before sending.")
|
||||
return
|
||||
if self._dirty:
|
||||
answer = QMessageBox.question(
|
||||
self, "Save settings first?",
|
||||
"Bolt sends the saved settings and logs. Save your changes before uploading?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes)
|
||||
if answer == QMessageBox.StandardButton.Yes and not self.save():
|
||||
return
|
||||
if self._diagnostics_worker is not None and self._diagnostics_worker.isRunning():
|
||||
return
|
||||
self.send_now_button.setEnabled(False)
|
||||
self.send_now_button.setText("Preparing bundle…")
|
||||
worker = DiagnosticsWorker(self.context, self.context.settings, self)
|
||||
worker.succeeded.connect(self._diagnostics_succeeded)
|
||||
worker.failed.connect(self._diagnostics_failed)
|
||||
worker.finished.connect(self._diagnostics_finished)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
self._diagnostics_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _diagnostics_succeeded(self, result: dict) -> None:
|
||||
stamp = time.strftime("%Y-%m-%d %H:%M")
|
||||
try:
|
||||
self.context.settings = self.context.settings_store.update(
|
||||
last_log_upload=stamp)
|
||||
except Exception:
|
||||
logger.exception("Could not record the diagnostics upload time")
|
||||
self.last_upload_label.setText(f"Last sent: {stamp}")
|
||||
log_id = result.get("id") if isinstance(result, dict) else ""
|
||||
self.toast_requested.emit(
|
||||
"Diagnostics sent", f"Bolt received the bundle{f' ({log_id})' if log_id else ''}.",
|
||||
"good")
|
||||
|
||||
def _diagnostics_failed(self, message: str) -> None:
|
||||
self.toast_requested.emit("Diagnostics upload failed", message, "critical")
|
||||
|
||||
def _diagnostics_finished(self) -> None:
|
||||
self._diagnostics_worker = None
|
||||
self.send_now_button.setText("Send diagnostics now")
|
||||
self.send_now_button.setEnabled(self.send_logs_checkbox.isChecked())
|
||||
|
||||
def sign_out(self) -> None:
|
||||
answer = QMessageBox.warning(
|
||||
self, "Sign out of BoltClam?",
|
||||
"This revokes this PC's BoltClam key. Your account and other devices are not affected.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel)
|
||||
if answer == QMessageBox.StandardButton.Yes:
|
||||
self.sign_out_requested.emit()
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
self._refresh_clamav_status()
|
||||
self._refresh_account()
|
||||
self._refresh_watched()
|
||||
super().showEvent(event)
|
||||
|
||||
def stop(self) -> None:
|
||||
stop_thread(self._diagnostics_worker, wait_ms=3500)
|
||||
self._diagnostics_worker = None
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Visual system: colors, fonts, formatting and the application stylesheet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from .. import __build_date__, __version__
|
||||
from .. import paths
|
||||
|
||||
# Named palette — "abyssal teal with one warm bolt". Deliberately not the
|
||||
# near-black + acid-green default: depth comes from layered sea-blues and a
|
||||
# pearl-white text tone, while amber is reserved for actions and warnings.
|
||||
BG = "#07131A"
|
||||
BG_DEEP = "#050E14"
|
||||
SIDEBAR = "#091A22"
|
||||
SURFACE = "#0C202A"
|
||||
SURFACE_RAISED = "#102A35"
|
||||
SURFACE_HOVER = "#14323E"
|
||||
BORDER = "#1B3946"
|
||||
BORDER_SOFT = "#16303B"
|
||||
TEXT = "#EDF6F4"
|
||||
TEXT_SOFT = "#C6D9D8"
|
||||
MUTED = "#8BA7B2"
|
||||
DIM = "#5F7C88"
|
||||
AMBER = "#F2B84B"
|
||||
AMBER_BRIGHT = "#FFD37A"
|
||||
AMBER_DARK = "#A97218"
|
||||
TEAL = "#59D8C9"
|
||||
TEAL_DARK = "#1F8F83"
|
||||
DANGER = "#FF6B5E"
|
||||
DANGER_DARK = "#B53C33"
|
||||
WARNING = "#F2B84B"
|
||||
INFO = "#7AA8FF"
|
||||
SUCCESS = "#59D8C9"
|
||||
|
||||
FONT_BODY = "Atkinson Hyperlegible"
|
||||
FONT_DISPLAY = "Bricolage Grotesque"
|
||||
FONT_MONO = "JetBrains Mono"
|
||||
FALLBACK_BODY = "Segoe UI"
|
||||
|
||||
|
||||
def load_fonts() -> None:
|
||||
folder = paths.resource_dir() / "fonts"
|
||||
if folder.is_dir():
|
||||
for font_file in sorted(folder.glob("*.ttf")):
|
||||
QFontDatabase.addApplicationFont(str(font_file))
|
||||
|
||||
|
||||
def body_font(size: float = 10.5, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
|
||||
font = QFont(FONT_BODY, 10)
|
||||
font.setPointSizeF(size)
|
||||
font.setWeight(weight)
|
||||
return font
|
||||
|
||||
|
||||
def display_font(size: float = 24, weight: QFont.Weight = QFont.Weight.DemiBold) -> QFont:
|
||||
font = QFont(FONT_DISPLAY, 10)
|
||||
font.setPointSizeF(size)
|
||||
font.setWeight(weight)
|
||||
font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
|
||||
return font
|
||||
|
||||
|
||||
def mono_font(size: float = 9.5) -> QFont:
|
||||
font = QFont(FONT_MONO, 10)
|
||||
font.setPointSizeF(size)
|
||||
return font
|
||||
|
||||
|
||||
def setup_application(app: QApplication) -> None:
|
||||
load_fonts()
|
||||
app.setStyle("Fusion")
|
||||
app.setFont(body_font(10.5))
|
||||
app.setApplicationName("BoltClam")
|
||||
app.setApplicationDisplayName("BoltClam")
|
||||
app.setApplicationVersion(__version__)
|
||||
app.setOrganizationName("The Majestic Network")
|
||||
app.setStyleSheet(stylesheet())
|
||||
|
||||
|
||||
def _resource_url(name: str) -> str:
|
||||
path = (paths.resource_dir() / name).resolve()
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def stylesheet() -> str:
|
||||
check = _resource_url("check.png")
|
||||
radio = _resource_url("radio.png")
|
||||
chevron = _resource_url("chevron_down.png")
|
||||
return f"""
|
||||
* {{
|
||||
outline: none;
|
||||
}}
|
||||
QWidget {{
|
||||
color: {TEXT};
|
||||
font-family: "{FONT_BODY}", "{FALLBACK_BODY}", sans-serif;
|
||||
font-size: 14px;
|
||||
}}
|
||||
QMainWindow, QWidget#Root, QWidget#Content {{
|
||||
background: {BG};
|
||||
}}
|
||||
QFrame#Sidebar {{
|
||||
background: {SIDEBAR};
|
||||
border-right: 1px solid {BORDER_SOFT};
|
||||
}}
|
||||
QFrame#Card, QFrame#Hero, QFrame#ScanConsole, QFrame#SettingsSection {{
|
||||
background: {SURFACE};
|
||||
border: 1px solid {BORDER_SOFT};
|
||||
border-radius: 14px;
|
||||
}}
|
||||
QFrame#Hero {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #0E2733, stop:0.55 #0C202A, stop:1 #0A1B24);
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 20px;
|
||||
}}
|
||||
QFrame#SidebarRule {{
|
||||
background: {BORDER_SOFT};
|
||||
max-height: 1px;
|
||||
border: none;
|
||||
}}
|
||||
QLabel {{
|
||||
background: transparent;
|
||||
}}
|
||||
QLabel#PageTitle {{
|
||||
font-family: "{FONT_DISPLAY}";
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QLabel#PageSubtitle {{
|
||||
color: {MUTED};
|
||||
font-size: 14px;
|
||||
}}
|
||||
QLabel#HeroTitle {{
|
||||
font-family: "{FONT_DISPLAY}";
|
||||
font-size: 27px;
|
||||
font-weight: 600;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QLabel#HeroSummary {{
|
||||
color: {TEXT_SOFT};
|
||||
font-size: 14px;
|
||||
}}
|
||||
QLabel#CardTitle {{
|
||||
font-family: "{FONT_DISPLAY}";
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QLabel#CardEyebrow {{
|
||||
color: {MUTED};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
QLabel#StatValue {{
|
||||
font-family: "{FONT_DISPLAY}";
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QLabel#StatLabel {{
|
||||
color: {MUTED};
|
||||
font-size: 12px;
|
||||
}}
|
||||
QLabel#Muted {{
|
||||
color: {MUTED};
|
||||
}}
|
||||
QLabel#Dim {{
|
||||
color: {DIM};
|
||||
font-size: 12px;
|
||||
}}
|
||||
QLabel#Mono, QLabel#PathLabel {{
|
||||
font-family: "{FONT_MONO}";
|
||||
font-size: 12px;
|
||||
color: {TEXT_SOFT};
|
||||
}}
|
||||
QLabel#DangerText {{ color: {DANGER}; }}
|
||||
QLabel#WarningText {{ color: {WARNING}; }}
|
||||
QLabel#SuccessText {{ color: {SUCCESS}; }}
|
||||
QLabel#BrandName {{
|
||||
font-family: "{FONT_DISPLAY}";
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QLabel#BrandTag {{
|
||||
color: {MUTED};
|
||||
font-size: 10.5px;
|
||||
}}
|
||||
QLabel#Pill {{
|
||||
border-radius: 9px;
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: rgba(89,216,201,0.12);
|
||||
color: {TEAL};
|
||||
}}
|
||||
QLabel#Pill[severity="warning"] {{
|
||||
background: rgba(242,184,75,0.13);
|
||||
color: {AMBER};
|
||||
}}
|
||||
QLabel#Pill[severity="critical"] {{
|
||||
background: rgba(255,107,94,0.14);
|
||||
color: {DANGER};
|
||||
}}
|
||||
QLabel#Pill[severity="info"] {{
|
||||
background: rgba(122,168,255,0.12);
|
||||
color: {INFO};
|
||||
}}
|
||||
QLabel#Pill[severity="muted"] {{
|
||||
background: rgba(139,167,178,0.1);
|
||||
color: {MUTED};
|
||||
}}
|
||||
QLabel#Pill[severity="good"] {{
|
||||
background: rgba(89,216,201,0.12);
|
||||
color: {TEAL};
|
||||
}}
|
||||
QPushButton {{
|
||||
font-family: "{FONT_BODY}";
|
||||
font-size: 13.5px;
|
||||
border-radius: 9px;
|
||||
padding: 9px 15px;
|
||||
background: transparent;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QPushButton#Primary {{
|
||||
background: {AMBER};
|
||||
color: #1B1304;
|
||||
font-weight: 700;
|
||||
border: 1px solid {AMBER_BRIGHT};
|
||||
}}
|
||||
QPushButton#Primary:hover {{ background: {AMBER_BRIGHT}; }}
|
||||
QPushButton#Primary:pressed {{ background: {AMBER_DARK}; }}
|
||||
QPushButton#Primary:disabled {{ background: #4A412D; color: #9A8C6D; border-color: #4A412D; }}
|
||||
QPushButton#Secondary {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
color: {TEXT};
|
||||
font-weight: 600;
|
||||
}}
|
||||
QPushButton#Secondary:hover {{ background: {SURFACE_HOVER}; border-color: #2A5060; }}
|
||||
QPushButton#Ghost {{
|
||||
color: {MUTED};
|
||||
border: 1px solid transparent;
|
||||
padding: 8px 10px;
|
||||
}}
|
||||
QPushButton#Ghost:hover {{ color: {TEXT}; background: {SURFACE_RAISED}; }}
|
||||
QPushButton#Danger {{
|
||||
background: rgba(255,107,94,0.12);
|
||||
border: 1px solid rgba(255,107,94,0.35);
|
||||
color: #FFB3AC;
|
||||
font-weight: 600;
|
||||
}}
|
||||
QPushButton#Danger:hover {{ background: rgba(255,107,94,0.2); }}
|
||||
QPushButton#Secondary:disabled {{
|
||||
color: {DIM};
|
||||
background: #0A1A22;
|
||||
border-color: {BORDER_SOFT};
|
||||
}}
|
||||
QPushButton#Danger:disabled {{
|
||||
color: #6E4A47;
|
||||
background: rgba(255,107,94,0.04);
|
||||
border-color: rgba(255,107,94,0.14);
|
||||
}}
|
||||
QPushButton#Ghost:disabled, QPushButton#Link:disabled {{
|
||||
color: #40555E;
|
||||
}}
|
||||
QPushButton#Link {{
|
||||
color: {TEAL};
|
||||
font-weight: 700;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}}
|
||||
QPushButton#Link:hover {{ color: #8CF5E8; }}
|
||||
QPushButton#IconButton {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
}}
|
||||
QPushButton#IconButton:hover {{ background: {SURFACE_HOVER}; }}
|
||||
QPushButton:focus {{
|
||||
border: 1px solid {AMBER};
|
||||
}}
|
||||
QLineEdit, QPlainTextEdit, QTextEdit, QComboBox {{
|
||||
background: {BG_DEEP};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 9px;
|
||||
padding: 9px 11px;
|
||||
color: {TEXT};
|
||||
selection-background-color: {AMBER};
|
||||
selection-color: #1B1304;
|
||||
}}
|
||||
QLineEdit:hover, QPlainTextEdit:hover, QComboBox:hover {{ border-color: #285163; }}
|
||||
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border-color: {AMBER}; }}
|
||||
QLineEdit:disabled, QPlainTextEdit:disabled, QComboBox:disabled {{
|
||||
color: {DIM}; background: #08151C;
|
||||
}}
|
||||
QPlainTextEdit#MonoInput {{
|
||||
font-family: "{FONT_MONO}";
|
||||
font-size: 12px;
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
border: none;
|
||||
width: 30px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url("{chevron}");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}}
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
selection-background-color: {SURFACE_HOVER};
|
||||
selection-color: {TEXT};
|
||||
padding: 4px;
|
||||
}}
|
||||
QCheckBox, QRadioButton {{
|
||||
color: {TEXT_SOFT};
|
||||
spacing: 9px;
|
||||
background: transparent;
|
||||
}}
|
||||
QCheckBox::indicator {{
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #2A4B5A;
|
||||
background: {BG_DEEP};
|
||||
}}
|
||||
QCheckBox::indicator:hover {{ border-color: {AMBER}; }}
|
||||
QCheckBox::indicator:checked {{
|
||||
background: {AMBER};
|
||||
border-color: {AMBER_BRIGHT};
|
||||
image: url("{check}");
|
||||
}}
|
||||
QRadioButton::indicator {{
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid #2A4B5A;
|
||||
background: {BG_DEEP};
|
||||
}}
|
||||
QRadioButton::indicator:checked {{
|
||||
background: {AMBER};
|
||||
border-color: {AMBER_BRIGHT};
|
||||
image: url("{radio}");
|
||||
}}
|
||||
QRadioButton#ChoiceTile {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 11px;
|
||||
padding: 12px;
|
||||
}}
|
||||
QRadioButton#ChoiceTile:hover {{ border-color: #2D5668; }}
|
||||
QRadioButton#ChoiceTile:checked {{
|
||||
border-color: {AMBER};
|
||||
background: rgba(242,184,75,0.07);
|
||||
}}
|
||||
QFrame#SegmentChip {{
|
||||
background: rgba(8,22,30,0.55);
|
||||
border: 1px solid {BORDER_SOFT};
|
||||
border-radius: 9px;
|
||||
}}
|
||||
QFrame#ConcernRow {{
|
||||
border-bottom: 1px solid {BORDER_SOFT};
|
||||
}}
|
||||
QFrame#ConcernRow:hover {{
|
||||
background: rgba(255,255,255,0.02);
|
||||
}}
|
||||
QTabWidget {{
|
||||
background: transparent;
|
||||
}}
|
||||
QTabWidget::pane {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
QTabWidget::tab-bar {{
|
||||
background: transparent;
|
||||
}}
|
||||
QTabBar {{
|
||||
background: transparent;
|
||||
qproperty-drawBase: 0;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: transparent;
|
||||
color: {MUTED};
|
||||
padding: 8px 14px;
|
||||
margin-right: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background-color: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
color: {TEXT};
|
||||
}}
|
||||
QTabBar::tab:hover {{ color: {TEXT}; }}
|
||||
QPushButton#TabPill {{
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: {MUTED};
|
||||
padding: 7px 15px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
QPushButton#TabPill:hover {{ color: {TEXT}; }}
|
||||
QPushButton#TabPill:checked {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
color: {TEXT};
|
||||
}}
|
||||
QTableWidget, QTableView {{
|
||||
background: transparent;
|
||||
alternate-background-color: transparent;
|
||||
border: none;
|
||||
gridline-color: transparent;
|
||||
selection-background-color: rgba(242,184,75,0.13);
|
||||
selection-color: {TEXT};
|
||||
}}
|
||||
QTableWidget::item {{ padding: 8px 6px; border-bottom: 1px solid {BORDER_SOFT}; }}
|
||||
QHeaderView {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
}}
|
||||
QHeaderView::section {{
|
||||
background-color: {SURFACE};
|
||||
color: {MUTED};
|
||||
border: none;
|
||||
border-bottom: 1px solid {BORDER};
|
||||
padding: 7px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
QTableWidget QHeaderView::section {{
|
||||
background-color: {SURFACE};
|
||||
}}
|
||||
QTableCornerButton::section {{ background: {SURFACE}; border: none; }}
|
||||
QListWidget {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER_SOFT};
|
||||
border-radius: 10px;
|
||||
margin: 0 0 7px 0;
|
||||
padding: 10px;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
border-color: {AMBER};
|
||||
background: rgba(242,184,75,0.08);
|
||||
color: {TEXT};
|
||||
}}
|
||||
QListWidget::item:hover {{ border-color: #2A5060; }}
|
||||
QScrollArea {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: transparent;
|
||||
width: 10px;
|
||||
margin: 2px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #23414E;
|
||||
min-height: 30px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: #2F5667; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: transparent;
|
||||
height: 10px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{ background: #23414E; border-radius: 4px; }}
|
||||
QProgressBar {{
|
||||
background: {BG_DEEP};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
height: 7px;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: {AMBER};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
QSplitter::handle {{ background: transparent; width: 10px; }}
|
||||
QToolTip {{
|
||||
background: {SURFACE_RAISED};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
QMenu {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
}}
|
||||
QMenu::item {{ padding: 7px 22px 7px 12px; border-radius: 5px; }}
|
||||
QMenu::item:selected {{ background: {SURFACE_HOVER}; }}
|
||||
QTextBrowser {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: {TEXT_SOFT};
|
||||
selection-background-color: {AMBER};
|
||||
selection-color: #1B1304;
|
||||
}}
|
||||
QDialog {{ background: {BG}; }}
|
||||
QFrame#LoginBrand {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #0D2B38, stop:0.6 #08202B, stop:1 #07131A);
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 18px;
|
||||
}}
|
||||
QFrame#LoginForm {{
|
||||
background: {SURFACE};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 18px;
|
||||
}}
|
||||
QFrame#Toast {{
|
||||
background: {SURFACE_RAISED};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 11px;
|
||||
}}
|
||||
QLabel#ToastTitle {{ font-weight: 700; color: {TEXT}; }}
|
||||
QLabel#ToastBody {{ color: {MUTED}; font-size: 12.5px; }}
|
||||
QFrame#Divider {{ background: {BORDER_SOFT}; max-height: 1px; border: none; }}
|
||||
"""
|
||||
|
||||
|
||||
def overall_color(overall: str) -> str:
|
||||
return {
|
||||
"protected": TEAL,
|
||||
"attention": AMBER,
|
||||
"at_risk": DANGER,
|
||||
"setup": INFO,
|
||||
}.get(overall, MUTED)
|
||||
|
||||
|
||||
def severity_color(severity: str) -> str:
|
||||
return {
|
||||
"critical": DANGER,
|
||||
"warning": AMBER,
|
||||
"info": INFO,
|
||||
"good": TEAL,
|
||||
"warn": AMBER,
|
||||
"bad": DANGER,
|
||||
"unknown": DIM,
|
||||
}.get(str(severity or "").lower(), MUTED)
|
||||
|
||||
|
||||
def status_color(status: str) -> str:
|
||||
return {
|
||||
"completed": TEAL,
|
||||
"running": AMBER,
|
||||
"cancelled": DIM,
|
||||
"failed": DANGER,
|
||||
"completed_with_errors": AMBER,
|
||||
"quarantined": TEAL,
|
||||
"deleted": TEAL,
|
||||
"restored": INFO,
|
||||
"detected": DANGER,
|
||||
}.get(str(status or "").lower(), MUTED)
|
||||
|
||||
|
||||
def parse_iso(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def relative_time(value: str | None) -> str:
|
||||
parsed = parse_iso(value)
|
||||
if parsed is None:
|
||||
return "Never"
|
||||
delta = (datetime.now(timezone.utc) - parsed).total_seconds()
|
||||
if delta < 45:
|
||||
return "just now"
|
||||
if delta < 3600:
|
||||
return f"{int(delta // 60)}m ago"
|
||||
if delta < 86400:
|
||||
return f"{int(delta // 3600)}h ago"
|
||||
if delta < 86400 * 7:
|
||||
return f"{int(delta // 86400)}d ago"
|
||||
return parsed.astimezone().strftime("%d %b %Y")
|
||||
|
||||
|
||||
def format_datetime(value: str | None) -> str:
|
||||
parsed = parse_iso(value)
|
||||
if parsed is None:
|
||||
return "—"
|
||||
return parsed.astimezone().strftime("%d %b %Y · %H:%M")
|
||||
|
||||
|
||||
def format_size(size: int | float | None) -> str:
|
||||
value = float(size or 0)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if value < 1024 or unit == "TB":
|
||||
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} TB"
|
||||
|
||||
|
||||
def format_duration(seconds: float | int | None) -> str:
|
||||
total = int(float(seconds or 0))
|
||||
if total < 60:
|
||||
return f"{total}s"
|
||||
minutes, sec = divmod(total, 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {sec}s"
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
|
||||
def version_line() -> str:
|
||||
return f"Version {__version__} · build {__build_date__}"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Bounded QThread shutdown for window close."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QThread
|
||||
|
||||
|
||||
def stop_thread(worker: QThread | None, wait_ms: int = 4000) -> None:
|
||||
"""Let a worker finish, then terminate it as a last resort.
|
||||
|
||||
QThread must never be destroyed while still running; Qt aborts the
|
||||
process. Network and PowerShell calls can outlive a window close, so
|
||||
request interruption, wait, and only then terminate. This runs during
|
||||
application shutdown, never on a normal user action.
|
||||
"""
|
||||
if worker is None:
|
||||
return
|
||||
try:
|
||||
if worker.isRunning():
|
||||
worker.requestInterruption()
|
||||
if not worker.wait(max(250, int(wait_ms))):
|
||||
worker.terminate()
|
||||
worker.wait(1000)
|
||||
except RuntimeError:
|
||||
# The C++ object was already deleted by deleteLater().
|
||||
pass
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Release checks, version comparison, asset selection and checksum verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from . import __version__, paths
|
||||
from .api_client import ApiClient
|
||||
from .models import UpdateAsset, UpdateFeed, UpdateRelease
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VERSION_RE = re.compile(r"^[vV]?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def parse_version(value: str) -> tuple[tuple[int, ...], tuple] | None:
|
||||
match = _VERSION_RE.match(str(value or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
numbers = tuple(int(part) for part in match.group(1).split("."))
|
||||
prerelease_raw = match.group(2)
|
||||
if not prerelease_raw:
|
||||
prerelease: tuple = ()
|
||||
else:
|
||||
parts: list[tuple[int, object]] = []
|
||||
for token in prerelease_raw.split("."):
|
||||
if token.isdigit():
|
||||
parts.append((0, int(token)))
|
||||
else:
|
||||
parts.append((1, token))
|
||||
prerelease = tuple(parts)
|
||||
return numbers, prerelease
|
||||
|
||||
|
||||
def _compare_numbers(left: tuple[int, ...], right: tuple[int, ...]) -> int:
|
||||
length = max(len(left), len(right))
|
||||
left_padded = left + (0,) * (length - len(left))
|
||||
right_padded = right + (0,) * (length - len(right))
|
||||
return (left_padded > right_padded) - (left_padded < right_padded)
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str = __version__) -> bool:
|
||||
parsed_candidate = parse_version(candidate)
|
||||
if parsed_candidate is None:
|
||||
return False
|
||||
parsed_current = parse_version(current)
|
||||
if parsed_current is None:
|
||||
return True
|
||||
candidate_numbers, candidate_pre = parsed_candidate
|
||||
current_numbers, current_pre = parsed_current
|
||||
numbers = _compare_numbers(candidate_numbers, current_numbers)
|
||||
if numbers != 0:
|
||||
return numbers > 0
|
||||
# Same numeric version: a stable release outranks its prereleases.
|
||||
if not candidate_pre and current_pre:
|
||||
return True
|
||||
if candidate_pre and not current_pre:
|
||||
return False
|
||||
return candidate_pre > current_pre
|
||||
|
||||
|
||||
def choose_asset(release: UpdateRelease) -> UpdateAsset | None:
|
||||
assets = [asset for asset in release.assets if asset.download_url]
|
||||
if not assets:
|
||||
return None
|
||||
|
||||
def score(asset: UpdateAsset) -> tuple:
|
||||
name = asset.name.lower()
|
||||
executable = name.endswith(".exe")
|
||||
setup = "setup" in name or "installer" in name
|
||||
windows = "windows" in name or "win" in name
|
||||
archive = name.endswith(".zip")
|
||||
return (
|
||||
0 if (executable and setup) else
|
||||
1 if (executable and windows) else
|
||||
2 if executable else
|
||||
3 if (archive and windows) else
|
||||
4 if archive else 9,
|
||||
len(name),
|
||||
)
|
||||
|
||||
usable = [asset for asset in assets
|
||||
if asset.name.lower().endswith((".exe", ".zip", ".msi"))]
|
||||
return min(usable, key=score) if usable else None
|
||||
|
||||
|
||||
def checksum_asset(release: UpdateRelease) -> UpdateAsset | None:
|
||||
wanted = {"sha256sums.txt", "sha256sum.txt", "checksums.txt",
|
||||
"sha256sums", "checksums.sha256"}
|
||||
for asset in release.assets:
|
||||
if asset.name.lower() in wanted:
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
def parse_checksums(text: str) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in str(text or "").splitlines():
|
||||
match = re.match(r"^\s*([0-9a-fA-F]{64})\s+\*?(.+?)\s*$", line)
|
||||
if match:
|
||||
result[Path(match.group(2)).name] = match.group(1).lower()
|
||||
return result
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
base = Path(str(name or "download")).name
|
||||
base = re.sub(r"[^A-Za-z0-9._() +-]", "_", base)[:180]
|
||||
return base or "download"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadedUpdate:
|
||||
path: Path
|
||||
sha256: str
|
||||
verified: bool
|
||||
expected_sha256: str = ""
|
||||
|
||||
|
||||
class UpdateService:
|
||||
def __init__(self, api: ApiClient, channel: str = "release") -> None:
|
||||
self.api = api
|
||||
self.channel = "beta" if channel == "beta" else "release"
|
||||
|
||||
def check(self, force: bool = False) -> UpdateFeed:
|
||||
payload = self.api.get_updates(self.channel, force=force)
|
||||
return UpdateFeed.from_api(payload)
|
||||
|
||||
def update_available(self, feed: UpdateFeed) -> bool:
|
||||
return bool(feed.latest and is_newer(feed.latest.tag, __version__))
|
||||
|
||||
def download(self, release: UpdateRelease, asset: UpdateAsset,
|
||||
progress: Callable[[int, int], None] | None = None) -> DownloadedUpdate:
|
||||
target_dir = paths.updates_dir() / _safe_name(release.tag or release.id or "release")
|
||||
target = target_dir / _safe_name(asset.name)
|
||||
try:
|
||||
path, digest = self.api.download_asset(asset.download_url, target, progress)
|
||||
except Exception as exc:
|
||||
raise UpdateError(f"The update could not be downloaded: {exc}") from exc
|
||||
|
||||
expected = ""
|
||||
checksum_file = checksum_asset(release)
|
||||
if checksum_file is not None:
|
||||
temp_checksum = target_dir / "SHA256SUMS.txt"
|
||||
try:
|
||||
checksum_path, _hash = self.api.download_asset(
|
||||
checksum_file.download_url, temp_checksum)
|
||||
text = checksum_path.read_text(encoding="utf-8", errors="replace")
|
||||
expected = parse_checksums(text).get(asset.name, "")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not download the checksum file: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
temp_checksum.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if expected and digest.lower() != expected.lower():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise UpdateError("The downloaded update did not match its SHA-256 checksum.")
|
||||
return DownloadedUpdate(path=path, sha256=digest, verified=bool(expected),
|
||||
expected_sha256=expected)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Best-effort file locking for the real-time download gate.
|
||||
|
||||
Windows has no user-mode API that lets an ordinary process intercept every
|
||||
``CreateFile`` (that is a kernel minifilter). What this module *can* do is
|
||||
hold the download file open with ``dwShareMode = 0`` while ClamAV scans a
|
||||
copy, which makes Windows refuse other opens for that period. If the file
|
||||
was already open elsewhere, we fall back to scanning a copy and say so in
|
||||
the event detail. This is a meaningful speed bump, not a guarantee against
|
||||
a kernel-level bypass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LockError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
if os.name == "nt":
|
||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_CreateFileW = _kernel32.CreateFileW
|
||||
_CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.HANDLE]
|
||||
_CreateFileW.restype = wintypes.HANDLE
|
||||
_ReadFile = _kernel32.ReadFile
|
||||
_ReadFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||
_ReadFile.restype = wintypes.BOOL
|
||||
_CloseHandle = _kernel32.CloseHandle
|
||||
_CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
_CloseHandle.restype = wintypes.BOOL
|
||||
_SetFileInformationByHandle = _kernel32.SetFileInformationByHandle
|
||||
_SetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.c_int,
|
||||
wintypes.LPVOID, wintypes.DWORD]
|
||||
_SetFileInformationByHandle.restype = wintypes.BOOL
|
||||
|
||||
_GENERIC_READ = 0x80000000
|
||||
_DELETE = 0x00010000
|
||||
_OPEN_EXISTING = 3
|
||||
_FILE_ATTRIBUTE_NORMAL = 0x80
|
||||
_FILE_DISPOSITION_INFO_CLASS = 4
|
||||
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
||||
|
||||
class _FILE_DISPOSITION_INFO(ctypes.Structure):
|
||||
_fields_ = [("DeleteFile", wintypes.BOOL)]
|
||||
else:
|
||||
_kernel32 = None
|
||||
_FILE_DISPOSITION_INFO = None
|
||||
|
||||
|
||||
class ExclusiveFile:
|
||||
"""Open a file (share-deny on Windows) and copy it while held."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._handle = None
|
||||
self._file = None
|
||||
self._delete_access = False
|
||||
self._delete_on_close = False
|
||||
self._open = False
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return bool(self._open and os.name == "nt")
|
||||
|
||||
def open(self) -> None:
|
||||
if self._open:
|
||||
return
|
||||
if os.name == "nt":
|
||||
handle = _CreateFileW(
|
||||
str(self.path), _GENERIC_READ | _DELETE, 0, None, _OPEN_EXISTING,
|
||||
_FILE_ATTRIBUTE_NORMAL, None,
|
||||
)
|
||||
if handle == _INVALID_HANDLE_VALUE:
|
||||
handle = _CreateFileW(
|
||||
str(self.path), _GENERIC_READ, 0, None, _OPEN_EXISTING,
|
||||
_FILE_ATTRIBUTE_NORMAL, None,
|
||||
)
|
||||
self._delete_access = False
|
||||
else:
|
||||
self._delete_access = True
|
||||
if handle == _INVALID_HANDLE_VALUE:
|
||||
error = ctypes.get_last_error()
|
||||
raise LockError(f"Could not open {self.path.name} exclusively (error {error}).")
|
||||
self._handle = handle
|
||||
else:
|
||||
try:
|
||||
self._file = open(self.path, "rb")
|
||||
except OSError as exc:
|
||||
raise LockError(f"Could not open {self.path.name}: {exc}") from exc
|
||||
self._open = True
|
||||
|
||||
def copy_to(self, destination: Path) -> None:
|
||||
if not self._open:
|
||||
self.open()
|
||||
destination = Path(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if os.name == "nt":
|
||||
buffer = ctypes.create_string_buffer(1024 * 1024)
|
||||
read = wintypes.DWORD()
|
||||
with open(destination, "wb") as output:
|
||||
while True:
|
||||
ok = _ReadFile(self._handle, buffer, len(buffer),
|
||||
ctypes.byref(read), None)
|
||||
if not ok:
|
||||
raise LockError(
|
||||
f"Could not read {self.path.name} (error "
|
||||
f"{ctypes.get_last_error()}).")
|
||||
if read.value == 0:
|
||||
break
|
||||
output.write(buffer.raw[:read.value])
|
||||
else:
|
||||
with open(destination, "wb") as output:
|
||||
shutil.copyfileobj(self._file, output, length=1024 * 1024)
|
||||
except OSError as exc:
|
||||
raise LockError(f"Could not copy {self.path.name}: {exc}") from exc
|
||||
|
||||
def mark_delete_on_close(self) -> bool:
|
||||
if os.name != "nt" or not self._delete_access or self._handle is None:
|
||||
return False
|
||||
info = _FILE_DISPOSITION_INFO(True)
|
||||
ok = _SetFileInformationByHandle(
|
||||
self._handle, _FILE_DISPOSITION_INFO_CLASS, ctypes.byref(info),
|
||||
ctypes.sizeof(info),
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("Could not mark %s delete-on-close (error %s)",
|
||||
self.path, ctypes.get_last_error())
|
||||
return False
|
||||
self._delete_on_close = True
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._open:
|
||||
return
|
||||
if os.name == "nt":
|
||||
if self._handle is not None:
|
||||
_CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
else:
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
self._open = False
|
||||
|
||||
def __enter__(self) -> "ExclusiveFile":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def copy_for_scan(path: Path, destination: Path, attempts: int = 8,
|
||||
delay: float = 0.4) -> tuple[ExclusiveFile, bool]:
|
||||
"""Copy *path* while holding it where possible.
|
||||
|
||||
Returns ``(locker, exclusive)``. The caller must keep ``locker`` open
|
||||
until the scan finishes, then close it. If the file cannot be opened
|
||||
exclusively (another process is still writing or reading it), this
|
||||
retries briefly and then copies without the lock.
|
||||
"""
|
||||
import time
|
||||
|
||||
last_error: Exception | None = None
|
||||
for _ in range(max(1, attempts)):
|
||||
locker = ExclusiveFile(path)
|
||||
try:
|
||||
locker.open()
|
||||
locker.copy_to(destination)
|
||||
return locker, locker.exclusive
|
||||
except LockError as exc:
|
||||
last_error = exc
|
||||
try:
|
||||
locker.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(delay)
|
||||
# Last resort: a plain copy. The watcher already waited for the size to
|
||||
# stop changing, so this is still normally a complete file.
|
||||
try:
|
||||
shutil.copy2(path, destination)
|
||||
except OSError as exc:
|
||||
raise LockError(f"Could not copy {path.name}: {exc}") from exc
|
||||
logger.warning("Real-time scan could not exclusively lock %s: %s", path, last_error)
|
||||
return ExclusiveFile(path), False
|
||||
Reference in New Issue
Block a user