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,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
|
||||
Reference in New Issue
Block a user