"""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'Create or manage your account') 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()