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>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""Small typed records shared by the storage, scanner and UI layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Detection:
|
||||
path: str
|
||||
signature: str
|
||||
action: str = "detected"
|
||||
quarantine_id: str | None = None
|
||||
size: int = 0
|
||||
detected_at: str = ""
|
||||
id: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.detected_at:
|
||||
self.detected_at = now_iso()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanRecord:
|
||||
id: int | None
|
||||
started_at: str
|
||||
finished_at: str | None
|
||||
scope: str
|
||||
status: str
|
||||
paths: list[str]
|
||||
scanned_files: int = 0
|
||||
infected_files: int = 0
|
||||
errors: int = 0
|
||||
engine_version: str = ""
|
||||
db_version: str = ""
|
||||
duration_seconds: float = 0.0
|
||||
cancelled: bool = False
|
||||
detections: list[Detection] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def scope_label(self) -> str:
|
||||
return "Full disk" if self.scope == "full" else "User homes"
|
||||
|
||||
@property
|
||||
def threat_count(self) -> int:
|
||||
return max(self.infected_files, len(self.detections))
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineItem:
|
||||
id: str
|
||||
original_path: str
|
||||
quarantine_path: str
|
||||
signature: str
|
||||
sha256: str
|
||||
size: int
|
||||
created_at: str
|
||||
status: str = "quarantined"
|
||||
scan_id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealtimeEvent:
|
||||
created_at: str
|
||||
path: str
|
||||
signature: str
|
||||
action: str
|
||||
detail: str = ""
|
||||
id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClamAVInfo:
|
||||
available: bool
|
||||
path: Path | None = None
|
||||
engine_version: str = ""
|
||||
db_version: str = ""
|
||||
db_path: Path | None = None
|
||||
db_date: str = ""
|
||||
db_age_days: float | None = None
|
||||
error: str = ""
|
||||
|
||||
@property
|
||||
def db_fresh(self) -> bool:
|
||||
return self.db_age_days is not None and self.db_age_days <= 7
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
if not self.available:
|
||||
return self.error or "ClamAV not found"
|
||||
bits = [self.engine_version or "ClamAV"]
|
||||
if self.db_version:
|
||||
bits.append(f"signatures {self.db_version}")
|
||||
if self.db_age_days is not None:
|
||||
bits.append(f"{self.db_age_days:.0f}d old")
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateAsset:
|
||||
id: str
|
||||
name: str
|
||||
size: int
|
||||
content_type: str
|
||||
download_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateRelease:
|
||||
id: str
|
||||
tag: str
|
||||
name: str
|
||||
notes: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
html_url: str
|
||||
assets: list[UpdateAsset] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateFeed:
|
||||
channel: str
|
||||
latest: UpdateRelease | None
|
||||
releases: list[UpdateRelease]
|
||||
checked_at: str
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict) -> "UpdateFeed":
|
||||
def release_from(item: dict) -> UpdateRelease:
|
||||
assets = [
|
||||
UpdateAsset(
|
||||
id=str(asset.get("id") or ""),
|
||||
name=str(asset.get("name") or "download"),
|
||||
size=int(asset.get("size") or 0),
|
||||
content_type=str(asset.get("content_type") or ""),
|
||||
download_url=str(asset.get("download_url") or ""),
|
||||
)
|
||||
for asset in (item.get("assets") or [])
|
||||
if isinstance(asset, dict)
|
||||
]
|
||||
return UpdateRelease(
|
||||
id=str(item.get("id") or ""),
|
||||
tag=str(item.get("tag") or ""),
|
||||
name=str(item.get("name") or item.get("tag") or ""),
|
||||
notes=str(item.get("notes") or ""),
|
||||
prerelease=bool(item.get("prerelease")),
|
||||
published_at=str(item.get("published_at") or item.get("created_at") or ""),
|
||||
html_url=str(item.get("html_url") or ""),
|
||||
assets=assets,
|
||||
)
|
||||
|
||||
releases = [release_from(item) for item in (payload.get("releases") or [])
|
||||
if isinstance(item, dict)]
|
||||
latest_raw = payload.get("latest")
|
||||
latest = release_from(latest_raw) if isinstance(latest_raw, dict) else (
|
||||
releases[0] if releases else None)
|
||||
return cls(
|
||||
channel=str(payload.get("channel") or "release"),
|
||||
latest=latest,
|
||||
releases=releases,
|
||||
checked_at=str(payload.get("checked_at") or now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def display_name(path: str) -> str:
|
||||
"""Basename for a Windows or POSIX path, independent of host OS."""
|
||||
text = str(path or "")
|
||||
name = Path(text.replace("\\", "/")).name
|
||||
return name or text
|
||||
|
||||
|
||||
def human_scope(scope: str) -> str:
|
||||
return "Full disk" if scope == "full" else "User homes"
|
||||
|
||||
|
||||
def human_action(action: str) -> str:
|
||||
return {
|
||||
"detected": "Detected",
|
||||
"quarantined": "Quarantined",
|
||||
"deleted": "Deleted",
|
||||
"failed": "Action failed",
|
||||
"ignored": "Ignored",
|
||||
"allowed": "Allowed",
|
||||
"blocked": "Blocked",
|
||||
}.get(action, action.replace("_", " ").title())
|
||||
Reference in New Issue
Block a user