"""
Interface graphique PySide6 — reproduit l'expérience de base de LogMeIn Hamachi :
champ nom de réseau + mot de passe, bouton créer/rejoindre, liste de pairs façon
"lobby" avec statut visuel, ping temps réel et copie rapide de l'IP virtuelle.

Toutes les opérations réseau bloquantes (REST, WebSocket, WireGuard) tournent dans
un QThread dédié (NetworkWorker) pour ne jamais geler l'UI. Les pings périodiques
tournent dans un second QThread dédié (PingWorker), séparé pour ne jamais retarder
les opérations réseau critiques (jointure, mise à jour du tunnel).
"""

from __future__ import annotations

import functools
import datetime
import logging
import socket
import sys
import time
from collections import deque

import requests

from PySide6.QtCore import QObject, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import (
    QAction,
    QBrush,
    QColor,
    QDesktopServices,
    QIcon,
    QPainter,
    QPixmap,
)
from PySide6.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QComboBox,
    QDialog,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QHeaderView,
    QInputDialog,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QMenu,
    QMessageBox,
    QPushButton,
    QStatusBar,
    QSystemTrayIcon,
    QTableWidget,
    QTableWidgetItem,
    QTextEdit,
    QVBoxLayout,
    QWidget,
)

import a2s_client
import acl_rules
import config
import connection_quality
import display_name_settings
import game_launcher
import diagnostics
import file_browser
import firewall_manager
import hosting_diagnostics
import hosts_file_manager
import http_probe
import duckdns
import duckdns_settings
import invitation
import launch_options
import network_defaults
import parallel_probe
import ping_client
import recent_networks
import server_reachability
import server_service_manager
import server_settings
import service_types
import steam_favorites_manager
import network_client
import theme_manager
import lan_override
import tunnel_health
import version
import wireguard_manager
import theme_settings
import wireguard_port_settings
import licence_client
import licence_settings
import licences_tierces
import ws_transport_settings
from lan_broadcast_relay import LanBroadcastRelay
from network_client import PEER_APPLY_DEBOUNCE_SECONDS, NetworkAlreadyExistsError, NetworkClient

logger = logging.getLogger("meshamoto-client.gui")

# Steam conserve son propre état en mémoire et réécrit serverbrowser_hist.vdf en
# quittant : écrire pendant qu'il tourne ne produit rien de visible (voir
# steam_favorites_manager). Mieux vaut le refuser et le dire que laisser l'utilisateur
# constater que sa liste de favoris n'a pas bougé.
STEAM_RUNNING_MESSAGE = (
    "Steam est ouvert. Il réécrit sa liste de favoris en quittant, ce qui annulerait "
    "la modification.\n\nFermez complètement Steam, puis recochez la case."
)
STEAM_RUNNING_ON_CLEAR_MESSAGE = (
    "Steam est ouvert : les entrées Meshamoto ont été retirées du fichier, mais Steam "
    "peut les réécrire en quittant. Relancez le retrait Steam fermé si elles réapparaissent."
)
STEAM_NOT_FOUND_MESSAGE = (
    "Aucune installation Steam trouvée sur cette machine (dossier userdata introuvable)."
)


class NetworkWorker(QObject):
    """
    Exécute les appels réseau bloquants dans un thread séparé.
    Communique avec l'UI exclusivement via des signaux Qt (thread-safe par construction).
    """

    joined = Signal(str)  # virtual_ip attribuée
    peers_updated = Signal(list)  # list[dict]
    error = Signal(str)
    # (message, definitif). SÉPARÉ de `error` à dessein : `_on_error` ouvre une
    # modale, et faire surgir une boîte bloquante à chaque coupure passagère —
    # que la reconnexion automatique résout en une seconde — serait pire que le
    # silence qu'on corrige (point 70).
    connection_state = Signal(str, bool)
    isolation_changed = Signal(bool)  # état effectif après tentative (succès=demandé, échec=inchangé)
    dns_changed = Signal(bool)  # état effectif après tentative (succès=demandé, échec=inchangé)
    steam_changed = Signal(bool, str)  # état effectif, message à afficher ("" si aucun)
    tunnel_repaired = Signal(bool)  # True si la remise en service a abouti
    lan_address_requise = Signal(str, str)  # hôte du serveur, explication à afficher
    password_rotated = Signal(bool, str, str)  # réussi, nouveau mot de passe, message
    peer_kicked = Signal(bool, str, str)  # réussi, pseudo, message
    network_deleted = Signal(bool, str, str)  # réussi, nom du réseau, message
    shutdown_complete = Signal()

    def __init__(self, client: NetworkClient) -> None:
        super().__init__()
        self.client = client

    def do_create_and_join(self, name: str, password: str, display_name: str) -> None:
        # Toute tentative repart d'une résolution propre. Sans ça, une substitution
        # mémorisée pour le réseau de la maison survivrait à un changement de
        # réseau et rendrait le serveur injoignable ailleurs — c'est-à-dire le
        # piège exact que cette fonctionnalité remplace (une ligne hosts oubliée,
        # rencontrée en conditions réelles le 2026-07-30).
        lan_override.annuler()
        try:
            self._executer_jointure(name, password, display_name)
            return
        except Exception as exc:  # requests.HTTPError, RuntimeError (droits admin), etc.
            logger.exception("Échec de connexion au réseau")
            if not server_reachability.est_echec_de_connexion(exc):
                self.error.emit(str(exc))
                return
            premiere_erreur = exc

        # Le serveur n'a pas répondu. S'il est chez nous et qu'on connaît son
        # adresse locale, on refait un essai en la forçant.
        hote = self.client.server_host
        adresse_lan = server_settings.load_lan_address(config.SERVER_ADDRESS_PATH, hote)
        adresse_deja_tentee = ""
        echec_tls = False
        if adresse_lan and lan_override.appliquer(hote, adresse_lan):
            try:
                self._executer_jointure(name, password, display_name)
                logger.info("Connexion établie via l'adresse locale %s", adresse_lan)
                return
            except Exception as exc_repli:  # noqa: BLE001 - diagnostic ci-dessous
                logger.exception("Échec aussi via l'adresse locale mémorisée")
                lan_override.annuler()

                # **Une erreur qui n'est PAS une panne de connexion prouve que la
                # substitution a RÉUSSI** : le serveur a été joint, et il a
                # répondu quelque chose de désagréable (401 sur un mauvais mot de
                # passe, 409, 5xx...). Ce cas doit sortir tel quel.
                # Sans cette porte, le 2026-07-31, un mot de passe faux saisi sur
                # un PC Windows s'est retrouvé présenté comme un problème de PORT
                # — le diagnostic envoyait vérifier le pare-feu et le schéma d'URL
                # alors qu'il suffisait de corriger un champ.
                if not server_reachability.est_echec_de_connexion(exc_repli):
                    self.error.emit(self._message_d_erreur(exc_repli))
                    return

                adresse_deja_tentee = adresse_lan
                # L'exception du REPLI, pas la première : c'est elle qui dit si la
                # connexion a abouti. `SSLError` héritant de `ConnectionError`, les
                # confondre ferait annoncer « rien ne répond » d'un serveur qui a
                # répondu.
                echec_tls = server_reachability.est_echec_tls(exc_repli)

        explication = self._message_d_erreur(premiere_erreur)
        # Le cas est reconnu comme « serveur sur notre propre réseau » : plutôt que
        # de laisser l'utilisateur éditer un fichier hosts, on lui demande l'adresse
        # une fois et on s'en souvient.
        if explication != str(premiere_erreur) and "VOTRE réseau local" in explication:
            if adresse_deja_tentee:
                # NE PAS redemander : ce serait la même question, la même réponse,
                # le même échec — la boucle constatée en conditions réelles le
                # 2026-07-31. L'adresse était juste, c'est le port qui ne l'était
                # pas, et on a tout ce qu'il faut pour le dire.
                self.error.emit(server_reachability.expliquer_substitution_infructueuse(
                    hote, adresse_deja_tentee,
                    self.client.server_port, self.client.use_tls, echec_tls,
                ))
                return
            self.lan_address_requise.emit(hote, explication)
            return
        self.error.emit(explication)

    def _executer_jointure(self, name: str, password: str, display_name: str) -> None:
        try:
            self.client.create_network(name, password)
        except NetworkAlreadyExistsError:
            # Cas normal : un autre pair a déjà créé ce réseau, on le rejoint
            # simplement. Toute AUTRE erreur (serveur injoignable, panne...) doit
            # remonter immédiatement plutôt que de continuer vers join_network,
            # qui échouerait de toute façon avec un message moins clair.
            pass
        session = self.client.join_network(name, password, display_name)
        self.joined.emit(session.virtual_ip)
        self.client.start_listening(
            on_peers_updated=lambda peers: self.peers_updated.emit(peers),
            on_error=lambda msg: self.error.emit(msg),
            on_connection_state=lambda msg, definitif: self.connection_state.emit(
                msg, definitif
            ),
        )

    def _message_d_erreur(self, exception: Exception) -> str:
        """
        Enrichit l'erreur brute quand on sait expliquer la panne.

        Le seul cas traité aujourd'hui est celui du serveur hébergé sur le même
        réseau local que le client, joint par son adresse publique : la box ne
        boucle pas la connexion sur elle-même et l'utilisateur ne récolte qu'un
        `ConnectTimeoutError` après dix secondes. C'est le premier obstacle de
        l'auto-hébergement, et il est parfaitement muet sans ça.

        Le diagnostic ne tourne qu'APRÈS un échec de connexion, jamais en
        préventif : il coûte plusieurs secondes de SSDP et de STUN.
        """
        if isinstance(exception, network_client.MauvaisMotDePasseError):
            # Le serveur a répondu 401 : il est parfaitement joignable. Le dire
            # explicitement évite qu'on aille chercher du côté du réseau, ce qui
            # est exactement ce qui s'est produit le 2026-07-31.
            return (
                "Le serveur a refusé la connexion : le nom du réseau ou le mot de "
                "passe est incorrect.\n\n"
                "Le serveur est bien joignable — ce n'est ni le réseau, ni le "
                "pare-feu, ni l'adresse. Vérifiez les champs « Nom du réseau » et "
                "« Mot de passe », en respectant les majuscules.\n\n"
                "Si l'hôte vous a envoyé un code d'invitation, collez-le plutôt "
                "que de retaper les informations : « Revoir l'accueil » puis "
                "« Un ami m'a envoyé un code »."
            )
        if not server_reachability.est_echec_de_connexion(exception):
            return str(exception)
        try:
            explication = server_reachability.diagnostiquer_serveur_injoignable(
                self.client.server_host
            )
        except Exception:  # noqa: BLE001 - un diagnostic raté ne doit rien casser
            logger.exception("Diagnostic de serveur injoignable en échec")
            return str(exception)
        return explication or str(exception)

    def do_repair_tunnel(self) -> None:
        """
        Remonte le tunnel après une panne locale (interface disparue).

        Obligatoirement dans ce thread : `repair_tunnel` prend `_apply_lock` et
        peut attendre qu'une application en cours se termine, ce qui gèlerait la
        fenêtre. Une exception ici ne doit pas non plus faire échouer le cycle de
        ping qui l'a déclenchée — d'où le repli sur `tunnel_repaired(False)`.
        """
        try:
            self.tunnel_repaired.emit(self.client.repair_tunnel())
        except Exception as exc:  # noqa: BLE001
            logger.exception("Réparation du tunnel en échec")
            self.error.emit(str(exc))
            self.tunnel_repaired.emit(False)

    def do_set_isolation(self, enabled: bool, peer_acls: dict) -> None:
        """
        Active ou désactive le mode isolation (pare-feu système). Nécessite une session
        active pour connaître le CIDR du réseau virtuel à autoriser en entrant.
        `peer_acls` (IP virtuelle -> ports autorisés) restreint les pairs listés à ces
        ports uniquement plutôt qu'à tout le réseau virtuel (voir acl_rules.py).
        """
        if self.client.session is None:
            self.error.emit("Rejoignez d'abord un réseau avant d'activer le mode isolation.")
            self.isolation_changed.emit(False)
            return
        try:
            if enabled:
                firewall_manager.enable_isolation(self.client.session.cidr, peer_acls)
            else:
                firewall_manager.disable_isolation(list(peer_acls.keys()))
            self.isolation_changed.emit(enabled)
        except Exception as exc:  # droits insuffisants, nft/netsh absent, etc.
            logger.exception("Échec de bascule du mode isolation")
            self.error.emit(str(exc))
            # On renvoie l'état précédent (la bascule a échoué) pour resynchroniser la case à cocher
            self.isolation_changed.emit(not enabled)

    def do_set_dns(self, enabled: bool, name_to_ip: dict[str, str]) -> None:
        """Active/désactive le DNS interne (bloc dans le fichier hosts système)."""
        try:
            if enabled:
                hosts_file_manager.update_entries(name_to_ip)
            else:
                hosts_file_manager.clear_entries()
            self.dns_changed.emit(enabled)
        except Exception as exc:  # droits insuffisants, fichier hosts introuvable, etc.
            logger.exception("Échec de bascule du DNS interne")
            self.error.emit(str(exc))
            self.dns_changed.emit(not enabled)

    def do_update_dns_entries(self, name_to_ip: dict[str, str]) -> None:
        """Rafraîchit le bloc DNS interne suite à une mise à jour de la liste de pairs
        (appelé seulement quand le DNS interne est déjà activé)."""
        try:
            hosts_file_manager.update_entries(name_to_ip)
        except Exception:
            logger.exception("Échec de mise à jour du DNS interne")

    def do_set_steam_favorites(self, enabled: bool, peers: dict, peer_ports: dict) -> None:
        """
        Écrit (ou retire) les pairs dans les favoris du navigateur de serveurs Steam.

        Passe par ce thread et non par le thread graphique : `is_steam_running()` lance
        `tasklist` sous Windows (jusqu'à 5 s de timeout) et l'écriture parcourt tous les
        comptes Steam de la machine.
        """
        if self.client.session is None:
            self.steam_changed.emit(False, "Rejoignez d'abord un réseau.")
            return
        cidr = self.client.session.cidr
        try:
            if enabled:
                if not steam_favorites_manager.find_favorites_files():
                    self.steam_changed.emit(False, STEAM_NOT_FOUND_MESSAGE)
                    return
                if steam_favorites_manager.is_steam_running():
                    self.steam_changed.emit(False, STEAM_RUNNING_MESSAGE)
                    return
                steam_favorites_manager.update_favorites(
                    peers, cidr, config.SOURCE_ENGINE_LAN_PORT, peer_ports
                )
                self.steam_changed.emit(True, "")
            else:
                steam_favorites_manager.clear_favorites(cidr)
                self.steam_changed.emit(
                    False,
                    STEAM_RUNNING_ON_CLEAR_MESSAGE if steam_favorites_manager.is_steam_running() else "",
                )
        except Exception as exc:  # dossier Steam illisible, etc.
            logger.exception("Échec de bascule des favoris Steam")
            self.steam_changed.emit(not enabled, str(exc))

    def do_update_steam_favorites(self, peers: dict, peer_ports: dict) -> None:
        """Rafraîchit les favoris suite à une mise à jour de la liste de pairs (appelé
        seulement quand la case est déjà cochée).

        Ne fait rien si Steam a été lancé entre-temps : l'écriture serait perdue à sa
        fermeture, et la case reste cochée — les favoris déjà en place restent valides,
        seuls les pairs arrivés depuis manqueront jusqu'à la prochaine occasion."""
        if self.client.session is None:
            return
        try:
            if steam_favorites_manager.is_steam_running():
                logger.info("Favoris Steam non rafraîchis : Steam est ouvert")
                return
            steam_favorites_manager.update_favorites(
                peers, self.client.session.cidr, config.SOURCE_ENGINE_LAN_PORT, peer_ports
            )
        except Exception:
            logger.exception("Échec de mise à jour des favoris Steam")

    def do_request_relay_fallback(self, target_peer_id: int) -> None:
        """Demande une session de relais UDP de secours vers ce pair (voir
        _on_ping_results dans MainWindow : déclenché après plusieurs échecs de ping
        consécutifs). Best-effort : pas de signal de retour, la liste de pairs mise à
        jour (peers_updated) reflétera le résultat le cas échéant."""
        try:
            self.client.request_relay_fallback(target_peer_id)
        except Exception:
            logger.exception("Échec de la demande de relais de secours pour le pair %s", target_peer_id)

    def do_announce_services(self, services: list) -> None:
        """Déclare l'état complet des services locaux, diffusé aux autres pairs
        (voir gui.py, 'Mes services hébergés')."""
        try:
            self.client.announce_services(services)
        except Exception:
            logger.exception("Échec de l'annonce des services hébergés")

    def do_clear_service(self) -> None:
        try:
            self.client.clear_service()
        except Exception:
            logger.exception("Échec de l'effacement du service hébergé")

    def do_rotate_password(self, nom: str, ancien: str, nouveau: str) -> None:
        """
        Change le mot de passe du réseau dans le thread réseau : c'est un appel
        HTTP, il n'a rien à faire sur le thread graphique.

        Les trois cas sont distingués — mot de passe refusé, serveur injoignable,
        et le reste — parce qu'ils appellent des gestes différents : corriger une
        saisie, vérifier son serveur, ou lire le journal.
        """
        try:
            self.client.rotate_password(nom, ancien, nouveau)
        except network_client.MauvaisMotDePasseError:
            self.password_rotated.emit(
                False, "",
                "Le mot de passe actuel est refusé par le serveur (ou ce réseau "
                "n'existe pas). Vérifiez le champ « Mot de passe ».",
            )
        except requests.RequestException as exc:
            logger.warning("Rotation du mot de passe impossible : %s", exc)
            self.password_rotated.emit(
                False, "", "Le serveur est injoignable : le mot de passe n'a PAS été changé."
            )
        except Exception as exc:  # noqa: BLE001
            logger.exception("Rotation du mot de passe en échec")
            self.password_rotated.emit(False, "", str(exc))
        else:
            self.password_rotated.emit(True, nouveau, "")

    def do_kick_peer(self, nom: str, mot_de_passe: str, peer_id: int, pseudo: str) -> None:
        try:
            self.client.kick_peer(nom, mot_de_passe, peer_id)
        except network_client.MauvaisMotDePasseError:
            self.peer_kicked.emit(
                False, pseudo,
                "Le mot de passe du réseau est refusé par le serveur : impossible "
                "d'expulser ce pair.",
            )
        except requests.RequestException as exc:
            logger.warning("Expulsion impossible : %s", exc)
            self.peer_kicked.emit(False, pseudo, "Le serveur est injoignable.")
        except Exception as exc:  # noqa: BLE001
            logger.exception("Expulsion en échec")
            self.peer_kicked.emit(False, pseudo, str(exc))
        else:
            self.peer_kicked.emit(True, pseudo, "")

    def do_delete_network(self, nom: str, mot_de_passe: str) -> None:
        try:
            self.client.delete_network(nom, mot_de_passe)
        except network_client.MauvaisMotDePasseError:
            self.network_deleted.emit(
                False, nom,
                "Le mot de passe est refusé par le serveur (ou ce réseau n'existe déjà plus).",
            )
        except requests.RequestException as exc:
            logger.warning("Suppression du réseau impossible : %s", exc)
            self.network_deleted.emit(
                False, nom, "Le serveur est injoignable : le réseau n'a PAS été supprimé."
            )
        except Exception as exc:  # noqa: BLE001
            logger.exception("Suppression du réseau en échec")
            self.network_deleted.emit(False, nom, str(exc))
        else:
            self.network_deleted.emit(True, nom, "")

    def do_shutdown_cleanup(
        self, isolation_enabled: bool, acl_virtual_ips: list, dns_enabled: bool,
        service_shared: bool, steam_enabled: bool = False,
    ) -> None:
        """
        Nettoyage à la fermeture de l'appli, exécuté dans ce thread dédié plutôt que
        dans MainWindow.closeEvent — ces appels (pare-feu, DNS, wg-quick down) peuvent
        chacun déclencher une invite pkexec bloquante ; les faire sur le thread
        graphique gelait toute la fenêtre (et l'icône de la barre système) tant que
        l'utilisateur n'avait pas répondu, sans aucun retour visuel pour expliquer
        pourquoi rien ne se passait.
        """
        if isolation_enabled:
            try:
                firewall_manager.disable_isolation(acl_virtual_ips)
            except Exception:
                logger.exception("Échec lors du nettoyage des règles d'isolation à la fermeture")
        if dns_enabled:
            try:
                hosts_file_manager.clear_entries()
            except Exception:
                logger.exception("Échec lors du nettoyage du DNS interne à la fermeture")
        if steam_enabled and self.client.session is not None:
            # Avant client.stop(), qui libère la session dont on tire le CIDR : c'est lui
            # qui identifie NOS entrées parmi les favoris de l'utilisateur.
            try:
                steam_favorites_manager.clear_favorites(self.client.session.cidr)
            except Exception:
                logger.exception("Échec du retrait des favoris Steam à la fermeture")
        if service_shared:
            try:
                self.client.clear_service()
            except Exception:
                logger.exception("Échec lors de l'effacement du service hébergé à la fermeture")
        try:
            self.client.stop()
        except Exception:
            logger.exception("Échec lors de la désinstallation du tunnel à la fermeture")
        self.shutdown_complete.emit()


# Sépare les services d'un même pair dans la colonne Service. Volontairement autre
# chose que « · », qu'`a2s_client.resume()` utilise déjà À L'INTÉRIEUR d'un service
# (« nmo_broadway · 3/16 ») : réutiliser le même séparateur rendrait impossible de
# dire où finit un service et où commence le suivant.
_SEPARATEUR_SERVICES = "  |  "


def _sonder_un_service(virtual_ip: str, port: int, type_service: str):
    """
    Sonde un service selon son TYPE. Renvoie None si rien ne répond, ou si le type
    ne se sonde pas.

    Fonction de module et non méthode : c'est de l'aiguillage pur, et l'extraire la
    rend testable sans instancier de `QThread` — la famille de segfauls des points 27,
    45, 47 et 59 vient précisément des tests qui doivent monter une fenêtre pour
    exercer trois lignes.

    Ne lève jamais : `parallel_probe` isole déjà les exceptions, mais un service en
    erreur ne doit en aucun cas vider la colonne de tous les autres.
    """
    sonde = service_types.comportement_de(type_service).sonde
    try:
        if sonde == service_types.SONDE_A2S:
            return a2s_client.interroger(virtual_ip, port)
        if sonde == service_types.SONDE_HTTP:
            return http_probe.sonder(virtual_ip, port)
    except Exception:  # noqa: BLE001 - une sonde ne fait jamais échouer le lot
        logger.debug("Sonde de %s:%s (%s) en échec", virtual_ip, port, type_service)
        return None
    # Type sans sonde (voix, fichier, ou type inconnu venu d'un pair plus récent).
    # None y signifie « pas d'information », et le salon l'affiche comme tel plutôt
    # que d'affirmer une extinction qu'il n'a pas mesurée.
    return None


class PingWorker(QObject):
    """
    Mesure la latence vers les pairs dans un thread dédié, séparé de NetworkWorker :
    un ping qui traîne (pair injoignable, jusqu'à ~1s de timeout par pair) ne doit
    jamais retarder une opération réseau critique (jointure, mise à jour du tunnel).
    """

    ping_results = Signal(dict)  # {virtual_ip: latence_ms | None}
    diagnostics_ready = Signal(list)  # list[diagnostics.DiagnosticResult]
    # `Signal(object)` et NON `Signal(dict)` : la clé est un tuple `(virtual_ip, port)`
    # depuis qu'un pair peut annoncer plusieurs services, or `dict` se marshalle en
    # `QVariantMap`, qui n'accepte que des clés CHAÎNE. Qt ne lève pas — il journalise
    # `Cannot copy-convert (dict) to C++` sur la sortie standard et livre un
    # dictionnaire VIDE. Le salon n'aurait donc jamais connu l'état d'aucun service,
    # en silence. `object` fait passer l'objet Python tel quel.
    services_probed = Signal(object)  # {(virtual_ip, port): InfoServeur | InfoService | None}

    def __init__(self, parent: QObject | None = None) -> None:
        super().__init__(parent)
        # Dernier verdict de handshake calculé, réutilisé par les tests de port
        # successifs — voir _handshake_result.
        self._handshake_cache: tuple[str, diagnostics.DiagnosticResult] | None = None

    def do_ping_peers(self, virtual_ips: list[str]) -> None:
        """
        Mesure la latence vers tous les pairs, EN PARALLÈLE.

        En séquentiel, huit pairs injoignables coûtaient 8 secondes mesurées
        pour un cycle de 5 : les demandes s'empilaient plus vite qu'elles ne se
        vidaient, et le lobby paraissait figé au pire moment — celui où
        justement quelque chose ne va pas.

        Passe par `mesurer_latence` et non par `ping` : la vivacité est établie
        par l'écho applicatif dans le tunnel, l'ICMP ne servant que de repli pour
        les pairs qui ne le servent pas encore (point 67bis). Tout ce qui suit —
        affichage, compteurs d'échec, repli sur relais — en hérite sans autre
        modification, puisque tout dépend de « cette latence est-elle None ? ».
        """
        self.ping_results.emit(parallel_probe.sonder(virtual_ips, ping_client.mesurer_latence))

    def do_probe_services(self, services: list) -> None:
        """
        Interroge les services annoncés pour savoir s'ils sont réellement vivants.

        Greffé sur le même thread que les pings et déclenché par le même cycle :
        c'est exactement le même profil d'appel (requête courte vers l'IP virtuelle
        d'un pair, avec timeout), et ça évite un troisième thread — dont on a vu
        au point 45 qu'il n'est jamais gratuit.

        `services` est une liste de (virtual_ip, port, type). **La sonde dépend du
        TYPE** : A2S pour un serveur de jeu, HTTP pour une interface web, aucune pour
        le reste. Avant le typage, tout service recevait une requête A2S — donc une
        interface web restait 1,5 s en attente à chaque cycle et s'affichait « sans
        réponse » alors qu'elle allait parfaitement bien.

        Le résultat est indexé par **(virtual_ip, port)** et non par IP seule : un pair
        peut désormais annoncer plusieurs services, et les mélanger ferait afficher
        l'état de l'un sur la ligne de l'autre.

        Un service éteint est le cas NORMAL, pas une anomalie : il ressort à None.
        """
        # En parallèle pour la même raison que les pings : un service éteint coûte le
        # délai d'attente complet, et c'est le cas NORMAL — un pair laisse son service
        # annoncé après avoir fermé le jeu.
        self.services_probed.emit(parallel_probe.sonder(
            services,
            lambda service: _sonder_un_service(*service),
            cle=lambda service: (service[0], service[1]),
        ))

    def do_run_diagnostics(
        self, virtual_ip: str, public_key: str, port: int, protocol: str, peer_os: str = ""
    ) -> None:
        """Exécute les vérifications de connectivité vers un pair (voir diagnostics.py).
        `port=0` signifie "pas de test de port applicatif", seulement ping + handshake —
        c'est la vérification initiale à l'ouverture de la fenêtre, la seule qui
        interroge réellement WireGuard (voir _handshake_result).
        `peer_os` est vide si le pair n'a pas signalé le sien (client plus ancien)."""
        results = [
            diagnostics.check_ping(virtual_ip, peer_os or None),
            self._handshake_result(public_key, refresh=not port),
        ]
        if port:
            results.append(diagnostics.check_port(virtual_ip, port, protocol))
        self.diagnostics_ready.emit(results)

    def _handshake_result(self, public_key: str, refresh: bool) -> diagnostics.DiagnosticResult:
        """
        Verdict de handshake pour ce pair, en réinterrogeant WireGuard seulement si
        `refresh`. `diagnostics.check_handshake` passe par `wg show`, qui exige les
        privilèges root : sur Linux, chaque appel coûte une invite pkexec. Comme
        `show_results` réaffiche la liste complète des résultats à chaque fois, la ligne
        handshake doit rester présente lors d'un test de port — mais la recalculer
        redemandait le mot de passe à chaque clic sur "Tester ce port". On réutilise donc
        le verdict de la vérification initiale pour toute la durée de la fenêtre (rouvrir
        le diagnostic refait un vrai appel, avec `port=0`).
        """
        if not refresh and self._handshake_cache is not None and self._handshake_cache[0] == public_key:
            return self._handshake_cache[1]
        result = diagnostics.check_handshake(public_key)
        self._handshake_cache = (public_key, result)
        return result


_MOIS_FR = (
    "janvier", "février", "mars", "avril", "mai", "juin",
    "juillet", "août", "septembre", "octobre", "novembre", "décembre",
)


_LICENCES_FR = {
    "permanent": "licence permanente",
    "demo": "licence d'essai",
}


def _licence_en_francais(palier: str | None) -> str:
    """
    Libellé accordé, plutôt que l'identifiant brut du jeton.

    Le premier jet interpolait le palier tel quel et produisait « licence
    permanent » -- « licence » est féminin. Signalé le 2026-08-07.

    **Le repli n'invente pas d'accord** : un palier inconnu (le serveur peut en
    introduire un que ce client ne connaît pas encore) donne « licence « X » »,
    entre guillemets, ce qui reste juste quel que soit son genre. Deviner
    produirait une faute sur exactement les valeurs qu'on n'a pas prévues.
    """
    if not palier:
        return "licence de palier inconnu"
    if palier in _LICENCES_FR:
        return _LICENCES_FR[palier]
    # Le palier vient du JETON, donc du serveur : sans borne, une valeur bavarde
    # élargirait la fenêtre principale (le libellé n'a plus de retour à la ligne).
    return f"licence « {palier[:24]} »"


def _date_en_francais(quand: datetime.datetime) -> str:
    """
    Date lisible, SANS dépendre de la locale du processus.

    `strftime("%B")` rend le mois dans la locale courante, que Qt ne fixe pas : le
    libellé sortait « 06 September 2026 » au milieu d'une interface entièrement
    française (constaté le 2026-08-07 en regardant le rendu réel, pas les tests --
    aucun d'eux ne pouvait le voir, ils comparaient des sous-chaînes). Poser la
    locale globalement serait pire : c'est un état de processus qui influerait sur
    l'analyse de nombres ailleurs dans le programme.
    """
    return f"{quand.day} {_MOIS_FR[quand.month - 1]} {quand.year}"


class WelcomeDialog(QDialog):
    """
    Premier écran : deux chemins explicites, plutôt qu'un formulaire.

    Sans lui, Meshamoto s'ouvre sur des champs « Serveur », « Nom du réseau »,
    « Mot de passe » et « Port WireGuard » — ce qui suppose de savoir ce qu'est
    un serveur de signalisation avant même d'avoir commencé. C'est le moment où
    l'on perd les gens.

    **L'hébergement est présenté en premier, délibérément.** Meshamoto est un
    outil d'auto-hébergement : le réseau appartient à celui qui le fait tourner,
    et aucune société ne voit qui se connecte à quoi. Reléguer ça derrière
    « paramètres avancés » ferait passer le sujet du projet pour un détail.
    """

    HEBERGER = "heberger"
    REJOINDRE = "rejoindre"
    MANUEL = "manuel"
    SERVEUR_GERE = "serveur_gere"

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle("Bienvenue dans Meshamoto")
        self.resize(560, 420)
        self.choix: str | None = None
        self.invitation: invitation.Invitation | None = None

        layout = QVBoxLayout(self)

        intro = QLabel(
            "Meshamoto relie vos machines par un tunnel chiffré, comme si elles "
            "étaient sur le même réseau local — pour jouer en réseau, accéder à "
            "un NAS ou partager un serveur de jeu.\n\n"
            "Le serveur qui met les machines en relation est le vôtre : personne "
            "d'autre ne voit qui se connecte à quoi, et vos données ne passent "
            "jamais par une société tierce."
        )
        intro.setWordWrap(True)
        layout.addWidget(intro)

        heberger_box = QGroupBox("Je crée mon réseau")
        heberger_layout = QVBoxLayout(heberger_box)
        detail_heberger = QLabel(
            "Meshamoto fait tourner le serveur sur cette machine et vous donne un "
            "code à envoyer à vos amis. C'est le mode normal — rien à configurer "
            "ailleurs."
        )
        detail_heberger.setWordWrap(True)
        heberger_layout.addWidget(detail_heberger)
        self.heberger_button = QPushButton("Héberger mon réseau sur cette machine")
        self.heberger_button.clicked.connect(self._choisir_heberger)
        heberger_layout.addWidget(self.heberger_button)
        layout.addWidget(heberger_box)

        rejoindre_box = QGroupBox("Un ami m'a envoyé un code")
        rejoindre_layout = QVBoxLayout(rejoindre_box)
        detail_rejoindre = QLabel(
            "Collez le code reçu : il contient tout ce qu'il faut, vous n'avez "
            "rien d'autre à saisir."
        )
        detail_rejoindre.setWordWrap(True)
        rejoindre_layout.addWidget(detail_rejoindre)
        self.code_input = QLineEdit()
        self.code_input.setPlaceholderText("MESHA1-...")
        self.code_input.textChanged.connect(self._verifier_code)
        rejoindre_layout.addWidget(self.code_input)
        self.code_feedback = QLabel("")
        self.code_feedback.setWordWrap(True)
        rejoindre_layout.addWidget(self.code_feedback)
        self.rejoindre_button = QPushButton("Rejoindre ce réseau")
        self.rejoindre_button.setEnabled(False)
        self.rejoindre_button.clicked.connect(self._choisir_rejoindre)
        rejoindre_layout.addWidget(self.rejoindre_button)
        layout.addWidget(rejoindre_box)

        # TROISIÈME CHEMIN, ajouté le 2026-08-07. Sans lui, quelqu'un qui achète une
        # clé n'a AUCUN moyen d'apprendre l'adresse du serveur : le défaut du client
        # est la boucle locale, le site n'affichait pas l'adresse, et une clé de
        # seize caractères ne peut pas la transporter — contrairement au code
        # d'invitation, qui est le seul mécanisme du produit à le faire. Le parcours
        # de l'acheteur seul était donc cassé, alors que chaque pièce fonctionnait.
        gere_box = QGroupBox("Utiliser le serveur Meshamoto")
        gere_layout = QVBoxLayout(gere_box)
        detail_gere = QLabel(
            "Nous faisons la mise en relation, il n'y a rien à configurer sur "
            "votre routeur. Vos parties, elles, ne passent pas par nous : le "
            "tunnel reste direct entre vos machines.\n\n"
            f"Adresse utilisée : {config.MANAGED_SERVER_URL}"
        )
        detail_gere.setWordWrap(True)
        gere_layout.addWidget(detail_gere)
        self.serveur_gere_button = QPushButton("Utiliser le serveur Meshamoto")
        self.serveur_gere_button.clicked.connect(self._choisir_serveur_gere)
        gere_layout.addWidget(self.serveur_gere_button)
        layout.addWidget(gere_box)

        self.manuel_button = QPushButton("Configurer manuellement")
        self.manuel_button.setToolTip(
            "Pour saisir soi-même l'adresse d'un serveur existant, sans code."
        )
        self.manuel_button.clicked.connect(self._choisir_manuel)
        layout.addWidget(self.manuel_button)

    def _verifier_code(self, texte: str) -> None:
        """
        Retour immédiat à la frappe, plutôt qu'un échec après avoir cliqué.

        Distingue trois états : rien de collé, quelque chose qui ne ressemble
        pas à un code, et un code qu'on n'arrive pas à lire. Le dernier est le
        plus utile — c'est presque toujours un copier-coller incomplet.
        """
        if not texte.strip():
            self.code_feedback.setText("")
            self.rejoindre_button.setEnabled(False)
            return
        self.invitation = invitation.decoder(texte)
        if self.invitation is not None:
            self.code_feedback.setText(
                f"Réseau « {self.invitation.network_name} » sur "
                f"{self.invitation.server_address}"
            )
            self.rejoindre_button.setEnabled(True)
            return
        self.rejoindre_button.setEnabled(False)
        if invitation.ressemble_a_une_invitation(texte):
            self.code_feedback.setText(
                "Ce code est incomplet ou abîmé. Recopiez-le en entier, "
                "il commence par MESHA1-."
            )
        else:
            self.code_feedback.setText("Ce n'est pas un code d'invitation Meshamoto.")

    def _choisir_heberger(self) -> None:
        self.choix = self.HEBERGER
        self.accept()

    def _choisir_rejoindre(self) -> None:
        if self.invitation is None:
            return
        self.choix = self.REJOINDRE
        self.accept()

    def _choisir_manuel(self) -> None:
        self.choix = self.MANUEL
        self.accept()

    def _choisir_serveur_gere(self) -> None:
        self.choix = self.SERVEUR_GERE
        self.accept()


class HostingWorker(QObject):
    """
    Pilote le serveur de signalisation local dans un thread dédié.

    Troisième worker, et pour la même raison que PingWorker est séparé de
    NetworkWorker : ces appels sont lents et sans rapport avec le tunnel. Une
    découverte SSDP prend jusqu'à 3 s et un `systemctl` peut traîner au
    démarrage de session — les faire passer par le thread réseau retarderait
    une jointure ou une mise à jour de pairs, et les faire sur le thread
    graphique gèlerait la fenêtre.
    """

    status_ready = Signal(object)  # ServiceStatus
    logs_ready = Signal(str)
    diagnostics_ready = Signal(object)  # list[hosting_diagnostics.Constat]
    duckdns_result = Signal(bool, str)  # succès, message affichable tel quel
    error = Signal(str)

    def do_refresh_status(self) -> None:
        try:
            self.status_ready.emit(server_service_manager.status())
        except Exception as exc:  # noqa: BLE001 - remonté à l'écran, jamais avalé
            logger.exception("Lecture de l'état du serveur en échec")
            self.error.emit(str(exc))

    def do_start(self) -> None:
        try:
            server_service_manager.start()
        except Exception as exc:  # noqa: BLE001
            logger.exception("Démarrage du serveur en échec")
            self.error.emit(str(exc))
        self.do_refresh_status()

    def do_stop(self) -> None:
        try:
            server_service_manager.stop()
        except Exception as exc:  # noqa: BLE001
            logger.exception("Arrêt du serveur en échec")
            self.error.emit(str(exc))
        self.do_refresh_status()

    def do_set_autostart(self, enabled: bool) -> None:
        try:
            server_service_manager.set_autostart(enabled)
        except Exception as exc:  # noqa: BLE001
            logger.exception("Configuration du démarrage automatique en échec")
            self.error.emit(str(exc))
        # Toujours relire l'état réel plutôt que de croire la case cochée : sur
        # Linux, l'activation peut réussir sans le linger, auquel cas le
        # démarrage automatique n'est PAS effectif (voir le backend).
        self.do_refresh_status()

    def do_fetch_logs(self, lines: int) -> None:
        try:
            self.logs_ready.emit(server_service_manager.recent_logs(lines))
        except Exception as exc:  # noqa: BLE001
            logger.exception("Lecture du journal en échec")
            self.logs_ready.emit(f"Journal indisponible : {exc}")

    def do_diagnose(self, port: int, domain: str) -> None:
        try:
            self.diagnostics_ready.emit(hosting_diagnostics.diagnostiquer(port, domain))
        except Exception as exc:  # noqa: BLE001
            logger.exception("Diagnostic d'hébergement en échec")
            self.error.emit(str(exc))

    def do_duckdns_save(self, domaines: str, token: str) -> None:
        """
        Enregistre le couple puis publie l'adresse courante.

        Dans ce thread parce que l'appel HTTP peut prendre jusqu'à 30 s : sur le
        thread graphique, la fenêtre gèlerait exactement pendant que l'utilisateur
        attend un verdict.

        L'enregistrement a lieu AVANT l'appel, et même si celui-ci échoue : sur
        Arch la minuterie systemd lit ce fichier et réessaiera toute seule, ce qui
        rattrape une panne réseau passagère. Ne rien écrire tant que DuckDNS n'a
        pas répondu obligerait l'utilisateur à tout retaper.
        """
        domaines = duckdns_settings.normaliser_domaine(domaines)
        token = token.strip()
        erreur = duckdns_settings.valider(domaines, token)
        if erreur:
            self.duckdns_result.emit(False, erreur)
            return
        try:
            duckdns_settings.enregistrer(config.DUCKDNS_SETTINGS_PATH, domaines, token)
        except OSError as exc:
            logger.warning("Écriture des réglages DuckDNS impossible : %s", exc)
            self.duckdns_result.emit(False, f"Impossible d'enregistrer les réglages : {exc}")
            return

        resultat = duckdns.mettre_a_jour(domaines, token)
        message = resultat.message
        if resultat.succes:
            message += f" Vos amis peuvent utiliser {domaines}.duckdns.org."
        self.duckdns_result.emit(resultat.succes, message)


class HostingDialog(QDialog):
    """
    Panneau « Héberger un serveur » : démarrer/arrêter la signalisation locale,
    choisir si elle revient au démarrage de la machine, et diagnostiquer sa
    joignabilité depuis Internet.

    Ne fait aucun appel bloquant elle-même — elle émet des demandes et affiche
    ce que MainWindow lui repousse depuis HostingWorker, même découpage que
    DiagnosticDialog.
    """

    start_requested = Signal()
    stop_requested = Signal()
    autostart_requested = Signal(bool)
    diagnose_requested = Signal(int, str)
    logs_requested = Signal(int)
    duckdns_save_requested = Signal(str, str)  # sous-domaine, token

    _SYMBOLES = {
        hosting_diagnostics.OK: "✓",
        hosting_diagnostics.AVERTISSEMENT: "!",
        hosting_diagnostics.PROBLEME: "✗",
    }

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle("Héberger un serveur Meshamoto")
        self.resize(520, 560)

        layout = QVBoxLayout(self)

        explication = QLabel(
            "Héberger votre propre serveur de signalisation rend votre réseau "
            "indépendant : aucune société tierce ne voit qui se connecte à quoi. "
            "Le serveur ne transporte jamais vos données, seulement les adresses "
            "permettant aux machines de se trouver."
        )
        explication.setWordWrap(True)
        layout.addWidget(explication)

        etat_box = QGroupBox("État")
        etat_layout = QVBoxLayout(etat_box)
        self.status_label = QLabel("Vérification en cours...")
        self.status_label.setWordWrap(True)
        etat_layout.addWidget(self.status_label)

        boutons = QHBoxLayout()
        self.start_button = QPushButton("Démarrer")
        self.start_button.clicked.connect(self.start_requested.emit)
        boutons.addWidget(self.start_button)
        self.stop_button = QPushButton("Arrêter")
        self.stop_button.clicked.connect(self.stop_requested.emit)
        boutons.addWidget(self.stop_button)
        etat_layout.addLayout(boutons)

        self.autostart_checkbox = QCheckBox(
            "Redémarrer automatiquement au démarrage de la machine"
        )
        self.autostart_checkbox.toggled.connect(self._on_autostart_toggled)
        etat_layout.addWidget(self.autostart_checkbox)
        layout.addWidget(etat_box)

        duck_box = QGroupBox("Nom de domaine DuckDNS (gratuit)")
        duck_layout = QVBoxLayout(duck_box)
        duck_intro = QLabel(
            "Votre adresse Internet change toute seule. Un nom DuckDNS la suit "
            "automatiquement, pour que vos amis gardent la même adresse.\n"
            "Créez un compte sur duckdns.org, choisissez un sous-domaine, puis "
            "recopiez-le ici avec votre token."
        )
        duck_intro.setWordWrap(True)
        duck_layout.addWidget(duck_intro)

        duck_form = QFormLayout()
        self.duckdns_domain_input = QLineEdit()
        self.duckdns_domain_input.setPlaceholderText("monreseau")
        duck_form.addRow("Sous-domaine :", self.duckdns_domain_input)
        self.duckdns_token_input = QLineEdit()
        # Le token est un secret : il ouvre la modification de l'enregistrement
        # DNS. Le masquer évite qu'il traîne sur une capture d'écran ou un
        # partage d'écran — c'est le cas d'usage même de ce panneau.
        self.duckdns_token_input.setEchoMode(QLineEdit.EchoMode.Password)
        self.duckdns_token_input.setPlaceholderText("token affiché sur duckdns.org")
        duck_form.addRow("Token :", self.duckdns_token_input)
        duck_layout.addLayout(duck_form)

        self.duckdns_save_button = QPushButton("Enregistrer et mettre à jour maintenant")
        self.duckdns_save_button.clicked.connect(self._on_duckdns_save_clicked)
        duck_layout.addWidget(self.duckdns_save_button)

        self.duckdns_status_label = QLabel("")
        self.duckdns_status_label.setWordWrap(True)
        duck_layout.addWidget(self.duckdns_status_label)
        layout.addWidget(duck_box)

        diag_box = QGroupBox("Joignabilité depuis Internet")
        diag_layout = QVBoxLayout(diag_box)
        ligne_domaine = QHBoxLayout()
        ligne_domaine.addWidget(QLabel("Nom de domaine (facultatif) :"))
        self.domain_input = QLineEdit()
        self.domain_input.setPlaceholderText("ex: monreseau.duckdns.org")
        ligne_domaine.addWidget(self.domain_input, stretch=1)
        diag_layout.addLayout(ligne_domaine)

        self.diagnose_button = QPushButton("Vérifier la joignabilité")
        self.diagnose_button.clicked.connect(self._on_diagnose_clicked)
        diag_layout.addWidget(self.diagnose_button)

        self.diagnostics_view = QTextEdit()
        self.diagnostics_view.setReadOnly(True)
        self.diagnostics_view.setPlaceholderText(
            "Le diagnostic vérifie que le serveur répond, que votre box laisse "
            "entrer le port, et que votre nom de domaine pointe bien ici."
        )
        diag_layout.addWidget(self.diagnostics_view)
        layout.addWidget(diag_box)

        self.logs_view = QTextEdit()
        self.logs_view.setReadOnly(True)
        self.logs_view.setPlaceholderText("Journal du serveur")
        layout.addWidget(self.logs_view)

        # `_appliquer_etat` est appelée par MainWindow ; ce drapeau évite qu'elle
        # ne redéclenche une écriture en repositionnant la case à cocher.
        self._synchronisation_en_cours = False

    def _on_autostart_toggled(self, checked: bool) -> None:
        if self._synchronisation_en_cours:
            return
        self.autostart_requested.emit(checked)

    def charger_duckdns(self, domaines: str, token: str) -> None:
        """Réaffiche les réglages mémorisés à l'ouverture du panneau."""
        self.duckdns_domain_input.setText(domaines)
        self.duckdns_token_input.setText(token)
        if domaines and not self.domain_input.text().strip():
            # Le champ de diagnostic attend le nom COMPLET : le pré-remplir évite
            # d'avoir à retaper, et surtout d'écrire le sous-domaine seul, qui ne
            # résoudrait pas et ferait conclure à tort que le nom est cassé.
            self.domain_input.setText(f"{domaines}.duckdns.org")

    def _on_duckdns_save_clicked(self) -> None:
        self.duckdns_status_label.setText("Mise à jour en cours...")
        self.duckdns_save_requested.emit(
            self.duckdns_domain_input.text(), self.duckdns_token_input.text()
        )

    def afficher_resultat_duckdns(self, succes: bool, message: str) -> None:
        self.duckdns_status_label.setText(("✅ " if succes else "⚠️ ") + message)

    def _on_diagnose_clicked(self) -> None:
        self.diagnostics_view.setPlainText("Vérification en cours...")
        self.diagnose_requested.emit(
            config.DEFAULT_SERVER_PORT, self.domain_input.text().strip()
        )

    def appliquer_etat(self, status: server_service_manager.ServiceStatus) -> None:
        """Reflète l'état réel du serveur : c'est lui qui fait foi, jamais la
        position des contrôles."""
        self.status_label.setText(status.detail)
        self.start_button.setEnabled(status.installed and not status.running)
        self.stop_button.setEnabled(status.installed and status.running)
        self.autostart_checkbox.setEnabled(status.installed)

        self._synchronisation_en_cours = True
        self.autostart_checkbox.setChecked(status.autostart)
        self._synchronisation_en_cours = False

        if status.running:
            self.logs_requested.emit(40)

    def afficher_journal(self, texte: str) -> None:
        self.logs_view.setPlainText(texte)

    def afficher_diagnostics(self, constats: list[hosting_diagnostics.Constat]) -> None:
        lignes = [
            f"[{self._SYMBOLES.get(constat.niveau, '?')}] {constat.libelle}\n    {constat.detail}"
            for constat in constats
        ]
        self.diagnostics_view.setPlainText("\n\n".join(lignes))


class DiagnosticDialog(QDialog):
    """
    Fenêtre de diagnostic pour un pair : ping, handshake WireGuard, et test de port
    optionnel. Ne fait aucun appel réseau elle-même — se contente d'émettre
    `test_port_requested` et d'afficher les résultats poussés par MainWindow (voir
    PingWorker.do_run_diagnostics, exécuté dans un thread dédié pour ne pas geler l'UI).
    """

    test_port_requested = Signal(int, str)  # port, protocol

    def __init__(self, display_name: str, virtual_ip: str, peer_os: str = "", parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle(f"Diagnostic — {display_name} ({virtual_ip})")
        self.resize(420, 320)

        layout = QVBoxLayout(self)

        self.results_view = QTextEdit()
        self.results_view.setReadOnly(True)
        self.results_view.setPlainText("Vérification en cours...")
        layout.addWidget(self.results_view)

        # Conditionné à l'OS du PAIR diagnostiqué, pas à celui de cette machine : ce
        # panneau explique pourquoi ce pair-là ne répond pas, et la règle ci-dessous est
        # à appliquer sur SA machine. Le test portait auparavant sur `sys.platform`, donc
        # l'astuce restait invisible depuis Linux — précisément le cas où elle sert — et
        # s'affichait depuis Windows même pour un pair Linux, où elle induit en erreur.
        if peer_os == "windows":
            windows_tip = QLabel(
                "Astuce : ce pair tourne sous Windows, dont le pare-feu ne répond pas à "
                "l'ICMP entrant par défaut, même en profil \"Privé\". Si le ping échoue "
                "mais que le handshake est récent, faites-lui ajouter cette règle sur SA "
                "machine avant de chercher ailleurs :\n"
                "netsh advfirewall firewall add rule name=\"ICMP Allow incoming\" "
                "protocol=icmpv4:8,any dir=in action=allow"
            )
            windows_tip.setWordWrap(True)
            layout.addWidget(windows_tip)

        port_row = QHBoxLayout()
        self.port_input = QLineEdit()
        self.port_input.setPlaceholderText("Port à tester (ex: 25565)")
        port_row.addWidget(self.port_input, stretch=1)
        self.protocol_combo = QComboBox()
        self.protocol_combo.addItems(["tcp", "udp"])
        port_row.addWidget(self.protocol_combo)
        self.test_port_button = QPushButton("Tester ce port")
        self.test_port_button.clicked.connect(self._on_test_port_clicked)
        port_row.addWidget(self.test_port_button)
        layout.addLayout(port_row)

    def _on_test_port_clicked(self) -> None:
        text = self.port_input.text().strip()
        if not text.isdigit() or not (1 <= int(text) <= 65535):
            QMessageBox.warning(self, "Port invalide", "Entrez un numéro de port entre 1 et 65535.")
            return
        self.test_port_requested.emit(int(text), self.protocol_combo.currentText())

    def show_results(self, results: list[diagnostics.DiagnosticResult]) -> None:
        lines = []
        for result in results:
            symbol = {True: "✓", False: "✗", None: "?"}[result.ok]
            lines.append(f"[{symbol}] {result.label} : {result.detail}")
        self.results_view.setPlainText("\n\n".join(lines))


class ServiceAclDialog(QDialog):
    """
    Sélectionne, parmi les pairs déjà restreints par une ACL, ceux à qui ajouter le
    port du service qu'on vient de partager — pratique pour ne pas retaper toute leur
    liste de ports autorisés. Ne concerne QUE les pairs déjà sous restriction : un pair
    à accès complet n'apparaît pas ici, ajouter une restriction à quelqu'un qui avait
    un accès complet serait une réduction d'accès surprenante, pas l'effet "exposer"
    attendu (voir gui.py::_on_link_service_acl_clicked).
    """

    def __init__(self, restricted_peers: list[tuple[str, str, list[int]]], port: int, parent=None) -> None:
        super().__init__(parent)
        self.setWindowTitle(f"Ajouter le port {port} aux pairs restreints")
        self.resize(420, 300)

        layout = QVBoxLayout(self)
        layout.addWidget(QLabel(f"Cochez les pairs qui doivent aussi pouvoir accéder au port {port} :"))

        self.list_widget = QListWidget()
        for virtual_ip, display_name, ports in restricted_peers:
            item = QListWidgetItem(f"{display_name} ({virtual_ip}) — ports actuels : {ports}")
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(Qt.CheckState.Checked if port in ports else Qt.CheckState.Unchecked)
            item.setData(Qt.ItemDataRole.UserRole, virtual_ip)
            self.list_widget.addItem(item)
        layout.addWidget(self.list_widget)

        buttons = QHBoxLayout()
        ok_button = QPushButton("Appliquer")
        ok_button.clicked.connect(self.accept)
        buttons.addWidget(ok_button)
        cancel_button = QPushButton("Annuler")
        cancel_button.clicked.connect(self.reject)
        buttons.addWidget(cancel_button)
        layout.addLayout(buttons)

    def selected_virtual_ips(self) -> list[str]:
        result = []
        for i in range(self.list_widget.count()):
            item = self.list_widget.item(i)
            if item.checkState() == Qt.CheckState.Checked:
                result.append(item.data(Qt.ItemDataRole.UserRole))
        return result


class MainWindow(QMainWindow):
    request_join = Signal(str, str, str)  # name, password, display_name -> transmis au worker
    request_isolation = Signal(bool, dict)  # enabled, {virtual_ip: [ports]} -> transmis au worker
    request_dns = Signal(bool, dict)  # enabled, {display_name: virtual_ip} -> transmis au worker
    request_dns_update = Signal(dict)  # {display_name: virtual_ip} -> transmis au worker
    request_steam = Signal(bool, dict, dict)  # enabled, {pseudo: ip}, {pseudo: port} -> worker
    request_steam_update = Signal(dict, dict)  # {pseudo: ip}, {pseudo: port} -> transmis au worker
    request_relay_fallback = Signal(int)  # target_peer_id -> transmis au worker
    request_ping = Signal(list)  # list[str] d'IP virtuelles -> transmis au PingWorker
    request_service_probe = Signal(list)  # list[(virtual_ip, port)] -> transmis au PingWorker
    request_diagnostics = Signal(str, str, int, str, str)  # virtual_ip, public_key, port (0=aucun), protocol, os du pair ("" si inconnu)
    # `Signal(object)` et NON `Signal(list)` : la charge est une liste de dataclasses
    # `service_types.Service`, et Qt marshalle `list` en `QVariantList` — ce qui a déjà
    # silencieusement vidé `services_probed` quand ses clés sont devenues des tuples.
    # `object` fait passer l'objet Python tel quel.
    request_announce_services = Signal(object)  # list[service_types.Service] -> worker
    request_clear_service = Signal()  # -> transmis au worker
    request_rotate_password = Signal(str, str, str)  # nom, ancien, nouveau
    request_kick_peer = Signal(str, str, int, str)  # nom, mdp, peer_id, pseudo
    request_delete_network = Signal(str, str)  # nom, mdp
    request_shutdown = Signal(bool, list, bool, bool, bool)  # isolation_enabled, acl_ips, dns_enabled, service_shared, steam_enabled
    request_duckdns_save = Signal(str, str)  # -> transmis au HostingWorker
    request_hosting_status = Signal()  # -> transmis au HostingWorker
    request_hosting_start = Signal()
    request_hosting_stop = Signal()
    request_hosting_autostart = Signal(bool)
    request_hosting_logs = Signal(int)  # nombre de lignes
    request_hosting_diagnose = Signal(int, str)  # port, nom de domaine ("" si aucun)
    request_tunnel_repair = Signal()  # -> transmis au NetworkWorker

    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("Meshamoto")
        self.resize(420, 480)

        self.client = NetworkClient()
        self._pending_server_host = config.DEFAULT_SERVER_HOST
        self._pending_server_tls = config.DEFAULT_SERVER_TLS
        self._pending_server_port = config.DEFAULT_SERVER_PORT
        self._pending_name = ""
        self._pending_password = ""
        self._current_peers: list[dict] = []
        self._local_virtual_ip: str | None = None
        self._ping_failure_counts: dict[str, int] = {}
        self._ping_history: dict[str, deque] = {}
        self._relay_requested_virtual_ips: set[str] = set()
        # Dernière fois que le repli sur relais a été ÉVALUÉ (voir
        # config.PING_ESCALATION_MIN_INTERVAL_SECONDS). Initialisé loin dans le passé
        # pour que le tout premier cycle compte normalement.
        self._dernier_cycle_escalade = float("-inf")
        # Empêche la boucle « réparation qui échoue -> même diagnostic -> réparation ».
        # Remis à zéro seulement quand un pair répond de nouveau.
        self._tunnel_repair_attempted = False
        # {virtual_ip: a2s_client.InfoServeur | None} — None = sondé et muet.
        # Une IP absente signifie « jamais sondé », état distinct des deux autres.
        # Indexé par (virtual_ip, port) : un pair peut annoncer plusieurs services, et
        # une clé réduite à l'IP ferait afficher l'état de l'un sur la ligne de l'autre.
        self._service_status: dict[tuple[str, int], object] = {}
        self.lan_relay: LanBroadcastRelay | None = None
        self._diagnostic_dialog: DiagnosticDialog | None = None
        self._hosting_dialog: HostingDialog | None = None
        # Parcours guidé « je crée mon réseau » : deux étapes asynchrones à
        # enchaîner (le serveur doit tourner avant qu'on puisse créer le réseau,
        # et le réseau doit exister avant qu'on puisse montrer un code). Deux
        # drapeaux plutôt qu'un : le second doit survivre à la connexion, qui
        # baisse le premier.
        self._hebergement_guide_en_attente = False
        self._hebergement_guide_a_presenter = False
        # Diagnostic de joignabilité lancé tout seul à l'ouverture du panneau,
        # une fois le serveur debout (voir `_on_host_server_clicked`).
        self._diagnostic_auto_a_lancer = False
        self._service_shared = False
        # Source de vérité des services annoncés : la liste affichée en est le reflet,
        # jamais l'inverse (voir `_annoncer_services`).
        self._services_declares: list[service_types.Service] = []
        self._has_received_first_peer_list = False
        self._shutting_down = False
        self.hosting_thread: QThread | None = None
        self.hosting_worker: HostingWorker | None = None
        self._setup_peer_refresh_debounce()
        self._setup_worker_thread()
        self._setup_ping_worker()
        self._build_ui()
        self._setup_tray_icon()

    def _setup_ping_worker(self) -> None:
        self.ping_thread = QThread(self)
        self.ping_worker = PingWorker()
        self.ping_worker.moveToThread(self.ping_thread)

        self.request_ping.connect(self.ping_worker.do_ping_peers)
        self.ping_worker.ping_results.connect(self._on_ping_results)
        self.request_diagnostics.connect(self.ping_worker.do_run_diagnostics)
        self.ping_worker.diagnostics_ready.connect(self._on_diagnostics_ready)
        self.request_service_probe.connect(self.ping_worker.do_probe_services)
        self.ping_worker.services_probed.connect(self._on_services_probed)

        self.ping_thread.start()

        self.ping_timer = QTimer(self)
        self.ping_timer.setInterval(5000)  # toutes les 5s, un pair injoignable a un timeout de ~1s
        self.ping_timer.timeout.connect(self._request_ping_tick)
        self.ping_timer.start()

    def _setup_peer_refresh_debounce(self) -> None:
        """
        Regroupe les rafraîchissements déclenchés par la liste de pairs (DNS interne,
        favoris Steam), pour la même raison que le debounce côté tunnel
        (network_client._apply_peers) : le serveur rediffuse la liste plusieurs fois
        d'affilée quand un pair rejoint (accept() de la WebSocket, puis chaque
        endpoint_update), et chaque écriture du fichier hosts coûte une invite pkexec sur
        Linux. Ces rafraîchissements passent par des signaux GUI distincts, ils
        échappaient donc complètement au debounce déjà en place dans network_client. Même
        délai, pour un comportement cohérent entre tous.
        """
        self._peer_refresh_timer = QTimer(self)
        self._peer_refresh_timer.setSingleShot(True)
        self._peer_refresh_timer.setInterval(int(PEER_APPLY_DEBOUNCE_SECONDS * 1000))
        self._peer_refresh_timer.timeout.connect(self._emit_pending_peer_refresh)

    def _emit_pending_peer_refresh(self) -> None:
        """Envoie au worker la dernière liste de pairs connue (le timer a été relancé à
        chaque diffusion intermédiaire, seule la plus récente arrive ici)."""
        if self.dns_checkbox.isChecked():
            self.request_dns_update.emit(self._current_name_to_ip())
        if self.steam_checkbox.isChecked():
            self.request_steam_update.emit(*self._current_steam_peers())

    def _request_ping_tick(self) -> None:
        virtual_ips = [p["virtual_ip"] for p in self._current_peers]
        if virtual_ips:
            self.request_ping.emit(virtual_ips)
        # Seuls les services dont le TYPE définit une sonde sont interrogés. Avant le
        # typage, tout service annoncé recevait une requête A2S : une interface web y
        # coûtait 1,5 s de délai à chaque cycle pour finir affichée « sans réponse ».
        services = [
            (p["virtual_ip"], s.port, s.type_service)
            for p in self._current_peers
            for s in service_types.services_du_pair(p)
            if s.comportement().sonde != service_types.SONDE_AUCUNE
        ]
        if services:
            self.request_service_probe.emit(services)

    def _setup_worker_thread(self) -> None:
        self.worker_thread = QThread(self)
        self.worker = NetworkWorker(self.client)
        self.worker.moveToThread(self.worker_thread)

        self.request_join.connect(self.worker.do_create_and_join)
        self.request_isolation.connect(self.worker.do_set_isolation)
        self.request_dns.connect(self.worker.do_set_dns)
        self.request_dns_update.connect(self.worker.do_update_dns_entries)
        self.request_steam.connect(self.worker.do_set_steam_favorites)
        self.request_steam_update.connect(self.worker.do_update_steam_favorites)
        self.request_relay_fallback.connect(self.worker.do_request_relay_fallback)
        self.request_announce_services.connect(self.worker.do_announce_services)
        self.request_clear_service.connect(self.worker.do_clear_service)
        self.request_rotate_password.connect(self.worker.do_rotate_password)
        self.worker.password_rotated.connect(self._on_password_rotated)
        self.request_kick_peer.connect(self.worker.do_kick_peer)
        self.worker.peer_kicked.connect(self._on_peer_kicked)
        self.request_delete_network.connect(self.worker.do_delete_network)
        self.worker.network_deleted.connect(self._on_network_deleted)
        self.request_shutdown.connect(self.worker.do_shutdown_cleanup)
        self.request_tunnel_repair.connect(self.worker.do_repair_tunnel)
        self.worker.tunnel_repaired.connect(self._on_tunnel_repaired)
        self.worker.lan_address_requise.connect(self._on_lan_address_requise)
        self.worker.shutdown_complete.connect(self._on_shutdown_complete)
        self.worker.joined.connect(self._on_joined)
        self.worker.peers_updated.connect(self._on_peers_updated)
        self.worker.error.connect(self._on_error)
        self.worker.connection_state.connect(self._on_connection_state)
        self.worker.isolation_changed.connect(self._on_isolation_changed)
        self.worker.dns_changed.connect(self._on_dns_changed)
        self.worker.steam_changed.connect(self._on_steam_changed)

        self.worker_thread.start()

    def _setup_hosting_worker(self) -> None:
        """
        Crée le thread d'hébergement à la PREMIÈRE ouverture du panneau, pas au
        démarrage.

        Deux raisons, et la seconde a été trouvée en lançant la suite : la
        plupart des utilisateurs n'hébergent pas, donc un thread permanent pour
        rien ; et surtout, un troisième QThread créé systématiquement survivait à
        la `MainWindow` dans tous les tests GUI existants (qui n'arrêtent que
        `worker_thread` et `ping_thread`), ce qui faisait segfauler PySide6 au
        ramassage de la fenêtre — exactement le mécanisme du point 27, et le
        crash tombait dans des tests sans aucun rapport.
        """
        if self.hosting_thread is not None:
            return
        self.hosting_thread = QThread(self)
        self.hosting_worker = HostingWorker()
        self.hosting_worker.moveToThread(self.hosting_thread)

        self.request_hosting_status.connect(self.hosting_worker.do_refresh_status)
        self.request_hosting_start.connect(self.hosting_worker.do_start)
        self.request_hosting_stop.connect(self.hosting_worker.do_stop)
        self.request_hosting_autostart.connect(self.hosting_worker.do_set_autostart)
        self.request_hosting_logs.connect(self.hosting_worker.do_fetch_logs)
        self.request_hosting_diagnose.connect(self.hosting_worker.do_diagnose)
        self.request_duckdns_save.connect(self.hosting_worker.do_duckdns_save)

        self.hosting_worker.status_ready.connect(self._on_hosting_status)
        self.hosting_worker.logs_ready.connect(self._on_hosting_logs)
        self.hosting_worker.diagnostics_ready.connect(self._on_hosting_diagnostics)
        self.hosting_worker.duckdns_result.connect(self._on_duckdns_result)
        self.hosting_worker.error.connect(self._on_error)

        self.hosting_thread.start()

    def _on_host_server_clicked(self) -> None:
        """
        Ouvre le panneau d'hébergement, en le réutilisant s'il existe déjà.

        La fenêtre est conservée sur `self` plutôt que recréée : une QDialog
        locale serait ramassée pendant qu'un signal du worker est encore en vol,
        ce qui fait segfauler PySide6 (même famille de problème que le cycle de
        vie des fenêtres au point 27).
        """
        self._setup_hosting_worker()
        if self._hosting_dialog is None:
            dialog = HostingDialog(self)
            dialog.start_requested.connect(self.request_hosting_start.emit)
            dialog.stop_requested.connect(self.request_hosting_stop.emit)
            dialog.autostart_requested.connect(self.request_hosting_autostart.emit)
            dialog.logs_requested.connect(self.request_hosting_logs.emit)
            dialog.diagnose_requested.connect(self.request_hosting_diagnose.emit)
            dialog.duckdns_save_requested.connect(self.request_duckdns_save.emit)
            domaines, token = duckdns_settings.charger(config.DUCKDNS_SETTINGS_PATH)
            dialog.charger_duckdns(domaines, token)
            self._hosting_dialog = dialog
        self._hosting_dialog.show()
        self._hosting_dialog.raise_()
        self.request_hosting_status.emit()
        # Le diagnostic existait déjà mais n'était sur le chemin de personne : il
        # fallait savoir qu'il fallait cliquer. C'est pourtant lui qui dit ce qui
        # manque encore (redirection de port, nom de domaine) à quelqu'un qui
        # vient d'allumer son serveur et le croit joignable.
        #
        # Armé ici, mais déclenché par `_on_hosting_status` seulement quand le
        # serveur TOURNE : diagnostiquer la joignabilité d'un serveur éteint ne
        # produit que des échecs dus à l'extinction, ce qui enverrait chercher une
        # redirection de port alors qu'il suffit d'appuyer sur Démarrer.
        self._diagnostic_auto_a_lancer = True

    def _on_duckdns_result(self, succes: bool, message: str) -> None:
        if self._hosting_dialog is not None:
            self._hosting_dialog.afficher_resultat_duckdns(succes, message)

    def afficher_accueil_si_premier_lancement(self) -> None:
        """
        Montre l'écran d'accueil tant qu'aucun réseau n'a jamais été rejoint.

        Le critère est « aucun réseau récent » et non un drapeau dédié : un
        drapeau mentirait après une réinstallation ou un effacement de la
        configuration, précisément les moments où l'utilisateur a de nouveau
        besoin d'être guidé.
        """
        if recent_networks.load_recent_networks(config.RECENT_NETWORKS_PATH):
            return
        self.afficher_accueil()

    def afficher_accueil(self) -> None:
        dialogue = WelcomeDialog(self)
        if not dialogue.exec():
            return
        if dialogue.choix == WelcomeDialog.REJOINDRE and dialogue.invitation is not None:
            self._appliquer_invitation(dialogue.invitation)
        elif dialogue.choix == WelcomeDialog.HEBERGER:
            self._demarrer_hebergement_guide()
        elif dialogue.choix == WelcomeDialog.SERVEUR_GERE:
            self._utiliser_serveur_gere()

    def _utiliser_serveur_gere(self) -> None:
        """
        Pré-remplit l'adresse du serveur géré, et **s'arrête là**.

        On ne connecte pas dans la foulée, contrairement au chemin « héberger » : il
        manque encore le nom du réseau et son mot de passe, que l'utilisateur seul
        connaît. Enchaîner produirait un échec d'authentification là où il n'y a
        qu'un formulaire à finir.

        Le pseudo est posé s'il est vide, pour la raison du point 52 : trois « peer »
        dans un salon ne se distinguent pas, et personne ne remplit ce champ avant
        d'avoir vu le problème. Une valeur déjà saisie n'est jamais écrasée — cet
        écran est aussi accessible par « Revoir l'accueil », donc en pleine session.
        """
        self.server_address_input.setText(config.MANAGED_SERVER_URL)
        if not self.display_name_input.text().strip():
            self.display_name_input.setText(socket.gethostname() or "")
        self.network_name_input.setFocus()

    def _demarrer_hebergement_guide(self) -> None:
        """
        Chemin « je crée mon réseau », de bout en bout.

        Avant le 2026-07-31 ce chemin démarrait le serveur **et s'arrêtait là** :
        aucun réseau n'existait encore, les champs nom et mot de passe restaient
        vides, et il fallait deviner qu'il fallait les remplir ailleurs puis se
        connecter pour obtenir le code d'invitation promis par l'écran d'accueil.
        Rien n'y conduisait. La promesse n'était donc pas tenue par le chemin qui
        l'affichait.
        """
        # Le serveur local devient la cible : c'est ce que veut dire « héberger
        # sur cette machine », et le laisser deviner l'adresse serait exactement
        # le genre de détail qui fait abandonner.
        self.server_address_input.setText(
            server_settings.format_server_address(
                "127.0.0.1", config.DEFAULT_SERVER_PORT, False
            )
        )
        # Ne JAMAIS écraser un réseau déjà saisi : ce chemin est aussi accessible
        # par le bouton « Revoir l'accueil » depuis une session en cours.
        if not self.network_name_input.text().strip():
            self.network_name_input.setText(
                network_defaults.nom_reseau_par_defaut(socket.gethostname() or "")
            )
        if not self.password_input.text():
            self.password_input.setText(network_defaults.generer_mot_de_passe())
        if not self.display_name_input.text().strip():
            self.display_name_input.setText(socket.gethostname() or "")

        # La connexion n'est PAS lancée ici : le serveur vient à peine d'être
        # démarré et son démarrage est asynchrone. `_on_hosting_status` prend le
        # relais dès qu'il tourne réellement — connecter tout de suite échouerait
        # sur un refus de connexion, avec un message qui accuserait le réseau.
        self._hebergement_guide_en_attente = True
        self._hebergement_guide_a_presenter = True
        self._on_host_server_clicked()

    def _appliquer_invitation(self, invit: invitation.Invitation) -> None:
        """Remplit le formulaire depuis un code, puis connecte — l'utilisateur
        n'a rien à retaper, c'est toute la promesse du code."""
        self.server_address_input.setText(invit.server_address)
        self.network_name_input.setText(invit.network_name)
        self.password_input.setText(invit.password)
        if invit.server_lan_address:
            analyse = server_settings.parse_server_address(invit.server_address)
            if analyse is not None:
                # L'hôte connaît l'adresse locale de son serveur ; la transmettre
                # évite au visiteur sous le même toit de buter sur le hairpin NAT
                # (point 51) sans rien comprendre.
                server_settings.save_lan_address(
                    config.SERVER_ADDRESS_PATH, analyse[0], invit.server_lan_address
                )
        if not self.display_name_input.text().strip():
            # Le nom de la machine est un défaut bien meilleur que « peer » (le
            # repli existant) : dans un lobby, trois « peer » ne se distinguent
            # pas, alors que personne ne songe à remplir ce champ avant de voir
            # le problème.
            self.display_name_input.setText(socket.gethostname() or "")
        self._on_connect_clicked()

    def _construire_code_invitation(self, *, silencieux: bool = False) -> tuple[str, str]:
        """
        Fabrique le code à envoyer aux amis : `(code, avertissement)`, ou
        `("", "")` si c'est impossible.

        L'adresse locale du serveur n'est jointe que s'il tourne bien sur cette
        machine : la transmettre pour un serveur distant enverrait les invités
        vers une adresse qui n'existe pas chez eux — le piège que le point 51
        vient justement de supprimer.

        `silencieux` sert au parcours guidé, qui enchaîne déjà sur sa propre
        boîte de dialogue : deux modales successives pour un seul geste donneraient
        l'impression que quelque chose s'est mal passé.
        """
        texte_adresse = self.server_address_input.text().strip()
        analyse = server_settings.parse_server_address(texte_adresse)
        if analyse is None:
            if not silencieux:
                self.status_bar.showMessage("Adresse de serveur invalide.", 4000)
            return "", ""
        hote, port, tls = analyse

        adresse_lan = ""
        avertissement = ""
        if hote in ("127.0.0.1", "localhost", "::1"):
            # Piège trouvé en déroulant le parcours complet : quand on héberge
            # sur cette machine, l'adresse du serveur est la BOUCLE LOCALE. La
            # transmettre telle quelle enverrait chaque invité se connecter à sa
            # PROPRE machine — un échec parfaitement incompréhensible pour lui.
            adresse_locale = network_client._detect_local_ip() or ""
            if not adresse_locale:
                if not silencieux:
                    QMessageBox.warning(
                        self, "Adresse introuvable",
                        "Impossible de déterminer l'adresse de cette machine sur votre "
                        "réseau. Renseignez l'adresse par laquelle vos amis joignent "
                        "votre serveur avant de créer un code.",
                    )
                return "", ""

            # Le nom public l'emporte quand il existe : l'adresse LAN seule ne vaut
            # que sous le même toit, et derrière un reverse proxy elle porterait en
            # plus un port qui n'écoute nulle part (constaté le 2026-07-31).
            domaine, _token = duckdns_settings.charger(config.DUCKDNS_SETTINGS_PATH)
            domaine_public = f"{domaine}.duckdns.org" if domaine else ""
            texte_adresse, adresse_lan = invitation.choisir_adresse_a_partager(
                server_settings.format_server_address(hote, port, tls),
                server_settings.format_server_address(adresse_locale, port, tls),
                adresse_locale,
                domaine_public,
            )
            if domaine_public:
                avertissement = (
                    f"\n\nCe code utilise votre nom public {domaine_public} : il vaut "
                    "donc aussi pour quelqu'un qui n'est pas chez vous. Vos amis sous "
                    f"le même toit passeront automatiquement par {adresse_locale}."
                )
            else:
                avertissement = (
                    "\n\nCe code vaut pour votre réseau local, puisque votre serveur est "
                    f"annoncé sur {adresse_locale}. Pour inviter quelqu'un de "
                    "l'extérieur, renseignez un nom de domaine dans le panneau "
                    "« Héberger un serveur » — le code l'utilisera alors."
                )

        code = invitation.encoder(invitation.Invitation(
            server_address=texte_adresse,
            network_name=self.network_name_input.text().strip(),
            password=self.password_input.text(),
            server_lan_address=adresse_lan,
        ))
        return code, avertissement

    def _on_copy_invitation_clicked(self) -> None:
        """Met dans le presse-papiers le code à envoyer aux amis."""
        code, avertissement = self._construire_code_invitation()
        if not code:
            return
        QApplication.clipboard().setText(code)
        QMessageBox.information(
            self, "Code d'invitation copié",
            "Le code est dans votre presse-papiers. Envoyez-le à vos amis : ils "
            "n'auront qu'à le coller au premier lancement.\n\n"
            "⚠️ Ce code contient le mot de passe du réseau. Partagez-le avec le "
            "même soin que le mot de passe lui-même — il ne peut ni expirer ni "
            "être révoqué." + avertissement,
        )

    def _on_rotate_password_clicked(self) -> None:
        """
        Change le mot de passe du réseau, donc révoque les codes déjà distribués.

        Le nouveau mot de passe est **généré**, pas demandé : c'est une révocation
        d'urgence, et faire inventer un mot de passe à ce moment-là produirait
        soit une attente, soit une valeur faible choisie dans la précipitation.
        L'utilisateur peut toujours le remplacer ensuite dans le champ.
        """
        nom = self.network_name_input.text().strip()
        ancien = self.password_input.text()
        if not nom or not ancien:
            QMessageBox.warning(
                self, "Réseau inconnu",
                "Renseignez le nom du réseau et son mot de passe actuel avant de "
                "le changer.",
            )
            return

        confirmation = QMessageBox.question(
            self, "Changer le mot de passe ?",
            f"Un nouveau mot de passe va être généré pour <b>{nom}</b>.<br><br>"
            "• Les codes d'invitation déjà envoyés <b>cesseront de fonctionner</b>.<br>"
            "• Les machines <b>déjà membres restent connectées</b> : le mot de passe "
            "ne garde que l'entrée, pas les tunnels en cours.<br>"
            "• Vos amis devront recevoir un nouveau code pour rejoindre à l'avenir.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        if confirmation != QMessageBox.StandardButton.Yes:
            return

        nouveau = network_defaults.generer_mot_de_passe()
        self.rotate_password_button.setEnabled(False)
        self.request_rotate_password.emit(nom, ancien, nouveau)

    def _on_password_rotated(self, reussi: bool, nouveau: str, message: str) -> None:
        self.rotate_password_button.setEnabled(True)
        if not reussi:
            QMessageBox.warning(self, "Changement impossible", message)
            return

        # Mémoriser AVANT d'annoncer : si l'application était fermée entre les
        # deux, l'utilisateur se retrouverait verrouillé hors de son propre
        # réseau avec un mot de passe qu'il n'a jamais vu.
        self.password_input.setText(nouveau)
        recent_networks.save_recent_network(
            config.RECENT_NETWORKS_PATH, self.network_name_input.text().strip(), nouveau
        )
        self._reload_recent_networks_combo()

        code, _ = self._construire_code_invitation(silencieux=True)
        boite = QMessageBox(self)
        boite.setWindowTitle("Mot de passe changé")
        boite.setTextFormat(Qt.TextFormat.RichText)
        texte = (
            f"Nouveau mot de passe : <b>{nouveau}</b><br><br>"
            "Les anciens codes d'invitation ne fonctionnent plus."
        )
        bouton_copier = None
        if code:
            texte += "<br><br>Voici le nouveau code à distribuer."
            boite.setDetailedText(code)
        boite.setText(texte)
        if code:
            bouton_copier = boite.addButton("Copier le nouveau code", QMessageBox.ButtonRole.AcceptRole)
        boite.addButton("Fermer", QMessageBox.ButtonRole.RejectRole)
        boite.exec()
        if bouton_copier is not None and boite.clickedButton() is bouton_copier:
            QApplication.clipboard().setText(code)
            self.status_bar.showMessage("Nouveau code d'invitation copié", 5000)

    def _on_lan_address_requise(self, hote: str, explication: str) -> None:
        """
        Demande l'adresse locale du serveur, une seule fois, puis réessaie.

        Remplace le contournement par fichier hosts, qui est global, permanent et
        manuel — donc qui se retourne contre l'utilisateur dès qu'il change de
        réseau. Ici la substitution ne vit que dans ce processus et repart à zéro
        à chaque tentative de connexion : la machine peut passer du LAN au
        cellulaire et revenir sans qu'on y touche.
        """
        self.connect_button.setEnabled(True)
        prefixe = ""
        adresse_locale = network_client._detect_local_ip()
        if adresse_locale and adresse_locale.count(".") == 3:
            prefixe = adresse_locale.rsplit(".", 1)[0] + "."

        saisie, valide = QInputDialog.getText(
            self,
            "Serveur sur votre réseau local",
            explication
            + "\n\nEntrez l'adresse locale du serveur pour que Meshamoto s'en "
              "souvienne et l'utilise automatiquement quand vous êtes sur ce réseau :",
            QLineEdit.EchoMode.Normal,
            prefixe,
        )
        if not valide:
            self.status_bar.showMessage(
                "Connexion abandonnée : le serveur est sur votre réseau local et son "
                "adresse locale n'est pas connue.", 0,
            )
            return
        adresse = saisie.strip()
        if not lan_override.adresse_valide(adresse):
            QMessageBox.warning(
                self, "Adresse invalide",
                "Entrez une adresse IPv4, par exemple 192.168.0.10.\n\nUn nom d'hôte "
                "ne conviendrait pas : c'est justement la résolution de ce nom qu'on "
                "contourne.",
            )
            return
        server_settings.save_lan_address(config.SERVER_ADDRESS_PATH, hote, adresse)
        self.status_bar.showMessage(f"Nouvel essai via {adresse}...")
        self.connect_button.setEnabled(False)
        self.request_join.emit(self._pending_name, self._pending_password,
                               self.display_name_input.text().strip())

    def _on_hosting_status(self, status) -> None:
        if self._hosting_dialog is not None:
            self._hosting_dialog.appliquer_etat(status)

        # Suite du parcours guidé : le réseau ne peut être créé qu'une fois le
        # serveur DEBOUT, et son démarrage est asynchrone. Le drapeau est baissé
        # avant de connecter, jamais après : un second passage de statut pendant
        # la connexion relancerait sinon une deuxième tentative.
        if self._hebergement_guide_en_attente and getattr(status, "running", False):
            self._hebergement_guide_en_attente = False
            self._on_connect_clicked()

        if (
            self._diagnostic_auto_a_lancer
            and getattr(status, "running", False)
            and self._hosting_dialog is not None
        ):
            self._diagnostic_auto_a_lancer = False
            self.request_hosting_diagnose.emit(
                config.DEFAULT_SERVER_PORT,
                self._hosting_dialog.domain_input.text().strip(),
            )

    def _on_hosting_logs(self, texte: str) -> None:
        if self._hosting_dialog is not None:
            self._hosting_dialog.afficher_journal(texte)

    def _on_hosting_diagnostics(self, constats) -> None:
        if self._hosting_dialog is not None:
            self._hosting_dialog.afficher_diagnostics(constats)

    def _build_licence_input(self) -> QLineEdit:
        """
        Champ « Clé de licence », **facultatif et vide par défaut**.

        Vide est le cas normal : quelqu'un qui héberge son propre serveur n'a aucune
        clé, et rien ne lui en réclame jamais — c'est l'invariant posé côté serveur
        dans `habilitation.py`. Le champ n'existe que pour ceux qui se servent du
        serveur géré.

        Pas de masquage à la saisie, contrairement au mot de passe : une clé de
        licence se recopie depuis un courriel ou une page web, et la masquer
        empêcherait de vérifier qu'on ne s'est pas trompé — or c'est précisément la
        faute qu'on cherche à éviter, au point que l'alphabet exclut les caractères
        confondables (voir server/licence_common.py).
        """
        champ = QLineEdit()
        champ.setPlaceholderText("facultatif — MESH-XXXX-XXXX-XXXX")
        champ.setText(licence_settings.charger(config.LICENCE_SETTINGS_PATH).cle)
        champ.setToolTip(
            "Nécessaire uniquement pour utiliser le serveur de Meshamoto.\n\n"
            "Laissez vide si vous hébergez votre propre serveur : Meshamoto est "
            "alors gratuit et complet, sans aucune clé."
        )
        champ.editingFinished.connect(self._on_licence_changed)
        return champ

    def _on_licence_changed(self) -> None:
        """
        Mémorise la clé saisie. **Change la clé jette le jeton de l'ancienne** — voir
        `licence_settings.enregistrer_cle` : le conserver masquerait une saisie fautive
        jusqu'à l'expiration du cache, des jours plus tard.
        """
        saisie = self.licence_input.text().strip()
        if saisie == licence_settings.charger(config.LICENCE_SETTINGS_PATH).cle:
            return  # `editingFinished` part aussi à la perte de focus, sans changement
        if saisie:
            licence_settings.enregistrer_cle(config.LICENCE_SETTINGS_PATH, saisie)
        else:
            licence_settings.oublier(config.LICENCE_SETTINGS_PATH)
        self._rafraichir_etat_licence()

    def _rafraichir_etat_licence(self) -> None:
        """
        Dit à l'écran ce que vaut la clé saisie.

        Écrit le 2026-08-07 sur signalement : quelqu'un avait collé une clé
        parfaitement valide, activée avec succès (`200 OK` côté serveur, jeton en
        cache), et **rien ne le lui disait** — la seule confirmation était une ligne
        « Habilitation renouvelée » dans un fichier de journal. C'est exactement ce
        que la ligne du projet interdit.

        **On affiche l'état du JETON, jamais un verdict sur le format de la clé.**
        Valider le format ici supposerait de dupliquer `normaliser_cle`, qui vit côté
        serveur — deux implémentations qui divergeraient en silence (point 38) — et
        surtout ça dirait « clé valide » d'une clé bien formée mais révoquée. Le
        jeton, lui, EST le verdict du serveur : il n'existe que si l'activation a
        réussi.
        """
        reglages = licence_settings.charger(config.LICENCE_SETTINGS_PATH)
        if not reglages.cle:
            self.licence_status.setText(
                "Aucune clé : vous hébergez votre propre serveur, et c'est gratuit."
            )
            return
        jeton = reglages.jeton
        echeance = licence_client.echeance_du_jeton(jeton) if jeton else None
        if echeance is None:
            self.licence_status.setText(
                "Clé enregistrée — elle sera vérifiée à la prochaine connexion."
            )
            return
        palier = licence_client.palier_du_jeton(jeton)
        jour = _date_en_francais(datetime.datetime.fromtimestamp(echeance))
        # Formulation COURTE : le premier jet (« Clé acceptée par le serveur — …,
        # habilitation valable jusqu'au … ») faisait deux lignes dans le formulaire et
        # décalait tout ce qui suit, pour dire la même chose. Signalé le 2026-08-07.
        self.licence_status.setText(
            f"Clé validée — {_licence_en_francais(palier)} jusqu'au {jour}."
        )

    def _show_about(self) -> None:
        """
        Version, licence et composants tiers.

        Le texte vient de `licences_tierces.avis_complet`, qui garantit l'avis
        essentiel même si le fichier de notices manque — une obligation de licence ne
        doit pas dépendre de la réussite d'un empaquetage.
        """
        # PAS un QMessageBox : son texte informatif NE DÉFILE PAS. L'avis fait plus
        # de 3 000 caractères, donc la boîte débordait de l'écran et la fin -- dont
        # la façon d'obtenir les sources d'un composant copyleft -- était
        # inatteignable. Une obligation de licence qu'on ne peut pas lire n'est pas
        # remplie. Signalé le 2026-08-07.
        boite = QDialog(self)
        boite.setWindowTitle(f"À propos de Meshamoto {version.version_affichee()}")
        boite.resize(720, 560)
        disposition = QVBoxLayout(boite)

        vue = QTextEdit()
        vue.setReadOnly(True)
        vue.setMarkdown(licences_tierces.avis_complet())
        disposition.addWidget(vue)

        fermer = QPushButton("Fermer")
        fermer.clicked.connect(boite.accept)
        disposition.addWidget(fermer)
        boite.exec()

    def _build_restricted_network_checkbox(self) -> QCheckBox:
        """
        Case « Mode réseau restreint », mémorisée, **décochée par défaut**.

        Formulée par le SYMPTÔME et non par la technique : celui qui la cherche ne sait
        pas ce qu'est l'UDP, il sait que le Wi-Fi de son hôtel ne marche pas. Le
        « comment » est dans l'infobulle, pour qui veut savoir.

        L'état est poussé dans le client réseau à chaque changement ET à la connexion
        (voir `_apply_restricted_network_mode`) : la case peut être cochée avant comme
        après avoir rejoint un réseau, et les deux chemins doivent aboutir au même
        état — sinon le réglage ne prendrait effet qu'au lancement suivant, ce qui est
        exactement le moment où l'utilisateur cherche à comprendre.
        """
        case = QCheckBox("Mode réseau restreint")
        case.setChecked(
            ws_transport_settings.charger_mode_restreint(config.WS_TRANSPORT_SETTINGS_PATH)
        )
        case.setToolTip(
            "À cocher si aucun pair n'est joignable alors que la connexion au serveur "
            "fonctionne — typiquement un Wi-Fi d'hôtel, un réseau d'entreprise ou un "
            "portail captif qui bloque l'UDP.\n\n"
            "Le trafic passe alors par le serveur en TCP sur le port 443, comme une "
            "page web. C'est plus lent qu'une connexion directe : à n'utiliser que si "
            "rien d'autre ne marche.\n\n"
            "Sans effet tant qu'un chemin direct fonctionne, et propre à cette machine "
            "— les autres pairs n'ont rien à changer."
        )
        case.toggled.connect(self._on_restricted_network_toggled)
        return case

    def _on_restricted_network_toggled(self, actif: bool) -> None:
        ws_transport_settings.enregistrer_mode_restreint(
            config.WS_TRANSPORT_SETTINGS_PATH, actif
        )
        self._apply_restricted_network_mode()

    def _apply_restricted_network_mode(self) -> None:
        """
        Reporte l'état de la case sur le client réseau.

        Appelé à la construction ET à chaque basculement. `setChecked` n'émet rien
        quand il ne change pas la valeur, donc s'en remettre au seul signal laisserait
        un réglage mémorisé sans effet jusqu'au premier clic — c'est-à-dire jamais,
        pour quelqu'un qui l'a coché la fois précédente.
        """
        client = getattr(self, "client", None)
        if client is not None:
            client.definir_mode_reseau_restreint(self.restricted_network_checkbox.isChecked())

    def _build_listen_port_input(self) -> QLineEdit:
        """
        Champ du port UDP d'écoute WireGuard, appliqué immédiatement à la saisie.

        Le réglage mémorisé est appliqué dès la construction : le port doit être en
        place AVANT la découverte STUN et le montage du tunnel, qui le lisent tous deux
        depuis `config`. Une variable d'environnement, si elle est définie, l'emporte —
        le champ passe alors en lecture seule pour ne pas laisser croire à un réglage
        modifiable qui serait ignoré.
        """
        field = QLineEdit()
        if not config.WIREGUARD_PORT_FORCED_BY_ENV:
            config.set_wireguard_listen_port(
                wireguard_port_settings.load_listen_port(
                    config.WIREGUARD_PORT_SETTINGS_PATH, config.DEFAULT_WIREGUARD_LISTEN_PORT
                )
            )
        field.setText(str(config.WIREGUARD_LISTEN_PORT))
        field.setToolTip(
            "Port UDP d'écoute du tunnel. À changer uniquement si un autre tunnel "
            "WireGuard occupe déjà ce port sur cette machine. Propre à cette machine : "
            "les autres pairs n'ont pas besoin du même."
        )
        if config.WIREGUARD_PORT_FORCED_BY_ENV:
            field.setReadOnly(True)
            field.setToolTip(
                f"Imposé par la variable d'environnement {config.WIREGUARD_PORT_ENV_VAR}."
            )
        else:
            field.editingFinished.connect(self._on_listen_port_changed)
        return field

    def _on_listen_port_changed(self) -> None:
        """Valide, applique et mémorise le port saisi ; restaure l'ancien si invalide."""
        text = self.listen_port_input.text().strip()
        try:
            port = int(text)
        except ValueError:
            port = -1
        if not wireguard_port_settings.is_valid_port(port):
            QMessageBox.warning(
                self,
                "Port invalide",
                f"Le port d'écoute doit être un nombre entre "
                f"{wireguard_port_settings.MIN_PORT} et {wireguard_port_settings.MAX_PORT}.",
            )
            self.listen_port_input.setText(str(config.WIREGUARD_LISTEN_PORT))
            return
        if port == config.WIREGUARD_LISTEN_PORT:
            return
        config.set_wireguard_listen_port(port)
        wireguard_port_settings.save_listen_port(config.WIREGUARD_PORT_SETTINGS_PATH, port)
        logger.info("Port d'écoute WireGuard réglé sur %d", port)
        if self.client.session is not None:
            self.status_bar.showMessage(
                "Port d'écoute modifié : reconnectez-vous pour l'appliquer au tunnel."
            )

    def _build_ui(self) -> None:
        central = QWidget()
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)

        # --- Thème clair/sombre ---
        # Disponible immédiatement, sans attendre une connexion à un réseau
        # (contrairement à isolation/DNS/relais LAN ci-dessous).
        self.dark_theme_checkbox = QCheckBox("Thème sombre")
        self.dark_theme_checkbox.setToolTip(
            "Bascule manuelle : PySide6 ne suit pas toujours le thème système selon "
            "l'environnement de lancement (venv, AppImage...). Mémorisé d'un lancement à l'autre."
        )
        saved_theme = theme_settings.load_theme(config.THEME_SETTINGS_PATH)
        app_instance = QApplication.instance()
        if app_instance is not None:
            theme_manager.apply_theme(app_instance, saved_theme)
        self.dark_theme_checkbox.blockSignals(True)
        self.dark_theme_checkbox.setChecked(saved_theme == "dark")
        self.dark_theme_checkbox.blockSignals(False)
        self.dark_theme_checkbox.toggled.connect(self._on_theme_toggled)

        # --- Hébergement ---
        # Volontairement en haut et toujours accessible, comme le thème : héberger
        # un serveur est ce qu'on fait AVANT de pouvoir rejoindre un réseau, donc
        # le bouton ne peut pas dépendre d'une connexion établie.
        entete = QHBoxLayout()
        entete.addWidget(self.dark_theme_checkbox, stretch=1)
        # « À propos » n'est PAS un ornement : la LGPLv3 exige un avis visible sur
        # l'utilisation de Qt et sur le droit de le remplacer. Un fichier dans le
        # dossier d'installation ne suffit pas à « prominent notice ».
        self.about_button = QPushButton("À propos")
        self.about_button.setToolTip("Version, licence, et composants tiers utilisés.")
        self.about_button.clicked.connect(self._show_about)
        entete.addWidget(self.about_button)
        # Le guide est en ligne et non embarqué : il décrit aussi des réglages de JEUX
        # et de pare-feu qui évoluent sans rapport avec nos versions, et une copie figée
        # dans le binaire vieillirait à chaque publication sans que personne le voie.
        # Placé ici parce que c'est l'endroit où l'on cherche de l'aide quand ça coince.
        self.guide_button = QPushButton("Guide ↗")
        self.guide_button.setToolTip(
            f"Ouvre {config.GUIDE_URL} dans votre navigateur : démarrer, héberger un "
            "service de jeu, héberger votre propre serveur."
        )
        self.guide_button.clicked.connect(
            lambda: QDesktopServices.openUrl(QUrl(config.GUIDE_URL))
        )
        entete.addWidget(self.guide_button)
        self.host_server_button = QPushButton("Héberger un serveur…")
        self.host_server_button.setToolTip(
            "Faire tourner le serveur de signalisation sur cette machine, sans terminal. "
            "Votre réseau ne dépend alors d'aucun service tiers."
        )
        self.host_server_button.clicked.connect(self._on_host_server_clicked)
        entete.addWidget(self.host_server_button)
        layout.addLayout(entete)

        # --- Formulaire de connexion ---
        form_box = QGroupBox("Réseau")
        form_box_layout = QVBoxLayout()

        # Réseaux récents : sélectionner un réseau déjà rejoint pré-remplit nom + mot
        # de passe, pour éviter de les ressaisir à chaque connexion.
        recent_row = QHBoxLayout()
        self.recent_networks_combo = QComboBox()
        self.recent_networks_combo.currentIndexChanged.connect(self._on_recent_network_selected)
        recent_row.addWidget(self.recent_networks_combo, stretch=1)
        self.forget_network_button = QPushButton("Oublier")
        self.forget_network_button.clicked.connect(self._on_forget_network_clicked)
        recent_row.addWidget(self.forget_network_button)
        form_box_layout.addLayout(recent_row)

        form = QFormLayout()
        self.server_address_input = QLineEdit()
        self.server_address_input.setPlaceholderText("ex: 192.168.1.50:8765")
        saved_server = server_settings.load_server_address(config.SERVER_ADDRESS_PATH)
        default_host, default_port = saved_server or (config.DEFAULT_SERVER_HOST, config.DEFAULT_SERVER_PORT)
        default_tls = server_settings.load_server_tls(config.SERVER_ADDRESS_PATH) or config.DEFAULT_SERVER_TLS
        self.server_address_input.setText(
            server_settings.format_server_address(default_host, default_port, default_tls)
        )
        self.server_address_input.setToolTip(
            "Préfixez par https:// pour chiffrer la liaison au serveur. Sans TLS, le mot "
            "de passe du réseau circule en clair : à réserver à un LAN de confiance."
        )
        self.network_name_input = QLineEdit()
        self.network_name_input.setPlaceholderText("ex: mon-reseau-jeux")
        self.password_input = QLineEdit()
        self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
        self.display_name_input = QLineEdit()
        self.display_name_input.setPlaceholderText(
            "Nom affiché aux autres pairs (vide : conserve le nom actuel)"
        )
        # UNIQUEMENT le pseudo mémorisé, jamais un défaut calculé — voir point 69bis.
        # Le premier jet retombait ici sur `socket.gethostname()`, ce qui a détruit les
        # pseudos du parc à la mise à jour : au premier lancement le fichier n'existe
        # pas encore, le champ se remplissait donc du nom de machine, que le client
        # envoyait comme un choix délibéré. Le `COALESCE` serveur ne protège que le
        # silence, et il n'y avait plus de silence.
        # Un champ vide signifie « je ne déclare rien » et laisse le serveur conserver
        # le nom qu'il connaît. Les parcours d'accueil et de code d'invitation, eux,
        # écrivent bien le nom de machine (point 52) : c'est légitime, ils s'adressent à
        # quelqu'un qui n'a encore aucun nom à préserver.
        self.display_name_input.setText(
            display_name_settings.load_display_name(config.DISPLAY_NAME_SETTINGS_PATH)
            or ""
        )
        self.listen_port_input = self._build_listen_port_input()
        self.licence_input = self._build_licence_input()
        self.restricted_network_checkbox = self._build_restricted_network_checkbox()
        self._apply_restricted_network_mode()

        form.addRow("Serveur :", self.server_address_input)
        form.addRow("Nom du réseau :", self.network_name_input)
        form.addRow("Mot de passe :", self.password_input)
        form.addRow("Pseudo :", self.display_name_input)
        form.addRow("Port WireGuard :", self.listen_port_input)
        form.addRow("Clé de licence :", self.licence_input)
        # Libellé d'état sous le champ : sans lui, une clé valide et une clé refusée
        # produisent exactement le même écran (rien).
        self.licence_status = QLabel("")
        # setWordWrap(False) DÉLIBÉRÉMENT. Mesuré le 2026-08-07 : le texte fait 348 px
        # dans un libellé large de 476 -- il tient -- et Qt lui allouait quand même
        # DEUX lignes, parce qu'avec le retour à la ligne activé QFormLayout négocie la
        # hauteur sur une largeur plus étroite que la largeur finale. Sans lui : une
        # ligne, et la fenêtre garde exactement la même largeur (618 px).
        self.licence_status.setWordWrap(False)
        form.addRow("", self.licence_status)
        form.addRow("", self.restricted_network_checkbox)
        form_box_layout.addLayout(form)
        form_box.setLayout(form_box_layout)
        layout.addWidget(form_box)

        self._rafraichir_etat_licence()
        self._reload_recent_networks_combo()

        # --- Bouton de connexion ---
        self.connect_button = QPushButton("Créer / Rejoindre")
        self.connect_button.clicked.connect(self._on_connect_clicked)
        layout.addWidget(self.connect_button)

        # --- Invitation ---
        # Le pendant du collage au premier lancement : sans moyen de PRODUIRE le
        # code, l'hôte devrait encore dicter trois choses à ses amis.
        invitation_row = QHBoxLayout()
        self.copy_invitation_button = QPushButton("Copier le code d'invitation")
        self.copy_invitation_button.setToolTip(
            "Une seule chaîne à envoyer à vos amis : elle contient l'adresse du "
            "serveur, le nom du réseau et le mot de passe. Ils n'ont qu'à la coller."
        )
        self.copy_invitation_button.clicked.connect(self._on_copy_invitation_clicked)
        invitation_row.addWidget(self.copy_invitation_button)
        # La contrepartie du bouton précédent : un code d'invitation ne peut ni
        # expirer ni être rappelé, donc le SEUL moyen de le neutraliser est de
        # changer ce qu'il transporte. Sans ce bouton, « ne partagez jamais ce
        # code » était la seule consigne possible — et un code est fait pour
        # circuler.
        self.rotate_password_button = QPushButton("Changer le mot de passe")
        self.rotate_password_button.setToolTip(
            "Génère un nouveau mot de passe : les codes d'invitation déjà envoyés "
            "cessent de fonctionner. Les machines déjà membres restent connectées."
        )
        self.rotate_password_button.clicked.connect(self._on_rotate_password_clicked)
        invitation_row.addWidget(self.rotate_password_button)
        self.welcome_button = QPushButton("Revoir l'accueil")
        self.welcome_button.setToolTip(
            "Rouvre l'écran de bienvenue : héberger son réseau, ou en rejoindre "
            "un avec un code."
        )
        self.welcome_button.clicked.connect(self.afficher_accueil)
        invitation_row.addWidget(self.welcome_button)
        # Séparé des boutons ci-dessus par un ressort : c'est la seule action
        # irréversible de l'interface, et elle voisinerait sinon des boutons qu'on
        # utilise souvent, donc qu'on clique sans lire.
        invitation_row.addStretch(1)
        self.delete_network_button = QPushButton("Supprimer le réseau")
        self.delete_network_button.setToolTip(
            "Supprime définitivement le réseau et tous ses membres du serveur. "
            "Irréversible."
        )
        self.delete_network_button.clicked.connect(self._on_delete_network_clicked)
        invitation_row.addWidget(self.delete_network_button)
        layout.addLayout(invitation_row)

        # --- IP virtuelle assignée ---
        self.virtual_ip_label = QLabel("IP virtuelle : (non connecté)")
        layout.addWidget(self.virtual_ip_label)

        # --- Mode isolation ---
        self.isolation_checkbox = QCheckBox("Mode isolation (bloquer les connexions entrantes hors réseau)")
        self.isolation_checkbox.setToolTip(
            "Une fois activé, seuls les autres pairs de CE réseau peuvent t'initier une "
            "connexion (partage de fichiers, jeu hébergé, etc.). Ta navigation internet et "
            "tes connexions sortantes ne sont jamais affectées. Modifie le pare-feu système."
        )
        self.isolation_checkbox.setEnabled(False)  # activé seulement une fois connecté à un réseau
        self.isolation_checkbox.toggled.connect(self._on_isolation_toggled)
        layout.addWidget(self.isolation_checkbox)

        # --- DNS interne (résoudre les pairs par nom) ---
        self.dns_checkbox = QCheckBox("DNS interne (résoudre les pairs par nom, ex: alice.meshamoto)")
        self.dns_checkbox.setToolTip(
            "Ajoute un bloc délimité dans le fichier hosts système pour chaque pair "
            "(ex: 25.0.0.5 alice.meshamoto) — plus besoin de retenir les IP virtuelles. "
            "Retiré automatiquement à la désactivation ou à la fermeture."
        )
        self.dns_checkbox.setEnabled(False)  # activé seulement une fois connecté à un réseau
        self.dns_checkbox.toggled.connect(self._on_dns_toggled)
        layout.addWidget(self.dns_checkbox)

        # --- Relais LAN broadcast (jeux rétro moteur Source) ---
        self.lan_relay_checkbox = QCheckBox(
            "Relais LAN pour jeux rétro (moteur Source — Half-Life, CS 1.6, TF2, L4D/2...)"
        )
        self.lan_relay_checkbox.setToolTip(
            "Relaie entre pairs la requête de découverte LAN broadcast de ces jeux (port "
            f"{config.SOURCE_ENGINE_LAN_PORT}), que WireGuard ne transporte pas nativement. "
            "Expérimental : format confirmé par capture réseau sur Left 4 Dead 2 uniquement."
        )
        self.lan_relay_checkbox.setEnabled(False)  # activé seulement une fois connecté à un réseau
        self.lan_relay_checkbox.toggled.connect(self._on_lan_relay_toggled)
        layout.addWidget(self.lan_relay_checkbox)

        # --- Favoris Steam (ce que le relais LAN ci-dessus promettait sans y parvenir) ---
        self.steam_checkbox = QCheckBox("Ajouter les pairs aux favoris Steam")
        self.steam_checkbox.setToolTip(
            "Écrit chaque pair dans les favoris du navigateur de serveurs Steam, sur le "
            f"port {config.SOURCE_ENGINE_LAN_PORT} — ou sur le port du service hébergé "
            "qu'il annonce. Les parties apparaissent alors dans l'onglet Favoris, sans "
            "découverte LAN.\n\n"
            "Steam doit être FERMÉ : il réécrit ce fichier en quittant et annulerait la "
            "modification. Les entrées sont retirées à la désactivation et à la fermeture."
        )
        self.steam_checkbox.setEnabled(False)  # activé seulement une fois connecté à un réseau
        self.steam_checkbox.toggled.connect(self._on_steam_toggled)
        layout.addWidget(self.steam_checkbox)

        # --- Mes services hébergés (One-Click Host) ---
        service_box = QGroupBox("Mes services hébergés")
        service_box_layout = QVBoxLayout()
        service_layout = QHBoxLayout()
        self.service_label_input = QLineEdit()
        self.service_label_input.setPlaceholderText("ex: Palworld")
        self.service_port_input = QLineEdit()
        self.service_port_input.setPlaceholderText("Port (ex: 8211)")
        # Le TYPE est déclaré, jamais déduit du port : deviner « 8096 donc web » serait
        # une heuristique, et nommer une cause non mesurée a déjà coûté cher (point 62).
        # `jeu` en premier, donc par défaut — c'est le comportement d'avant le typage,
        # et rien ne doit changer pour qui ne touche à rien.
        self.service_type_combo = QComboBox()
        for type_service in service_types.TYPES_CONNUS:
            self.service_type_combo.addItem(
                service_types.description_de(type_service), type_service
            )
        self.service_type_combo.setToolTip(
            "Ce que les autres pairs pourront faire de ce service : rejoindre une "
            "partie (Steam), l'ouvrir dans le navigateur, parcourir un partage de "
            "fichiers, ou simplement copier son adresse.\n\n"
            "Le type décide aussi de la façon dont Meshamoto vérifie que le service "
            "répond — un serveur de jeu et une interface web ne s'interrogent pas "
            "de la même manière."
        )
        service_layout.addWidget(self.service_label_input, stretch=2)
        service_layout.addWidget(self.service_port_input, stretch=1)
        service_layout.addWidget(self.service_type_combo, stretch=1)
        self.service_share_button = QPushButton("Ajouter")
        self.service_share_button.setToolTip(
            "Annonce ce service aux autres pairs du réseau : il apparaîtra dans leur "
            "vue lobby avec un bouton adapté à son type.\n\n"
            f"Jusqu'à {service_types.SERVICES_MAX} services simultanés."
        )
        self.service_share_button.setEnabled(False)  # activé seulement une fois connecté
        self.service_share_button.clicked.connect(self._on_share_service_clicked)
        service_layout.addWidget(self.service_share_button)
        self.service_acl_button = QPushButton("Restreindre l'accès...")
        self.service_acl_button.setToolTip(
            "Ajouter le port de ce service aux pairs déjà restreints par une ACL, sans "
            "retaper leur liste de ports. N'affecte que les pairs déjà sous restriction "
            "(voir bouton \"Restreindre...\" dans le tableau des pairs) — ne réduit "
            "jamais l'accès d'un pair qui avait l'accès complet."
        )
        self.service_acl_button.setEnabled(False)  # activé seulement une fois le service partagé
        self.service_acl_button.clicked.connect(self._on_link_service_acl_clicked)
        service_layout.addWidget(self.service_acl_button)
        service_box_layout.addLayout(service_layout)

        # La liste EST l'état déclaré : il n'y a pas de bouton « Partager » séparé, et
        # donc pas d'écart possible entre ce qui est affiché et ce qui est annoncé.
        # Chaque ajout ou retrait réémet la liste complète (voir `_annoncer_services`).
        services_ligne = QHBoxLayout()
        self.service_list = QListWidget()
        self.service_list.setToolTip(
            "Les services que vous annoncez aux autres pairs. Ils disparaissent à la "
            "fermeture de Meshamoto."
        )
        # Trois lignes visibles : assez pour le cas courant sans manger la fenêtre.
        self.service_list.setMaximumHeight(72)
        services_ligne.addWidget(self.service_list, stretch=1)
        self.service_remove_button = QPushButton("Retirer")
        self.service_remove_button.setToolTip(
            "Cesse d'annoncer le service sélectionné. Les autres pairs le verront "
            "disparaître de leur salon."
        )
        self.service_remove_button.setEnabled(False)
        self.service_remove_button.clicked.connect(self._on_remove_service_clicked)
        services_ligne.addWidget(self.service_remove_button)
        service_box_layout.addLayout(services_ligne)
        self.service_list.itemSelectionChanged.connect(self._on_service_selection_changed)

        # --- Option de lancement (forcer le jeu à se lier sur l'IP virtuelle) ---
        launch_option_layout = QHBoxLayout()
        self.launch_option_combo = QComboBox()
        for template in launch_options.LAUNCH_OPTION_TEMPLATES:
            self.launch_option_combo.addItem(template.engine, template)
        launch_option_layout.addWidget(self.launch_option_combo, stretch=1)
        self.copy_launch_option_button = QPushButton("Copier l'option de lancement")
        self.copy_launch_option_button.setToolTip(
            "Certains moteurs (Source, GoldSrc...) se lient sur la première carte "
            "réseau détectée, pas forcément le tunnel Meshamoto — cette option force "
            "le jeu à utiliser ton IP virtuelle."
        )
        self.copy_launch_option_button.setEnabled(False)  # activé seulement une fois connecté
        self.copy_launch_option_button.clicked.connect(self._on_copy_launch_option_clicked)
        launch_option_layout.addWidget(self.copy_launch_option_button)
        service_box_layout.addLayout(launch_option_layout)

        service_box.setLayout(service_box_layout)
        layout.addWidget(service_box)

        # --- Liste de pairs façon "lobby" ---
        peers_box = QGroupBox("Pairs")
        peers_layout = QVBoxLayout()
        self.peers_table = QTableWidget(0, 8)
        self.peers_table.setHorizontalHeaderLabels(
            ["Pseudo", "IP virtuelle", "Ping", "", "ACL", "Diagnostic", "Service", ""]
        )
        self.peers_table.verticalHeader().setVisible(False)
        self.peers_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.peers_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.peers_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.peers_table.customContextMenuRequested.connect(self._on_peers_table_context_menu)
        header = self.peers_table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
        peers_layout.addWidget(self.peers_table)
        peers_box.setLayout(peers_layout)
        layout.addWidget(peers_box, stretch=1)

        # --- Barre de statut ---
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        # Widget PERMANENT (à droite) et non `showMessage` : la barre d'état sert
        # aux messages transitoires, qui écraseraient la version à la première
        # action. Là, elle reste lisible en permanence — c'est tout l'intérêt,
        # savoir d'un coup d'œil quelle build tourne sur chaque machine.
        self.version_label = QLabel(f"Meshamoto {version.version_affichee()}")
        self.version_label.setToolTip(
            "Version de ce client. À comparer avec celle de vos pairs quand un "
            "comportement diffère d'une machine à l'autre."
        )
        self.status_bar.addPermanentWidget(self.version_label)
        self.status_bar.showMessage("Prêt")

    def _status_dot(self, connected: bool) -> QIcon:
        """Génère une petite pastille verte/grise pour le statut de connexion d'un pair."""
        pixmap = QPixmap(12, 12)
        pixmap.fill(Qt.GlobalColor.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        color = QColor("#2ecc71") if connected else QColor("#95a5a6")
        painter.setBrush(QBrush(color))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(0, 0, 12, 12)
        painter.end()
        return QIcon(pixmap)

    _QUALITY_COLORS = {"green": "#2ecc71", "yellow": "#f1c40f", "orange": "#e67e22"}

    def _quality_dot(self, quality: str) -> QIcon:
        """Petite pastille colorée pour l'indicateur de qualité de connexion (voir
        connection_quality.py) : vert = P2P stable, jaune = P2P instable, orange =
        relais de secours actif."""
        pixmap = QPixmap(10, 10)
        pixmap.fill(Qt.GlobalColor.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setBrush(QBrush(QColor(self._QUALITY_COLORS[quality])))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(0, 0, 10, 10)
        painter.end()
        return QIcon(pixmap)

    def _build_tray_icon_image(self) -> QIcon:
        """Icône générique pour la zone de notification système — dessinée par le
        code, même philosophie que l'icône AppImage provisoire (pas d'asset tiers)."""
        pixmap = QPixmap(32, 32)
        pixmap.fill(Qt.GlobalColor.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setBrush(QBrush(QColor("#2ecc71")))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(1, 1, 30, 30)
        painter.setPen(QColor("white"))
        font = painter.font()
        font.setBold(True)
        font.setPointSize(15)
        painter.setFont(font)
        painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "M")
        painter.end()
        return QIcon(pixmap)

    def _setup_tray_icon(self) -> None:
        if not QSystemTrayIcon.isSystemTrayAvailable():
            logger.info("Zone de notification système indisponible — notifications désactivées.")
            self.tray_icon = None
            return
        self.tray_icon = QSystemTrayIcon(self._build_tray_icon_image(), self)
        self.tray_icon.setToolTip("Meshamoto")
        tray_menu = QMenu()
        quit_action = QAction("Quitter", tray_menu)
        quit_action.triggered.connect(self.close)
        tray_menu.addAction(quit_action)
        self.tray_icon.setContextMenu(tray_menu)
        self.tray_icon.show()

    def _notify_peer_changes(self, old_peers: list[dict], new_peers: list[dict]) -> None:
        """Notification système (+bip) quand un pair rejoint ou quitte le réseau
        virtuel — évite d'avoir à garder l'œil sur la fenêtre en permanence pendant
        qu'on attend que des amis se connectent."""
        old_ips = {p["virtual_ip"] for p in old_peers}
        new_ips = {p["virtual_ip"] for p in new_peers}
        joined = [p for p in new_peers if p["virtual_ip"] not in old_ips]
        left = [p for p in old_peers if p["virtual_ip"] not in new_ips]
        nouvelles_parties = self._parties_nouvellement_ouvertes(old_peers, new_peers)
        if not joined and not left and not nouvelles_parties:
            return

        QApplication.beep()
        if self.tray_icon is None:
            return
        if joined:
            names = ", ".join(p.get("display_name") or p["virtual_ip"] for p in joined)
            self.tray_icon.showMessage(
                "Meshamoto", f"{names} a rejoint le réseau", QSystemTrayIcon.MessageIcon.Information, 4000
            )
        if left:
            names = ", ".join(p.get("display_name") or p["virtual_ip"] for p in left)
            self.tray_icon.showMessage(
                "Meshamoto", f"{names} a quitté le réseau", QSystemTrayIcon.MessageIcon.Information, 4000
            )
        for pseudo, libelle in nouvelles_parties:
            self.tray_icon.showMessage(
                "Partie ouverte", f"{pseudo} héberge maintenant {libelle}",
                QSystemTrayIcon.MessageIcon.Information, 6000,
            )

    @staticmethod
    def _parties_nouvellement_ouvertes(
        old_peers: list[dict], new_peers: list[dict]
    ) -> list[tuple[str, str]]:
        """
        Pairs qui viennent d'annoncer une partie -> [(pseudo, libellé)].

        C'est le manque le plus concret face à Radmin et Hamachi : ils ne
        partagent qu'un nom de réseau, donc personne n'apprend qu'une partie
        vient de démarrer sans surveiller la fenêtre ou demander sur un autre
        canal.

        On compare le LIBELLÉ et pas seulement sa présence : un pair qui passe
        d'un jeu à un autre ouvre bien une nouvelle partie. Un pair qui vient
        d'arriver en annonçant déjà un service compte aussi — c'est le cas
        courant de quelqu'un qui lance Meshamoto avec son serveur déjà démarré.
        """
        anciens = {
            p["virtual_ip"]: p.get("hosted_service_label") or "" for p in old_peers
        }
        nouvelles = []
        for peer in new_peers:
            libelle = peer.get("hosted_service_label") or ""
            if not libelle or libelle == anciens.get(peer["virtual_ip"], ""):
                continue
            nouvelles.append((peer.get("display_name") or peer["virtual_ip"], libelle))
        return nouvelles

    def _on_services_probed(self, resultats: dict) -> None:
        """
        Mémorise l'état des parties et rafraîchit la colonne Service.

        L'état est conservé à part des pairs diffusés par le serveur : il change
        à chaque cycle de sonde (5 s) alors que la liste de pairs, elle, ne
        bouge presque jamais — les mélanger ferait repeindre tout le tableau
        pour un compteur de joueurs.
        """
        self._service_status.update(resultats)
        self._refresh_service_cells()

    def _refresh_service_cells(self) -> None:
        for row in range(self.peers_table.rowCount()):
            ip_item = self.peers_table.item(row, 1)
            if ip_item is None:
                continue
            peer = next(
                (p for p in self._current_peers if p["virtual_ip"] == ip_item.text()), None
            )
            if peer is None:
                continue
            self.peers_table.setItem(row, 6, QTableWidgetItem(self._texte_service(peer)))

    def _texte_service(self, peer: dict) -> str:
        """
        Libellé de la colonne Service, enrichi de l'état réel du serveur.

        Trois états distincts, et la distinction compte : jamais sondé (on ne
        sait pas encore), sondé et vivant (carte + joueurs), sondé et muet. Ce
        dernier cas est celui qui manque partout ailleurs — un libellé de
        service affiché indéfiniment alors que le serveur est éteint depuis des
        heures, qu'on ne découvre qu'en essayant de rejoindre.
        """
        services = service_types.services_du_pair(peer)
        if not services:
            return "—"
        virtual_ip = peer["virtual_ip"]
        return _SEPARATEUR_SERVICES.join(
            self._texte_d_un_service(virtual_ip, s) for s in services
        )

    def _texte_d_un_service(self, virtual_ip: str, service: service_types.Service) -> str:
        """Fragment affiché pour un service, selon ce qu'on sait de lui."""
        libelle, port = service.libelle, service.port

        # Un type sans sonde (voix, fichier, ou type inconnu venu d'un pair plus
        # récent) n'est jamais interrogé : on affiche ce qui est déclaré, sans rien
        # prétendre sur son état. Affirmer une extinction non mesurée serait dire
        # plus que ce qu'on sait.
        if service.comportement().sonde == service_types.SONDE_AUCUNE:
            return f"{libelle} ({port})"

        cle = (virtual_ip, port)
        if cle not in self._service_status:
            return f"{libelle} ({port})"

        info = self._service_status[cle]
        if info is None:
            # Pas forcément une panne : beaucoup de jeux ne parlent tout
            # simplement pas le protocole A2S. D'où « sans réponse » et non
            # « hors ligne », qui affirmerait plus que ce qu'on sait.
            return f"{libelle} ({port}) — sans réponse"
        resume = info.resume()
        return f"{libelle} — {resume}" if resume else f"{libelle} ({port})"

    def _reload_recent_networks_combo(self) -> None:
        self.recent_networks_combo.blockSignals(True)
        self.recent_networks_combo.clear()
        self.recent_networks_combo.addItem("-- Nouveau réseau --", None)
        for entry in recent_networks.load_recent_networks(config.RECENT_NETWORKS_PATH):
            self.recent_networks_combo.addItem(entry.get("name", ""), entry)
        self.recent_networks_combo.blockSignals(False)

    def _on_recent_network_selected(self, index: int) -> None:
        entry = self.recent_networks_combo.itemData(index)
        if entry is None:
            return
        self.network_name_input.setText(entry.get("name", ""))
        self.password_input.setText(entry.get("password", ""))

    def _on_forget_network_clicked(self) -> None:
        entry = self.recent_networks_combo.itemData(self.recent_networks_combo.currentIndex())
        if entry is None:
            return
        recent_networks.forget_network(config.RECENT_NETWORKS_PATH, entry["name"])
        self._reload_recent_networks_combo()
        self.network_name_input.clear()
        self.password_input.clear()

    def _on_connect_clicked(self) -> None:
        server_address = self.server_address_input.text().strip()
        name = self.network_name_input.text().strip()
        password = self.password_input.text()
        # Chaîne VIDE et non « peer » quand le champ est vide : elle signifie « je ne
        # déclare pas de pseudo », et le serveur conserve alors le nom déjà connu au
        # lieu de l'écraser (COALESCE, point 69). Renvoyer un littéral détruisait
        # l'information côté serveur, sans retour possible.
        # Le vide reste une `str` et non `None` parce que `request_join` est un signal
        # Qt typé `Signal(str, str, str)` ; la conversion en `None` se fait dans
        # `NetworkClient.join_network`, au moment de construire la requête.
        choisi = display_name_settings.normaliser(self.display_name_input.text())
        display_name = choisi or ""
        if choisi is not None:
            display_name_settings.save_display_name(
                config.DISPLAY_NAME_SETTINGS_PATH, choisi
            )

        if not server_address or not name or not password:
            QMessageBox.warning(
                self, "Champs manquants",
                "L'adresse du serveur, le nom du réseau et le mot de passe sont requis.",
            )
            return

        analyse = server_settings.parse_server_address(server_address)
        if analyse is None:
            # LE FORMAT RECOMMANDÉ EST ANNONCÉ EN PREMIER, et ce n'est pas cosmétique.
            # L'ancien message ouvrait sur « hôte:port » et ne mentionnait `https://`
            # qu'en remarque sur le chiffrement. Quelqu'un qui saisissait un nom de
            # domaine, se le voyait refuser, et suivait le message à la lettre
            # aboutissait à `nom:443` — c'est-à-dire du HTTP EN CLAIR vers le port
            # TLS, qui échoue derrière un reverse proxy. Observé le 2026-08-04 en
            # connectant les postes au serveur du VPS.
            QMessageBox.warning(
                self, "Adresse serveur invalide",
                "Pour un serveur joignable depuis Internet :\n"
                "    https://mon-serveur.exemple.net\n"
                "Le port est facultatif dans cette forme — il découle du https://.\n\n"
                "Pour un serveur sur votre réseau local, sans chiffrement :\n"
                "    192.168.1.50:8765\n"
                "Le port est alors obligatoire.\n\n"
                "N'écrivez pas « mon-serveur.exemple.net:443 » sans le https:// : "
                "la liaison partirait en clair vers le port chiffré, et échouerait.",
            )
            return

        host, port_numero, self._pending_server_tls = analyse
        self._pending_server_host = host
        self._pending_server_port = port_numero
        self._pending_name = name
        self._pending_password = password
        # MÉMORISÉE DÈS QU'ELLE EST VALIDE, sans attendre que la connexion aboutisse.
        # Elle ne l'était qu'en cas de SUCCÈS (`_on_connected`), donc une tentative
        # ratée — mauvais mot de passe, serveur momentanément injoignable, TLS qui
        # bronche — la perdait entièrement : il fallait retaper l'adresse au
        # lancement suivant, c'est-à-dire précisément quand on s'apprête à réessayer.
        # Une adresse de serveur n'est pas un secret et son coût de conservation est
        # nul ; le mot de passe, lui, ne suit surtout pas ce chemin (il n'est retenu
        # qu'après un succès, par `recent_networks`).
        server_settings.save_server_address(
            config.SERVER_ADDRESS_PATH, host, port_numero, self._pending_server_tls
        )
        self.client.set_server(
            self._pending_server_host, self._pending_server_port, self._pending_server_tls
        )
        self.connect_button.setEnabled(False)
        self.status_bar.showMessage("Connexion en cours...")
        self.request_join.emit(name, password, display_name)

    def _warn_if_virtual_range_overlaps_lan(self) -> None:
        """
        Signale un recouvrement entre la plage virtuelle et le LAN physique.

        Affiché dans l'étiquette de l'IP virtuelle et dans la barre d'état plutôt que
        dans une modale : l'utilisateur ne peut rien y faire sur le moment (la parade
        est côté serveur, MESHAMOTO_VNET_CIDR), et une modale de plus alourdirait les
        tests sans rien apporter. Le journal garde une trace en WARNING.
        """
        session = self.client.session
        if session is None:
            return
        local_ip = network_client._detect_local_ip()
        if not network_client.local_ip_conflicts_with_virtual_cidr(local_ip, session.cidr):
            return
        logger.warning(
            "L'IP locale %s appartient a la plage virtuelle %s : conflit d'adressage",
            local_ip, session.cidr,
        )
        self.virtual_ip_label.setText(
            f"IP virtuelle : {session.virtual_ip}   ⚠ CONFLIT — votre réseau local "
            f"({local_ip}) est dans la plage virtuelle {session.cidr}"
        )
        self.status_bar.showMessage(
            "Conflit d'adressage : relancez le serveur avec MESHAMOTO_VNET_CIDR sur une "
            "autre plage, puis recréez le réseau."
        )

    def _on_joined(self, virtual_ip: str) -> None:
        self._local_virtual_ip = virtual_ip
        # Le jeton vient d'être obtenu et mis en cache par le worker : c'est le
        # seul instant où l'écran peut passer de « sera vérifiée » à « acceptée ».
        self._rafraichir_etat_licence()
        self.virtual_ip_label.setText(f"IP virtuelle : {virtual_ip}")
        self.status_bar.showMessage("Connecté")
        self._warn_if_virtual_range_overlaps_lan()
        self.connect_button.setEnabled(True)
        self.isolation_checkbox.setEnabled(True)
        self.dns_checkbox.setEnabled(True)
        self.lan_relay_checkbox.setEnabled(True)
        self.steam_checkbox.setEnabled(True)
        self.service_share_button.setEnabled(True)
        self.copy_launch_option_button.setEnabled(True)
        # L'adresse du serveur a déjà été mémorisée au moment de la SAISIE (voir
        # `_on_connect_clicked`) : la réécrire ici n'ajouterait rien et donnerait deux
        # endroits où la même valeur est persistée. Le mot de passe, lui, n'est retenu
        # qu'ICI, c'est-à-dire après un succès — mémoriser un mot de passe qui vient
        # d'être refusé le proposerait ensuite en boucle.
        recent_networks.save_recent_network(
            config.RECENT_NETWORKS_PATH, self._pending_name, self._pending_password
        )
        self._reload_recent_networks_combo()

        if self._hebergement_guide_a_presenter:
            self._hebergement_guide_a_presenter = False
            self._presenter_reseau_cree()

    def _presenter_reseau_cree(self) -> None:
        """
        Dernière étape du parcours guidé : montrer le code d'invitation.

        C'est ce que l'écran d'accueil avait promis (« vous donne un code à
        envoyer à vos amis ») et que personne ne produisait. Le mot de passe est
        affiché **en clair et en entier** : il vient d'être généré, l'utilisateur
        ne l'a jamais vu, et c'est sa seule occasion de le noter s'il veut le
        transmettre autrement que par le code.
        """
        code, avertissement = self._construire_code_invitation(silencieux=True)
        texte = (
            f"Votre réseau <b>{self._pending_name}</b> est créé.<br><br>"
            f"Mot de passe : <b>{self._pending_password}</b><br><br>"
        )
        if code:
            texte += (
                "Le code d'invitation ci-dessous contient tout ce qu'il faut : "
                "votre ami n'aura qu'à le coller.<br><br>"
                "<b>Il contient le mot de passe</b> — ne le publiez pas."
            )
            if avertissement:
                texte += "<br><br>" + avertissement.strip().replace("\n", " ")
        else:
            # Sans code (adresse locale introuvable), on ne laisse pas croire que
            # l'étape est faite : les informations à transmettre sont dites en
            # toutes lettres.
            texte += (
                "Transmettez à vos amis l'adresse de votre serveur, le nom du "
                "réseau et ce mot de passe."
            )

        boite = QMessageBox(self)
        boite.setWindowTitle("Votre réseau est prêt")
        boite.setTextFormat(Qt.TextFormat.RichText)
        boite.setText(texte)
        if code:
            boite.setDetailedText(code)
            bouton_copier = boite.addButton("Copier le code", QMessageBox.ButtonRole.AcceptRole)
        else:
            bouton_copier = None
        boite.addButton("Fermer", QMessageBox.ButtonRole.RejectRole)
        boite.exec()
        if bouton_copier is not None and boite.clickedButton() is bouton_copier:
            QApplication.clipboard().setText(code)
            self.status_bar.showMessage("Code d'invitation copié", 5000)

    def _current_acl_rules(self) -> dict[str, list[int]]:
        """Règles ACL persistées, filtrées aux pairs actuellement présents dans le
        réseau (une règle pour un pair qui a quitté ne sert à rien tant qu'il n'est
        pas revenu)."""
        all_rules = acl_rules.load_acl_rules(config.ACL_RULES_PATH)
        current_ips = {p["virtual_ip"] for p in self._current_peers}
        return {ip: ports for ip, ports in all_rules.items() if ip in current_ips}

    def _confirm_isolation(self) -> bool:
        """
        Prévient que l'activation coupe les accès entrants depuis le réseau local.

        Piège découvert en conditions réelles le 2026-07-29 : l'utilisateur a activé le
        mode isolation sur sa machine Windows et a **perdu son accès RDP** dans la
        foulée. C'est le comportement voulu — mais sur une machine administrée à
        distance sans accès physique, c'est un aller simple. La porte de sortie (se
        reconnecter sur l'IP virtuelle du pair) n'est évidente qu'après coup, d'où ce
        rappel au moment du clic plutôt qu'une ligne de README que personne ne relit.
        """
        answer = QMessageBox.warning(
            self,
            "Activer le mode isolation ?",
            "Les connexions entrantes depuis votre réseau local vont être bloquées : "
            "bureau à distance (RDP), SSH, partages de fichiers, imprimantes...\n\n"
            "Ces services resteront joignables par les autres pairs, via leur adresse "
            "virtuelle 25.x.x.x — c'est justement l'intérêt du mode isolation.\n\n"
            "⚠ Si vous administrez CETTE machine à distance, reconnectez-vous sur son "
            "adresse virtuelle après activation, sinon vous perdrez l'accès.\n\n"
            "Votre trafic sortant n'est jamais affecté.",
            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
            QMessageBox.StandardButton.Cancel,
        )
        return answer == QMessageBox.StandardButton.Ok

    def _on_isolation_toggled(self, checked: bool) -> None:
        if checked and not self._confirm_isolation():
            # blockSignals : on remet la case dans son état sans repasser par ici
            self.isolation_checkbox.blockSignals(True)
            self.isolation_checkbox.setChecked(False)
            self.isolation_checkbox.blockSignals(False)
            self.status_bar.showMessage("Mode isolation non activé")
            return
        # On désactive la case pendant l'opération pour éviter les clics multiples
        # (la commande pare-feu peut prendre un instant, surtout avec un prompt pkexec sur Linux)
        self.isolation_checkbox.setEnabled(False)
        self.status_bar.showMessage("Activation du mode isolation..." if checked else "Désactivation du mode isolation...")
        self.request_isolation.emit(checked, self._current_acl_rules())

    def _on_isolation_changed(self, enabled: bool) -> None:
        # blockSignals évite de redéclencher _on_isolation_toggled lors de la resynchronisation
        self.isolation_checkbox.blockSignals(True)
        self.isolation_checkbox.setChecked(enabled)
        self.isolation_checkbox.blockSignals(False)
        self.isolation_checkbox.setEnabled(True)
        self.status_bar.showMessage("Mode isolation activé" if enabled else "Mode isolation désactivé")

    def _current_name_to_ip(self) -> dict[str, str]:
        return {p.get("display_name", ""): p["virtual_ip"] for p in self._current_peers}

    def _on_dns_toggled(self, checked: bool) -> None:
        # On désactive la case pendant l'opération pour éviter les clics multiples
        # (l'écriture du fichier hosts peut prendre un instant, surtout avec un prompt
        # pkexec sur Linux)
        self.dns_checkbox.setEnabled(False)
        self.status_bar.showMessage("Activation du DNS interne..." if checked else "Désactivation du DNS interne...")
        self.request_dns.emit(checked, self._current_name_to_ip())

    def _on_dns_changed(self, enabled: bool) -> None:
        # blockSignals évite de redéclencher _on_dns_toggled lors de la resynchronisation
        self.dns_checkbox.blockSignals(True)
        self.dns_checkbox.setChecked(enabled)
        self.dns_checkbox.blockSignals(False)
        self.dns_checkbox.setEnabled(True)
        self.status_bar.showMessage("DNS interne activé" if enabled else "DNS interne désactivé")

    def _current_steam_peers(self) -> tuple[dict[str, str], dict[str, int]]:
        """Pairs à écrire dans les favoris Steam, et le port propre à chacun.

        Le port du service hébergé annoncé par un pair l'emporte sur le port par défaut
        du moteur Source : un pair qui héberge sur 27016 (deux serveurs sur une même
        machine) ou sur un tout autre jeu doit apparaître avec SON port, sinon la
        favorite pointe dans le vide."""
        peers: dict[str, str] = {}
        ports: dict[str, int] = {}
        for peer in self._current_peers:
            virtual_ip = peer["virtual_ip"]
            pseudo = peer.get("display_name") or virtual_ip
            peers[pseudo] = virtual_ip
            # SEULS les services de type `jeu` vont dans les favoris Steam. Avant le
            # typage, n'importe quel port annoncé y atterrissait — un pair qui héberge
            # Jellyfin sur 8096 aurait fait écrire `192.168.192.x:8096` dans l'onglet
            # Favoris, une liste persistante et visible que l'utilisateur a construite
            # lui-même (point 41).
            jeu = next(
                (
                    s
                    for s in service_types.services_du_pair(peer)
                    if s.type_service == service_types.TYPE_JEU
                ),
                None,
            )
            if jeu is not None:
                ports[pseudo] = jeu.port
        return peers, ports

    def _on_steam_toggled(self, checked: bool) -> None:
        # Case désactivée pendant l'opération : parcourir les comptes Steam et détecter
        # le processus (tasklist sous Windows) n'est pas instantané.
        self.steam_checkbox.setEnabled(False)
        self.status_bar.showMessage(
            "Écriture des favoris Steam..." if checked else "Retrait des favoris Steam..."
        )
        self.request_steam.emit(checked, *self._current_steam_peers())

    def _on_steam_changed(self, enabled: bool, message: str) -> None:
        # blockSignals évite de redéclencher _on_steam_toggled lors de la resynchronisation
        self.steam_checkbox.blockSignals(True)
        self.steam_checkbox.setChecked(enabled)
        self.steam_checkbox.blockSignals(False)
        self.steam_checkbox.setEnabled(True)
        self.status_bar.showMessage("Favoris Steam à jour" if enabled else "Favoris Steam retirés")
        if message:
            QMessageBox.warning(self, "Favoris Steam", message)

    def _on_theme_toggled(self, checked: bool) -> None:
        theme = "dark" if checked else "light"
        app_instance = QApplication.instance()
        if app_instance is not None:
            theme_manager.apply_theme(app_instance, theme)
        theme_settings.save_theme(config.THEME_SETTINGS_PATH, theme)
        self.status_bar.showMessage("Thème sombre activé" if checked else "Thème clair activé")

    def _on_lan_relay_toggled(self, checked: bool) -> None:
        if checked:
            assert self._local_virtual_ip is not None  # bouton activé seulement après jointure
            self.lan_relay = LanBroadcastRelay(config.SOURCE_ENGINE_LAN_PORT, self._local_virtual_ip)
            self.lan_relay.set_peers([p["virtual_ip"] for p in self._current_peers])
            try:
                self.lan_relay.start()
            except OSError as exc:
                logger.exception("Échec de démarrage du relais LAN")
                self.lan_relay = None
                self.lan_relay_checkbox.blockSignals(True)
                self.lan_relay_checkbox.setChecked(False)
                self.lan_relay_checkbox.blockSignals(False)
                QMessageBox.critical(
                    self, "Relais LAN",
                    f"Impossible de démarrer le relais LAN (port {config.SOURCE_ENGINE_LAN_PORT}) : {exc}",
                )
                return
            self.status_bar.showMessage(f"Relais LAN activé (port {config.SOURCE_ENGINE_LAN_PORT})")
        else:
            if self.lan_relay is not None:
                self.lan_relay.stop()
                self.lan_relay = None
            self.status_bar.showMessage("Relais LAN désactivé")

    def _on_share_service_clicked(self) -> None:
        label = self.service_label_input.text().strip()
        port_text = self.service_port_input.text().strip()
        type_service = self.service_type_combo.currentData()

        if not port_text.isdigit():
            QMessageBox.warning(
                self, "Champs invalides",
                "Entrez un nom de service et un port valide (1-65535).",
            )
            return
        try:
            # La validation vit dans `service_types` et pas ici : elle est partagée
            # avec la lecture de ce qu'annoncent les autres pairs, et deux règles
            # séparées finiraient par diverger.
            service = service_types.valider(label, int(port_text), type_service)
        except ValueError as exc:
            QMessageBox.warning(self, "Champs invalides", str(exc))
            return

        if len(self._services_declares) >= service_types.SERVICES_MAX:
            QMessageBox.warning(
                self, "Trop de services",
                f"Meshamoto n'annonce que {service_types.SERVICES_MAX} services à la "
                "fois. Retirez-en un avant d'en ajouter un autre.",
            )
            return
        if any(s.port == service.port for s in self._services_declares):
            QMessageBox.warning(
                self, "Port déjà annoncé",
                f"Le port {service.port} est déjà annoncé. Retirez-le d'abord si vous "
                "voulez changer son nom ou son type.",
            )
            return

        self._services_declares.append(service)
        self._annoncer_services()
        self.service_label_input.clear()
        self.service_port_input.clear()
        self.status_bar.showMessage(f"Service annoncé : {service.libelle} ({service.port})")

    def _on_remove_service_clicked(self) -> None:
        ligne = self.service_list.currentRow()
        if not (0 <= ligne < len(self._services_declares)):
            return
        retire = self._services_declares.pop(ligne)
        self._annoncer_services()
        self.status_bar.showMessage(f"Service retiré : {retire.libelle} ({retire.port})")

    def _on_service_selection_changed(self) -> None:
        self.service_remove_button.setEnabled(self.service_list.currentRow() >= 0)

    def _annoncer_services(self) -> None:
        """
        Réémet l'état COMPLET et rafraîchit la liste affichée.

        L'état est toujours envoyé en entier plutôt que par différence : un message
        perdu laisserait sinon les deux côtés en désaccord sans que rien ne le signale.
        """
        self.request_announce_services.emit(list(self._services_declares))
        self.service_list.clear()
        for service in self._services_declares:
            self.service_list.addItem(
                f"{service.libelle}  ·  port {service.port}  ·  "
                f"{service_types.description_de(service.type_service)}"
            )
        # `_service_shared` gouverne le nettoyage à la fermeture et l'accès au bouton
        # d'ACL : il vaut désormais « la liste n'est pas vide ».
        self._service_shared = bool(self._services_declares)
        self.service_acl_button.setEnabled(self._service_shared)
        self.service_remove_button.setEnabled(self.service_list.currentRow() >= 0)

    def _on_link_service_acl_clicked(self) -> None:
        if not self._services_declares:
            return
        # Le service SÉLECTIONNÉ, à défaut le premier. Lire le champ de saisie comme
        # avant serait faux depuis qu'il se vide après chaque ajout : on ouvrirait le
        # port d'un service qui n'est pas celui qu'on croit, ou on planterait sur une
        # chaîne vide.
        ligne = self.service_list.currentRow()
        if not (0 <= ligne < len(self._services_declares)):
            ligne = 0
        port = self._services_declares[ligne].port
        all_rules = acl_rules.load_acl_rules(config.ACL_RULES_PATH)
        restricted_peers = [
            (p["virtual_ip"], p.get("display_name", p["virtual_ip"]), all_rules[p["virtual_ip"]])
            for p in self._current_peers
            if p["virtual_ip"] in all_rules
        ]
        if not restricted_peers:
            QMessageBox.information(
                self, "Aucun pair restreint",
                "Aucun pair n'a de restriction ACL active pour l'instant — rien à faire "
                "ici. Utilisez le bouton \"Restreindre...\" d'un pair dans le tableau pour "
                "lui donner un accès limité, vous pourrez ensuite y ajouter ce service.",
            )
            return

        dialog = ServiceAclDialog(restricted_peers, port, parent=self)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        updated_count = 0
        for virtual_ip in dialog.selected_virtual_ips():
            current_ports = all_rules[virtual_ip]
            if port not in current_ports:
                acl_rules.set_peer_ports(config.ACL_RULES_PATH, virtual_ip, current_ports + [port])
                updated_count += 1

        if self.isolation_checkbox.isChecked():
            self.request_isolation.emit(True, self._current_acl_rules())
        self.status_bar.showMessage(f"Port {port} ajouté à {updated_count} pair(s) restreint(s)")

    def _on_copy_launch_option_clicked(self) -> None:
        assert self._local_virtual_ip is not None  # bouton activé seulement après jointure
        template = self.launch_option_combo.currentData()
        rendered = template.render(self._local_virtual_ip)
        QApplication.clipboard().setText(rendered)
        self.status_bar.showMessage(f"Option de lancement copiée : {rendered}", 5000)

    def _on_peers_updated(self, peers: list[dict]) -> None:
        if self._has_received_first_peer_list:
            self._notify_peer_changes(self._current_peers, peers)
        self._has_received_first_peer_list = True
        self._current_peers = peers
        current_ips = {p["virtual_ip"] for p in peers}
        self._ping_failure_counts = {
            ip: count for ip, count in self._ping_failure_counts.items() if ip in current_ips
        }
        self._ping_history = {ip: h for ip, h in self._ping_history.items() if ip in current_ips}
        # Le marqueur « relais déjà demandé » ne survit pas à la disparition du
        # relay_port : sans ça, un pair dont la session de relais a EXPIRÉ côté
        # serveur restait marqué à vie, donc plus jamais rééligible au repli.
        # Observé le 2026-08-02 (point 67bis) : le serveur a rediffusé un endpoint
        # direct à l'expiration, les pings ont continué d'échouer, et plus rien
        # n'a jamais redemandé de relais — le pair est resté sur un chemin muet.
        # `&= current_ips` ne couvrait que le départ du pair, pas la fin de sa session.
        self._relay_requested_virtual_ips &= {
            p["virtual_ip"] for p in peers if p.get("relay_port")
        }
        if self.lan_relay is not None:
            self.lan_relay.set_peers([p["virtual_ip"] for p in peers])
        if self.dns_checkbox.isChecked() or self.steam_checkbox.isChecked():
            self._peer_refresh_timer.start()  # relance le délai : voir _setup_peer_refresh_debounce
        self.peers_table.setRowCount(len(peers))
        for row, peer in enumerate(peers):
            connected = bool(peer.get("endpoint")) or bool(peer.get("local_endpoint"))
            pseudo_item = QTableWidgetItem(peer.get("display_name", ""))
            pseudo_item.setIcon(self._status_dot(connected))
            self.peers_table.setItem(row, 0, pseudo_item)
            self.peers_table.setItem(row, 1, QTableWidgetItem(peer.get("virtual_ip", "")))
            self.peers_table.setItem(row, 2, QTableWidgetItem("—"))

            copy_button = QPushButton("Copier IP")
            copy_button.clicked.connect(functools.partial(self._on_copy_ip_clicked, peer.get("virtual_ip", "")))
            self.peers_table.setCellWidget(row, 3, copy_button)

            acl_button = QPushButton("Restreindre...")
            acl_button.setToolTip(
                "Limiter ce pair à des ports précis en mode isolation, au lieu de "
                "tout le réseau virtuel (ex: n'autoriser que ton serveur de jeu)."
            )
            acl_button.clicked.connect(
                functools.partial(
                    self._on_edit_acl_clicked, peer.get("virtual_ip", ""), peer.get("display_name", "")
                )
            )
            self.peers_table.setCellWidget(row, 4, acl_button)

            diag_button = QPushButton("Diagnostiquer")
            diag_button.setToolTip("Vérifier le ping, le handshake WireGuard, et tester un port applicatif précis.")
            diag_button.clicked.connect(
                functools.partial(
                    self._on_diagnostics_clicked,
                    peer.get("virtual_ip", ""), peer.get("public_key", ""), peer.get("display_name", ""),
                )
            )
            self.peers_table.setCellWidget(row, 5, diag_button)

            services = service_types.services_du_pair(peer)
            if services:
                self.peers_table.setItem(row, 6, QTableWidgetItem(self._texte_service(peer)))
                self.peers_table.setCellWidget(row, 7, self._bouton_de_service(peer, services))
            else:
                self.peers_table.setItem(row, 6, QTableWidgetItem("—"))
                self.peers_table.setCellWidget(row, 7, None)
        self.status_bar.showMessage(f"{len(peers)} pair(s) dans le réseau")
        self._request_ping_tick()  # premier ping immédiat, pas d'attente du prochain tick

    def _on_ping_results(self, results: dict) -> None:
        for virtual_ip, latency in results.items():
            self._ping_history.setdefault(virtual_ip, deque(maxlen=config.PING_HISTORY_WINDOW)).append(latency)

        for row in range(self.peers_table.rowCount()):
            ip_item = self.peers_table.item(row, 1)
            if ip_item is None or ip_item.text() not in results:
                continue
            virtual_ip = ip_item.text()
            latency = results[virtual_ip]
            text = f"{latency:.0f} ms" if latency is not None else "injoignable"
            ping_item = QTableWidgetItem(text)
            quality = connection_quality.evaluate_quality(
                list(self._ping_history.get(virtual_ip, [])), virtual_ip in self._relay_requested_virtual_ips
            )
            if quality.quality is not None:
                ping_item.setIcon(self._quality_dot(quality.quality))
            ping_item.setToolTip(quality.description)
            self.peers_table.setItem(row, 2, ping_item)

        # Un tunnel qui vient d'être réinstallé fait forcément échouer les pings le
        # temps qu'il remonte : ces échecs-là ne disent rien de la qualité du lien et
        # ne doivent pas déclencher de repli sur relais (voir tunnel_recently_changed).
        motif_de_grace = self.client.motif_de_grace(config.PING_GRACE_AFTER_TUNNEL_CHANGE_SECONDS)
        if motif_de_grace is not None:
            # Journalisé, et seulement quand la grâce sert VRAIMENT à quelque chose.
            # Sans cette ligne le garde-fou est invisible : on ne peut pas distinguer
            # « la grâce a évité un repli injustifié » de « il n'y avait rien à
            # éviter », ce qui rend le point 77 invérifiable en production — même
            # défaut que le renouvellement UPnP du point 74i, et même remède. Rare
            # par construction : la fenêtre dure trois cycles de ping.
            muets = [ip for ip, latence in results.items() if latence is None]
            if muets:
                logger.info(
                    "Grâce (%s) : %d échec(s) de sonde non comptés dans le repli "
                    "sur relais (%s)",
                    motif_de_grace,
                    len(muets),
                    ", ".join(sorted(muets)),
                )
            self._ping_failure_counts.clear()
            return

        # L'ESCALADE EST CADENCÉE PAR LE TEMPS, PAS PAR LES DIFFUSIONS.
        #
        # `_on_peers_updated` déclenche un cycle de ping immédiat à chaque diffusion,
        # pour que l'affichage réagisse sans attendre le prochain tick. Tout ce qui
        # suit — contrôle de santé du tunnel ET comptage des échecs — ne doit
        # pourtant s'exécuter qu'au rythme du timer : sinon le compteur mesure un
        # nombre de diffusions et non un temps écoulé, et une rafale franchit le
        # seuil en deux secondes (point 76). Le retour anticipé ne touche pas
        # l'affichage, déjà mis à jour au-dessus.
        maintenant = time.monotonic()
        if maintenant - self._dernier_cycle_escalade < config.PING_ESCALATION_MIN_INTERVAL_SECONDS:
            return
        self._dernier_cycle_escalade = maintenant

        # Avant toute escalade par pair : la panne est-elle CHEZ NOUS ? Sans cette
        # question, un tunnel local mort (driver remplacé par une mise à jour
        # Windows, port d'écoute volé — point 32) se présentait comme une panne de
        # chaque pair, et déclenchait une demande de relais par pair. Le relais ne
        # peut rien y faire, et chaque allocation réinstalle le tunnel de tout le
        # monde (points 26 et 28).
        verdict = self._evaluer_sante_du_tunnel(results)
        if verdict.message:
            self.status_bar.showMessage(verdict.message, 8000)
        if not verdict.relais_pertinent:
            self._tenter_reparation_du_tunnel()
            return

        for virtual_ip, latency in results.items():
            if latency is not None:
                self._ping_failure_counts[virtual_ip] = 0
                # Un pair qui répond prouve que le tunnel remarche : c'est la
                # seule preuve qui autorise une future réparation.
                self._tunnel_repair_attempted = False
                continue
            # Un pair absent du tunnel MONTÉ n'a pas de route chez nous : son
            # échec est certain et ne mesure rien. Compter ici poussait un pair
            # tout neuf sur le relais avant que quiconque ait pu lui parler
            # (point 75). Remis à zéro et non simplement ignoré, comme la grâce
            # du point 28 : sinon les échecs de la fenêtre d'arrivée resteraient
            # en réserve et s'additionneraient au premier échec légitime.
            if not self.client.peer_dans_tunnel_applique(virtual_ip):
                self._ping_failure_counts[virtual_ip] = 0
                continue
            failure_count = self._ping_failure_counts.get(virtual_ip, 0) + 1
            self._ping_failure_counts[virtual_ip] = failure_count
            if (
                failure_count < config.RELAY_FALLBACK_FAILURE_THRESHOLD
                or virtual_ip in self._relay_requested_virtual_ips
            ):
                continue
            peer = next((p for p in self._current_peers if p.get("virtual_ip") == virtual_ip), None)
            if peer is None or "id" not in peer:
                continue
            # Un pair sans aucun endpoint connu n'a jamais été joignable : le relais de
            # secours sert à contourner un NAT symétrique entre deux pairs qui SE
            # VOIENT, pas à ressusciter une machine éteinte. Demander un relais ici ne
            # rétablirait rien et modifierait la liste de pairs (ajout du relay_port),
            # ce qui déclenche une réinstallation complète du tunnel chez tout le monde.
            if not peer.get("endpoint") and not peer.get("local_endpoint"):
                continue
            self._relay_requested_virtual_ips.add(virtual_ip)
            self.status_bar.showMessage(
                f"Connexion directe à {peer.get('display_name', virtual_ip)} instable, "
                "tentative via le relais de secours..."
            )
            self.request_relay_fallback.emit(peer["id"])

    def _evaluer_sante_du_tunnel(self, results: dict) -> tunnel_health.Verdict:
        """
        Verdict sur le cycle de pings qui vient de s'achever.

        L'état de l'interface n'est interrogé QUE si plus personne ne répond :
        c'est un appel système (`ip link show` / `sc query`), certes court et
        non privilégié, mais il tournerait sinon sur le thread graphique toutes
        les cinq secondes pour rien. Dans le cas nominal — au moins un pair
        répond — aucun processus n'est lancé.
        """
        injoignables = sum(1 for latence in results.values() if latence is None)
        if injoignables < len(results) or not results:
            return tunnel_health.Verdict(tunnel_health.NORMAL)
        # Au démarrage, la liste de pairs arrive AVANT le montage (débounce) :
        # sans cette garde, le premier cycle de ping voit des pairs sans
        # interface et annonce une panne à chaque lancement. Observé en
        # conditions réelles, et le message est d'autant plus déroutant que
        # tout se passe bien.
        if not self.client.tunnel_ever_applied:
            return tunnel_health.Verdict(tunnel_health.NORMAL)
        try:
            actif = wireguard_manager.is_tunnel_active()
        except Exception:  # noqa: BLE001 - un diagnostic ne doit jamais casser le lobby
            logger.exception("Lecture de l'état du tunnel en échec")
            return tunnel_health.Verdict(tunnel_health.NORMAL)
        return tunnel_health.evaluer(
            nombre_de_pairs=len(results),
            pairs_injoignables=injoignables,
            tunnel_actif=actif,
            changement_recent=self.client.tunnel_recently_changed(
                config.PING_GRACE_AFTER_TUNNEL_CHANGE_SECONDS
            ),
        )

    def _tenter_reparation_du_tunnel(self) -> None:
        """
        Remonte le tunnel une fois, et une seule, par panne détectée.

        Le garde-fou est le cœur du sujet : une réparation qui échoue laisse le
        tunnel mort, donc le cycle suivant reposerait le même diagnostic et
        relancerait une réparation, indéfiniment. Le drapeau n'est remis à zéro
        qu'au retour effectif d'un pair (`_on_ping_results`), c'est-à-dire quand
        la réparation a démontré qu'elle avait marché.
        """
        if self._tunnel_repair_attempted:
            return
        self._tunnel_repair_attempted = True
        logger.warning("Tunnel local inactif alors que des pairs sont connectés : réparation.")
        self.request_tunnel_repair.emit()

    def _on_tunnel_repaired(self, reussi: bool) -> None:
        if reussi:
            self.status_bar.showMessage("Tunnel remis en service.", 6000)
            # Les compteurs accumulés pendant la panne ne disent rien de la
            # qualité des liens : les garder ferait basculer sur le relais dès
            # le premier échec suivant.
            self._ping_failure_counts.clear()
        else:
            self.status_bar.showMessage(
                "Le tunnel n'a pas pu être remis en service. Reconnectez-vous au réseau.", 0
            )

    def _on_copy_ip_clicked(self, virtual_ip: str) -> None:
        QApplication.clipboard().setText(virtual_ip)
        self.status_bar.showMessage(f"IP {virtual_ip} copiée", 3000)

    def _on_copy_service_address_clicked(self, virtual_ip: str, port: int) -> None:
        address = f"{virtual_ip}:{port}"
        QApplication.clipboard().setText(address)
        self.status_bar.showMessage(f"Adresse {address} copiée", 3000)

    # Libellé du bouton selon l'action. Court : la colonne est étroite.
    _LIBELLES_ACTION = {
        service_types.ACTION_REJOINDRE: "Rejoindre",
        service_types.ACTION_NAVIGATEUR: "Ouvrir",
        service_types.ACTION_EXPLORATEUR: "Parcourir",
        service_types.ACTION_COPIER: "Copier",
    }

    def _bouton_de_service(
        self, peer: dict, services: list[service_types.Service]
    ) -> QPushButton:
        """
        Bouton d'action de la colonne 7, dont le comportement dépend du TYPE.

        Un seul service : le bouton agit directement, comme avant. Plusieurs : il ouvre
        un menu. Cette bascule évite d'imposer un clic de plus au cas courant — un pair
        n'annonce presque jamais qu'un seul service — tout en rendant les autres
        atteignables sans réorganiser toute la table en une ligne par service.
        """
        virtual_ip = peer.get("virtual_ip", "")
        nom = peer.get("display_name") or virtual_ip
        if len(services) == 1:
            service = services[0]
            bouton = QPushButton(
                self._LIBELLES_ACTION.get(service.comportement().action, "Copier")
            )
            bouton.setToolTip(self._infobulle_de_service(peer, service))
            bouton.clicked.connect(
                functools.partial(self._activer_service, virtual_ip, nom, service)
            )
            return bouton

        bouton = QPushButton(f"{len(services)} services ▾")
        menu = QMenu(bouton)
        for service in services:
            action = menu.addAction(
                f"{self._LIBELLES_ACTION.get(service.comportement().action, 'Copier')}"
                f" — {service.libelle} ({service.port})"
            )
            action.setToolTip(self._infobulle_de_service(peer, service))
            action.triggered.connect(
                functools.partial(self._activer_service, virtual_ip, nom, service)
            )
        # Le menu est rattaché au bouton : sans parent Qt, il serait ramassé dès la
        # sortie de cette méthode et le clic n'ouvrirait rien.
        bouton.setMenu(menu)
        return bouton

    def _infobulle_de_service(self, peer: dict, service: service_types.Service) -> str:
        nom = peer.get("display_name") or peer.get("virtual_ip", "")
        adresse = service.adresse(peer.get("virtual_ip", ""))
        action = service.comportement().action
        if action == service_types.ACTION_REJOINDRE:
            return (
                f"Lance la partie de {nom} via Steam. Sans Steam, l'adresse {adresse} "
                "est copiée dans le presse-papiers."
            )
        if action == service_types.ACTION_NAVIGATEUR:
            return f"Ouvre http://{adresse} dans votre navigateur."
        if action == service_types.ACTION_EXPLORATEUR:
            return f"Ouvre le partage de fichiers de {nom} dans l'explorateur système."
        return f"Copie l'adresse {adresse} dans le presse-papiers."

    def _activer_service(
        self, virtual_ip: str, nom: str, service: service_types.Service
    ) -> None:
        """
        Exécute l'action associée au type du service.

        Le défaut est la COPIE de l'adresse, jamais l'inaction : un type inconnu vient
        d'un pair plus récent, et un bouton qui ne fait rien serait pire que pas de
        bouton du tout.
        """
        action = service.comportement().action
        if action == service_types.ACTION_REJOINDRE:
            self._on_join_game_clicked(virtual_ip, service.port)
        elif action == service_types.ACTION_NAVIGATEUR:
            self._ouvrir_service_web(virtual_ip, service.port)
        elif action == service_types.ACTION_EXPLORATEUR:
            self._on_browse_files_clicked(virtual_ip, nom)
        else:
            self._on_copy_service_address_clicked(virtual_ip, service.port)

    def _ouvrir_service_web(self, virtual_ip: str, port: int) -> None:
        """
        Ouvre l'interface web d'un pair dans le navigateur du système.

        **En `http://` et non `https://`** : le trafic circule déjà dans un tunnel
        WireGuard chiffré, donc TLS n'apporterait rien ici, et un certificat
        auto-signé — le cas normal pour un service auto-hébergé — ferait s'interposer
        un avertissement de sécurité que l'utilisateur apprendrait à ignorer.

        Pas de modale en cas d'échec, même raison qu'au bouton « Rejoindre » :
        l'absence de navigateur n'est pas une erreur du produit.
        """
        url = f"http://{virtual_ip}:{port}"
        try:
            QDesktopServices.openUrl(QUrl(url))
        except Exception as exc:  # noqa: BLE001 - remonté à l'écran, jamais avalé
            logger.exception("Ouverture du service web en échec")
            self._on_copy_service_address_clicked(virtual_ip, port)
            self.status_bar.showMessage(
                f"Impossible d'ouvrir le navigateur ({exc}) — adresse copiée.", 6000
            )
            return
        self.status_bar.showMessage(f"Ouverture de {url}...", 4000)

    def _on_join_game_clicked(self, virtual_ip: str, port: int) -> None:
        """
        Lance la partie du pair via Steam, ou retombe sur la copie de l'adresse.

        Le repli n'est pas un pis-aller : un pair peut parfaitement héberger un
        jeu hors Steam (Minecraft, Palworld), et faire disparaître le bouton
        dans ce cas laisserait l'utilisateur sans rien. La barre d'état explique
        alors ce qui s'est passé plutôt que de laisser croire à un clic sans effet.

        Pas de modale : l'absence de Steam n'est pas une erreur, et une boîte de
        dialogue à chaque clic serait vite insupportable.
        """
        try:
            url = game_launcher.rejoindre(virtual_ip, port)
        except game_launcher.SteamIntrouvableError:
            self._on_copy_service_address_clicked(virtual_ip, port)
            self.status_bar.showMessage(
                f"Steam introuvable — adresse {virtual_ip}:{port} copiée, à coller dans "
                "la connexion directe du jeu.", 6000,
            )
            return
        except Exception as exc:  # noqa: BLE001 - remonté à l'écran, jamais avalé
            logger.exception("Lancement de la partie en échec")
            self.status_bar.showMessage(f"Impossible de lancer la partie : {exc}", 6000)
            return
        logger.info("Lancement de la partie via %s", url)
        self.status_bar.showMessage(f"Lancement de la partie sur {virtual_ip}:{port}...", 4000)

    def _resolve_peer_row(self, position) -> tuple[str, str] | None:
        """(IP virtuelle, pseudo) du pair sous `position` dans le tableau, ou None si
        `position` ne correspond à aucune ligne — factorisé hors de
        _on_peers_table_context_menu pour rester testable sans passer par le menu
        contextuel Qt lui-même (QMenu.exec() bloque réellement, y compris en offscreen)."""
        row = self.peers_table.rowAt(position.y())
        if row < 0:
            return None
        ip_item = self.peers_table.item(row, 1)
        if ip_item is None or not ip_item.text():
            return None
        virtual_ip = ip_item.text()
        pseudo_item = self.peers_table.item(row, 0)
        display_name = pseudo_item.text() if pseudo_item is not None else virtual_ip
        return virtual_ip, display_name

    def _on_peers_table_context_menu(self, position) -> None:
        resolved = self._resolve_peer_row(position)
        if resolved is None:
            return
        virtual_ip, display_name = resolved

        menu = QMenu(self)
        browse_action = menu.addAction("Parcourir les fichiers (SMB)")
        kick_action = menu.addAction("Expulser du réseau")
        chosen = menu.exec(self.peers_table.viewport().mapToGlobal(position))
        if chosen == browse_action:
            self._on_browse_files_clicked(virtual_ip, display_name)
        elif chosen == kick_action:
            self._on_kick_peer_clicked(virtual_ip, display_name)

    def _on_kick_peer_clicked(self, virtual_ip: str, display_name: str) -> None:
        """
        Retire un pair du réseau.

        **La confirmation dit que l'expulsion seule ne suffit pas.** Le mot de
        passe est la seule identité du projet : un pair expulsé qui le connaît
        toujours peut rejoindre immédiatement. Passer ça sous silence promettrait
        une exclusion qui n'en est pas une — c'est précisément le genre d'échec
        silencieux que ce projet s'attache à nommer.
        """
        pair = next(
            (p for p in self._current_peers if p.get("virtual_ip") == virtual_ip), None
        )
        if pair is None or not pair.get("id"):
            QMessageBox.warning(
                self, "Pair introuvable",
                "Ce pair n'est plus dans la liste : il a peut-être déjà quitté le réseau.",
            )
            return

        confirmation = QMessageBox.question(
            self, "Expulser ce pair ?",
            f"<b>{display_name}</b> ({virtual_ip}) sera retiré du réseau et sa "
            "connexion fermée.<br><br>"
            "⚠️ <b>Il pourra revenir</b> s'il connaît toujours le mot de passe. Pour "
            "l'exclure réellement, changez aussi le mot de passe du réseau — les "
            "autres membres auront alors besoin d'un nouveau code.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        if confirmation != QMessageBox.StandardButton.Yes:
            return

        self.request_kick_peer.emit(
            self.network_name_input.text().strip(),
            self.password_input.text(),
            int(pair["id"]),
            display_name,
        )

    def _on_peer_kicked(self, reussi: bool, display_name: str, message: str) -> None:
        if reussi:
            self.status_bar.showMessage(f"{display_name} a été expulsé du réseau.", 5000)
        else:
            QMessageBox.warning(self, "Expulsion impossible", message)

    def _on_delete_network_clicked(self) -> None:
        """
        Supprime définitivement le réseau.

        La confirmation exige de **retaper le nom**. C'est la seule action
        irréversible de l'interface, et un simple Oui/Non se clique par réflexe —
        d'autant que ce bouton voisine ceux d'invitation, qu'on utilise souvent.
        """
        nom = self.network_name_input.text().strip()
        mot_de_passe = self.password_input.text()
        if not nom or not mot_de_passe:
            QMessageBox.warning(
                self, "Réseau inconnu",
                "Renseignez le nom du réseau et son mot de passe avant de le supprimer.",
            )
            return

        saisie, valide = QInputDialog.getText(
            self, "Supprimer définitivement ce réseau ?",
            f"Le réseau « {nom} » et tous ses membres seront supprimés du serveur.\n"
            "Les tunnels en cours seront coupés. Cette action est IRRÉVERSIBLE.\n\n"
            f"Tapez le nom du réseau pour confirmer :",
        )
        if not valide or saisie.strip() != nom:
            if valide:
                self.status_bar.showMessage("Nom incorrect : suppression annulée.", 4000)
            return

        self.request_delete_network.emit(nom, mot_de_passe)

    def _on_network_deleted(self, reussi: bool, nom: str, message: str) -> None:
        if not reussi:
            QMessageBox.warning(self, "Suppression impossible", message)
            return

        # Oublier localement : garder un réseau qui n'existe plus proposerait
        # ensuite une reconnexion vouée à un « réseau introuvable ».
        recent_networks.forget_network(config.RECENT_NETWORKS_PATH, nom)
        self._reload_recent_networks_combo()
        self.network_name_input.clear()
        self.password_input.clear()
        QMessageBox.information(
            self, "Réseau supprimé",
            f"Le réseau « {nom} » n'existe plus sur le serveur.\n\n"
            "Fermez Meshamoto ou reconnectez-vous à un autre réseau pour démonter "
            "le tunnel local.",
        )

    def _on_browse_files_clicked(self, virtual_ip: str, display_name: str) -> None:
        try:
            file_browser.open_peer_files(virtual_ip)
            self.status_bar.showMessage(f"Ouverture de l'explorateur pour {display_name}...", 3000)
        except Exception as exc:
            logger.exception("Échec de l'ouverture de l'explorateur de fichiers pour %s", virtual_ip)
            QMessageBox.warning(
                self, "Impossible d'ouvrir l'explorateur",
                f"Impossible d'ouvrir l'explorateur de fichiers pour {display_name} : {exc}",
            )

    def _on_edit_acl_clicked(self, virtual_ip: str, display_name: str) -> None:
        current_rules = acl_rules.load_acl_rules(config.ACL_RULES_PATH)
        current_ports = current_rules.get(virtual_ip, [])
        current_text = ", ".join(str(p) for p in current_ports)

        text, ok = QInputDialog.getText(
            self,
            f"Restreindre {display_name}",
            "Ports autorisés pour ce pair, séparés par des virgules (laisser vide "
            "pour rendre l'accès complet au réseau virtuel — comportement par défaut) "
            ":\nAppliqué seulement si le mode isolation est activé.",
            text=current_text,
        )
        if not ok:
            return

        try:
            ports = [int(p.strip()) for p in text.split(",") if p.strip()]
        except ValueError:
            QMessageBox.warning(self, "Ports invalides", "Entrez uniquement des numéros de port séparés par des virgules.")
            return
        invalid = [p for p in ports if not (1 <= p <= 65535)]
        if invalid:
            QMessageBox.warning(self, "Ports invalides", f"Hors plage (1-65535) : {invalid}")
            return

        acl_rules.set_peer_ports(config.ACL_RULES_PATH, virtual_ip, ports)
        if ports:
            self.status_bar.showMessage(f"{display_name} restreint aux ports : {ports}")
        else:
            self.status_bar.showMessage(f"{display_name} : accès complet au réseau virtuel rétabli")

        if self.isolation_checkbox.isChecked():
            self.request_isolation.emit(True, self._current_acl_rules())

    def _peer_os_family(self, virtual_ip: str) -> str:
        """OS signalé par ce pair, ou "" s'il tourne un client antérieur à ce champ."""
        peer = next((p for p in self._current_peers if p.get("virtual_ip") == virtual_ip), None)
        return (peer or {}).get("os_family") or ""

    def _on_diagnostics_clicked(self, virtual_ip: str, public_key: str, display_name: str) -> None:
        peer_os = self._peer_os_family(virtual_ip)
        dialog = DiagnosticDialog(display_name, virtual_ip, peer_os, parent=self)
        dialog.test_port_requested.connect(
            lambda port, protocol: self.request_diagnostics.emit(
                virtual_ip, public_key, port, protocol, peer_os
            )
        )
        self._diagnostic_dialog = dialog
        # vérif initiale : ping + handshake seuls
        self.request_diagnostics.emit(virtual_ip, public_key, 0, "tcp", peer_os)
        dialog.exec()
        self._diagnostic_dialog = None

    def _on_diagnostics_ready(self, results: list) -> None:
        if self._diagnostic_dialog is not None:
            self._diagnostic_dialog.show_results(results)

    def _on_error(self, message: str) -> None:
        self.connect_button.setEnabled(True)
        self.status_bar.showMessage(f"Erreur : {message}")
        QMessageBox.critical(self, "Erreur", message)

    def _on_connection_state(self, message: str, definitif: bool) -> None:
        """
        État du lien de signalisation, distinct des erreurs (point 70).

        Une coupure passagère va dans la barre d'état et nulle part ailleurs : la
        reconnexion automatique la résout en général en une seconde, et une modale y
        serait plus pénible que le problème. Le seul cas qui interrompt l'utilisateur
        est celui où le serveur ne veut plus de ce pair — là il DOIT agir, et rien
        d'autre ne le lui dirait.
        """
        self.status_bar.showMessage(message)
        if definitif:
            self.connect_button.setEnabled(True)
            QMessageBox.warning(self, "Déconnecté du réseau", message)

    def closeEvent(self, event) -> None:  # noqa: N802 (nom imposé par Qt)
        # Le nettoyage (pare-feu, DNS, wg-quick down) peut chacun déclencher une
        # invite pkexec bloquante : on ne le fait plus ici, sur le thread graphique
        # (ça gelait toute la fenêtre, invisible pour l'utilisateur qui ne voyait
        # aucune invite apparaître). On masque la fenêtre tout de suite pour un
        # retour immédiat, et le vrai nettoyage tourne sur worker_thread ; l'appli ne
        # quitte réellement qu'une fois _on_shutdown_complete reçu.
        if self._shutting_down:
            event.ignore()
            return
        self._shutting_down = True
        event.ignore()
        self.ping_timer.stop()
        # Sinon un rafraîchissement encore en attente réécrirait le fichier hosts (donc
        # une invite pkexec de plus) ou les favoris Steam juste après le nettoyage qui
        # vient de les vider.
        self._peer_refresh_timer.stop()
        if self.lan_relay is not None:
            self.lan_relay.stop()
        self.hide()
        if self.tray_icon is not None:
            self.tray_icon.setToolTip("Meshamoto (fermeture en cours...)")
        self.request_shutdown.emit(
            self.isolation_checkbox.isChecked(),
            list(self._current_acl_rules().keys()),
            self.dns_checkbox.isChecked(),
            self._service_shared,
            self.steam_checkbox.isChecked(),
        )

    def _on_shutdown_complete(self) -> None:
        self.worker_thread.quit()
        self.worker_thread.wait(2000)
        self.ping_thread.quit()
        self.ping_thread.wait(2000)
        # Le serveur hébergé n'est délibérément PAS arrêté ici : c'est tout
        # l'intérêt d'en faire un service. Seul le thread qui le pilote s'arrête,
        # et seulement s'il a été créé (le panneau n'a peut-être jamais été ouvert).
        if self.hosting_thread is not None:
            self.hosting_thread.quit()
            self.hosting_thread.wait(2000)
        QApplication.quit()


def main() -> None:
    config.ensure_directories()
    # Fichier de log en plus de la console : indispensable en usage empaqueté (.exe
    # Windows sans console, lanceur .desktop Linux avec Terminal=false) où stderr
    # n'est visible nulle part pour l'utilisateur ni pour le support.
    handlers: list[logging.Handler] = [
        logging.FileHandler(config.LOG_DIR / "meshamoto.log", encoding="utf-8")
    ]
    if sys.stderr is not None:
        handlers.append(logging.StreamHandler())
    logging.basicConfig(
        level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=handlers
    )
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    # Après `show()` : l'accueil est modal, et l'ouvrir avant laisserait une
    # fenêtre principale invisible derrière lui — l'utilisateur ne saurait pas
    # d'où vient la boîte de dialogue.
    window.afficher_accueil_si_premier_lancement()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
