Files
BoltClam/src/boltclam/ui/login_dialog.py
T
TheMajesticMagicianandClaude Code addd00364d 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>
2026-10-25 16:21:14 -06:00

349 lines
13 KiB
Python

"""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()