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']}