Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc3330fa4c | ||
|
|
142eff5916 | ||
|
|
0bb8036df8 | ||
|
|
e781acbf4a | ||
|
|
99e1db5880 | ||
|
|
9e72fa4155 |
@@ -6,6 +6,43 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.13.0] — 2026-09-08
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Un thème clair, au choix dans Paramètres → Apparence.** Le thème sombre reste celui par défaut : rien ne change sur les postes déjà installés tant que le réglage n'est pas touché. Le choix est lu au lancement — les feuilles de style sont posées dans les constructeurs des fenêtres, et les repeindre à chaud supposerait de reconstruire celle qui porte l'état du tunnel, ses fils d'exécution et ses minuteries. Le message de confirmation l'annonce plutôt que de laisser croire à un réglage sans effet.
|
||||||
|
- Le module de thème publie désormais deux palettes derrière les mêmes noms. Les cent soixante-dix appels répartis dans l'interface lisent `theme.BG` sans rien savoir du thème actif : ajouter une palette n'a demandé de toucher aucun widget.
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- **Les couleurs qui vivaient encore hors du module de thème l'ont rejoint.** Le graphe de bande passante peignait son fond en dur — un panneau sombre au milieu d'une page claire — et sept de ses teintes sont maintenant des jetons. Quatre couleurs de survol de la fenêtre principale, recopiées à la main, désignent les jetons de même valeur.
|
||||||
|
- **Le texte posé sur un aplat saturé ne suit plus la couleur de texte ordinaire.** Bouton plein, onglet actif, bandeau, ligne sélectionnée : ils portaient `TEXT`, blanc en thème sombre et presque noir en thème clair, où il aurait disparu sur le fond d'accent. Un jeton distinct le dit, blanc dans les deux thèmes.
|
||||||
|
- Trois variantes d'état des champs et des boutons — lecture seule, désactivé, flèches de compteur — étaient écrites en dur dans la feuille de style commune sous forme de voiles blancs translucides, c'est-à-dire invisibles sur un fond clair. Elles deviennent des jetons de palette.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **Deux endroits figeaient le thème au chargement de leur module** et auraient gardé les teintes sombres quel que soit le réglage : la feuille de style de la fenêtre d'historique, construite au niveau du module, et la table état → couleur du diagnostic, évaluée comme attribut de classe. La première devient une fonction, la seconde ne retient que la puce et demande la couleur au thème à l'affichage.
|
||||||
|
- **Le bandeau de titre du panneau d'administration s'affichait blanc sur gris pâle en thème clair.** Il prend le fond en creux de la fenêtre, sur lequel le blanc des bandeaux d'onglets — qui sont, eux, des aplats saturés — ne se voyait pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.1] — 2026-09-08
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **Sous Windows, l'application n'était jamais relancée après une mise à jour.** Trois choses y concouraient : le service lance l'installeur avec `/VERYSILENT`, l'entrée `[Run]` qui rouvre l'application porte `skipifsilent` et s'en trouve écartée, et `RestartApplications=no` laisse le Restart Manager fermer sans rouvrir. Le service annonçait pourtant que l'application redémarrerait. Lui rendre l'entrée `[Run]` n'aurait rien réglé : l'installeur étant lancé par le service, il tourne en LocalSystem dans la session 0, où l'application se serait ouverte sans bureau visible et avec les privilèges du système. La relance revient donc au service, seul composant capable d'ouvrir un processus dans la session d'un autre, et que l'installeur redémarre une fois les binaires en place. Le jeton est celui de la session console : l'application repart avec les droits de l'utilisateur.
|
||||||
|
- **Le marqueur de relance est posé par l'installeur, non par le service.** Le service ne saurait qu'avoir lancé un installeur, quand celui-ci sait que l'installation a abouti. Il porte de surcroît déjà la correction, si bien que la première mise à jour en bénéficie — un marqueur écrit par l'ancien service ne serait apparu qu'à la deuxième. Il n'est posé qu'en installation silencieuse : une installation lancée à la main rouvre déjà l'application par son entrée `[Run]`.
|
||||||
|
- **L'installeur copiait par-dessus un binaire encore verrouillé.** L'attente censée laisser mourir le service demandait à `RenameFile` de renommer le fichier sur lui-même et tenait l'échec pour un verrou. Elle ne mesurait rien : Windows autorise le renommage de l'image d'un processus vivant — c'est ce qui rend `restartreplace` possible — et `MoveFile` refuse de toute façon une destination existante. La réponse était donc la même quel que soit l'état réel du service. La copie échouait, `restartreplace` reportait le remplacement au redémarrage suivant, et l'installeur s'achevait en annonçant une réussite que rien n'avait accomplie. L'attente porte maintenant sur la disparition du processus, relevée par `tasklist` — dont le nom d'image, contrairement aux libellés d'état de `sc.exe`, n'est pas traduit sur un Windows français.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.0] — 2026-09-08
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Une saisie de code réussie se voit enfin à l'écran.** Le message du serveur partait dans le seul journal, pendant que la fenêtre de saisie se refermait : rien ne distinguait un code accepté d'un code ignoré, et la durée obtenue — précisément ce que l'utilisateur venait de demander — restait invisible. Une confirmation l'annonce désormais avec son échéance et le temps restant.
|
||||||
|
- **Le client vérifie ensuite que le trafic atteint réellement le réseau distant.** Le serveur peut enregistrer l'autorisation sans que la règle de pare-feu s'applique ; le réseau reste alors muet malgré un code accepté, et l'utilisateur ne pouvait que le découvrir seul, plus tard. La vérification observe ce qui passe, là où le sondage se contente de demander au serveur ce qu'il a enregistré. Son verdict rejoint le journal, succès comme échec, en nommant la cible et en renvoyant à l'administrateur.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **Un refus du serveur pouvait faire lever une exception au lieu de s'afficher.** Contre un serveur antérieur à la v0.3.2, qui exige encore un identifiant que ce poste ne transmet plus, `POST /api/session` répond 422 — et le `detail` d'une erreur de validation FastAPI est une *liste*, non une phrase. Elle atterrissait telle quelle dans le champ `message` de la réponse, que l'interface passe ensuite à Qt : le refus n'était pas montré, il interrompait le traitement de la réponse. Seule une chaîne est désormais retenue comme message, et ce cas précis explique ce qu'il faut faire — mettre le serveur à jour. Un corps JSON qui n'est pas un objet, qui provoquait la même interruption un cran plus tôt, est écarté de même.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.11.0] — 2026-09-08
|
## [0.11.0] — 2026-09-08
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
## ──────────────────────────────────────────────
|
## ──────────────────────────────────────────────
|
||||||
|
|
||||||
APP := wgsecure
|
APP := wgsecure
|
||||||
VERSION := 0.11.0
|
VERSION := 0.13.0
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PYTHON := $(VENV)/bin/python3
|
PYTHON := $(VENV)/bin/python3
|
||||||
PIP := $(VENV)/bin/pip
|
PIP := $(VENV)/bin/pip
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🛡️ WGSecure (WGS)
|
# 🛡️ WGSecure (WGS)
|
||||||
|
|
||||||
🚀 v0.11.0 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
|
🚀 v0.13.0 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
|
||||||
|
|
||||||
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**.
|
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**.
|
||||||
|
|
||||||
@@ -210,4 +210,4 @@ WGSecure/
|
|||||||
## 👤 Auteur
|
## 👤 Auteur
|
||||||
|
|
||||||
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
||||||
Version :  — Septembre 2026
|
Version :  — Septembre 2026
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
__version__ = "0.11.0"
|
__version__ = "0.13.0"
|
||||||
APP_NAME = "WGSecure"
|
APP_NAME = "WGSecure"
|
||||||
APP_SHORT = "WGS"
|
APP_SHORT = "WGS"
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ _DEFAULT: dict[str, Any] = {
|
|||||||
"auto_connect_on_startup": False,
|
"auto_connect_on_startup": False,
|
||||||
"auto_reconnect": True,
|
"auto_reconnect": True,
|
||||||
"reconnect_interval": 15,
|
"reconnect_interval": 15,
|
||||||
|
# « dark » ou « light ». Lu une seule fois, au lancement : les feuilles
|
||||||
|
# de style sont posées dans les constructeurs des fenêtres (cf.
|
||||||
|
# app/ui/theme.py). Une valeur inconnue retombe sur « dark ».
|
||||||
|
"theme": "dark",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -101,6 +101,23 @@ def _parse_until(raw: object) -> datetime | None:
|
|||||||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
||||||
|
"""Le motif du refus tel que le serveur l'énonce, ou "" s'il n'en dit rien.
|
||||||
|
|
||||||
|
FastAPI répond aux erreurs de validation (422) par un `detail` qui est une
|
||||||
|
*liste* d'erreurs, pas une phrase. Relayée telle quelle, elle atterrissait
|
||||||
|
dans un champ `message` que l'interface passe ensuite à Qt : le refus
|
||||||
|
n'était alors pas affiché, il levait une exception au moment de le lire.
|
||||||
|
Seule une chaîne est un message ; tout autre corps ne dit rien
|
||||||
|
d'affichable, et le message générique de l'appelant vaut mieux.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
||||||
|
except (ValueError, OSError, AttributeError):
|
||||||
|
return "" # corps illisible, ou JSON sans objet
|
||||||
|
return detail if isinstance(detail, str) else ""
|
||||||
|
|
||||||
|
|
||||||
def api_base_url(cfg: Config) -> str:
|
def api_base_url(cfg: Config) -> str:
|
||||||
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
|
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
|
||||||
|
|
||||||
@@ -179,7 +196,7 @@ def _routed_through_tunnel(cfg: Config, target: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _remote_network_reachable(cfg: Config) -> bool:
|
def remote_network_reachable(cfg: Config) -> bool:
|
||||||
"""Le trafic passe-t-il réellement vers le réseau distant ?
|
"""Le trafic passe-t-il réellement vers le réseau distant ?
|
||||||
|
|
||||||
Repli pour les serveurs antérieurs à la v0.3.0, qui n'exposent pas
|
Repli pour les serveurs antérieurs à la v0.3.0, qui n'exposent pas
|
||||||
@@ -243,11 +260,8 @@ def session_status(cfg: Config) -> AuthResult:
|
|||||||
return AuthResult(True, "Accès réseau déjà ouvert jusqu'à "
|
return AuthResult(True, "Accès réseau déjà ouvert jusqu'à "
|
||||||
f"{until.astimezone():%d/%m/%Y %H:%M}", until)
|
f"{until.astimezone():%d/%m/%Y %H:%M}", until)
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
try:
|
detail = _error_detail(exc)
|
||||||
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
if exc.code == 404 and remote_network_reachable(cfg):
|
||||||
except (ValueError, OSError):
|
|
||||||
detail = ""
|
|
||||||
if exc.code == 404 and _remote_network_reachable(cfg):
|
|
||||||
# Serveur antérieur à la v0.3.0 : la route n'existe pas, mais le
|
# Serveur antérieur à la v0.3.0 : la route n'existe pas, mais le
|
||||||
# trafic atteint le réseau distant — l'accès est donc bien ouvert.
|
# trafic atteint le réseau distant — l'accès est donc bien ouvert.
|
||||||
# Aucune échéance en revanche : seul le serveur la connaît, et il
|
# Aucune échéance en revanche : seul le serveur la connaît, et il
|
||||||
@@ -324,10 +338,16 @@ def authenticate(cfg: Config, code: str) -> AuthResult:
|
|||||||
# Le serveur explique lui-même le refus (code invalide, compte
|
# Le serveur explique lui-même le refus (code invalide, compte
|
||||||
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
|
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
|
||||||
# message générique qui obligerait à consulter les journaux.
|
# message générique qui obligerait à consulter les journaux.
|
||||||
try:
|
detail = _error_detail(exc)
|
||||||
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
if exc.code == 422:
|
||||||
except (ValueError, OSError):
|
# Serveur antérieur à la v0.3.2 : il exige encore un identifiant
|
||||||
detail = ""
|
# dans le corps, que ce poste ne transmet plus. Son `detail` est
|
||||||
|
# la liste d'erreurs de validation de FastAPI — `_error_detail`
|
||||||
|
# l'écarte, et il n'en resterait qu'un « code 422 » énigmatique.
|
||||||
|
return AuthResult(False, "Ce serveur attend un identifiant VPN "
|
||||||
|
"que ce poste ne transmet plus. Mettez "
|
||||||
|
"le serveur à jour (v0.3.2 ou "
|
||||||
|
"supérieur).")
|
||||||
return AuthResult(False, detail
|
return AuthResult(False, detail
|
||||||
or f"Authentification refusée (code {exc.code})")
|
or f"Authentification refusée (code {exc.code})")
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
@@ -38,6 +39,9 @@ import win32event
|
|||||||
import win32file
|
import win32file
|
||||||
import win32pipe
|
import win32pipe
|
||||||
import win32security
|
import win32security
|
||||||
|
import win32process
|
||||||
|
import win32profile
|
||||||
|
import win32ts
|
||||||
import win32service
|
import win32service
|
||||||
import win32serviceutil
|
import win32serviceutil
|
||||||
|
|
||||||
@@ -66,6 +70,21 @@ _LOG_FILE = os.path.join(_LOG_DIR, "helper.log")
|
|||||||
# poser une règle DNS valable pour toute la machine.
|
# poser une règle DNS valable pour toute la machine.
|
||||||
_OWNER_FILE = os.path.join(_LOG_DIR, "owner.sid")
|
_OWNER_FILE = os.path.join(_LOG_DIR, "owner.sid")
|
||||||
|
|
||||||
|
# Posé par l'installeur au terme d'une installation silencieuse, relu par ce
|
||||||
|
# service qu'il redémarre juste après. L'installeur en est l'auteur et non le
|
||||||
|
# service : lui seul sait qu'une installation s'est réellement achevée, là où
|
||||||
|
# le service ne saurait que l'avoir lancée. Et comme il porte déjà la version
|
||||||
|
# corrigée, la toute première mise à jour en bénéficie — un marqueur écrit par
|
||||||
|
# le service ne serait apparu qu'à la deuxième.
|
||||||
|
#
|
||||||
|
# Sa date de modification fait foi pour la péremption : elle est posée par
|
||||||
|
# l'écriture elle-même, sans que l'auteur ait à l'inscrire.
|
||||||
|
_PENDING_RELAUNCH = os.path.join(_LOG_DIR, "relaunch.pending")
|
||||||
|
# Au-delà, le marqueur est tenu pour un reliquat : un service qui redémarre
|
||||||
|
# des jours plus tard, pour une tout autre raison, n'a pas à ouvrir une
|
||||||
|
# fenêtre sur le bureau de l'utilisateur.
|
||||||
|
_RELAUNCH_WINDOW = 30 * 60
|
||||||
|
|
||||||
# Directives refusées dans un `.conf` : elles font exécuter des commandes par
|
# Directives refusées dans un `.conf` : elles font exécuter des commandes par
|
||||||
# le service, donc en LocalSystem. `wireguard.exe` les ignore sous Windows,
|
# le service, donc en LocalSystem. `wireguard.exe` les ignore sous Windows,
|
||||||
# mais le fichier n'a aucune raison d'en porter — les refuser coûte une ligne
|
# mais le fichier n'a aucune raison d'en porter — les refuser coûte une ligne
|
||||||
@@ -526,6 +545,85 @@ def _handle_connection(handle, logger: logging.Logger) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _relaunch_after_update(logger: logging.Logger) -> None:
|
||||||
|
"""Relance l'interface dans la session de l'utilisateur, après une mise à jour.
|
||||||
|
|
||||||
|
Rien ne la relançait. L'installeur est lancé par ce service, donc en
|
||||||
|
LocalSystem et dans la session 0 ; son entrée `[Run]` porte `skipifsilent`
|
||||||
|
et le `/VERYSILENT` de l'appel l'écarte. La lui rendre ne réglerait rien :
|
||||||
|
elle démarrerait l'interface là où elle tourne, session 0, sans bureau
|
||||||
|
visible et avec les privilèges du système — une fenêtre que personne ne
|
||||||
|
voit, tenant le VPN en SYSTEM.
|
||||||
|
|
||||||
|
Le geste revient donc ici. Ce service est le seul composant à pouvoir
|
||||||
|
ouvrir un processus dans la session d'un autre, et l'installeur le
|
||||||
|
redémarre une fois les binaires en place, après avoir posé le marqueur que
|
||||||
|
voici : le service qui le lit est déjà la nouvelle version.
|
||||||
|
|
||||||
|
Le jeton vient de la session console : l'interface repart avec les droits
|
||||||
|
de l'utilisateur, pas ceux du service.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
age = time.time() - os.path.getmtime(_PENDING_RELAUNCH)
|
||||||
|
with open(_PENDING_RELAUNCH, encoding="utf-8") as f:
|
||||||
|
version = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return # cas ordinaire : pas de mise à jour
|
||||||
|
finally:
|
||||||
|
# Effacé quoi qu'il arrive, y compris si la relance échoue : mieux
|
||||||
|
# vaut une interface à rouvrir à la main qu'un marqueur qui la
|
||||||
|
# rouvrirait à chaque démarrage du service.
|
||||||
|
try:
|
||||||
|
os.unlink(_PENDING_RELAUNCH)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if age > _RELAUNCH_WINDOW:
|
||||||
|
logger.info("Marqueur de relance périmé (%d s), ignoré", age)
|
||||||
|
return
|
||||||
|
|
||||||
|
exe = os.path.join(os.path.dirname(sys.executable), "wgsecure.exe")
|
||||||
|
if not os.path.isfile(exe):
|
||||||
|
logger.warning("Relance impossible : %s introuvable", exe)
|
||||||
|
return
|
||||||
|
|
||||||
|
session = win32ts.WTSGetActiveConsoleSessionId()
|
||||||
|
if session == 0xFFFFFFFF:
|
||||||
|
# Mise à jour appliquée alors que personne n'est connecté : il n'y a
|
||||||
|
# pas de bureau où ouvrir la fenêtre, et l'utilisateur retrouvera
|
||||||
|
# l'application à jour à sa prochaine ouverture de session.
|
||||||
|
logger.info("Aucune session console : relance sans objet")
|
||||||
|
return
|
||||||
|
|
||||||
|
token = env = None
|
||||||
|
try:
|
||||||
|
token = win32ts.WTSQueryUserToken(session)
|
||||||
|
env = win32profile.CreateEnvironmentBlock(token, False)
|
||||||
|
startup = win32process.STARTUPINFO()
|
||||||
|
startup.lpDesktop = r"winsta0\default"
|
||||||
|
handles = win32process.CreateProcessAsUser(
|
||||||
|
token, exe, None, None, None, False,
|
||||||
|
win32con.CREATE_UNICODE_ENVIRONMENT, env,
|
||||||
|
os.path.dirname(exe), startup)
|
||||||
|
for h in handles[:2]: # processus et thread, inutiles ici
|
||||||
|
win32api.CloseHandle(h)
|
||||||
|
logger.info("Interface relancée en %s dans la session %d",
|
||||||
|
version or "version inconnue", session)
|
||||||
|
except pywintypes.error as e:
|
||||||
|
logger.warning("Relance de l'interface impossible : %s", e)
|
||||||
|
finally:
|
||||||
|
if env is not None:
|
||||||
|
try:
|
||||||
|
win32profile.DestroyEnvironmentBlock(env)
|
||||||
|
except pywintypes.error:
|
||||||
|
pass
|
||||||
|
if token is not None:
|
||||||
|
try:
|
||||||
|
win32api.CloseHandle(token)
|
||||||
|
except pywintypes.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class WGSecureHelperService(win32serviceutil.ServiceFramework):
|
class WGSecureHelperService(win32serviceutil.ServiceFramework):
|
||||||
_svc_name_ = "WGSecureHelper"
|
_svc_name_ = "WGSecureHelper"
|
||||||
_svc_display_name_ = "WGSecure Helper"
|
_svc_display_name_ = "WGSecure Helper"
|
||||||
@@ -575,6 +673,14 @@ class WGSecureHelperService(win32serviceutil.ServiceFramework):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info("Service démarré")
|
logger.info("Service démarré")
|
||||||
|
# Avant de servir : ce démarrage est peut-être celui que l'installeur
|
||||||
|
# vient de provoquer. Protégé à part — une relance qui échoue ne doit
|
||||||
|
# pas empêcher le service de faire son travail.
|
||||||
|
try:
|
||||||
|
_relaunch_after_update(logger)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Relance post-mise à jour en échec")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._serve_forever(logger)
|
self._serve_forever(logger)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+38
-10
@@ -102,7 +102,7 @@ class AdminWindow(QDialog):
|
|||||||
|
|
||||||
banner = QLabel(" WGSecure — Configuration Administrateur")
|
banner = QLabel(" WGSecure — Configuration Administrateur")
|
||||||
banner.setFixedHeight(40)
|
banner.setFixedHeight(40)
|
||||||
banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN))
|
banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN, theme.TEXT))
|
||||||
layout.addWidget(banner)
|
layout.addWidget(banner)
|
||||||
|
|
||||||
# Onglets de premier niveau : config qui conditionne la connexion
|
# Onglets de premier niveau : config qui conditionne la connexion
|
||||||
@@ -941,6 +941,18 @@ class AdminWindow(QDialog):
|
|||||||
|
|
||||||
lay.addWidget(grp2)
|
lay.addWidget(grp2)
|
||||||
|
|
||||||
|
grp3 = QGroupBox("Apparence")
|
||||||
|
form3 = QFormLayout(grp3)
|
||||||
|
|
||||||
|
self._cmb_theme = QComboBox()
|
||||||
|
for cle, libelle in theme.PALETTES.items():
|
||||||
|
self._cmb_theme.addItem(libelle, cle)
|
||||||
|
actuel = self._cmb_theme.findData(theme.CURRENT)
|
||||||
|
self._cmb_theme.setCurrentIndex(actuel if actuel >= 0 else 0)
|
||||||
|
self._row(form3, "Thème :", self._cmb_theme)
|
||||||
|
|
||||||
|
lay.addWidget(grp3)
|
||||||
|
|
||||||
btn_apply = QPushButton("✅ Appliquer les paramètres")
|
btn_apply = QPushButton("✅ Appliquer les paramètres")
|
||||||
btn_apply.clicked.connect(self._apply_settings)
|
btn_apply.clicked.connect(self._apply_settings)
|
||||||
lay.addWidget(btn_apply)
|
lay.addWidget(btn_apply)
|
||||||
@@ -952,12 +964,26 @@ class AdminWindow(QDialog):
|
|||||||
self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked())
|
self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked())
|
||||||
self._cfg.set("ui", "reconnect_interval", self._spin_interval.value())
|
self._cfg.set("ui", "reconnect_interval", self._spin_interval.value())
|
||||||
self._cfg.set("ui", "auto_connect_on_startup", self._chk_auto_connect.isChecked())
|
self._cfg.set("ui", "auto_connect_on_startup", self._chk_auto_connect.isChecked())
|
||||||
|
# Le thème n'est lu qu'au lancement : les feuilles de style sont
|
||||||
|
# posées dans les constructeurs des fenêtres, et les repeindre
|
||||||
|
# supposerait de reconstruire celle qui porte le tunnel. Le dire plutôt
|
||||||
|
# que de laisser croire à un réglage sans effet.
|
||||||
|
choisi = self._cmb_theme.currentData()
|
||||||
|
theme_change = choisi != (self._cfg.get("ui", "theme") or "dark")
|
||||||
|
self._cfg.set("ui", "theme", choisi)
|
||||||
|
|
||||||
autostart = self._chk_autostart.isChecked()
|
autostart = self._chk_autostart.isChecked()
|
||||||
if autostart != bool(self._cfg.get("ui", "autostart")):
|
if autostart != bool(self._cfg.get("ui", "autostart")):
|
||||||
self._cfg.set_autostart(autostart)
|
self._cfg.set_autostart(autostart)
|
||||||
else:
|
else:
|
||||||
self._cfg.save()
|
self._cfg.save()
|
||||||
QMessageBox.information(self, "Paramètres", "Paramètres enregistrés.")
|
|
||||||
|
message = "Paramètres enregistrés."
|
||||||
|
if theme_change:
|
||||||
|
message += ("\n\nLe thème « "
|
||||||
|
f"{theme.PALETTES.get(choisi, choisi)} » s'appliquera "
|
||||||
|
"au prochain démarrage de WGSecure.")
|
||||||
|
QMessageBox.information(self, "Paramètres", message)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Onglet Sécurité
|
# Onglet Sécurité
|
||||||
@@ -1579,13 +1605,11 @@ class AdminWindow(QDialog):
|
|||||||
v.addWidget(btn_close)
|
v.addWidget(btn_close)
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
#: Rendu d'une étape de diagnostic : puce, couleur du libellé.
|
#: Puce d'une étape de diagnostic. La couleur ne figure pas ici : évaluée
|
||||||
_STEP_STYLE = {
|
#: à l'import comme le reste d'un attribut de classe, elle aurait retenu
|
||||||
"ok": ("✅", theme.OK_TEXT),
|
#: les teintes du thème d'alors. Elle est demandée au thème à l'affichage
|
||||||
"warn": ("⚠️", theme.WARN_TEXT),
|
#: (cf. `theme.state_text`).
|
||||||
"fail": ("❌", theme.FAIL_TEXT),
|
_STEP_BULLET = {"ok": "✅", "warn": "⚠️", "fail": "❌", "skip": "⏭️"}
|
||||||
"skip": ("⏭️", theme.SKIP_TEXT),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _run_test(self):
|
def _run_test(self):
|
||||||
if self._diag_worker is not None:
|
if self._diag_worker is not None:
|
||||||
@@ -1628,7 +1652,11 @@ class AdminWindow(QDialog):
|
|||||||
# chaque cellule renvoyant à la ligne.
|
# chaque cellule renvoyant à la ligne.
|
||||||
rows = ['<table width="100%" cellspacing="0" cellpadding="0">']
|
rows = ['<table width="100%" cellspacing="0" cellpadding="0">']
|
||||||
for st in steps:
|
for st in steps:
|
||||||
bullet, color = self._STEP_STYLE.get(st["state"], ("•", theme.TEXT))
|
if st["state"] in self._STEP_BULLET:
|
||||||
|
bullet = self._STEP_BULLET[st["state"]]
|
||||||
|
color = theme.state_text(st["state"])
|
||||||
|
else:
|
||||||
|
bullet, color = "•", theme.TEXT
|
||||||
weight = "bold" if st["state"] == "fail" else "normal"
|
weight = "bold" if st["state"] == "fail" else "normal"
|
||||||
rows.append(
|
rows.append(
|
||||||
'<tr>'
|
'<tr>'
|
||||||
|
|||||||
+9
-7
@@ -5,6 +5,8 @@ from PyQt6.QtWidgets import QWidget
|
|||||||
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPainterPath
|
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPainterPath
|
||||||
from PyQt6.QtCore import Qt, QRect, QPointF
|
from PyQt6.QtCore import Qt, QRect, QPointF
|
||||||
|
|
||||||
|
from app.ui import theme
|
||||||
|
|
||||||
|
|
||||||
def _fmt(bps: float) -> str:
|
def _fmt(bps: float) -> str:
|
||||||
if bps >= 1_048_576:
|
if bps >= 1_048_576:
|
||||||
@@ -43,7 +45,7 @@ class BandwidthGraph(QWidget):
|
|||||||
pad_l, pad_r, pad_t, pad_b = 42, 8, 6, 20
|
pad_l, pad_r, pad_t, pad_b = 42, 8, 6, 20
|
||||||
|
|
||||||
# Fond
|
# Fond
|
||||||
p.fillRect(0, 0, w, h, QColor("#17202a"))
|
p.fillRect(0, 0, w, h, QColor(theme.GRAPH_BG))
|
||||||
|
|
||||||
plot_w = w - pad_l - pad_r
|
plot_w = w - pad_l - pad_r
|
||||||
plot_h = h - pad_t - pad_b
|
plot_h = h - pad_t - pad_b
|
||||||
@@ -71,18 +73,18 @@ class BandwidthGraph(QWidget):
|
|||||||
line.lineTo(QPointF(x, y))
|
line.lineTo(QPointF(x, y))
|
||||||
p.drawPath(line)
|
p.drawPath(line)
|
||||||
|
|
||||||
_path(self._rx, "#5dade2", "#1a3a52") # RX bleu
|
_path(self._rx, theme.GRAPH_RX, theme.GRAPH_RX_FILL) # RX
|
||||||
_path(self._tx, "#58d68d", "#1a3d2b") # TX vert
|
_path(self._tx, theme.GRAPH_TX, theme.GRAPH_TX_FILL) # TX
|
||||||
|
|
||||||
# Axes
|
# Axes
|
||||||
p.setPen(QPen(QColor("#2e4057")))
|
p.setPen(QPen(QColor(theme.GRAPH_AXIS)))
|
||||||
p.drawLine(pad_l, pad_t, pad_l, pad_t + plot_h)
|
p.drawLine(pad_l, pad_t, pad_l, pad_t + plot_h)
|
||||||
p.drawLine(pad_l, pad_t + plot_h, w - pad_r, pad_t + plot_h)
|
p.drawLine(pad_l, pad_t + plot_h, w - pad_r, pad_t + plot_h)
|
||||||
|
|
||||||
# Labels Y
|
# Labels Y
|
||||||
font = QFont("Arial", 8)
|
font = QFont("Arial", 8)
|
||||||
p.setFont(font)
|
p.setFont(font)
|
||||||
p.setPen(QPen(QColor("#5d6d7e")))
|
p.setPen(QPen(QColor(theme.GRAPH_LABEL)))
|
||||||
for frac, label in [(0.0, _fmt(max_val)), (0.5, _fmt(max_val / 2)), (1.0, "0")]:
|
for frac, label in [(0.0, _fmt(max_val)), (0.5, _fmt(max_val / 2)), (1.0, "0")]:
|
||||||
y = int(pad_t + frac * plot_h)
|
y = int(pad_t + frac * plot_h)
|
||||||
p.drawText(QRect(0, y - 8, pad_l - 2, 16),
|
p.drawText(QRect(0, y - 8, pad_l - 2, 16),
|
||||||
@@ -90,9 +92,9 @@ class BandwidthGraph(QWidget):
|
|||||||
label)
|
label)
|
||||||
|
|
||||||
# Légende
|
# Légende
|
||||||
p.setPen(QPen(QColor("#5dade2")))
|
p.setPen(QPen(QColor(theme.GRAPH_RX)))
|
||||||
p.drawText(pad_l + 4, pad_t + 12, f"↓ {_fmt(self._rx[-1])}")
|
p.drawText(pad_l + 4, pad_t + 12, f"↓ {_fmt(self._rx[-1])}")
|
||||||
p.setPen(QPen(QColor("#58d68d")))
|
p.setPen(QPen(QColor(theme.GRAPH_TX)))
|
||||||
p.drawText(pad_l + 90, pad_t + 12, f"↑ {_fmt(self._tx[-1])}")
|
p.drawText(pad_l + 90, pad_t + 12, f"↑ {_fmt(self._tx[-1])}")
|
||||||
|
|
||||||
p.end()
|
p.end()
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ from PyQt6.QtGui import QColor, QFont
|
|||||||
from app.core import history as hist
|
from app.core import history as hist
|
||||||
from app.ui import theme
|
from app.ui import theme
|
||||||
|
|
||||||
_CSS = f"""
|
def _css() -> str:
|
||||||
|
"""Feuille de style de la fenêtre d'historique.
|
||||||
|
|
||||||
|
Fonction et non constante : bâtie à l'import, elle retenait les couleurs
|
||||||
|
du thème chargé à ce moment-là et ignorait celui appliqué au démarrage.
|
||||||
|
"""
|
||||||
|
return f"""
|
||||||
QDialog {{ background: {theme.BG_SUNKEN}; }}
|
QDialog {{ background: {theme.BG_SUNKEN}; }}
|
||||||
QLabel {{ color: {theme.TEXT}; background: transparent; }}
|
QLabel {{ color: {theme.TEXT}; background: transparent; }}
|
||||||
QTableWidget {{
|
QTableWidget {{
|
||||||
@@ -21,12 +27,14 @@ _CSS = f"""
|
|||||||
background: {theme.BG_RAISED}; color: {theme.TEXT}; padding: 5px;
|
background: {theme.BG_RAISED}; color: {theme.TEXT}; padding: 5px;
|
||||||
border: none; font-weight: bold; font-size: 11px;
|
border: none; font-weight: bold; font-size: 11px;
|
||||||
}}
|
}}
|
||||||
QTableWidget::item:selected {{ background: {theme.ACCENT}; }}
|
QTableWidget::item:selected {{ background: {theme.ACCENT};
|
||||||
|
color: {theme.ON_SOLID}; }}
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 5px;
|
background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 5px;
|
||||||
padding: 6px 14px; border: none;
|
padding: 6px 14px; border: none;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}
|
QPushButton:hover {{ background: {theme.ACCENT_HOVER};
|
||||||
|
color: {theme.ON_SOLID}; }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +43,7 @@ class HistoryDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("WGSecure — Historique des sessions")
|
self.setWindowTitle("WGSecure — Historique des sessions")
|
||||||
self.setMinimumSize(680, 420)
|
self.setMinimumSize(680, 420)
|
||||||
self.setStyleSheet(_CSS)
|
self.setStyleSheet(_css())
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
@@ -64,7 +72,7 @@ class HistoryDialog(QDialog):
|
|||||||
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
self._table.setAlternatingRowColors(True)
|
self._table.setAlternatingRowColors(True)
|
||||||
self._table.setStyleSheet(
|
self._table.setStyleSheet(
|
||||||
_CSS +
|
_css() +
|
||||||
f"QTableWidget {{ alternate-background-color: {theme.BG_RAISED}; }}"
|
f"QTableWidget {{ alternate-background-color: {theme.BG_RAISED}; }}"
|
||||||
)
|
)
|
||||||
layout.addWidget(self._table)
|
layout.addWidget(self._table)
|
||||||
|
|||||||
+66
-11
@@ -88,6 +88,10 @@ class MainWindow(QMainWindow):
|
|||||||
# déclenché sans invite est déjà parti.
|
# déclenché sans invite est déjà parti.
|
||||||
self._probe_worker: ValueWorker | None = None
|
self._probe_worker: ValueWorker | None = None
|
||||||
self._probe_prompt = False
|
self._probe_prompt = False
|
||||||
|
# Vérification, après un code accepté, que le trafic atteint bien le
|
||||||
|
# réseau distant. Distincte du sondage : celui-ci demande au serveur
|
||||||
|
# ce qu'il a enregistré, celle-là observe ce qui passe réellement.
|
||||||
|
self._reach_worker: ValueWorker | None = None
|
||||||
# Date du dernier sondage abouti, et nombre de sondages consécutifs
|
# Date du dernier sondage abouti, et nombre de sondages consécutifs
|
||||||
# ayant trouvé fermé un accès qu'on croyait ouvert (cf.
|
# ayant trouvé fermé un accès qu'on croyait ouvert (cf.
|
||||||
# `_access_stale` et `_on_probe_reply`). `_access_message` évite de
|
# `_access_stale` et `_on_probe_reply`). `_access_message` évite de
|
||||||
@@ -549,9 +553,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_connect = QPushButton("Se connecter")
|
self._btn_connect = QPushButton("Se connecter")
|
||||||
self._btn_connect.setFixedHeight(44)
|
self._btn_connect.setFixedHeight(44)
|
||||||
self._btn_connect.setStyleSheet(
|
self._btn_connect.setStyleSheet(
|
||||||
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
|
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;"
|
||||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||||
"QPushButton:hover { background: #27ae60; }"
|
f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}"
|
||||||
f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}"
|
f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}"
|
||||||
)
|
)
|
||||||
self._btn_connect.clicked.connect(self._on_connect)
|
self._btn_connect.clicked.connect(self._on_connect)
|
||||||
@@ -686,14 +690,16 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _style_access_button(self, expected: bool):
|
def _style_access_button(self, expected: bool):
|
||||||
"""Met le bouton en avant seulement quand un code est réellement dû."""
|
"""Met le bouton en avant seulement quand un code est réellement dû."""
|
||||||
bg, hover = ((theme.WARN_SOLID, "#d68910") if expected
|
bg, hover, fg = ((theme.WARN_SOLID, theme.WARN_HOVER, theme.ON_SOLID)
|
||||||
else (theme.BG_RAISED, theme.ACCENT_HOVER))
|
if expected
|
||||||
|
else (theme.BG_RAISED, theme.ACCENT_HOVER, theme.TEXT))
|
||||||
weight = "bold" if expected else "normal"
|
weight = "bold" if expected else "normal"
|
||||||
self._btn_access.setStyleSheet(
|
self._btn_access.setStyleSheet(
|
||||||
f"QPushButton {{ background: {bg}; color: {theme.TEXT};"
|
f"QPushButton {{ background: {bg}; color: {fg};"
|
||||||
f" font-size: 12px; font-weight: {weight}; border-radius: 6px;"
|
f" font-size: 12px; font-weight: {weight}; border-radius: 6px;"
|
||||||
" border: none; }"
|
" border: none; }"
|
||||||
f"QPushButton:hover {{ background: {hover}; }}"
|
f"QPushButton:hover {{ background: {hover};"
|
||||||
|
f" color: {theme.ON_SOLID}; }}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _sync_window_height(self):
|
def _sync_window_height(self):
|
||||||
@@ -1081,9 +1087,9 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._btn_connect.setText("Se déconnecter")
|
self._btn_connect.setText("Se déconnecter")
|
||||||
self._btn_connect.setStyleSheet(
|
self._btn_connect.setStyleSheet(
|
||||||
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; font-size: 14px;"
|
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;"
|
||||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||||
"QPushButton:hover { background: #c0392b; }"
|
f"QPushButton:hover {{ background: {theme.FAIL_SOLID}; }}"
|
||||||
)
|
)
|
||||||
self._info_labels["rx_tx"].setText(
|
self._info_labels["rx_tx"].setText(
|
||||||
f"↓ {info.get('rx_bytes','—')} ↑ {info.get('tx_bytes','—')}"
|
f"↓ {info.get('rx_bytes','—')} ↑ {info.get('tx_bytes','—')}"
|
||||||
@@ -1105,9 +1111,9 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._btn_connect.setText("Se connecter")
|
self._btn_connect.setText("Se connecter")
|
||||||
self._btn_connect.setStyleSheet(
|
self._btn_connect.setStyleSheet(
|
||||||
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
|
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;"
|
||||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||||
"QPushButton:hover { background: #27ae60; }"
|
f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}"
|
||||||
)
|
)
|
||||||
self._info_labels["rx_tx"].setText("—")
|
self._info_labels["rx_tx"].setText("—")
|
||||||
self._info_labels["handshake"].setText("—")
|
self._info_labels["handshake"].setText("—")
|
||||||
@@ -1369,6 +1375,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._access_denials = 0
|
self._access_denials = 0
|
||||||
self._probe_retried = False
|
self._probe_retried = False
|
||||||
self._add_log(message, "success")
|
self._add_log(message, "success")
|
||||||
|
self._announce_access_granted()
|
||||||
else:
|
else:
|
||||||
# Abandon : l'état d'accès n'est pas touché. Il l'était, et à tort
|
# Abandon : l'état d'accès n'est pas touché. Il l'était, et à tort
|
||||||
# — un sondage abouti pendant que la fenêtre était ouverte (les
|
# — un sondage abouti pendant que la fenêtre était ouverte (les
|
||||||
@@ -1383,6 +1390,53 @@ class MainWindow(QMainWindow):
|
|||||||
"accès distant fermé", "warning")
|
"accès distant fermé", "warning")
|
||||||
self._refresh_status()
|
self._refresh_status()
|
||||||
|
|
||||||
|
def _announce_access_granted(self):
|
||||||
|
"""Confirme la validation, en disant jusqu'à quand l'accès tient.
|
||||||
|
|
||||||
|
Le message du serveur partait jusqu'ici dans le seul journal, pendant
|
||||||
|
que la fenêtre se refermait : rien ne distinguait à l'écran un code
|
||||||
|
accepté d'un code ignoré, et la durée obtenue restait invisible alors
|
||||||
|
que c'est précisément ce que l'utilisateur vient de demander.
|
||||||
|
"""
|
||||||
|
if self._access_until is not None:
|
||||||
|
fin = self._access_until.astimezone()
|
||||||
|
texte = (f"Code accepté.\n\nAccès au réseau distant ouvert "
|
||||||
|
f"jusqu'au {fin:%d/%m/%Y à %H:%M} "
|
||||||
|
f"(soit {self._remaining_text()}).")
|
||||||
|
else:
|
||||||
|
texte = ("Code accepté.\n\nAccès au réseau distant ouvert. Le "
|
||||||
|
"serveur n'a pas annoncé d'échéance.")
|
||||||
|
QMessageBox.information(self, "Accès ouvert", texte)
|
||||||
|
# Le serveur peut avoir enregistré l'autorisation sans que le trafic
|
||||||
|
# passe pour autant — règle de pare-feu non appliquée, par exemple.
|
||||||
|
# Le constater vaut mieux que laisser l'utilisateur découvrir seul un
|
||||||
|
# réseau resté muet après un code pourtant accepté.
|
||||||
|
self._verify_access_effective()
|
||||||
|
|
||||||
|
def _verify_access_effective(self):
|
||||||
|
"""Vérifie en fond que le trafic atteint réellement le réseau distant."""
|
||||||
|
if self._reach_worker is not None:
|
||||||
|
return
|
||||||
|
self._reach_worker = ValueWorker(vpn_session.remote_network_reachable,
|
||||||
|
self._cfg, parent=self)
|
||||||
|
self._reach_worker.done.connect(self._on_reach_reply)
|
||||||
|
self._reach_worker.finished.connect(self._reach_worker.deleteLater)
|
||||||
|
self._reach_worker.start()
|
||||||
|
|
||||||
|
def _on_reach_reply(self, joignable: bool):
|
||||||
|
self._reach_worker = None
|
||||||
|
cible = (self._cfg.wg.get("split_dns_server") or "").strip()
|
||||||
|
if not cible:
|
||||||
|
return # rien à sonder, donc rien à conclure
|
||||||
|
if joignable:
|
||||||
|
self._add_log(f"Trafic vers le réseau distant confirmé ({cible})",
|
||||||
|
"success")
|
||||||
|
return
|
||||||
|
self._add_log(
|
||||||
|
f"Code accepté, mais {cible} reste injoignable : le serveur a "
|
||||||
|
"enregistré l'autorisation sans que le trafic passe. Prévenez "
|
||||||
|
"l'administrateur.", "warning")
|
||||||
|
|
||||||
def _begin_task(self, label: str):
|
def _begin_task(self, label: str):
|
||||||
self._connecting = True
|
self._connecting = True
|
||||||
self._btn_connect.setEnabled(False)
|
self._btn_connect.setEnabled(False)
|
||||||
@@ -1505,7 +1559,7 @@ class MainWindow(QMainWindow):
|
|||||||
# Laisser les threads en cours se terminer avant de démonter le tunnel :
|
# Laisser les threads en cours se terminer avant de démonter le tunnel :
|
||||||
# une connexion et une déconnexion simultanées laisseraient un état mixte.
|
# une connexion et une déconnexion simultanées laisseraient un état mixte.
|
||||||
for worker in (self._worker, self._ping_worker, self._probe_worker,
|
for worker in (self._worker, self._ping_worker, self._probe_worker,
|
||||||
self._update_worker):
|
self._reach_worker, self._update_worker):
|
||||||
try:
|
try:
|
||||||
if worker is not None and worker.isRunning():
|
if worker is not None and worker.isRunning():
|
||||||
worker.wait(15000)
|
worker.wait(15000)
|
||||||
@@ -1520,6 +1574,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._worker = None
|
self._worker = None
|
||||||
self._ping_worker = None
|
self._ping_worker = None
|
||||||
self._probe_worker = None
|
self._probe_worker = None
|
||||||
|
self._reach_worker = None
|
||||||
self._update_worker = None
|
self._update_worker = None
|
||||||
|
|
||||||
if self._session_id is not None:
|
if self._session_id is not None:
|
||||||
|
|||||||
+321
-153
@@ -5,77 +5,308 @@ distinctes dans le seul panneau d'administration, cinq bandeaux d'onglets sans
|
|||||||
rapport de teinte entre eux, et des blocs de résultat dont le fond ne
|
rapport de teinte entre eux, et des blocs de résultat dont le fond ne
|
||||||
correspondait ni à celui de la page ni à celui de leurs voisins. Tout passe
|
correspondait ni à celui de la page ni à celui de leurs voisins. Tout passe
|
||||||
désormais par ce module — une couleur ne se choisit plus au cas par cas.
|
désormais par ce module — une couleur ne se choisit plus au cas par cas.
|
||||||
|
|
||||||
|
Deux palettes cohabitent. Les noms exportés — `BG`, `TEXT`, `TAB_CSS`… — ne
|
||||||
|
changent pas : `apply()` les rebranche sur la palette choisie au démarrage,
|
||||||
|
et les cent soixante-dix appels répartis dans l'interface continuent de lire
|
||||||
|
`theme.BG` sans rien savoir du thème actif. C'est ce qui permet d'ajouter une
|
||||||
|
palette sans toucher à un seul widget.
|
||||||
|
|
||||||
|
Le choix est lu une fois, au lancement (cf. `main.py`) : les feuilles de style
|
||||||
|
sont posées dans les constructeurs des fenêtres, et une bascule à chaud
|
||||||
|
demanderait de les reconstruire — celle qui porte l'état du tunnel comprise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ── Fonds ────────────────────────────────────────────────────────────────────
|
# ── Palettes ────────────────────────────────────────────────────────────────
|
||||||
BG = "#1c2833" # fond de l'application et de toutes les pages
|
# Chaque palette porte les mêmes clés ; ce sont elles que `apply()` publie
|
||||||
BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code
|
# comme variables de module. Tout ce qui dépend du thème vit ici, y compris
|
||||||
BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires
|
# ce qui était auparavant écrit au fil des feuilles de style ci-dessous —
|
||||||
BORDER = "rgba(255,255,255,0.18)"
|
# une valeur en dur dans `TAB_CSS` aurait été un fond blanc translucide posé
|
||||||
BORDER_SOFT = "rgba(255,255,255,0.10)"
|
# sur un thème clair, c'est-à-dire rien du tout.
|
||||||
INPUT_BG = "rgba(255,255,255,0.10)" # fond des champs de saisie et listes
|
|
||||||
|
|
||||||
# ── Textes ───────────────────────────────────────────────────────────────────
|
_DARK = {
|
||||||
TEXT = "white"
|
# Fonds
|
||||||
TEXT_MUTED = "rgba(255,255,255,0.70)"
|
"BG": "#1c2833", # fond de l'application et de toutes les pages
|
||||||
TEXT_FAINT = "rgba(255,255,255,0.50)"
|
"BG_SUNKEN": "#17202a", # zones en creux : configurations, blocs de code
|
||||||
TEXT_CODE = "#a8d8ea" # texte à chasse fixe (aperçus de config, rapports)
|
"BG_RAISED": "#253545", # posé sur le fond : listes, en-têtes secondaires
|
||||||
|
"BORDER": "rgba(255,255,255,0.18)",
|
||||||
|
"BORDER_SOFT": "rgba(255,255,255,0.10)",
|
||||||
|
"INPUT_BG": "rgba(255,255,255,0.10)", # champs de saisie et listes
|
||||||
|
# Variantes d'état des champs et boutons, jusqu'ici en dur dans TAB_CSS.
|
||||||
|
"INPUT_BG_READONLY": "rgba(255,255,255,0.06)",
|
||||||
|
"INPUT_BG_DISABLED": "rgba(255,255,255,0.04)",
|
||||||
|
"SPIN_BUTTON_BG": "rgba(255,255,255,0.15)",
|
||||||
|
"BUTTON_DISABLED_BG": "rgba(255,255,255,0.08)",
|
||||||
|
|
||||||
# ── Accent ───────────────────────────────────────────────────────────────────
|
# Textes
|
||||||
ACCENT = "#2471a3"
|
"TEXT": "white",
|
||||||
ACCENT_HOVER = "#1a5276"
|
"TEXT_MUTED": "rgba(255,255,255,0.70)",
|
||||||
ACCENT_ACTIVE = "#154360"
|
"TEXT_FAINT": "rgba(255,255,255,0.50)",
|
||||||
ACCENT_LIGHT = "#5dade2"
|
"TEXT_CODE": "#a8d8ea", # chasse fixe (aperçus de config, rapports)
|
||||||
|
# Texte posé sur un aplat saturé — bouton plein, onglet actif, bandeau.
|
||||||
|
# Distinct de TEXT : sur un thème clair, TEXT est presque noir et
|
||||||
|
# deviendrait illisible sur un fond d'accent.
|
||||||
|
"ON_SOLID": "white",
|
||||||
|
"BANNER_TEXT": "white",
|
||||||
|
|
||||||
# ── États ────────────────────────────────────────────────────────────────────
|
# Accent
|
||||||
# Chaque état porte un trio : teinte du texte, fond translucide, bouton plein.
|
"ACCENT": "#2471a3",
|
||||||
OK_TEXT, OK_BG, OK_SOLID = "#a9dfbf", "rgba(39,174,96,0.18)", "#1e8449"
|
"ACCENT_HOVER": "#1a5276",
|
||||||
WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b"
|
"ACCENT_ACTIVE": "#154360",
|
||||||
FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b"
|
"ACCENT_LIGHT": "#5dade2",
|
||||||
SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)"
|
# Fond de l'encart d'introduction : l'accent très dilué.
|
||||||
|
"HINT_BG": "rgba(36,113,163,0.16)",
|
||||||
|
|
||||||
# Teintes de survol des boutons pleins ok/fail — reprises telles quelles à
|
# États — chacun porte un trio : texte, fond translucide, bouton plein.
|
||||||
# plusieurs endroits (panneau admin, fenêtre principale) : un seul nom leur
|
"OK_TEXT": "#a9dfbf", "OK_BG": "rgba(39,174,96,0.18)", "OK_SOLID": "#1e8449",
|
||||||
# évite de dériver l'une de l'autre au fil des modifications.
|
"WARN_TEXT": "#f7dc6f", "WARN_BG": "rgba(241,196,15,0.15)", "WARN_SOLID": "#b7950b",
|
||||||
OK_HOVER = "#27ae60"
|
"FAIL_TEXT": "#f1948a", "FAIL_BG": "rgba(231,76,60,0.18)", "FAIL_SOLID": "#c0392b",
|
||||||
FAIL_HOVER = "#e74c3c"
|
"SKIP_TEXT": "rgba(255,255,255,0.45)",
|
||||||
|
"SKIP_BG": "rgba(255,255,255,0.05)",
|
||||||
|
# Survols des boutons pleins — nommés pour ne pas dériver l'un de l'autre.
|
||||||
|
"OK_HOVER": "#27ae60",
|
||||||
|
"WARN_HOVER": "#d68910",
|
||||||
|
"FAIL_HOVER": "#e74c3c",
|
||||||
|
|
||||||
_STATE = {
|
# Badge de ping (fenêtre principale). Teintes propres, volontairement plus
|
||||||
"ok": (OK_TEXT, OK_BG),
|
# tranchées que les aplats d'état : le badge est petit et lu d'un coup.
|
||||||
"warn": (WARN_TEXT, WARN_BG),
|
"PING_GOOD_BG": "#1e8449", "PING_GOOD_FG": "white",
|
||||||
"fail": (FAIL_TEXT, FAIL_BG),
|
"PING_MEDIUM_BG": "#d4ac0d", "PING_MEDIUM_FG": "black",
|
||||||
"skip": (SKIP_TEXT, SKIP_BG),
|
"PING_BAD_BG": "#922b21", "PING_BAD_FG": "white",
|
||||||
"idle": (TEXT_MUTED, "transparent"),
|
|
||||||
|
# Graphe de bande passante : fond, puis trait et remplissage par série.
|
||||||
|
"GRAPH_BG": "#17202a",
|
||||||
|
"GRAPH_RX": "#5dade2", "GRAPH_RX_FILL": "#1a3a52",
|
||||||
|
"GRAPH_TX": "#58d68d", "GRAPH_TX_FILL": "#1a3d2b",
|
||||||
|
"GRAPH_AXIS": "#2e4057", "GRAPH_LABEL": "#5d6d7e",
|
||||||
|
|
||||||
|
# Bandeaux d'onglets. Six teintes calées sur la même luminosité et la même
|
||||||
|
# saturation : les onglets restent distinguables sans que l'un paraisse
|
||||||
|
# plus clair ou plus saturé que les autres.
|
||||||
|
"BANNER": {
|
||||||
|
"wireguard": "#1a5276",
|
||||||
|
"keys": "#1a4a6e",
|
||||||
|
"test": "#1a4a7a",
|
||||||
|
"settings": "#155e52",
|
||||||
|
"security": "#7a3b1a",
|
||||||
|
"about": "#243b53",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Badge de ping (fenêtre principale) ──────────────────────────────────────
|
# Le thème clair n'est pas l'inverse du sombre : inverser une luminance donne
|
||||||
PING_CSS = {
|
# des aplats criards et des textes d'état illisibles. Les fonds descendent
|
||||||
"good": "background:#1e8449; color:white;",
|
# vers un gris très pâle plutôt que vers le blanc pur — un blanc plein fait
|
||||||
"medium": "background:#d4ac0d; color:black;",
|
# vibrer les bordures et fatigue sur une fenêtre qui reste ouverte la journée.
|
||||||
"bad": "background:#922b21; color:white;",
|
# Les teintes d'état, elles, sont assombries : `#a9dfbf` est un vert lisible
|
||||||
"offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};",
|
# sur ardoise et invisible sur papier.
|
||||||
|
#
|
||||||
|
# L'accent, les bandeaux et les aplats pleins ne bougent pas : ils portent
|
||||||
|
# déjà un texte blanc dans les deux thèmes, et les garder identiques donne à
|
||||||
|
# l'application la même identité d'un thème à l'autre.
|
||||||
|
_LIGHT = {
|
||||||
|
"BG": "#f4f6f8",
|
||||||
|
"BG_SUNKEN": "#e9edf1",
|
||||||
|
"BG_RAISED": "#ffffff",
|
||||||
|
"BORDER": "rgba(28,40,51,0.22)",
|
||||||
|
"BORDER_SOFT": "rgba(28,40,51,0.12)",
|
||||||
|
"INPUT_BG": "#ffffff",
|
||||||
|
"INPUT_BG_READONLY": "rgba(28,40,51,0.05)",
|
||||||
|
"INPUT_BG_DISABLED": "rgba(28,40,51,0.03)",
|
||||||
|
"SPIN_BUTTON_BG": "rgba(28,40,51,0.10)",
|
||||||
|
"BUTTON_DISABLED_BG": "rgba(28,40,51,0.08)",
|
||||||
|
|
||||||
|
"TEXT": "#1c2833", # l'ardoise du thème sombre, passée au texte
|
||||||
|
"TEXT_MUTED": "rgba(28,40,51,0.72)",
|
||||||
|
"TEXT_FAINT": "rgba(28,40,51,0.50)",
|
||||||
|
"TEXT_CODE": "#1a5276",
|
||||||
|
"ON_SOLID": "white",
|
||||||
|
"BANNER_TEXT": "white",
|
||||||
|
|
||||||
|
"ACCENT": "#2471a3",
|
||||||
|
"ACCENT_HOVER": "#1a5276",
|
||||||
|
"ACCENT_ACTIVE": "#154360",
|
||||||
|
# Plus soutenu que dans le thème sombre : `#5dade2` sert aussi de couleur
|
||||||
|
# de texte au journal, et se perd sur un fond pâle.
|
||||||
|
"ACCENT_LIGHT": "#2e86c1",
|
||||||
|
"HINT_BG": "rgba(36,113,163,0.10)",
|
||||||
|
|
||||||
|
"OK_TEXT": "#1a6b3c", "OK_BG": "rgba(39,174,96,0.14)", "OK_SOLID": "#1e8449",
|
||||||
|
"WARN_TEXT": "#8a6d0b", "WARN_BG": "rgba(241,196,15,0.22)", "WARN_SOLID": "#b7950b",
|
||||||
|
"FAIL_TEXT": "#a5281b", "FAIL_BG": "rgba(231,76,60,0.12)", "FAIL_SOLID": "#c0392b",
|
||||||
|
"SKIP_TEXT": "rgba(28,40,51,0.45)",
|
||||||
|
"SKIP_BG": "rgba(28,40,51,0.05)",
|
||||||
|
"OK_HOVER": "#27ae60",
|
||||||
|
"WARN_HOVER": "#d68910",
|
||||||
|
"FAIL_HOVER": "#e74c3c",
|
||||||
|
|
||||||
|
"PING_GOOD_BG": "#1e8449", "PING_GOOD_FG": "white",
|
||||||
|
"PING_MEDIUM_BG": "#b7950b", "PING_MEDIUM_FG": "white",
|
||||||
|
"PING_BAD_BG": "#922b21", "PING_BAD_FG": "white",
|
||||||
|
|
||||||
|
# Le graphe garde son fond en creux, pâle comme le reste ; les deux séries
|
||||||
|
# sont assombries pour rester lisibles dessus, et leur remplissage devient
|
||||||
|
# un voile de la même teinte au lieu d'un aplat sombre.
|
||||||
|
"GRAPH_BG": "#e9edf1",
|
||||||
|
"GRAPH_RX": "#2e86c1", "GRAPH_RX_FILL": "#cfe3f2",
|
||||||
|
"GRAPH_TX": "#1e8449", "GRAPH_TX_FILL": "#d3efdd",
|
||||||
|
"GRAPH_AXIS": "#c3ccd6", "GRAPH_LABEL": "#5d6d7e",
|
||||||
|
|
||||||
|
"BANNER": {
|
||||||
|
"wireguard": "#1a5276",
|
||||||
|
"keys": "#1a4a6e",
|
||||||
|
"test": "#1a4a7a",
|
||||||
|
"settings": "#155e52",
|
||||||
|
"security": "#7a3b1a",
|
||||||
|
"about": "#243b53",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Entrées de journal (fenêtre principale) ─────────────────────────────────
|
#: Palettes proposées, dans l'ordre où l'interface les présente.
|
||||||
LOG_COLORS = {
|
PALETTES = {"dark": "Sombre", "light": "Clair"}
|
||||||
"success": OK_TEXT,
|
_PALETTES = {"dark": _DARK, "light": _LIGHT}
|
||||||
"error": FAIL_TEXT,
|
|
||||||
"warning": WARN_TEXT,
|
|
||||||
"info": ACCENT_LIGHT,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Bandeaux d'onglets ───────────────────────────────────────────────────────
|
#: Palette active. Renseignée par `apply()`, lue par le panneau de réglages.
|
||||||
# Six teintes calées sur la même luminosité et la même saturation : les
|
CURRENT = "dark"
|
||||||
# onglets restent distinguables sans que l'un paraisse plus clair ou plus
|
|
||||||
# saturé que les autres, ce qui était le cas des valeurs choisies à la main.
|
|
||||||
BANNER = {
|
def apply(name: str) -> str:
|
||||||
"wireguard": "#1a5276",
|
"""Rend `name` active et recalcule tout ce qui en dépend.
|
||||||
"keys": "#1a4a6e",
|
|
||||||
"test": "#1a4a7a",
|
Rend le nom réellement appliqué : un réglage inconnu — configuration
|
||||||
"settings": "#155e52",
|
écrite à la main, retour à une version antérieure — retombe sur le thème
|
||||||
"security": "#7a3b1a",
|
sombre plutôt que de faire échouer le démarrage de l'interface.
|
||||||
"about": "#243b53",
|
|
||||||
}
|
À appeler avant de construire la moindre fenêtre : les feuilles de style
|
||||||
|
sont posées dans les constructeurs et ne sont pas relues ensuite.
|
||||||
|
"""
|
||||||
|
global CURRENT
|
||||||
|
CURRENT = name if name in _PALETTES else "dark"
|
||||||
|
globals().update(_PALETTES[CURRENT])
|
||||||
|
_derive()
|
||||||
|
return CURRENT
|
||||||
|
|
||||||
|
|
||||||
|
def _derive() -> None:
|
||||||
|
"""Recompose les valeurs dérivées de la palette qui vient d'être publiée.
|
||||||
|
|
||||||
|
Les fonctions du module lisent les jetons à l'appel et suivent donc
|
||||||
|
d'elles-mêmes ; seules les constantes calculées une fois à l'import —
|
||||||
|
feuilles de style complètes et tables d'état — doivent être refaites.
|
||||||
|
"""
|
||||||
|
globals().update(
|
||||||
|
_STATE={
|
||||||
|
"ok": (OK_TEXT, OK_BG),
|
||||||
|
"warn": (WARN_TEXT, WARN_BG),
|
||||||
|
"fail": (FAIL_TEXT, FAIL_BG),
|
||||||
|
"skip": (SKIP_TEXT, SKIP_BG),
|
||||||
|
"idle": (TEXT_MUTED, "transparent"),
|
||||||
|
},
|
||||||
|
PING_CSS={
|
||||||
|
"good": f"background:{PING_GOOD_BG}; color:{PING_GOOD_FG};",
|
||||||
|
"medium": f"background:{PING_MEDIUM_BG}; color:{PING_MEDIUM_FG};",
|
||||||
|
"bad": f"background:{PING_BAD_BG}; color:{PING_BAD_FG};",
|
||||||
|
"offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};",
|
||||||
|
},
|
||||||
|
LOG_COLORS={
|
||||||
|
"success": OK_TEXT,
|
||||||
|
"error": FAIL_TEXT,
|
||||||
|
"warning": WARN_TEXT,
|
||||||
|
"info": ACCENT_LIGHT,
|
||||||
|
},
|
||||||
|
TAB_CSS=f"""
|
||||||
|
QWidget {{ background-color: {BG}; color: {TEXT}; }}
|
||||||
|
QLabel {{ color: {TEXT}; background: transparent; }}
|
||||||
|
QCheckBox {{ color: {TEXT}; background: transparent; }}
|
||||||
|
QGroupBox {{
|
||||||
|
color: {TEXT};
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
QGroupBox::title {{
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
left: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: {TEXT};
|
||||||
|
}}
|
||||||
|
QLineEdit {{
|
||||||
|
background: {INPUT_BG};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}}
|
||||||
|
QLineEdit:read-only {{ background: {INPUT_BG_READONLY}; }}
|
||||||
|
QLineEdit:disabled {{ background: {INPUT_BG_DISABLED};
|
||||||
|
color: {TEXT_FAINT}; }}
|
||||||
|
QSpinBox {{
|
||||||
|
background: {INPUT_BG};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}}
|
||||||
|
QSpinBox::up-button, QSpinBox::down-button {{
|
||||||
|
background: {SPIN_BUTTON_BG};
|
||||||
|
}}
|
||||||
|
QComboBox {{
|
||||||
|
background: {INPUT_BG};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}}
|
||||||
|
QComboBox::drop-down {{ border: none; }}
|
||||||
|
QComboBox QAbstractItemView {{
|
||||||
|
background: {BG_RAISED};
|
||||||
|
color: {TEXT};
|
||||||
|
selection-background-color: {ACCENT};
|
||||||
|
selection-color: {ON_SOLID};
|
||||||
|
}}
|
||||||
|
QTextEdit {{
|
||||||
|
background: {BG_SUNKEN};
|
||||||
|
color: {TEXT_CODE};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
}}
|
||||||
|
QPushButton {{
|
||||||
|
background: {ACCENT};
|
||||||
|
color: {ON_SOLID};
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {ACCENT_HOVER}; }}
|
||||||
|
QPushButton:checked {{ background: {ACCENT_ACTIVE}; }}
|
||||||
|
QPushButton:disabled {{ background: {BUTTON_DISABLED_BG};
|
||||||
|
color: {TEXT_FAINT}; }}
|
||||||
|
""",
|
||||||
|
TABBAR_CSS=f"""
|
||||||
|
QTabWidget::pane {{ border: none; background: {BG}; }}
|
||||||
|
QTabBar::tab {{
|
||||||
|
background: {BG_RAISED};
|
||||||
|
color: {TEXT_MUTED};
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
QTabBar::tab:selected {{ background: {ACCENT}; color: {ON_SOLID}; }}
|
||||||
|
QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER};
|
||||||
|
color: {ON_SOLID}; }}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def state_text(state: str) -> str:
|
||||||
|
"""Couleur de texte d'un état, pour un rendu qui se passe de fond.
|
||||||
|
|
||||||
|
Le panneau de diagnostic tenait sa propre table état → couleur, bâtie à
|
||||||
|
l'import de son module : elle gardait donc les teintes du thème chargé à
|
||||||
|
ce moment-là, quel que soit celui appliqué ensuite. La question revient
|
||||||
|
ici, où la réponse suit la palette active.
|
||||||
|
"""
|
||||||
|
return _STATE.get(state, _STATE["idle"])[0]
|
||||||
|
|
||||||
|
|
||||||
def result_style(state: str, font_size: int = 11) -> str:
|
def result_style(state: str, font_size: int = 11) -> str:
|
||||||
@@ -116,109 +347,40 @@ def scrollarea_style() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def banner_style(color: str) -> str:
|
def banner_style(color: str, fg: str = "") -> str:
|
||||||
return (f"background: {color}; color: {TEXT}; font-size: 13px;"
|
"""Bandeau de titre posé en tête d'une fenêtre ou d'une page.
|
||||||
" font-weight: bold; padding-left: 8px;")
|
|
||||||
|
`fg` vaut le blanc des bandeaux d'onglets, qui sont des aplats saturés.
|
||||||
|
Le bandeau de titre du panneau d'administration, lui, prend le fond en
|
||||||
|
creux de la fenêtre : en thème clair, c'est un gris très pâle sur lequel
|
||||||
|
du blanc ne se voit pas. Il passe donc la couleur de texte ordinaire.
|
||||||
|
"""
|
||||||
|
return (f"background: {color}; color: {fg or BANNER_TEXT};"
|
||||||
|
" font-size: 13px; font-weight: bold; padding-left: 8px;")
|
||||||
|
|
||||||
|
|
||||||
#: CSS sombre commun à toutes les pages.
|
def primary_button_style(solid: str = "", hover: str = "") -> str:
|
||||||
TAB_CSS = f"""
|
"""Bouton d'action principale d'une page (lancer un test, par exemple).
|
||||||
QWidget {{ background-color: {BG}; color: {TEXT}; }}
|
|
||||||
QLabel {{ color: {TEXT}; background: transparent; }}
|
|
||||||
QCheckBox {{ color: {TEXT}; background: transparent; }}
|
|
||||||
QGroupBox {{
|
|
||||||
color: {TEXT};
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid {BORDER};
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-top: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
}}
|
|
||||||
QGroupBox::title {{
|
|
||||||
subcontrol-origin: margin;
|
|
||||||
left: 10px;
|
|
||||||
padding: 0 4px;
|
|
||||||
color: {TEXT};
|
|
||||||
}}
|
|
||||||
QLineEdit {{
|
|
||||||
background: {INPUT_BG};
|
|
||||||
color: {TEXT};
|
|
||||||
border: 1px solid {BORDER};
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
}}
|
|
||||||
QLineEdit:read-only {{ background: rgba(255,255,255,0.06); }}
|
|
||||||
QLineEdit:disabled {{ background: rgba(255,255,255,0.04);
|
|
||||||
color: {TEXT_FAINT}; }}
|
|
||||||
QSpinBox {{
|
|
||||||
background: {INPUT_BG};
|
|
||||||
color: {TEXT};
|
|
||||||
border: 1px solid {BORDER};
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 3px 6px;
|
|
||||||
}}
|
|
||||||
QSpinBox::up-button, QSpinBox::down-button {{
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
}}
|
|
||||||
QComboBox {{
|
|
||||||
background: {INPUT_BG};
|
|
||||||
color: {TEXT};
|
|
||||||
border: 1px solid {BORDER};
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
}}
|
|
||||||
QComboBox::drop-down {{ border: none; }}
|
|
||||||
QComboBox QAbstractItemView {{
|
|
||||||
background: {BG_RAISED};
|
|
||||||
color: {TEXT};
|
|
||||||
selection-background-color: {ACCENT};
|
|
||||||
}}
|
|
||||||
QTextEdit {{
|
|
||||||
background: {BG_SUNKEN};
|
|
||||||
color: {TEXT_CODE};
|
|
||||||
border: 1px solid {BORDER};
|
|
||||||
border-radius: 4px;
|
|
||||||
}}
|
|
||||||
QPushButton {{
|
|
||||||
background: {ACCENT};
|
|
||||||
color: {TEXT};
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
border: none;
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{ background: {ACCENT_HOVER}; }}
|
|
||||||
QPushButton:checked {{ background: {ACCENT_ACTIVE}; }}
|
|
||||||
QPushButton:disabled {{ background: rgba(255,255,255,0.08);
|
|
||||||
color: {TEXT_FAINT}; }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Barre d'onglets, utilisée par le panneau principal et les sous-onglets.
|
Les valeurs par défaut sont résolues à l'appel, et non à la définition :
|
||||||
TABBAR_CSS = f"""
|
prises dans la signature, elles auraient figé les teintes de la palette
|
||||||
QTabWidget::pane {{ border: none; background: {BG}; }}
|
active au moment de l'import — soit toujours celles du thème sombre.
|
||||||
QTabBar::tab {{
|
"""
|
||||||
background: {BG_RAISED};
|
solid = solid or OK_SOLID
|
||||||
color: {TEXT_MUTED};
|
hover = hover or OK_HOVER
|
||||||
padding: 6px 14px;
|
return (f"QPushButton {{ padding: 9px; background: {solid};"
|
||||||
border: none;
|
f" color: {ON_SOLID};"
|
||||||
}}
|
|
||||||
QTabBar::tab:selected {{ background: {ACCENT}; color: {TEXT}; }}
|
|
||||||
QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER};
|
|
||||||
color: {TEXT}; }}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def primary_button_style(solid: str = OK_SOLID, hover: str = OK_HOVER) -> str:
|
|
||||||
"""Bouton d'action principale d'une page (lancer un test, par exemple)."""
|
|
||||||
return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};"
|
|
||||||
f" border-radius: 5px; font-weight: bold; border: none; }}"
|
f" border-radius: 5px; font-weight: bold; border: none; }}"
|
||||||
f"QPushButton:hover {{ background: {hover}; }}")
|
f"QPushButton:hover {{ background: {hover}; }}")
|
||||||
|
|
||||||
|
|
||||||
def secondary_button_style() -> str:
|
def secondary_button_style() -> str:
|
||||||
"""Bouton d'action secondaire d'une page (à côté d'une action principale)."""
|
"""Bouton d'action secondaire d'une page (à côté d'une action principale)."""
|
||||||
return (f"QPushButton {{ padding: 9px; background: {BG_RAISED}; color: {TEXT};"
|
return (f"QPushButton {{ padding: 9px; background: {BG_RAISED};"
|
||||||
|
f" color: {TEXT};"
|
||||||
f" border-radius: 5px; border: none; }}"
|
f" border-radius: 5px; border: none; }}"
|
||||||
f"QPushButton:hover {{ background: {ACCENT_HOVER}; }}")
|
f"QPushButton:hover {{ background: {ACCENT_HOVER};"
|
||||||
|
f" color: {ON_SOLID}; }}")
|
||||||
|
|
||||||
|
|
||||||
def hint_style() -> str:
|
def hint_style() -> str:
|
||||||
@@ -227,7 +389,13 @@ def hint_style() -> str:
|
|||||||
Assez contrasté pour se détacher du fond de page, assez sobre pour ne pas
|
Assez contrasté pour se détacher du fond de page, assez sobre pour ne pas
|
||||||
concurrencer le contenu : un liseré d'accent à gauche, pas de cadre plein.
|
concurrencer le contenu : un liseré d'accent à gauche, pas de cadre plein.
|
||||||
"""
|
"""
|
||||||
return (f"background: rgba(36,113,163,0.16);"
|
return (f"background: {HINT_BG};"
|
||||||
f" border-left: 3px solid {ACCENT_LIGHT};"
|
f" border-left: 3px solid {ACCENT_LIGHT};"
|
||||||
f" border-radius: 4px; padding: 9px 11px;"
|
f" border-radius: 4px; padding: 9px 11px;"
|
||||||
f" color: {TEXT_MUTED}; font-size: 11px;")
|
f" color: {TEXT_MUTED}; font-size: 11px;")
|
||||||
|
|
||||||
|
|
||||||
|
# Palette par défaut publiée dès l'import : le module reste utilisable sans
|
||||||
|
# initialisation explicite, et `main.py` n'a qu'à rappeler `apply()` avec le
|
||||||
|
# réglage lu en configuration.
|
||||||
|
apply("dark")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.core import log as conn_log
|
|||||||
from app.core import shutdown as shutdown_guard
|
from app.core import shutdown as shutdown_guard
|
||||||
from app.core.single_instance import SingleInstanceGuard
|
from app.core.single_instance import SingleInstanceGuard
|
||||||
from app.ui.main_window import MainWindow
|
from app.ui.main_window import MainWindow
|
||||||
|
from app.ui import theme
|
||||||
from app.ui.systray import SystemTray
|
from app.ui.systray import SystemTray
|
||||||
from app.ui import icons
|
from app.ui import icons
|
||||||
from app.utils.platform_utils import install_desktop_entry
|
from app.utils.platform_utils import install_desktop_entry
|
||||||
@@ -112,6 +113,13 @@ def main():
|
|||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
# Avant la moindre fenêtre : les feuilles de style sont posées dans les
|
||||||
|
# constructeurs, et une palette changée après coup n'atteindrait plus
|
||||||
|
# rien. `apply` retombe seule sur le thème sombre si le réglage est
|
||||||
|
# inconnu — une configuration écrite à la main ne doit pas empêcher
|
||||||
|
# l'application de s'ouvrir.
|
||||||
|
theme.apply(config.get("ui", "theme") or "dark")
|
||||||
|
|
||||||
# Reste d'une session précédente mal terminée : DNS détourné vers un
|
# Reste d'une session précédente mal terminée : DNS détourné vers un
|
||||||
# résolveur mort. À faire avant d'ouvrir la fenêtre pour rendre l'accès
|
# résolveur mort. À faire avant d'ouvrir la fenêtre pour rendre l'accès
|
||||||
# au réseau tout de suite.
|
# au réseau tout de suite.
|
||||||
|
|||||||
+71
-7
@@ -5,7 +5,7 @@
|
|||||||
; ──────────────────────────────────────────────
|
; ──────────────────────────────────────────────
|
||||||
|
|
||||||
#define MyAppName "WGSecure"
|
#define MyAppName "WGSecure"
|
||||||
#define MyAppVersion "0.11.0"
|
#define MyAppVersion "0.13.0"
|
||||||
#define MyAppPublisher "WGSecure"
|
#define MyAppPublisher "WGSecure"
|
||||||
#define MyAppExeName "wgsecure.exe"
|
#define MyAppExeName "wgsecure.exe"
|
||||||
|
|
||||||
@@ -35,6 +35,13 @@ PrivilegesRequired=admin
|
|||||||
; remplacement au prochain démarrage si un verrou subsiste malgré tout.
|
; remplacement au prochain démarrage si un verrou subsiste malgré tout.
|
||||||
CloseApplications=yes
|
CloseApplications=yes
|
||||||
CloseApplicationsFilter=*.exe
|
CloseApplicationsFilter=*.exe
|
||||||
|
; Le Restart Manager ferme l'application, il ne la rouvre pas. C'est
|
||||||
|
; volontaire : lors d'une mise à jour automatique, cet installeur est lancé
|
||||||
|
; par le service, donc en LocalSystem depuis la session 0 — ce que le Restart
|
||||||
|
; Manager y relancerait n'aurait ni bureau visible ni les droits de
|
||||||
|
; l'utilisateur. La relance est confiée au service, qui repart une fois les
|
||||||
|
; binaires en place et sait ouvrir un processus dans la session console
|
||||||
|
; (cf. `_relaunch_after_update` dans app/service/helper_service.py).
|
||||||
RestartApplications=no
|
RestartApplications=no
|
||||||
|
|
||||||
[Languages]
|
[Languages]
|
||||||
@@ -71,6 +78,11 @@ Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /quiet /norestart"; S
|
|||||||
; (PrivilegesRequired=admin ci-dessus), donc pas d'UAC supplémentaire non plus.
|
; (PrivilegesRequired=admin ci-dessus), donc pas d'UAC supplémentaire non plus.
|
||||||
; `waituntilterminated` : l'app ne doit pas démarrer avant que wg.exe existe.
|
; `waituntilterminated` : l'app ne doit pas démarrer avant que wg.exe existe.
|
||||||
Filename: "{tmp}\wireguard-installer.exe"; StatusMsg: "Installation de WireGuard…"; Check: WireGuardNeedsInstall; Flags: waituntilterminated
|
Filename: "{tmp}\wireguard-installer.exe"; StatusMsg: "Installation de WireGuard…"; Check: WireGuardNeedsInstall; Flags: waituntilterminated
|
||||||
|
; `skipifsilent` est délibéré : cette entrée ne vaut que pour une installation
|
||||||
|
; lancée à la main, où elle démarre l'application sous le compte qui installe.
|
||||||
|
; La mise à jour automatique, elle, passe par `/VERYSILENT` sous LocalSystem —
|
||||||
|
; l'application y serait ouverte dans la session 0, invisible et en SYSTEM.
|
||||||
|
; C'est le service qui la relance dans la bonne session (cf. RestartApplications).
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
@@ -131,6 +143,31 @@ begin
|
|||||||
ewWaitUntilTerminated, ResultCode);
|
ewWaitUntilTerminated, ResultCode);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// Le processus du service est-il encore vivant ?
|
||||||
|
//
|
||||||
|
// L'attente précédente demandait à RenameFile() de renommer le binaire sur
|
||||||
|
// lui-même, en tenant l'échec pour un verrou. Elle ne mesurait rien : Windows
|
||||||
|
// laisse renommer l'image d'un processus vivant — c'est même ce qui rend
|
||||||
|
// possible le `restartreplace` — et MoveFile() refuse de toute façon une
|
||||||
|
// destination existante, verrou ou pas. La boucle rendait donc toujours la
|
||||||
|
// même réponse, quel que soit l'état réel du service, et la copie partait sur
|
||||||
|
// un fichier encore tenu : elle échouait, `restartreplace` reportait le
|
||||||
|
// remplacement au redémarrage suivant, et l'installeur s'achevait en
|
||||||
|
// annonçant une réussite que rien n'avait accomplie.
|
||||||
|
//
|
||||||
|
// tasklist interroge la liste des processus, et le nom d'image n'est pas
|
||||||
|
// traduit — contrairement aux libellés d'état de sc.exe, qui le sont sur un
|
||||||
|
// Windows français.
|
||||||
|
function HelperStillRunning: Boolean;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := Exec(ExpandConstant('{cmd}'),
|
||||||
|
'/C tasklist /FI "IMAGENAME eq wgsecure-helper.exe" | find /I "wgsecure-helper.exe"',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode)
|
||||||
|
and (ResultCode = 0);
|
||||||
|
end;
|
||||||
|
|
||||||
// Arrêt de tout ce qui tient les fichiers, avant la copie. Le service se
|
// Arrêt de tout ce qui tient les fichiers, avant la copie. Le service se
|
||||||
// remplace lui-même : il doit donc être à l'arrêt, et `sc stop` rend la main
|
// remplace lui-même : il doit donc être à l'arrêt, et `sc stop` rend la main
|
||||||
// avant que le processus n'ait fini de mourir — d'où l'attente qui suit.
|
// avant que le processus n'ait fini de mourir — d'où l'attente qui suit.
|
||||||
@@ -156,24 +193,51 @@ begin
|
|||||||
Exec(ExpandConstant('{sys}\sc.exe'), 'stop WGSecureHelper', '', SW_HIDE,
|
Exec(ExpandConstant('{sys}\sc.exe'), 'stop WGSecureHelper', '', SW_HIDE,
|
||||||
ewWaitUntilTerminated, ResultCode);
|
ewWaitUntilTerminated, ResultCode);
|
||||||
// `sc stop` demande l'arrêt sans l'attendre : copier tout de suite
|
// `sc stop` demande l'arrêt sans l'attendre : copier tout de suite
|
||||||
// retomberait sur un binaire encore verrouillé.
|
// retomberait sur un binaire encore verrouillé. On attend donc la
|
||||||
for I := 1 to 20 do
|
// disparition du *processus*, seule chose qui tienne le verrou — le service
|
||||||
|
// peut être annoncé arrêté alors que son image l'est encore.
|
||||||
|
for I := 1 to 60 do
|
||||||
begin
|
begin
|
||||||
if not FileExists(ExpandConstant('{app}\wgsecure-helper.exe')) then
|
if not HelperStillRunning then
|
||||||
Break;
|
|
||||||
if RenameFile(ExpandConstant('{app}\wgsecure-helper.exe'),
|
|
||||||
ExpandConstant('{app}\wgsecure-helper.exe')) then
|
|
||||||
Break;
|
Break;
|
||||||
Sleep(500);
|
Sleep(500);
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// Signale au service qu'il devra rouvrir l'interface. Uniquement en mode
|
||||||
|
// silencieux : une installation lancée à la main rouvre l'application par son
|
||||||
|
// entrée [Run], sous le compte qui installe, et poser le marqueur ferait
|
||||||
|
// alors deux ouvertures pour une seule installation.
|
||||||
|
//
|
||||||
|
// Écrit ici plutôt que par le service qui a lancé cet installeur : le service
|
||||||
|
// ne sait que l'avoir lancé, cet installeur sait que l'installation a abouti.
|
||||||
|
// Il porte de surcroît déjà la correction, donc la première mise à jour en
|
||||||
|
// profite — un marqueur posé par l'ancien service n'aurait rien produit.
|
||||||
|
//
|
||||||
|
// Avant `InstallHelperService`, qui démarre le service : c'est ce démarrage
|
||||||
|
// qui le relit.
|
||||||
|
procedure MarkRelaunchPending;
|
||||||
|
var
|
||||||
|
Dir: String;
|
||||||
|
begin
|
||||||
|
if not WizardSilent then
|
||||||
|
exit;
|
||||||
|
Dir := ExpandConstant('{commonappdata}\WGSecure');
|
||||||
|
if not ForceDirectories(Dir) then
|
||||||
|
exit; // pas de marqueur, pas de relance
|
||||||
|
SaveStringToFile(Dir + '\relaunch.pending',
|
||||||
|
'{#MyAppVersion}', False);
|
||||||
|
end;
|
||||||
|
|
||||||
procedure CurStepChanged(CurStep: TSetupStep);
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
begin
|
begin
|
||||||
if CurStep = ssInstall then
|
if CurStep = ssInstall then
|
||||||
StopForUpgrade;
|
StopForUpgrade;
|
||||||
if CurStep = ssPostInstall then
|
if CurStep = ssPostInstall then
|
||||||
|
begin
|
||||||
|
MarkRelaunchPending;
|
||||||
InstallHelperService;
|
InstallHelperService;
|
||||||
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
|||||||
Reference in New Issue
Block a user