zapret-kvn/xray_fluent/ui/settings_page.py
loop-uh d5a4715771
Some checks failed
Windows project source guards / test (push) Has been cancelled
feat: use official Amnezia transport and organize runtime modules
2026-09-06 00:14:08 +03:00

760 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, parent=None):
super().__init__(icon, title, content, parent)
self.spin = SpinBox(self)
self.spin.setRange(min_val, max_val)
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, "Тема", "Выберите светлую, тёмную или системную тему",
[("Авто", "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, "Обход локальной сети",
"Не отправлять локальные адреса через системный прокси Windows",
parent=network_group,
)
self.reconnect_card = SwitchSettingCard(
FIF.SYNC, "Переподключение при смене сети",
"Автоматически переподключаться при смене сетевого адаптера",
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_threshold_card = _SpinCard(
FIF.SPEED_HIGH, "Порог скорости (КБ/с)",
"Минимальная скорость загрузки для срабатывания",
min_val=1, max_val=10000, parent=auto_switch_group,
)
self.auto_switch_delay_card = _SpinCard(
FIF.STOP_WATCH, "Задержка (секунды)",
"Время ожидания перед переключением",
min_val=5, max_val=300, parent=auto_switch_group,
)
self.auto_switch_cooldown_card = _SpinCard(
FIF.HISTORY, "Кулдаун (секунды)",
"Минимальный интервал между автопереключениями",
min_val=10, max_val=600, parent=auto_switch_group,
)
auto_switch_group.addSettingCard(self.auto_switch_card)
auto_switch_group.addSettingCard(self.auto_switch_threshold_card)
auto_switch_group.addSettingCard(self.auto_switch_delay_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, parent=rotation_group,
)
self.rotation_jitter_card = _SpinCard(
FIF.DICTIONARY_ADD, "Разброс интервала (%)",
"Случайное отклонение от интервала, чтобы переключения не были строго периодичными",
min_val=0, max_val=90, 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", "Относительные пути разрешаются от папки приложения",
parent=paths_group,
)
self.singbox_path_card = _BrowseCard(
FIF.COMMAND_PROMPT, "Путь к sing-box", "Маршрутизация и DNS для всех протоколов; относительные пути — от папки приложения",
parent=paths_group,
)
self.proxy_engine_card = _ComboCard(
FIF.DEVELOPER_TOOLS, "Движок прокси",
"Маршрутизация и DNS — sing-box; VLESS — Xray; Hysteria2 — Hysteria",
[
("sing-box", "singbox"),
],
parent=paths_group,
)
self.tun_engine_card = _ComboCard(
FIF.DEVELOPER_TOOLS, "Движок TUN",
"Единственный TUN и общие правила всех протоколов — sing-box",
[
("sing-box", "singbox"),
],
parent=paths_group,
)
paths_group.addSettingCard(self.xray_path_card)
paths_group.addSettingCard(self.singbox_path_card)
paths_group.addSettingCard(self.proxy_engine_card)
paths_group.addSettingCard(self.tun_engine_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, 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, "Проверять обновления",
"Периодически проверять наличие новых версий при запуске",
parent=updates_group,
)
self.allow_updates_card = SwitchSettingCard(
FIF.DOWNLOAD, "Разрешить обновления",
"Разрешить загрузку и установку обновлений приложения",
parent=updates_group,
)
self.xray_auto_update_card = SwitchSettingCard(
FIF.CLOUD_DOWNLOAD, "Автообновление ядра Xray",
"Автоматически обновлять ядро 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, parent=security_group,
)
security_group.addSettingCard(self.password_card)
security_group.addSettingCard(self.auto_lock_card)
root.addWidget(security_group)
root.addStretch(1)
# ============================================================
# 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.proxy_engine_card.combo.currentIndexChanged.connect(self._auto_save)
self.tun_engine_card.combo.currentIndexChanged.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_threshold_card.spin.valueChanged.connect(self._auto_save)
self.auto_switch_delay_card.spin.valueChanged.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)
# ================================================================
# 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._select_combo_data(self.proxy_engine_card.combo, settings.proxy_engine)
self._select_combo_data(self.tun_engine_card.combo, settings.tun_engine)
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_threshold_card.spin.setValue(settings.auto_switch_threshold_kbps)
self.auto_switch_delay_card.spin.setValue(settings.auto_switch_delay_sec)
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
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 = self.proxy_engine_card.combo.currentData() or "singbox"
data.tun_engine = self.tun_engine_card.combo.currentData() or "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_threshold_kbps = int(self.auto_switch_threshold_card.spin.value())
data.auto_switch_delay_sec = int(self.auto_switch_delay_card.spin.value())
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()))
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