from __future__ import annotations from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QMessageBox, QInputDialog, QLineEdit, QListWidget, QListWidgetItem, QApplication, QComboBox, QSizePolicy, ) from PyQt6.QtCore import Qt, QTimer, QDateTime from PyQt6.QtGui import QFont, QCloseEvent, QColor from app.core.config import Config from app.core import wireguard as wg_core from app.core import log as conn_log from app.core import history as hist from app.ui.mfa_dialog import MFADialog from app.ui.admin_window import AdminWindow from app.ui.bw_graph import BandwidthGraph from app.ui.history_dialog import HistoryDialog from app.ui import icons _DARK = "#1c2833" _DARK2 = "#17202a" _CARD = "#212f3d" _LOG_COLORS = { "success": "#a9dfbf", "error": "#f1948a", "warning": "#f9e79f", "info": "#aed6f1", } _PING_CSS = { "good": "background:#1e8449; color:white;", "medium": "background:#d4ac0d; color:black;", "bad": "background:#922b21; color:white;", "offline": "background:#2e4057; color:#5d6d7e;", } class MainWindow(QMainWindow): def __init__(self, config: Config, parent=None): super().__init__(parent) self._cfg = config self._connecting = False self._connected_since: QDateTime | None = None self._prev_rx: int | None = None self._prev_tx: int | None = None self._auto_reconnect_active = False self._session_id: int | None = None self.setWindowTitle("WGSecure") self.setFixedSize(430, 680) self.setWindowFlags( Qt.WindowType.Window | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMinimizeButtonHint ) self.setWindowIcon(icons.icon_app()) self._build_ui() # Timers self._status_timer = QTimer(self) self._status_timer.timeout.connect(self._refresh_status) self._status_timer.start(3000) self._clock_timer = QTimer(self) self._clock_timer.timeout.connect(self._tick_duration) self._clock_timer.start(1000) self._bw_timer = QTimer(self) self._bw_timer.timeout.connect(self._update_bw) self._bw_timer.start(3000) self._ping_timer = QTimer(self) self._ping_timer.timeout.connect(self._update_ping) self._ping_timer.start(10000) self._reconnect_timer = QTimer(self) self._reconnect_timer.timeout.connect(self._check_reconnect) self._refresh_status() self._reload_log() self._reload_profiles() if self._cfg.get("ui", "auto_connect_on_startup") and self._cfg.configured: QTimer.singleShot(500, self._on_connect) # ------------------------------------------------------------------ # # Construction UI # ------------------------------------------------------------------ # def _build_ui(self): central = QWidget() central.setStyleSheet(f"background: {_DARK};") self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # ── En-tête ────────────────────────────────────────────────── header = QWidget() header.setFixedHeight(72) header.setStyleSheet(f"background: {_DARK2};") h = QHBoxLayout(header) h.setContentsMargins(14, 8, 14, 8) self._icon_label = QLabel() self._icon_label.setFixedSize(40, 40) self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) h.addWidget(self._icon_label) title_col = QVBoxLayout() title_col.setSpacing(1) title_lbl = QLabel("WGSecure") title_lbl.setStyleSheet( "color: white; font-size: 16px; font-weight: bold; background: transparent;" ) self._profile_combo = QComboBox() self._profile_combo.setFixedHeight(22) self._profile_combo.setStyleSheet( "QComboBox { background: #2e4057; color: #aed6f1; border: none;" " border-radius: 3px; font-size: 10px; padding: 0 6px; }" "QComboBox::drop-down { border: none; }" "QComboBox QAbstractItemView { background: #2e4057; color: white; }" ) self._profile_combo.currentTextChanged.connect(self._on_profile_changed) title_col.addWidget(title_lbl) title_col.addWidget(self._profile_combo) h.addLayout(title_col) h.addStretch() # Ping badge self._ping_badge = QLabel("— ms") self._ping_badge.setFixedSize(62, 22) self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) self._ping_badge.setStyleSheet( f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}" ) h.addWidget(self._ping_badge) # Copier clé publique self._btn_copy_key = QPushButton("📋 Clé pub.") self._btn_copy_key.setFixedHeight(26) self._btn_copy_key.setStyleSheet( "QPushButton { background: #2471a3; color: white; border-radius: 5px;" " font-size: 10px; padding: 0 8px; border: none; }" "QPushButton:hover { background: #1a5276; }" "QPushButton:disabled { background: #2e4057; color: #5d6d7e; }" ) self._btn_copy_key.clicked.connect(self._copy_public_key) h.addWidget(self._btn_copy_key) root.addWidget(header) # ── Statut ─────────────────────────────────────────────────── status_area = QWidget() status_area.setFixedHeight(80) status_area.setStyleSheet(f"background: {_CARD};") s = QVBoxLayout(status_area) s.setContentsMargins(10, 6, 10, 6) s.setAlignment(Qt.AlignmentFlag.AlignCenter) self._status_badge = QLabel("● Déconnecté") self._status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) self._status_badge.setStyleSheet( "color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;" ) s.addWidget(self._status_badge) self._duration_label = QLabel("") self._duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._duration_label.setStyleSheet( "color: #5d6d7e; font-size: 11px; background: transparent;" ) s.addWidget(self._duration_label) root.addWidget(status_area) root.addWidget(self._hsep()) # ── Infos ───────────────────────────────────────────────────── info_frame = QFrame() info_frame.setStyleSheet(f"background: {_DARK};") info_frame.setAutoFillBackground(True) il = QVBoxLayout(info_frame) il.setContentsMargins(14, 8, 14, 8) il.setSpacing(5) self._info_labels: dict[str, QLabel] = {} for key, icon_txt, label in [ ("server", "🌐", "Serveur"), ("iface", "🔌", "Interface"), ("addr", "📍", "Adresse IP"), ("mfa", "🔐", "MFA"), ("rx_tx", "↕️ ", "Transfert"), ("handshake", "🤝", "Handshake"), ]: rw = QWidget() rw.setStyleSheet("background: transparent;") rl = QHBoxLayout(rw) rl.setContentsMargins(0, 0, 0, 0) rl.setSpacing(5) rl.addWidget(self._lbl(icon_txt, fixed=20, color="#5d6d7e", size=11)) rl.addWidget(self._lbl(f"{label} :", fixed=100, color="#5d6d7e", size=12)) val = self._lbl("—", color="#aed6f1", size=12) val.setWordWrap(True) self._info_labels[key] = val rl.addWidget(val, 1) il.addWidget(rw) root.addWidget(info_frame) root.addWidget(self._hsep()) # ── Graphique bande passante ─────────────────────────────────── bw_header = self._section_header("📊 Bande passante") root.addWidget(bw_header) self._bw_graph = BandwidthGraph() self._bw_graph.setFixedHeight(75) root.addWidget(self._bw_graph) root.addWidget(self._hsep()) # ── Journal ─────────────────────────────────────────────────── log_hdr = self._section_header("📋 Journal", btn_text="Effacer", btn_slot=self._clear_log) root.addWidget(log_hdr) self._log_list = QListWidget() self._log_list.setFixedHeight(78) self._log_list.setStyleSheet( f"QListWidget {{ background: {_DARK2}; color: #aed6f1;" " border: none; font-size: 10px; font-family: Courier; }}" "QListWidget::item { padding: 1px 6px; }" ) self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) root.addWidget(self._log_list) root.addWidget(self._hsep()) # ── Boutons ─────────────────────────────────────────────────── btn_area = QWidget() btn_area.setStyleSheet(f"background: {_DARK2};") bl = QVBoxLayout(btn_area) bl.setContentsMargins(14, 10, 14, 12) bl.setSpacing(7) self._btn_connect = QPushButton("Se connecter") self._btn_connect.setFixedHeight(42) self._btn_connect.setStyleSheet( "QPushButton { background: #1e8449; color: white; font-size: 14px;" " font-weight: bold; border-radius: 6px; border: none; }" "QPushButton:hover { background: #27ae60; }" "QPushButton:disabled { background: #2e4057; color: #5d6d7e; }" ) self._btn_connect.clicked.connect(self._on_connect) bl.addWidget(self._btn_connect) secondary_row = QHBoxLayout() secondary_row.setSpacing(6) btn_admin = QPushButton("⚙️ Administrateur") btn_admin.setFixedHeight(30) btn_admin.setStyleSheet( "QPushButton { background: #2e4057; color: white; border-radius: 6px;" " font-size: 11px; border: none; }" "QPushButton:hover { background: #3d5166; }" ) btn_admin.clicked.connect(self._open_admin) secondary_row.addWidget(btn_admin) btn_history = QPushButton("📊 Historique") btn_history.setFixedHeight(30) btn_history.setStyleSheet( "QPushButton { background: #2e4057; color: white; border-radius: 6px;" " font-size: 11px; border: none; }" "QPushButton:hover { background: #3d5166; }" ) btn_history.clicked.connect(self._open_history) secondary_row.addWidget(btn_history) bl.addLayout(secondary_row) root.addWidget(btn_area) # ── Helpers UI ─────────────────────────────────────────────────────── def _lbl(self, text: str, fixed: int = 0, color: str = "white", size: int = 12) -> QLabel: lbl = QLabel(text) lbl.setStyleSheet( f"color: {color}; font-size: {size}px; background: transparent;" ) if fixed: lbl.setFixedWidth(fixed) return lbl def _hsep(self) -> QFrame: f = QFrame() f.setFrameShape(QFrame.Shape.HLine) f.setFixedHeight(1) f.setStyleSheet(f"background: {_DARK2};") return f def _section_header(self, title: str, btn_text: str = "", btn_slot=None) -> QWidget: w = QWidget() w.setFixedHeight(24) w.setStyleSheet(f"background: {_DARK2};") hl = QHBoxLayout(w) hl.setContentsMargins(12, 0, 12, 0) t = QLabel(title) t.setStyleSheet("color: #5d6d7e; font-size: 10px; font-weight: bold;" " background: transparent;") hl.addWidget(t) hl.addStretch() if btn_text and btn_slot: b = QPushButton(btn_text) b.setFixedHeight(18) b.setStyleSheet( "QPushButton { background: transparent; color: #5d6d7e;" " font-size: 9px; border: none; padding: 0 4px; }" "QPushButton:hover { color: white; }" ) b.clicked.connect(btn_slot) hl.addWidget(b) return w # ------------------------------------------------------------------ # # Profils # ------------------------------------------------------------------ # def _reload_profiles(self): self._profile_combo.blockSignals(True) self._profile_combo.clear() self._profile_combo.addItem(f"⭐ {self._cfg.active_profile} (actif)") for name in self._cfg.list_profiles(): marker = "✓ " if name == self._cfg.active_profile else " " self._profile_combo.addItem(f"{marker}{name}") self._profile_combo.blockSignals(False) def _on_profile_changed(self, text: str): if "(actif)" in text: return name = text.lstrip("✓ ").strip() if not name or name == self._cfg.active_profile: return if self._cfg.load_profile(name): self._add_log(f"Profil chargé : {name}", "info") self._reload_profiles() self._refresh_status() # ------------------------------------------------------------------ # # Journal # ------------------------------------------------------------------ # def _reload_log(self): self._log_list.clear() for ev in conn_log.get_events(30): self._add_log_item(ev["ts"], ev["msg"], ev["kind"]) self._log_list.scrollToBottom() def _clear_log(self): conn_log.clear() self._log_list.clear() def _add_log(self, msg: str, kind: str = "info"): conn_log.log_event(msg, kind) ts = QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss") self._add_log_item(ts, msg, kind) def _add_log_item(self, ts: str, msg: str, kind: str): color = _LOG_COLORS.get(kind, "#aed6f1") item = QListWidgetItem(f"[{ts}] {msg}") item.setForeground(QColor(color)) self._log_list.addItem(item) self._log_list.scrollToBottom() # ------------------------------------------------------------------ # # Timers # ------------------------------------------------------------------ # def _tick_duration(self): if self._connected_since is None: return secs = self._connected_since.secsTo(QDateTime.currentDateTime()) h, rem = divmod(secs, 3600) m, s = divmod(rem, 60) self._duration_label.setText(f"Connecté depuis {h:02d}h {m:02d}m {s:02d}s") def _update_bw(self): iface = self._cfg.wg.get("interface_name", "wgs0") result = wg_core.get_interface_bytes(iface) if result is None or self._prev_rx is None: self._prev_rx, self._prev_tx = (result or (0, 0)) return rx_now, tx_now = result dt = 3.0 rx_bps = max(0, rx_now - self._prev_rx) / dt tx_bps = max(0, tx_now - self._prev_tx) / dt self._prev_rx, self._prev_tx = rx_now, tx_now if wg_core.is_connected(self._cfg): self._bw_graph.push(rx_bps, tx_bps) else: self._bw_graph.push(0, 0) def _update_ping(self): host = self._cfg.wg.get("server_endpoint", "") if not host or not wg_core.is_connected(self._cfg): self._ping_badge.setText("— ms") self._ping_badge.setStyleSheet( f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}" ) return ms = wg_core.ping_server(host) if ms is None: css = _PING_CSS["bad"] txt = "hors ligne" elif ms < 50: css = _PING_CSS["good"] txt = f"{ms} ms" elif ms < 200: css = _PING_CSS["medium"] txt = f"{ms} ms" else: css = _PING_CSS["bad"] txt = f"{ms} ms" self._ping_badge.setText(txt) self._ping_badge.setStyleSheet( f"border-radius: 11px; font-size: 10px; font-weight: bold; {css}" ) def _check_reconnect(self): if not self._cfg.get("ui", "auto_reconnect"): return try: connected = wg_core.is_connected(self._cfg) except Exception: connected = False if not connected and self._cfg.configured: self._add_log("Auto-reconnexion…", "warning") self._on_connect() # ------------------------------------------------------------------ # # Statut WireGuard # ------------------------------------------------------------------ # def _refresh_status(self): wg = self._cfg.wg srv = wg.get("server_endpoint", "") port = wg.get("server_port", "") self._info_labels["server"].setText(f"{srv}:{port}" if srv else "—") self._info_labels["iface"].setText(wg.get("interface_name", "wgs0")) self._info_labels["addr"].setText(wg.get("client_address", "—")) self._info_labels["mfa"].setText( "Activé ✓" if self._cfg.mfa_enabled else "Désactivé" ) self._btn_copy_key.setEnabled(bool(wg.get("client_public_key", ""))) # Auto-reconnect timer if self._cfg.get("ui", "auto_reconnect"): interval = int(self._cfg.get("ui", "reconnect_interval") or 30) * 1000 if not self._reconnect_timer.isActive(): self._reconnect_timer.start(interval) else: self._reconnect_timer.stop() try: info = wg_core.get_status_info(self._cfg) connected = info["connected"] except Exception: connected = False info = {} if connected: if self._connected_since is None: self._connected_since = QDateTime.currentDateTime() self._status_badge.setText("● Connecté") self._status_badge.setStyleSheet( "color: #27ae60; font-size: 15px; font-weight: bold; background: transparent;" ) self._btn_connect.setText("Se déconnecter") self._btn_connect.setStyleSheet( "QPushButton { background: #922b21; color: white; font-size: 14px;" " font-weight: bold; border-radius: 6px; border: none; }" "QPushButton:hover { background: #c0392b; }" ) self._info_labels["rx_tx"].setText( f"↓ {info.get('rx_bytes','—')} ↑ {info.get('tx_bytes','—')}" ) self._info_labels["handshake"].setText(info.get("last_handshake", "—")) self._duration_label.setStyleSheet( "color: #a9dfbf; font-size: 11px; background: transparent;" ) self.setWindowIcon(icons.icon_connected()) else: if self._connected_since is not None: self._connected_since = None self._duration_label.setText("") self._bw_graph.reset() self._prev_rx = self._prev_tx = None self._status_badge.setText("● Déconnecté") self._status_badge.setStyleSheet( "color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;" ) self._btn_connect.setText("Se connecter") self._btn_connect.setStyleSheet( "QPushButton { background: #1e8449; color: white; font-size: 14px;" " font-weight: bold; border-radius: 6px; border: none; }" "QPushButton:hover { background: #27ae60; }" ) self._info_labels["rx_tx"].setText("—") self._info_labels["handshake"].setText("—") self._duration_label.setStyleSheet( "color: #5d6d7e; font-size: 11px; background: transparent;" ) self.setWindowIcon(icons.icon_disconnected()) pix = (icons.icon_connected() if connected else icons.icon_disconnected()).pixmap(36, 36) self._icon_label.setPixmap(pix) # ------------------------------------------------------------------ # # Actions # ------------------------------------------------------------------ # def _copy_public_key(self): pub = self._cfg.wg.get("client_public_key", "") if pub: QApplication.clipboard().setText(pub) self._add_log("Clé publique copiée dans le presse-papiers", "info") def _on_connect(self): if self._connecting: return try: connected = wg_core.is_connected(self._cfg) except Exception: connected = False if connected: ok, msg = wg_core.disconnect(self._cfg) if ok: if self._session_id is not None: rx = self._prev_rx or 0 tx = self._prev_tx or 0 hist.end_session(self._session_id, rx, tx) self._session_id = None self._add_log("Tunnel WireGuard désactivé", "warning") else: self._add_log(f"Erreur déconnexion : {msg}", "error") QMessageBox.warning(self, "Erreur", msg) else: if not self._cfg.configured: QMessageBox.information( self, "Configuration manquante", "WireGuard n'est pas encore configuré.\n" "Ouvrez le panneau Administrateur." ) return if self._cfg.mfa_enabled: if not self._cfg.mfa_secret: QMessageBox.warning(self, "MFA non configuré", "MFA activé mais aucun secret défini.") return dlg = MFADialog(self._cfg.mfa_secret, self) if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified(): self._add_log("MFA annulé ou échoué", "warning") return self._add_log("Authentification MFA réussie ✓", "success") self._btn_connect.setEnabled(False) self._btn_connect.setText("Connexion…") self._connecting = True QApplication.processEvents() ok, msg = wg_core.connect(self._cfg) self._connecting = False self._btn_connect.setEnabled(True) if ok: self._connected_since = QDateTime.currentDateTime() endpoint = self._cfg.wg.get("server_endpoint", "") profile = self._cfg.active_profile self._session_id = hist.start_session(endpoint, profile) self._add_log(f"Connexion établie → {endpoint}", "success") QTimer.singleShot(5000, self._update_ping) else: self._add_log(f"Erreur : {msg}", "error") QMessageBox.warning(self, "Erreur de connexion", msg) self._refresh_status() def _open_history(self): HistoryDialog(self).exec() def _open_admin(self): if self._cfg.has_admin_password(): pw, ok = QInputDialog.getText( self, "Accès Administrateur", "Mot de passe administrateur :", QLineEdit.EchoMode.Password, ) if not ok: return if not self._cfg.check_admin_password(pw): QMessageBox.warning(self, "Refusé", "Mot de passe incorrect.") return dlg = AdminWindow(self._cfg, self) dlg.exec() self._reload_profiles() self._refresh_status() def closeEvent(self, event: QCloseEvent): if self._cfg.get("ui", "minimize_to_tray"): event.ignore() self.hide() else: self._status_timer.stop() self._clock_timer.stop() self._bw_timer.stop() self._ping_timer.stop() self._reconnect_timer.stop() try: if wg_core.is_connected(self._cfg): wg_core.disconnect(self._cfg) except Exception: pass event.accept()