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