zapret-kvn/xray_fluent/ui/main_window.py
loop-uh 5965af36c5
Some checks failed
Windows project source guards / test (push) Failing after 26s
Возврат окна из трея: хватка памяти растёт вместе с программой, страницы строятся заранее; в журнал — причины для зеркал
Сегодняшние правки Zapret 2 GUI по журналу пользователя перенесены в KVN. 1) Пока окно скрыто, раз в минуту проверяется рабочий набор, и при росте от 16 МБ хватка и снимок страниц берутся заново (запуск в трей брал их на пятой секунде, пока программа ещё не закончила стартовые работы). 2) Страницы окна (DeferredPage) строились только при первом открытии, то есть под щелчком; ui/page_warmup.py собирает страницы узлов, подписок, настроек, обновлений и журнала заранее, но только пока окно скрыто или свёрнуто, через 2 минуты после запуска и когда человек минуту не трогал мышь и клавиатуру: по одной с паузой 3 с (решение пользователя 2026-10-20 для GUI, цена — память страниц в трее). 3) Общее ядро обновления (копия из Zapret 2 GUI) пишет в журнал, откуда получен список имён git-сервера и почему не открылось ни одно имя.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:55:59 -04:00

2436 lines
122 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from copy import deepcopy
from dataclasses import fields
from pathlib import Path
import os
import sys
import time
import logging
from PyQt6.QtCore import QEvent, QPoint, QTimer, QUrl
from PyQt6.QtGui import QAction, QActionGroup, QCloseEvent, QDesktopServices, QGuiApplication, QIcon
from PyQt6.QtWidgets import QApplication, QDialog, QFileDialog, QMenu, QSystemTrayIcon, QWidget
from qfluentwidgets import (
FluentIcon as FIF,
FluentWindow,
InfoBar,
InfoBarIcon,
InfoBarPosition,
NavigationDisplayMode,
NavigationItemPosition,
)
from ..diagnostics.connection_message import connection_message
from ..diagnostics.runtime_logging import redact_server_addresses
from ..diagnostics.startup_clock import since_process_start_ms
from ..application.controller import AppController
from ..application.selection_source import SelectionSource
from ..application.route_check import RouteCheck
from ..application.singbox_editor_check import SingboxEditorCheck
from ..profiles.storage import PassphraseRequired
from ..constants import APP_ICON_PATH, APP_NAME, APP_VERSION, BASE_DIR, LOG_DIR
from ..profiles.models import AppSettings, Node, RoutingSettings, Subscription, SubscriptionUpdateResult
from ..importer.subscription_http import mask_subscription_url
from ..updates.app_updater import (
AppUpdate,
PreparedUpdate,
UpdateChecker,
UpdateDownloader,
)
from ..updates.installer.launch import launch_installer
from ..updates.installer.plan import InstallPlan
from ..updates.auto_update import (
MAX_AUTO_ATTEMPTS,
auto_install_block_reason,
record_attempt,
resolve_startup,
)
from ..engines.xray import XrayCoreUpdateResult
from .dashboard_page import DashboardPage
from .deferred_page import DeferredPage
from .lock_dialog import PasswordDialog
from .logs_page import LogsPage
from .nodes_page import NodesPage
from .configs_page import SECTIONS as ROUTING_SECTIONS, ConfigsPage
from .settings_page import SettingsPage
from .subscriptions_page import SubscriptionDeleteDialog, SubscriptionsPage
from .about_page import AboutPage
from .history_page import HistoryPage
from .page_warmup import HiddenPageWarmup
from .window_presence import WindowPresence, is_native_minimize_command, is_native_restore_command
from .window_reveal import cloak_until_painted, is_system_show_event
from .theme import apply_theme, stop_system_theme_listener, sync_system_theme_listener
from .tour import TOUR_STEPS, TourOverlay, TourStep, active_tour
from .updates_page import UpdatesPage
from .zapret_page import ZapretPage
#: Поля настроек, которые пишет само окно (геометрия, боковое меню, вид
#: таблицы серверов). Страница настроек их не редактирует, но отправляет
#: снимок, сделанный при ``set_values``, — без переноса этих полей любое
#: изменение настроек откатывало бы их к старым значениям.
#: Settings edited outside the Settings page (window geometry, the Zapret page,
#: the tour flag): its stale copy must not roll them back when it saves.
_WINDOW_OWNED_SETTINGS = tuple(
item.name for item in fields(AppSettings)
if item.name.startswith(("window_", "nodes_", "zapret_")) or item.name in ("nav_expanded", "tour_done", "system_proxy_before_tun")
)
APP_UPDATE_INITIAL_DELAY_MS = 2500
APP_UPDATE_INTERVAL_MS = 30 * 60 * 1000
# Обновление ждёт, пока на экране игра или видео: заглядываем с этой паузой.
# Пока человек в игре, программа раз в несколько секунд смотрит на экран
# (дёшево) и проверяет выпуск, только когда он освободился.
UPDATE_BUSY_RECHECK_MS = 5 * 1000
# Скачанное обновление ждёт конца переключения подключения не дольше этого.
UPDATE_APPLY_MAX_WAIT_S = 120
UPDATE_APPLY_RETRY_MS = 2000
# Сколько приложение ждёт подтверждения от установщика, прежде чем закрыться.
# Запас большой: антивирус может долго проверять только что распакованный exe.
INSTALLER_START_MAX_WAIT_S = 60
INSTALLER_START_POLL_MS = 200
# Экскурсия при первом запуске: даём окну дорисоваться, а если мешает диалог,
# обновление или окно не на переднем плане — проверяем снова, но не бесконечно
# (активация окна запускает проверку заново).
TOUR_AUTOSTART_DELAY_MS = 1200
TOUR_AUTOSTART_RETRY_MS = 2000
TOUR_AUTOSTART_MAX_TRIES = 60
def _runtime_identity_log_line() -> str:
"""Return the non-secret runtime identity written to the regular app log."""
executable = Path(sys.executable).resolve()
base_dir = BASE_DIR.resolve()
return (
f"[runtime] app_version={APP_VERSION} "
f"executable={executable} "
f"base_dir={base_dir}"
)
class MainWindow(FluentWindow):
def __init__(self, defer_init: bool = False):
super().__init__()
# Присутствие окна: память подтягивается заранее, когда окно возвращают из долгого фона.
self._window_presence = WindowPresence(self)
self._quitting = False
self._tray_notified = False
self._state_loaded = False
self._initialized = False
self._startup_loader = None
self._metadata_worker = None
self._startup_started = time.perf_counter()
self._bulk_task_tip: InfoBar | None = None
self._bulk_task_type: str | None = None
# Один таймер на закрытие подсказки: новый раунд его останавливает.
# Раньше каждый раунд ставил свой singleShot, и запоздавший таймер
# прошлого раунда закрывал подсказку уже следующего.
self._bulk_task_tip_close_timer = QTimer(self)
self._bulk_task_tip_close_timer.setSingleShot(True)
self._bulk_task_tip_close_timer.setInterval(1000)
self._bulk_task_tip_close_timer.timeout.connect(self._clear_bulk_task_tip)
self._speed_test_was_cancelled = False
self._tray_available = QSystemTrayIcon.isSystemTrayAvailable()
self._deferred_dashboard_metrics: tuple[float, float, int | None] | None = None
self._deferred_process_stats: list | None = None
self._has_deferred_process_stats = False
self._geometry_persistence_ready = False
self._restoring_geometry = False
self._geometry_applied = False
self._nav_expanded_pref = False
self._app_update_scheduler_ready = False
self._update_background = False
self._update_apply_deadline = 0.0
self._app_update_timer = QTimer(self)
self._app_update_timer.setSingleShot(True)
self._app_update_timer.timeout.connect(self._on_app_update_timer_timeout)
# Слушатель сервера, который ведёт очередь обновлений (создаётся после запуска).
self._update_signal = None
self._previous_update_report: dict | None = None
self._app_update_first_check_done = False
# Начатая экскурсия прячет плашку до конца запуска, но не навсегда.
# Брошенная экскурсия не запускается снова до следующего запуска.
self._tour_started_this_session = False
self._tour_autostart_tries = 0
self._tour_autostart_timer = QTimer(self)
self._tour_autostart_timer.setSingleShot(True)
self._tour_autostart_timer.timeout.connect(self._try_autostart_tour)
self.tray: QSystemTrayIcon | None = None
self.tray_show_action: QAction | None = None
self.tray_connect_action: QAction | None = None
self.tray_next_action: QAction | None = None
self.tray_quit_action: QAction | None = None
self.tray_mode_menu: QMenu | None = None
self.tray_mode_group: QActionGroup | None = None
self.tray_mode_global: QAction | None = None
self.tray_mode_rule: QAction | None = None
self.tray_mode_direct: QAction | None = None
self._app_icon = QIcon(str(APP_ICON_PATH))
self.setWindowTitle(APP_NAME)
self.setWindowIcon(self._app_icon)
self.resize(1000, 720)
if not defer_init:
self.initialize()
def initialize(self, *, load_state: bool = True) -> None:
if self._initialized:
return
self._initialized = True
self._nodes_view_prefs_applied = False
self.controller = AppController(self)
self.dashboard_page = DashboardPage(self)
self.nodes_page = DeferredPage(NodesPage, "nodes", self)
self.subscriptions_page = DeferredPage(SubscriptionsPage, "subscriptions", self)
self.configs_page = DeferredPage(ConfigsPage, "configs", self)
self.zapret_page = DeferredPage(ZapretPage, "zapret", self)
self.logs_page = DeferredPage(LogsPage, "logs", self)
self.settings_page = DeferredPage(SettingsPage, "settings", self)
self.updates_page = DeferredPage(UpdatesPage, "updates", self)
self.history_page = DeferredPage(HistoryPage, "history", self)
self.about_page = DeferredPage(AboutPage, "about", self)
# Запуск в трей: страницы, которые человек нажмёт первыми, строятся заранее, пока
# окно скрыто и он отошёл, а не под щелчком при первом открытии окна.
self._page_warmup = HiddenPageWarmup(
self,
lambda: (
self.nodes_page,
self.subscriptions_page,
self.settings_page,
self.updates_page,
self.logs_page,
),
)
self._create_navigation()
self._create_tray()
self._connect_signals()
# Keep this in the ordinary application log as well as startup.log so
# a support report can identify the exact portable copy in use.
self.controller._log(_runtime_identity_log_line())
self._init_window()
self.dashboard_page.set_initializing(True)
self.navigationInterface.setEnabled(False)
for action in (self.tray_connect_action, self.tray_next_action):
if action:
action.setEnabled(False)
self.configs_page.created.connect(self._load_config_editor_documents)
if load_state:
from ..application.startup_service import StartupLoader
self._startup_loader = StartupLoader(self)
self._startup_loader.early.connect(lambda settings, _locked: self._apply_window_geometry(settings))
self._startup_loader.loaded.connect(self.complete_initialization)
self._startup_loader.password_needed.connect(self._ask_startup_password)
self._startup_loader.failed.connect(self._startup_failed)
QTimer.singleShot(0, self._startup_loader.start)
def _ask_startup_password(self):
if self._quitting:
return
dialog = PasswordDialog("Зашифрованные данные", self)
if dialog.exec() != int(QDialog.DialogCode.Accepted):
self._quit_app()
return
self._startup_loader.start(dialog.password())
def _startup_failed(self, message):
if self._quitting:
return
self.dashboard_page.set_initializing(False)
self.dashboard_page.set_runtime_status("error", message)
self.dashboard_page.set_transition_busy(True)
self._show_status("error", message)
def complete_initialization(self, state, history):
if self._quitting or self._geometry_persistence_ready:
return
if self._startup_loader is not None:
self.controller.storage.passphrase = self._startup_loader.storage.passphrase
self.controller.storage._known_encrypted = self._startup_loader.storage.is_encrypted()
self.controller.load(state, history)
self._state_loaded = True
self._apply_theme(state.settings.theme, state.settings.accent_color)
self._apply_window_geometry(state.settings)
if state.security.enabled:
self.controller.locked = True
if not self._ensure_unlocked(startup=True):
return
self._geometry_persistence_ready = True
self.dashboard_page.set_initializing(False)
self.navigationInterface.setEnabled(True)
self._restore_nav_preference(state.settings)
for action in (self.tray_connect_action, self.tray_next_action):
if action:
action.setEnabled(True)
self.history_page.set_storage(self.controller.traffic_history)
resume = self._resolve_previous_update()
if resume is None:
self.controller.auto_connect_if_needed()
else:
# Перезапуск после обновления возвращает ровно то состояние, что
# было: выключенный VPN не включается автоподключением, а
# включённый — поднимается даже без него.
self.controller.resume_after_app_update(resume)
self._app_update_scheduler_ready = True
self._sync_app_update_timer(state.settings)
self._start_update_signal()
self._consume_update_error_log()
self.controller.repair_launch_on_startup()
from ..application.startup_service import MetadataWorker
from ..profiles.path_utils import resolve_configured_path
from ..constants import SINGBOX_PATH_DEFAULT
sb_path = resolve_configured_path(state.settings.singbox_path, default_path=SINGBOX_PATH_DEFAULT, use_default_if_empty=True) or SINGBOX_PATH_DEFAULT
self._metadata_worker = MetadataWorker(state.settings.xray_path, str(sb_path), self)
self._metadata_worker.ready.connect(self._metadata_ready)
self._metadata_worker.start()
if state.settings.xray_auto_update:
QTimer.singleShot(4500, lambda: self.controller.run_xray_core_update(True, silent=True) if not self._quitting else None)
logging.getLogger("xray_fluent.bootstrap").info(
"controls_ready_ms=%.1f process_ms=%.0f",
(time.perf_counter() - self._startup_started) * 1000,
since_process_start_ms(),
)
self._schedule_tour_autostart()
def _metadata_ready(self, result):
if self._quitting:
return
self.updates_page.set_xray_version(result["xray"])
self.updates_page.set_singbox_version(result["singbox"])
self._init_zapret_page(result["presets"])
def finish_background_shutdown(self):
# Слушатель системной темы — дочерний поток окна: пока он работает,
# окно нельзя разрушить (qFatal в Qt), а само оно не завершается.
if not stop_system_theme_listener():
logging.getLogger("xray_fluent.bootstrap").warning("System theme listener did not stop")
if self._startup_loader:
self._startup_loader.finish_shutdown()
if self._metadata_worker and self._metadata_worker.isRunning():
self._metadata_worker.requestInterruption()
self._metadata_worker.wait()
worker = getattr(getattr(self, "controller", None), "_country_resolver", None)
if worker and worker.isRunning():
worker.requestInterruption()
worker.wait()
def _create_navigation(self) -> None:
self.navigationInterface.setMinimumExpandWidth(1100)
self.navigationInterface.setExpandWidth(200)
# Основные пункты — в прокручиваемой области панели: верхняя (TOP) не
# прокручивается, и на низком окне раскрытая «Маршрутизация» наезжала
# на пункты под ней.
scroll = NavigationItemPosition.SCROLL
self.addSubInterface(self.dashboard_page, FIF.SPEED_HIGH, "Панель", scroll)
self.addSubInterface(self.nodes_page, FIF.LINK, "Серверы", scroll)
self.addSubInterface(self.subscriptions_page, FIF.CLOUD, "Подписки", scroll)
routing_item = self.addSubInterface(self.configs_page, FIF.IOT, "Маршрутизация", scroll)
routing_item.clicked.connect(lambda *_args: self._open_routing_section("overview"))
for key, title, icon in ROUTING_SECTIONS:
if key == "overview":
continue
self.navigationInterface.addItem(
routeKey=f"configs-{key}",
icon=icon,
text=title,
onClick=lambda *_args, section=key: self._open_routing_section(section),
tooltip=title,
parentRouteKey=self.configs_page.objectName(),
)
# Раскрытые подпункты не должны оставаться за нижним краем панели.
routing_item.expandAni.finished.connect(lambda: self._reveal_nav_item(routing_item))
self.addSubInterface(self.zapret_page, FIF.COMMAND_PROMPT, "Zapret", scroll)
self.addSubInterface(self.logs_page, FIF.DOCUMENT, "Логи", scroll)
self.addSubInterface(self.history_page, FIF.HISTORY, "История", scroll)
# Не страница, а кнопка: запускает экскурсию поверх текущей страницы.
self.navigationInterface.addItem(
routeKey="tour",
icon=FIF.EDUCATION,
text="Обучение",
onClick=lambda *_args: self._on_tour_nav_clicked(),
selectable=False,
position=NavigationItemPosition.BOTTOM,
tooltip="Экскурсия по приложению",
)
self.addSubInterface(self.about_page, FIF.INFO, "О проекте", NavigationItemPosition.BOTTOM)
self.addSubInterface(self.updates_page, FIF.UPDATE, "Обновления", NavigationItemPosition.BOTTOM)
self.addSubInterface(self.settings_page, FIF.SETTING, "Настройки", NavigationItemPosition.BOTTOM)
# Подключаемся после внутреннего toggle() панели — к моменту вызова
# слота режим уже выбран.
self.navigationInterface.panel.menuButton.clicked.connect(self._on_nav_menu_clicked)
def _reveal_nav_item(self, item: QWidget) -> None:
"""Докрутить панель к раскрытым подпунктам, не пряча сам пункт.
``ensureWidgetVisible`` для пункта выше окна прокрутки уводит его
заголовок за верхний край — считаем прокрутку сами.
"""
if not getattr(item, "isExpanded", False):
return
area = self.navigationInterface.panel.scrollArea
bar = area.verticalScrollBar()
top = item.mapTo(area.widget(), QPoint(0, 0)).y()
bottom = top + item.height()
visible = area.viewport().height()
if top < bar.value():
bar.setValue(top)
elif bottom > bar.value() + visible:
bar.setValue(min(top, bottom - visible))
def _open_routing_section(self, key: str) -> None:
"""Подпункт «Маршрутизации»: одна страница-контейнер, свой раздел внутри."""
self.switchTo(self.configs_page)
self.configs_page.show_section(key)
route = self.configs_page.objectName() if key == "overview" else f"configs-{key}"
self.navigationInterface.setCurrentItem(route)
# ── Боковое меню: запоминаем явный выбор пользователя ──────
def _on_nav_menu_clicked(self) -> None:
"""Запоминает развёрнуто ли меню — только по клику на кнопку меню.
Автосворачивание на узком окне и всплывающий режим MENU (окно уже
порога развёртывания) предпочтением не считаются.
"""
panel = self.navigationInterface.panel
if panel.displayMode == NavigationDisplayMode.MENU:
return
expanded = panel.displayMode == NavigationDisplayMode.EXPAND and bool(panel.expandAni.property("expand"))
self._nav_expanded_pref = expanded
controller = getattr(self, "controller", None)
if controller is None or not self._geometry_persistence_ready:
return
if controller.state.settings.nav_expanded != expanded:
controller.state.settings.nav_expanded = expanded
controller.schedule_save()
def _restore_nav_preference(self, settings: AppSettings) -> None:
self._nav_expanded_pref = bool(settings.nav_expanded)
self._sync_nav_to_preference()
def _sync_nav_to_preference(self) -> None:
"""Разворачивает меню, если пользователь так оставил и окно достаточно широкое."""
if not self._nav_expanded_pref:
return
panel = self.navigationInterface.panel
if panel.displayMode == NavigationDisplayMode.COMPACT and self.width() >= panel.minimumExpandWidth:
panel.expand(useAni=False)
# expand(useAni=False) не испускает displayModeChanged, а FluentWindow
# по нему поднимает заголовок окна над панелью — делаем это сами.
title_bar = getattr(self, "titleBar", None)
if title_bar is not None:
title_bar.raise_()
def _create_tray(self) -> None:
if not self._tray_available:
return
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(self._app_icon)
self.tray.setToolTip(APP_NAME)
menu = QMenu()
self.tray_show_action = QAction("Показать", self)
self.tray_connect_action = QAction("Подключить", self)
self.tray_next_action = QAction("Следующий сервер", self)
self.tray_quit_action = QAction("Выход", self)
self.tray_mode_menu = QMenu("Режим", menu)
self.tray_mode_group = QActionGroup(self)
self.tray_mode_group.setExclusive(True)
self.tray_mode_global = QAction("Глобальный", self)
self.tray_mode_global.setCheckable(True)
self.tray_mode_rule = QAction("По правилам", self)
self.tray_mode_rule.setCheckable(True)
self.tray_mode_direct = QAction("Прямой", self)
self.tray_mode_direct.setCheckable(True)
self.tray_mode_group.addAction(self.tray_mode_global)
self.tray_mode_group.addAction(self.tray_mode_rule)
self.tray_mode_group.addAction(self.tray_mode_direct)
self.tray_mode_menu.addAction(self.tray_mode_global)
self.tray_mode_menu.addAction(self.tray_mode_rule)
self.tray_mode_menu.addAction(self.tray_mode_direct)
menu.addAction(self.tray_show_action)
menu.addAction(self.tray_connect_action)
menu.addAction(self.tray_next_action)
menu.addMenu(self.tray_mode_menu)
menu.addSeparator()
menu.addAction(self.tray_quit_action)
self.tray.setContextMenu(menu)
self.tray.show()
self.tray.activated.connect(self._on_tray_activated)
self.tray_show_action.triggered.connect(self._toggle_window_visible)
self.tray_connect_action.triggered.connect(self.controller.toggle_connection)
self.tray_next_action.triggered.connect(
lambda: self.controller.switch_next_node(source=SelectionSource.TRAY_NEXT)
)
self.tray_quit_action.triggered.connect(self._quit_app)
self.tray_mode_global.triggered.connect(lambda: self._set_mode_from_tray("global"))
self.tray_mode_rule.triggered.connect(lambda: self._set_mode_from_tray("rule"))
self.tray_mode_direct.triggered.connect(lambda: self._set_mode_from_tray("direct"))
def _connect_signals(self) -> None:
self.dashboard_page.logs_requested.connect(lambda: self.switchTo(self.logs_page))
self.dashboard_page.mode_changed.connect(self._set_mode_only)
self.dashboard_page.toggle_connection_requested.connect(self.controller.toggle_connection)
self.dashboard_page.tun_toggled.connect(self._on_dashboard_tun_toggled)
self.dashboard_page.proxy_toggled.connect(self._on_dashboard_proxy_toggled)
self.controller.system_proxy_state_changed.connect(self.dashboard_page.set_system_proxy_state)
self.dashboard_page.servers_requested.connect(lambda: self.switchTo(self.nodes_page))
self.dashboard_page.next_node_requested.connect(
lambda: self.controller.switch_next_node(source=SelectionSource.DASHBOARD_NEXT)
)
self.dashboard_page.configs_requested.connect(lambda: self.switchTo(self.configs_page))
self.nodes_page.import_clipboard_requested.connect(self._import_nodes_from_clipboard)
self.nodes_page.delete_requested.connect(self.controller.remove_nodes)
self.nodes_page.hide_subscription_nodes_requested.connect(self.controller.hide_subscription_nodes)
self.nodes_page.reorder_requested.connect(self.controller.reorder_nodes)
self.nodes_page.connect_node_requested.connect(
lambda node_id, source: self.controller.set_selected_node(node_id, source=source)
)
self.nodes_page.ping_requested.connect(self._ping_requested)
self.nodes_page.export_outbound_json_requested.connect(self._export_outbound_json)
self.nodes_page.export_runtime_json_requested.connect(self._export_runtime_json)
self.nodes_page.edit_node_requested.connect(self._on_edit_node)
self.nodes_page.node_edit_saved.connect(self._on_node_edit_saved)
self.nodes_page.bulk_edit_requested.connect(self._on_bulk_edit_nodes)
self.nodes_page.bulk_edit_applied.connect(self._on_bulk_edit_applied)
self.nodes_page.favorite_requested.connect(self._set_favorites)
self.nodes_page.view_prefs_changed.connect(self._on_nodes_view_prefs_changed)
self.subscriptions_page.add_requested.connect(self._add_subscription)
self.subscriptions_page.edit_requested.connect(self._edit_subscription)
self.subscriptions_page.editor_save_requested.connect(self._save_subscription_from_editor)
self.subscriptions_page.update_requested.connect(
lambda sid, mode: self.controller.update_subscription(sid, mode=mode)
)
self.subscriptions_page.force_update_requested.connect(
lambda sid, mode: self.controller.update_subscription(
sid,
mode=mode,
force_refresh=True,
)
)
self.subscriptions_page.check_requested.connect(
lambda sid, mode: self.controller.check_subscription(sid, mode=mode)
)
self.subscriptions_page.update_all_requested.connect(
lambda mode: self.controller.update_all_subscriptions(mode=mode)
)
self.subscriptions_page.delete_requested.connect(self._delete_subscription)
self.subscriptions_page.auto_update_changed.connect(self._set_subscription_auto_update)
self.subscriptions_page.open_url_requested.connect(
lambda url: QDesktopServices.openUrl(QUrl(url))
)
self.subscriptions_page.show_url_requested.connect(self._show_subscription_url)
self.subscriptions_page.reset_hidden_requested.connect(self._reset_subscription_hidden)
self.subscriptions_page.accept_pending_url_requested.connect(self._accept_subscription_url)
self.configs_page.open_requested.connect(self._open_core_config)
self.configs_page.config_selected.connect(self._select_core_config)
self.configs_page.template_selected.connect(self._select_core_template)
self.configs_page.reset_requested.connect(self._reset_core_config_to_template)
self.configs_page.save_requested.connect(self._save_core_config)
self.configs_page.validate_requested.connect(self._validate_core_config)
self.configs_page.route_check_requested.connect(self._start_route_check)
self.configs_page.apply_requested.connect(self._apply_core_config)
self.zapret_page.start_requested.connect(self._on_zapret_start)
self.zapret_page.stop_requested.connect(self._on_zapret_stop)
self.zapret_page.target_settings_changed.connect(self.controller.apply_zapret_target_settings)
self.zapret_page.preset_selected.connect(self._on_zapret_preset_selected)
self.zapret_page.autostart_changed.connect(self._on_zapret_autostart_changed)
self.controller.zapret.started.connect(self._refresh_zapret_status)
self.controller.zapret.stopped.connect(self._refresh_zapret_status)
self.controller.zapret.error.connect(self._on_zapret_error)
self.controller.zapret.log_line.connect(
lambda line: self.logs_page.append_line(redact_server_addresses(line))
)
self.controller.zapret.plan_changed.connect(self._on_zapret_plan_changed)
self.controller.transition_state_changed.connect(self.zapret_page.set_server_status)
self.controller.transition_state_changed.connect(self._refresh_zapret_status)
self.logs_page.clear_requested.connect(self._clear_logs_view)
self.logs_page.export_diag_requested.connect(self._export_diagnostics)
self.settings_page.save_requested.connect(self._on_settings_page_saved)
self.settings_page.auto_lock_minutes_changed.connect(self._update_auto_lock_minutes)
self.settings_page.set_password_requested.connect(self._set_password)
self.settings_page.disable_password_requested.connect(self.controller.disable_master_password)
self.settings_page.lock_now_requested.connect(self.controller.lock)
self.updates_page.check_app_requested.connect(self._check_updates)
self.updates_page.check_xray_requested.connect(self._check_xray_updates)
self.updates_page.update_xray_requested.connect(self._update_xray_core)
self.updates_page.check_singbox_requested.connect(self._check_singbox_updates)
self.updates_page.update_singbox_requested.connect(self._update_singbox_core)
self.settings_page.export_backup_requested.connect(self._export_backup)
self.settings_page.import_backup_requested.connect(self._import_backup)
self.settings_page.set_encryption_requested.connect(self._set_encryption)
self.settings_page.disable_encryption_requested.connect(self._disable_encryption)
# Тест скорости
self.nodes_page.speed_test_requested.connect(self._speed_test_requested)
self.nodes_page.cancel_speed_test_requested.connect(self._cancel_speed_test_requested)
self.nodes_page.cancel_ping_requested.connect(self.controller.cancel_ping)
self.controller.speed_updated.connect(self._on_speed_updated)
self.controller.speed_test_cancelled.connect(self._on_speed_test_cancelled)
self.controller.nodes_changed.connect(self._on_nodes_changed)
self.controller.countries_changed.connect(self._on_countries_changed)
self.controller.subscriptions_changed.connect(self._on_subscriptions_changed)
self.controller.subscription_update_started.connect(self._on_subscription_update_started)
self.controller.subscription_update_progress.connect(self.subscriptions_page.set_update_phase)
self.controller.subscription_update_finished.connect(self._on_subscription_update_finished)
self.controller.selection_changed.connect(self._on_selection_changed)
self.controller.connection_changed.connect(self._on_connection_changed)
self.controller.connection_status_changed.connect(self.dashboard_page.set_runtime_status)
self.controller.routing_changed.connect(self._on_routing_changed)
self.controller.settings_changed.connect(self._on_settings_changed)
self.controller.log_line.connect(self.logs_page.append_line)
self.controller.runtime_errors_changed.connect(self.logs_page.set_error_records)
self.logs_page.set_error_records(self.controller.runtime_errors.snapshot())
self.controller.status.connect(self._show_status)
self._connect_startup_diagnostics()
self.controller.bulk_task_progress.connect(self._on_bulk_task_progress)
self.controller.ping_updated.connect(self._on_ping_updated)
self.controller.speed_progress_updated.connect(self._on_speed_progress_updated)
self.controller.connectivity_test_done.connect(self._on_connectivity_test_done)
self.controller.live_metrics_updated.connect(self._on_live_metrics_updated)
self.controller.xray_update_result.connect(self._on_xray_update_result)
self.controller.lock_state_changed.connect(self._on_lock_state_changed)
self.controller.auto_switch_triggered.connect(self._on_auto_switch)
self.controller.transition_state_changed.connect(self._on_transition_state_changed)
self.stackedWidget.currentChanged.connect(self._on_current_interface_changed)
def _init_window(self) -> None:
self.setMinimumSize(860, 560)
self.setWindowTitle(APP_NAME)
self.setWindowIcon(self._app_icon)
self._apply_window_geometry(self.controller.state.settings)
def _apply_window_geometry(self, settings: AppSettings) -> None:
from .window_geometry import fitted_geometry
screens = QGuiApplication.screens()
primary = screens.index(QGuiApplication.primaryScreen()) if screens else 0
rect, minimum = fitted_geometry(settings, [screen.availableGeometry() for screen in screens], primary)
self._restoring_geometry = True
try:
self.setMinimumSize(minimum)
self.setGeometry(rect)
self._geometry_applied = True
finally:
self._restoring_geometry = False
# Смена экрана при перетаскивании окна не трогает геометрию: окном в этот
# момент владеет цикл перемещения Windows, а логические прямоугольники
# экранов с разным масштабом не стыкуются — «подгонка» посреди переноса
# выбирала старый экран и возвращала окно обратно. Здесь только снижаем
# минимальный размер, чтобы окно поместилось на маленьком экране.
def _on_window_screen_changed(self, screen) -> None:
if screen is None:
return
from .window_geometry import MINIMUM_SIZE
area = screen.availableGeometry()
current = self.minimumSize()
self.setMinimumSize(
min(current.width(), MINIMUM_SIZE.width(), area.width()),
min(current.height(), MINIMUM_SIZE.height(), area.height()),
)
# Разрешение, панель задач или масштаб поменялись у экрана с окном —
# подгоняем после того, как Qt обновит геометрию. Чужие экраны не
# трогают окно: пересчёт по ним и давал отскок.
def _on_screen_geometry_changed(self, screen) -> None:
if screen is not self.screen():
return
QTimer.singleShot(0, self._fit_current_screen)
def _fit_current_screen(self, *_):
if not self._geometry_persistence_ready or self._restoring_geometry:
return
from copy import copy
settings = copy(self.controller.state.settings)
geo = self.normalGeometry() if self.isMaximized() else self.geometry()
settings.window_x, settings.window_y = geo.x(), geo.y()
settings.window_width, settings.window_height = geo.width(), geo.height()
if not self.isMaximized():
self._apply_window_geometry(settings)
else:
from .window_geometry import MINIMUM_SIZE
area = self.screen().availableGeometry()
self.setMinimumSize(min(MINIMUM_SIZE.width(), area.width()), min(MINIMUM_SIZE.height(), area.height()))
def nativeEvent(self, event_type, message): # noqa: N802 (Qt override)
# Окно из панели задач разворачивает Windows: команда приходит раньше, чем Qt
# покажет и нарисует окно, — самое время подтянуть память из фона.
if is_native_restore_command(message):
self._window_presence.return_intent()
elif is_native_minimize_command(message):
# Windows сжимает память свёрнутого окна сразу после этой команды:
# защита ставится до того, а не после.
self._window_presence.away_intent()
return super().nativeEvent(event_type, message)
def changeEvent(self, event):
super().changeEvent(event)
# Окно за другими окнами экскурсию не начинает — ждёт, пока его выведут вперёд.
if event.type() == QEvent.Type.ActivationChange and self.isActiveWindow():
self._schedule_tour_autostart()
if event.type() == QEvent.Type.WindowStateChange and not self.isMinimized():
QTimer.singleShot(0, self._flush_deferred_dashboard_metrics)
def showEvent(self, event):
super().showEvent(event)
if not is_system_show_event(event):
# Наш show() (запуск, значок в трее): окно ещё не на экране. Прячем его
# до отрисовки, иначе Windows выведет его пустым и белым.
cloak_until_painted(self)
if not getattr(self, "_first_show_logged", False):
self._first_show_logged = True
QTimer.singleShot(0, lambda: logging.getLogger("xray_fluent.bootstrap").info(
"first_window_ms=%.1f process_ms=%.0f",
(time.perf_counter() - self._startup_started) * 1000,
since_process_start_ms(),
))
# Запуск в трей окно не показывает — экскурсия ждёт первого появления.
self._schedule_tour_autostart()
# Метрики, пришедшие, пока окно было скрыто, дашборд получает одним разом.
QTimer.singleShot(0, self._flush_deferred_dashboard_metrics)
if not getattr(self, "_screen_signals_connected", False) and self.windowHandle():
self._screen_signals_connected = True
self.windowHandle().screenChanged.connect(self._on_window_screen_changed)
for screen in QGuiApplication.screens():
screen.availableGeometryChanged.connect(lambda *_, s=screen: self._on_screen_geometry_changed(s))
screen.logicalDotsPerInchChanged.connect(lambda *_, s=screen: self._on_screen_geometry_changed(s))
def _on_countries_changed(self, node_ids) -> None:
self.nodes_page.update_countries(node_ids)
if self.controller.state.selected_node_id in node_ids:
self.dashboard_page.set_selected_node(self.controller.selected_node)
def _on_nodes_changed(self, nodes: list[Node]) -> None:
if not self._nodes_view_prefs_applied:
# Apply persisted list view preferences before the first set_nodes.
self._nodes_view_prefs_applied = True
self.nodes_page.apply_view_settings(self.controller.state.settings)
selected_id = self.controller.state.selected_node_id
self.nodes_page.set_nodes(nodes, selected_id)
self.dashboard_page.set_nodes(nodes, selected_id)
self._refresh_tray_tooltip()
self.subscriptions_page.set_data(self.controller.state.subscriptions, nodes)
def _on_subscriptions_changed(self, subscriptions: list[Subscription]) -> None:
self.subscriptions_page.set_data(subscriptions, self.controller.state.nodes)
self.nodes_page.set_subscriptions(subscriptions)
def _add_subscription(self) -> None:
self.subscriptions_page.open_editor(None)
def _edit_subscription(self, subscription_id: str) -> None:
subscription = self.controller.get_subscription(subscription_id)
if subscription is None:
return
provider_names = [
node.provider_name or node.name
for node in self.controller.state.nodes
if node.subscription_id == subscription_id
]
self.subscriptions_page.open_editor(subscription, provider_names)
def _save_subscription_from_editor(self, subscription_id: str) -> None:
"""Commit the in-page subscription form (empty id = a new subscription).
On failure the sub-page stays open so the entered URL survives the error.
"""
editor = self.subscriptions_page.editor
if not subscription_id:
try:
started = self.controller.add_subscription(editor.subscription_value())
except Exception as exc:
self._show_status("error", str(exc))
return
self.subscriptions_page.close_editor()
if started:
self._show_status("info", "Проверяю подписку…")
return
try:
changed = self.controller.update_subscription_definition(
subscription_id, editor.update_values()
)
except Exception as exc:
self._show_status("error", str(exc))
return
self.subscriptions_page.close_editor()
if changed:
self.controller.update_subscription(subscription_id)
def _delete_subscription(self, subscription_id: str) -> None:
subscription = self.controller.get_subscription(subscription_id)
if subscription is None:
return
dialog = SubscriptionDeleteDialog(subscription.name or "Подписка", self)
if dialog.exec() == int(QDialog.DialogCode.Accepted):
self.controller.remove_subscription(
subscription_id,
keep_nodes=dialog.keep_nodes_check.isChecked(),
)
def _set_subscription_auto_update(self, subscription_id: str, enabled: bool) -> None:
subscription = self.controller.get_subscription(subscription_id)
if subscription is None or subscription.auto_update == enabled:
return
try:
changed = self.controller.update_subscription_definition(subscription_id, {"auto_update": enabled})
except Exception as exc: # noqa: BLE001 — показать ошибку и правду, а не выбор
self._show_status("error", str(exc))
changed = False
else:
if not changed:
self._show_status("warning", "Подписка сейчас обновляется — повторите позже")
if not changed:
# Правка не принята (подписка в очереди/обновляется): переключатель
# должен вернуться к сохранённому значению сразу, а не при следующей
# перерисовке таблицы.
self.subscriptions_page.set_data(self.controller.state.subscriptions, self.controller.state.nodes)
def _show_subscription_url(self, subscription_id: str) -> None:
subscription = self.controller.get_subscription(subscription_id)
if subscription is not None:
self._show_status("info-long", mask_subscription_url(subscription.url))
def _reset_subscription_hidden(self, subscription_id: str) -> None:
if self.controller.reset_subscription_hidden_nodes(subscription_id):
self.controller.update_subscription(subscription_id)
def _accept_subscription_url(self, subscription_id: str) -> None:
if self.controller.accept_subscription_pending_url(subscription_id):
self.controller.update_subscription(subscription_id)
def _on_subscription_update_started(self, subscription_id: str) -> None:
self.subscriptions_page.set_updating(subscription_id, True)
def _on_subscription_update_finished(self, result: SubscriptionUpdateResult) -> None:
self.subscriptions_page.set_updating(result.subscription_id, False)
if result.success:
message = result.message
if result.check_only:
# Проверка ничего не сохраняет: счётчики изменений у неё всегда
# нулевые, а расхождение с сохранённым списком выглядело как
# потерянные серверы.
if not result.not_modified and result.stored_count != result.source_count:
message += (
f"; сохранено {result.stored_count} — "
"нажмите «Обновить», чтобы применить"
)
if result.skipped:
message += f"; пропущено {result.skipped}"
elif not result.not_modified:
message += (
f"; добавлено {result.added}, обновлено {result.updated}, "
f"удалено {result.removed}, пропущено {result.skipped}"
)
if result.warnings:
message += f"; предупреждения: {'; '.join(result.warnings[:2])}"
self._show_status("warning-long" if result.warnings else "success", message)
else:
self._show_status("error", result.message)
def _set_favorites(self, node_ids: set[str], favorite: bool) -> None:
for node in self.controller.state.nodes:
if node.id in node_ids:
node.is_favorite = favorite
self.controller.nodes_changed.emit(self.controller.state.nodes)
self.controller.schedule_save()
def _on_nodes_view_prefs_changed(self, prefs: dict) -> None:
controller = getattr(self, "controller", None)
if controller is None:
return
settings = controller.state.settings
for key, value in prefs.items():
if hasattr(settings, key):
setattr(settings, key, value)
controller.schedule_save()
def _on_selection_changed(self, node: Node | None) -> None:
self.nodes_page.set_active_node(node.id if node else None)
self.dashboard_page.set_selected_node(node)
if node:
self.dashboard_page.set_selected_latency(node.ping_ms)
else:
self.dashboard_page.set_selected_latency(None)
self.zapret_page.set_selected_node(node)
self._refresh_tray_tooltip()
def _on_connection_changed(self, connected: bool) -> None:
if connected:
self._config_saved_unapplied = False # новая сессия читает сохранённый конфиг
self.dashboard_page.set_active_tun_mode(self.controller.active_tun_mode())
self.dashboard_page.set_connection(connected)
# Локальный прокси есть в обоих режимах; в TUN — только на 127.0.0.1.
if connected:
socks_port, http_port = self.controller.get_effective_proxy_ports()
self.dashboard_page.set_proxy_ports(socks_port, http_port)
else:
self.dashboard_page.set_proxy_ports(0, 0)
# Подключение/отключение меняет системный прокси — обновляем снимок.
self.dashboard_page.set_system_proxy_state(self.controller.query_system_proxy_state())
if not connected:
self._deferred_dashboard_metrics = None
self._deferred_process_stats = None
self._has_deferred_process_stats = False
if self.tray_connect_action is not None:
self.tray_connect_action.setText("Отключить" if connected else "Подключить")
self._refresh_tray_tooltip()
def _on_transition_state_changed(self, busy: bool, _message: str) -> None:
# Сначала факт (режим сессии), затем «занято»: иначе выбор плитки
# сверился бы с режимом сессии до перехода.
self.dashboard_page.set_active_tun_mode(self.controller.active_tun_mode())
self.dashboard_page.set_transition_busy(busy)
if self.tray_connect_action is not None:
self.tray_connect_action.setEnabled(not busy)
def _on_routing_changed(self, routing: RoutingSettings) -> None:
self.dashboard_page.set_routing_snapshot(routing)
if self.tray_mode_global is not None:
self.tray_mode_global.setChecked(routing.mode == "global")
if self.tray_mode_rule is not None:
self.tray_mode_rule.setChecked(routing.mode == "rule")
if self.tray_mode_direct is not None:
self.tray_mode_direct.setChecked(routing.mode == "direct")
def _on_settings_changed(self, settings: AppSettings) -> None:
self.settings_page.set_values(settings, self.controller.state.security)
self.zapret_page.set_target_settings(settings.zapret_target)
self.settings_page.set_encryption_active(self.controller.is_data_encrypted())
# Сверяем дашборд с реальным состоянием прокси Windows (быстрое
# чтение реестра; на не-Windows вернётся supported=False).
self.dashboard_page.set_system_proxy_state(self.controller.query_system_proxy_state())
self.dashboard_page.set_settings_snapshot(settings)
self._apply_theme(settings.theme, settings.accent_color)
routing_controls_enabled = bool(settings.tun_mode and settings.tun_engine == "tun2socks")
for action in (self.tray_mode_global, self.tray_mode_rule, self.tray_mode_direct):
if action is not None:
action.setEnabled(routing_controls_enabled)
self._refresh_tray_tooltip()
if self._app_update_scheduler_ready:
self._sync_app_update_timer(settings)
def _on_ping_updated(self, node_id: str, ping_ms: int | None) -> None:
self.nodes_page.update_ping(node_id, ping_ms)
self.nodes_page.refresh_detail(node_id)
node = self.controller.selected_node
if node and node.id == node_id:
self.dashboard_page.set_selected_latency(ping_ms)
def _on_live_metrics_updated(self, payload: dict[str, object]) -> None:
down_bps = float(payload.get("down_bps") or 0.0)
up_bps = float(payload.get("up_bps") or 0.0)
latency = payload.get("latency_ms")
latency_ms = int(latency) if isinstance(latency, int) else None
process_stats = payload.get("process_stats")
if not self._is_dashboard_active():
self._deferred_dashboard_metrics = (down_bps, up_bps, latency_ms)
if process_stats is not None:
self._deferred_process_stats = list(process_stats)
self._has_deferred_process_stats = True
return
self.dashboard_page.set_live_metrics(down_bps, up_bps, latency_ms)
if process_stats is not None:
self.dashboard_page.set_process_stats(process_stats)
def _is_dashboard_active(self) -> bool:
# Дашборд обновляется только когда его видно: открытая страница скрытого
# окна копила бы изменения раскладки, которые Qt разбирает при показе.
return (
self.stackedWidget.currentWidget() is self.dashboard_page
and self.isVisible()
and not self.isMinimized()
)
def _on_current_interface_changed(self, _index: int) -> None:
self._flush_deferred_dashboard_metrics()
def _flush_deferred_dashboard_metrics(self) -> None:
"""Доставляет дашборду то, что пришло, пока он был не виден (страница, свёрнутое окно, трей)."""
if not self._is_dashboard_active():
return
if self._deferred_dashboard_metrics is not None:
down_bps, up_bps, latency_ms = self._deferred_dashboard_metrics
self.dashboard_page.set_live_metrics(down_bps, up_bps, latency_ms)
self._deferred_dashboard_metrics = None
if self._has_deferred_process_stats:
self.dashboard_page.set_process_stats(self._deferred_process_stats or [])
self._deferred_process_stats = None
self._has_deferred_process_stats = False
def _on_xray_update_result(self, result: XrayCoreUpdateResult) -> None:
self.logs_page.append_line(f"[core-update] {result.status}: {result.message}")
if result.status == "error":
self.updates_page.set_xray_error(result.message)
elif result.status in {"updated", "up_to_date"}:
self.updates_page.set_xray_success(result.message)
else:
self.updates_page.set_xray_status(result.message)
if result.updated:
self.updates_page.set_xray_version(result.latest_version)
def _on_connectivity_test_done(self, ok: bool, message: str, elapsed_ms: int | None) -> None:
if ok and elapsed_ms is not None:
self.logs_page.append_line(f"[test] ok {elapsed_ms} ms | {message}")
else:
self.logs_page.append_line(f"[test] fail | {message}")
def _on_lock_state_changed(self, locked: bool) -> None:
if locked:
tour = active_tour(self)
if tour is not None:
tour.finish("interrupted")
self._show_status("warning", "Приложение заблокировано")
self._ensure_unlocked(startup=False)
def _on_auto_switch(self, node_name: str) -> None:
InfoBar.warning(
"Авто-переключение",
f"Сервер не отвечает. Переключение на {node_name}...",
position=InfoBarPosition.TOP_RIGHT,
duration=30000,
parent=self,
)
def _on_bulk_task_progress(self, task: str, current: int, total: int, completed: bool) -> None:
task = task.strip().lower()
if task not in {"ping", "speed"}:
return
total = max(1, total)
current = max(0, min(current, total))
title = "Пинг серверов" if task == "ping" else "Тест скорости"
self._bulk_task_tip_close_timer.stop()
if completed:
if task == "ping":
self.nodes_page.finish_ping_activity()
else:
self.nodes_page.finish_speed_activity()
if self._bulk_task_tip and self._bulk_task_type == task:
if task == "speed" and self._speed_test_was_cancelled:
self._set_bulk_task_tip_content(f"Остановлено {current}/{total}")
self._speed_test_was_cancelled = False
else:
self._set_bulk_task_tip_content(f"Завершено {current}/{total}")
self._bulk_task_tip_close_timer.start()
return
content = f"{current}/{total}"
if self._bulk_task_tip is None or self._bulk_task_type != task:
self._clear_bulk_task_tip()
tip = InfoBar(
icon=InfoBarIcon.INFORMATION,
title=title,
content=content,
isClosable=True,
duration=-1,
position=InfoBarPosition.TOP_RIGHT,
parent=self,
)
tip.setMinimumWidth(220)
tip.closedSignal.connect(self._on_bulk_task_tip_closed)
tip.show()
self._bulk_task_tip = tip
self._bulk_task_type = task
return
update_step = max(1, (total + 99) // 100)
if current != total and current % update_step:
return
self._set_bulk_task_tip_content(content)
def _on_bulk_task_tip_closed(self) -> None:
self._bulk_task_tip = None
self._bulk_task_type = None
def _set_bulk_task_tip_content(self, content: str) -> None:
if self._bulk_task_tip is None:
return
self._bulk_task_tip.content = content
self._bulk_task_tip.contentLabel.setText(content)
def _clear_bulk_task_tip(self) -> None:
if self._bulk_task_tip is None:
return
tip = self._bulk_task_tip
self._bulk_task_tip = None
self._bulk_task_type = None
tip.close()
def _connect_startup_diagnostics(self) -> None:
"""Показать сообщения bootstrap и подписаться на признак подмены DNS."""
from ..application import startup_notices
from ..network import bootstrap_dns
status = self.controller.status
def tampering(_host: str) -> None:
# Вызывается из фонового потока: сигнал Qt доставит сообщение в GUI.
status.emit(
"warning",
"DNS вашего провайдера отвечает, что VPN-сервера не существует, хотя он есть: "
"ответы подменяются. Приложение использует защищённый DNS.",
)
bootstrap_dns.set_tampering_listener(tampering)
def show_pending() -> None:
for level, message in startup_notices.drain():
status.emit(level, message)
QTimer.singleShot(2000, show_pending)
def _show_status(self, level: str, message: str) -> None:
message = connection_message(message)
level = level.lower().strip()
long_duration = level.endswith("-long")
if long_duration:
level = level[:-5]
dashboard = getattr(self, "dashboard_page", None)
if (level == "error" and dashboard is not None
and dashboard._connection_phase == "error"
and dashboard._connection_message == message
and self._is_dashboard_active()):
return
if level == "error":
InfoBar.error("Ошибка", message, position=InfoBarPosition.TOP_RIGHT, duration=6000, parent=self)
elif level == "warning":
InfoBar.warning(
"Внимание",
message,
position=InfoBarPosition.TOP_RIGHT,
duration=10000 if long_duration else 3000,
parent=self,
)
elif level == "success":
InfoBar.success("Успешно", message, position=InfoBarPosition.TOP_RIGHT, duration=2200, parent=self)
else:
InfoBar.info(
"Инфо",
message,
position=InfoBarPosition.TOP_RIGHT,
duration=10000 if long_duration else 2200,
parent=self,
)
def _import_nodes_from_clipboard(self) -> None:
clipboard = QApplication.clipboard()
text = clipboard.text().strip() if clipboard is not None else ""
if not text:
self._show_status("warning", "Буфер обмена пуст")
return
added, errors = self.controller.import_nodes_from_text(text)
if added:
self._show_status("success", f"Импортировано серверов: {added}")
if errors:
# Раньше показывались только первые две ошибки, остальные терялись молча —
# при импорте большой подписки это скрывало реальный масштаб проблемы.
for line in errors:
self.controller._log(f"[import] {line}")
preview = "; ".join(errors[:5])
if len(errors) > 5:
preview = f"{preview}; …ещё {len(errors) - 5} (полный список — на странице логов)"
self._show_status(
"warning-long",
f"Не удалось импортировать ссылок: {len(errors)}. {preview}",
)
if not added and not errors:
self._show_status("warning", "Новых серверов не импортировано")
def _ping_requested(self, ids: set[str]) -> None:
self.nodes_page.start_ping_activity(ids or None)
if ids:
self.controller.ping_nodes(ids)
else:
self.controller.ping_nodes(None)
def _speed_test_requested(self, ids: set[str]) -> None:
started = self.controller.speed_test_nodes(ids or None)
if started:
self._speed_test_was_cancelled = False
self.nodes_page.start_speed_activity()
def _cancel_speed_test_requested(self) -> None:
if self.controller.cancel_speed_test():
self.nodes_page.mark_speed_test_stopping()
if self._bulk_task_tip and self._bulk_task_type == "speed":
self._set_bulk_task_tip_content("Останавливаю...")
def _on_speed_test_cancelled(self, _completed: int, _total: int) -> None:
self._speed_test_was_cancelled = True
def _on_speed_progress_updated(self, node_id: str, percent: int) -> None:
self.nodes_page.update_speed_progress(node_id, percent)
def _on_speed_updated(self, node_id: str, speed_mbps: float | None, _is_alive: bool) -> None:
self.nodes_page.update_speed(node_id, speed_mbps)
self.nodes_page.refresh_detail(node_id)
def _on_edit_node(self, node_id: str) -> None:
node = self.controller._get_node_by_id(node_id)
if not node:
return
self.nodes_page.open_node_editor(node, self.controller.get_all_groups())
def _on_node_edit_saved(self, node_id: str, fields: dict) -> None:
self.controller.update_node(node_id, fields)
self.nodes_page.close_editor()
self._show_status("success", "Сервер обновлён")
def _on_bulk_edit_nodes(self, node_ids: set[str]) -> None:
if not node_ids:
return
self.nodes_page.open_bulk_editor(node_ids, self.controller.get_all_groups())
def _on_bulk_edit_applied(self, node_ids: set[str], operations: dict) -> None:
self.nodes_page.close_editor()
if operations["group"] or operations["add_tags"] or operations["remove_tags"]:
count = self.controller.bulk_update_nodes(set(node_ids), operations)
self._show_status("success", f"Обновлено серверов: {count}")
def _export_outbound_json(self, node_id: str) -> None:
payload = self.controller.export_node_outbound_json(node_id)
if not payload:
self._show_status("warning", "Выберите сервер для экспорта")
return
self._save_json_payload(payload, "outbound.json")
def _export_runtime_json(self, node_id: str) -> None:
payload = self.controller.export_runtime_config_json(node_id)
if not payload:
self._show_status("warning", "Выберите сервер для экспорта")
return
suggested_name = "singbox_config.json" if self.controller.is_singbox_editor_mode() else "xray_config.json"
self._save_json_payload(payload, suggested_name)
def _save_json_payload(self, payload: str, suggested_name: str) -> None:
file_path, _ = QFileDialog.getSaveFileName(self, "Экспорт JSON", suggested_name, "JSON files (*.json)")
if file_path:
with open(file_path, "w", encoding="utf-8") as file:
file.write(payload)
self._show_status("success", f"JSON экспортирован: {file_path}")
return
clipboard = QApplication.clipboard()
if clipboard is not None:
clipboard.setText(payload)
self._show_status("info", "Экспорт отменён, JSON скопирован в буфер обмена")
@staticmethod
def _core_title(core: str) -> str:
return "sing-box" if core == "singbox" else "xray"
def _get_core_profile_dir(self, core: str, kind: str) -> Path:
if core == "singbox":
return self.controller.get_singbox_config_dir() if kind == "config" else self.controller.get_singbox_template_dir()
return self.controller.get_xray_config_dir() if kind == "config" else self.controller.get_xray_template_dir()
def _get_active_core_profile_relative(self, core: str, kind: str) -> str:
base_dir = self._get_core_profile_dir(core, kind).resolve()
if core == "singbox":
path = (
self.controller.get_active_singbox_config_path()
if kind == "config"
else self.controller.get_active_singbox_template_path()
)
else:
path = (
self.controller.get_active_xray_config_path()
if kind == "config"
else self.controller.get_active_xray_template_path()
)
if path is None:
return ""
try:
return path.resolve().relative_to(base_dir).as_posix()
except ValueError:
return ""
def _list_core_profile_items(self, core: str, kind: str) -> list[tuple[str, str]]:
base_dir = self._get_core_profile_dir(core, kind).resolve()
items: list[tuple[str, str]] = []
for path in sorted(base_dir.rglob("*.json")):
relative = path.relative_to(base_dir).as_posix()
items.append((relative, relative))
return items
def _refresh_core_profile_choices(self, core: str) -> None:
config_items = self._list_core_profile_items(core, "config")
template_items = self._list_core_profile_items(core, "template")
self.configs_page.set_available_configs(
core,
config_items,
self._get_active_core_profile_relative(core, "config"),
)
self.configs_page.set_available_templates(
core,
template_items,
self._get_active_core_profile_relative(core, "template"),
)
def _sync_core_template_for_config(self, core: str, config_path: Path) -> Path | None:
if core == "singbox":
template_path = self.controller._default_singbox_template_path_for_config(config_path)
if template_path is not None:
self.controller._set_active_singbox_template_path(template_path, emit_signal=False)
return template_path
template_path = self.controller._default_xray_template_path_for_config(config_path)
if template_path is not None:
self.controller._set_active_xray_template_path(template_path, emit_signal=False)
return template_path
def _load_config_editor_documents(self) -> None:
for core in ("singbox",):
self._load_core_config_document(core)
def _load_core_config_document(self, core: str) -> None:
try:
if core == "singbox":
path, text = self.controller.load_active_singbox_config_text()
else:
path, text = self.controller.load_active_xray_config_text()
except Exception as exc:
self.configs_page.set_status(core, "error", str(exc))
return
self.configs_page.set_document(core, path, text)
template_path = (
self.controller.get_active_singbox_template_path()
if core == "singbox"
else self.controller.get_active_xray_template_path()
)
self._refresh_core_profile_choices(core)
self.configs_page.set_template_source(core, template_path)
self.configs_page.set_status(core, "info", f"Открыта активная копия: {path.name}")
def _select_core_config(self, core: str, relative_path: str) -> None:
try:
if core == "singbox":
path, text = self.controller.load_singbox_config_text(relative_path)
else:
path, text = self.controller.load_xray_config_text(relative_path)
template_path = self._sync_core_template_for_config(core, path)
if template_path is None:
template_path = (
self.controller.get_active_singbox_template_path()
if core == "singbox"
else self.controller.get_active_xray_template_path()
)
except Exception as exc:
self._refresh_core_profile_choices(core)
self.configs_page.set_status(core, "error", str(exc))
self._show_status("error", str(exc).splitlines()[0])
return
self.configs_page.set_document(core, path, text)
self._refresh_core_profile_choices(core)
self.configs_page.set_template_source(core, template_path)
self.configs_page.set_status(core, "info", f"Открыт конфиг: {path.name}")
self._show_status("success", f"Открыт конфиг: {path.name}")
def _select_core_template(self, core: str, relative_path: str) -> None:
try:
if core == "singbox":
path, text = self.controller.import_singbox_template(relative_path)
template_path = self.controller.get_active_singbox_template_path()
else:
path, text = self.controller.import_xray_template(relative_path)
template_path = self.controller.get_active_xray_template_path()
except Exception as exc:
self._refresh_core_profile_choices(core)
self.configs_page.set_status(core, "error", str(exc))
self._show_status("error", str(exc).splitlines()[0])
return
self.configs_page.set_document(core, path, text)
self._refresh_core_profile_choices(core)
self.configs_page.set_template_source(core, template_path)
self.configs_page.set_status(core, "info", f"Применён шаблон: {Path(relative_path).name}")
self._show_status("success", f"Применён шаблон: {Path(relative_path).name}")
def _open_core_config(self, core: str) -> None:
title = self._core_title(core)
base_dir = str(
self.controller.get_singbox_template_dir()
if core == "singbox"
else self.controller.get_xray_template_dir()
)
file_path, _ = QFileDialog.getOpenFileName(self, f"Импортировать {title} template", base_dir, "JSON files (*.json)")
if not file_path:
return
try:
if core == "singbox":
path, text = self.controller.import_singbox_template(file_path)
template_path = self.controller.get_active_singbox_template_path()
else:
path, text = self.controller.import_xray_template(file_path)
template_path = self.controller.get_active_xray_template_path()
except Exception as exc:
self.configs_page.set_status(core, "error", str(exc))
self._show_status("error", str(exc).splitlines()[0])
return
self.configs_page.set_document(core, path, text)
self._refresh_core_profile_choices(core)
self.configs_page.set_template_source(core, template_path)
self.configs_page.set_status(core, "info", f"Импортирован template и обновлена активная копия: {path.name}")
self._show_status("success", f"Импортирован template и обновлён активный конфиг: {Path(file_path).name}")
def _reset_core_config_to_template(self, core: str) -> None:
if core == "singbox":
ok, path, message = self.controller.reset_active_singbox_config_to_template()
template_path = self.controller.get_active_singbox_template_path()
loader = self.controller.load_active_singbox_config_text
else:
ok, path, message = self.controller.reset_active_xray_config_to_template()
template_path = self.controller.get_active_xray_template_path()
loader = self.controller.load_active_xray_config_text
if not ok or path is None:
self.configs_page.set_status(core, "error", message)
self._show_status("error", message.splitlines()[0])
return
loaded_path, text = loader()
self.configs_page.set_document(core, loaded_path, text)
self._refresh_core_profile_choices(core)
self.configs_page.set_template_source(core, template_path)
self.configs_page.set_status(core, "success", message)
self._show_status("success", message)
def _save_core_config(self, core: str, text: str) -> None:
try:
if core == "singbox":
path = self.controller.save_singbox_config_text(text)
path, saved_text = self.controller.load_active_singbox_config_text()
else:
path = self.controller.save_xray_config_text(text)
saved_text = text
except Exception as exc:
self.configs_page.set_status(core, "error", str(exc))
self._show_status("error", str(exc).splitlines()[0])
return
self.configs_page.mark_saved(core, path, saved_text)
if saved_text != text:
self.configs_page.set_document(core, path, saved_text)
message = f"Сохранено: {path.name}"
if self.controller.connected:
# Файл на диске новый, а работающее ядро — по старым правилам.
self._config_saved_unapplied = True
message += ". Работающее подключение — по прежним правилам: нажмите «Применить»."
self.configs_page.set_status(core, "success", message)
self._show_status("success", message)
def _validate_core_config(self, core: str, text: str) -> None:
if core == "singbox":
repair = self.controller.try_repair_singbox_json_text(text)
if repair is not None:
self.configs_page.replace_editor_text(core, repair.repaired_text)
message = (
f"Конфиг восстановлен автоматически: {repair.description}. "
"Проверьте результат и нажмите «Сохранить» или «Применить»."
)
self.configs_page.set_status(core, "warning", message)
self._show_status("warning-long", message)
return
ok, message = self.controller.validate_singbox_json_text(text)
if ok:
self._start_singbox_editor_check(text)
return
else:
ok, message = self.controller.validate_xray_json_text(text)
self.configs_page.set_status(core, "success" if ok else "error", message)
if ok:
self._show_status("success", "JSON корректен")
def _start_singbox_editor_check(self, text: str) -> None:
"""«Проверить»: сам sing-box check по редактируемому JSON, вне GUI-потока."""
checker = getattr(self, "_singbox_editor_check", None)
if checker is None:
checker = SingboxEditorCheck(self)
checker.finished.connect(self._on_singbox_editor_check_finished)
self._singbox_editor_check = checker
self._singbox_editor_check_generation = checker.start(self.controller.state.settings.singbox_path, text)
self.configs_page.set_status("singbox", "info", "JSON корректен. Проверяю ядром sing-box…")
def _start_route_check(self, host: str, text: str) -> None:
"""«Проверить сайт»: маршрут по редактируемому JSON, наборы спрашиваем у ядра — вне GUI-потока."""
checker = getattr(self, "_route_check", None)
if checker is None:
checker = RouteCheck(self)
checker.finished.connect(self._on_route_check_finished)
self._route_check = checker
settings = self.controller.state.settings
self._route_check_generation = checker.start(settings.singbox_path, text, host, tun=bool(settings.tun_mode))
def _on_route_check_finished(self, generation: int, verdict, error: str) -> None:
if generation == getattr(self, "_route_check_generation", 0):
unapplied = bool(getattr(self, "_config_saved_unapplied", False)) and self.controller.connected
self.configs_page.show_route_check(verdict, error, unapplied=unapplied)
def _on_singbox_editor_check_finished(self, generation: int, level: str, message: str) -> None:
if generation != getattr(self, "_singbox_editor_check_generation", 0):
return
self.configs_page.set_status("singbox", level, message)
self._show_status("success" if level == "success" else "warning-long" if level == "warning" else "error", message.splitlines()[0])
def _apply_core_config(self, core: str, text: str) -> None:
if core == "singbox":
ok, path, message = self.controller.apply_singbox_config_text(text)
else:
ok, path, message = self.controller.apply_xray_config_text(text)
if not ok:
self.configs_page.set_status(core, "error", message)
self._show_status("error", message.splitlines()[0])
return
if path is not None:
if core == "singbox":
loaded_path, saved_text = self.controller.load_active_singbox_config_text()
self.configs_page.set_document(core, loaded_path, saved_text)
else:
self.configs_page.mark_saved(core, path, text)
self._config_saved_unapplied = False
level = "info" if "Применяю" in message else "success"
self.configs_page.set_status(core, level, message)
self._show_status(level, message.splitlines()[0])
# ── Обучающая экскурсия ─────────────────────────────────────
def _start_tour(self) -> None:
if active_tour(self) is not None:
return
self._tour_started_this_session = True
overlay = TourOverlay(self, TOUR_STEPS)
overlay.step_opening.connect(self._open_tour_step)
overlay.step_upcoming.connect(self._prewarm_tour_step)
overlay.finished.connect(self._on_tour_finished)
# Страницы под затемнением переключаются без «выезда»: его всё равно
# не видно, а перерисовка страницы на каждом кадре даёт рывки.
self._tour_restores_page_animation = self.stackedWidget.isAnimationEnabled()
self.stackedWidget.setAnimationEnabled(False)
overlay.start()
def _on_tour_nav_clicked(self) -> None:
# Невыделяемый пункт панель сама не сворачивает: в узком окне
# выпавшее меню осталось бы поверх подсвеченных элементов.
panel = self.navigationInterface.panel
if panel.displayMode == NavigationDisplayMode.MENU:
panel.collapse()
self._start_tour()
def _open_tour_step(self, step: TourStep) -> None:
if step.page is None:
return
page = getattr(self, step.page)
if step.section is not None:
self._open_routing_section(step.section)
else:
self.switchTo(page)
# Шаг показывает саму страницу, а не открытую на ней вложенную.
show_root = getattr(page, "show_root", None)
if show_root is not None:
show_root()
def _prewarm_tour_step(self, step: TourStep) -> None:
"""Построить страницу следующего шага заранее, пока читают текущий."""
ensure = getattr(getattr(self, step.page or "", None), "ensure_page", None)
if ensure is not None and not self._quitting:
ensure()
def _on_tour_finished(self, reason: str) -> None:
self.stackedWidget.setAnimationEnabled(self._tour_restores_page_animation)
# «Пройдена» — только по решению человека: дошёл до конца или закрыл
# сам. Окно ушло в трей или приложение прервало экскурсию — покажем
# её снова при следующем запуске.
if reason in ("done", "skipped"):
self._mark_tour_done()
if reason == "done":
self.switchTo(self.dashboard_page)
def _mark_tour_done(self) -> None:
settings = self.controller.state.settings
if not settings.tour_done:
settings.tour_done = True
self.controller.schedule_save()
def _tour_autostart_wanted(self) -> bool:
return not (
self._quitting
or self._tour_started_this_session
or self.controller.state.settings.tour_done
)
def _schedule_tour_autostart(self) -> None:
timer = getattr(self, "_tour_autostart_timer", None)
if timer is None or not getattr(self, "_geometry_persistence_ready", False):
return
if not self._tour_autostart_wanted() or not self.isVisible() or timer.isActive():
return
self._tour_autostart_tries = 0
timer.start(TOUR_AUTOSTART_DELAY_MS)
def _try_autostart_tour(self) -> None:
if not self._tour_autostart_wanted() or active_tour(self) is not None or not self.isVisible():
return
app = QApplication.instance()
busy = (
self.isMinimized()
# Окно за другими окнами (например, после фонового обновления):
# экскурсию никто бы не увидел.
or not self.isActiveWindow()
or self.controller.locked
or getattr(self, "_update_in_progress", False)
or app.activeModalWidget() is not None
or app.activePopupWidget() is not None
)
if busy:
self._tour_autostart_tries += 1
if self._tour_autostart_tries < TOUR_AUTOSTART_MAX_TRIES:
self._tour_autostart_timer.start(TOUR_AUTOSTART_RETRY_MS)
return
self._start_tour()
def _on_settings_page_saved(self, settings: AppSettings) -> None:
current = self.controller.state.settings
for name in _WINDOW_OWNED_SETTINGS:
setattr(settings, name, deepcopy(getattr(current, name)))
self.controller.update_settings(settings)
def _on_dashboard_tun_toggled(self, checked: bool) -> None:
from copy import deepcopy
settings = deepcopy(self.controller.state.settings)
settings.set_tun_mode(checked)
self.controller.update_settings(settings)
def _on_dashboard_proxy_toggled(self, checked: bool) -> None:
from copy import deepcopy
settings = deepcopy(self.controller.state.settings)
settings.enable_system_proxy = checked
self.controller.update_settings(settings)
def _set_mode_only(self, mode: str) -> None:
from copy import deepcopy
routing = deepcopy(self.controller.state.routing)
routing.mode = mode
self.controller.update_routing(routing)
def _set_mode_from_tray(self, mode: str) -> None:
self._set_mode_only(mode)
def _set_password(self, password: str) -> None:
self.controller.set_master_password(password)
self._show_status("success", "Мастер-пароль включён")
def _update_auto_lock_minutes(self, minutes: int) -> None:
self.controller.state.security.auto_lock_minutes = max(1, minutes)
self.controller.save()
def _clear_logs_view(self) -> None:
self.logs_page.clear_view()
def _export_diagnostics(self) -> None:
path = self.controller.build_diagnostics()
self._show_status("success", f"Диагностика экспортирована: {path}")
from .file_manager import reveal_file
try:
reveal_file(path)
except OSError:
self._show_status("warning", f"Архив сохранён, но папку открыть не удалось: {path}")
# ── Zapret ───────────────────────────────────────────────
def _init_zapret_page(self, infos=None) -> None:
if infos is None:
from ..engines.zapret import presets
infos = presets.list_preset_infos()
settings = self.controller.state.settings
saved = settings.zapret_preset
self._zapret_error = ""
self.zapret_page.set_presets(infos, saved)
self.zapret_page.set_target_settings(settings.zapret_target)
self.zapret_page.set_selected_node(self.controller.selected_node)
self.zapret_page.set_autostart(settings.zapret_autostart)
self._refresh_zapret_status()
if settings.zapret_autostart and saved and any(p.name == saved for p in infos):
QTimer.singleShot(1000, lambda: self._on_zapret_start(saved))
def _refresh_zapret_status(self, *_args) -> None:
from .zapret_page import ZapretStatus
zapret = self.controller.zapret
preset = zapret.plan.preset if zapret.plan is not None else ""
if zapret.running and zapret.plan is not None:
self._zapret_error = ""
status = ZapretStatus("running", preset)
elif zapret.active or self.controller.bypass.manual_start_pending:
status = ZapretStatus("starting", preset or self.controller.state.settings.zapret_preset)
elif getattr(self, "_zapret_error", ""):
status = ZapretStatus("error", message=self._zapret_error)
else:
status = ZapretStatus("stopped")
self.zapret_page.set_status(status)
def _on_zapret_start(self, preset_name: str) -> None:
self._zapret_error = ""
self.controller.start_zapret(preset_name)
self._refresh_zapret_status()
def _on_zapret_stop(self) -> None:
self._zapret_error = ""
self.controller.stop_zapret()
self._refresh_zapret_status()
def _on_zapret_error(self, message: str) -> None:
self._zapret_error = message
self._refresh_zapret_status()
self._show_status("error", f"Zapret: {message}")
def _on_zapret_plan_changed(self, plan) -> None:
self.zapret_page.set_rule(plan.rule if plan is not None else None)
self._refresh_zapret_status()
def _on_zapret_preset_selected(self, name: str) -> None:
self.controller.state.settings.zapret_preset = name
self.controller.schedule_save()
def _on_zapret_autostart_changed(self, enabled: bool) -> None:
self.controller.state.settings.zapret_autostart = bool(enabled)
self.controller.schedule_save()
def _check_updates(self, silent: bool = False) -> None:
if getattr(self, "_update_in_progress", False):
return
self._update_in_progress = True
self._pending_update: AppUpdate | None = None
checker = UpdateChecker(proxy_url=self._active_update_proxy_url(), parent=self)
self._update_checker = checker
checker.result.connect(
lambda update: self._on_update_check_result(update, silent, checker)
)
checker.error.connect(
lambda error: self._on_update_check_error(error, silent, checker)
)
checker.finished.connect(checker.deleteLater)
checker.start()
if not silent:
self.updates_page.show_checking()
def _sync_app_update_timer(self, settings: AppSettings) -> None:
"""Keep the one-shot/periodic application update timer in sync."""
if self._quitting or not settings.check_updates:
self._app_update_timer.stop()
return
if self._app_update_timer.isActive():
return
# The first check remains the existing startup check. After it fires,
# _on_app_update_timer_timeout switches this same timer to the 30-minute
# cadence instead of creating another timer.
self._app_update_timer.setInterval(APP_UPDATE_INITIAL_DELAY_MS)
self._app_update_timer.start()
def _on_app_update_timer_timeout(self) -> None:
if self._quitting or not self._app_update_scheduler_ready:
return
settings = self.controller.state.settings
if not settings.check_updates:
return
# _check_updates owns the concurrent-check guard. Even if a user
# started a manual check at the same moment, this timer callback cannot
# create a second UpdateChecker thread.
# Пока сервер с очередью на связи, о новой версии сообщает он: проверка
# раз в полчаса от каждой программы только нагружала бы Forgejo. Первая
# проверка после запуска идёт всегда.
signal = getattr(self, "_update_signal", None)
# Разрешение сервера ещё действует, а версия не встала (сорвалось
# скачивание или установка): повторяем здесь же — второго разрешения
# сервер не пришлёт. Паузы и лимит попыток держит auto_update.
if (
not getattr(self, "_app_update_first_check_done", False)
or signal is None
or not signal.queue_reachable()
or signal.has_live_grant()
):
self._app_update_first_check_done = True
self._check_updates(silent=True)
if self._quitting or not self.controller.state.settings.check_updates:
return
self._app_update_timer.setInterval(APP_UPDATE_INTERVAL_MS)
self._app_update_timer.start()
def _stop_app_update_timer(self) -> None:
self._app_update_scheduler_ready = False
self._app_update_timer.stop()
signal = getattr(self, "_update_signal", None)
if signal is not None:
signal.stop()
def _start_update_signal(self) -> None:
"""Запускает слушателя сервера: о новой версии сообщает он, он же
ведёт очередь на скачивание и раздаёт версию по ступеням."""
if self._update_signal is not None or self._quitting:
return
try:
from ..updates.update_signal import UpdateSignal
settings = self.controller.state.settings
signal = UpdateSignal(
is_enabled=lambda: bool(
not self._quitting and settings.check_updates and settings.allow_updates
),
window_shown=lambda: bool(self.isVisible() and not self.isMinimized()),
connected=lambda: bool(self.controller.connected),
parent=self,
)
signal.granted.connect(self._on_update_granted)
signal.set_report(self._previous_update_report)
signal.start()
self._update_signal = signal
except Exception:
# Без слушателя программа проверяет обновления сама, как раньше.
self.controller._logger.exception("[update] Слушатель сервера обновлений не запущен")
def _on_update_granted(self, version: str, attempt: int = 0) -> None:
"""Сервер разрешил обновиться: проверяем выпуск и ставим его."""
if self._quitting or not self.controller.state.settings.check_updates:
return
if getattr(self, "_update_in_progress", False):
# Идёт другая проверка (она могла начаться до выхода версии).
if attempt < 4:
QTimer.singleShot(15000, lambda: self._on_update_granted(version, attempt + 1))
return
self._check_updates(silent=True)
def _update_wait_reason(self, update: AppUpdate) -> str:
"""Почему фоновая установка ждёт; пустая строка — можно ставить.
Пока сервер ведёт очередь, программа ставит версию только с его
разрешения: иначе все пошли бы за архивом разом. Если сервера с
очередью нет, всё работает как раньше. Посреди игры или видео на весь
экран обновление ждёт в любом случае: перезапуск оборвал бы
подключение.
"""
signal = self._update_signal
if signal is None:
return ""
if signal.queue_reachable() and not signal.is_granted(update.version):
return "версия раздаётся по очереди, ждём разрешения сервера"
if signal.is_urgent(update.version):
# Установленную версию сервер отозвал: сломанную меняем сразу.
return ""
busy = signal.busy_reason()
if busy:
if not getattr(self, "_busy_recheck_pending", False):
self._busy_recheck_pending = True
QTimer.singleShot(UPDATE_BUSY_RECHECK_MS, self._recheck_update_when_free)
return f"{busy} — обновление поставится, когда освободитесь"
return ""
def _recheck_update_when_free(self) -> None:
self._busy_recheck_pending = False
if self._quitting or not self.controller.state.settings.check_updates:
return
signal = getattr(self, "_update_signal", None)
if signal is not None and signal.busy_reason():
# Всё ещё в игре: смотрим на экран дальше, выпуск не трогаем.
self._busy_recheck_pending = True
QTimer.singleShot(UPDATE_BUSY_RECHECK_MS, self._recheck_update_when_free)
return
self._check_updates(silent=True)
def _active_update_proxy_url(self) -> str | None:
if not self.controller.connected:
return None
from ..constants import PROXY_HOST
port = self.controller.get_effective_http_proxy_port()
return f"http://{PROXY_HOST}:{port}" if port else None
def _on_update_check_error(
self,
err: str,
silent: bool,
checker: UpdateChecker | None = None,
) -> None:
if checker is not None and checker is not getattr(self, "_update_checker", None):
return
self._update_in_progress = False
if silent:
message = " ".join(str(err).split()) or "неизвестная ошибка"
# Use the file logger directly: the failure is retained in
# data/logs/app.log without producing a toast or a repeated UI
# notification for every 30-minute background attempt.
self.controller._logger.warning(
"[update] Фоновая проверка обновлений не выполнена: %s",
message,
)
return
if not silent:
self.updates_page.show_idle()
self.updates_page.set_app_error(f"Ошибка проверки: {err}")
self._show_status("error", f"Ошибка проверки обновлений: {err}")
def _on_update_check_result(
self,
update: AppUpdate | None,
silent: bool,
checker: UpdateChecker | None = None,
) -> None:
if checker is not None and checker is not getattr(self, "_update_checker", None):
return
self._update_in_progress = False
if update is None:
if not silent:
self.updates_page.show_up_to_date()
self._show_status("info", "У вас установлена последняя версия")
return
if not self.controller.state.settings.allow_updates:
self._pending_update = None
self.updates_page.show_idle()
self.updates_page.set_app_status(
f"Доступна новая версия: v{update.version}. Установка отключена в настройках"
)
if not silent:
self._show_status("warning", "Доступно обновление, но установка отключена в настройках")
return
self._pending_update = update
self.updates_page.show_update_available(update.version)
try:
self.updates_page.download_btn.clicked.disconnect()
except TypeError:
pass
# Кнопка на странице — ручной путь в обход лимита автопопыток. Она
# запоминает свою версию: новая проверка обнуляет _pending_update, а
# кнопка всё ещё на экране — 14.10.2026 нажатие в этот миг запускало
# загрузку «ничего» с ошибкой «ни напрямую, ни через прокси».
self.updates_page.download_btn.clicked.connect(
lambda _checked=False, chosen=update: self._start_update_download(chosen)
)
# Ручная проверка — уже действие пользователя: ставим сразу.
if not silent:
self._start_update_download(update)
return
# Фоновая проверка ставит обновление сама, без окон. Модальное окно
# здесь раньше и давало «вечную» просьбу обновиться: после каждого
# отката или перезапуска оно появлялось снова.
reason = self._auto_install_block_reason(update) or self._update_wait_reason(update)
if reason:
self.controller._logger.info(
"[update] Автоустановка v%s отложена: %s", update.version, reason
)
self.updates_page.set_app_status(
f"Доступна новая версия: v{update.version}. "
f"Автоустановка отложена: {reason}"
)
return
self._start_update_download(update, background=True)
def _auto_install_block_reason(self, update: AppUpdate) -> str:
if self.controller.state.security.enabled:
# После перезапуска приложение ждёт пароль и не подключается —
# тихая установка оставила бы пользователя без VPN.
return "включена защита паролем, установите вручную"
return auto_install_block_reason(update.version, APP_VERSION)
def _start_update_download(self, update: AppUpdate | None, *, background: bool = False) -> None:
if update is None:
return
if not self.controller.state.settings.allow_updates:
self.updates_page.show_idle()
self.updates_page.set_app_status("Установка обновлений отключена в настройках")
if not background:
self._show_status("warning", "Установка обновлений отключена в настройках")
return
downloader = getattr(self, "_update_downloader", None)
if downloader is not None and downloader.isRunning():
return
self._update_in_progress = True
self._update_background = background
if not background:
self.switchTo(self.updates_page)
self.updates_page.show_download_progress(0)
proxy_url = self._active_update_proxy_url()
self._update_downloader = UpdateDownloader(update, proxy_url=proxy_url, parent=self)
self._update_downloader.progress.connect(self.updates_page.show_download_progress)
self._update_downloader.status.connect(self.updates_page.set_app_status)
self._update_downloader.finished_ok.connect(self._on_update_ready)
self._update_downloader.error.connect(self._on_update_error)
self._update_downloader.start()
def _restart_update_in_tray(self, background: bool) -> bool:
if not self._tray_available:
return False
if not background:
return not self.isVisible()
# Тихая установка не должна выводить окно поверх чужих программ:
# после перезапуска оно показывается, только если пользователь сейчас
# в нём и работает. Свёрнутое или фоновое окно уходит в трей.
# Только существующий флаг --tray: откат запускает прежнюю сборку с теми
# же аргументами, а новый флаг она бы не поняла.
return not (self.isVisible() and not self.isMinimized() and self.isActiveWindow())
def _on_update_ready(self) -> None:
self.updates_page.set_app_status("Обновление загружено. Перезапуск...")
if not self._update_background:
self._show_status("success", "Обновление загружено. Перезапуск...")
self._update_apply_deadline = time.monotonic() + UPDATE_APPLY_MAX_WAIT_S
QTimer.singleShot(1500, self._apply_downloaded_update)
def _apply_downloaded_update(self) -> None:
if self._quitting:
return
downloader = self._update_downloader
prepared = getattr(downloader, "prepared", None)
if downloader is None or prepared is None:
return
# Перезапуск посреди переключения сервера оставил бы ядро в
# полусостоянии; ждём конца перехода, но не бесконечно.
if (
self.controller.transition_busy()
and time.monotonic() < self._update_apply_deadline
):
QTimer.singleShot(UPDATE_APPLY_RETRY_MS, self._apply_downloaded_update)
return
reconnect = bool(self.controller.connected or self.controller._desired_connected)
try:
signal = getattr(self, "_update_signal", None)
record_attempt(
downloader.update.version,
reconnect=reconnect,
# С какой версии и по какому разрешению идёт обновление: новая
# версия сообщит серверу, что оно дошло.
from_version=APP_VERSION,
granted_at=signal.granted_at(downloader.update.version) if signal is not None else 0.0,
# Какой ступенью и сколько скачано: сервер видит, как работает хранилище.
via=str(getattr(downloader, "via", "") or ""),
downloaded=int(getattr(downloader, "downloaded", 0) or 0),
)
plan = self._install_plan(prepared)
installer = launch_installer(plan)
except Exception as exc:
self.controller._logger.exception("[update] Не удалось запустить установку")
self._on_update_error(f"Не удалось запустить установку: {exc}", why="handoff")
return
self.controller._logger.info(
"[update] Установка v%s: перезапуск (подключение будет %s)",
downloader.update.version,
"восстановлено" if reconnect else "выключено",
)
self._await_installer(
installer, plan.ready_marker, time.monotonic() + INSTALLER_START_MAX_WAIT_S
)
def _await_installer(self, installer, ready_marker: Path, deadline: float) -> None:
"""Выйти, когда установщик подтвердил запуск; иначе остаться работать."""
if self._quitting:
return
if ready_marker.exists():
self._quit_for_update()
return
exit_code = installer.poll()
if exit_code is None and time.monotonic() < deadline:
QTimer.singleShot(
INSTALLER_START_POLL_MS,
lambda: self._await_installer(installer, ready_marker, deadline),
)
return
if exit_code is None:
# Молчащий установщик нельзя оставлять: позже он дождался бы
# своего и закрыл работающее приложение.
installer.kill()
reason = "установщик не ответил"
else:
reason = f"установщик завершился с кодом {exit_code}"
self.controller._logger.error("[update] Установка не началась: %s", reason)
self._on_update_error(f"Не удалось запустить установку: {reason}", why="handoff")
def _install_plan(self, prepared: PreparedUpdate) -> InstallPlan:
settings = self.controller.state.settings
geometry = self.geometry()
return InstallPlan(
version=prepared.version,
app_pid=os.getpid(),
app_dir=BASE_DIR,
source_dir=prepared.source_dir,
work_dir=prepared.work_dir,
start_in_tray=self._restart_update_in_tray(self._update_background),
theme=settings.theme,
accent=settings.accent_color,
# Окно обновления встаёт на место главного окна, если оно на экране.
anchor=(
(geometry.x(), geometry.y(), geometry.width(), geometry.height())
if self.isVisible() and not self.isMinimized()
else None
),
)
def _on_update_error(self, err: str, why: str = "") -> None:
self._update_in_progress = False
self.updates_page.show_idle()
self.updates_page.set_app_error(f"Ошибка: {err}")
downloader = getattr(self, "_update_downloader", None)
signal = getattr(self, "_update_signal", None)
if self._update_background and downloader is not None and signal is not None:
# Сервер обновлений узнает, почему версия не дошла до установки.
signal.note_miss(downloader.update.version, why or getattr(downloader, "failure_code", "") or "other")
if downloader is not None and getattr(downloader, "failure_permanent", False):
# Неисправный архив не исправится повторной загрузкой: такая
# попытка идёт в тот же лимит, что и неудачная установка.
try:
record_attempt(downloader.update.version, reconnect=False, restarting=False)
except Exception:
self.controller._logger.exception("[update] Не удалось записать попытку обновления")
if self._update_background:
# Фоновая попытка не шумит: сетевую ошибку повторит 30-минутный тик.
self.controller._logger.warning(
"[update] Фоновая установка не выполнена: %s", " ".join(str(err).split())
)
return
self._show_status("error", err)
def _quit_for_update(self) -> None:
self._quitting = True
self._stop_app_update_timer()
self._save_geometry()
self._shutdown_controller()
app = QApplication.instance()
if app is not None:
app.quit()
def _resolve_previous_update(self) -> bool | None:
"""Итог прошлой автоустановки; возвращает, нужно ли вернуть подключение."""
try:
outcome = resolve_startup(APP_VERSION)
except Exception:
self.controller._logger.exception("[update] Не удалось прочитать запись об обновлении")
return None
if outcome is None:
return None
self._previous_update_report = outcome.report
if outcome.updated:
self.controller._logger.info("[update] Приложение обновлено до v%s", APP_VERSION)
self.updates_page.set_app_status(f"Приложение обновлено до v{APP_VERSION}")
else:
self.controller._logger.warning(
"[update] Установка v%s не удалась (попытка %d из %d)",
outcome.version, outcome.attempts, MAX_AUTO_ATTEMPTS,
)
return outcome.resume
def _consume_update_error_log(self) -> None:
error_log = LOG_DIR / "update_error.log"
if not error_log.exists():
return
archived_log = LOG_DIR / "update_error.last.log"
try:
content = error_log.read_text(encoding="utf-8").strip()
except Exception:
content = ""
try:
if archived_log.exists():
archived_log.unlink()
error_log.replace(archived_log)
except Exception:
pass
message = "Предыдущее обновление не завершилось. См. data/logs/update_error.last.log"
if content:
self.logs_page.append_line("[update] previous install failed")
for line in content.splitlines()[:10]:
if line.strip():
self.logs_page.append_line(f"[update] {line.strip()}")
self.updates_page.set_app_error(message)
QTimer.singleShot(0, lambda: self._show_status("error", message))
def _check_xray_updates(self) -> None:
self.updates_page.set_xray_status("Проверка обновлений Xray...")
self.controller.run_xray_core_update(False, silent=False)
def _update_xray_core(self) -> None:
self.updates_page.set_xray_status("Обновление Xray...")
self.controller.run_xray_core_update(True, silent=False)
def _check_singbox_updates(self) -> None:
self.updates_page.set_singbox_status("Проверка обновлений sing-box...")
self._start_singbox_core_update(apply_update=False)
def _update_singbox_core(self) -> None:
self.updates_page.set_singbox_status("Обновление sing-box...")
self._start_singbox_core_update(apply_update=True)
def _start_singbox_core_update(self, *, apply_update: bool) -> None:
from ..engines.singbox.core_updater import SingboxCoreUpdateWorker
existing = getattr(self, "_singbox_core_worker", None)
if existing is not None and existing.isRunning():
return
worker = SingboxCoreUpdateWorker(
self.controller.state.settings.singbox_path,
apply_update,
parent=self,
)
worker.finished_with_result.connect(self._on_singbox_update_result)
self._singbox_core_worker = worker
worker.start()
def _on_singbox_update_result(self, result) -> None:
self.logs_page.append_line(f"[singbox-core-update] {result.status}: {result.message}")
if result.status == "error":
self.updates_page.set_singbox_error(result.message)
elif result.status in {"updated", "up_to_date"}:
self.updates_page.set_singbox_success(result.message)
else:
self.updates_page.set_singbox_status(result.message)
version = result.current_version or result.latest_version
if version:
self.updates_page.set_singbox_version(version)
def _apply_theme(self, theme_name: str, accent_color: str) -> None:
# Deduplicated: theme.apply_theme skips setTheme/setThemeColor when
# the requested values match the last applied ones.
apply_theme(theme_name, accent_color)
# "system" mode follows the OS via SystemThemeListener; any other
# mode stops the listener.
sync_system_theme_listener(theme_name, self)
def _refresh_tray_tooltip(self) -> None:
if self.tray is None:
return
node = self.controller.selected_node
status = "Подключено" if self.controller.connected else "Отключено"
if node is not None:
node_text = node.name
elif self.controller.is_singbox_editor_mode():
node_text = self.controller.get_active_singbox_config_name()
elif self.controller.uses_xray_raw_config():
node_text = self.controller.get_active_xray_config_name()
else:
node_text = "Нет сервера"
self.tray.setToolTip(f"{APP_NAME}\n{status}\n{node_text}")
def _ensure_unlocked(self, startup: bool) -> bool:
if not self.controller.state.security.enabled:
return True
while True:
dialog = PasswordDialog("Разблокировка", self)
result = dialog.exec()
if result != int(QDialog.DialogCode.Accepted):
if startup:
self._quit_app()
return False
if self.controller.unlock(dialog.password()):
self._show_status("success", "Разблокировано")
return True
self._show_status("error", "Неверный пароль")
def _load_with_passphrase(self) -> bool:
if self.controller.load():
self._apply_window_geometry(self.controller.state.settings)
self._geometry_persistence_ready = True
return True
# State file is encrypted — ask for passphrase
while True:
dialog = PasswordDialog("Зашифрованные данные", self)
dialog.password_edit.setPlaceholderText("Введите пароль шифрования")
result = dialog.exec()
if result != int(QDialog.DialogCode.Accepted):
self._quit_app()
return False
passphrase = dialog.password()
if not passphrase:
continue
self.controller.storage.passphrase = passphrase
try:
self.controller.load()
self._geometry_persistence_ready = True
return True
except Exception:
self._show_status("error", "Неверный пароль шифрования")
def _export_backup(self) -> None:
file_path, _ = QFileDialog.getSaveFileName(
self, "Экспорт резервной копии", "xray_fluent_backup.json", "Backup files (*.json)"
)
if not file_path:
return
passphrase = ""
dialog = PasswordDialog("Зашифровать резервную копию?", self)
dialog.password_edit.setPlaceholderText("Пароль (оставьте пустым для открытого формата)")
if dialog.exec() == int(QDialog.DialogCode.Accepted):
passphrase = dialog.password()
try:
from pathlib import Path
self.controller.export_backup(Path(file_path), passphrase)
self._show_status("success", f"Резервная копия экспортирована: {file_path}")
except Exception as exc:
self._show_status("error", f"Ошибка экспорта: {exc}")
def _import_backup(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(
self, "Импорт резервной копии", "", "Backup files (*.json);;All files (*)"
)
if not file_path:
return
from pathlib import Path
path = Path(file_path)
raw = path.read_text(encoding="utf-8").strip()
passphrase = ""
from ..platform.windows.security import is_passphrase_encrypted
if is_passphrase_encrypted(raw):
dialog = PasswordDialog("Расшифровать резервную копию", self)
dialog.password_edit.setPlaceholderText("Введите пароль резервной копии")
if dialog.exec() != int(QDialog.DialogCode.Accepted):
return
passphrase = dialog.password()
try:
self.controller.import_backup(path, passphrase)
self._load_config_editor_documents()
self._show_status("success", "Резервная копия успешно импортирована")
except PassphraseRequired:
self._show_status("error", "Для этой резервной копии требуется пароль")
except Exception as exc:
self._show_status("error", f"Ошибка импорта: {exc}")
def _set_encryption(self, passphrase: str) -> None:
self.controller.set_data_passphrase(passphrase)
self.settings_page.set_encryption_active(True)
def _disable_encryption(self) -> None:
self.controller.clear_data_passphrase()
self.settings_page.set_encryption_active(False)
def _toggle_window_visible(self) -> None:
# У свёрнутого окна isVisible() тоже True: клик по значку в трее прятал его,
# а не возвращал. Прячем только окно, которое человек видит.
if self.isVisible() and not self.isMinimized():
self._window_presence.away_intent()
self.hide()
else:
self._window_presence.return_intent()
if self.isMinimized():
self.showNormal()
else:
self.show()
self.activateWindow()
self.raise_()
def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
if reason in {QSystemTrayIcon.ActivationReason.Trigger, QSystemTrayIcon.ActivationReason.DoubleClick}:
self._toggle_window_visible()
def _shutdown_controller(self):
if self._startup_loader:
self._startup_loader.cancel()
if self._state_loaded:
# Экскурсию оборвал выход (например, установка обновления).
tour = active_tour(self)
if tour is not None:
tour.finish("interrupted")
self.controller.shutdown()
else:
# Cancelling startup must never overwrite the real state with defaults.
self.controller.network_monitor.stop()
def _quit_app(self) -> None:
self._quitting = True
if self._startup_loader:
self._startup_loader.cancel()
self._stop_app_update_timer()
self._save_geometry()
self._shutdown_controller()
app = QApplication.instance()
if app is not None:
app.quit()
def _save_geometry(self, *, persist: bool = True) -> None:
controller = getattr(self, "controller", None)
if controller is None or self._restoring_geometry or not self._geometry_persistence_ready:
return
if self.isMinimized():
return
geo = self.normalGeometry() if self.isMaximized() else self.geometry()
s = controller.state.settings
s.window_x = geo.x()
s.window_y = geo.y()
s.window_width = geo.width()
s.window_height = geo.height()
if persist and self._geometry_persistence_ready:
controller.schedule_save()
# Перетаскивание/ресайз только запоминают геометрию: на диск она уходит
# при сворачивании в трей, выходе или любом другом сохранении.
def moveEvent(self, e) -> None:
super().moveEvent(e)
self._save_geometry(persist=False)
def resizeEvent(self, e) -> None:
super().resizeEvent(e)
self._save_geometry(persist=False)
# resizeEvent приходит ещё из конструктора FluentWindow — до того, как
# __init__ успел создать флаг.
if getattr(self, "_geometry_persistence_ready", False):
self._sync_nav_to_preference()
def closeEvent(self, e: QCloseEvent) -> None:
if self._quitting:
e.accept()
return
if not self._tray_available:
self._quitting = True
self._stop_app_update_timer()
self._save_geometry()
self._shutdown_controller()
e.accept()
app = QApplication.instance()
if app is not None:
app.quit()
return
self._save_geometry()
if self._state_loaded:
self.controller.save()
e.ignore()
self._window_presence.away_intent()
self.hide()
if self.tray is not None and not self._tray_notified:
self.tray.showMessage(APP_NAME, "Приложение свёрнуто в системный трей", QSystemTrayIcon.MessageIcon.Information, 2000)
self._tray_notified = True