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>
202 lines
7.3 KiB
Python
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
|