Some checks failed
Windows project source guards / test (push) Has been cancelled
Пользователь вписал прокси ZapretKVN в Telegram, переключился в «VPN (TUN)», и Telegram перестал подключаться вовсе (16.10.2026): TUN-план не открывал SOCKS/HTTP, а сессия при этом записывала порты по умолчанию, на которых никто не слушал. Решение владельца — прокси в обоих режимах. plan_singbox_runtime добавляет тот же mixed/http, что режим «Прокси», через _ensure_singbox_proxy_runtime_contract, но с listen=127.0.0.1: раздачу прокси в сеть включают режимом «Прокси», в TUN её никто не просил. Порты своей сессии передаются как разрешённые — переподключение их сохраняет. Панель показывает адрес и в TUN (подсказка: только для программ этого компьютера), кнопка «Добавить в Telegram» — только в режиме «Прокси». Тест «TUN не объявляет порты» закреплял прежний контракт и заменён на обратный. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1318 lines
65 KiB
Python
1318 lines
65 KiB
Python
"""Главная панель: статус защиты, сервер, режим, трафик, приложения, маршрутизация.
|
||
|
||
Экран читается сверху вниз так же, как в привычных VPN-клиентах:
|
||
|
||
1. **Статус** — сцена «Этот ПК ⟷ сфера ⟷ сервер» (сфера — кнопка питания),
|
||
крупный заголовок состояния, одна понятная строка пояснения, кнопка и
|
||
строка текущего сервера со сменой сервера;
|
||
2. **Режим работы** — две плитки «VPN (TUN)» / «Прокси» и системный прокси;
|
||
3. **Трафик** — плитки скорости/пинга/объёма и график;
|
||
4. **Приложения** и **Маршрутизация** — рядом на широком окне, друг под
|
||
другом на узком.
|
||
|
||
Живая графика (``dashboard_widgets``) экономит CPU: таймер кадров работает
|
||
только у сцены и только пока идёт подключение или трафик, при этом страница
|
||
видна, а окно не свёрнуто.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import time
|
||
from collections import deque
|
||
from pathlib import Path
|
||
|
||
from PyQt6.QtCore import Qt, QTimer, QEvent, QUrl, pyqtSignal
|
||
from PyQt6.QtGui import QBrush, QColor, QDesktopServices, QFont
|
||
from PyQt6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QBoxLayout,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QSizePolicy,
|
||
QTableWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
from qfluentwidgets.common.font import getFont
|
||
from qfluentwidgets import (
|
||
BodyLabel,
|
||
CaptionLabel,
|
||
CardWidget,
|
||
ComboBox,
|
||
FluentIcon as FIF,
|
||
IndeterminateProgressBar,
|
||
InfoBar,
|
||
InfoBarPosition,
|
||
PushButton,
|
||
StrongBodyLabel,
|
||
SubtitleLabel,
|
||
SwitchButton,
|
||
TableWidget,
|
||
TitleLabel,
|
||
TransparentPushButton,
|
||
)
|
||
|
||
from ..diagnostics.connection_message import connection_message
|
||
from ..profiles.models import AppSettings, Node, RoutingSettings
|
||
from ..platform.windows.proxy_manager import SystemProxyState
|
||
from .base_page import BODY_MARGINS, ScrollablePage
|
||
from .connection_orb import CONNECTED, CONNECTING, ERROR, IDLE
|
||
from .dashboard_widgets import ConnectionScene, FlagBadge, ModeTile, ProcessBars, SignalBars, StatTile, latency_color
|
||
from .detail_page import DetailPage, StackedSection
|
||
from .privacy import masked_endpoint, node_name_text
|
||
from .theme import error_color, graph_down_color, graph_up_color, on_theme_or_accent_changed, positive_color
|
||
from .traffic_graph import DetailTrafficGraphWidget, TrafficGraphWidget
|
||
from .adaptive_buttons import AdaptiveButtonRow
|
||
from .pending_state import PendingValue
|
||
|
||
#: Ширина области прокрутки, ниже которой карточки встают в одну колонку.
|
||
NARROW_WIDTH = 900
|
||
#: Смена TUN/прокси — полный переход (снятие адаптера ~11 с + запуск), поэтому
|
||
#: выбор плитки ждёт факта дольше, чем запись системного прокси.
|
||
MODE_INTENT_TIMEOUT_S = 60.0
|
||
# Компактная плотность: маленький экран или ноутбук с масштабом 125–150 %.
|
||
COMPACT_HEIGHT = 780
|
||
COMPACT_WIDTH = 980
|
||
# Графика тянется под окно плавно: доля высоты области просмотра с пределами.
|
||
# Текст и кнопки не масштабируются (читаемость и масштаб Windows важнее) —
|
||
# у них только две плотности.
|
||
_ORB_SHARE, _ORB_MIN, _ORB_MAX = 0.18, 96, 156
|
||
_GRAPH_SHARE, _GRAPH_MIN, _GRAPH_MAX = 0.16, 64, 170
|
||
_TITLE_PX = {True: 20, False: 24}
|
||
|
||
|
||
def hero_sizes(viewport_height: int) -> tuple[int, int, int]:
|
||
"""(диаметр сферы, поле сцены, высота графика) для высоты области просмотра."""
|
||
orb = int(max(_ORB_MIN, min(_ORB_MAX, viewport_height * _ORB_SHARE)))
|
||
padding = int(18 + (orb - _ORB_MIN) * 0.3)
|
||
graph = int(max(_GRAPH_MIN, min(_GRAPH_MAX, viewport_height * _GRAPH_SHARE)))
|
||
return orb, padding, graph
|
||
|
||
|
||
def _proxy_hint(enabled: bool) -> str:
|
||
"""Подпись переключателя «Системный прокси Windows» — что сейчас идёт через туннель."""
|
||
if enabled:
|
||
return "Браузеры и большинство программ подхватывают прокси сами"
|
||
return "Выключен — через туннель идут только программы с ручной настройкой прокси"
|
||
|
||
|
||
def _format_speed(value_bps: float) -> str:
|
||
value = 0.0 if not math.isfinite(value_bps) else max(0.0, value_bps)
|
||
units = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s", "PB/s", "EB/s"]
|
||
unit_index = 0
|
||
while value >= 1024.0 and unit_index < len(units) - 1:
|
||
value /= 1024.0
|
||
unit_index += 1
|
||
if unit_index == 0:
|
||
return f"{int(value)} {units[unit_index]}"
|
||
return f"{value:.2f} {units[unit_index]}"
|
||
|
||
|
||
def _format_latency(value_ms: int | None) -> str:
|
||
if value_ms is None:
|
||
return "--"
|
||
return f"{value_ms} ms"
|
||
|
||
|
||
def _latency_quality(value_ms: int | None) -> str:
|
||
if value_ms is None:
|
||
return "Нет замера"
|
||
if value_ms < 80:
|
||
return "Отличная связь"
|
||
if value_ms < 150:
|
||
return "Хорошая связь"
|
||
if value_ms < 300:
|
||
return "Средняя связь"
|
||
return "Слабая связь"
|
||
|
||
|
||
def _mode_title(mode: str) -> str:
|
||
mapping = {
|
||
"global": "Глобальный",
|
||
"rule": "Правила",
|
||
"direct": "Прямой",
|
||
}
|
||
return mapping.get(mode, mode.title() or "Неизвестно")
|
||
|
||
|
||
def _card_header(parent: QWidget, title: str) -> tuple[QHBoxLayout, StrongBodyLabel]:
|
||
row = QHBoxLayout()
|
||
row.setSpacing(8)
|
||
label = StrongBodyLabel(title, parent)
|
||
row.addWidget(label)
|
||
row.addStretch(1)
|
||
return row, label
|
||
|
||
|
||
def telegram_socks_url(port: int) -> str:
|
||
"""Ссылка, по которой Telegram сам предлагает подключить локальный SOCKS5."""
|
||
|
||
return f"tg://socks?server=127.0.0.1&port={int(port)}"
|
||
|
||
|
||
class DashboardPage(StackedSection):
|
||
logs_requested = pyqtSignal()
|
||
toggle_connection_requested = pyqtSignal()
|
||
mode_changed = pyqtSignal(str)
|
||
tun_toggled = pyqtSignal(bool)
|
||
proxy_toggled = pyqtSignal(bool)
|
||
servers_requested = pyqtSignal()
|
||
next_node_requested = pyqtSignal()
|
||
configs_requested = pyqtSignal()
|
||
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self.setObjectName("dashboard")
|
||
|
||
self._node_count = 0
|
||
self._selected_node: Node | None = None
|
||
self._connected = False
|
||
self._connection_phase = "idle"
|
||
self._connection_message = "Прокси остановлен"
|
||
self._mode = "rule"
|
||
self._settings = AppSettings()
|
||
self._routing = RoutingSettings()
|
||
self._system_proxy_state: SystemProxyState | None = None
|
||
self._selected_latency_ms: int | None = None
|
||
self._live_rtt_ms: int | None = None
|
||
self._proxy_socks_port = 0
|
||
self._proxy_http_port = 0
|
||
self._transition_busy = False
|
||
self._initializing = False
|
||
self._last_down_bps = 0.0
|
||
self._last_up_bps = 0.0
|
||
self._peak_bps = 0.0
|
||
self._session_proxy_bytes: int | None = None
|
||
self._down_history: deque[float] = deque(maxlen=300)
|
||
self._up_history: deque[float] = deque(maxlen=300)
|
||
self._last_process_stats: list | None = None
|
||
self._connected_since: float | None = None
|
||
self._grid_narrow = False
|
||
self._compact: bool | None = None
|
||
self._proxy_intent: PendingValue[bool] = PendingValue()
|
||
# Выбор плитки VPN/Прокси поверх режима работающей сессии.
|
||
self._tun_intent: PendingValue[bool] = PendingValue(timeout_s=MODE_INTENT_TIMEOUT_S)
|
||
self._active_tun_mode: bool | None = None
|
||
self._in_grid_relayout = False
|
||
self._title_state = IDLE
|
||
|
||
self._refresh_timer = QTimer(self)
|
||
self._refresh_timer.setSingleShot(True)
|
||
self._refresh_timer.setInterval(30)
|
||
self._refresh_timer.timeout.connect(self._do_refresh_dashboard)
|
||
|
||
# ── Root view: main dashboard (scrollable, AC7) ────────
|
||
self._main_page = ScrollablePage()
|
||
self.set_root_page(self._main_page)
|
||
|
||
container = self._main_page.body
|
||
root = self._main_page.body_layout
|
||
root.addWidget(SubtitleLabel("Панель управления", container))
|
||
self._main_page.scroll_area.viewport().installEventFilter(self)
|
||
|
||
self._build_connection_card(container)
|
||
self._build_mode_card(container)
|
||
self._build_traffic_card(container)
|
||
self._build_processes_card(container)
|
||
self._build_routing_card(container)
|
||
|
||
for card in (
|
||
self.connection_card,
|
||
self.mode_card,
|
||
self.traffic_card,
|
||
self.processes_card,
|
||
self.routing_card,
|
||
):
|
||
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||
|
||
grid = QGridLayout()
|
||
grid.setHorizontalSpacing(12)
|
||
grid.setVerticalSpacing(12)
|
||
grid.setColumnStretch(0, 1)
|
||
grid.setColumnStretch(1, 1)
|
||
grid.addWidget(self.processes_card, 0, 0)
|
||
grid.addWidget(self.routing_card, 0, 1)
|
||
self._cards_grid = grid
|
||
|
||
root.addWidget(self.connection_card)
|
||
root.addWidget(self.mode_card)
|
||
root.addWidget(self.traffic_card)
|
||
root.addLayout(grid)
|
||
root.addStretch(1)
|
||
|
||
self._build_sub_pages()
|
||
|
||
on_theme_or_accent_changed(self._color_traffic_captions)
|
||
on_theme_or_accent_changed(self._color_state_title)
|
||
self._color_traffic_captions()
|
||
|
||
self.show_root()
|
||
self._sync_switches()
|
||
self._refresh_dashboard()
|
||
|
||
# ── Построение карточек ───────────────────────────────────
|
||
|
||
def _build_connection_card(self, container: QWidget) -> None:
|
||
card = CardWidget(container)
|
||
self.connection_card = card
|
||
layout = QVBoxLayout(card)
|
||
self._connection_layout = layout
|
||
layout.setContentsMargins(16, 8, 16, 14)
|
||
layout.setSpacing(4)
|
||
|
||
# Слева — состояние текстом, справа — сцена туннеля; сфера в её
|
||
# центре — единственная кнопка подключения (мышь, Tab + Enter/пробел).
|
||
# На узком окне сцена встаёт над текстом (_apply_hero_direction).
|
||
self._hero_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
||
self._hero_layout.setSpacing(16)
|
||
self._hero_text = QWidget(card)
|
||
text = QVBoxLayout(self._hero_text)
|
||
text.setContentsMargins(4, 0, 0, 0)
|
||
text.setSpacing(4)
|
||
self._hero_text_layout = text
|
||
self.connection_state_label = TitleLabel("Не подключено", self._hero_text)
|
||
self.connection_state_label.setWordWrap(True)
|
||
self.connection_uptime_label = CaptionLabel("", self._hero_text)
|
||
self.connection_uptime_label.setWordWrap(True)
|
||
self.connection_uptime_label.hide()
|
||
self.connection_status_label = BodyLabel("", self._hero_text)
|
||
self.connection_status_label.setWordWrap(True)
|
||
self.logs_btn = PushButton(FIF.DOCUMENT, "Открыть логи", self._hero_text)
|
||
self.logs_btn.clicked.connect(self.logs_requested)
|
||
self.logs_btn.hide()
|
||
text.addStretch(1)
|
||
text.addWidget(self.connection_state_label)
|
||
text.addWidget(self.connection_uptime_label)
|
||
text.addWidget(self.connection_status_label)
|
||
text.addSpacing(4)
|
||
text.addWidget(self.logs_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
||
text.addStretch(1)
|
||
self._hero_layout.addWidget(self._hero_text, 2)
|
||
|
||
self.connection_scene = ConnectionScene(card, orb_diameter=_ORB_MAX)
|
||
self.connection_orb = self.connection_scene.orb
|
||
self.connection_orb.clicked.connect(self._on_orb_clicked)
|
||
self._hero_layout.addWidget(self.connection_scene, 3)
|
||
layout.addLayout(self._hero_layout)
|
||
self._hero_stacked: bool | None = None
|
||
self._apply_hero_direction(stacked=False)
|
||
|
||
self.startup_progress = IndeterminateProgressBar(card)
|
||
self.startup_progress.hide()
|
||
layout.addWidget(self.startup_progress)
|
||
|
||
separator = QFrame(card)
|
||
separator.setFrameShape(QFrame.Shape.HLine)
|
||
separator.setFrameShadow(QFrame.Shadow.Plain)
|
||
separator.setEnabled(False)
|
||
layout.addSpacing(8)
|
||
layout.addWidget(separator)
|
||
layout.addSpacing(4)
|
||
|
||
# Строка текущего сервера — «где я», как выбор локации в VPN-клиентах.
|
||
self.server_row = QWidget(card)
|
||
row = QHBoxLayout(self.server_row)
|
||
row.setContentsMargins(0, 4, 0, 0)
|
||
row.setSpacing(12)
|
||
self.server_flag = FlagBadge(self.server_row, diameter=40)
|
||
row.addWidget(self.server_flag)
|
||
info = QVBoxLayout()
|
||
info.setSpacing(0)
|
||
self.server_name_label = StrongBodyLabel("Сервер не выбран", self.server_row)
|
||
self.server_name_label.setWordWrap(True)
|
||
self.connection_target_label = CaptionLabel("", self.server_row)
|
||
self.connection_target_label.setWordWrap(True)
|
||
info.addWidget(self.server_name_label)
|
||
info.addWidget(self.connection_target_label)
|
||
row.addLayout(info, 1)
|
||
self.signal_bars = SignalBars(self.server_row)
|
||
row.addWidget(self.signal_bars, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
self.server_ping_label = CaptionLabel("--", self.server_row)
|
||
self.server_ping_label.setMinimumWidth(48)
|
||
row.addWidget(self.server_ping_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
self.next_server_btn = PushButton(FIF.SYNC, "Следующий", self.server_row)
|
||
self.next_server_btn.setToolTip("Переключиться на следующий сервер из списка")
|
||
# Только мышью: с фокусом клавиатуры пробел/Enter, нажатые «мимо» (окно
|
||
# активно, но человек печатает не сюда), незаметно меняли сервер.
|
||
self.next_server_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
self.next_server_btn.clicked.connect(self.next_node_requested)
|
||
row.addWidget(self.next_server_btn)
|
||
self.servers_btn = PushButton(FIF.MENU, "Все серверы", self.server_row)
|
||
self.servers_btn.clicked.connect(self.servers_requested)
|
||
row.addWidget(self.servers_btn)
|
||
# Не хватает места — подписи кнопок уходят в подсказки, остаются значки.
|
||
self._server_buttons_fit = AdaptiveButtonRow(row, [self.servers_btn, self.next_server_btn])
|
||
layout.addWidget(self.server_row)
|
||
|
||
def _build_mode_card(self, container: QWidget) -> None:
|
||
card = CardWidget(container)
|
||
self.mode_card = card
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(18, 14, 18, 16)
|
||
layout.setSpacing(10)
|
||
header, _title = _card_header(card, "Режим работы")
|
||
self.connection_engine_label = CaptionLabel("", card)
|
||
self.connection_engine_label.setWordWrap(True)
|
||
header.addWidget(self.connection_engine_label)
|
||
layout.addLayout(header)
|
||
|
||
tiles = QHBoxLayout()
|
||
tiles.setSpacing(10)
|
||
self.vpn_tile = ModeTile(
|
||
"vpn", "VPN (TUN)", "Весь трафик компьютера идёт через туннель — любые программы и игры", card
|
||
)
|
||
self.proxy_tile = ModeTile(
|
||
"proxy", "Прокси", "Через туннель идут программы, которые используют прокси: браузеры и большинство приложений", card
|
||
)
|
||
# Слоты — bound-методы, не лямбды: замыкание на self держало бы
|
||
# Python-обёртку страницы (см. motion.FrameGate про циклы владения).
|
||
self.vpn_tile.clicked.connect(self._on_vpn_tile_clicked)
|
||
self.proxy_tile.clicked.connect(self._on_proxy_tile_clicked)
|
||
tiles.addWidget(self.vpn_tile, 1)
|
||
tiles.addWidget(self.proxy_tile, 1)
|
||
layout.addLayout(tiles)
|
||
|
||
self.proxy_options = QWidget(card)
|
||
options = QHBoxLayout(self.proxy_options)
|
||
options.setContentsMargins(4, 2, 0, 0)
|
||
options.setSpacing(12)
|
||
texts = QVBoxLayout()
|
||
texts.setSpacing(0)
|
||
texts.addWidget(BodyLabel("Системный прокси Windows", self.proxy_options))
|
||
self.proxy_hint_label = CaptionLabel(_proxy_hint(False), self.proxy_options)
|
||
self.proxy_hint_label.setWordWrap(True)
|
||
texts.addWidget(self.proxy_hint_label)
|
||
options.addLayout(texts, 1)
|
||
self.proxy_switch = SwitchButton(self.proxy_options)
|
||
self.proxy_switch.setOnText("Вкл")
|
||
self.proxy_switch.setOffText("Выкл")
|
||
self.proxy_switch.checkedChanged.connect(self._on_proxy_toggled)
|
||
options.addWidget(self.proxy_switch, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addWidget(self.proxy_options)
|
||
|
||
# Адрес локального прокси и кнопка, которая отдаёт его Telegram одной
|
||
# ссылкой tg://socks: вписывать адрес руками пользователи не находили,
|
||
# и Telegram молча шёл мимо сервера (16.10.2026).
|
||
self.proxy_address_row = QWidget(card)
|
||
address = QHBoxLayout(self.proxy_address_row)
|
||
address.setContentsMargins(0, 0, 0, 0)
|
||
address.setSpacing(12)
|
||
self.connection_ports_label = CaptionLabel("", self.proxy_address_row)
|
||
self.connection_ports_label.setWordWrap(True)
|
||
self.connection_ports_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
address.addWidget(self.connection_ports_label, 1)
|
||
self.telegram_proxy_btn = PushButton(FIF.SEND, "Добавить в Telegram", self.proxy_address_row)
|
||
self.telegram_proxy_btn.setToolTip(
|
||
"Откроет Telegram и предложит подключить этот прокси (SOCKS5). "
|
||
"Тогда Telegram пойдёт через сервер"
|
||
)
|
||
self.telegram_proxy_btn.clicked.connect(self._add_proxy_to_telegram)
|
||
address.addWidget(self.telegram_proxy_btn, 0, Qt.AlignmentFlag.AlignVCenter)
|
||
self.connection_ports_label.setVisible(False)
|
||
self.proxy_address_row.setVisible(False)
|
||
layout.addWidget(self.proxy_address_row)
|
||
|
||
def _build_traffic_card(self, container: QWidget) -> None:
|
||
card = CardWidget(container)
|
||
self.traffic_card = card
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(18, 14, 18, 16)
|
||
layout.setSpacing(10)
|
||
header, _title = _card_header(card, "Трафик")
|
||
details = TransparentPushButton(FIF.CHEVRON_RIGHT, "Подробнее", card)
|
||
details.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
|
||
details.clicked.connect(self._show_traffic_page)
|
||
header.addWidget(details)
|
||
layout.addLayout(header)
|
||
|
||
self.down_tile = StatTile("● Загрузка", card, value="0 B/s")
|
||
self.up_tile = StatTile("● Отдача", card, value="0 B/s")
|
||
self.ping_tile = StatTile("Пинг", card)
|
||
self.session_tile = StatTile("Через VPN за сессию", card)
|
||
self.traffic_down_label = self.down_tile.value_label
|
||
self.traffic_up_label = self.up_tile.value_label
|
||
self.traffic_rtt_label = self.ping_tile.value_label
|
||
self.traffic_session_label = self.session_tile.value_label
|
||
self.traffic_peak_label = self.session_tile.detail_label
|
||
self._traffic_down_caption = self.down_tile.caption_label
|
||
self._traffic_up_caption = self.up_tile.caption_label
|
||
self.ping_quality_label = self.ping_tile.detail_label
|
||
self.down_tile.detail_label.setText("к вам")
|
||
self.up_tile.detail_label.setText("от вас")
|
||
self._stat_tiles = (self.down_tile, self.up_tile, self.ping_tile, self.session_tile)
|
||
stats = QGridLayout()
|
||
stats.setHorizontalSpacing(16)
|
||
stats.setVerticalSpacing(10)
|
||
self._stats_grid = stats
|
||
self._place_stat_tiles(narrow=False)
|
||
layout.addLayout(stats)
|
||
|
||
self.traffic_graph = TrafficGraphWidget(card)
|
||
self.traffic_graph.clicked.connect(self._show_traffic_page)
|
||
layout.addWidget(self.traffic_graph, 1)
|
||
|
||
def _build_processes_card(self, container: QWidget) -> None:
|
||
card = CardWidget(container)
|
||
self.processes_card = card
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(18, 14, 18, 16)
|
||
layout.setSpacing(8)
|
||
header, _title = _card_header(card, "Приложения")
|
||
self.processes_btn = TransparentPushButton(FIF.CHEVRON_RIGHT, "Все", card)
|
||
self.processes_btn.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
|
||
self.processes_btn.clicked.connect(self._show_proc_page)
|
||
header.addWidget(self.processes_btn)
|
||
layout.addLayout(header)
|
||
self.process_summary_label = CaptionLabel("Статистика появится после подключения", card)
|
||
self.process_summary_label.setWordWrap(True)
|
||
layout.addWidget(self.process_summary_label)
|
||
self.process_bars = ProcessBars(card, rows=4)
|
||
self.process_bars.hide()
|
||
layout.addWidget(self.process_bars)
|
||
layout.addStretch(1)
|
||
|
||
def _build_routing_card(self, container: QWidget) -> None:
|
||
card = CardWidget(container)
|
||
self.routing_card = card
|
||
layout = QVBoxLayout(card)
|
||
layout.setContentsMargins(18, 14, 18, 16)
|
||
layout.setSpacing(6)
|
||
header, _title = _card_header(card, "Маршрутизация")
|
||
self.configs_btn = TransparentPushButton(FIF.CODE, "Конфиг", card)
|
||
self.configs_btn.setToolTip("Открыть редактор конфига — правила и DNS задаются там")
|
||
self.configs_btn.clicked.connect(self.configs_requested)
|
||
header.addWidget(self.configs_btn)
|
||
layout.addLayout(header)
|
||
self.routing_mode_label = BodyLabel("", card)
|
||
self.routing_mode_label.setWordWrap(True)
|
||
layout.addWidget(self.routing_mode_label)
|
||
self.mode_combo = ComboBox(card)
|
||
self.mode_combo.addItem("Глобальный", userData="global")
|
||
self.mode_combo.addItem("Правила", userData="rule")
|
||
self.mode_combo.addItem("Прямой", userData="direct")
|
||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||
layout.addWidget(self.mode_combo)
|
||
self.routing_rules_label = CaptionLabel("", card)
|
||
self.routing_bypass_label = CaptionLabel("", card)
|
||
self.routing_dns_label = CaptionLabel("", card)
|
||
for label in (self.routing_rules_label, self.routing_bypass_label, self.routing_dns_label):
|
||
label.setWordWrap(True)
|
||
layout.addWidget(label)
|
||
layout.addStretch(1)
|
||
|
||
def _build_sub_pages(self) -> None:
|
||
# ── Sub-page: traffic detail (scrollable, AC7) ──
|
||
self._traffic_detail_page = DetailPage(
|
||
"Панель управления",
|
||
"Трафик",
|
||
root_key="dashboard",
|
||
page_key="traffic",
|
||
)
|
||
self.add_sub_page(self._traffic_detail_page)
|
||
|
||
detail_layout = self._traffic_detail_page.content_layout
|
||
|
||
self._detail_graph = DetailTrafficGraphWidget(self._traffic_detail_page)
|
||
detail_layout.addWidget(self._detail_graph, 1)
|
||
|
||
detail_stats_row = QHBoxLayout()
|
||
detail_stats_row.setSpacing(16)
|
||
self._detail_down_label = BodyLabel("Загрузка: 0 B/s", self._traffic_detail_page)
|
||
self._detail_up_label = BodyLabel("Выгрузка: 0 B/s", self._traffic_detail_page)
|
||
self._detail_rtt_label = BodyLabel("RTT: --", self._traffic_detail_page)
|
||
self._detail_peak_label = BodyLabel("Пик: 0 B/s", self._traffic_detail_page)
|
||
detail_stats_row.addWidget(self._detail_down_label)
|
||
detail_stats_row.addWidget(self._detail_up_label)
|
||
detail_stats_row.addWidget(self._detail_rtt_label)
|
||
detail_stats_row.addWidget(self._detail_peak_label)
|
||
detail_stats_row.addStretch(1)
|
||
detail_layout.addLayout(detail_stats_row)
|
||
|
||
# ── Sub-page: process traffic detail (scrollable, AC7) ──
|
||
self._proc_detail_page = DetailPage(
|
||
"Панель управления",
|
||
"Трафик по процессам",
|
||
root_key="dashboard",
|
||
page_key="processes",
|
||
)
|
||
self.add_sub_page(self._proc_detail_page)
|
||
|
||
proc_detail_layout = self._proc_detail_page.content_layout
|
||
|
||
col_tooltips = [
|
||
"Имя исполняемого файла приложения",
|
||
"Текущая скорость загрузки/выгрузки",
|
||
"Объём трафика через VPN (зашифрованный, через прокси-сервер)",
|
||
"Объём трафика напрямую (без VPN, к серверу напрямую)",
|
||
"Активные соединения (всего за сессию)",
|
||
"Домен или IP с наибольшим трафиком",
|
||
"Общий объём трафика за сессию",
|
||
]
|
||
self._proc_detail_table = TableWidget(self._proc_detail_page)
|
||
self._proc_detail_table.setColumnCount(7)
|
||
self._proc_detail_table.setHorizontalHeaderLabels(
|
||
["Процесс", "Скорость", "VPN", "Прямой", "Соединения", "Основной хост", "Всего"]
|
||
)
|
||
header = self._proc_detail_table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
|
||
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch)
|
||
for col in (1, 2, 3, 4, 6):
|
||
header.setSectionResizeMode(col, QHeaderView.ResizeMode.ResizeToContents)
|
||
for col, tip in enumerate(col_tooltips):
|
||
item = self._proc_detail_table.horizontalHeaderItem(col)
|
||
if item:
|
||
item.setToolTip(tip)
|
||
self._proc_detail_table.verticalHeader().setVisible(False)
|
||
self._proc_detail_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self._proc_detail_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self._proc_detail_table.setMinimumHeight(400)
|
||
proc_detail_layout.addWidget(self._proc_detail_table, 1)
|
||
|
||
# ── Adaptive card grid (AC10) ─────────────────────────────
|
||
|
||
def resizeEvent(self, event) -> None:
|
||
super().resizeEvent(event)
|
||
self._update_adaptive_grid()
|
||
|
||
def _place_stat_tiles(self, *, narrow: bool) -> None:
|
||
for tile in self._stat_tiles:
|
||
self._stats_grid.removeWidget(tile)
|
||
columns = 2 if narrow else 4
|
||
for index, tile in enumerate(self._stat_tiles):
|
||
self._stats_grid.addWidget(tile, index // columns, index % columns)
|
||
for column in range(4):
|
||
self._stats_grid.setColumnStretch(column, 1 if column < columns else 0)
|
||
|
||
def _apply_hero_size(self, viewport_height: int) -> None:
|
||
"""Сфера, сцена и график плавно следуют высоте окна."""
|
||
orb, padding, graph = hero_sizes(viewport_height)
|
||
if abs(orb - self.connection_orb.width()) >= 2:
|
||
self.connection_scene.set_orb_diameter(orb, padding)
|
||
if abs(graph - self.traffic_graph.maximumHeight()) >= 2:
|
||
self.traffic_graph.setFixedHeight(graph)
|
||
|
||
def _apply_density(self, compact: bool) -> None:
|
||
"""Размеры под экран: на маленьком всё меньше, чтобы главное влезало.
|
||
|
||
Меняются только размеры и поля существующих виджетов.
|
||
"""
|
||
self.connection_state_label.setFont(getFont(_TITLE_PX[compact], QFont.Weight.DemiBold))
|
||
self._connection_layout.setContentsMargins(*((12, 4, 12, 10) if compact else (16, 8, 16, 14)))
|
||
self.server_flag.set_diameter(32 if compact else 40)
|
||
for tile in (self.vpn_tile, self.proxy_tile):
|
||
tile.set_compact(compact)
|
||
body = self._main_page.body_layout
|
||
body.setContentsMargins(*((16, 12, 16, 12) if compact else BODY_MARGINS))
|
||
body.setSpacing(8 if compact else 12)
|
||
|
||
def _apply_hero_direction(self, *, stacked: bool) -> None:
|
||
"""Широко: текст слева, сцена справа. Узко: сцена сверху, текст по центру."""
|
||
if stacked == self._hero_stacked:
|
||
return
|
||
self._hero_stacked = stacked
|
||
self._hero_layout.setDirection(
|
||
QBoxLayout.Direction.BottomToTop if stacked else QBoxLayout.Direction.LeftToRight
|
||
)
|
||
align = Qt.AlignmentFlag.AlignHCenter if stacked else Qt.AlignmentFlag.AlignLeft
|
||
for label in (self.connection_state_label, self.connection_uptime_label, self.connection_status_label):
|
||
label.setAlignment(align | Qt.AlignmentFlag.AlignVCenter)
|
||
self._hero_text_layout.setAlignment(self.logs_btn, align)
|
||
|
||
def _update_adaptive_grid(self) -> None:
|
||
"""На узком окне (< 900 px) карточки встают в одну колонку.
|
||
|
||
«Маршрутизация» уходит во вторую строку под «Приложения», плитки
|
||
трафика — в сетку 2×2. Виджеты только переставляются, не пересоздаются.
|
||
На маленьком экране (``COMPACT_*``) панель ещё и уменьшается.
|
||
"""
|
||
if self._in_grid_relayout:
|
||
return
|
||
viewport = self._main_page.scroll_area.viewport()
|
||
self._apply_hero_size(viewport.height())
|
||
compact = viewport.height() < COMPACT_HEIGHT or viewport.width() < COMPACT_WIDTH
|
||
if compact != self._compact:
|
||
self._compact = compact
|
||
self._apply_density(compact)
|
||
narrow = viewport.width() < NARROW_WIDTH
|
||
self._apply_hero_direction(stacked=narrow)
|
||
if narrow == self._grid_narrow:
|
||
return
|
||
self._in_grid_relayout = True
|
||
try:
|
||
self._grid_narrow = narrow
|
||
self._cards_grid.removeWidget(self.routing_card)
|
||
self._cards_grid.removeWidget(self.processes_card)
|
||
if narrow:
|
||
self._cards_grid.addWidget(self.processes_card, 0, 0, 1, 2)
|
||
self._cards_grid.addWidget(self.routing_card, 1, 0, 1, 2)
|
||
else:
|
||
self._cards_grid.addWidget(self.processes_card, 0, 0)
|
||
self._cards_grid.addWidget(self.routing_card, 0, 1)
|
||
self._place_stat_tiles(narrow=narrow)
|
||
finally:
|
||
self._in_grid_relayout = False
|
||
|
||
def eventFilter(self, obj, event):
|
||
if obj is self._main_page.scroll_area.viewport() and event.type() == QEvent.Type.Resize:
|
||
QTimer.singleShot(0, self._update_adaptive_grid)
|
||
return super().eventFilter(obj, event)
|
||
|
||
def set_initializing(self, initializing: bool):
|
||
self._initializing = initializing
|
||
self.startup_progress.setVisible(initializing)
|
||
if initializing:
|
||
self.startup_progress.start()
|
||
else:
|
||
self.startup_progress.stop()
|
||
self._refresh_dashboard()
|
||
|
||
# ── Public API ────────────────────────────────────────────
|
||
|
||
def set_nodes(self, nodes: list[Node], selected_node_id: str | None) -> None:
|
||
self._node_count = len(nodes)
|
||
self._selected_node = next(
|
||
(node for node in nodes if selected_node_id and node.id == selected_node_id),
|
||
nodes[0] if nodes else None,
|
||
)
|
||
self._refresh_dashboard()
|
||
|
||
def set_selected_node(self, node: Node | None) -> None:
|
||
self._selected_node = node
|
||
self._refresh_dashboard()
|
||
|
||
def set_connection(self, connected: bool) -> None:
|
||
if connected and not self._connected:
|
||
self._connected_since = time.monotonic()
|
||
elif not connected:
|
||
self._connected_since = None
|
||
self._connected = connected
|
||
if not connected:
|
||
self._last_down_bps = 0.0
|
||
self._last_up_bps = 0.0
|
||
self._live_rtt_ms = None
|
||
self._peak_bps = 0.0
|
||
self._session_proxy_bytes = None
|
||
self._down_history.clear()
|
||
self._up_history.clear()
|
||
self.traffic_graph.clear_data()
|
||
self.connection_scene.set_traffic(0.0, 0.0)
|
||
self._clear_process_tables()
|
||
self._last_process_stats = None
|
||
self._show_process_summary("Статистика появится после подключения", [])
|
||
if self._connection_phase == "running":
|
||
self._connection_phase = "idle"
|
||
self._connection_message = self._default_connection_message()
|
||
elif self._connection_phase == "idle":
|
||
self._connection_phase = "running"
|
||
self._connection_message = self._default_connection_message()
|
||
self._refresh_dashboard()
|
||
|
||
def set_runtime_status(self, phase: str, message: str) -> None:
|
||
normalized = (phase or "idle").strip().lower()
|
||
if normalized not in {"idle", "starting", "running", "error"}:
|
||
normalized = "idle"
|
||
self._connection_phase = normalized
|
||
self._connection_message = connection_message(message) or self._default_connection_message()
|
||
self._refresh_dashboard()
|
||
|
||
def set_mode(self, mode: str) -> None:
|
||
self._mode = mode
|
||
self._routing.mode = mode
|
||
self.mode_combo.blockSignals(True)
|
||
for index in range(self.mode_combo.count()):
|
||
if self.mode_combo.itemData(index) == mode:
|
||
self.mode_combo.setCurrentIndex(index)
|
||
break
|
||
self.mode_combo.blockSignals(False)
|
||
self._refresh_dashboard()
|
||
|
||
def set_proxy_ports(self, socks_port: int, http_port: int) -> None:
|
||
self._proxy_socks_port = max(0, int(socks_port))
|
||
self._proxy_http_port = max(0, int(http_port))
|
||
self._refresh_dashboard()
|
||
|
||
def set_tun_mode(self, enabled: bool) -> None:
|
||
self._settings.tun_mode = enabled
|
||
if self._connection_phase in {"idle", "running"}:
|
||
self._connection_message = self._default_connection_message()
|
||
self._sync_switches()
|
||
self._refresh_dashboard()
|
||
|
||
def set_settings_snapshot(self, settings: AppSettings) -> None:
|
||
self._settings = settings
|
||
if self._connection_phase in {"idle", "running"}:
|
||
self._connection_message = self._default_connection_message()
|
||
self._sync_switches()
|
||
self._refresh_dashboard()
|
||
|
||
def set_system_proxy_state(self, state: SystemProxyState | None) -> None:
|
||
"""Реальное состояние системного прокси Windows (снимок из реестра).
|
||
|
||
На не-Windows (``supported=False``) переключатель по-прежнему отражает
|
||
сохранённый флаг ``enable_system_proxy``.
|
||
"""
|
||
self._system_proxy_state = state
|
||
self._sync_switches()
|
||
self._refresh_dashboard()
|
||
|
||
def set_routing_snapshot(self, routing: RoutingSettings) -> None:
|
||
self._routing = routing
|
||
self.set_mode(routing.mode)
|
||
|
||
def set_selected_latency(self, value: int | None) -> None:
|
||
self._selected_latency_ms = value
|
||
if self._selected_node is not None:
|
||
self._selected_node.ping_ms = value
|
||
self._refresh_dashboard()
|
||
|
||
def set_live_metrics(self, down_bps: float, up_bps: float, latency_ms: int | None) -> None:
|
||
self._last_down_bps = max(0.0, down_bps)
|
||
self._last_up_bps = max(0.0, up_bps)
|
||
self._live_rtt_ms = latency_ms
|
||
self._peak_bps = max(self._peak_bps, self._last_down_bps, self._last_up_bps)
|
||
self._down_history.append(self._last_down_bps)
|
||
self._up_history.append(self._last_up_bps)
|
||
self.traffic_graph.add_point(self._last_down_bps, self._last_up_bps)
|
||
self.connection_scene.set_traffic(self._last_down_bps, self._last_up_bps)
|
||
if self._stack.currentWidget() is self._traffic_detail_page:
|
||
self._detail_graph.add_point(self._last_down_bps, self._last_up_bps)
|
||
self._refresh_dashboard()
|
||
|
||
def set_process_stats(self, stats: list | None) -> None:
|
||
if stats is None:
|
||
self._last_process_stats = None
|
||
self._session_proxy_bytes = None
|
||
self._clear_process_tables()
|
||
self._show_process_summary("Статистика по приложениям недоступна для этого режима", [])
|
||
self._refresh_dashboard()
|
||
return
|
||
self._last_process_stats = list(stats)
|
||
# Счётчики процессов накопительные за сессию — сумма не теряет
|
||
# выборки, пропущенные, пока панель была скрыта.
|
||
self._session_proxy_bytes = sum(max(0, int(ps.proxy_bytes)) for ps in stats)
|
||
ranked = sorted(stats, key=lambda ps: ps.upload + ps.download, reverse=True)
|
||
top_total = max((ps.upload + ps.download for ps in ranked), default=0)
|
||
items = [
|
||
(ps.exe, self._format_bytes(ps.upload + ps.download),
|
||
(ps.upload + ps.download) / top_total if top_total > 0 else 0.0)
|
||
for ps in ranked[: self.process_bars.rows()]
|
||
]
|
||
if stats:
|
||
summary = f"Приложений с трафиком: {len(stats)}"
|
||
else:
|
||
summary = "Активных соединений пока нет"
|
||
self._show_process_summary(summary, items)
|
||
if self._stack.currentWidget() is self._proc_detail_page:
|
||
self._apply_process_stats_to_table(self._proc_detail_table, stats)
|
||
self._refresh_dashboard()
|
||
|
||
def set_transition_busy(self, busy: bool) -> None:
|
||
if self._transition_busy and not busy:
|
||
# Координатор снимает «занято» только в конце: переход выполнен,
|
||
# отменён или не удался. Ждать больше нечего — дальше показываем
|
||
# факт (режим сессии), а не выбор с «…» до таймаута.
|
||
self._tun_intent.clear()
|
||
self._transition_busy = busy
|
||
self._sync_switches()
|
||
self._refresh_dashboard()
|
||
|
||
def set_active_tun_mode(self, tun: bool | None) -> None:
|
||
"""Режим работающей сессии (``None`` — сессии нет); приходит после
|
||
каждого перехода и смены подключения."""
|
||
self._active_tun_mode = None if tun is None else bool(tun)
|
||
self._sync_switches()
|
||
|
||
@staticmethod
|
||
def _format_bytes(b: int) -> str:
|
||
value = float(max(0, int(b)))
|
||
units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
|
||
unit_idx = 0
|
||
while value >= 1024.0 and unit_idx < len(units) - 1:
|
||
value /= 1024.0
|
||
unit_idx += 1
|
||
|
||
if unit_idx == 0:
|
||
return f"{int(value)} {units[unit_idx]}"
|
||
if unit_idx <= 2:
|
||
return f"{value:.1f} {units[unit_idx]}"
|
||
return f"{value:.2f} {units[unit_idx]}"
|
||
|
||
# ── Refresh logic ─────────────────────────────────────────
|
||
|
||
def _refresh_dashboard(self) -> None:
|
||
if not self._refresh_timer.isActive():
|
||
self._refresh_timer.start()
|
||
|
||
def _do_refresh_dashboard(self) -> None:
|
||
self._refresh_connection_card()
|
||
self._refresh_mode_card()
|
||
self._refresh_traffic_card()
|
||
self._refresh_routing_card()
|
||
self._apply_interaction_state()
|
||
if self._stack.currentWidget() is self._traffic_detail_page:
|
||
self._refresh_detail_stats()
|
||
|
||
def _refresh_connection_card(self) -> None:
|
||
self.connection_state_label.setText(self._headline())
|
||
status = self._status_line()
|
||
self.connection_status_label.setText(status)
|
||
self.connection_status_label.setVisible(bool(status))
|
||
self.logs_btn.setVisible(self._connection_phase == "error")
|
||
orb_state = self._orb_state()
|
||
self.connection_orb.set_state(orb_state)
|
||
self.connection_orb.set_action_text("Подготовка…" if self._initializing else self._toggle_action_text())
|
||
self.connection_scene.set_state(orb_state)
|
||
if orb_state != self._title_state:
|
||
self._title_state = orb_state
|
||
self._color_state_title()
|
||
uptime = self._uptime_text()
|
||
self.connection_uptime_label.setText(uptime)
|
||
self.connection_uptime_label.setVisible(bool(uptime))
|
||
|
||
node = self._selected_node
|
||
country = (node.country_code or node.country_override) if node is not None else ""
|
||
self.server_name_label.setText(self._server_title())
|
||
self.connection_target_label.setText(self._selected_node_summary())
|
||
self.server_flag.set_country(country)
|
||
self.connection_scene.set_server(node_name_text(node) if node is not None else "Сервер", country)
|
||
latency = self._effective_latency()
|
||
self.signal_bars.set_latency(latency)
|
||
self.server_ping_label.setText(_format_latency(latency))
|
||
|
||
def _refresh_mode_card(self) -> None:
|
||
core = self._settings.tun_engine if self._settings.tun_mode else self._settings.proxy_engine
|
||
self.connection_engine_label.setText(f"Ядро: {core}")
|
||
self.connection_engine_label.setToolTip(self._route_engine_label())
|
||
self.proxy_options.setVisible(not self._settings.tun_mode)
|
||
ports_text = self._proxy_ports_text()
|
||
self.connection_ports_label.setText(ports_text)
|
||
self.connection_ports_label.setToolTip(self._proxy_ports_tooltip())
|
||
self.connection_ports_label.setVisible(bool(ports_text))
|
||
self.proxy_address_row.setVisible(bool(ports_text))
|
||
# В TUN Telegram идёт через сервер сам; кнопка нужна режиму «Прокси».
|
||
self.telegram_proxy_btn.setVisible(bool(ports_text) and not self._settings.tun_mode)
|
||
|
||
def _color_state_title(self, *_args) -> None:
|
||
"""Заголовок состояния в цвет сферы: зелёный — есть защита, красный — ошибка."""
|
||
state = self._title_state
|
||
if state == CONNECTED:
|
||
color = positive_color()
|
||
self.connection_state_label.setTextColor(color, color)
|
||
elif state == ERROR:
|
||
color = error_color()
|
||
self.connection_state_label.setTextColor(color, color)
|
||
else:
|
||
self.connection_state_label.setTextColor(QColor(0, 0, 0), QColor(255, 255, 255))
|
||
|
||
def _orb_state(self) -> str:
|
||
if self._initializing or self._transition_busy or self._connection_phase == "starting":
|
||
return CONNECTING
|
||
if self._connection_phase == "error":
|
||
return ERROR
|
||
if self._connected:
|
||
return CONNECTED
|
||
return IDLE
|
||
|
||
def _uptime_text(self) -> str:
|
||
if not self._connected or self._connected_since is None:
|
||
return ""
|
||
seconds = int(time.monotonic() - self._connected_since)
|
||
hours, rest = divmod(seconds, 3600)
|
||
return f"В сети {hours:d}:{rest // 60:02d}:{rest % 60:02d}"
|
||
|
||
def _on_orb_clicked(self) -> None:
|
||
# Сфера сама выключена, пока идёт подключение (_apply_interaction_state).
|
||
self.toggle_connection_requested.emit()
|
||
|
||
def _refresh_traffic_card(self) -> None:
|
||
self.traffic_down_label.setText(_format_speed(self._last_down_bps))
|
||
self.traffic_up_label.setText(_format_speed(self._last_up_bps))
|
||
latency = self._effective_latency()
|
||
self.traffic_rtt_label.setText(_format_latency(latency))
|
||
self.ping_quality_label.setText(_latency_quality(latency))
|
||
self._set_quality_color(latency_color(latency))
|
||
if self._session_proxy_bytes is None:
|
||
self.traffic_session_label.setText("--")
|
||
else:
|
||
self.traffic_session_label.setText(self._format_bytes(self._session_proxy_bytes))
|
||
self.traffic_peak_label.setText(f"Пик: {_format_speed(self._peak_bps)}")
|
||
if not self._connected:
|
||
self.traffic_graph.set_placeholder("График появится после подключения")
|
||
else:
|
||
self.traffic_graph.set_placeholder("Ждём первые данные…")
|
||
|
||
def _color_traffic_captions(self, *_args) -> None:
|
||
for caption, color in ((self._traffic_down_caption, graph_down_color()),
|
||
(self._traffic_up_caption, graph_up_color())):
|
||
caption.setTextColor(color, color)
|
||
self._quality_color_name = None # тема сменилась — цвет применить заново
|
||
self._set_quality_color(latency_color(self._effective_latency()))
|
||
|
||
def _set_quality_color(self, color: QColor) -> None:
|
||
# setTextColor у меток qfluentwidgets переустанавливает стиль;
|
||
# при обновлении раз в секунду зовём только при реальной смене цвета.
|
||
name = color.name(QColor.NameFormat.HexArgb)
|
||
if name == getattr(self, "_quality_color_name", None):
|
||
return
|
||
self._quality_color_name = name
|
||
self.ping_quality_label.setTextColor(color, color)
|
||
|
||
def _show_process_summary(self, summary: str, items: list[tuple[str, str, float]]) -> None:
|
||
self.process_summary_label.setText(summary)
|
||
self.process_bars.set_items(items)
|
||
self.process_bars.setVisible(bool(items))
|
||
|
||
def _refresh_routing_card(self) -> None:
|
||
self.configs_btn.setVisible(not self._is_tun2socks_mode())
|
||
if not self._settings.tun_mode:
|
||
core = "sing-box" if self._is_singbox_proxy_mode() else "Xray"
|
||
self.routing_mode_label.setText(f"Правила из конфига {core}")
|
||
self.routing_rules_label.setText(
|
||
"Правила работают для трафика, который пришёл в прокси; что не попало в прокси — идёт напрямую."
|
||
)
|
||
if self._settings.enable_system_proxy:
|
||
self.routing_bypass_label.setText(
|
||
"Системный прокси Windows включён — браузеры и большинство программ идут через него."
|
||
)
|
||
else:
|
||
self.routing_bypass_label.setText(
|
||
"Системный прокси выключен — программы нужно настроить на прокси вручную."
|
||
)
|
||
self.routing_dns_label.setText("DNS и правила меняются в редакторе конфига.")
|
||
return
|
||
if self._is_xray_tun_mode():
|
||
self.routing_mode_label.setText("Правила из конфига Xray (экспериментальный TUN)")
|
||
self.routing_rules_label.setText(
|
||
"Весь трафик системы попадает в Xray — правила по процессам и путям работают для всех программ."
|
||
)
|
||
self.routing_bypass_label.setText(
|
||
"Системный прокси Windows не используется; статистика по приложениям скромнее, чем у sing-box."
|
||
)
|
||
self.routing_dns_label.setText("DNS и правила меняются в редакторе конфига.")
|
||
return
|
||
if self._settings.tun_engine == "singbox":
|
||
self.routing_mode_label.setText("Правила из конфига sing-box")
|
||
self.routing_rules_label.setText(
|
||
"Перехватывается весь трафик системы — правила по программам, доменам и IP работают полностью."
|
||
)
|
||
self.routing_bypass_label.setText(
|
||
"Серверы xhttp и Hysteria 2 автоматически обслуживаются вспомогательными ядрами."
|
||
)
|
||
self.routing_dns_label.setText("DNS и правила меняются в редакторе конфига.")
|
||
return
|
||
self.routing_mode_label.setText(f"Режим: {_mode_title(self._routing.mode)}")
|
||
self.routing_rules_label.setText(
|
||
f"Напрямую: {len(self._routing.direct_domains)} Через VPN: {len(self._routing.proxy_domains)} "
|
||
f"Блок: {len(self._routing.block_domains)}"
|
||
)
|
||
bypass = "включён" if self._routing.bypass_lan else "выключен"
|
||
self.routing_bypass_label.setText(f"Обход локальной сети: {bypass}")
|
||
self.routing_dns_label.setText(f"DNS: {self._routing.dns_mode.title()}")
|
||
|
||
def _refresh_detail_stats(self) -> None:
|
||
self._detail_down_label.setText(f"Загрузка: {_format_speed(self._last_down_bps)}")
|
||
self._detail_up_label.setText(f"Выгрузка: {_format_speed(self._last_up_bps)}")
|
||
self._detail_rtt_label.setText(f"RTT: {_format_latency(self._effective_latency())}")
|
||
self._detail_peak_label.setText(f"Пик: {_format_speed(self._peak_bps)}")
|
||
|
||
# ── Traffic subpage navigation ────────────────────────────
|
||
|
||
def _show_traffic_page(self) -> None:
|
||
"""Switch to the traffic detail subpage."""
|
||
self._detail_graph.set_data(self._down_history, self._up_history)
|
||
self._refresh_detail_stats()
|
||
self.show_sub_page(self._traffic_detail_page)
|
||
|
||
def _show_main_page(self) -> None:
|
||
"""Switch back to the main dashboard."""
|
||
self.show_root()
|
||
|
||
# ── Process subpage navigation ──────────────────────────
|
||
|
||
def _show_proc_page(self) -> None:
|
||
self._update_proc_detail_table()
|
||
self.show_sub_page(self._proc_detail_page)
|
||
|
||
def _update_proc_detail_table(self) -> None:
|
||
"""Refresh detail table from the latest cached process stats."""
|
||
self._apply_process_stats_to_table(self._proc_detail_table, self._last_process_stats or [])
|
||
|
||
def _clear_process_tables(self) -> None:
|
||
self._proc_detail_table.setRowCount(0)
|
||
|
||
def _apply_process_stats_to_table(self, table: TableWidget, stats: list) -> None:
|
||
table.setUpdatesEnabled(False)
|
||
try:
|
||
if table.rowCount() != len(stats):
|
||
table.setRowCount(len(stats))
|
||
for row, ps in enumerate(stats):
|
||
speed = f"↓{_format_speed(ps.down_speed)} ↑{_format_speed(ps.up_speed)}"
|
||
conn_text = f"{ps.connections} ({ps.total_connections})" if ps.total_connections > ps.connections else str(ps.connections)
|
||
host = ps.top_host
|
||
if len(host) > 30:
|
||
host = host[:27] + "..."
|
||
total = ps.upload + ps.download
|
||
|
||
self._set_table_text(table, row, 0, ps.exe)
|
||
self._set_table_text(table, row, 1, speed)
|
||
vpn_item = self._set_table_text(table, row, 2, self._format_bytes(ps.proxy_bytes))
|
||
vpn_item.setForeground(positive_color() if ps.proxy_bytes > 0 else QBrush())
|
||
self._set_table_text(table, row, 3, self._format_bytes(ps.direct_bytes))
|
||
self._set_table_text(table, row, 4, conn_text)
|
||
self._set_table_text(table, row, 5, host)
|
||
self._set_table_text(table, row, 6, self._format_bytes(total))
|
||
finally:
|
||
table.setUpdatesEnabled(True)
|
||
|
||
def _set_table_text(self, table: TableWidget, row: int, column: int, text: str) -> QTableWidgetItem:
|
||
item = table.item(row, column)
|
||
if item is None:
|
||
item = QTableWidgetItem(text)
|
||
table.setItem(row, column, item)
|
||
return item
|
||
if item.text() != text:
|
||
item.setText(text)
|
||
return item
|
||
|
||
# ── Helpers ───────────────────────────────────────────────
|
||
|
||
def _effective_latency(self) -> int | None:
|
||
return self._live_rtt_ms if self._live_rtt_ms is not None else self._selected_latency_ms
|
||
|
||
def _is_xray_tun_mode(self) -> bool:
|
||
return bool(self._settings.tun_mode and self._settings.tun_engine == "xray")
|
||
|
||
def _is_tun2socks_mode(self) -> bool:
|
||
return bool(self._settings.tun_mode and self._settings.tun_engine == "tun2socks")
|
||
|
||
def _is_singbox_proxy_mode(self) -> bool:
|
||
return bool(not self._settings.tun_mode and self._settings.proxy_engine == "singbox")
|
||
|
||
def _route_engine_label(self) -> str:
|
||
if self._settings.tun_mode:
|
||
if self._settings.tun_engine == "singbox":
|
||
return "VPN (TUN) -> sing-box (recommended, auto hybrid)"
|
||
if self._is_xray_tun_mode():
|
||
config_name = Path(self._settings.xray_config_file or "default.json").name
|
||
return f"VPN (TUN) -> xray (experimental) · raw xray config: {config_name}"
|
||
return "VPN (TUN) -> tun2socks"
|
||
if self._is_singbox_proxy_mode():
|
||
config_name = Path(self._settings.singbox_config_file or "default.json").name
|
||
target = f"sing-box extended config: {config_name}"
|
||
else:
|
||
config_name = Path(self._settings.xray_config_file or "default.json").name
|
||
target = f"xray config: {config_name}"
|
||
if self._settings.enable_system_proxy:
|
||
return f"Системный прокси Windows -> {target}"
|
||
return f"Локальный proxy -> {target} (только вручную настроенные приложения)"
|
||
|
||
def _default_connection_message(self) -> str:
|
||
action = "VPN" if self._settings.tun_mode else "Прокси"
|
||
return f"{action} {'работает' if self._connected else 'остановлен'}"
|
||
|
||
def _proxy_ports_text(self) -> str:
|
||
if not self._connected or self._proxy_socks_port <= 0 or self._proxy_http_port <= 0:
|
||
return ""
|
||
# Каждый протокол — со своим портом; то, что SOCKS-порт sing-box
|
||
# (mixed) понимает и HTTP, — подробность для подсказки, не для строки.
|
||
return (
|
||
f"SOCKS5: 127.0.0.1:{self._proxy_socks_port} · "
|
||
f"HTTP: 127.0.0.1:{self._proxy_http_port}"
|
||
)
|
||
|
||
def _add_proxy_to_telegram(self) -> None:
|
||
if not self._proxy_ports_text():
|
||
return
|
||
if QDesktopServices.openUrl(QUrl(telegram_socks_url(self._proxy_socks_port))):
|
||
return
|
||
InfoBar.warning(
|
||
"Telegram не открылся",
|
||
"Похоже, Telegram на компьютере не установлен. Впишите прокси вручную: "
|
||
f"SOCKS5, адрес 127.0.0.1, порт {self._proxy_socks_port}.",
|
||
position=InfoBarPosition.TOP_RIGHT,
|
||
duration=8000,
|
||
parent=self.window(),
|
||
)
|
||
|
||
def _proxy_ports_tooltip(self) -> str:
|
||
if not self._proxy_ports_text():
|
||
return ""
|
||
tip = (
|
||
f"SOCKS5 — 127.0.0.1:{self._proxy_socks_port}\n"
|
||
f"HTTP — 127.0.0.1:{self._proxy_http_port}"
|
||
)
|
||
if self._is_singbox_proxy_mode():
|
||
tip += f"\nПорт {self._proxy_socks_port} принимает и HTTP (режим mixed)"
|
||
if self._settings.tun_mode:
|
||
tip += "\nВ режиме VPN (TUN) прокси доступен только программам этого компьютера"
|
||
return tip
|
||
|
||
def _system_proxy_note(self) -> str:
|
||
"""Пояснение о реальном состоянии прокси Windows (чужой прокси / PAC)."""
|
||
state = self._system_proxy_state
|
||
if state is None or not state.supported:
|
||
return ""
|
||
if state.autoconfig_url:
|
||
return "PAC-скрипт активен в Windows"
|
||
if state.enabled and not state.is_ours:
|
||
return "Системный прокси: включён (другое приложение)"
|
||
return ""
|
||
|
||
def _config_summary(self) -> str:
|
||
if (self._settings.tun_mode and self._settings.tun_engine == "singbox") or self._is_singbox_proxy_mode():
|
||
return f"Конфиг sing-box: {Path(self._settings.singbox_config_file or 'default.json').name}"
|
||
if (not self._settings.tun_mode and self._settings.proxy_engine == "xray") or self._is_xray_tun_mode():
|
||
return f"Конфиг Xray: {Path(self._settings.xray_config_file or 'default.json').name}"
|
||
return "Выберите сервер на странице «Серверы»"
|
||
|
||
def _connected_title(self) -> str:
|
||
"""Честный заголовок: «Защищено» — только когда туннель ловит весь трафик."""
|
||
if self._settings.tun_mode:
|
||
return "Защищено"
|
||
if self._settings.enable_system_proxy:
|
||
return "Подключено"
|
||
return "Прокси запущен"
|
||
|
||
def _headline(self) -> str:
|
||
if self._initializing:
|
||
return "Подготовка…"
|
||
if self._connection_phase == "starting":
|
||
return "Подключение…"
|
||
if self._connection_phase == "error":
|
||
return "Ошибка подключения"
|
||
if self._connection_phase == "running" or self._connected:
|
||
return self._connected_title()
|
||
return "Не подключено"
|
||
|
||
def _status_line(self) -> str:
|
||
if self._initializing:
|
||
return "Загрузка данных…"
|
||
if self._connection_phase in {"starting", "error"}:
|
||
text = self._connection_message
|
||
elif self._connected:
|
||
# Что идёт через туннель, говорит карточка «Режим работы».
|
||
text = ""
|
||
else:
|
||
text = "Нажмите на кнопку питания, чтобы подключиться"
|
||
note = self._system_proxy_note()
|
||
if note:
|
||
text = f"{text} • {note}" if text else note
|
||
return text
|
||
|
||
def _toggle_action_text(self) -> str:
|
||
if self._settings.tun_mode:
|
||
return "Отключить VPN" if self._connected else "Подключить VPN"
|
||
return "Остановить прокси" if self._connected else "Запустить прокси"
|
||
|
||
def _server_title(self) -> str:
|
||
if self._selected_node is None:
|
||
return "Сервер не выбран"
|
||
return node_name_text(self._selected_node) or "Безымянный сервер"
|
||
|
||
def _selected_node_summary(self) -> str:
|
||
"""Протокол и замаскированный адрес выбранного сервера (без имени)."""
|
||
if self._selected_node is None:
|
||
return self._config_summary()
|
||
scheme = self._selected_node.scheme.upper() if self._selected_node.scheme else "NODE"
|
||
parts = [scheme, masked_endpoint()]
|
||
group = self._selected_node.group
|
||
if group and group not in {"Default", "По умолчанию"}:
|
||
parts.append(group)
|
||
return " · ".join(parts)
|
||
|
||
# ── Signal handlers ───────────────────────────────────────
|
||
|
||
def _on_mode_changed(self, index: int) -> None:
|
||
value = self.mode_combo.itemData(index)
|
||
if value:
|
||
self.mode_changed.emit(str(value))
|
||
|
||
def _on_vpn_tile_clicked(self) -> None:
|
||
self._on_mode_tile_clicked(True)
|
||
|
||
def _on_proxy_tile_clicked(self) -> None:
|
||
self._on_mode_tile_clicked(False)
|
||
|
||
def _on_mode_tile_clicked(self, tun: bool) -> None:
|
||
# Сравниваем с тем, что видит пользователь: если после сбоя плитка
|
||
# показывает фактический режим, клик по другой — повтор перехода.
|
||
if tun == self._displayed_tun_mode():
|
||
return
|
||
# Плитка отзывается сразу и не откатывается, пока идёт переход; второй
|
||
# клик просто меняет цель (координатор сливает запросы в последний).
|
||
self._tun_intent.request(tun)
|
||
QTimer.singleShot(int(self._tun_intent.timeout_s * 1000) + 50, self._sync_switches)
|
||
self._sync_switches()
|
||
self.tun_toggled.emit(tun)
|
||
|
||
def _observed_tun_mode(self) -> bool | None:
|
||
"""Факт: режим работающей сессии; без сессии — настройка.
|
||
|
||
``None`` — идёт переход и сессии пока нет: факта ещё нет, и выбор
|
||
пользователя не должен сниматься на промежуточном шаге.
|
||
"""
|
||
if self._active_tun_mode is not None:
|
||
return self._active_tun_mode
|
||
if self._transition_busy:
|
||
return None
|
||
return bool(self._settings.tun_mode)
|
||
|
||
def _displayed_tun_mode(self) -> bool:
|
||
shown = self._tun_intent.display(self._observed_tun_mode())
|
||
return bool(self._settings.tun_mode) if shown is None else bool(shown)
|
||
|
||
def _on_proxy_toggled(self, checked: bool) -> None:
|
||
# Показываем выбор сразу; фактический прокси Windows догонит в фоне.
|
||
self._proxy_intent.request(checked)
|
||
QTimer.singleShot(int(self._proxy_intent.timeout_s * 1000) + 50, self._sync_switches)
|
||
self.proxy_toggled.emit(checked)
|
||
|
||
def _observed_system_proxy(self) -> bool:
|
||
"""Факт: включён ли наш системный прокси (без подключения — просто настройка)."""
|
||
proxy_on = self._settings.enable_system_proxy
|
||
state = self._system_proxy_state
|
||
if self._connected and state is not None and state.supported:
|
||
# Подключено: правда — в реестре Windows, а не во флаге настроек.
|
||
proxy_on = bool(state.enabled and state.is_ours)
|
||
elif state is not None and state.supported and state.enabled and state.is_ours:
|
||
# Наш прокси реально активен в Windows — показываем «Вкл»,
|
||
# даже если сохранённый флаг ещё не синхронизирован.
|
||
proxy_on = True
|
||
return proxy_on
|
||
|
||
def _sync_switches(self) -> None:
|
||
tun = self._displayed_tun_mode()
|
||
tun_applying = self._tun_intent.pending
|
||
self.vpn_tile.setChecked(tun)
|
||
self.proxy_tile.setChecked(not tun)
|
||
self.vpn_tile.set_applying(tun_applying and tun)
|
||
self.proxy_tile.set_applying(tun_applying and not tun)
|
||
|
||
# Намерение пользователя поверх факта: пока изменение применяется,
|
||
# переключатель не откатывается к старому состоянию реестра.
|
||
proxy_on = self._proxy_intent.display(self._observed_system_proxy())
|
||
applying = self._proxy_intent.pending
|
||
self.proxy_switch.blockSignals(True)
|
||
self.proxy_switch.setChecked(proxy_on)
|
||
self.proxy_switch.setText(("Вкл" if proxy_on else "Выкл") + ("…" if applying else ""))
|
||
self.proxy_switch.setToolTip("Применяется…" if applying else "")
|
||
self.proxy_switch.blockSignals(False)
|
||
self.proxy_hint_label.setText(_proxy_hint(proxy_on))
|
||
self._apply_interaction_state()
|
||
|
||
def _apply_interaction_state(self) -> None:
|
||
has_profiles = self._node_count > 0 or not self._settings.tun_mode or (
|
||
self._settings.tun_mode and self._settings.tun_engine in {"singbox", "xray"}
|
||
)
|
||
busy = self._initializing or self._transition_busy or self._connection_phase == "starting"
|
||
self.connection_orb.setEnabled(has_profiles and not busy)
|
||
self.connection_orb.set_state(self._orb_state())
|
||
self.vpn_tile.setEnabled(not busy)
|
||
self.proxy_tile.setEnabled(not busy)
|
||
self.next_server_btn.setEnabled(self._node_count > 1 and not busy)
|
||
self.mode_combo.setVisible(self._is_tun2socks_mode())
|
||
self.mode_combo.setEnabled(not busy and self._is_tun2socks_mode())
|
||
self.proxy_switch.setEnabled(not busy and not self._settings.tun_mode)
|