3 Commits
Author SHA1 Message Date
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
TheMajesticMagicianandClaude Code ffe95020bc Fix PyQt6 drawText overload in the startup splash
The splash passed two alignment flags to QPainter.drawText, which has no matching PyQt6 overload. Combine them into one int flags value and cast the remaining painted labels for safety. Adds splash and full-page render coverage.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 17:12:02 -06:00
TheMajesticMagicianandClaude Code b262b1e664 Fix Python 3.11 detection in the Windows build script
sys.version_info[:2] prints '(3, 11)', so the old substring check for '3.11' always failed even on a valid Python install. Probe the major.minor string explicitly and support the py launcher or python on PATH.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 17:06:10 -06:00
6 changed files with 75 additions and 26 deletions
+13 -9
View File
@@ -10,15 +10,19 @@ $Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root Set-Location $Root
function Get-Python { function Get-Python {
$candidates = @("py -3.11", "py -3", "python") $candidates = @(
@{ Command = "py"; Arguments = @("-3.11") },
@{ Command = "py"; Arguments = @("-3") },
@{ Command = "python"; Arguments = @() },
@{ Command = "python3"; Arguments = @() }
)
foreach ($candidate in $candidates) { foreach ($candidate in $candidates) {
try { try {
$parts = $candidate.Split(" ") $exe = $candidate.Command
$exe = $parts[0] $exeArgs = @($candidate.Arguments)
$exeArgs = @($parts | Select-Object -Skip 1) $probe = & $exe @exeArgs -c "import sys; print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1]))" 2>$null
$output = & $exe @exeArgs -c "import sys; print(sys.executable); print(sys.version_info[:2])" 2>$null if ($LASTEXITCODE -eq 0 -and (($probe | Out-String).Trim() -eq "3.11")) {
if ($LASTEXITCODE -eq 0 -and $output -match "3\.11") { return $candidate
return $parts
} }
} catch { } } catch { }
} }
@@ -31,8 +35,8 @@ $python = Get-Python
if (-not (Test-Path $venv)) { if (-not (Test-Path $venv)) {
Write-Host "Creating build environment..." Write-Host "Creating build environment..."
$pythonExe = $python[0] $pythonExe = $python.Command
$pythonArgs = @($python | Select-Object -Skip 1) $pythonArgs = @($python.Arguments)
& $pythonExe @pythonArgs -m venv $venv & $pythonExe @pythonArgs -m venv $venv
} }
$py = Join-Path $venv "Scripts\python.exe" $py = Join-Path $venv "Scripts\python.exe"
+18 -10
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
import logging import logging
import os import os
import sys import sys
import threading
import time import time
import traceback
from pathlib import Path from pathlib import Path
from PyQt6.QtCore import QEventLoop, QLockFile, QRectF, Qt, QTimer from PyQt6.QtCore import QEventLoop, QLockFile, QRectF, Qt, QTimer
@@ -18,7 +20,7 @@ from .auth import AuthService, CredentialStore, Session, SessionExpired
from .clamav import ClamAVService from .clamav import ClamAVService
from .config import AccountCache, SettingsStore from .config import AccountCache, SettingsStore
from .context import AppContext from .context import AppContext
from .logging_setup import configure as configure_logging from .logging_setup import configure as configure_logging, log_path
from .quarantine import QuarantineService from .quarantine import QuarantineService
from .storage import Storage from .storage import Storage
from .ui import brand, theme from .ui import brand, theme
@@ -50,20 +52,19 @@ def _splash_pixmap() -> QPixmap:
brand.paint_mark(painter, QRectF(34, 34, 72, 72)) brand.paint_mark(painter, QRectF(34, 34, 72, 72))
painter.setPen(QColor(theme.TEXT)) painter.setPen(QColor(theme.TEXT))
painter.setFont(theme.display_font(26, QFont.Weight.DemiBold)) painter.setFont(theme.display_font(26, QFont.Weight.DemiBold))
painter.drawText(QRectF(124, 36, 300, 40), Qt.AlignmentFlag.AlignLeft, left_center = int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
Qt.AlignmentFlag.AlignVCenter, "BoltClam") painter.drawText(QRectF(124, 36, 300, 40), left_center, "BoltClam")
painter.setPen(QColor(theme.MUTED)) painter.setPen(QColor(theme.MUTED))
painter.setFont(theme.body_font(10.0)) painter.setFont(theme.body_font(10.0))
painter.drawText(QRectF(126, 76, 300, 24), Qt.AlignmentFlag.AlignLeft, painter.drawText(QRectF(126, 76, 300, 24), left_center,
Qt.AlignmentFlag.AlignVCenter, "ClamAV protection, without the noise.") "ClamAV protection, without the noise.")
painter.setPen(QColor(theme.TEXT_SOFT)) painter.setPen(QColor(theme.TEXT_SOFT))
painter.setFont(theme.body_font(10.5)) painter.setFont(theme.body_font(10.5))
painter.drawText(QRectF(36, 154, 390, 26), Qt.AlignmentFlag.AlignLeft, painter.drawText(QRectF(36, 154, 390, 26), left_center, "Starting protection…")
Qt.AlignmentFlag.AlignVCenter, "Starting protection…")
painter.setPen(QColor(theme.DIM)) painter.setPen(QColor(theme.DIM))
painter.setFont(theme.mono_font(8.0)) painter.setFont(theme.mono_font(8.0))
painter.drawText(QRectF(36, 206, 390, 20), Qt.AlignmentFlag.AlignLeft, painter.drawText(QRectF(36, 206, 390, 20), left_center,
Qt.AlignmentFlag.AlignVCenter, f"Version {__version__} · {__build_date__}") f"Version {__version__} · {__build_date__}")
painter.end() painter.end()
return image return image
@@ -74,10 +75,17 @@ def _install_exception_hook() -> None:
sys.__excepthook__(exc_type, exc_value, exc_traceback) sys.__excepthook__(exc_type, exc_value, exc_traceback)
return return
logger.critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback)) 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: try:
QMessageBox.critical( QMessageBox.critical(
None, "BoltClam encountered an error", None, "BoltClam encountered an error",
f"{exc_type.__name__}: {exc_value}\n\nDetails were written to the BoltClam log.") f"{detail}\n\nFull details: {log_path()}")
except Exception: except Exception:
pass pass
+10
View File
@@ -259,6 +259,16 @@ class DownloadWatcher(QThread):
self._stop.set() self._stop.set()
def run(self) -> None: def run(self) -> None:
try:
self._watch_loop()
except Exception:
logger.exception("Real-time watcher stopped after an error")
try:
self.watch_status.emit("Real-time watcher stopped")
except Exception:
pass
def _watch_loop(self) -> None:
import threading import threading
stop = threading.Event() stop = threading.Event()
+7 -5
View File
@@ -142,7 +142,7 @@ class Avatar(QWidget):
painter.setPen(QColor(theme.AMBER_BRIGHT)) painter.setPen(QColor(theme.AMBER_BRIGHT))
font = theme.display_font(self._size * 0.30, QFont.Weight.DemiBold) font = theme.display_font(self._size * 0.30, QFont.Weight.DemiBold)
painter.setFont(font) painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.initials()) painter.drawText(rect, int(Qt.AlignmentFlag.AlignCenter), self.initials())
class ProtectionDial(QWidget): class ProtectionDial(QWidget):
@@ -331,8 +331,9 @@ class NavButton(QAbstractButton):
painter.setPen(QColor(theme.TEXT if checked else theme.TEXT_SOFT)) painter.setPen(QColor(theme.TEXT if checked else theme.TEXT_SOFT))
font = theme.body_font(10.4, QFont.Weight.DemiBold if checked else QFont.Weight.Normal) font = theme.body_font(10.4, QFont.Weight.DemiBold if checked else QFont.Weight.Normal)
painter.setFont(font) painter.setFont(font)
painter.drawText(QRectF(46, 0, self.width() - 52, self.height()), painter.drawText(
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, QRectF(46, 0, self.width() - 52, self.height()),
int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft),
self.text()) self.text())
@@ -529,8 +530,9 @@ class ScanButton(QAbstractButton):
painter.setPen(QColor("#241704" if self.isEnabled() else "#807A68")) painter.setPen(QColor("#241704" if self.isEnabled() else "#807A68"))
painter.setFont(theme.body_font(11.2, QFont.Weight.Bold)) painter.setFont(theme.body_font(11.2, QFont.Weight.Bold))
painter.drawText(QRectF(core.left(), core.center().y() + 20, core.width(), 24), painter.drawText(
Qt.AlignmentFlag.AlignCenter, QRectF(core.left(), core.center().y() + 20, core.width(), 24),
int(Qt.AlignmentFlag.AlignCenter),
"Scanning…" if self._scanning else "Start scan") "Scanning…" if self._scanning else "Start scan")
+13
View File
@@ -58,6 +58,19 @@ def test_firefox_download_preference_is_watched(tmp_path, monkeypatch):
assert download.resolve() in [path.resolve() for path in roots] assert download.resolve() in [path.resolve() for path in roots]
def test_watcher_stops_cleanly_instead_of_raising(monkeypatch, qapp):
messages: list[str] = []
watcher = realtime.DownloadWatcher(interval=0.1)
watcher.watch_status.connect(messages.append)
monkeypatch.setattr(realtime, "browser_download_roots", lambda extra: (_ for _ in ()).throw(
RuntimeError("simulated watcher failure")))
watcher.start()
assert watcher.wait(3000)
qapp.processEvents()
assert not watcher.isRunning()
assert "stopped" in " ".join(messages).lower()
def test_copy_for_scan_preserves_bytes(tmp_path): def test_copy_for_scan_preserves_bytes(tmp_path):
source = tmp_path / "download.exe" source = tmp_path / "download.exe"
source.write_bytes(b"sample bytes") source.write_bytes(b"sample bytes")
+12
View File
@@ -66,12 +66,24 @@ def test_main_window_builds_four_pages_and_switches_tabs(qapp, isolated_env):
assert window.stack.currentIndex() == 3 assert window.stack.currentIndex() == 3
assert window.settings_page.channel_combo.currentData() == "release" assert window.settings_page.channel_combo.currentData() == "release"
assert window.about_page.rows["account"].value_label.text() == "Aaron" assert window.about_page.rows["account"].value_label.text() == "Aaron"
for index in range(4):
window.stack.setCurrentIndex(index)
qapp.processEvents()
assert not window.stack.currentWidget().grab().isNull()
finally: finally:
window._allow_close = True window._allow_close = True
window.close() window.close()
qapp.processEvents() qapp.processEvents()
def test_splash_pixmap_renders(qapp):
from boltclam.app import _splash_pixmap
pixmap = _splash_pixmap()
assert not pixmap.isNull()
assert pixmap.width() == 460 and pixmap.height() == 250
def test_login_dialog_builds_without_starting_a_worker(qapp, isolated_env): def test_login_dialog_builds_without_starting_a_worker(qapp, isolated_env):
auth = AuthService( auth = AuthService(
"http://127.0.0.1:9", install_id="test", "http://127.0.0.1:9", install_id="test",