Files
LorenEteval_Furious/Furious/Window/SettingsPage.py
T
2026-09-19 15:58:53 +08:00

1153 lines
38 KiB
Python
Raw 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.
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Present application configuration as a Fluent page of reusable cards."""
from __future__ import annotations
from Furious.Controllers import (
APPLICATION_THEME_SETTING,
SYSTEM_PROXY_MODE_OPTIONS,
)
from Furious.Frozenlib import *
from Furious.Plugins import (
CapabilityKind,
PluginSettingControl,
PluginSettingDescriptor,
PluginSettingsSection,
getPluginRegistry,
)
from Furious.Qt import *
from Furious.Qt import gettext as _
from Furious.Service import PROXY_ENDPOINT_INFO_SETTING, isCoreActive
from Furious.Service.TrafficStatsManager import (
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
METRICS_COLLECTION_SETTING,
)
from Furious.Window.TunSettingsDialog import tunModeTitle
from PySide6 import QtCore
from PySide6.QtWidgets import *
from collections import Counter
from collections.abc import Callable
import logging
__all__ = ['SettingsPage']
logger = logging.getLogger(__name__)
def _endpointPrivacyParagraphs():
"""Return concise translated disclosure text for the current providers."""
return (
_(
'<b>Public IP</b><br>\n'
"Your proxy's public IPv4 and IPv6 addresses are checked<br>\n"
'through the active proxy connection using Cloudflare,<br>\n'
'with ipify as a fallback.<br>\nThese services '
"can observe the proxy's public IP."
),
_(
'<b>Approximate Location</b><br>\n'
'The detected public IP is sent to ipapi.co<br>\n'
'to estimate country, city, region, and network organization.<br>\n'
'IP-based location can be inaccurate.'
),
_(
'<b>Map</b><br>\n'
'Map styles and tiles for the approximate area are loaded<br>\n'
'from OpenFreeMap, using OpenStreetMap data.<br>\n'
'OpenFreeMap receives these map requests.'
),
)
def _endpointPrivacyMessageBox(parent=None):
"""Build one transient Fluent data-usage disclosure."""
mbox = AppQMessageBox(
icon=AppQMessageBox.Icon.Information,
parent=parent,
text=_('Proxy Endpoint Information & Privacy'),
buttons=AppQMessageBox.StandardButton.Ok,
)
mbox.informativeLabel.setTextFormat(QtCore.Qt.TextFormat.RichText)
mbox.setInformativeText('<br><br>\n'.join(_endpointPrivacyParagraphs()))
return mbox
def _restartApplicationTitle() -> str:
"""Return the translated privilege-restart title for this platform."""
if ADMINISTRATOR_NAME == 'Administrator':
return _('Restart The Application As Administrator')
return _('Restart The Application As Superuser')
class _SettingsCard(Mixins.ThemeAware, QFrame):
"""Lay out a themed icon, title, description, and trailing control."""
IconSize = QtCore.QSize(20, 20)
def __init__(
self,
iconFileName: str,
control: QWidget,
title='',
description='',
*,
translatable=True,
parent=None,
):
"""Initialize one reusable settings row."""
super().__init__(parent)
self.setObjectName('SettingsCard')
self.iconFileName = ''
self.iconLabel = QLabel(parent=self)
self.iconLabel.setObjectName('SettingsCardIcon')
self.iconLabel.setFixedSize(self.IconSize)
self.titleLabel = AppQLabel(
title,
translatable=translatable,
parent=self,
)
self.titleLabel.setObjectName('SettingsCardTitle')
self.descriptionLabel = AppQLabel(
description,
translatable=translatable,
parent=self,
)
self.descriptionLabel.setObjectName('SettingsCardDescription')
self.descriptionLabel.setWordWrap(True)
self.control = control
self.control.setParent(self)
self.textLayout = QVBoxLayout()
self.textLayout.setContentsMargins(0, 0, 0, 0)
self.textLayout.setSpacing(2)
self.textLayout.addWidget(self.titleLabel)
self.textLayout.addWidget(self.descriptionLabel)
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(14)
layout.addWidget(self.iconLabel, 0, QtCore.Qt.AlignmentFlag.AlignTop)
layout.addLayout(self.textLayout, 1)
layout.addSpacing(16)
layout.addWidget(self.control, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
self.setIconFileName(iconFileName)
def setTexts(self, title: str, description: str):
"""Set already translated card text."""
self.titleLabel.setText(title)
self.descriptionLabel.setText(description)
def setIconFileName(self, iconFileName: str | None):
"""Set or remove the optional leading card icon."""
self.iconFileName = iconFileName or ''
# Keep the fixed-size icon column in the layout even when no artwork is
# requested so every card's title and description share one text edge.
self.iconLabel.setVisible(True)
if self.iconFileName:
self.setIconByTheme(APP().theme())
else:
self.iconLabel.clear()
def setIconByTheme(self, theme: str):
"""Refresh the card icon for the active theme."""
if not self.iconFileName:
return
iconFactory = (
bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon
)
self.iconLabel.setPixmap(iconFactory(self.iconFileName).pixmap(self.IconSize))
def themeChangedCallback(self, theme: str):
"""Refresh themed card artwork."""
self.setIconByTheme(theme)
self.control.update()
class _ToggleSettingsCard(_SettingsCard):
"""Bind one binary application preference to a trailing checkbox."""
def __init__(
self,
iconFileName,
settingName,
callback,
title='',
description='',
*,
translatable=True,
parent=None,
):
"""Initialize a persistent binary setting card."""
self.settingName = settingName
self._callback = callback
self.checkBox = AppQSwitch()
self.checkBox.syncChecked(AppSettings.isStateON_(settingName))
self.checkBox.toggled.connect(self._requestedState)
super().__init__(
iconFileName,
self.checkBox,
title,
description,
translatable=translatable,
parent=parent,
)
@QtCore.Slot(bool)
def _requestedState(self, checked: bool):
"""Apply a requested setting and restore persisted state on failure."""
if self._callback(checked) is False:
self.sync()
def sync(self):
"""Refresh the control from persistent state without applying it."""
self.checkBox.syncChecked(AppSettings.isStateON_(self.settingName))
class _EndpointInfoSettingsCard(_ToggleSettingsCard):
"""Pair the opt-in endpoint switch with an in-app privacy explanation."""
def __init__(self, callback, privacyCallback, parent=None):
"""Initialize one translated privacy-sensitive settings card."""
super().__init__(
'geo-alt.svg',
PROXY_ENDPOINT_INFO_SETTING,
callback,
_('Enable Proxy Endpoint Information'),
_('Inspect the active proxy public address and approximate location.'),
parent=parent,
)
self.privacyButton = AppQPushButton(_('Data usage'), parent=self)
self.privacyButton.setObjectName('SettingsLinkButton')
self.privacyButton.setFlat(True)
linkFont = self.privacyButton.font()
linkFont.setUnderline(True)
self.privacyButton.setFont(linkFont)
self.privacyButton.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.privacyButton.clicked.connect(privacyCallback)
self.textLayout.addWidget(
self.privacyButton,
0,
QtCore.Qt.AlignmentFlag.AlignLeft,
)
class _ActionToggleSettingsCard(_SettingsCard):
"""Present a plugin-provided checkable action as a Fluent switch."""
def __init__(
self,
iconFileName,
action,
title='',
description='',
*,
translatable=False,
parent=None,
):
"""Bind switch requests and external action-state changes."""
self.action = action
self.checkBox = AppQSwitch()
self.checkBox.syncChecked(action.isChecked())
self.checkBox.setEnabled(action.isEnabled())
self.checkBox.toggled.connect(self._requestedState)
action.toggled.connect(self.sync)
action.changed.connect(self.sync)
super().__init__(
iconFileName,
self.checkBox,
title,
description,
translatable=translatable,
parent=parent,
)
@QtCore.Slot(bool)
def _requestedState(self, checked: bool):
"""Trigger the action only when its state differs from the switch."""
if self.action.isChecked() != checked:
self.action.trigger()
@QtCore.Slot()
def sync(self, *_args):
"""Synchronize state changed by another plugin UI entry point."""
if self.checkBox.isChecked() != self.action.isChecked():
self.checkBox.syncChecked(self.action.isChecked())
self.checkBox.setEnabled(self.action.isEnabled())
class _ActionSettingsCard(_SettingsCard):
"""Expose an existing command through one settings row."""
def __init__(
self,
iconFileName,
callback: Callable,
title='',
description='',
buttonText='',
*,
translatable=True,
buttonTranslatable=None,
parent=None,
):
"""Initialize a card with a compact trailing action button."""
if buttonTranslatable is None:
buttonTranslatable = translatable
self.button = AppQPushButton(
buttonText,
translatable=buttonTranslatable,
)
self.button.setObjectName('SettingsActionButton')
self.button.clicked.connect(callback)
super().__init__(
iconFileName,
self.button,
title,
description,
translatable=translatable,
parent=parent,
)
class _LineEditSettingsCard(_SettingsCard):
"""Persist one text setting when editing finishes."""
def __init__(
self,
iconFileName,
settingName,
title='',
description='',
*,
placeholder='',
secret=False,
strip=True,
translatable=True,
parent=None,
):
"""Initialize one bounded settings text editor."""
self.settingName = settingName
self.strip = strip
self.lineEdit = AppQLineEdit(translatable=False)
self.lineEdit.setObjectName('SettingsLineEdit')
self.lineEdit.setMaximumWidth(420)
self.lineEdit.setMinimumWidth(260)
self.lineEdit.setPlaceholderText(placeholder)
self.lineEdit.setText(str(AppSettings.get(settingName) or ''))
if secret:
self.lineEdit.setEchoMode(AppQLineEdit.EchoMode.Password)
self.lineEdit.editingFinished.connect(self.persist)
super().__init__(
iconFileName,
self.lineEdit,
title,
description,
translatable=translatable,
parent=parent,
)
def persist(self):
"""Store the current text without coupling it to graph code."""
value = self.lineEdit.text()
if self.strip:
value = value.strip()
AppSettings.set(self.settingName, value)
class _LanguageSettingsCard(_SettingsCard):
"""Select and apply one supported application language."""
def __init__(self, title='', description='', parent=None):
"""Initialize the stable language-name selector."""
# These are intentional native-language representations (for example,
# "Русский" and "简体中文"), not strings in the active UI locale.
self.comboBox = QComboBox()
self.comboBox.setObjectName('SettingsComboBox')
self.comboBox.setMinimumWidth(180)
for languageName, language in LANGUAGE_TO_ABBR.items():
self.comboBox.addItem(languageName, language)
self.sync()
self.comboBox.currentIndexChanged.connect(self._selectionChanged)
super().__init__(
'globe2.svg',
self.comboBox,
title,
description,
parent=parent,
)
def sync(self):
"""Select the persisted language without retriggering translation."""
if not hasattr(self, 'comboBox'):
return
blocker = QtCore.QSignalBlocker(self.comboBox)
index = self.comboBox.findData(AppSettings.get('Language'))
self.comboBox.setCurrentIndex(max(index, 0))
del blocker
@QtCore.Slot(int)
def _selectionChanged(self, _index: int):
"""Delegate language application to the settings controller."""
language = self.comboBox.currentData()
if isinstance(language, str):
AppSettingsController().setLanguage(language)
class _ApplicationThemeSettingsCard(_SettingsCard):
"""Select the source of the application light or dark appearance."""
Options = (
('Follow System Appearance', ApplicationTheme.System),
('Light Theme', ApplicationTheme.Light),
('Dark Theme', ApplicationTheme.Dark),
)
_TranslatableOptions = (
_('Follow System Appearance'),
_('Light Theme'),
_('Dark Theme'),
)
def __init__(self, title='', description='', parent=None):
"""Initialize the translated application-theme selector."""
self.comboBox = AppQComboBox()
self.comboBox.setObjectName('SettingsComboBox')
self.comboBox.setContentWidthAdjustable()
self.comboBox.setMinimumWidth(260)
for label, preference in self.Options:
self.comboBox.addItem(_(label), preference.value)
self.sync()
self.comboBox.currentIndexChanged.connect(self._selectionChanged)
super().__init__(
'moon-stars.svg',
self.comboBox,
title,
description,
parent=parent,
)
def sync(self):
"""Select the persisted preference without applying it again."""
blocker = QtCore.QSignalBlocker(self.comboBox)
index = self.comboBox.findData(AppSettings.get(APPLICATION_THEME_SETTING))
self.comboBox.setCurrentIndex(max(index, 0))
del blocker
@QtCore.Slot(int)
def _selectionChanged(self, _index: int):
"""Persist and apply the selected application theme preference."""
preference = self.comboBox.currentData()
if isinstance(preference, str):
AppSettingsController().setApplicationTheme(preference)
class _SystemProxySettingsCard(_SettingsCard):
"""Select how Furious manages the operating-system proxy."""
Options = SYSTEM_PROXY_MODE_OPTIONS
_TranslatableOptions = (
_('Automatically Configure System Proxy'),
_('Do Not Change System Proxy'),
)
def __init__(self, title='', description='', parent=None):
"""Initialize the translated system-proxy mode selector."""
self.comboBox = AppQComboBox()
self.comboBox.setObjectName('SettingsComboBox')
self.comboBox.setContentWidthAdjustable()
self.comboBox.setMinimumWidth(260)
for label, mode in self.Options:
self.comboBox.addItem(_(label), mode)
self.sync()
self.comboBox.currentIndexChanged.connect(self._selectionChanged)
super().__init__(
'hdd-network.svg',
self.comboBox,
title,
description,
parent=parent,
)
def sync(self, mode=None):
"""Select the persisted proxy mode without writing it again."""
blocker = QtCore.QSignalBlocker(self.comboBox)
selectedMode = (
mode if isinstance(mode, str) else AppSettings.get('SystemProxyMode')
)
index = self.comboBox.findData(selectedMode)
self.comboBox.setCurrentIndex(max(index, 0))
del blocker
@QtCore.Slot(int)
def _selectionChanged(self, _index: int):
"""Persist the selected proxy-management mode."""
mode = self.comboBox.currentData()
if isinstance(mode, str):
AppSettingsController().setSystemProxyMode(mode)
class _SettingsSection(QWidget):
"""Group a translated heading and a stack of settings cards."""
def __init__(self, title='', *, translatable=True, parent=None):
"""Initialize an empty settings section."""
super().__init__(parent)
self.titleLabel = AppQLabel(
title,
translatable=translatable,
parent=self,
)
self.titleLabel.setObjectName('SettingsSectionTitle')
self.cards = []
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(6)
self.layout.addWidget(self.titleLabel)
def addCard(self, card: _SettingsCard):
"""Append a card while retaining it for translation and syncing."""
self.cards.append(card)
self.layout.addWidget(card)
def suppressDuplicateIcons(self):
"""Remove repeated artwork that adds no meaning within this section."""
iconCounts = Counter(
card.iconFileName for card in self.cards if card.iconFileName
)
duplicateIcons = {
iconFileName for iconFileName, count in iconCounts.items() if count > 1
}
for card in self.cards:
if card.iconFileName in duplicateIcons:
card.setIconFileName(None)
class SettingsPage(Mixins.QTranslatable, QMainWindow):
"""Compose application settings without owning their operational logic."""
def __init__(
self,
*,
tunSettingsDialogFactory,
proxyBypassDialog,
networkTestDialog,
checkForUpdates,
openAboutPage,
restartAsAdmin,
openApplicationFolder,
parent=None,
):
"""Build settings sections around existing actions and dialogs."""
super().__init__(parent)
self.setObjectName('SettingsPage')
(
self._tunSettingsDialogFactory,
self._proxyBypassDialog,
self._networkTestDialog,
self._checkForUpdates,
self._openAboutPage,
self._restartAsAdmin,
self._openApplicationFolder,
) = (
tunSettingsDialogFactory,
proxyBypassDialog,
networkTestDialog,
checkForUpdates,
openAboutPage,
restartAsAdmin,
openApplicationFolder,
)
self.pageTitleLabel = AppQLabel(_('Settings'))
self.pageTitleLabel.setObjectName('SettingsPageTitle')
self.generalSection = _SettingsSection(_('General'))
self.connectionSection = _SettingsSection(_('Connection and Interface'))
self.applicationSection = _SettingsSection(_('Application'))
self.pluginSettingsTitleLabel = AppQLabel(_('Plugin Settings'))
self.pluginSettingsTitleLabel.setObjectName('SettingsSectionTitle')
self.pluginSections = []
self._pluginActions = []
self._tunModeAvailable = AppSettingsController().tunModeAvailable()
self.tunModeCard = _ToggleSettingsCard(
'shield-check.svg',
'VPNMode',
AppSettingsController().setTUNMode,
tunModeTitle(),
_('Route system traffic through the active proxy connection.'),
)
self.tunModeCard.setEnabled(self._tunModeAvailable)
AppSettingsController().tunModeChanged.connect(
self.tunModeCard.checkBox.syncCheckedAnimated
)
(
self.applicationThemeCard,
self.languageCard,
self.monochromeCard,
self.startupCard,
self.powerSaveCard,
) = (
_ApplicationThemeSettingsCard(
_('Application Theme'),
_('Choose how the application appearance is determined.'),
),
_LanguageSettingsCard(
_('Language'),
_('Choose the language used by the application interface.'),
),
_ToggleSettingsCard(
'circle-half.svg',
'UseMonochromeTrayIcon',
AppSettingsController().setMonochromeTrayIcon,
_('Use Monochrome Tray Icon'),
_('Use a theme-aware single-color system tray icon.'),
),
_ToggleSettingsCard(
'power.svg',
'StartupOnBoot',
AppSettingsController().setStartupOnBoot,
_('Startup On Boot'),
_('Start the application automatically after signing in.'),
),
_ToggleSettingsCard(
'battery-half.svg',
'PowerSaveMode',
AppSettingsController().setPowerSaveMode,
_('Power Save Mode'),
_('Reduce background activity when the application is idle.'),
),
)
self.generalSection.addCard(self.tunModeCard)
self.generalSection.addCard(self.applicationThemeCard)
self.generalSection.addCard(self.languageCard)
self.generalSection.addCard(self.monochromeCard)
if PLATFORM == 'Darwin':
self.hideDockCard = _ToggleSettingsCard(
'window.svg',
'HideDockIcon',
AppSettingsController().setDockIconHidden,
_('Hide Dock Icon'),
_('Keep the application available from the menu bar only.'),
)
self.generalSection.addCard(self.hideDockCard)
else:
self.hideDockCard = None
self.generalSection.addCard(self.startupCard)
self.generalSection.addCard(self.powerSaveCard)
(
self.tunSettingsCard,
self.systemProxyCard,
self.proxyBypassCard,
self.networkTestCard,
self.forceLocalhostCard,
self.connectionProgressCard,
self.metricsCollectionCard,
self.endpointInfoCard,
self.clearTrafficUsageCard,
self.editorWhitespaceCard,
) = (
_ActionSettingsCard(
'diagram-3.svg',
self._openTUNSettings,
_('Customize Tun2socks Settings...'),
_('Configure the external Tun2socks network interface and routing.'),
_('Open'),
),
_SystemProxySettingsCard(
_('System Proxy'),
_(
'Choose whether Furious automatically configures the operating system proxy.'
),
),
_ActionSettingsCard(
'signpost-split.svg',
self._proxyBypassDialog.open,
_('Customize System Proxy Bypass Address...'),
_('Choose destinations that bypass the operating system proxy.'),
_('Open'),
),
_ActionSettingsCard(
'link-45deg.svg',
self._networkTestDialog.open,
_('Customize Network Test URL...'),
_('Choose the URL used for download speed tests.'),
_('Open'),
),
_ToggleSettingsCard(
'pc-display-horizontal.svg',
'ForceToLocalhostWhenSettingLocalProxy',
AppSettingsController().setForceLocalProxy,
_('Force To 127.0.0.1 When Setting Local Proxy'),
_('Use the IPv4 loopback address when configuring the system proxy.'),
),
_ToggleSettingsCard(
'hourglass-split.svg',
'ShowProgressBarWhenConnecting',
AppSettingsController().setConnectionProgressVisible,
_('Show Progress Bar When Connecting'),
_('Show connection progress while proxy services are starting.'),
),
_ToggleSettingsCard(
'speedometer.svg',
METRICS_COLLECTION_SETTING,
AppSettingsController().setMetricsCollectionEnabled,
_('Enable Metrics Collection'),
_('Collect network speed and traffic history while connected.'),
),
_EndpointInfoSettingsCard(
AppSettingsController().setProxyEndpointInfoEnabled,
self._showEndpointPrivacy,
),
_ToggleSettingsCard(
'arrow-repeat.svg',
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
AppSettingsController().setClearTrafficUsageOnReconnect,
_('Clear Traffic Usage Statistics On Reconnect'),
_(
'Start accumulated upload and download usage from zero after reconnecting.'
),
),
_ToggleSettingsCard(
'text-paragraph.svg',
'ShowTabAndSpacesInEditor',
AppSettingsController().setEditorWhitespaceVisible,
_('Show Tab And Spaces In Editor'),
_('Display whitespace markers in configuration editors.'),
),
)
AppSettingsController().systemProxyModeChanged.connect(
self.systemProxyCard.sync
)
if not SystemRuntime.flatpakID():
self.connectionSection.addCard(self.tunSettingsCard)
self.connectionSection.addCard(self.systemProxyCard)
self.connectionSection.addCard(self.proxyBypassCard)
self.connectionSection.addCard(self.networkTestCard)
self.connectionSection.addCard(self.forceLocalhostCard)
self.connectionSection.addCard(self.connectionProgressCard)
self.connectionSection.addCard(self.metricsCollectionCard)
self.connectionSection.addCard(self.endpointInfoCard)
self.connectionSection.addCard(self.clearTrafficUsageCard)
self.connectionSection.addCard(self.editorWhitespaceCard)
if SystemRuntime.isAssetsFolderWritable():
self.autoAssetsCard = _ToggleSettingsCard(
'cloud-arrow-down.svg',
'AutoUpdateAssetFiles',
AppSettingsController().setAutoUpdateAssets,
_('Automatically Update Asset Files'),
_('Keep supported proxy-core data files up to date.'),
)
self.connectionSection.addCard(self.autoAssetsCard)
else:
self.autoAssetsCard = None
self._buildPluginSections()
(
self.updateCard,
self.aboutCard,
) = (
_ActionSettingsCard(
'download.svg',
lambda: self._checkForUpdates(parent=self),
_('Check For Updates'),
_('Check for a newer application and proxy-core release.'),
_('Check'),
),
_ActionSettingsCard(
'info-circle.svg',
self._openAboutPage,
_('About'),
f'{APPLICATION_NAME} {APPLICATION_VERSION}',
_('View'),
),
)
self.applicationSection.addCard(self.updateCard)
self.applicationSection.addCard(self.aboutCard)
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
self.restartCard = _ActionSettingsCard(
'arrow-clockwise.svg',
self._restartAsAdmin,
_restartApplicationTitle(),
_('Restart with privileges required by system-level networking.'),
_('Restart'),
)
self.generalSection.addCard(self.restartCard)
else:
self.restartCard = None
if PLATFORM != 'Darwin' and not SystemRuntime.flatpakID():
self.openFolderCard = _ActionSettingsCard(
'folder2-open.svg',
self._openApplicationFolder,
_('Open Application Folder'),
_('Open the folder containing the current application.'),
_('Open'),
)
self.generalSection.addCard(self.openFolderCard)
else:
self.openFolderCard = None
contentWidget = QWidget()
contentWidget.setObjectName('SettingsPageContent')
contentLayout = QVBoxLayout(contentWidget)
contentLayout.setContentsMargins(20, 18, 20, 24)
contentLayout.setSpacing(22)
contentLayout.addWidget(self.pageTitleLabel)
contentLayout.addWidget(self.generalSection)
contentLayout.addWidget(self.connectionSection)
if self.pluginSections:
contentLayout.addWidget(self.pluginSettingsTitleLabel)
for section in self.pluginSections:
contentLayout.addWidget(section)
contentLayout.addWidget(self.applicationSection)
contentLayout.addStretch(1)
self.scrollArea = QScrollArea()
self.scrollArea.setObjectName('SettingsScrollArea')
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setFrameShape(QFrame.Shape.NoFrame)
self.scrollArea.setWidget(contentWidget)
self.setCentralWidget(self.scrollArea)
self.retranslate()
@staticmethod
def _translatedPluginText(value: str, translatable: bool) -> str:
"""Translate host-owned plugin metadata only when explicitly requested."""
return _(value) if translatable else value
def _cardFromPluginDescriptor(self, descriptor: PluginSettingDescriptor):
"""Create one host-owned Fluent card from a plugin descriptor."""
if not descriptor.id or not descriptor.title:
raise ValueError('plugin settings require non-empty IDs and titles')
try:
control = PluginSettingControl(descriptor.control)
except ValueError as ex:
raise ValueError(
f'unsupported plugin setting control: {descriptor.control!r}'
) from ex
title, description = (
self._translatedPluginText(
descriptor.title,
descriptor.translatable,
),
self._translatedPluginText(
descriptor.description,
descriptor.translatable,
),
)
if control == PluginSettingControl.Toggle:
if not descriptor.settingName:
raise ValueError('toggle plugin settings require settingName')
callback = descriptor.callback
if callback is None:
def callback(enabled, settingName=descriptor.settingName):
"""Persist a declarative plugin toggle by default."""
if enabled:
AppSettings.turnON_(settingName)
else:
AppSettings.turnOFF(settingName)
card = _ToggleSettingsCard(
descriptor.iconFileName,
descriptor.settingName,
callback,
title,
description,
translatable=descriptor.translatable,
)
elif control in (PluginSettingControl.Text, PluginSettingControl.Password):
if not descriptor.settingName:
raise ValueError('text plugin settings require settingName')
card = _LineEditSettingsCard(
descriptor.iconFileName,
descriptor.settingName,
title,
description,
placeholder=descriptor.placeholder,
secret=control == PluginSettingControl.Password,
strip=descriptor.strip,
translatable=descriptor.translatable,
)
else:
if not callable(descriptor.callback):
raise ValueError('action plugin settings require a callback')
card = _ActionSettingsCard(
descriptor.iconFileName,
descriptor.callback,
title,
description,
self._translatedPluginText(
descriptor.buttonText,
descriptor.translatable,
),
translatable=descriptor.translatable,
)
return card
def _addPluginDescriptorSection(
self,
sectionDescriptor: PluginSettingsSection,
):
"""Validate and append one declarative plugin settings section."""
if not isinstance(sectionDescriptor, PluginSettingsSection):
raise TypeError('plugin settings providers must return sections')
section = _SettingsSection(
self._translatedPluginText(
sectionDescriptor.title,
sectionDescriptor.translatable,
),
translatable=sectionDescriptor.translatable,
)
section._pluginDescriptor = sectionDescriptor
for descriptor in sectionDescriptor.settings:
if not isinstance(descriptor, PluginSettingDescriptor):
raise TypeError('plugin settings sections must contain descriptors')
section.addCard(self._cardFromPluginDescriptor(descriptor))
section.suppressDuplicateIcons()
if section.cards:
self.pluginSections.append(section)
def _addPluginActionSection(self, plugin, metadata, registry):
"""Move legacy plugin management actions into dynamic Settings cards."""
actions = registry.managementActions(
plugin,
parent=self,
isCoreActive=isCoreActive,
)
section = _SettingsSection(
metadata.displayName,
translatable=False,
)
section._pluginActionMetadata = metadata
description = (
_(metadata.description) if metadata.description else metadata.displayName
)
for action in actions:
if isinstance(action, AppQSeparator):
continue
if not isinstance(action, AppQAction):
continue
self._pluginActions.append(action)
iconFileName = action.iconFileName or 'plugin.svg'
if action.isCheckable():
card = _ActionToggleSettingsCard(
iconFileName,
action,
action.text(),
description,
translatable=True,
)
else:
card = _ActionSettingsCard(
iconFileName,
lambda _checked=False, target=action: target.trigger(),
action.text(),
description,
_('Open'),
translatable=True,
buttonTranslatable=True,
)
# Plugin/action names are literal identities; descriptions may be
# host translation keys contributed through the static catalog.
card.titleLabel.translatable = False
action.changed.connect(
lambda target=card, source=action, owner=metadata: target.setTexts(
source.text(),
_(owner.description) if owner.description else owner.displayName,
)
)
section.addCard(card)
section.suppressDuplicateIcons()
if section.cards:
self.pluginSections.append(section)
def _buildPluginSections(self):
"""Build plugin settings/actions without hard-coding plugin identities."""
registry = getPluginRegistry()
for plugin in registry.plugins():
metadata = registry.metadataFor(plugin)
for provider in registry.capabilities(
CapabilityKind.PluginSettings,
plugin,
):
try:
sections = tuple(provider.createSections(parent=self))
for section in sections:
self._addPluginDescriptorSection(section)
except Exception as ex:
# Any non-exit exceptions
logger.error(
f'failed to create settings from plugin '
f'{metadata.id!r}: {ex}'
)
if registry.capabilities(CapabilityKind.ActionProvider, plugin):
try:
self._addPluginActionSection(plugin, metadata, registry)
except Exception as ex:
# Any non-exit exceptions
logger.error(
f'failed to create management settings from plugin '
f'{metadata.id!r}: {ex}'
)
def _openTUNSettings(self):
"""Create and retain the existing Tun2socks settings dialog."""
self._tunSettingsDialogFactory(parent=self).open()
def _showEndpointPrivacy(self):
"""Show the end-user disclosure for the active endpoint providers."""
_endpointPrivacyMessageBox(self).open()
def setConnectionControlsEnabled(self, enabled: bool):
"""Disable connection-sensitive settings during a transition."""
self.tunModeCard.setEnabled(bool(enabled) and self._tunModeAvailable)
self.systemProxyCard.comboBox.setEnabled(bool(enabled))
def showEvent(self, event):
"""Synchronize controls in case a legacy action changed a setting."""
super().showEvent(event)
for section in (
self.generalSection,
self.connectionSection,
self.applicationSection,
*self.pluginSections,
):
for card in section.cards:
sync = getattr(card, 'sync', None)
if callable(sync):
sync()
self.languageCard.sync()
def retranslate(self):
"""Synchronize page-level dynamic state after a language change."""
self.languageCard.sync()