Socle de la refonte GUI V6 (couche présentation uniquement, aucune logique de détection) : - license_store: stockage licence hors dépôt (%LOCALAPPDATA%/Aivanov | XDG), read/write atomique/delete, ne journalise aucun token - license_client: LicenseStatus + activate/check/local_status, session HTTP injectable, serveur indisponible géré sans crash, aucune clé privée - theme: 4 thèmes + couleurs de statut licence - app + tab_about: shell customtkinter minimal (header, bandeau licence, 3 onglets), onglet À propos étoffé - Pseudonymisation_Gui_V6.py: point d'entrée + --self-test (exit 0 sans fenêtre) - requirements.txt: customtkinter==5.2.2 Tests: 20 nouveaux (store sur vrais fichiers, client sur session injectée). Suite tests/unit: 167 passed, 0 régression. V5/moteur/managers/specs intacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
7.0 KiB
Python
196 lines
7.0 KiB
Python
"""Client licence de la GUI V6 (mockable, sans clé privée).
|
|
|
|
Contrat final aligné sur le portail ``app_aivanov`` :
|
|
|
|
- ``activate(token, machine_id)`` → ``POST /api/v1/activate``
|
|
- ``check(license_ref, machine_id)`` → ``POST /api/v1/check``
|
|
|
|
Principes :
|
|
|
|
- La session HTTP est **injectable** (paramètre ``session``) : en test on
|
|
fournit une fausse session, aucun appel réseau réel n'est effectué.
|
|
- Le client **ne contient aucune clé privée** : la vérification de signature
|
|
est faite côté serveur ; le client stocke seulement la licence signée reçue.
|
|
- Serveur indisponible / réponse illisible → statut ``unavailable``, jamais
|
|
d'exception remontée à l'appelant (la GUI ne doit pas crasher).
|
|
- Aucun token n'est journalisé.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional, Protocol
|
|
|
|
from gui_v6.license_store import LicenseStore
|
|
|
|
# Statuts métier exposés à l'UI.
|
|
ACTIVE = "active"
|
|
GRACE = "grace"
|
|
EXPIRED = "expired"
|
|
REVOKED = "revoked"
|
|
NONE = "none"
|
|
UNAVAILABLE = "unavailable"
|
|
INVALID = "invalid"
|
|
|
|
_VALID_STATES = {ACTIVE, GRACE}
|
|
|
|
|
|
class _HttpResponse(Protocol):
|
|
status_code: int
|
|
|
|
def json(self) -> Any: ...
|
|
|
|
|
|
class _HttpSession(Protocol):
|
|
def post(self, url: str, json: dict, timeout: float) -> _HttpResponse: ...
|
|
|
|
|
|
@dataclass
|
|
class LicenseStatus:
|
|
"""État de licence destiné à l'affichage et aux décisions de la GUI."""
|
|
|
|
valid: bool
|
|
status: str
|
|
message: str = ""
|
|
expires_at: Optional[str] = None
|
|
grace_days: int = 0
|
|
machine_id: Optional[str] = None
|
|
license_ref: Optional[str] = None
|
|
|
|
@classmethod
|
|
def none(cls, message: str = "Aucune licence active") -> "LicenseStatus":
|
|
return cls(valid=False, status=NONE, message=message)
|
|
|
|
@classmethod
|
|
def unavailable(
|
|
cls, message: str = "Serveur de licence indisponible"
|
|
) -> "LicenseStatus":
|
|
return cls(valid=False, status=UNAVAILABLE, message=message)
|
|
|
|
@classmethod
|
|
def invalid(cls, message: str = "Licence ou jeton invalide") -> "LicenseStatus":
|
|
return cls(valid=False, status=INVALID, message=message)
|
|
|
|
@classmethod
|
|
def from_payload(
|
|
cls, payload: dict[str, Any], machine_id: Optional[str] = None
|
|
) -> "LicenseStatus":
|
|
"""Construit un statut depuis la réponse API ou un ancien payload plat.
|
|
|
|
Le portail renvoie ``{"state": "...", "license": {"payload": ...}}``.
|
|
Les payloads plats ``{"status": "...", "license_ref": ...}`` restent
|
|
acceptés pour garder les tests/unités locales simples et robustes.
|
|
"""
|
|
status = str(payload.get("state") or payload.get("status") or NONE)
|
|
license_envelope = payload.get("license")
|
|
signed_payload = (
|
|
license_envelope.get("payload")
|
|
if isinstance(license_envelope, dict)
|
|
and isinstance(license_envelope.get("payload"), dict)
|
|
else {}
|
|
)
|
|
source = signed_payload if signed_payload else payload
|
|
try:
|
|
grace = int(source.get("grace_days", payload.get("grace_days", 0)) or 0)
|
|
except (TypeError, ValueError):
|
|
grace = 0
|
|
return cls(
|
|
valid=status in _VALID_STATES,
|
|
status=status,
|
|
message=str(payload.get("message", "") or ""),
|
|
expires_at=source.get("expires_at") or payload.get("expires_at"),
|
|
grace_days=grace,
|
|
machine_id=source.get("machine_id") or payload.get("machine_id") or machine_id,
|
|
license_ref=source.get("license_ref") or payload.get("license_ref"),
|
|
)
|
|
|
|
|
|
class LicenseClient:
|
|
"""Appelle le portail licence via une session HTTP injectable."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
session: Optional[_HttpSession] = None,
|
|
store: Optional[LicenseStore] = None,
|
|
timeout: float = 10.0,
|
|
) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
self._session = session
|
|
self._store = store if store is not None else LicenseStore()
|
|
self._timeout = timeout
|
|
|
|
def _get_session(self) -> _HttpSession:
|
|
if self._session is None:
|
|
# Import paresseux : pas de dépendance dure si une session est injectée.
|
|
import requests
|
|
|
|
self._session = requests.Session()
|
|
return self._session
|
|
|
|
def _post(self, endpoint: str, payload: dict) -> Optional[_HttpResponse]:
|
|
try:
|
|
session = self._get_session()
|
|
return session.post(
|
|
f"{self._base_url}{endpoint}", json=payload, timeout=self._timeout
|
|
)
|
|
except Exception:
|
|
# Réseau indisponible, DNS, timeout, requests absent… : pas de crash.
|
|
return None
|
|
|
|
@staticmethod
|
|
def _parse(response: Optional[_HttpResponse]) -> Optional[dict]:
|
|
if response is None:
|
|
return None
|
|
try:
|
|
data = response.json()
|
|
except Exception:
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
def activate(self, token: str, machine_id: str) -> LicenseStatus:
|
|
"""Active une licence à partir d'un jeton. Stocke la licence si valide."""
|
|
response = self._post(
|
|
"/api/v1/activate", {"token": token, "machine_id": machine_id}
|
|
)
|
|
if response is None:
|
|
return LicenseStatus.unavailable()
|
|
if getattr(response, "status_code", 500) >= 400:
|
|
payload = self._parse(response) or {}
|
|
message = str(payload.get("detail") or payload.get("message") or "")
|
|
return LicenseStatus.invalid(message or "Jeton refusé par le serveur")
|
|
payload = self._parse(response)
|
|
if payload is None:
|
|
return LicenseStatus.unavailable()
|
|
status = LicenseStatus.from_payload(payload, machine_id)
|
|
if status.valid:
|
|
# Stocke l'enveloppe signée reçue du serveur, jamais le jeton d'activation.
|
|
self._store.save(payload)
|
|
return status
|
|
|
|
def check(self, license_ref: str, machine_id: str) -> LicenseStatus:
|
|
"""Vérifie l'état d'une licence existante auprès du portail."""
|
|
response = self._post(
|
|
"/api/v1/check", {"license_ref": license_ref, "machine_id": machine_id}
|
|
)
|
|
if response is None:
|
|
return LicenseStatus.unavailable()
|
|
if getattr(response, "status_code", 500) >= 400:
|
|
payload = self._parse(response) or {}
|
|
message = str(payload.get("detail") or payload.get("message") or "")
|
|
return LicenseStatus.invalid(message or "Licence refusée par le serveur")
|
|
payload = self._parse(response)
|
|
if payload is None:
|
|
return LicenseStatus.unavailable()
|
|
status = LicenseStatus.from_payload(payload, machine_id)
|
|
if status.valid:
|
|
self._store.save(payload)
|
|
return status
|
|
|
|
def local_status(self) -> LicenseStatus:
|
|
"""État de licence depuis le stockage local, sans appel réseau."""
|
|
data = self._store.load()
|
|
if not data:
|
|
return LicenseStatus.none()
|
|
return LicenseStatus.from_payload(data)
|