zapret-kvn/xray_fluent/ui/settings_page.py
loop-uh eda5cd35cb
Some checks failed
Windows project source guards / test (push) Failing after 23s
Автозапуск через Планировщик, окно приложения после окна обновления, понятные настройки
Автозапуск не работал вовсе: приложение записывало себя в HKCU\...\Run,
а exe требует прав администратора, и такие записи Windows при входе в
систему молча пропускает. Теперь это задание Планировщика «при входе
пользователя» с наивысшими правами. При каждом старте задание сверяется с
настройкой и текущим путём — это чинит автозапуск тем, у кого он уже
включён, и переживает перенос портативной папки. Отказ Windows возвращает
переключатель в прежнее положение, а не оставляет его «включённым».

Обновление: новая версия стартует под замком и показывает своё окно только
после закрытия окна обновления; о готовности она сообщает установщику сама
(вместо «не упала за 5 секунд»). Окно обновления живёт не меньше 7 секунд.

Настройки: описания переписаны простым языком, единицы измерения стоят у
числа, зависимые настройки скрыты, пока выключен их переключатель, убраны
списки «Движок прокси» и «Движок TUN» с единственным вариантом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:27:55 +03:00

784 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
from copy import deepcopy
from PyQt6.QtCore import QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QPainter, QPen
from PyQt6.QtWidgets import QFileDialog, QPushButton, QSizePolicy, QWidget
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
ComboBox,
FluentIcon as FIF,
LineEdit,
PasswordLineEdit,
PrimaryPushSettingCard,
PushButton,
PushSettingCard,
SettingCard,
SpinBox,
SettingCardGroup,
SubtitleLabel,
SwitchSettingCard,
)
from qfluentwidgets.components.settings.setting_card import ColorPickerButton
from ..constants import SINGBOX_PATH_DEFAULT, XRAY_PATH_DEFAULT
from ..profiles.models import AppSettings, SecuritySettings, clamp_subscriptions_check_interval
from .base_page import ScrollablePage
from .theme import ACCENT_PRESETS, DEFAULT_ACCENT, normalize_accent, text_color
from ..profiles.path_utils import normalize_configured_path, resolve_configured_path
def _expand_horizontally(widget) -> None:
"""Let the widget stretch horizontally on wide windows (AC9)."""
policy = widget.sizePolicy()
policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
widget.setSizePolicy(policy)
class _ComboCard(SettingCard):
"""Setting card with a combo box on the right."""
def __init__(self, icon, title, content, items: list[tuple[str, str]], parent=None):
super().__init__(icon, title, content, parent)
self.combo = ComboBox(self)
self.combo.setMinimumWidth(220)
for text, data in items:
self.combo.addItem(text, userData=data)
self.hBoxLayout.addWidget(self.combo, 0, Qt.AlignmentFlag.AlignRight)
self.hBoxLayout.addSpacing(16)
class _SpinCard(SettingCard):
"""Setting card with a spin box on the right."""
def __init__(self, icon, title, content, min_val=1, max_val=65535, suffix="", parent=None):
super().__init__(icon, title, content, parent)
self.spin = SpinBox(self)
self.spin.setRange(min_val, max_val)
# Единица измерения стоит у самого числа, а не в скобках заголовка.
self.spin.setSuffix(suffix)
self.spin.setMinimumWidth(180)
self.hBoxLayout.addWidget(self.spin, 0, Qt.AlignmentFlag.AlignRight)
self.hBoxLayout.addSpacing(16)
class _AccentSwatch(QPushButton):
"""Round preset-color swatch inside the accent card (AC9, no dialogs)."""
def __init__(self, color_hex: str, name: str, parent=None):
super().__init__(parent)
self._color_hex = color_hex
self.setCheckable(True)
self.setFixedSize(24, 24)
self.setToolTip(name)
self.setCursor(Qt.CursorShape.PointingHandCursor)
@property
def color_hex(self) -> str:
return self._color_hex
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
if self.isChecked():
# Selection ring around the checked swatch (A5).
painter.setPen(QPen(text_color(), 1.6))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(QRectF(self.rect()).adjusted(1.0, 1.0, -1.0, -1.0))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(self._color_hex))
painter.drawEllipse(QRectF(self.rect()).adjusted(4.0, 4.0, -4.0, -4.0))
painter.end()
class _ColorCard(SettingCard):
"""Setting card with preset accent swatches and a color picker button."""
def __init__(self, icon, title, content, parent=None):
super().__init__(icon, title, content, parent)
self.swatches: list[_AccentSwatch] = []
for color_hex, name in ACCENT_PRESETS:
swatch = _AccentSwatch(color_hex, name, self)
swatch.clicked.connect(
lambda _checked=False, value=color_hex: self._on_swatch_clicked(value)
)
self.hBoxLayout.addWidget(swatch, 0, Qt.AlignmentFlag.AlignRight)
self.hBoxLayout.addSpacing(4)
self.swatches.append(swatch)
self.hBoxLayout.addSpacing(8)
self.picker = ColorPickerButton(QColor(DEFAULT_ACCENT), title, self)
self.picker.colorChanged.connect(self._on_picker_color_changed)
self.hBoxLayout.addWidget(self.picker, 0, Qt.AlignmentFlag.AlignRight)
self.hBoxLayout.addSpacing(16)
self._sync_swatches()
def current_hex(self) -> str:
"""Normalized (#RRGGBB, upper case) color of the picker."""
return normalize_accent(self.picker.color.name())
def set_color(self, color) -> None:
"""Set the picker color without emitting colorChanged (loading path)."""
self.picker.setColor(QColor(color))
self._sync_swatches()
def _on_swatch_clicked(self, color_hex: str) -> None:
color = QColor(color_hex)
self.picker.setColor(color)
# ColorPickerButton.setColor does not emit colorChanged itself; the
# page's auto-save listens on that signal, so emit it explicitly.
self.picker.colorChanged.emit(color)
self._sync_swatches()
def _on_picker_color_changed(self, _color) -> None:
self._sync_swatches()
def _sync_swatches(self) -> None:
current = self.current_hex()
for swatch in self.swatches:
swatch.setChecked(normalize_accent(swatch.color_hex) == current)
swatch.update()
class _LineEditCard(SettingCard):
"""Setting card with a line edit on the right."""
def __init__(self, icon, title, content, placeholder="", parent=None):
super().__init__(icon, title, content, parent)
self.edit = LineEdit(self)
self.edit.setPlaceholderText(placeholder)
self.edit.setMinimumWidth(240)
_expand_horizontally(self.edit)
self.hBoxLayout.addWidget(self.edit, 1)
self.hBoxLayout.addSpacing(16)
class _BrowseCard(SettingCard):
"""Setting card with a line edit + browse button on the right."""
def __init__(self, icon, title, content, parent=None):
super().__init__(icon, title, content, parent)
self.edit = LineEdit(self)
self.edit.setMinimumWidth(220)
_expand_horizontally(self.edit)
self.btn = PushButton("Обзор", self)
self.hBoxLayout.addWidget(self.edit, 1)
self.hBoxLayout.addSpacing(8)
self.hBoxLayout.addWidget(self.btn, 0, Qt.AlignmentFlag.AlignRight)
self.hBoxLayout.addSpacing(16)
class _PasswordActionCard(SettingCard):
"""Setting card with a password edit and action buttons."""
def __init__(self, icon, title, content, placeholder="", buttons: list[str] | None = None, parent=None):
super().__init__(icon, title, content, parent)
self.edit = PasswordLineEdit(self)
self.edit.setPlaceholderText(placeholder)
self.edit.setMinimumWidth(260)
self.hBoxLayout.addWidget(self.edit, 0, Qt.AlignmentFlag.AlignRight)
self.buttons: list[PushButton] = []
for text in (buttons or []):
self.hBoxLayout.addSpacing(8)
btn = PushButton(text, self)
self.hBoxLayout.addWidget(btn, 0, Qt.AlignmentFlag.AlignRight)
self.buttons.append(btn)
self.hBoxLayout.addSpacing(16)
class SettingsPage(ScrollablePage):
save_requested = pyqtSignal(object)
auto_lock_minutes_changed = pyqtSignal(int)
set_password_requested = pyqtSignal(str)
disable_password_requested = pyqtSignal()
lock_now_requested = pyqtSignal()
# Update buttons moved to UpdatesPage
export_backup_requested = pyqtSignal()
import_backup_requested = pyqtSignal()
set_encryption_requested = pyqtSignal(str)
disable_encryption_requested = pyqtSignal()
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("settings")
self._settings = AppSettings()
self._security = SecuritySettings()
self._loading = False
# --- Scrollable body (AC7, base_page.ScrollablePage) ---
container = self.body
root = self.body_layout
root.setSpacing(4)
root.addWidget(SubtitleLabel("Настройки", container))
root.addSpacing(8)
# ============================================================
# Appearance
# ============================================================
appearance_group = SettingCardGroup("Внешний вид", container)
self.theme_card = _ComboCard(
FIF.BRUSH, "Тема", "Светлая, тёмная или такая же, как в Windows",
[("Как в Windows", "system"), ("Светлая", "light"), ("Тёмная", "dark")],
parent=appearance_group,
)
self.accent_card = _ColorCard(
FIF.PALETTE, "Цвет акцента", "Цвет кнопок, переключателей и выделения",
parent=appearance_group,
)
appearance_group.addSettingCard(self.theme_card)
appearance_group.addSettingCard(self.accent_card)
root.addWidget(appearance_group)
# ============================================================
# Network
# ============================================================
network_group = SettingCardGroup("Сеть", container)
self.proxy_bypass_lan_card = SwitchSettingCard(
FIF.HOME, "Локальная сеть напрямую",
"Роутер, принтеры и другие домашние устройства открываются без прокси",
parent=network_group,
)
self.reconnect_card = SwitchSettingCard(
FIF.SYNC, "Переподключение при смене сети",
"Восстанавливать подключение после перехода на другой Wi-Fi или кабель",
parent=network_group,
)
network_group.addSettingCard(self.proxy_bypass_lan_card)
network_group.addSettingCard(self.reconnect_card)
root.addWidget(network_group)
# ============================================================
# Auto-switch
# ============================================================
auto_switch_group = SettingCardGroup("Смена сервера при сбоях", container)
self.auto_switch_card = SwitchSettingCard(
FIF.SYNC, "Менять сервер, если он перестал отвечать",
"Приложение само перейдёт на другой рабочий сервер",
parent=auto_switch_group,
)
self.auto_switch_low_speed_card = SwitchSettingCard(
FIF.SPEED_MEDIUM, "Менять сервер при низкой скорости",
"Если загрузка дольше 20 секунд идёт медленнее 1 Мбит/с — перейти "
"на сервер, который быстрее хотя бы вдвое",
parent=auto_switch_group,
)
self.auto_switch_cooldown_card = _SpinCard(
FIF.HISTORY, "Пауза между сменами",
"Не менять сервер чаще, чем раз в указанное время",
min_val=10, max_val=600, suffix=" с", parent=auto_switch_group,
)
auto_switch_group.addSettingCard(self.auto_switch_card)
auto_switch_group.addSettingCard(self.auto_switch_low_speed_card)
auto_switch_group.addSettingCard(self.auto_switch_cooldown_card)
root.addWidget(auto_switch_group)
# ============================================================
# Rotation
# ============================================================
rotation_group = SettingCardGroup("Ротация серверов", container)
self.rotation_card = SwitchSettingCard(
FIF.ROTATE, "Менять сервер по расписанию",
"Регулярно переходить на другой сервер, не разрывая подключение",
parent=rotation_group,
)
self.rotation_mode_card = _ComboCard(
FIF.RIGHT_ARROW, "Порядок",
"Как выбирается следующий сервер",
[("Случайный", "random"), ("По очереди", "sequential")],
parent=rotation_group,
)
self.rotation_interval_card = _SpinCard(
FIF.STOP_WATCH, "Как часто менять",
"Время работы на одном сервере",
min_val=30, max_val=86400, suffix=" с", parent=rotation_group,
)
self.rotation_jitter_card = _SpinCard(
FIF.DICTIONARY_ADD, "Случайный разброс",
"На сколько время может отклоняться, чтобы смены не шли строго по часам",
min_val=0, max_val=90, suffix=" %", parent=rotation_group,
)
self.rotation_pool_card = _ComboCard(
FIF.FILTER, "Какие серверы использовать",
"Все или только из одной группы, с одним тегом, из одной подписки",
[
("Все серверы", "all"),
("Группа", "group"),
("Тег", "tag"),
("Подписка", "subscription"),
],
parent=rotation_group,
)
self.rotation_pool_value_card = _LineEditCard(
FIF.TAG, "Название группы", "",
placeholder="например: Нидерланды",
parent=rotation_group,
)
self.rotation_only_alive_card = SwitchSettingCard(
FIF.HEART, "Только рабочие серверы",
"Пропускать серверы, которые не прошли проверку",
parent=rotation_group,
)
self.rotation_max_nodes_card = _SpinCard(
FIF.MENU, "Сколько серверов брать",
"Наибольшее число серверов, между которыми идёт смена",
min_val=2, max_val=50, parent=rotation_group,
)
rotation_group.addSettingCard(self.rotation_card)
rotation_group.addSettingCard(self.rotation_mode_card)
rotation_group.addSettingCard(self.rotation_interval_card)
rotation_group.addSettingCard(self.rotation_jitter_card)
rotation_group.addSettingCard(self.rotation_pool_card)
rotation_group.addSettingCard(self.rotation_pool_value_card)
rotation_group.addSettingCard(self.rotation_only_alive_card)
rotation_group.addSettingCard(self.rotation_max_nodes_card)
root.addWidget(rotation_group)
# ============================================================
# Core paths
# ============================================================
paths_group = SettingCardGroup("Ядра", container)
self.xray_path_card = _BrowseCard(
FIF.COMMAND_PROMPT, "Файл Xray", "Путь к xray.exe; можно указать относительно папки приложения",
parent=paths_group,
)
self.singbox_path_card = _BrowseCard(
FIF.COMMAND_PROMPT, "Файл sing-box", "Путь к sing-box.exe; можно указать относительно папки приложения",
parent=paths_group,
)
paths_group.addSettingCard(self.xray_path_card)
paths_group.addSettingCard(self.singbox_path_card)
root.addWidget(paths_group)
# ============================================================
# Startup
# ============================================================
startup_group = SettingCardGroup("Запуск", container)
self.launch_card = SwitchSettingCard(
FIF.POWER_BUTTON, "Запускать вместе с Windows",
"Приложение стартует при входе в систему и сворачивается в трей",
parent=startup_group,
)
self.auto_connect_card = SwitchSettingCard(
FIF.PLAY, "Подключаться при запуске",
"Сразу подключаться к серверу, выбранному в прошлый раз",
parent=startup_group,
)
self.startup_order_card = _ComboCard(
FIF.ALIGNMENT, "Что сначала",
"Подключиться сразу или сперва получить свежий список серверов",
[
("Подключаться сразу", "immediate"),
("Сначала обновить подписки", "after_subscriptions"),
],
parent=startup_group,
)
startup_group.addSettingCard(self.launch_card)
startup_group.addSettingCard(self.auto_connect_card)
startup_group.addSettingCard(self.startup_order_card)
root.addWidget(startup_group)
# ============================================================
# Subscriptions
# ============================================================
subscriptions_group = SettingCardGroup("Подписки", container)
self.subscriptions_auto_update_card = SwitchSettingCard(
FIF.CLOUD, "Обновлять подписки автоматически",
"Приложение само получает свежие списки серверов",
parent=subscriptions_group,
)
self.subscriptions_startup_check_card = SwitchSettingCard(
FIF.CLOUD_DOWNLOAD, "Проверять при запуске",
"Один раз, вскоре после старта приложения",
parent=subscriptions_group,
)
self.subscriptions_interval_card = _SpinCard(
FIF.STOP_WATCH, "Как часто проверять",
"От 5 минут до 24 часов",
min_val=5, max_val=1440, suffix=" мин", parent=subscriptions_group,
)
subscriptions_group.addSettingCard(self.subscriptions_auto_update_card)
subscriptions_group.addSettingCard(self.subscriptions_startup_check_card)
subscriptions_group.addSettingCard(self.subscriptions_interval_card)
root.addWidget(subscriptions_group)
# ============================================================
# Updates
# ============================================================
updates_group = SettingCardGroup("Обновления", container)
self.check_updates_card = SwitchSettingCard(
FIF.UPDATE, "Проверять обновления",
"При запуске и каждые 30 минут",
parent=updates_group,
)
self.allow_updates_card = SwitchSettingCard(
FIF.DOWNLOAD, "Устанавливать автоматически",
"Приложение само обновится, перезапустится и вернёт подключение",
parent=updates_group,
)
self.xray_auto_update_card = SwitchSettingCard(
FIF.CLOUD_DOWNLOAD, "Обновлять ядро Xray",
"Проверять и ставить новую версию ядра при запуске",
parent=updates_group,
)
updates_group.addSettingCard(self.check_updates_card)
updates_group.addSettingCard(self.allow_updates_card)
updates_group.addSettingCard(self.xray_auto_update_card)
root.addWidget(updates_group)
# ============================================================
# Data
# ============================================================
data_group = SettingCardGroup("Данные", container)
self.encryption_card = _PasswordActionCard(
FIF.FINGERPRINT, "Пароль шифрования",
"Защитить файл состояния паролем",
placeholder="Введите пароль",
buttons=["Включить шифрование", "Отключить шифрование"],
parent=data_group,
)
self.export_backup_card = PushSettingCard(
"Экспорт", FIF.SAVE, "Экспорт резервной копии",
"Экспортировать полное состояние приложения в файл",
parent=data_group,
)
self.import_backup_card = PushSettingCard(
"Импорт", FIF.FOLDER, "Импорт резервной копии",
"Восстановить состояние приложения из резервной копии",
parent=data_group,
)
data_group.addSettingCard(self.encryption_card)
data_group.addSettingCard(self.export_backup_card)
data_group.addSettingCard(self.import_backup_card)
root.addWidget(data_group)
# ============================================================
# Security
# ============================================================
security_group = SettingCardGroup("Безопасность", container)
self.password_card = _PasswordActionCard(
FIF.CERTIFICATE, "Мастер-пароль",
"Установите пароль для блокировки приложения",
placeholder="Введите новый пароль",
buttons=["Установить пароль", "Отключить пароль", "Заблокировать"],
parent=security_group,
)
self.auto_lock_card = _SpinCard(
FIF.STOP_WATCH, "Автоблокировка",
"Блокировать приложение, если им не пользуются указанное время",
min_val=1, max_val=120, suffix=" мин", parent=security_group,
)
security_group.addSettingCard(self.password_card)
security_group.addSettingCard(self.auto_lock_card)
root.addWidget(security_group)
root.addStretch(1)
self._groups = (
appearance_group, network_group, auto_switch_group, rotation_group, paths_group,
startup_group, subscriptions_group, updates_group, data_group, security_group,
)
# ============================================================
# Signal connections
# ============================================================
# Browse buttons
self.xray_path_card.btn.clicked.connect(self._choose_xray_path)
self.singbox_path_card.btn.clicked.connect(self._choose_singbox_path)
# Password / encryption / backup buttons
self.password_card.buttons[0].clicked.connect(self._emit_password) # Set password
self.password_card.buttons[1].clicked.connect(self.disable_password_requested) # Disable password
self.password_card.buttons[2].clicked.connect(self.lock_now_requested) # Lock now
self.encryption_card.buttons[0].clicked.connect(self._emit_set_encryption) # Set encryption
self.encryption_card.buttons[1].clicked.connect(self.disable_encryption_requested) # Disable encryption
self.export_backup_card.clicked.connect(self.export_backup_requested)
self.import_backup_card.clicked.connect(self.import_backup_requested)
# Update action buttons
# Update buttons moved to UpdatesPage
# --- Auto-save connections ---
self.theme_card.combo.currentIndexChanged.connect(self._auto_save)
self.accent_card.picker.colorChanged.connect(self._auto_save)
self.proxy_bypass_lan_card.checkedChanged.connect(self._auto_save)
self.xray_path_card.edit.editingFinished.connect(self._auto_save)
self.singbox_path_card.edit.editingFinished.connect(self._auto_save)
self.launch_card.checkedChanged.connect(self._auto_save)
self.auto_connect_card.checkedChanged.connect(self._auto_save)
self.startup_order_card.combo.currentIndexChanged.connect(self._auto_save)
self.subscriptions_auto_update_card.checkedChanged.connect(self._auto_save)
self.subscriptions_startup_check_card.checkedChanged.connect(self._auto_save)
self.subscriptions_interval_card.spin.valueChanged.connect(self._auto_save)
self.reconnect_card.checkedChanged.connect(self._auto_save)
self.check_updates_card.checkedChanged.connect(self._auto_save)
self.allow_updates_card.checkedChanged.connect(self._auto_save)
self.xray_auto_update_card.checkedChanged.connect(self._auto_save)
self.auto_switch_card.checkedChanged.connect(self._auto_save)
self.auto_switch_low_speed_card.checkedChanged.connect(self._auto_save)
self.auto_switch_cooldown_card.spin.valueChanged.connect(self._auto_save)
self.rotation_card.checkedChanged.connect(self._auto_save)
self.rotation_mode_card.combo.currentIndexChanged.connect(self._auto_save)
self.rotation_interval_card.spin.valueChanged.connect(self._auto_save)
self.rotation_jitter_card.spin.valueChanged.connect(self._auto_save)
self.rotation_pool_card.combo.currentIndexChanged.connect(self._auto_save)
self.rotation_pool_value_card.edit.editingFinished.connect(self._auto_save)
self.rotation_only_alive_card.checkedChanged.connect(self._auto_save)
self.rotation_max_nodes_card.spin.valueChanged.connect(self._auto_save)
self.auto_lock_card.spin.valueChanged.connect(self._auto_save)
# Настройки, которые сейчас ни на что не влияют, не показываются.
for switch in (
self.auto_switch_card, self.rotation_card, self.auto_connect_card,
self.subscriptions_auto_update_card, self.subscriptions_startup_check_card,
self.check_updates_card,
):
switch.checkedChanged.connect(self._sync_dependent_cards)
self.rotation_pool_card.combo.currentIndexChanged.connect(self._sync_dependent_cards)
self._sync_dependent_cards()
# ================================================================
# Public API
# ================================================================
def set_values(self, settings: AppSettings, security: SecuritySettings) -> None:
self._loading = True
self._settings = deepcopy(settings)
self._security = deepcopy(security)
self._select_combo_data(self.theme_card.combo, settings.theme)
self.accent_card.set_color(QColor(settings.accent_color or DEFAULT_ACCENT))
self.proxy_bypass_lan_card.setChecked(settings.system_proxy_bypass_lan)
self.xray_path_card.edit.setText(
normalize_configured_path(
settings.xray_path,
default_path=XRAY_PATH_DEFAULT,
use_default_if_empty=True,
migrate_default_location=True,
)
)
self.singbox_path_card.edit.setText(
normalize_configured_path(
settings.singbox_path,
default_path=SINGBOX_PATH_DEFAULT,
use_default_if_empty=True,
migrate_default_location=True,
)
)
self.launch_card.setChecked(settings.launch_on_startup)
self.auto_connect_card.setChecked(settings.auto_connect_last)
self._select_combo_data(self.startup_order_card.combo, settings.startup_connect_order)
self.subscriptions_auto_update_card.setChecked(settings.subscriptions_auto_update)
self.subscriptions_startup_check_card.setChecked(settings.subscriptions_check_on_startup)
self.subscriptions_interval_card.spin.setValue(
clamp_subscriptions_check_interval(settings.subscriptions_check_interval_min)
)
self.reconnect_card.setChecked(settings.reconnect_on_network_change)
self.check_updates_card.setChecked(settings.check_updates)
self.allow_updates_card.setChecked(settings.allow_updates)
self.xray_auto_update_card.setChecked(settings.xray_auto_update)
self.auto_switch_card.setChecked(settings.auto_switch_enabled)
self.auto_switch_low_speed_card.setChecked(settings.auto_switch_low_speed_enabled)
self.auto_switch_cooldown_card.spin.setValue(settings.auto_switch_cooldown_sec)
self.rotation_card.setChecked(settings.rotation_enabled)
self._select_combo_data(self.rotation_mode_card.combo, settings.rotation_mode)
self.rotation_interval_card.spin.setValue(settings.rotation_interval_sec)
self.rotation_jitter_card.spin.setValue(settings.rotation_jitter_pct)
self._select_combo_data(self.rotation_pool_card.combo, settings.rotation_pool)
self.rotation_pool_value_card.edit.setText(settings.rotation_pool_value)
self.rotation_only_alive_card.setChecked(settings.rotation_only_alive)
self.rotation_max_nodes_card.spin.setValue(settings.rotation_max_nodes)
self.auto_lock_card.spin.setValue(security.auto_lock_minutes)
self.password_card.edit.clear()
self._loading = False
self._sync_dependent_cards()
def set_encryption_active(self, active: bool) -> None:
self.encryption_card.buttons[1].setEnabled(active) # Disable encryption btn
# ================================================================
# Private slots
# ================================================================
def _choose_xray_path(self) -> None:
current_path = resolve_configured_path(
self.xray_path_card.edit.text(),
default_path=XRAY_PATH_DEFAULT,
use_default_if_empty=True,
migrate_default_location=True,
)
file_path, _ = QFileDialog.getOpenFileName(
self,
"Выберите xray.exe",
str(current_path or XRAY_PATH_DEFAULT),
"xray.exe (xray.exe)",
)
if file_path:
self.xray_path_card.edit.setText(
normalize_configured_path(
file_path,
default_path=XRAY_PATH_DEFAULT,
use_default_if_empty=True,
migrate_default_location=True,
)
)
self._auto_save()
def _choose_singbox_path(self) -> None:
current_path = resolve_configured_path(
self.singbox_path_card.edit.text(),
default_path=SINGBOX_PATH_DEFAULT,
migrate_default_location=True,
)
file_path, _ = QFileDialog.getOpenFileName(
self,
"Выберите sing-box.exe",
str(current_path or SINGBOX_PATH_DEFAULT),
"sing-box.exe (sing-box.exe)",
)
if file_path:
self.singbox_path_card.edit.setText(
normalize_configured_path(
file_path,
default_path=SINGBOX_PATH_DEFAULT,
migrate_default_location=True,
)
)
self._auto_save()
def _auto_save(self) -> None:
if self._loading:
return
data = deepcopy(self._settings)
data.theme = str(self.theme_card.combo.currentData() or "system")
data.accent_color = self.accent_card.current_hex()
data.system_proxy_bypass_lan = self.proxy_bypass_lan_card.isChecked()
data.xray_path = normalize_configured_path(
self.xray_path_card.edit.text(),
default_path=XRAY_PATH_DEFAULT,
use_default_if_empty=True,
migrate_default_location=True,
)
data.singbox_path = normalize_configured_path(
self.singbox_path_card.edit.text(),
default_path=SINGBOX_PATH_DEFAULT,
use_default_if_empty=True,
migrate_default_location=True,
)
self.xray_path_card.edit.setText(data.xray_path)
self.singbox_path_card.edit.setText(data.singbox_path)
data.proxy_engine = "singbox"
data.tun_engine = "singbox"
data.start_minimized = False
data.launch_on_startup = self.launch_card.isChecked()
data.auto_connect_last = self.auto_connect_card.isChecked()
data.startup_connect_order = str(self.startup_order_card.combo.currentData() or "immediate")
data.subscriptions_auto_update = self.subscriptions_auto_update_card.isChecked()
data.subscriptions_check_on_startup = self.subscriptions_startup_check_card.isChecked()
data.subscriptions_check_interval_min = int(self.subscriptions_interval_card.spin.value())
data.reconnect_on_network_change = self.reconnect_card.isChecked()
data.check_updates = self.check_updates_card.isChecked()
data.allow_updates = self.allow_updates_card.isChecked()
data.xray_auto_update = self.xray_auto_update_card.isChecked()
data.auto_switch_enabled = self.auto_switch_card.isChecked()
data.auto_switch_low_speed_enabled = self.auto_switch_low_speed_card.isChecked()
data.auto_switch_cooldown_sec = int(self.auto_switch_cooldown_card.spin.value())
data.rotation_enabled = self.rotation_card.isChecked()
data.rotation_mode = str(self.rotation_mode_card.combo.currentData() or "random")
data.rotation_interval_sec = int(self.rotation_interval_card.spin.value())
data.rotation_jitter_pct = int(self.rotation_jitter_card.spin.value())
data.rotation_pool = str(self.rotation_pool_card.combo.currentData() or "all")
data.rotation_pool_value = self.rotation_pool_value_card.edit.text().strip()
data.rotation_only_alive = self.rotation_only_alive_card.isChecked()
data.rotation_max_nodes = int(self.rotation_max_nodes_card.spin.value())
self.save_requested.emit(data)
self.auto_lock_minutes_changed.emit(int(self.auto_lock_card.spin.value()))
_POOL_VALUE_TITLES = {
"group": ("Название группы", "например: Нидерланды"),
"tag": ("Тег", "например: игры"),
"subscription": ("Подписка", "название или идентификатор подписки"),
}
def _sync_dependent_cards(self, *_args) -> None:
"""Показать только те настройки, которые сейчас на что-то влияют."""
auto_switch = self.auto_switch_card.isChecked()
self.auto_switch_low_speed_card.setVisible(auto_switch)
self.auto_switch_cooldown_card.setVisible(auto_switch)
rotation = self.rotation_card.isChecked()
for card in (
self.rotation_mode_card, self.rotation_interval_card, self.rotation_jitter_card,
self.rotation_pool_card, self.rotation_only_alive_card, self.rotation_max_nodes_card,
):
card.setVisible(rotation)
pool = str(self.rotation_pool_card.combo.currentData() or "all")
self.rotation_pool_value_card.setVisible(rotation and pool in self._POOL_VALUE_TITLES)
title, placeholder = self._POOL_VALUE_TITLES.get(pool, self._POOL_VALUE_TITLES["group"])
self.rotation_pool_value_card.setTitle(title)
self.rotation_pool_value_card.edit.setPlaceholderText(placeholder)
subscriptions = self.subscriptions_auto_update_card.isChecked()
self.subscriptions_startup_check_card.setVisible(subscriptions)
self.subscriptions_interval_card.setVisible(subscriptions)
# Выбирать порядок есть смысл, только когда при запуске делается и то, и другое.
self.startup_order_card.setVisible(
self.auto_connect_card.isChecked()
and subscriptions
and self.subscriptions_startup_check_card.isChecked()
)
self.allow_updates_card.setVisible(self.check_updates_card.isChecked())
for group in self._groups:
group.adjustSize()
def _emit_set_encryption(self) -> None:
value = self.encryption_card.edit.text().strip()
if value:
self.set_encryption_requested.emit(value)
self.encryption_card.edit.clear()
def _emit_password(self) -> None:
value = self.password_card.edit.text().strip()
if value:
self.set_password_requested.emit(value)
self.password_card.edit.clear()
@staticmethod
def _select_combo_data(combo: ComboBox, value: str) -> None:
for index in range(combo.count()):
if combo.itemData(index) == value:
combo.setCurrentIndex(index)
return