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>
2436 lines
122 KiB
Python
2436 lines
122 KiB
Python
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
|