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>
192 lines
5.0 KiB
Python
192 lines
5.0 KiB
Python
"""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())
|