import re from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame, QFileDialog, QSizePolicy, QComboBox, QScrollArea, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont from app.core.config import Config from app.core import wireguard as wg_core from app.core import dns as dns_util from app.ui import theme from app.ui.worker import ValueWorker from app.utils.platform_utils import ( is_windows, run_command, run_privileged, secure_delete, ) class AdminWindow(QDialog): def __init__(self, config: Config, parent=None): super().__init__(parent) self._cfg = config # Sondes réseau (ping, `dig`, handshake, sudo…) : jusqu'à plusieurs # secondes chacune. Lancées telles quelles depuis un clic, elles # gelaient toute la fenêtre (« Ne répond plus ») le temps de leur # exécution — ces workers les déportent hors du thread Qt. 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 | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint ) # 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. # # Hauteur relevée de 15 % depuis : le sous-onglet Tunnel compte # désormais trois groupes titrés au lieu d'un, et les encarts # explicatifs des pages de test tiennent sur plusieurs lignes. À # l'ancienne hauteur, les libellés se chevauchaient faute de place. # # Le plancher suit, et ce n'est pas cosmétique : il valait 520, alors # que la page la plus exigeante — « Test connexion », dont le rapport # réclame 200 px incompressibles — en demande 569 une fois retirés les # 134 px de bandeau, marges et barre d'onglets. Réduire la fenêtre y # écrasait les libellés les uns sur les autres. 703 est la hauteur # mesurée à laquelle plus aucune page non défilable ne déborde. self.setMinimumSize(640, 703) self.resize(720, 736) self._build_ui() self._load_values() # ------------------------------------------------------------------ # # Helper : page sombre avec bandeau coloré # ------------------------------------------------------------------ # def _dark_page(self, banner_text: str, banner_color: str, scroll: bool = False): """Retourne (page_widget, body_layout) : fond sombre + bandeau. `scroll=True` place le corps dans une zone défilante : indispensable pour les pages dont le contenu grandit à l'usage (rapport de diagnostic), sinon les widgets se compriment jusqu'à l'illisible. """ w = QWidget() w.setStyleSheet(theme.TAB_CSS) outer = QVBoxLayout(w) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) hdr = QLabel(f" {banner_text}") hdr.setFixedHeight(36) hdr.setStyleSheet(theme.banner_style(banner_color)) outer.addWidget(hdr) body = QWidget() body_layout = QVBoxLayout(body) body_layout.setContentsMargins(14, 14, 14, 14) body_layout.setSpacing(10) if scroll: area = QScrollArea() area.setWidgetResizable(True) area.setStyleSheet(theme.scrollarea_style()) area.setWidget(body) outer.addWidget(area) else: outer.addWidget(body) return w, body_layout # ------------------------------------------------------------------ # # Construction principale # ------------------------------------------------------------------ # def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) banner = QLabel(" WGSecure — Configuration Administrateur") banner.setFixedHeight(40) 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(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_about(), "À propos") layout.addWidget(tabs) # Barre de boutons bar = QWidget() 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(theme.secondary_button_style()) btn_cancel.clicked.connect(self.reject) btn_save = QPushButton("Enregistrer et fermer") btn_save.setStyleSheet(theme.primary_button_style()) btn_save.clicked.connect(self._save_and_close) btn_row.addStretch() btn_row.addWidget(btn_cancel) btn_row.addWidget(btn_save) layout.addWidget(bar) # ------------------------------------------------------------------ # # Onglet WireGuard # ------------------------------------------------------------------ # @staticmethod def _row(form: QFormLayout, text: str, widget, label_w: int = 148, field_h: int = 28) -> None: """Ligne de formulaire : label largeur fixe + champ hauteur uniforme. widget peut être un QWidget ou un QHBoxLayout (sans setFixedHeight).""" from PyQt6.QtWidgets import QLayout lbl = QLabel(text) lbl.setFixedWidth(label_w) lbl.setFixedHeight(field_h) lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) lbl.setStyleSheet(f"color: {theme.TEXT_MUTED}; background: transparent;") if not isinstance(widget, QLayout): widget.setFixedHeight(field_h) form.addRow(lbl, widget) def _collapsible_group(self, title: str) -> tuple[QGroupBox, QFormLayout]: """Groupe replié par défaut, dépliable d'un clic sur son titre. `QGroupBox.setCheckable` seul ne replie rien : il grise le contenu en le laissant occuper la place, ce qui allonge la page sans rien gagner. D'où le conteneur intermédiaire, masqué et rendu visible à la volée — la fenêtre se remesure alors sur le contenu réellement affiché. """ box = QGroupBox(title) box.setCheckable(True) box.setChecked(False) outer = QVBoxLayout(box) outer.setContentsMargins(0, 0, 0, 0) body = QWidget() body.setVisible(False) form = QFormLayout(body) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) outer.addWidget(body) box.toggled.connect(body.setVisible) return box, form @staticmethod def _hint(text: str) -> QLabel: """Encart expliquant à quoi sert l'onglet où il figure. Les pages s'ouvraient sur un formulaire nu : rien ne disait ce qu'on y décidait, ni ce qui se passait si on n'y touchait pas. """ lbl = QLabel(text) lbl.setWordWrap(True) lbl.setStyleSheet(theme.hint_style()) return lbl def _scroll_page(self) -> tuple[QWidget, QVBoxLayout]: """Sous-onglet défilant : fond de page, marges homogènes.""" page = QWidget() page.setStyleSheet(theme.TAB_CSS) outer = QVBoxLayout(page) outer.setContentsMargins(0, 0, 0, 0) area = QScrollArea() area.setWidgetResizable(True) area.setStyleSheet(theme.scrollarea_style()) body = QWidget() lay = QVBoxLayout(body) lay.setContentsMargins(14, 12, 14, 12) lay.setSpacing(10) area.setWidget(body) outer.addWidget(area) return page, lay def _tab_wireguard(self) -> QWidget: """Configuration WireGuard, en sous-onglets. 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 & profils") sub.addTab(self._subtab_keys(), "🔑 Clés") lay.addWidget(sub) self._refresh_profile_list() return w # ── Sous-onglet : tunnel & profils ──────────────────────────────────── def _subtab_tunnel(self) -> QWidget: page, lay = self._scroll_page() lay.addWidget(self._hint( "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) # ── Clé pré-partagée ───────────────────────────────────────────── # Adresse, port et clé publique du serveur ont rejoint « Avancé » : # ils décrivent le pair tel que le serveur le déclare, viennent du # `.conf` importé et ne se modifient pas à la main. grp = QGroupBox("Clé pré-partagée (optionnelle)") form = QFormLayout(grp) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) # Un champ vide ne disait pas si la PSK était oubliée ou volontairement # absente : le diagnostic avertissait donc sur tout serveur qui n'en # utilise pas, c'est-à-dire la configuration WireGuard par défaut. self._use_psk = QCheckBox("Le serveur utilise une clé pré-partagée") self._use_psk.setToolTip( "À cocher uniquement si le serveur déclare une PresharedKey pour ce\n" "pair. Une PSK écrite alors que le serveur n'en attend pas casse le\n" "handshake aussi sûrement qu'une PSK manquante." ) self._row(form, "Clé pré-partagée :", self._use_psk) self._psk = QLineEdit() self._psk.setEchoMode(QLineEdit.EchoMode.Password) self._psk.setPlaceholderText("(clé pré-partagée, 32 octets en base64)") self._psk.setToolTip( "Clé pré-partagée (PresharedKey). Doit être identique des deux côtés :\n" "si le serveur en attend une et qu'elle manque ici, le tunnel monte\n" "mais aucun handshake n'aboutit." ) 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 ) ) 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(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. self._use_psk.toggled.connect(self._sync_psk_enabled) self._sync_psk_enabled(False) lay.addWidget(grp) # ── Interface client ───────────────────────────────────────────── # Trois groupes, du plus courant au plus rare : ce qu'un .conf importé # renseigne, le split-DNS, puis le reste — replié. La page alignait # quinze champs d'un bloc, où l'adresse du client et le MTU avaient le # même poids visuel alors que l'un se règle toujours et l'autre jamais. grp2 = QGroupBox("Interface client") form2 = QFormLayout(grp2) form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self._client_addr = QLineEdit() self._client_addr.setPlaceholderText("10.8.0.2/24") self._client_addr.setToolTip( "Adresse IP de ce poste dans le réseau du tunnel, telle que le\n" "serveur l'a attribuée. Format : IP/Masque (ex. 10.6.0.6/32).\n" "C'est à cette adresse que le serveur reconnaît ce poste." ) self._row(form2, "Adresse IP client :", self._client_addr) self._allowed_ips = QLineEdit() self._allowed_ips.setPlaceholderText("10.8.0.0/24") self._allowed_ips.setToolTip( "Réseaux routés dans le tunnel. N'y mettez pas le réseau local qui\n" "porte la passerelle vers le serveur : elle deviendrait joignable\n" "par le tunnel seul, et le handshake n'aboutirait plus." ) self._row(form2, "IPs autorisées :", self._allowed_ips) self._dns = QLineEdit() self._dns.setPlaceholderText("1.1.1.1") self._dns.setToolTip( "Résolveur employé par le tunnel pour tous les noms.\n" "Laisser vide pour conserver le DNS du système." ) self._row(form2, "DNS :", self._dns) lay.addWidget(grp2) # ── Split-DNS ──────────────────────────────────────────────────── # Regroupés et nommés : trois champs portaient « DNS » dans une seule # liste, sans que rien ne dise lesquels allaient ensemble. grp3 = QGroupBox("Split-DNS — noms du réseau distant") form3 = QFormLayout(grp3) form3.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) note3 = QLabel( "Résout les noms internes par le serveur DNS du réseau distant, " "et tout le reste par le DNS ci-dessus. Les deux champs vont " "ensemble : l'un sans l'autre ne fait rien." ) note3.setWordWrap(True) note3.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;") form3.addRow(note3) self._split_dns_server = QLineEdit() self._split_dns_server.setPlaceholderText("192.168.1.210") self._split_dns_server.setToolTip( "Serveur DNS du réseau distant, joint par le tunnel.\n" "Il doit être couvert par les IPs autorisées." ) self._row(form3, "Serveur DNS distant :", self._split_dns_server) self._split_dns_domains = QLineEdit() self._split_dns_domains.setPlaceholderText("local, internal") self._split_dns_domains.setToolTip( "Domaines confiés au serveur ci-dessus, séparés par des virgules.\n" "Vide = split-DNS inactif, tout passe par le DNS général." ) self._row(form3, "Domaines concernés :", self._split_dns_domains) lay.addWidget(grp3) # ── Avancé ─────────────────────────────────────────────────────── grp4, form4 = self._collapsible_group( "Avancé — issus du serveur ou corrects par défaut ; à ne pas modifier") # Adresse, port et clé publique du serveur : ils viennent du `.conf` # fourni par l'administrateur et décrivent un pair déjà déclaré côté # serveur. Les modifier à la main ne produit aucune erreur visible — # juste un handshake qui n'aboutit jamais — d'où leur place ici, # repliée, et leur verrouillage après import. self._srv_endpoint = QLineEdit() self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") self._srv_endpoint.setToolTip( "Adresse IP ou nom d'hôte du serveur WireGuard.\n" "Si vous utilisez un nom, assurez-vous qu'il est résolvable." ) self._row(form4, "Adresse serveur :", self._srv_endpoint) self._srv_port = QSpinBox() self._srv_port.setRange(1, 65535) self._srv_port.setValue(51820) self._srv_port.setToolTip( "Port UDP sur lequel le serveur écoute. Un port erroné ne produit\n" "aucune erreur visible : WireGuard ignore silencieusement les\n" "paquets, le tunnel monte et le handshake n'arrive jamais." ) self._srv_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._row(form4, "Port UDP :", self._srv_port) self._srv_pubkey = QLineEdit() self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") self._srv_pubkey.setToolTip( "Clé publique du serveur, en base64. Doit correspondre exactement\n" "à celle que le serveur présente, sans quoi rien ne négocie." ) self._row(form4, "Clé publique serveur :", self._srv_pubkey) self._iface_name = QLineEdit() self._iface_name.setPlaceholderText("wgs0") self._iface_name.setToolTip( "Nom de l'interface réseau WireGuard. Doit être unique et\n" "correspondre au nom du service sous Windows." ) self._row(form4, "Nom interface :", self._iface_name) self._api_url = QLineEdit() self._api_url.setPlaceholderText("déduit automatiquement") self._api_url.setToolTip( "URL du serveur WGSecure, joignable depuis le tunnel.\n" "Laissée vide, elle est déduite de l'adresse du client et des\n" "IPs autorisées (10.6.0.6/32 + 10.6.0.0/24 → http://10.6.0.1:8000).\n" "À ne renseigner que si le serveur écoute ailleurs." ) self._row(form4, "Serveur d'authentification :", self._api_url) self._keepalive = QSpinBox() self._keepalive.setRange(0, 300) self._keepalive.setValue(25) self._keepalive.setToolTip( "Intervalle en secondes entre deux paquets keepalive. 0 = désactivé.\n" "Maintient la connexion à travers un NAT." ) self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._row(form4, "Keepalive (s) :", self._keepalive) self._mtu = QSpinBox() self._mtu.setRange(0, 9000) self._mtu.setSpecialValueText("automatique") self._mtu.setToolTip( "0 = laisser WireGuard décider. Abaisser à 1280–1420 si le tunnel\n" "monte mais que les gros paquets ne passent pas." ) self._mtu.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._row(form4, "MTU :", self._mtu) lay.addWidget(grp4) 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 # Enregistré, pas seulement appliqué : sans cela le verrou tombait à # la fermeture du panneau, et les champs décrivant le pair # redevenaient éditables au rechargement sans que rien ne l'annonce. if self._cfg.get("wg", "config_locked") != locked: self._cfg.set("wg", "config_locked", locked) self._cfg.save() 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() lay.addWidget(self._hint( "Votre identité auprès du serveur. La clé privée ne quitte jamais " "cette machine ; c'est la clé publique, dérivée d'elle, que " "l'administrateur du serveur doit inscrire pour ce pair. Régénérer " "la paire invalide l'accès tant que le serveur n'a pas la nouvelle." )) grp = QGroupBox("Paire de clés client (Curve25519)") form = QFormLayout(grp) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self._priv_key = QLineEdit() self._priv_key.setEchoMode(QLineEdit.EchoMode.Password) self._priv_key.setPlaceholderText("(générer ou coller)") self._priv_key.setToolTip( "Clé privée de ce client (format base64). " "À garder SECRETE ! La clé publique est dérivée automatiquement." ) self._priv_key.setFixedHeight(28) 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 ) ) priv_row = QHBoxLayout() priv_row.setSpacing(6) priv_row.addWidget(self._priv_key) priv_row.addWidget(self._show_priv_btn) self._row(form, "Clé privée :", priv_row) self._pub_key = QLineEdit() self._pub_key.setReadOnly(True) self._pub_key.setPlaceholderText("(dérivée automatiquement)") self._pub_key.setToolTip( "C'est cette clé que le serveur doit déclarer pour ce pair." ) self._row(form, "Clé publique :", self._pub_key) 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) lay.addStretch() return page # ------------------------------------------------------------------ # # Onglet Test connexion # ------------------------------------------------------------------ # def _tab_test(self) -> QWidget: """Page de tests, découpée en sous-onglets. Les trois tests s'empilaient sur une page unique : le rapport de 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 mot de passe administrateur. """ w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"]) lay.setContentsMargins(0, 0, 0, 0) sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion") sub.addTab(self._subtab_dns(), "🌐 Résolution des noms") lay.addWidget(sub) return w def _test_page(self) -> tuple[QWidget, QVBoxLayout]: """Corps d'un sous-onglet de test : fond de page, marges homogènes.""" page = QWidget() page.setStyleSheet(theme.TAB_CSS) v = QVBoxLayout(page) v.setContentsMargins(14, 14, 14, 14) v.setSpacing(10) return page, v # ── Sous-onglet : chaîne de connexion ──────────────────────────────── def _subtab_diagnostic(self) -> QWidget: page, v = self._test_page() v.addWidget(self._hint( "Parcourt la chaîne complète, de la configuration locale jusqu'à " "la résolution DNS dans le tunnel, et s'arrête sur la première " "étape qui bloque. À lancer en premier quand la connexion échoue " "sans message." )) grp = QGroupBox("Diagnostic de la chaîne de connexion") g = QVBoxLayout(grp) lbl = QLabel( "Vérifie toute la chaîne : configuration, clés, route, port UDP, " "montage du tunnel, handshake, trafic et DNS — et désigne l'étape " "qui bloque." ) # Sans repli automatique, les sauts de ligne écrits à la main figeaient # la largeur du texte : la fenêtre s'élargissait, le libellé non. lbl.setWordWrap(True) g.addWidget(lbl) self._btn_diag_test = QPushButton("🚀 Lancer le test de connexion") self._btn_diag_test.setStyleSheet(theme.primary_button_style()) self._btn_diag_test.clicked.connect(self._run_test) g.addWidget(self._btn_diag_test) # QLabel auparavant : le rapport fait une dizaine d'étapes avec un # détail par étape, et un QLabel se laisse écraser par le layout — # le texte était rogné à quelques lignes illisibles. Une vue en # lecture seule porte sa propre barre de défilement : le rapport reste # entier quelle que soit la taille de la fenêtre. self._test_result = QTextEdit() self._test_result.setReadOnly(True) self._test_result.setMinimumHeight(200) self._test_result.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) self._test_result.setStyleSheet(theme.result_view_style()) g.addWidget(self._test_result, 1) v.addWidget(grp, 1) return page # ── Sous-onglet : fuite DNS ────────────────────────────────────────── def _subtab_dns(self) -> QWidget: page, v = self._test_page() # Volontairement court : chaque groupe ci-dessous porte déjà sa propre # explication, et l'encart les répétait — il occupait 93 px et faisait # déborder la page, poussant les libellés les uns sur les autres. v.addWidget(self._hint( "Le tunnel peut être monté et le trafic passer pendant qu'aucun " "nom ne se résout : c'est ici que cela se voit, et se répare." )) grp = QGroupBox("🔍 Par où sortent les requêtes de noms ?") g = QVBoxLayout(grp) lbl = QLabel( "Compare le résolveur réellement actif au DNS déclaré pour le " "tunnel. Une divergence expose ce que ce poste consulte au réseau " "local, et peut lui faire obtenir de mauvaises réponses." ) lbl.setWordWrap(True) g.addWidget(lbl) self._btn_dns_test = QPushButton("🔍 Analyser la résolution des noms") self._btn_dns_test.setStyleSheet(theme.primary_button_style()) self._btn_dns_test.clicked.connect(self._run_dns_test) g.addWidget(self._btn_dns_test) self._btn_dns_fix = QPushButton("🛠️ Réparer — plus aucun nom ne se résout") self._btn_dns_fix.setToolTip( "Retire la configuration DNS laissée par un tunnel arrêté " "brutalement, qui empêche toute résolution de noms." ) self._btn_dns_fix.clicked.connect(self._repair_dns) g.addWidget(self._btn_dns_fix) self._dns_result = QTextEdit() self._dns_result.setReadOnly(True) self._dns_result.setMinimumHeight(150) self._dns_result.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) 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 (vérifie le split-DNS)") g2 = QVBoxLayout(grp2) lbl2 = QLabel( "L'analyse ci-dessus ne dit rien du split-DNS : un nom interne " "peut recevoir une réponse publique inattendue alors que la " "résolution « fonctionne ».\n" f"Ex. : {example}, ou le nom court s'il est couvert par le " "suffixe de recherche du réseau distant." ) lbl2.setWordWrap(True) g2.addWidget(lbl2) 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 ───────────────────────────────── def _privileges_tab_title(self) -> str: return "🛡️ Élévation" if is_windows() else "🔐 Droits sudo" def _subtab_privileges(self) -> QWidget: """Droits nécessaires pour monter le tunnel. La page parlait de règles sudo sur les deux plateformes. Sous Windows sudo n'existe pas : le texte, le bouton et le conseil « make setup-sudoers » ne désignaient rien, et l'utilisateur n'avait aucun moyen de savoir pourquoi une fenêtre UAC s'ouvrait à chaque connexion. """ page, v = self._test_page() v.addWidget(self._hint( "Monter un tunnel WireGuard demande des droits que l'application " "n'a pas par défaut. Cette page dit lesquels manquent, et comment " "éviter la fenêtre d'authentification qui s'ouvre sinon à chaque " "connexion." if not is_windows() else "Installer ou retirer le service du tunnel demande les droits " "administrateur. Cette page dit si le processus les possède, et " "comment éviter la fenêtre UAC qui s'ouvre sinon à chaque " "connexion." )) if is_windows(): grp = QGroupBox("🛡️ Élévation Windows (UAC)") g = QVBoxLayout(grp) g.addWidget(QLabel( "Installer ou retirer le service du tunnel exige les droits " "administrateur.\nSans élévation, Windows ouvre une fenêtre " "UAC à chaque connexion." )) btn = QPushButton("🛡️ Vérifier l'élévation") btn.setStyleSheet(theme.primary_button_style()) btn.clicked.connect(self._check_privileges) g.addWidget(btn) # Bouton pour configurer les permissions de l'utilisateur actuel self._btn_grant_permissions = QPushButton( "🔧 Autoriser l'utilisateur à gérer ses tunnels" ) self._btn_grant_permissions.setStyleSheet(theme.secondary_button_style()) self._btn_grant_permissions.clicked.connect(self._grant_wireguard_permissions) self._btn_grant_permissions.setToolTip( "Configure les permissions pour permettre à l'utilisateur actuel de " "créer et supprimer des tunnels WireGuard sans UAC. Nécessite des droits admin." ) g.addWidget(self._btn_grant_permissions) else: grp = QGroupBox("🔐 Droits d'exécution (sudo)") g = QVBoxLayout(grp) g.addWidget(QLabel( "Sans règle sudo, chaque connexion ouvre un dialogue " "d'authentification." )) btn = QPushButton("🔐 Vérifier les droits") if not is_windows(): btn.setStyleSheet(theme.primary_button_style()) btn.clicked.connect(self._check_privileges) g.addWidget(btn) self._priv_result = QTextEdit() self._priv_result.setReadOnly(True) self._priv_result.setMinimumHeight(150) self._priv_result.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) self._priv_result.setStyleSheet(theme.result_view_style()) g.addWidget(self._priv_result, 1) v.addWidget(grp, 1) return page def _refresh_profile_list(self): # `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_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 self._save_values() name, ok = QInputDialog.getText(self, "Sauvegarder le profil", "Nom du profil :") if not ok or not name.strip(): return self._cfg.save_profile(name.strip()) self._refresh_profile_list() QMessageBox.information(self, "Profil sauvegardé", f"Profil « {name.strip()} » sauvegardé.") def _load_profile(self): name = self._profile_select.currentData() if not name: return if self._cfg.load_profile(name): self._load_values() self._refresh_profile_list() QMessageBox.information(self, "Profil chargé", f"Profil « {name} » chargé.") def _delete_profile(self): name = self._profile_select.currentData() if not name: return reply = QMessageBox.question( self, "Confirmer", f"Supprimer le profil « {name} » ?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: self._cfg.delete_profile(name) self._refresh_profile_list() # ------------------------------------------------------------------ # # Onglet Paramètres # ------------------------------------------------------------------ # def _tab_settings(self) -> QWidget: w, lay = self._dark_page("⚙️ Paramètres de l'application", theme.BANNER["settings"]) lay.addWidget(self._hint( "Comportement de WGSecure lui-même : présence dans le systray, " "démarrage automatique, surveillance et reconnexion du tunnel. " "Rien ici ne modifie la configuration WireGuard." )) grp1 = QGroupBox("Comportement") form1 = QFormLayout(grp1) self._chk_tray = QCheckBox("Réduire dans le systray à la fermeture") 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.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.setChecked( bool(self._cfg.get("ui", "auto_connect_on_startup")) ) form1.addRow("", self._chk_auto_connect) lay.addWidget(grp1) grp2 = QGroupBox("Auto-reconnexion") form2 = QFormLayout(grp2) self._chk_autorecon = QCheckBox("Reconnecter automatiquement si le tunnel tombe") self._chk_autorecon.setChecked(bool(self._cfg.get("ui", "auto_reconnect"))) form2.addRow("", self._chk_autorecon) self._spin_interval = QSpinBox() self._spin_interval.setRange(10, 300) self._spin_interval.setValue(int(self._cfg.get("ui", "reconnect_interval") or 30)) self._spin_interval.setSuffix(" s") self._spin_interval.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._row(form2, "Intervalle :", self._spin_interval) lay.addWidget(grp2) btn_apply = QPushButton("✅ Appliquer les paramètres") btn_apply.clicked.connect(self._apply_settings) lay.addWidget(btn_apply) lay.addStretch() return w def _apply_settings(self): self._cfg.set("ui", "minimize_to_tray", self._chk_tray.isChecked()) self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked()) self._cfg.set("ui", "reconnect_interval", self._spin_interval.value()) self._cfg.set("ui", "auto_connect_on_startup", self._chk_auto_connect.isChecked()) autostart = self._chk_autostart.isChecked() if autostart != bool(self._cfg.get("ui", "autostart")): self._cfg.set_autostart(autostart) else: self._cfg.save() QMessageBox.information(self, "Paramètres", "Paramètres enregistrés.") # ------------------------------------------------------------------ # # Onglet Sécurité # ------------------------------------------------------------------ # def _tab_security(self) -> QWidget: """Sécurité : mot de passe administrateur et droits d'exécution. Les deux répondent à la même question — qui a le droit d'administrer ce poste. L'onglet MFA a disparu avec le secret TOTP local : le code d'accès est désormais réglé sur le serveur, qui est le seul à le connaître et le seul à ouvrir l'accès réseau. """ w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"]) lay.setContentsMargins(0, 0, 0, 0) sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) sub.addTab(self._subtab_password(), "🔑 Mot de passe") sub.addTab(self._subtab_privileges(), self._privileges_tab_title()) lay.addWidget(sub) return w def _subtab_password(self) -> QWidget: page, lay = self._test_page() lay.addWidget(self._hint( "Protège l'accès à ce panneau. Sans mot de passe, n'importe qui " "sur cette session peut lire la clé privée et modifier le tunnel." )) note = QLabel( "Définissez un mot de passe pour protéger l'accès au panneau Administrateur. " "Laissez vide pour désactiver la protection." ) note.setWordWrap(True) note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;") lay.addWidget(note) grp = QGroupBox("Mot de passe administrateur") form = QFormLayout(grp) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self._admin_pw1 = QLineEdit() self._admin_pw1.setEchoMode(QLineEdit.EchoMode.Password) self._admin_pw1.setPlaceholderText("Nouveau mot de passe") self._row(form, "Mot de passe :", self._admin_pw1) self._admin_pw2 = QLineEdit() self._admin_pw2.setEchoMode(QLineEdit.EchoMode.Password) self._admin_pw2.setPlaceholderText("Confirmer") self._row(form, "Confirmation :", self._admin_pw2) btn_set_pw = QPushButton("Définir le mot de passe") btn_set_pw.setStyleSheet( 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) lay.addWidget(grp) lay.addStretch() return page # ------------------------------------------------------------------ # # Onglet À propos # ------------------------------------------------------------------ # def _tab_about(self) -> QWidget: """Page « À propos », en huit sous-onglets (modèle QuiOulé/TuxMarket). Le contenu (fonctions, garanties, pile technique, auteur) vit dans `app/app_info.py`, séparé de sa présentation ici — le rendre modifiable sans toucher au Qt. Le journal est lu depuis CHANGELOG.md à l'affichage, jamais recopié. """ from app import app_info w, lay = self._dark_page("ℹ️ À propos", theme.BANNER["about"]) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) lay.addWidget(self._about_header(app_info)) sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions") sub.addTab(self._subtab_about_howto(app_info), "📖 Mode d'emploi") sub.addTab(self._subtab_about_requirements(app_info), "📦 Prérequis") sub.addTab(self._subtab_about_security(app_info), "🔐 Sécurité") sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties") sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique") sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal") sub.addTab(self._subtab_about_author(app_info), "👤 Auteur") lay.addWidget(sub) return w # ── En-tête identité : icône, nom, accroche, pastilles d'état ──────── def _about_header(self, info) -> QWidget: from app.ui.icons import icon_app box = QWidget() box.setStyleSheet(f"background: {theme.BG_RAISED};") v = QVBoxLayout(box) v.setContentsMargins(20, 16, 20, 14) v.setSpacing(6) v.setAlignment(Qt.AlignmentFlag.AlignCenter) logo = QLabel() logo.setPixmap(icon_app().pixmap(72, 72)) logo.setAlignment(Qt.AlignmentFlag.AlignCenter) v.addWidget(logo) name = QLabel(info.APP_NAME) name.setAlignment(Qt.AlignmentFlag.AlignCenter) nf = QFont(); nf.setPointSize(18); nf.setBold(True) name.setFont(nf) name.setStyleSheet(f"color: {theme.TEXT};") v.addWidget(name) tagline = QLabel(info.TAGLINE) tagline.setAlignment(Qt.AlignmentFlag.AlignCenter) tagline.setWordWrap(True) tagline.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;") v.addWidget(tagline) pills = QWidget() pills.setStyleSheet("background: transparent;") ph = QHBoxLayout(pills) ph.setContentsMargins(0, 4, 0, 0) ph.setSpacing(8) ph.setAlignment(Qt.AlignmentFlag.AlignCenter) for text, color in self._about_pills(info): pill = QLabel(text) pill.setStyleSheet( f"background: {color}; color: {theme.TEXT}; border-radius: 9px;" " padding: 3px 10px; font-size: 11px; font-weight: bold;" ) ph.addWidget(pill) v.addWidget(pills) return box def _about_pills(self, info) -> list[tuple[str, str]]: """Pastilles de version/licence, plus l'état réel de l'authentification serveur et des droits sudo — lu en direct, jamais une valeur supposée.""" from app.utils.platform_utils import ( has_root_privileges, is_windows, privilege_report, ) pills = [ (f"v{info.VERSION}", theme.ACCENT), (info.LICENSE, "#5b6b7a"), ] # Pastille fixe : l'ouverture de l'accès passe par le serveur dans # tous les cas. Elle se fondait sur la présence d'un identifiant VPN # en configuration locale, qui n'a jamais rien dit de ce que le # serveur exige — un poste sans identifiant pouvait être tenu en # quarantaine, et la pastille annonçait « Aucun code d'accès ». pills.append(("🔐 Accès validé par le serveur", theme.OK_SOLID)) if not is_windows(): if has_root_privileges(): pills.append(("⚠️ Exécuté en root", theme.WARN_SOLID)) else: rows = privilege_report() if rows and all(ok for _, ok, _ in rows): pills.append(("✅ sudoers configuré", theme.OK_SOLID)) elif rows: pills.append(("⚠️ dialogue à chaque appel", theme.WARN_SOLID)) return pills # ── 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, 2, 8, 2) iv.setSpacing(10) for item in items: iv.addWidget(item) iv.addStretch() area.setWidget(inner) return area def _about_group_label(self, text: str) -> QLabel: """Intitulé séparant deux groupes de cartes dans une même liste. Prérequis et Sécurité alignent une vingtaine de cartes chacun : sans rien pour marquer où finit « Linux » et où commence « Windows », la liste se lit comme un seul bloc indifférencié. """ lbl = QLabel(text) lbl.setWordWrap(True) lbl.setStyleSheet( f"color: {theme.ACCENT_LIGHT}; font-size: 13px; font-weight: bold;" " background: transparent; margin-top: 6px;" ) return lbl def _about_grouped(self, groups: list[tuple[str, list[tuple[str, str]]]]) -> QWidget: """Liste de cartes découpée en groupes titrés.""" items: list[QWidget] = [] for title, entries in groups: items.append(self._about_group_label(title)) items += [self._about_card(body, title=head) for head, body in entries] return self._about_card_list(items) # ── 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 : mode d'emploi ─────────────────────────────────────── def _subtab_about_howto(self, info) -> QWidget: page, v = self._test_page() v.addWidget(self._hint(info.HOWTO_FIRST_INTRO)) items: list[QWidget] = [ self._about_card(step, icon=f"{i}\u20e3") for i, step in enumerate(info.HOWTO_FIRST, 1) ] items.append(self._about_group_label("Au quotidien")) items += [self._about_card(body, title=head) for head, body in info.HOWTO_DAILY] items.append(self._about_group_label("Quand la connexion échoue")) items += [self._about_card(body, title=head) for head, body in info.HOWTO_TROUBLE] v.addWidget(self._about_card_list(items)) return page # ── Sous-onglet : prérequis ─────────────────────────────────────────── def _subtab_about_requirements(self, info) -> QWidget: page, v = self._test_page() v.addWidget(self._about_grouped(info.REQUIREMENTS)) return page # ── Sous-onglet : sécurité ──────────────────────────────────────────── def _subtab_about_security(self, info) -> QWidget: page, v = self._test_page() v.addWidget(self._about_grouped(info.SECURITY)) return page # ── Sous-onglet : garanties ─────────────────────────────────────────── def _subtab_about_guarantees(self, info) -> QWidget: page, v = self._test_page() v.addWidget(self._hint(info.GUARANTEES_INTRO)) 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() 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) ──────────────────── def _subtab_about_changelog(self, info) -> QWidget: page, v = self._test_page() view = QTextEdit() view.setReadOnly(True) 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']}
" ) for section in entry["sections"]: parts.append( f"{section['name']}
" "| {bullet} {escape(st["label"])} | ' f'' f'{escape(st["detail"])} | ' '