v0.7.0 : instance unique, handshake ré-affiché, sudoers fiabilisé, À propos refaite

- Instance unique : un second lancement réveille l'instance active.
- Autostart et reconnexion auto activés par défaut (nouvelle installation).
- Page « À propos » refaite en 5 onglets (contenu séparé dans app_info.py).
- Corrige le handshake/RX-TX qui ne s'affichaient plus (wg show sans élévation).
- Corrige le diagnostic sudo trompeur (sondes qui ne matchaient jamais les règles).
- Corrige make setup-sudoers bloqué hors session graphique (pkexec -> sudo), idempotent.
- Corrige le README : Kill Switch inexistant retiré, ordre d'élévation réel documenté.
This commit is contained in:
2026-09-01 19:18:24 +02:00
parent 6251a3ea8e
commit 8c09c02362
13 changed files with 590 additions and 104 deletions
+13
View File
@@ -6,6 +6,19 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
--- ---
## [0.7.0] — 2026-09-01
### Ajouté
- **Instance unique.** Un second lancement (icône du menu, raccourci double-cliqué, autostart concurrent d'un lancement manuel) réveille désormais l'instance déjà active au lieu d'ouvrir une deuxième fenêtre sur le même tunnel. Implémenté via un socket local nommé (`app/core/single_instance.py`), portable Linux/Windows.
- **Lancement automatique et reconnexion automatique activés par défaut** pour une nouvelle installation (reconnexion à 15 s). Une configuration existante n'est jamais modifiée sans action explicite.
- **Page « À propos » refaite** sur le modèle QuiOulé/TuxMarket : cinq onglets (Fonctions, Garanties, Technique, Journal, Auteur), en-tête avec pastilles d'état vivantes (MFA, configuration sudoers). Contenu déplacé dans `app/app_info.py`, séparé de la présentation ; version lue dynamiquement depuis `app.__version__`.
### Corrigé
- **Dernier handshake et compteurs RX/TX qui ne s'affichaient plus.** `wg show` exige les droits root même en lecture seule ; l'appel se faisait sans élévation et échouait silencieusement dès que l'application ne tournait plus en root (le cas normal une fois `make setup-sudoers` en place). Nouvelle fonction `run_privileged_readonly` : élévation silencieuse via `sudo -n` uniquement, jamais de dialogue sur un sondage exécuté toutes les 3 secondes.
- **Diagnostic des droits sudo trompeur.** `make check-privileges` testait chaque commande avec `--version`/`-V`, des appels qui ne correspondent à aucune règle NOPASSWD restreinte par motif (ex. `ip link delete dev *`) : le diagnostic affichait « dialogue à chaque appel » même quand tout était correctement configuré. Les sondes reproduisent maintenant un appel réel couvert par les règles installées.
- **`make setup-sudoers` restait bloqué indéfiniment** hors session graphique (SSH, tmux, terminal sans agent polkit) : `pkexec` attendait une réponse d'un agent d'authentification absent. Remplacé par `sudo`, qui invite directement dans le terminal. La cible est aussi rendue idempotente : une relance ne redemande le mot de passe que si les règles ont changé.
- **README obsolète** : retrait des mentions d'un Kill Switch iptables absent du code actuel (probablement retiré lors d'un refactor sans mise à jour de la doc) ; section sur l'élévation de privilèges corrigée pour refléter l'ordre réel (`sudo -n` → askpass → `pkexec`, et non l'inverse).
## [0.6.0] — 2026-09-01 ## [0.6.0] — 2026-09-01
### Ajouté ### Ajouté
+18 -5
View File
@@ -4,7 +4,7 @@
## ────────────────────────────────────────────── ## ──────────────────────────────────────────────
APP := wgsecure APP := wgsecure
VERSION := 0.6.0 VERSION := 0.7.0
VENV := .venv VENV := .venv
PYTHON := $(VENV)/bin/python3 PYTHON := $(VENV)/bin/python3
PIP := $(VENV)/bin/pip PIP := $(VENV)/bin/pip
@@ -193,12 +193,20 @@ print() if not rows else print(chr(10) + ' ⚠️ → lancez make setup-su
@echo "" @echo ""
# ── Droits sudo wg-quick sans dialogue de mot de passe ─────────────────────── # ── Droits sudo wg-quick sans dialogue de mot de passe ───────────────────────
# `pkexec` (utilisé jusqu'ici) exige un agent d'authentification polkit
# graphique : absent d'un terminal simple (SSH, tmux, session sans agent), il
# reste bloqué en attente d'une réponse qui n'arrive jamais. `sudo` n'a pas ce
# problème : lancé depuis un terminal, il y invite directement le mot de
# passe, avec ou sans polkit. La cible est idempotente : elle recalcule les
# règles à chaque lancement et n'invite pour un mot de passe que si le
# contenu déjà installé diffère (donc jamais lors d'une relance sans
# changement).
setup-sudoers: setup-sudoers:
@echo "" @echo ""
@echo " 🔧 Configuration sudoers (wg-quick + nettoyage réseau, sans mot de passe)" @echo " 🔧 Configuration sudoers (wg-quick + nettoyage réseau, sans mot de passe)"
@$(PYTHON) -c "\ @$(PYTHON) -c "\
import os, subprocess, tempfile, shutil; \ import os, subprocess, tempfile, shutil; \
user = os.environ.get('USER', os.path.basename(os.path.expanduser('~'))); \ user = os.environ.get('SUDO_USER') or os.environ.get('USER') or os.path.basename(os.path.expanduser('~')); \
w = lambda n: shutil.which(n) or ''; \ w = lambda n: shutil.which(n) or ''; \
cmds = [c for c in ( \ cmds = [c for c in ( \
w('wg-quick'), \ w('wg-quick'), \
@@ -209,14 +217,19 @@ cmds = [c for c in ( \
w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \ w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \
) if c]; \ ) if c]; \
rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \ rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \
fd, tmp = tempfile.mkstemp(prefix='wgsecure_sudoers_', dir=os.path.expanduser('~')); \ dest = '/etc/sudoers.d/wgsecure'; \
current = subprocess.run(['sudo', '-n', 'cat', dest], capture_output=True, text=True); \
already_ok = current.returncode == 0 and current.stdout == rules; \
print(' ✅ Déjà configuré pour ' + user + ' — rien à faire.') if already_ok else None; \
import sys; sys.exit(0) if already_ok else None; \
fd, tmp = tempfile.mkstemp(prefix='wgsecure_sudoers_'); \
os.write(fd, rules.encode()); os.close(fd); os.chmod(tmp, 0o600); \ os.write(fd, rules.encode()); os.close(fd); os.chmod(tmp, 0o600); \
chk = subprocess.run(['visudo', '-cqf', tmp], capture_output=True); \ chk = subprocess.run(['visudo', '-cqf', tmp], capture_output=True); \
ok = chk.returncode == 0; \ ok = chk.returncode == 0; \
r = subprocess.run(['pkexec', 'bash', '-c', 'install -m 440 -o root -g root ' + tmp + ' /etc/sudoers.d/wgsecure']) if ok else None; \ r = subprocess.run(['sudo', 'install', '-m', '440', '-o', 'root', '-g', 'root', tmp, dest]) if ok else None; \
os.unlink(tmp); \ os.unlink(tmp); \
print(' ❌ Règles sudoers invalides, rien installé : ' + chk.stderr.decode().strip()) if not ok else \ print(' ❌ Règles sudoers invalides, rien installé : ' + chk.stderr.decode().strip()) if not ok else \
print(' ✅ /etc/sudoers.d/wgsecure configuré — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')" print(' ✅ ' + dest + ' configuré pour ' + user + ' — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')"
@echo "" @echo ""
# ── Réinitialisation du mot de passe administrateur ────────────────────────── # ── Réinitialisation du mot de passe administrateur ──────────────────────────
+38 -18
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS) # 🛡️ WGSecure (WGS)
🚀 v0.6.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard 🚀 v0.7.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP). **WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP).
@@ -20,11 +20,12 @@
| 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` | | 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` |
| 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive | | 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive |
| 🔐 | **MFA TOTP** | Compatible Bitwarden + QR Code | | 🔐 | **MFA TOTP** | Compatible Bitwarden + QR Code |
| 🔒 | **Kill Switch** | Bloque tout trafic hors tunnel via iptables (Linux) |
| 🔍 | **Test DNS leak** | Vérifie que le DNS passe bien par le tunnel | | 🔍 | **Test DNS leak** | Vérifie que le DNS passe bien par le tunnel |
| 🏓 | **Ping en temps réel** | Badge de latence dans l'interface principale | | 🏓 | **Ping en temps réel** | Badge de latence dans l'interface principale |
| 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe | | 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe — activée par défaut (15 s) |
| 📡 | **Test de connexion** | Vérification UDP du serveur avant établissement du tunnel | | 📡 | **Test de connexion** | Handshake WireGuard réel, sans monter le tunnel, avec détection du filtrage sur port source |
| 🚀 | **Démarrage automatique** | Lancement au démarrage du système — activé par défaut |
| 1️⃣ | **Instance unique** | Un second lancement réveille l'instance déjà active plutôt que d'en ouvrir une seconde |
| 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) | | 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) |
--- ---
@@ -110,30 +111,47 @@ python main.py --no-tray
## 🐧 Élévation de privilèges Linux ## 🐧 Élévation de privilèges Linux
`wg-quick` nécessite les droits root pour monter/démonter le tunnel. `wg-quick`, la lecture de l'état du tunnel (`wg show`) et le nettoyage réseau
WGSecure tente dans l'ordre : (interface orpheline, DNS) nécessitent les droits root. WGSecure tente dans
l'ordre :
1. **pkexec** (polkit) — dialogue graphique GNOME/KDE, aucune configuration requise 1. **sudo -n** — silencieux, fonctionne si une règle NOPASSWD est en place
(voir ci-dessous)
2. **sudo -A** avec programme askpass graphique (`ssh-askpass`, etc.) 2. **sudo -A** avec programme askpass graphique (`ssh-askpass`, etc.)
3. **sudo -n** — fonctionne si une règle NOPASSWD est en place 3. **pkexec** (polkit) — dialogue graphique GNOME/KDE ; nécessite un agent
d'authentification actif, absent d'un simple terminal (SSH, tmux)
### Supprimer la demande de mot de passe (optionnel) Le sondage périodique de l'état du tunnel (toutes les 3 s) ne tente que
l'étape 1 : sans règle NOPASSWD, aucun dialogue n'apparaît, le handshake et
les compteurs RX/TX restent simplement vides.
Pour éviter toute invite à chaque connexion, créer une règle sudoers dédiée : ### Supprimer la demande de mot de passe (recommandé)
```bash
make setup-sudoers
```
Invite le mot de passe une fois dans le terminal (via `sudo`, plus fiable que
`pkexec` hors session graphique), puis installe une règle NOPASSWD limitée à
`wg-quick`, `wg`, et au nettoyage d'une interface/DNS orpheline — jamais un
accès root généralisé. Idempotent : une relance ne redemande le mot de passe
que si le contenu a changé. `make check-privileges` affiche l'état courant.
Pour le faire à la main :
```bash ```bash
sudo visudo -f /etc/sudoers.d/wgsecure sudo visudo -f /etc/sudoers.d/wgsecure
``` ```
Ajouter la ligne suivante (remplacer `<utilisateur>` par votre login) :
``` ```
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/wg-quick <utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/wg-quick
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/wg
<utilisateur> ALL=(ALL) NOPASSWD: /usr/sbin/ip link delete dev *
<utilisateur> ALL=(ALL) NOPASSWD: /usr/sbin/resolvconf -d *
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/resolvectl revert *
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/install -D -m 600 -o root -g root * /etc/wireguard/*
``` ```
> ⚠️ Cette règle accorde l'exécution de `wg-quick` sans mot de passe uniquement,
> et non un accès root général.
### Réinitialiser le mot de passe admin oublié ### Réinitialiser le mot de passe admin oublié
```bash ```bash
@@ -151,9 +169,12 @@ WGSecure/
├── Makefile ├── Makefile
├── app/ ├── app/
│ ├── __init__.py # Version │ ├── __init__.py # Version
│ ├── app_info.py # ️ Contenu de la page « À propos »
│ ├── core/ │ ├── core/
│ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt │ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt
│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, kill switch, DNS leak │ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, DNS leak
│ │ ├── wg_handshake.py # 🤝 Sonde de handshake réel, sans monter le tunnel
│ │ ├── single_instance.py # 1️⃣ Garde une seule instance active
│ │ ├── history.py # 🕘 Historique des sessions (sessions.json) │ │ ├── history.py # 🕘 Historique des sessions (sessions.json)
│ │ ├── log.py # 📋 Journal d'événements (events.json) │ │ ├── log.py # 📋 Journal d'événements (events.json)
│ │ └── mfa.py # 🔐 TOTP / QR Code │ │ └── mfa.py # 🔐 TOTP / QR Code
@@ -179,7 +200,6 @@ WGSecure/
| 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) | | 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) |
| 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local | | 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local |
| 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) | | 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) |
| 🔒 Kill Switch | Chaîne iptables dédiée `WGS_KILLSWITCH` — isolée du pare-feu système |
> ⚠️ **Recommandation** : chiffrez votre disque pour protéger le fichier de configuration. > ⚠️ **Recommandation** : chiffrez votre disque pour protéger le fichier de configuration.
@@ -188,4 +208,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 : ![v0.6.0](https://img.shields.io/badge/v0.6.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026 Version : ![v0.7.0](https://img.shields.io/badge/v0.7.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.6.0" __version__ = "0.7.0"
APP_NAME = "WGSecure" APP_NAME = "WGSecure"
APP_SHORT = "WGS" APP_SHORT = "WGS"
+128
View File
@@ -0,0 +1,128 @@
"""Contenu de la page « À propos ».
Source de vérité unique pour les textes de la page : l'onglet Qt
(app/ui/admin_window.py, méthode `_tab_about`) ne fait que les mettre en
forme. La version, elle, vient de `app.__version__` (app/__init__.py), le
seul endroit où elle est bumpée à chaque release — jamais recopiée en dur ici.
"""
import os
from app import __version__
_CHANGELOG_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "CHANGELOG.md"
)
APP_NAME = "WGSecure"
TAGLINE = "Client WireGuard avec surcouche MFA TOTP, pour Linux et Windows"
VERSION = __version__
LICENSE = "Usage personnel"
REPO_URL = "https://git.tuxtech.fr/tuxgyver/WGSecure"
AUTHOR_NAME = "Johnny"
AUTHOR_ROLE = "Auteur"
FEATURES = [
(
"Connexion en un clic",
"Icône dans la barre système, montage/démontage du tunnel WireGuard "
"sans quitter le systray.",
),
(
"Suivi en direct",
"Statut RX/TX, dernier handshake et latence affichés en continu ; "
"graphique de bande passante sur les 30 dernières secondes.",
),
(
"Journal et historique",
"Horodatage de chaque connexion, déconnexion et erreur ; historique "
"des sessions passées (durée, volume transféré, serveur).",
),
(
"Multi-profils",
"Plusieurs configurations WireGuard sauvegardées et basculables, "
"avec import/export de fichiers .conf standards.",
),
(
"Génération de clés",
"Paire de clés Curve25519 générée nativement en Python, sans "
"dépendre du binaire `wg` pour cette étape.",
),
(
"Mode Admin protégé",
"Configuration complète (serveur, DNS, IP, keepalive, MTU) derrière "
"un mot de passe et, en option, une double authentification TOTP.",
),
(
"Diagnostic réseau",
"Test de handshake réel sans monter le tunnel, détection du "
"filtrage sur port source, et test de fuite DNS.",
),
(
"Auto-reconnexion",
"Surveillance du tunnel et reconnexion automatique en cas de "
"coupure, à l'intervalle choisi.",
),
]
GUARANTEES_INTRO = "Ce que l'application garantit, quel que soit le profil configuré :"
GUARANTEES = [
"Le mot de passe du mode Admin n'est jamais stocké en clair : il est "
"haché avec bcrypt (12 tours) avant d'être écrit dans la configuration.",
"La clé privée WireGuard est toujours écrite avec des permissions "
"restrictives (600) — aussi bien dans la configuration locale que dans "
"le fichier installé sous /etc/wireguard.",
"Le test de handshake et le sondage de statut (dernier handshake, "
"RX/TX) n'ouvrent jamais de tunnel et ne modifient aucun fichier : ce "
"sont des lectures ou un simple datagramme UDP jetable.",
"Les commandes système nécessitant les privilèges root sont exécutées "
"une par une, jamais l'application entière : `make setup-sudoers` "
"autorise sans mot de passe uniquement wg-quick, wg, et la suppression "
"d'interface/DNS orpheline — jamais un accès root généralisé.",
"Le sondage périodique de l'état du tunnel (toutes les 3 secondes) "
"n'ouvre jamais de dialogue de mot de passe ou d'UAC : il échoue "
"silencieusement si les privilèges nécessaires ne sont pas déjà "
"accordés.",
"Le test de fuite DNS vérifie que les requêtes sortent bien par le "
"resolver attendu une fois le tunnel monté, plutôt que de supposer la "
"configuration correcte.",
]
TECH_STACK = [
("Interface", "PyQt6"),
("Cryptographie", "cryptography (X25519, ChaCha20-Poly1305, bcrypt)"),
("MFA", "pyotp (TOTP) + qrcode/Pillow pour le QR code"),
("Tunnel", "wg-quick / wg (Linux), WireGuard for Windows"),
("Élévation de privilèges", "sudo NOPASSWD ciblé, repli askpass puis pkexec"),
("Empaquetage", "PyInstaller (Linux/Windows) + Inno Setup (installeur Windows)"),
]
def parse_changelog() -> list[dict]:
"""Lit CHANGELOG.md : titres `## [x.y.z] — date`, sous-titres `### ...`
et puces `- ...` (avec leurs lignes de continuation) uniquement — le
format effectivement utilisé par ce fichier, sans dépendance Markdown.
"""
entries: list[dict] = []
version = None
section = None
try:
with open(_CHANGELOG_PATH, encoding="utf-8") as f:
for raw in f:
line = raw.rstrip("\n")
if line.startswith("## "):
version = {"title": line[3:].strip(), "sections": []}
entries.append(version)
section = None
elif line.startswith("### ") and version is not None:
section = {"name": line[4:].strip(), "items": []}
version["sections"].append(section)
elif line.startswith("- ") and section is not None:
section["items"].append(line[2:].strip())
elif (line.strip() and section is not None and section["items"]
and not line.startswith(("#", "---"))):
# Ligne de continuation d'une puce étalée sur plusieurs lignes.
section["items"][-1] += " " + line.strip()
except FileNotFoundError:
pass
return entries
+8 -4
View File
@@ -36,7 +36,7 @@ _WG_DEFAULT: dict[str, Any] = {
} }
_DEFAULT: dict[str, Any] = { _DEFAULT: dict[str, Any] = {
"version": "0.6.0", "version": "0.7.0",
"admin_password_hash": "", "admin_password_hash": "",
"admin_salt": "", "admin_salt": "",
"mfa_enabled": False, "mfa_enabled": False,
@@ -46,10 +46,10 @@ _DEFAULT: dict[str, Any] = {
"wg": deepcopy(_WG_DEFAULT), "wg": deepcopy(_WG_DEFAULT),
"ui": { "ui": {
"minimize_to_tray": True, "minimize_to_tray": True,
"autostart": False, "autostart": True,
"auto_connect_on_startup": False, "auto_connect_on_startup": False,
"auto_reconnect": False, "auto_reconnect": True,
"reconnect_interval": 30, "reconnect_interval": 15,
}, },
} }
@@ -69,7 +69,11 @@ class Config:
except Exception: except Exception:
self._data = deepcopy(_DEFAULT) self._data = deepcopy(_DEFAULT)
else: else:
# Première exécution : le défaut « autostart activé » n'a d'effet
# que si on l'applique réellement (fichier .desktop / clé de
# registre), sinon la case reste cochée sans rien avoir installé.
self._data = deepcopy(_DEFAULT) self._data = deepcopy(_DEFAULT)
self._apply_autostart(self._data["ui"]["autostart"])
def save(self): def save(self):
os.makedirs(os.path.dirname(self._path), exist_ok=True) os.makedirs(os.path.dirname(self._path), exist_ok=True)
+63
View File
@@ -0,0 +1,63 @@
"""Garantit une seule instance de WGSecure en cours d'exécution.
Avec le lancement automatique au démarrage désormais activé par défaut
([[wg-autostart-reconnect]]), un second lancement manuel (icône du menu,
raccourci double-cliqué) peut facilement se superposer à l'instance déjà
présente en systray — deux fenêtres sur le même tunnel, deux timers de
reconnexion concurrents. Le second lancement doit simplement réveiller le
premier et s'arrêter.
Implémenté via un socket local nommé (QLocalServer/QLocalSocket) : le
mécanisme Qt standard pour ça, portable Linux/Windows sans dépendance
supplémentaire. Le premier lancement écoute ; tout lancement suivant s'y
connecte, signale sa présence, puis quitte.
"""
from PyQt6.QtNetwork import QLocalServer, QLocalSocket
_SERVER_NAME = "wgsecure-single-instance"
class SingleInstanceGuard:
def __init__(self):
self._server: QLocalServer | None = None
self._on_activate = None
def try_acquire(self) -> bool:
"""True si cette instance est la première et doit continuer.
False si une autre tourne déjà : celle-ci lui a signalé sa présence
et doit se terminer sans ouvrir la moindre fenêtre.
"""
probe = QLocalSocket()
probe.connectToServer(_SERVER_NAME)
if probe.waitForConnected(200):
probe.write(b"activate")
probe.waitForBytesWritten(200)
probe.disconnectFromServer()
return False
# Un crash précédent peut laisser le socket enregistré sans qu'aucun
# serveur ne l'écoute : listen() échouerait indéfiniment sinon.
QLocalServer.removeServer(_SERVER_NAME)
self._server = QLocalServer()
self._server.newConnection.connect(self._handle_connection)
self._server.listen(_SERVER_NAME)
return True
def on_activate_requested(self, callback) -> None:
"""`callback` (sans argument) est appelé quand un second lancement
tente de démarrer — à connecter au réaffichage de la fenêtre."""
self._on_activate = callback
def _handle_connection(self) -> None:
sock = self._server.nextPendingConnection()
if sock is None:
return
sock.readyRead.connect(lambda: self._consume(sock))
def _consume(self, sock: QLocalSocket) -> None:
sock.readAll()
if self._on_activate:
self._on_activate()
sock.disconnectFromServer()
+9 -6
View File
@@ -14,6 +14,7 @@ from app.utils.platform_utils import (
is_windows, is_windows,
run_command, run_command,
run_privileged, run_privileged,
run_privileged_readonly,
wg_available, wg_available,
wg_exe, wg_exe,
wg_quick_available, wg_quick_available,
@@ -150,12 +151,14 @@ def write_client_config(cfg: Config) -> tuple[bool, str]:
def _wg_show(name: str) -> str: def _wg_show(name: str) -> str:
"""Sortie de `wg show <iface>`, ou "" si les privilèges manquent. """Sortie de `wg show <iface>`, ou "" si les privilèges manquent.
Attention : sans CAP_NET_ADMIN, `wg show` écrit « Unable to access `wg show` exige CAP_NET_ADMIN (root) pour lire l'état d'une interface,
interface: Operation not permitted » sur stderr **et sort avec le code 0**. même en lecture seule un utilisateur ordinaire reçoit « Unable to
Un simple test `code == 0` conclut donc « déconnecté » pour tout access interface: Operation not permitted ». D'où l'appel via
utilisateur non root c'était le cas de l'application entière. `run_privileged_readonly` : sans lui, le handshake et les compteurs de
trafic restent vides dès que l'application ne tourne plus en root (le cas
normal une fois `make setup-sudoers` en place pour wg-quick).
""" """
code, out, err = run_command([wg_exe(), "show", name]) code, out, err = run_privileged_readonly([wg_exe(), "show", name])
if code != 0 or not out: if code != 0 or not out:
return "" return ""
if "not permitted" in err.lower() or "unable to access" in err.lower(): if "not permitted" in err.lower() or "unable to access" in err.lower():
@@ -402,7 +405,7 @@ def last_handshake_age(name: str) -> int | None:
Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information
est inaccessible (privilèges insuffisants, `wg` absent). est inaccessible (privilèges insuffisants, `wg` absent).
""" """
code, out, _ = run_command([wg_exe(), "show", name, "latest-handshakes"], 10) code, out, _ = run_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10)
if code != 0 or not out.strip(): if code != 0 or not out.strip():
return None return None
newest = 0 newest = 0
+241 -58
View File
@@ -1,3 +1,5 @@
import re
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
@@ -861,69 +863,250 @@ class AdminWindow(QDialog):
# Onglet À propos # Onglet À propos
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _tab_about(self) -> QWidget: def _tab_about(self) -> QWidget:
w = QWidget() """Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket).
w.setStyleSheet(theme.TAB_CSS)
w.setAutoFillBackground(True)
layout = QVBoxLayout(w)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(12)
layout.setContentsMargins(30, 24, 30, 24)
from app.ui.icons import icon_app Le contenu (fonctions, garanties, pile technique, auteur) vit dans
logo_lbl = QLabel() `app/app_info.py`, séparé de sa présentation ici le rendre
logo_lbl.setPixmap(icon_app().pixmap(86, 86)) modifiable sans toucher au Qt. Le journal est lu depuis
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) CHANGELOG.md à l'affichage, jamais recopié.
layout.addWidget(logo_lbl) """
from app import app_info
w, lay = self._dark_page("️ À propos", theme.BANNER["about"])
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
name_lbl = QLabel("WGSecure") lay.addWidget(self._about_header(app_info))
name_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
f = QFont(); f.setPointSize(22); f.setBold(True)
name_lbl.setFont(f)
name_lbl.setStyleSheet("color: white;")
layout.addWidget(name_lbl)
version_lbl = QLabel("Version 0.6.0") sub = QTabWidget()
version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) sub.setStyleSheet(theme.TABBAR_CSS)
version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions")
layout.addWidget(version_lbl) sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties")
sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique")
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal")
sep.setStyleSheet("color: rgba(255,255,255,0.15);") sub.addTab(self._subtab_about_author(app_info), "👤 Auteur")
layout.addWidget(sep) lay.addWidget(sub)
desc_lbl = QLabel(
"Interface graphique WireGuard avec surcouche MFA TOTP.\n"
"Compatible Linux et Windows."
)
desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
desc_lbl.setWordWrap(True)
desc_lbl.setStyleSheet("color: white; font-size: 13px;")
layout.addWidget(desc_lbl)
sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet("color: rgba(255,255,255,0.15);")
layout.addWidget(sep2)
author_lbl = QLabel("Développé par")
author_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
author_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 12px;")
layout.addWidget(author_lbl)
brand_lbl = QLabel("JT-Tools by Johnny")
brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
fb = QFont(); fb.setPointSize(15); fb.setBold(True)
brand_lbl.setFont(fb)
brand_lbl.setStyleSheet("color: #5dade2;")
layout.addWidget(brand_lbl)
date_lbl = QLabel("Septembre 2026")
date_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
date_lbl.setStyleSheet("color: rgba(255,255,255,0.55); font-size: 12px;")
layout.addWidget(date_lbl)
layout.addStretch()
return w return w
# ── En-tête identité : icône, nom, accroche, pastilles d'état ────────
def _about_header(self, info) -> QWidget:
from app.ui.icons import icon_app
box = QWidget()
box.setStyleSheet(f"background: {theme.BG_RAISED};")
v = QVBoxLayout(box)
v.setContentsMargins(20, 16, 20, 14)
v.setSpacing(6)
v.setAlignment(Qt.AlignmentFlag.AlignCenter)
logo = QLabel()
logo.setPixmap(icon_app().pixmap(72, 72))
logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
v.addWidget(logo)
name = QLabel(info.APP_NAME)
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
nf = QFont(); nf.setPointSize(18); nf.setBold(True)
name.setFont(nf)
name.setStyleSheet(f"color: {theme.TEXT};")
v.addWidget(name)
tagline = QLabel(info.TAGLINE)
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
tagline.setWordWrap(True)
tagline.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;")
v.addWidget(tagline)
pills = QWidget()
pills.setStyleSheet("background: transparent;")
ph = QHBoxLayout(pills)
ph.setContentsMargins(0, 4, 0, 0)
ph.setSpacing(8)
ph.setAlignment(Qt.AlignmentFlag.AlignCenter)
for text, color in self._about_pills(info):
pill = QLabel(text)
pill.setStyleSheet(
f"background: {color}; color: white; border-radius: 9px;"
" padding: 3px 10px; font-size: 11px; font-weight: bold;"
)
ph.addWidget(pill)
v.addWidget(pills)
return box
def _about_pills(self, info) -> list[tuple[str, str]]:
"""Pastilles de version/licence, plus l'état réel de MFA et des
droits sudo lu en direct, jamais une valeur supposée."""
from app.utils.platform_utils import (
has_root_privileges, is_windows, privilege_report,
)
pills = [
(f"v{info.VERSION}", theme.ACCENT),
(info.LICENSE, "#5b6b7a"),
]
if self._cfg.mfa_enabled:
pills.append(("🔐 MFA activée", theme.OK_SOLID))
else:
pills.append(("🔐 MFA désactivée", "#5b6b7a"))
if not is_windows():
if has_root_privileges():
pills.append(("⚠️ Exécuté en root", theme.WARN_SOLID))
else:
rows = privilege_report()
if rows and all(ok for _, ok, _ in rows):
pills.append(("✅ sudoers configuré", theme.OK_SOLID))
elif rows:
pills.append(("⚠️ dialogue à chaque appel", theme.WARN_SOLID))
return pills
# ── Sous-onglet : fonctions ───────────────────────────────────────────
def _subtab_about_features(self, info) -> QWidget:
page, v = self._test_page()
area = QScrollArea()
area.setWidgetResizable(True)
area.setStyleSheet(theme.scrollarea_style())
inner = QWidget()
inner.setStyleSheet(f"background: {theme.BG};")
iv = QVBoxLayout(inner)
iv.setContentsMargins(0, 0, 8, 0)
iv.setSpacing(10)
for title, desc in info.FEATURES:
iv.addWidget(self._feature_row(title, desc))
iv.addStretch()
area.setWidget(inner)
v.addWidget(area)
return page
def _feature_row(self, title: str, desc: str) -> QWidget:
row = QWidget()
row.setStyleSheet("background: transparent;")
rv = QVBoxLayout(row)
rv.setContentsMargins(0, 0, 0, 0)
rv.setSpacing(2)
t = QLabel(title)
tf = QFont(); tf.setBold(True)
t.setFont(tf)
t.setStyleSheet(f"color: {theme.TEXT};")
rv.addWidget(t)
d = QLabel(desc)
d.setWordWrap(True)
d.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;")
rv.addWidget(d)
return row
# ── Sous-onglet : garanties ───────────────────────────────────────────
def _subtab_about_guarantees(self, info) -> QWidget:
page, v = self._test_page()
v.addWidget(self._hint(info.GUARANTEES_INTRO))
area = QScrollArea()
area.setWidgetResizable(True)
area.setStyleSheet(theme.scrollarea_style())
inner = QWidget()
inner.setStyleSheet(f"background: {theme.BG};")
iv = QVBoxLayout(inner)
iv.setContentsMargins(0, 0, 8, 0)
iv.setSpacing(8)
for item in info.GUARANTEES:
lbl = QLabel("" + item)
lbl.setWordWrap(True)
lbl.setStyleSheet(f"color: {theme.TEXT}; font-size: 12px;")
iv.addWidget(lbl)
iv.addStretch()
area.setWidget(inner)
v.addWidget(area)
return page
# ── Sous-onglet : pile technique ──────────────────────────────────────
def _subtab_about_tech(self, info) -> QWidget:
page, v = self._test_page()
grp = QGroupBox("Pile technique")
g = QFormLayout(grp)
for layer, tool in info.TECH_STACK:
layer_lbl = QLabel(layer)
lf = QFont(); lf.setBold(True)
layer_lbl.setFont(lf)
tool_lbl = QLabel(tool)
tool_lbl.setWordWrap(True)
tool_lbl.setStyleSheet(f"color: {theme.TEXT_MUTED};")
g.addRow(layer_lbl, tool_lbl)
v.addWidget(grp)
v.addStretch()
return page
# ── Sous-onglet : journal (lu depuis CHANGELOG.md) ────────────────────
def _subtab_about_changelog(self, info) -> QWidget:
page, v = self._test_page()
view = QTextEdit()
view.setReadOnly(True)
view.setStyleSheet(theme.result_view_style("idle", font_size=12))
parts = []
for entry in info.parse_changelog():
parts.append(
f"<p style='color:{theme.ACCENT_LIGHT};font-weight:bold;"
f"font-size:13px;margin-bottom:2px'>{entry['title']}</p>"
)
for section in entry["sections"]:
parts.append(f"<b>{section['name']}</b><ul style='margin-top:2px'>")
for item in section["items"]:
parts.append(
"<li>" + re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", item) + "</li>"
)
parts.append("</ul>")
view.setHtml("".join(parts) or "<i>Aucun changelog trouvé.</i>")
v.addWidget(view)
return page
# ── Sous-onglet : auteur ───────────────────────────────────────────────
def _subtab_about_author(self, info) -> QWidget:
page, v = self._test_page()
v.setAlignment(Qt.AlignmentFlag.AlignCenter)
card = QWidget()
card.setStyleSheet(f"background: {theme.BG_RAISED}; border-radius: 8px;")
cv = QVBoxLayout(card)
cv.setContentsMargins(24, 20, 24, 20)
cv.setSpacing(6)
cv.setAlignment(Qt.AlignmentFlag.AlignCenter)
initial = QLabel(info.AUTHOR_NAME[:1].upper())
initial.setFixedSize(56, 56)
initial.setAlignment(Qt.AlignmentFlag.AlignCenter)
initial.setStyleSheet(
f"background: {theme.ACCENT}; color: white; border-radius: 28px;"
" font-size: 22px; font-weight: bold;"
)
cv.addWidget(initial, alignment=Qt.AlignmentFlag.AlignCenter)
name = QLabel(info.AUTHOR_NAME)
nf = QFont(); nf.setPointSize(14); nf.setBold(True)
name.setFont(nf)
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
cv.addWidget(name)
role = QLabel(info.AUTHOR_ROLE)
role.setAlignment(Qt.AlignmentFlag.AlignCenter)
role.setStyleSheet(f"color: {theme.TEXT_MUTED};")
cv.addWidget(role)
repo = QLineEdit(info.REPO_URL)
repo.setReadOnly(True)
repo.setAlignment(Qt.AlignmentFlag.AlignCenter)
repo.setFixedWidth(320)
cv.addWidget(repo, alignment=Qt.AlignmentFlag.AlignCenter)
disclosure = QLabel(
"Application développée avec l'assistance de l'IA, sous la "
f"direction et la supervision de {info.AUTHOR_NAME}."
)
disclosure.setWordWrap(True)
disclosure.setAlignment(Qt.AlignmentFlag.AlignCenter)
disclosure.setFixedWidth(320)
disclosure.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;")
cv.addWidget(disclosure)
v.addStretch()
v.addWidget(card, alignment=Qt.AlignmentFlag.AlignCenter)
v.addStretch()
return page
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Chargement / sauvegarde # Chargement / sauvegarde
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
+1 -1
View File
@@ -480,7 +480,7 @@ class MainWindow(QMainWindow):
# Auto-reconnect timer # Auto-reconnect timer
if self._cfg.get("ui", "auto_reconnect"): if self._cfg.get("ui", "auto_reconnect"):
interval = int(self._cfg.get("ui", "reconnect_interval") or 30) * 1000 interval = int(self._cfg.get("ui", "reconnect_interval") or 15) * 1000
if not self._reconnect_timer.isActive(): if not self._reconnect_timer.isActive():
self._reconnect_timer.start(interval) self._reconnect_timer.start(interval)
else: else:
+50 -7
View File
@@ -280,6 +280,23 @@ def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]:
) )
def run_privileged_readonly(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
"""Comme `run_privileged`, mais pour un sondage périodique en lecture seule.
N'ouvre jamais de dialogue (askpass/pkexec/UAC) : un sondage lancé toutes
les quelques secondes (état du tunnel, handshake) rendrait l'application
inutilisable si chaque appel pouvait faire apparaître une invite. Ne
fonctionne donc que si le process est déjà root/administrateur, ou si la
commande est couverte par une règle sudo NOPASSWD (`make setup-sudoers`).
Échoue silencieusement sinon, comme un manque de privilèges ordinaire.
"""
if has_root_privileges():
return run_command(cmd, timeout)
if is_windows():
return -1, "", "Élévation requise"
return run_command(["sudo", "-n"] + cmd, timeout, env=_c_locale_env())
def _find_askpass() -> str: def _find_askpass() -> str:
"""Cherche un programme askpass graphique disponible.""" """Cherche un programme askpass graphique disponible."""
candidates = [ candidates = [
@@ -302,13 +319,39 @@ def _find_askpass() -> str:
# Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent. # Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent.
_PRIVILEGED_COMMANDS = ( #
# `make setup-sudoers` installe des règles NOPASSWD restreintes à des motifs
# d'arguments précis (ex. `ip link delete dev *`), jamais à la commande nue.
# Les sondes doivent donc reproduire un appel réel qui matche ce motif :
# tester avec `--version`/`-V` interroge un appel que sudoers n'autorise
# jamais, et affiche « dialogue à chaque appel » même quand tout est
# correctement configuré.
_PRIVILEGE_PROBE_IFACE = "wgsecure-privcheck0"
def _install_probe_cmd() -> list[str]:
"""Reproduit le motif `install -D -m 600 -o root -g root * /etc/wireguard/*`.
Écrit un fichier vide et sans conséquence (`.wgsecure-privcheck`, jamais
lu par wg-quick) : c'est le seul moyen fiable de tester ce motif, `sudo
-n -l` ne le pouvant pas (cf. `_sudo_refused_auth`).
"""
dest = os.path.join(get_wg_config_dir(), ".wgsecure-privcheck")
return ["install", "-D", "-m", "600", "-o", "root", "-g", "root",
"/dev/null", dest]
def _privileged_commands() -> tuple[tuple[list[str], str], ...]:
return (
(["wg-quick", "--help"], "monter / démonter le tunnel"), (["wg-quick", "--help"], "monter / démonter le tunnel"),
(["install", "--version"], "écrire la config dans /etc/wireguard"), (_install_probe_cmd(), "écrire la config dans /etc/wireguard"),
(["ip", "-V"], "supprimer une interface orpheline"), (["ip", "link", "delete", "dev", _PRIVILEGE_PROBE_IFACE],
(["resolvconf", "--version"], "retirer l'entrée DNS du tunnel"), "supprimer une interface orpheline"),
(["resolvectl", "--version"], "purger le DNS systemd-resolved"), (["resolvconf", "-d", _PRIVILEGE_PROBE_IFACE],
) "retirer l'entrée DNS du tunnel"),
(["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE],
"purger le DNS systemd-resolved"),
)
def privilege_report() -> list[tuple[str, bool, str]]: def privilege_report() -> list[tuple[str, bool, str]]:
@@ -322,7 +365,7 @@ def privilege_report() -> list[tuple[str, bool, str]]:
report: list[tuple[str, bool, str]] = [] report: list[tuple[str, bool, str]] = []
if is_windows() or has_root_privileges(): if is_windows() or has_root_privileges():
return report return report
for probe, role in _PRIVILEGED_COMMANDS: for probe, role in _privileged_commands():
if not _sh.which(probe[0]): if not _sh.which(probe[0]):
continue continue
code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env()) code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env())
+18 -2
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
WGSecure (WGS) Interface graphique WireGuard avec MFA WGSecure (WGS) Interface graphique WireGuard avec MFA
Version 0.6.0
""" """
import sys import sys
import argparse import argparse
@@ -9,10 +8,12 @@ import argparse
from PyQt6.QtWidgets import QApplication, QMessageBox, QInputDialog, QLineEdit, QSystemTrayIcon from PyQt6.QtWidgets import QApplication, QMessageBox, QInputDialog, QLineEdit, QSystemTrayIcon
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from app import __version__
from app.core.config import Config from app.core.config import Config
from app.core import dns as dns_util from app.core import dns as dns_util
from app.core import log as conn_log 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.ui.main_window import MainWindow from app.ui.main_window import MainWindow
from app.ui.systray import SystemTray from app.ui.systray import SystemTray
from app.ui import icons from app.ui import icons
@@ -92,8 +93,14 @@ def main():
) )
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("WGSecure") app.setApplicationName("WGSecure")
app.setApplicationVersion("0.6.0") app.setApplicationVersion(__version__)
app.setOrganizationName("WGS") app.setOrganizationName("WGS")
# Une seule instance à la fois : un second lancement réveille la
# première plutôt que d'ouvrir une deuxième fenêtre sur le même tunnel.
guard = SingleInstanceGuard()
if not guard.try_acquire():
sys.exit(0)
# Icône globale multi-tailles — barre des tâches + alt-tab # Icône globale multi-tailles — barre des tâches + alt-tab
app_icon = icons.icon_app() app_icon = icons.icon_app()
app.setWindowIcon(app_icon) app.setWindowIcon(app_icon)
@@ -122,10 +129,12 @@ def main():
win = AdminWindow(config) win = AdminWindow(config)
win.setWindowIcon(icons.icon_admin()) win.setWindowIcon(icons.icon_admin())
win.show() win.show()
guard.on_activate_requested(lambda: _raise_window(win))
sys.exit(app.exec()) sys.exit(app.exec())
# Mode User (défaut) # Mode User (défaut)
window = MainWindow(config) window = MainWindow(config)
guard.on_activate_requested(lambda: _raise_window(window))
# Systray # Systray
if not args.no_tray and QSystemTrayIcon_available(): if not args.no_tray and QSystemTrayIcon_available():
@@ -148,6 +157,13 @@ def main():
sys.exit(app.exec()) sys.exit(app.exec())
def _raise_window(window) -> None:
"""Ramène une fenêtre existante au premier plan (second lancement)."""
window.showNormal()
window.raise_()
window.activateWindow()
def QSystemTrayIcon_available() -> bool: def QSystemTrayIcon_available() -> bool:
from PyQt6.QtWidgets import QSystemTrayIcon from PyQt6.QtWidgets import QSystemTrayIcon
return QSystemTrayIcon.isSystemTrayAvailable() return QSystemTrayIcon.isSystemTrayAvailable()
+1 -1
View File
@@ -4,7 +4,7 @@
; ────────────────────────────────────────────── ; ──────────────────────────────────────────────
#define MyAppName "WGSecure" #define MyAppName "WGSecure"
#define MyAppVersion "0.6.0" #define MyAppVersion "0.7.0"
#define MyAppPublisher "WGSecure" #define MyAppPublisher "WGSecure"
#define MyAppExeName "wgsecure.exe" #define MyAppExeName "wgsecure.exe"