Files
BoltClam/src/boltclam/app.py
T
TheMajesticMagicianandClaude Code 47f520fb8f Improve crash diagnostics and isolate watcher-thread failures
Main-thread unhandled exceptions now show the traceback and log path; worker-thread exceptions log without touching Qt widgets. The download watcher catches its own loop failures and reports that it stopped instead of surfacing a blank error dialog. Adds a regression test for the watcher failure path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 17:22:20 -06:00

202 lines
7.3 KiB
Python

"""Application bootstrap: login/restore, dependency wiring, and window lifecycle."""
from __future__ import annotations
import logging
import os
import sys
import threading
import time
import traceback
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, log_path
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))
left_center = int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
painter.drawText(QRectF(124, 36, 300, 40), left_center, "BoltClam")
painter.setPen(QColor(theme.MUTED))
painter.setFont(theme.body_font(10.0))
painter.drawText(QRectF(126, 76, 300, 24), left_center,
"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), left_center, "Starting protection…")
painter.setPen(QColor(theme.DIM))
painter.setFont(theme.mono_font(8.0))
painter.drawText(QRectF(36, 206, 390, 20), left_center,
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))
# Qt widget calls are not safe from worker threads. Log there and
# leave the UI alone; the page's own status/toast reports the failure.
if threading.current_thread() is not threading.main_thread():
return
detail = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)).strip()
if len(detail) > 3200:
detail = "…\n" + detail[-3200:]
try:
QMessageBox.critical(
None, "BoltClam encountered an error",
f"{detail}\n\nFull details: {log_path()}")
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