From 902c17733025774915a0ffc087a7b525eb5be8da Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 4 Sep 2026 08:55:35 +0200 Subject: [PATCH] Add Remote DNS --- Makefile | 2 + app/core/dns.py | 171 ++++----- app/core/wireguard.py | 43 ++- app/ui/admin_window.py | 694 ++++++++++++++++++++++-------------- app/ui/history_dialog.py | 35 +- app/ui/main_window.py | 430 +++++++++++++--------- app/ui/mfa_dialog.py | 44 ++- app/ui/theme.py | 52 ++- app/utils/platform_utils.py | 4 + 9 files changed, 876 insertions(+), 599 deletions(-) diff --git a/Makefile b/Makefile index b52b285..50005a0 100644 --- a/Makefile +++ b/Makefile @@ -235,6 +235,8 @@ cmds = [c for c in ( \ w('ip') + ' link delete dev *' if w('ip') else '', \ w('resolvconf') + ' -d *' if w('resolvconf') else '', \ w('resolvectl') + ' revert *' if w('resolvectl') else '', \ + w('resolvectl') + ' dns *' if w('resolvectl') else '', \ + w('resolvectl') + ' domain *' if w('resolvectl') else '', \ w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \ ) if c]; \ rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \ diff --git a/app/core/dns.py b/app/core/dns.py index 3e30994..a8bbf7d 100644 --- a/app/core/dns.py +++ b/app/core/dns.py @@ -175,7 +175,7 @@ _SPLIT_DNS_RULE_PREFIX = "WGSecure-SplitDNS-" def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> str: """PowerShell script to add a NRPT rule for split-DNS. - + Args: server: DNS server IP address domains: List of domain suffixes (e.g., ["local", "internal"]) @@ -183,13 +183,23 @@ def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> s """ if not domains: return "" - # Build namespace list for NRPT rule - namespace_list = ", ".join(f"'{d.strip().strip('.')}'" for d in domains if d.strip()) + clean = [d.strip().strip(".") for d in domains if d.strip()] + # NRPT : un namespace sans point de tête ('h3adm.lan') ne matche que ce + # nom exact, jamais ses sous-domaines ; un namespace avec point de tête + # ('.h3adm.lan') ne matche que les sous-domaines, jamais le domaine lui- + # même. Les deux formes sont nécessaires pour couvrir toute la zone — + # comme `~domaine` sous systemd-resolved (Linux) couvre déjà les deux. + namespace_list = ", ".join(f"'{d}', '.{d}'" for d in clean) return ( f"$rule = Get-DnsClientNrptRule -Name '{rule_name}' -ErrorAction SilentlyContinue; " f"if ($rule) {{ Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue }}; " f"Add-DnsClientNrptRule -Name '{rule_name}' -Namespace @({namespace_list}) " - f"-NameServer '{server}' -Comment 'WGSecure Split-DNS' -ErrorAction SilentlyContinue" + f"-NameServer '{server}' -Comment 'WGSecure Split-DNS'; " + # Sans ce contrôle, une erreur réelle d'Add-DnsClientNrptRule restait + # invisible : le script se terminait quand même avec le code 0, et + # `setup_split_dns` rapportait un succès alors que la règle n'avait + # jamais été posée. + "if (-not $?) { exit 1 }" ) @@ -217,76 +227,36 @@ def _systemd_resolved_available() -> bool: def _setup_split_dns_linux(server: str, domains: list[str], iface: str) -> tuple[bool, str]: - """Configure le split-DNS sous Linux via systemd-resolved. - - Crée un fichier dans /etc/systemd/resolved.conf.d/ pour router - les domaines vers le serveur DNS spécifié. - - Note: Le redémarrage de systemd-resolved est nécessaire pour appliquer - la configuration, mais il est effectué de manière asynchrone pour - éviter de bloquer le handshake WireGuard. - - Args: - server: Adresse IP du serveur DNS (ex: 192.168.1.210) - domains: Liste de domaines (ex: ["local", "internal"]) - iface: Nom de l'interface WireGuard - - Returns: - (success, message) + """Configure le split-DNS sous Linux via systemd-resolved, par lien. + + Écrivait auparavant un fichier global dans /etc/systemd/resolved.conf.d/ + et redémarrait tout le service — deux privilèges que `make setup-sudoers` + ne couvrait pas (échec silencieux ou dialogue à chaque connexion), pour + un réglage qui entrait en concurrence avec la configuration éventuelle + d'autres liens et survivait à un arrêt brutal du tunnel. + + `resolvectl dns`/`resolvectl domain` posent la même route mais rattachée + à l'interface WireGuard elle-même — comme `wg-quick` le fait déjà pour le + DNS non split. `resolvectl revert ` (déjà autorisé sans mot de + passe, cf. `make setup-sudoers`) l'annule d'un coup, et systemd-resolved + la retire de lui-même dès que l'interface disparaît : plus de résidu à + surveiller après un crash. """ + if not shutil.which("resolvectl"): + return False, "resolvectl introuvable : split-DNS indisponible sur ce système" if not _systemd_resolved_available(): - return False, "systemd-resolved non disponible. Utilisez resolvconf ou configurez manuellement." - - # Créer le nom du fichier de config - config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf" - - # Contenu du fichier - domain_str = ", ".join(f"~{d.strip()}" for d in domains if d.strip()) - content = f"[Resolve]\nDNS={server}\nDomains={domain_str}\n" - - # Écrire le fichier (nécessite sudo) - import tempfile - import os - import subprocess - - # Créer un fichier temporaire - with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f: - f.write(content) - temp_path = f.name - - try: - # Copier le fichier temporaire vers /etc/systemd/resolved.conf.d/ - code, out, err = run_privileged( - ["sudo", "cp", temp_path, config_file], - timeout=10 - ) - - if code != 0: - return False, f"Échec de la copie du fichier: {err or out}" - - # Redémarrer systemd-resolved en arrière-plan pour ne pas bloquer - # Utiliser systemd-run pour un redémarrage asynchrone - restart_cmd = ["sudo", "systemctl", "restart", "systemd-resolved"] - try: - # Lancer en arrière-plan avec nohup pour éviter de bloquer - subprocess.Popen( - restart_cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - except Exception: - pass # Ignorer l'erreur, le fichier est déjà en place - - return True, ( - f"Split-DNS configuré pour {server} (domaines: {', '.join(domains)}). " - f"Redémarrez systemd-resolved manuellement si nécessaire: " - f"`sudo systemctl restart systemd-resolved`" - ) - finally: - try: - os.unlink(temp_path) - except Exception: - pass + return False, "systemd-resolved non actif : split-DNS indisponible" + + code, out, err = run_privileged(["resolvectl", "dns", iface, server], timeout=10) + if code != 0: + return False, f"Échec de configuration du DNS distant : {err or out or 'erreur inconnue'}" + + routing_domains = [f"~{d}" for d in domains] + code, out, err = run_privileged(["resolvectl", "domain", iface, *routing_domains], timeout=10) + if code != 0: + return False, f"Échec de configuration du domaine distant : {err or out or 'erreur inconnue'}" + + return True, f"Split-DNS configuré pour {server} (domaines : {', '.join(domains)})" def setup_split_dns(cfg: Config) -> tuple[bool, str]: @@ -334,34 +304,19 @@ def setup_split_dns(cfg: Config) -> tuple[bool, str]: def _cleanup_split_dns_linux(iface: str) -> tuple[bool, str]: - """Supprime la configuration split-DNS sous Linux. - - Note: Ne redémarre PAS systemd-resolved pour éviter de casser - la résolution DNS pendant une reconnexion. Le service sera - redémarré manuellement par l'utilisateur si nécessaire. - - Args: - iface: Nom de l'interface WireGuard - - Returns: - (success, message) + """Annule la config split-DNS par-lien de cette interface. + + `force_cleanup()` appelle ceci après avoir déjà supprimé l'interface la + plupart du temps (systemd-resolved retire alors sa config par-lien tout + seul) : `resolvectl revert` échoue simplement sur une interface déjà + partie, ce qui n'est pas une erreur — rien à nettoyer, pas un échec. """ - config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf" - - # Supprimer le fichier de config (nécessite sudo) - import os - if not os.path.exists(config_file): + if not shutil.which("resolvectl"): return True, "" - - code, out, err = run_privileged( - ["sudo", "rm", "-f", config_file], - timeout=10 - ) - - if code != 0: - return False, f"Échec de la suppression du fichier split-DNS: {err or out}" - - return True, "Configuration split-DNS supprimée (redémarrez systemd-resolved si nécessaire)" + code, out, err = run_privileged(["resolvectl", "revert", iface], timeout=10) + if code == 0: + return True, "Configuration split-DNS retirée" + return True, "" def cleanup_split_dns(cfg: Config) -> tuple[bool, str]: @@ -488,6 +443,26 @@ def resolution_works(timeout: float = 3.0) -> bool: socket.setdefaulttimeout(old) +def resolve_host(host: str, timeout: float = 3.0) -> tuple[bool, list[str]]: + """Résout un nom via le résolveur système. Retourne (succès, IPs uniques). + + Sert au split-DNS : `resolution_works()` ne confirme qu'un résolveur + quelconque répond, jamais que les noms du réseau distant (le domaine + configuré en split-DNS, ou un hôte à l'intérieur) résolvent bien vers ce + réseau plutôt que vers un NXDOMAIN ou une réponse publique inattendue. + """ + old = socket.getdefaulttimeout() + socket.setdefaulttimeout(timeout) + try: + infos = socket.getaddrinfo(host, None) + ips = list(dict.fromkeys(info[4][0] for info in infos)) + return True, ips + except OSError: + return False, [] + finally: + socket.setdefaulttimeout(old) + + def repair_if_broken(iface: str, tunnel_dns: str = "") -> tuple[bool, str]: """Répare le DNS si le tunnel est absent mais une entrée résiduelle traîne. diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 12b26f9..1bdc34e 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -268,6 +268,20 @@ def _wait_connected(cfg: Config, timeout: int = 15) -> bool: time.sleep(0.5) +def _post_connect_message(cfg: Config) -> str: + """Message de succès de connexion, complété d'un avertissement si le + split-DNS configuré n'a pas pu être appliqué. + + Son échec était auparavant ignoré silencieusement (`setup_split_dns` + appelé sans regarder son retour) : le tunnel se déclarait « activé » + même quand les noms du réseau distant ne se résolvaient jamais. + """ + ok, msg = dns_util.setup_split_dns(cfg) + if not ok: + return f"Tunnel WireGuard activé\n⚠ {msg}" + return "Tunnel WireGuard activé" + + def connect(cfg: Config) -> tuple[bool, str]: if not cfg.configured: return False, "WireGuard non configuré. Ouvrez le panneau Admin." @@ -302,9 +316,7 @@ def connect(cfg: Config) -> tuple[bool, str]: # démarrage effectif du tunnel est asynchrone. Tester l'état # immédiatement conclurait « échec » sur un tunnel qui monte. if code == 0 and _wait_connected(cfg, timeout=15): - # Configurer le split-DNS si un serveur dédié est défini - dns_util.setup_split_dns(cfg) - return True, "Tunnel WireGuard activé" + return True, _post_connect_message(cfg) force_cleanup(cfg) return False, err or f"Échec de l'installation du tunnel (code {code})" @@ -320,9 +332,7 @@ def connect(cfg: Config) -> tuple[bool, str]: # Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement code, _, err = run_privileged(["wg-quick", "up", name], timeout=90) if code == 0: - # Configurer le split-DNS si un serveur dédié est défini - dns_util.setup_split_dns(cfg) - return True, "Tunnel WireGuard activé" + return True, _post_connect_message(cfg) # Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le # dépassement de délai avant son trap de nettoyage). On démonte @@ -862,8 +872,25 @@ def _diag_tunnel(cfg: Config) -> list[dict]: steps.append(_step(_FAIL, "Résolution DNS dans le tunnel", "Plus aucun nom ne se résout — utilisez " "« Réparer le DNS »")) - else: - steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle")) + return steps + steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle")) + + # Un résolveur qui répond ne dit rien sur le split-DNS lui-même : le nom + # peut très bien se résoudre... vers une réponse publique inattendue au + # lieu du réseau distant. On vérifie donc explicitement le(s) domaine(s) + # configurés, pas seulement qu'une résolution quelconque fonctionne. + domains = wg.get("split_dns_domains") or [] + for domain in domains: + ok, ips = dns_util.resolve_host(domain, timeout=3.0) + if ok: + steps.append(_step(_OK, f"Résolution distante ({domain})", + "→ " + ", ".join(ips))) + else: + steps.append(_step( + _WARN, f"Résolution distante ({domain})", + "Ne se résout pas — vérifiez le serveur DNS distant " + f"({wg.get('split_dns_server', '?')}) et les règles NRPT/" + "systemd-resolved du split-DNS")) return steps diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index ec44d1a..70afe8e 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -4,7 +4,7 @@ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame, - QFileDialog, QSizePolicy, QListWidget, QScrollArea, + QFileDialog, QSizePolicy, QComboBox, QScrollArea, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont @@ -28,6 +28,8 @@ class AdminWindow(QDialog): self._diag_worker: ValueWorker | None = None self._dns_worker: ValueWorker | None = None self._dns_fix_worker: ValueWorker | None = None + self._resolve_worker: ValueWorker | None = None + self._fields_locked = False self.setWindowTitle("WGSecure — Panneau Administrateur") self.setWindowFlags( Qt.WindowType.Dialog @@ -37,12 +39,9 @@ class AdminWindow(QDialog): # Le découpage en sous-onglets a supprimé l'empilement qui imposait une # grande fenêtre : chaque page n'affiche plus qu'une section à la fois. self.setMinimumSize(640, 520) - self.resize(720, 600) + self.resize(720, 640) self._build_ui() self._load_values() - # L'aperçu s'ouvrait vide : il fallait cliquer « Rafraîchir » pour - # voir la configuration que l'application allait pourtant écrire. - self._refresh_preview() # ------------------------------------------------------------------ # # Helper : page sombre avec bandeau coloré @@ -63,10 +62,7 @@ class AdminWindow(QDialog): hdr = QLabel(f" {banner_text}") hdr.setFixedHeight(36) - hdr.setStyleSheet( - f"background: {banner_color}; color: white; font-size: 13px;" - " font-weight: bold; padding-left: 8px;" - ) + hdr.setStyleSheet(theme.banner_style(banner_color)) outer.addWidget(hdr) body = QWidget() @@ -93,47 +89,33 @@ class AdminWindow(QDialog): banner = QLabel(" WGSecure — Configuration Administrateur") banner.setFixedHeight(40) - banner.setStyleSheet( - "background: #17202a; color: white; font-size: 14px; font-weight: bold;" - ) + banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN)) layout.addWidget(banner) + # Onglets de premier niveau : config qui conditionne la connexion + # (WireGuard, Sécurité) d'abord, diagnostic et réglages d'appli + # ensuite, référence en dernier. tabs = QTabWidget() - tabs.setStyleSheet(""" - QTabWidget::pane { border: none; } - QTabBar::tab { padding: 8px 16px; background: #1c2833; - color: rgba(255,255,255,0.6); border: none; } - QTabBar::tab:selected { background: #2e4057; color: white; - font-weight: bold; } - QTabBar::tab:hover { background: #253545; color: white; } - """) + tabs.setStyleSheet(theme.TABBAR_CSS) tabs.addTab(self._tab_wireguard(), "WireGuard") + tabs.addTab(self._tab_security(), "Sécurité") tabs.addTab(self._tab_test(), "Test connexion") tabs.addTab(self._tab_settings(), "Paramètres") - tabs.addTab(self._tab_security(), "Sécurité") tabs.addTab(self._tab_about(), "À propos") layout.addWidget(tabs) # Barre de boutons bar = QWidget() - bar.setStyleSheet("background: #17202a;") + bar.setStyleSheet(f"background: {theme.BG_SUNKEN};") btn_row = QHBoxLayout(bar) btn_row.setContentsMargins(12, 8, 12, 10) btn_cancel = QPushButton("Fermer sans sauvegarder") - btn_cancel.setStyleSheet( - "QPushButton { padding: 8px 20px; background: #2e4057; color: white;" - " border-radius: 5px; border: none; }" - "QPushButton:hover { background: #3d5166; }" - ) + btn_cancel.setStyleSheet(theme.secondary_button_style()) btn_cancel.clicked.connect(self.reject) btn_save = QPushButton("Enregistrer et fermer") - btn_save.setStyleSheet( - "QPushButton { padding: 8px 20px; background: #1e8449; color: white;" - " border-radius: 5px; font-weight: bold; border: none; }" - "QPushButton:hover { background: #27ae60; }" - ) + btn_save.setStyleSheet(theme.primary_button_style()) btn_save.clicked.connect(self._save_and_close) btn_row.addStretch() @@ -154,7 +136,7 @@ class AdminWindow(QDialog): lbl.setFixedWidth(label_w) lbl.setFixedHeight(field_h) lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - lbl.setStyleSheet("color: rgba(255,255,255,0.75); background: transparent;") + lbl.setStyleSheet(f"color: {theme.TEXT_MUTED}; background: transparent;") if not isinstance(widget, QLayout): widget.setFixedHeight(field_h) form.addRow(lbl, widget) @@ -192,38 +174,115 @@ class AdminWindow(QDialog): def _tab_wireguard(self) -> QWidget: """Configuration WireGuard, en sous-onglets. - « WireGuard » et « Clés » étaient deux onglets de premier niveau qui se - chevauchaient : la clé publique du serveur se saisissait dans l'un - pendant que l'assistant qui la produit vivait dans l'autre, et les - quatre façons de sortir une configuration (aperçu, .conf, QR Code, - volet serveur) étaient réparties entre les deux sans logique. Le - découpage suit maintenant ce qu'on manipule : les paramètres du - tunnel, les clés, puis les fichiers. + Tunnel et Profils vivaient dans deux sous-onglets séparés alors + qu'ils décrivaient le même objet sous deux formes (champs éditables + d'un côté, aperçu texte en lecture seule de l'autre) — l'un + redisait ce que l'autre montrait déjà. Fusionnés : le profil actif, + les échanges de fichier et les champs du tunnel vivent maintenant + sur une seule page, dans l'ordre où on les utilise (quel profil → + quel fichier → quelles valeurs). Les clés restent à part : leur + cycle de vie (génération, régénération) est distinct. """ w, lay = self._dark_page("🛡️ WireGuard", theme.BANNER["wireguard"]) lay.setContentsMargins(0, 0, 0, 0) sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) - sub.addTab(self._subtab_tunnel(), "🔗 Tunnel") + sub.addTab(self._subtab_tunnel(), "🔗 Tunnel & profils") sub.addTab(self._subtab_keys(), "🔑 Clés") - sub.addTab(self._subtab_profiles(), "📂 Profils & fichiers") lay.addWidget(sub) self._refresh_profile_list() return w - # ── Sous-onglet : paramètres du tunnel ─────────────────────────────── + # ── Sous-onglet : tunnel & profils ──────────────────────────────────── def _subtab_tunnel(self) -> QWidget: page, lay = self._scroll_page() lay.addWidget(self._hint( - "Décrit le pair à joindre et la façon dont le trafic y entre. Ces " - "valeurs doivent correspondre exactement à ce que le serveur " - "déclare pour vous : une adresse, un port ou une clé qui divergent " - "ne produisent aucune erreur — le tunnel monte et le handshake " - "n'arrive jamais." + "Quel profil est actif, quel fichier l'a produit ou en sort, puis " + "les valeurs du tunnel lui-même. Ces valeurs doivent correspondre " + "exactement à ce que le serveur déclare pour vous : une adresse, " + "un port ou une clé qui divergent ne produisent aucune erreur — " + "le tunnel monte et le handshake n'arrive jamais." )) + # Bandeau de verrouillage : posé après l'import d'un .conf, pour + # qu'une modification à la main ne diverge pas silencieusement du + # fichier fourni par l'administrateur du serveur. + self._lock_banner = QWidget() + self._lock_banner.setStyleSheet( + f"background: {theme.WARN_BG}; border-radius: 4px;" + ) + lb = QHBoxLayout(self._lock_banner) + lb.setContentsMargins(10, 6, 10, 6) + lb_text = QLabel("🔒 Champs verrouillés — configuration importée depuis un .conf") + lb_text.setWordWrap(True) + lb_text.setStyleSheet(f"color: {theme.WARN_TEXT}; font-size: 11px; background: transparent;") + lb.addWidget(lb_text, 1) + # Pas de setFixedHeight ici : `secondary_button_style()` compte un + # padding vertical de 9px (18px cumulés) — une hauteur figée à 24px + # ailleurs dans ce fichier ne laisserait que 6px pour le texte, et le + # tronquerait. Tous les autres boutons utilisant ce style se passent + # de hauteur fixe pour la même raison. + btn_unlock = QPushButton("🔓 Déverrouiller") + btn_unlock.setStyleSheet(theme.secondary_button_style()) + btn_unlock.clicked.connect(lambda: self._set_fields_locked(False)) + lb.addWidget(btn_unlock) + self._lock_banner.setVisible(False) + lay.addWidget(self._lock_banner) + + # ── Profil & fichier : deux barres compactes plutôt qu'un second + # formulaire — ce qu'elles pilotent (les champs ci-dessous) ne se + # répète pas ici. + profile_row = QHBoxLayout() + profile_row.setSpacing(6) + profile_lbl = QLabel("Profil :") + profile_lbl.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;") + profile_row.addWidget(profile_lbl) + self._profile_select = QComboBox() + self._profile_select.setFixedHeight(26) + profile_row.addWidget(self._profile_select, 1) + btn_load_p = QPushButton("✅ Charger") + btn_load_p.setFixedHeight(26) + btn_load_p.clicked.connect(self._load_profile) + btn_save_p = QPushButton("💾 Enregistrer sous…") + btn_save_p.setFixedHeight(26) + btn_save_p.clicked.connect(self._save_profile) + btn_del_p = QPushButton("🗑️ Supprimer") + btn_del_p.setFixedHeight(26) + btn_del_p.setStyleSheet( + f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT};" + f" border-radius: 5px; padding: 0 12px; border: none; }}" + f"QPushButton:hover {{ background: {theme.FAIL_HOVER}; }}" + ) + btn_del_p.clicked.connect(self._delete_profile) + profile_row.addWidget(btn_load_p) + profile_row.addWidget(btn_save_p) + profile_row.addWidget(btn_del_p) + lay.addLayout(profile_row) + + file_row = QHBoxLayout() + file_row.setSpacing(6) + btn_preview = QPushButton("👁 Aperçu") + btn_preview.setToolTip("Montre le fichier exact qui sera écrit à la connexion.") + btn_preview.clicked.connect(self._show_config_preview) + btn_import = QPushButton("📂 Importer un .conf") + btn_import.clicked.connect(self._import_conf) + btn_export = QPushButton("💾 Exporter en .conf") + btn_export.clicked.connect(self._export_conf) + btn_qr = QPushButton("📱 QR Code") + btn_qr.setToolTip("Exporte la configuration pour l'application mobile.") + btn_qr.clicked.connect(self._export_qr) + for b in (btn_preview, btn_import, btn_export, btn_qr): + b.setFixedHeight(26) + file_row.addWidget(b) + lay.addLayout(file_row) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"background: {theme.BORDER_SOFT}; border: none; max-height: 1px;") + lay.addWidget(sep) + # ── Serveur ────────────────────────────────────────────────────── grp = QGroupBox("Serveur WireGuard") form = QFormLayout(grp) @@ -275,33 +334,30 @@ class AdminWindow(QDialog): "si le serveur en attend une et qu'elle manque ici, le tunnel monte\n" "mais aucun handshake n'aboutit." ) - show_psk = QPushButton("Afficher") - show_psk.setFixedSize(70, 28) - show_psk.setCheckable(True) - show_psk.toggled.connect( + self._show_psk_btn = QPushButton("Afficher") + self._show_psk_btn.setFixedSize(70, 28) + self._show_psk_btn.setCheckable(True) + self._show_psk_btn.toggled.connect( lambda checked: self._psk.setEchoMode( QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password ) ) - btn_gen_psk = QPushButton("Générer") - btn_gen_psk.setFixedSize(70, 28) - btn_gen_psk.clicked.connect( + self._gen_psk_btn = QPushButton("Générer") + self._gen_psk_btn.setFixedSize(70, 28) + self._gen_psk_btn.clicked.connect( lambda: self._psk.setText(wg_core.generate_preshared_key()) ) psk_row = QHBoxLayout() psk_row.setSpacing(6) psk_row.addWidget(self._psk) - psk_row.addWidget(show_psk) - psk_row.addWidget(btn_gen_psk) + psk_row.addWidget(self._show_psk_btn) + psk_row.addWidget(self._gen_psk_btn) self._row(form, "Valeur :", psk_row) # Le champ et ses boutons suivent la case : rien à saisir tant que # l'option est décochée. - def _sync_psk(checked: bool): - for wdg in (self._psk, show_psk, btn_gen_psk): - wdg.setEnabled(checked) - self._use_psk.toggled.connect(_sync_psk) - _sync_psk(False) + self._use_psk.toggled.connect(self._sync_psk_enabled) + self._sync_psk_enabled(False) lay.addWidget(grp) # ── Interface client ───────────────────────────────────────────── @@ -380,8 +436,49 @@ class AdminWindow(QDialog): self._row(form2, "MTU :", self._mtu) lay.addWidget(grp2) lay.addStretch() + + # Champs verrouillés après import d'un .conf. Le split-DNS et le nom + # d'interface en restent exclus : ce sont des réglages propres à + # WGSecure, absents du fichier importé, donc jamais écrasés par lui. + self._locked_fields = [ + self._srv_endpoint, self._srv_port, self._srv_pubkey, self._use_psk, + self._client_addr, self._dns, self._allowed_ips, + self._keepalive, self._mtu, + ] return page + def _sync_psk_enabled(self, checked: bool): + for wdg in (self._psk, self._show_psk_btn, self._gen_psk_btn): + wdg.setEnabled(checked) + + def _set_fields_locked(self, locked: bool): + """Verrouille (ou libère) les champs issus d'un import .conf. + + Éditer ces champs à la main après import diverge silencieusement du + fichier fourni par l'administrateur du serveur — le même risque que + documentent leurs info-bulles individuelles (port, IP ou clé qui ne + produisent aucune erreur visible, juste un handshake qui n'arrive + jamais). + """ + self._fields_locked = locked + for wdg in self._locked_fields: + wdg.setEnabled(not locked) + if locked: + self._psk.setEnabled(False) + self._show_psk_btn.setEnabled(False) + self._gen_psk_btn.setEnabled(False) + else: + self._sync_psk_enabled(self._use_psk.isChecked()) + self._lock_banner.setVisible(locked) + if hasattr(self, "_priv_key"): + self._priv_key.setEnabled(not locked) + self._show_priv_btn.setEnabled(not locked) + self._btn_gen_keys.setEnabled(not locked) + self._btn_gen_keys.setToolTip( + "Déverrouillez les champs dans l'onglet Tunnel pour régénérer les clés." + if locked else "" + ) + # ── Sous-onglet : clés ─────────────────────────────────────────────── def _subtab_keys(self) -> QWidget: page, lay = self._scroll_page() @@ -404,10 +501,10 @@ class AdminWindow(QDialog): "À garder SECRETE ! La clé publique est dérivée automatiquement." ) self._priv_key.setFixedHeight(28) - show_priv = QPushButton("Afficher") - show_priv.setFixedSize(70, 28) - show_priv.setCheckable(True) - show_priv.toggled.connect( + self._show_priv_btn = QPushButton("Afficher") + self._show_priv_btn.setFixedSize(70, 28) + self._show_priv_btn.setCheckable(True) + self._show_priv_btn.toggled.connect( lambda checked: self._priv_key.setEchoMode( QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password ) @@ -415,7 +512,7 @@ class AdminWindow(QDialog): priv_row = QHBoxLayout() priv_row.setSpacing(6) priv_row.addWidget(self._priv_key) - priv_row.addWidget(show_priv) + priv_row.addWidget(self._show_priv_btn) self._row(form, "Clé privée :", priv_row) self._pub_key = QLineEdit() @@ -426,10 +523,10 @@ class AdminWindow(QDialog): ) self._row(form, "Clé publique :", self._pub_key) - btn_gen = QPushButton("Générer une nouvelle paire de clés") - btn_gen.setFixedHeight(28) - btn_gen.clicked.connect(self._generate_keys) - self._row(form, "", btn_gen) + self._btn_gen_keys = QPushButton("Générer une nouvelle paire de clés") + self._btn_gen_keys.setFixedHeight(28) + self._btn_gen_keys.clicked.connect(self._generate_keys) + self._row(form, "", self._btn_gen_keys) lay.addWidget(grp) grp2 = QGroupBox("🖥️ Assistant : configuration serveur + client") @@ -449,84 +546,6 @@ class AdminWindow(QDialog): lay.addStretch() return page - # ── Sous-onglet : profils et fichiers ──────────────────────────────── - def _subtab_profiles(self) -> QWidget: - page, lay = self._scroll_page() - lay.addWidget(self._hint( - "Conserve plusieurs configurations complètes et bascule de l'une à " - "l'autre. L'aperçu montre le fichier exact qui sera écrit au " - "moment de la connexion — c'est lui qu'on importe, exporte ou " - "transmet par QR Code." - )) - - grp_p = QGroupBox("Profils") - gp = QVBoxLayout(grp_p) - gp.setSpacing(6) - gp.setContentsMargins(10, 14, 10, 10) - - self._profile_list = QListWidget() - self._profile_list.setFixedHeight(96) - self._profile_list.setStyleSheet( - f"QListWidget {{ background: rgba(255,255,255,0.07); color: {theme.TEXT};" - f" border: 1px solid {theme.BORDER}; border-radius: 4px; }}" - f"QListWidget::item:selected {{ background: {theme.ACCENT}; }}" - ) - gp.addWidget(self._profile_list) - - p_btn_row = QHBoxLayout() - p_btn_row.setSpacing(6) - btn_save_p = QPushButton("💾 Sauvegarder sous…") - btn_save_p.setFixedHeight(26) - btn_save_p.clicked.connect(self._save_profile) - btn_load_p = QPushButton("✅ Charger") - btn_load_p.setFixedHeight(26) - btn_load_p.clicked.connect(self._load_profile) - btn_del_p = QPushButton("🗑️ Supprimer") - btn_del_p.setFixedHeight(26) - btn_del_p.setStyleSheet( - f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT};" - f" border-radius: 5px; padding: 0 12px; border: none; }}" - f"QPushButton:hover {{ background: #e74c3c; }}" - ) - btn_del_p.clicked.connect(self._delete_profile) - p_btn_row.addWidget(btn_save_p) - p_btn_row.addWidget(btn_load_p) - p_btn_row.addWidget(btn_del_p) - gp.addLayout(p_btn_row) - lay.addWidget(grp_p) - - # Aperçu, import, export et QR Code étaient dispersés entre les deux - # anciens onglets : ils décrivent pourtant le même objet, le fichier - # de configuration du pair. - grp2 = QGroupBox("Configuration générée") - v2 = QVBoxLayout(grp2) - self._config_preview = QTextEdit() - self._config_preview.setReadOnly(True) - self._config_preview.setFont(QFont("Courier", 9)) - self._config_preview.setMinimumHeight(140) - self._config_preview.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - v2.addWidget(self._config_preview, 1) - - btn_row = QHBoxLayout() - btn_row.setSpacing(6) - btn_preview = QPushButton("🔄 Rafraîchir") - btn_preview.clicked.connect(self._refresh_preview) - btn_import = QPushButton("📂 Importer un .conf") - btn_import.clicked.connect(self._import_conf) - btn_export = QPushButton("💾 Exporter en .conf") - btn_export.clicked.connect(self._export_conf) - btn_qr = QPushButton("📱 QR Code") - btn_qr.setToolTip("Exporte la configuration pour l'application mobile.") - btn_qr.clicked.connect(self._export_qr) - for b in (btn_preview, btn_import, btn_export, btn_qr): - b.setFixedHeight(26) - btn_row.addWidget(b) - v2.addLayout(btn_row) - lay.addWidget(grp2, 1) - return page - # ------------------------------------------------------------------ # # Onglet MFA # ------------------------------------------------------------------ # @@ -539,12 +558,12 @@ class AdminWindow(QDialog): )) self._mfa_enabled_cb = QCheckBox("Activer la vérification MFA avant connexion") - self._mfa_enabled_cb.setStyleSheet("color: white; font-weight: bold;") + self._mfa_enabled_cb.setStyleSheet(f"color: {theme.TEXT}; font-weight: bold;") lay.addWidget(self._mfa_enabled_cb) sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) - sep.setStyleSheet("color: rgba(255,255,255,0.15);") + sep.setStyleSheet(f"color: {theme.BORDER_SOFT};") lay.addWidget(sep) grp = QGroupBox("Secret TOTP") @@ -566,7 +585,7 @@ class AdminWindow(QDialog): v2.addWidget(self._qr_label) self._mfa_uri_label = QLabel("") self._mfa_uri_label.setWordWrap(True) - self._mfa_uri_label.setStyleSheet("font-size: 9px; color: rgba(255,255,255,0.55);") + self._mfa_uri_label.setStyleSheet(f"font-size: 9px; color: {theme.TEXT_FAINT};") v2.addWidget(self._mfa_uri_label) btn_show_qr = QPushButton("Afficher le QR Code") btn_show_qr.clicked.connect(self._show_qr) @@ -585,6 +604,10 @@ class AdminWindow(QDialog): diagnostic, le plus long des trois, se retrouvait comprimé entre les deux autres et la page réclamait un défilement permanent. Chacun dispose maintenant de toute la hauteur. + + Les droits d'exécution (sudo/UAC) ont migré vers l'onglet Sécurité : + ce n'est pas un diagnostic de connexion mais un réglage d'accès, au + même titre que le MFA ou le mot de passe administrateur. """ w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"]) lay.setContentsMargins(0, 0, 0, 0) @@ -593,7 +616,6 @@ class AdminWindow(QDialog): sub.setStyleSheet(theme.TABBAR_CSS) sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion") sub.addTab(self._subtab_dns(), "🔍 Fuite DNS") - sub.addTab(self._subtab_privileges(), self._privileges_tab_title()) lay.addWidget(sub) return w @@ -678,6 +700,37 @@ class AdminWindow(QDialog): self._dns_result.setStyleSheet(theme.result_view_style()) g.addWidget(self._dns_result, 1) v.addWidget(grp, 1) + + # Résolution d'un nom précis : « résolution DNS fonctionnelle » (la + # sonde générique ci-dessus) ne dit rien sur le split-DNS lui-même — + # un nom du réseau distant peut se résoudre vers une réponse + # publique inattendue au lieu du réseau attendu. Le test de chaîne + # de connexion vérifie déjà automatiquement le(s) domaine(s) + # configuré(s) ; ce champ permet de tester n'importe quel nom précis + # (un hôte particulier, une forme courte via le suffixe de recherche…). + domains = self._cfg.wg.get("split_dns_domains") or [] + example = f"hôte.{domains[0]}" if domains else "hôte.domaine.exemple" + grp2 = QGroupBox("🌐 Résoudre un nom précis") + g2 = QVBoxLayout(grp2) + g2.addWidget(QLabel( + f"Ex. : {example}, ou juste le nom court s'il est dans le " + "suffixe de recherche du réseau distant." + )) + resolve_row = QHBoxLayout() + resolve_row.setSpacing(6) + self._resolve_input = QLineEdit() + self._resolve_input.setPlaceholderText(example) + self._resolve_input.returnPressed.connect(self._run_resolve_host) + resolve_row.addWidget(self._resolve_input, 1) + self._btn_resolve = QPushButton("🔍 Résoudre") + self._btn_resolve.clicked.connect(self._run_resolve_host) + resolve_row.addWidget(self._btn_resolve) + g2.addLayout(resolve_row) + self._resolve_result = QLabel("") + self._resolve_result.setWordWrap(True) + self._resolve_result.setStyleSheet(theme.result_style("idle")) + g2.addWidget(self._resolve_result) + v.addWidget(grp2) return page # ── Sous-onglet : droits d'exécution ───────────────────────────────── @@ -755,11 +808,18 @@ class AdminWindow(QDialog): return page def _refresh_profile_list(self): - self._profile_list.clear() + # `userData` porte le nom brut du profil : plus de préfixe (★/espaces) + # à retirer à la main pour retrouver ce qui a été sélectionné. + self._profile_select.blockSignals(True) + self._profile_select.clear() active = self._cfg.active_profile for name in self._cfg.list_profiles(): - marker = "★" if name == active else " " - self._profile_list.addItem(f"{marker} {name}") + marker = "★ " if name == active else " " + self._profile_select.addItem(f"{marker}{name}", name) + idx = self._profile_select.findData(active) + if idx >= 0: + self._profile_select.setCurrentIndex(idx) + self._profile_select.blockSignals(False) def _save_profile(self): from PyQt6.QtWidgets import QInputDialog @@ -774,10 +834,9 @@ class AdminWindow(QDialog): f"Profil « {name.strip()} » sauvegardé.") def _load_profile(self): - item = self._profile_list.currentItem() - if not item: + name = self._profile_select.currentData() + if not name: return - name = item.text().lstrip("★ ").strip() if self._cfg.load_profile(name): self._load_values() self._refresh_profile_list() @@ -785,10 +844,9 @@ class AdminWindow(QDialog): f"Profil « {name} » chargé.") def _delete_profile(self): - item = self._profile_list.currentItem() - if not item: + name = self._profile_select.currentData() + if not name: return - name = item.text().lstrip("★ ").strip() reply = QMessageBox.question( self, "Confirmer", f"Supprimer le profil « {name} » ?", @@ -813,19 +871,16 @@ class AdminWindow(QDialog): form1 = QFormLayout(grp1) self._chk_tray = QCheckBox("Réduire dans le systray à la fermeture") - self._chk_tray.setStyleSheet("color: white;") self._chk_tray.setChecked(bool(self._cfg.get("ui", "minimize_to_tray"))) form1.addRow("", self._chk_tray) self._chk_autostart = QCheckBox("Lancer WGSecure au démarrage du système") - self._chk_autostart.setStyleSheet("color: white;") self._chk_autostart.setChecked(bool(self._cfg.get("ui", "autostart"))) form1.addRow("", self._chk_autostart) self._chk_auto_connect = QCheckBox( "Se connecter automatiquement au démarrage (profil actif)" ) - self._chk_auto_connect.setStyleSheet("color: white;") self._chk_auto_connect.setChecked( bool(self._cfg.get("ui", "auto_connect_on_startup")) ) @@ -837,7 +892,6 @@ class AdminWindow(QDialog): form2 = QFormLayout(grp2) self._chk_autorecon = QCheckBox("Reconnecter automatiquement si le tunnel tombe") - self._chk_autorecon.setStyleSheet("color: white;") self._chk_autorecon.setChecked(bool(self._cfg.get("ui", "auto_reconnect"))) form2.addRow("", self._chk_autorecon) @@ -872,12 +926,12 @@ class AdminWindow(QDialog): # Onglet Sécurité # ------------------------------------------------------------------ # def _tab_security(self) -> QWidget: - """Sécurité : MFA et mot de passe administrateur. + """Sécurité : MFA, mot de passe administrateur et droits d'exécution. - Les deux vivaient dans des onglets de premier niveau séparés alors - qu'ils répondent à la même question — qui a le droit de se connecter - et d'administrer. Les regrouper libère aussi un onglet dans une barre - qui en comptait sept. + Les trois répondent à la même question — qui (ou quoi) a le droit de + se connecter et d'administrer. Les droits sudo/UAC y ont rejoint MFA + et mot de passe : ce n'est pas un test de connexion, c'est un réglage + d'accès du même ordre. """ w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"]) lay.setContentsMargins(0, 0, 0, 0) @@ -886,6 +940,7 @@ class AdminWindow(QDialog): sub.setStyleSheet(theme.TABBAR_CSS) sub.addTab(self._subtab_mfa(), "🔐 MFA (TOTP)") sub.addTab(self._subtab_password(), "🔑 Mot de passe") + sub.addTab(self._subtab_privileges(), self._privileges_tab_title()) lay.addWidget(sub) return w @@ -901,7 +956,7 @@ class AdminWindow(QDialog): "Laissez vide pour désactiver la protection." ) note.setWordWrap(True) - note.setStyleSheet("color: rgba(255,255,255,0.8); font-size: 12px;") + note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;") lay.addWidget(note) grp = QGroupBox("Mot de passe administrateur") @@ -920,7 +975,7 @@ class AdminWindow(QDialog): btn_set_pw = QPushButton("Définir le mot de passe") btn_set_pw.setStyleSheet( - theme.primary_button_style(theme.FAIL_SOLID, "#e74c3c") + theme.primary_button_style(theme.FAIL_SOLID, theme.FAIL_HOVER) ) btn_set_pw.clicked.connect(self._set_admin_password) self._row(form, "", btn_set_pw) @@ -993,7 +1048,7 @@ class AdminWindow(QDialog): for text, color in self._about_pills(info): pill = QLabel(text) pill.setStyleSheet( - f"background: {color}; color: white; border-radius: 9px;" + f"background: {color}; color: {theme.TEXT}; border-radius: 9px;" " padding: 3px 10px; font-size: 11px; font-weight: bold;" ) ph.addWidget(pill) @@ -1026,78 +1081,79 @@ class AdminWindow(QDialog): pills.append(("⚠️ dialogue à chaque appel", theme.WARN_SOLID)) return pills - # ── Sous-onglet : fonctions ─────────────────────────────────────────── - def _subtab_about_features(self, info) -> QWidget: - page, v = self._test_page() + # ── Carte partagée : titre/icône + corps, sur fond distinct de la page ── + def _about_card(self, body: str, title: str = "", icon: str = "") -> QWidget: + """Un item de liste À propos, posé sur sa propre carte. + + Fonctions, Garanties et Technique s'affichaient comme une simple + liste de libellés à 11-12px sur le fond de page, sans rien pour + distinguer un item du suivant — dense au point d'être difficile à + parcourir. Chaque item vit maintenant sur sa propre carte, avec sa + marge : l'œil sait où il commence et où il finit. + """ + card = QWidget() + card.setStyleSheet(f"background: {theme.BG_RAISED}; border-radius: 6px;") + outer = QHBoxLayout(card) + outer.setContentsMargins(12, 10, 12, 10) + outer.setSpacing(9) + if icon: + ic = QLabel(icon) + ic.setStyleSheet(f"color: {theme.TEXT}; font-size: 14px; background: transparent;") + ic.setAlignment(Qt.AlignmentFlag.AlignTop) + outer.addWidget(ic) + col = QVBoxLayout() + col.setSpacing(3) + if title: + t = QLabel(title) + t.setWordWrap(True) + t.setStyleSheet(f"color: {theme.TEXT}; font-size: 13px; font-weight: bold; background: transparent;") + col.addWidget(t) + b = QLabel(body) + b.setWordWrap(True) + b.setStyleSheet( + f"color: {theme.TEXT_MUTED if title else theme.TEXT};" + " font-size: 12px; background: transparent;" + ) + col.addWidget(b) + outer.addLayout(col, 1) + return card + + def _about_card_list(self, items: list[QWidget]) -> QWidget: + """Zone défilante empilant des cartes, espacées pour rester lisibles.""" area = QScrollArea() area.setWidgetResizable(True) area.setStyleSheet(theme.scrollarea_style()) inner = QWidget() inner.setStyleSheet(f"background: {theme.BG};") iv = QVBoxLayout(inner) - iv.setContentsMargins(0, 0, 8, 0) + iv.setContentsMargins(0, 2, 8, 2) iv.setSpacing(10) - for title, desc in info.FEATURES: - iv.addWidget(self._feature_row(title, desc)) + for item in items: + iv.addWidget(item) iv.addStretch() area.setWidget(inner) - v.addWidget(area) - return page + return area - def _feature_row(self, title: str, desc: str) -> QWidget: - row = QWidget() - row.setStyleSheet("background: transparent;") - rv = QVBoxLayout(row) - rv.setContentsMargins(0, 0, 0, 0) - rv.setSpacing(2) - t = QLabel(title) - tf = QFont(); tf.setBold(True) - t.setFont(tf) - t.setStyleSheet(f"color: {theme.TEXT};") - rv.addWidget(t) - d = QLabel(desc) - d.setWordWrap(True) - d.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;") - rv.addWidget(d) - return row + # ── Sous-onglet : fonctions ─────────────────────────────────────────── + def _subtab_about_features(self, info) -> QWidget: + page, v = self._test_page() + cards = [self._about_card(desc, title=title) for title, desc in info.FEATURES] + v.addWidget(self._about_card_list(cards)) + return page # ── Sous-onglet : garanties ─────────────────────────────────────────── def _subtab_about_guarantees(self, info) -> QWidget: page, v = self._test_page() v.addWidget(self._hint(info.GUARANTEES_INTRO)) - area = QScrollArea() - area.setWidgetResizable(True) - area.setStyleSheet(theme.scrollarea_style()) - inner = QWidget() - inner.setStyleSheet(f"background: {theme.BG};") - iv = QVBoxLayout(inner) - iv.setContentsMargins(0, 0, 8, 0) - iv.setSpacing(8) - for item in info.GUARANTEES: - lbl = QLabel("• " + item) - lbl.setWordWrap(True) - lbl.setStyleSheet(f"color: {theme.TEXT}; font-size: 12px;") - iv.addWidget(lbl) - iv.addStretch() - area.setWidget(inner) - v.addWidget(area) + cards = [self._about_card(item, icon="✅") for item in info.GUARANTEES] + v.addWidget(self._about_card_list(cards)) return page # ── Sous-onglet : pile technique ────────────────────────────────────── def _subtab_about_tech(self, info) -> QWidget: page, v = self._test_page() - grp = QGroupBox("Pile technique") - g = QFormLayout(grp) - for layer, tool in info.TECH_STACK: - layer_lbl = QLabel(layer) - lf = QFont(); lf.setBold(True) - layer_lbl.setFont(lf) - tool_lbl = QLabel(tool) - tool_lbl.setWordWrap(True) - tool_lbl.setStyleSheet(f"color: {theme.TEXT_MUTED};") - g.addRow(layer_lbl, tool_lbl) - v.addWidget(grp) - v.addStretch() + cards = [self._about_card(tool, title=layer) for layer, tool in info.TECH_STACK] + v.addWidget(self._about_card_list(cards)) return page # ── Sous-onglet : journal (lu depuis CHANGELOG.md) ──────────────────── @@ -1108,12 +1164,19 @@ class AdminWindow(QDialog): view.setStyleSheet(theme.result_view_style("idle", font_size=12)) parts = [] for entry in info.parse_changelog(): + # Marges généreuses entre versions et avant chaque section : les + # blocs se touchaient (2px), illisibles à la suite les uns des + # autres sans repère visuel entre deux versions. parts.append( f"

{entry['title']}

" + f"font-size:14px;margin-top:14px;margin-bottom:6px'>{entry['title']}

" ) for section in entry["sections"]: - parts.append(f"{section['name']}