Add plugin-contributed settings and navigation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:22:56 +08:00
parent 0254c6c6b1
commit 5b30c837c5
9 changed files with 734 additions and 78 deletions
+89 -2
View File
@@ -26,7 +26,13 @@ from Furious.Backends.Configuration import *
from .Process import Hysteria2
from .ProtocolEditors import HYSTERIA2_PROTOCOL_EDITORS
from .Protocols import HYSTERIA2_PROTOCOL_HANDLERS
from .Stats import Hysteria2StatsProvider, configuredHysteria2StatsTarget
from .Stats import (
HYSTERIA2_STATS_CLIENT_ID_SETTING,
HYSTERIA2_STATS_SECRET_SETTING,
HYSTERIA2_STATS_URL_SETTING,
Hysteria2StatsProvider,
configuredHysteria2StatsTarget,
)
from .TUN import *
import logging
@@ -36,6 +42,74 @@ __all__ = ['Hysteria2Plugin']
logger = logging.getLogger(__name__)
def _placeholder(x):
return x
_ = _placeholder
# Register host-owned descriptor text with the static translation extractor
# without making the headless plugin module import Qt presentation code.
_TRANSLATABLE_SETTINGS = (
_('Hysteria2 Traffic Statistics'),
_('Traffic Stats API URL'),
_('Hysteria2 server API address; /traffic is appended automatically.'),
_('Traffic Stats Client ID'),
_('Authentication ID reported by the Hysteria2 server.'),
_('Traffic Stats API Secret'),
_('Authorization value configured by the Hysteria2 server.'),
)
class Hysteria2SettingsProvider(PluginSettingsProvider):
"""Declare Hysteria 2 traffic-API preferences for the host UI."""
providerId = 'official.hysteria2.settings'
def createSections(self, parent=None, **kwargs):
"""Return host-rendered Hysteria 2 settings descriptors."""
del parent, kwargs
return (
PluginSettingsSection(
'traffic-statistics',
'Hysteria2 Traffic Statistics',
(
PluginSettingDescriptor(
'stats-url',
'Traffic Stats API URL',
'Hysteria2 server API address; /traffic is appended automatically.',
'activity.svg',
PluginSettingControl.Text,
HYSTERIA2_STATS_URL_SETTING,
placeholder='http://server:9999',
translatable=True,
),
PluginSettingDescriptor(
'stats-client-id',
'Traffic Stats Client ID',
'Authentication ID reported by the Hysteria2 server.',
'person-badge.svg',
PluginSettingControl.Text,
HYSTERIA2_STATS_CLIENT_ID_SETTING,
translatable=True,
),
PluginSettingDescriptor(
'stats-secret',
'Traffic Stats API Secret',
'Authorization value configured by the Hysteria2 server.',
'key.svg',
PluginSettingControl.Password,
HYSTERIA2_STATS_SECRET_SETTING,
translatable=True,
strip=False,
),
),
translatable=True,
),
)
class Hysteria2ActionProvider(ActionProvider):
"""Provide optional Hysteria 2 management UI independently of its runtime."""
@@ -137,7 +211,8 @@ class Hysteria2KernelFactory(KernelFactory):
exitCallback=request.exitCallback,
msgCallback=request.messageCallback,
)
process.hysteria2StatsTarget = configuredHysteria2StatsTarget()
setattr(process, 'hysteria2StatsTarget', configuredHysteria2StatsTarget())
return KernelLaunch(
process,
@@ -167,6 +242,17 @@ class Hysteria2KernelFactory(KernelFactory):
return (r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})',)
def _placeholder(x):
return x
_ = _placeholder
_TRANSLATABLE = [
_('Official Hysteria 2 protocol, editor, and runtime support.'),
]
class Hysteria2Plugin(FuriousPlugin):
"""Bundle official Hysteria 2 protocol and runtime capabilities."""
@@ -184,5 +270,6 @@ class Hysteria2Plugin(FuriousPlugin):
*HYSTERIA2_PROTOCOL_EDITORS,
Hysteria2KernelFactory(),
Hysteria2StatsProvider(),
Hysteria2SettingsProvider(),
Hysteria2ActionProvider(),
)
+88
View File
@@ -31,9 +31,15 @@ __all__ = [
'KernelFactory',
'KernelLaunch',
'KernelRequest',
'NavigationPageDescriptor',
'NavigationPageProvider',
'PluginCapability',
'PluginContext',
'PluginMetadata',
'PluginSettingControl',
'PluginSettingDescriptor',
'PluginSettingsProvider',
'PluginSettingsSection',
'ProtocolDescriptor',
'ProtocolEditorProvider',
'ProtocolHandler',
@@ -59,6 +65,8 @@ class CapabilityKind(str, Enum):
SubscriptionDecoder = 'subscription-decoder'
KernelFactory = 'kernel-factory'
TrafficStats = 'traffic-stats'
PluginSettings = 'plugin-settings'
NavigationPage = 'navigation-page'
Utility = 'utility'
@@ -101,6 +109,86 @@ class ActionProvider(PluginCapability):
return tuple()
class PluginSettingControl(str, Enum):
"""Identify host-rendered controls available to plugin settings."""
Toggle = 'toggle'
Text = 'text'
Password = 'password'
Action = 'action'
@dataclass(frozen=True)
class PluginSettingDescriptor:
"""Describe one setting without coupling a plugin to host widgets."""
id: str
title: str
description: str = ''
iconFileName: str = 'plugin.svg'
control: PluginSettingControl = PluginSettingControl.Text
settingName: str = ''
callback: Optional[Callable] = None
buttonText: str = 'Open'
placeholder: str = ''
translatable: bool = False
strip: bool = True
@dataclass(frozen=True)
class PluginSettingsSection:
"""Group declarative settings contributed by one plugin."""
id: str
title: str
settings: Tuple[PluginSettingDescriptor, ...]
translatable: bool = False
class PluginSettingsProvider(PluginCapability):
"""Contribute host-rendered settings sections dynamically."""
capabilityKind = CapabilityKind.PluginSettings
providerId = ''
@property
def capabilityId(self) -> str:
"""Return the settings-provider identifier."""
return self.providerId
def createSections(self, parent=None, **kwargs):
"""Return ``PluginSettingsSection`` values for the Settings page."""
return tuple()
@dataclass(frozen=True)
class NavigationPageDescriptor:
"""Describe a lazily constructed plugin navigation page."""
id: str
title: str
iconFileName: str
factory: Callable
order: int = 0
translatable: bool = False
class NavigationPageProvider(PluginCapability):
"""Contribute pages to the application's Fluent navigation rail."""
capabilityKind = CapabilityKind.NavigationPage
providerId = ''
@property
def capabilityId(self) -> str:
"""Return the navigation-page provider identifier."""
return self.providerId
def pageDescriptors(self):
"""Return ``NavigationPageDescriptor`` values."""
return tuple()
@dataclass(frozen=True)
class ProtocolDescriptor:
"""Describe one user-visible proxy protocol."""
+47 -2
View File
@@ -192,13 +192,58 @@ also dispatched through the selected factory.
Management actions are a distinct `ActionProvider`; a runtime factory therefore
does not import or own Qt UI merely because its plugin also offers settings.
The Settings page renders those actions under the owning plugin automatically.
Plugins that need persistent fields can additionally expose declarative
settings without importing host widgets:
```python
from Furious.Plugins import (
PluginSettingControl,
PluginSettingDescriptor,
PluginSettingsProvider,
PluginSettingsSection,
)
class ExampleSettings(PluginSettingsProvider):
providerId = 'example.settings'
def createSections(self, parent=None, **kwargs):
return (
PluginSettingsSection(
'api',
'Example API',
(
PluginSettingDescriptor(
'url',
'API URL',
'Address queried by the example plugin.',
settingName='ExampleApiUrl',
),
),
),
)
```
The host currently renders toggle, text, password, and action controls with the
same Fluent cards used by built-in settings. Plugins remain responsible for
registering any persistent `AppSettings` names they declare.
`NavigationPageProvider` is the independent extension point for full pages.
Each `NavigationPageDescriptor` supplies a literal or translatable title, icon,
stable ID, order, and a factory accepting `parent=`. The host namespaces page
IDs by plugin, retains the widgets, and inserts them between Subscription and
Metrics. A plugin can therefore expose a page without modifying `MainWindow`.
Traffic monitoring is similarly optional. A `TrafficStatsProvider` associates
one or more kernel types with a `TrafficStatsMonitor`. The monitor contains a
query callable and backend-specific target, allowing the host service to run
native API calls on a lightweight background thread. The service exposes both
cumulative usage and calculated rates to Qt widgets, and suspends polling while
its parent widget is hidden or minimized.
cumulative usage and calculated rates to Qt widgets. Collection continues while
the Metrics page is hidden, while graph painting remains lazy; the persistent
metrics-collection preference can suspend provider queries and history storage
entirely.
## Profiles and persistence
+8
View File
@@ -513,6 +513,14 @@ class PluginRegistry:
"""Return registered runtime traffic-statistics providers."""
return self.capabilities(CapabilityKind.TrafficStats)
def pluginSettingsProviders(self):
"""Return providers contributing host-rendered settings sections."""
return self.capabilities(CapabilityKind.PluginSettings)
def navigationPageProviders(self):
"""Return providers contributing application navigation pages."""
return self.capabilities(CapabilityKind.NavigationPage)
def handlerForProtocol(self, protocol):
"""Return the handler registered for a protocol identifier."""
entry = self._protocols.get(_normalizeIdentifier(protocol))
+12
View File
@@ -27,9 +27,15 @@ from .API import (
KernelFactory,
KernelLaunch,
KernelRequest,
NavigationPageDescriptor,
NavigationPageProvider,
PluginCapability,
PluginContext,
PluginMetadata,
PluginSettingControl,
PluginSettingDescriptor,
PluginSettingsProvider,
PluginSettingsSection,
ProtocolDescriptor,
ProtocolEditorProvider,
ProtocolHandler,
@@ -68,10 +74,16 @@ __all__ = [
'KernelFactory',
'KernelLaunch',
'KernelRequest',
'NavigationPageDescriptor',
'NavigationPageProvider',
'PluginCapability',
'PluginContext',
'PluginRegistry',
'PluginMetadata',
'PluginSettingControl',
'PluginSettingDescriptor',
'PluginSettingsProvider',
'PluginSettingsSection',
'ProtocolDescriptor',
'ProtocolEditorProvider',
'ProtocolHandler',
+21 -3
View File
@@ -325,6 +325,8 @@ class AppStyleSheet:
QWidget#MetricsPageContent,
QScrollArea#SettingsScrollArea,
QWidget#SettingsPageContent,
QScrollArea#SubscriptionScrollArea,
QWidget#SubscriptionPageContent,
QWidget#LogPageContent {{
border: none;
background-color: {palette['window']};
@@ -332,6 +334,7 @@ class AppStyleSheet:
QLabel#MetricsPageTitle,
QLabel#LogPageTitle,
QLabel#SubscriptionPageTitle,
QLabel#SettingsPageTitle {{
color: {palette['text_strong']};
font-size: 16pt;
@@ -1151,21 +1154,36 @@ class AppStyleSheet:
QWidget#TrafficStatsBadge {{
min-height: 24px;
padding: 0 2px;
padding: 0;
border: none;
background-color: transparent;
}}
QFrame#TrafficDirectionBadge {{
border: 1px solid {palette['border']};
border-radius: 6px;
background-color: {palette['raised']};
color: {palette['text_strong']};
}}
QWidget#TrafficStatsBadge QLabel {{
QFrame#TrafficDirectionBadge QLabel {{
border: none;
padding: 0;
background-color: transparent;
color: {palette['text_strong']};
}}
QWidget#TrafficStatsBadge QLabel#TrafficUsageLabel {{
QFrame#TrafficDirectionBadge[direction="download"] QLabel#TrafficSpeedLabel {{
color: {palette['metrics_download']};
font-weight: 600;
}}
QFrame#TrafficDirectionBadge[direction="upload"] QLabel#TrafficSpeedLabel {{
color: {palette['metrics_upload']};
font-weight: 600;
}}
QFrame#TrafficDirectionBadge QLabel#TrafficUsageLabel {{
color: {palette['muted']};
}}
+121
View File
@@ -0,0 +1,121 @@
# Copyright (C) 2024–present 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.
"""Bridge plugin UI capabilities into the host's page-based interface."""
from __future__ import annotations
from Furious.Frozenlib import APP
from Furious.Plugins import (
CapabilityKind,
NavigationPageDescriptor,
getPluginRegistry,
)
from PySide6.QtWidgets import QWidget
import logging
__all__ = ['PluginNavigationManager', 'isCoreActive']
logger = logging.getLogger(__name__)
def isCoreActive(coreType) -> bool:
"""Return whether the active connection owns a core of ``coreType``."""
try:
connectAction = APP().systemTray.ConnectAction
return connectAction.isConnected() and any(
isinstance(process, coreType)
for process in connectAction.coreManager.processesPool
)
except (AttributeError, RuntimeError):
return False
class PluginNavigationManager:
"""Validate, construct, and retain plugin-contributed navigation pages."""
def __init__(self, registry=None):
"""Initialize with the process-wide registry unless one is supplied."""
self.registry = registry or getPluginRegistry()
self._pages = []
def registerPages(self, navigationView):
"""Register plugin pages in stable plugin/provider/order sequence."""
contributions = []
for plugin in self.registry.plugins():
metadata = self.registry.metadataFor(plugin)
for provider in self.registry.capabilities(
CapabilityKind.NavigationPage,
plugin,
):
try:
descriptors = tuple(provider.pageDescriptors())
except Exception as ex:
logger.error(
f'plugin navigation provider '
f'{provider.capabilityId!r} failed: {ex}'
)
continue
for descriptor in descriptors:
if not isinstance(descriptor, NavigationPageDescriptor):
logger.error(
f'plugin {metadata.id!r} returned an invalid '
f'navigation-page descriptor'
)
continue
contributions.append((plugin, metadata, descriptor))
contributions.sort(key=lambda item: item[2].order)
for _plugin, metadata, descriptor in contributions:
pageId = f'plugin:{metadata.id}:{descriptor.id}'
try:
page = descriptor.factory(parent=navigationView)
except Exception as ex:
logger.error(f'failed to create plugin page {pageId!r}: {ex}')
continue
if not isinstance(page, QWidget):
logger.error(f'plugin page {pageId!r} did not create a QWidget')
continue
try:
navigationView.addPage(
pageId,
page,
descriptor.title,
descriptor.iconFileName,
translatable=descriptor.translatable,
)
except Exception as ex:
logger.error(f'failed to register plugin page {pageId!r}: {ex}')
page.deleteLater()
continue
self._pages.append((pageId, page))
return tuple(self._pages)
def pages(self):
"""Return successfully registered plugin pages."""
return tuple(self._pages)
+3
View File
@@ -41,6 +41,7 @@ from .MetricsDataManager import (
MetricSample,
MetricsDataManager,
)
from .PluginUIManager import PluginNavigationManager, isCoreActive
from .SubscriptionImporter import (
SubscriptionImportResult,
SubscriptionImportService,
@@ -71,6 +72,7 @@ __all__ = [
'MetricPoint',
'MetricSample',
'MetricsDataManager',
'PluginNavigationManager',
'coreLogCallback',
'SubscriptionImportResult',
'SubscriptionImportService',
@@ -81,4 +83,5 @@ __all__ = [
'formatTrafficSpeed',
'formatTrafficUsage',
'formatLogEntry',
'isCoreActive',
]
+345 -71
View File
@@ -19,35 +19,40 @@
from __future__ import annotations
from Furious.Actions.Language import LANGUAGE_TO_ABBR
from Furious.Actions.Settings import (
SettingsController,
TUNModeAction,
)
from Furious.Backends.Hysteria2.Stats import (
HYSTERIA2_STATS_CLIENT_ID_SETTING,
HYSTERIA2_STATS_SECRET_SETTING,
HYSTERIA2_STATS_URL_SETTING,
)
from Furious.Actions.Settings import TUNModeAction
from Furious.Controllers import SettingsController
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 isCoreActive
from Furious.Service.TrafficStatsManager import (
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
METRICS_COLLECTION_SETTING,
)
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import *
from collections.abc import Callable
import logging
__all__ = ['SettingsPage']
logger = logging.getLogger(__name__)
class _SettingsSwitch(QCheckBox):
"""Paint a lightweight Fluent-style binary switch with Qt."""
ControlSize = QtCore.QSize(38, 22)
AnimationDuration = 160
def __init__(self, parent=None):
"""Initialize the compact switch control."""
@@ -56,13 +61,70 @@ class _SettingsSwitch(QCheckBox):
self.setObjectName('SettingsToggle')
self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.setFixedSize(self.ControlSize)
self.toggled.connect(lambda _checked: self.update())
self._thumbPosition = 0.0
self._animation = QtCore.QPropertyAnimation(
self,
b'thumbPosition',
parent=self,
)
self._animation.setDuration(self.AnimationDuration)
self._animation.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
self.toggled.connect(self._animateToggle)
def _getThumbPosition(self) -> float:
"""Return the normalized thumb position used by the animation."""
return self._thumbPosition
def _setThumbPosition(self, position: float):
"""Update the animated thumb position and repaint the switch."""
self._thumbPosition = min(max(float(position), 0.0), 1.0)
self.update()
thumbPosition = QtCore.Property(
float,
_getThumbPosition,
_setThumbPosition,
)
@QtCore.Slot(bool)
def _animateToggle(self, checked: bool):
"""Animate the Fluent thumb and track to the requested state."""
self._animation.stop()
self._animation.setStartValue(self._thumbPosition)
self._animation.setEndValue(1.0 if checked else 0.0)
self._animation.start()
def syncChecked(self, checked: bool):
"""Synchronize external state without playing an entrance animation."""
blocker = QtCore.QSignalBlocker(self)
self.setChecked(bool(checked))
self._animation.stop()
self._setThumbPosition(1.0 if checked else 0.0)
del blocker
@staticmethod
def _blendColor(start, end, progress: float) -> QtGui.QColor:
"""Interpolate two theme colors for a smooth track transition."""
startColor, endColor = (
QtGui.QColor(start),
QtGui.QColor(end),
)
return QtGui.QColor.fromRgbF(
startColor.redF() + (endColor.redF() - startColor.redF()) * progress,
startColor.greenF() + (endColor.greenF() - startColor.greenF()) * progress,
startColor.blueF() + (endColor.blueF() - startColor.blueF()) * progress,
startColor.alphaF() + (endColor.alphaF() - startColor.alphaF()) * progress,
)
def paintEvent(self, event):
"""Draw the track and animated-position-equivalent thumb."""
del event
palette = AppStyleSheet.Palettes[APP().theme()]
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
@@ -75,17 +137,23 @@ class _SettingsSwitch(QCheckBox):
palette['border'],
palette['disabled'],
)
elif self.isChecked():
background, border, thumb = (
palette['accent'],
palette['accent'],
palette['accent_text'],
)
else:
background, border, thumb = (
palette['raised'],
palette['border_strong'],
palette['muted'],
self._blendColor(
palette['raised'],
palette['accent'],
self._thumbPosition,
),
self._blendColor(
palette['border_strong'],
palette['accent'],
self._thumbPosition,
),
self._blendColor(
palette['muted'],
palette['accent_text'],
self._thumbPosition,
),
)
painter.setPen(QtGui.QPen(QtGui.QColor(border), 1))
@@ -93,9 +161,9 @@ class _SettingsSwitch(QCheckBox):
painter.drawRoundedRect(track, radius, radius)
thumbDiameter = track.height() - 6
thumbX = (
track.right() - thumbDiameter - 3 if self.isChecked() else track.left() + 3
)
thumbStart = track.left() + 3
thumbEnd = track.right() - thumbDiameter - 3
thumbX = thumbStart + ((thumbEnd - thumbStart) * self._thumbPosition)
thumbRect = QtCore.QRectF(
thumbX,
track.top() + 3,
@@ -177,18 +245,45 @@ class _ToggleSettingsCard(_SettingsCard):
"""Initialize a persistent binary setting card."""
self.settingName = settingName
self.checkBox = _SettingsSwitch()
self.checkBox.setChecked(AppSettings.isStateON_(settingName))
self.checkBox.syncChecked(AppSettings.isStateON_(settingName))
self.checkBox.toggled.connect(callback)
super().__init__(iconFileName, self.checkBox, parent)
def sync(self):
"""Refresh the control from persistent state without applying it."""
blocker = QtCore.QSignalBlocker(self.checkBox)
self.checkBox.syncChecked(AppSettings.isStateON_(self.settingName))
self.checkBox.setChecked(AppSettings.isStateON_(self.settingName))
del blocker
class _ActionToggleSettingsCard(_SettingsCard):
"""Present a plugin-provided checkable action as a Fluent switch."""
def __init__(self, iconFileName, action, parent=None):
"""Bind switch requests and external action-state changes."""
self.action = action
self.checkBox = _SettingsSwitch()
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, 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):
@@ -346,8 +441,13 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.generalSection = _SettingsSection()
self.connectionSection = _SettingsSection()
self.hysteriaSection = _SettingsSection()
self.applicationSection = _SettingsSection()
self.pluginSettingsTitleLabel = QLabel()
self.pluginSettingsTitleLabel.setObjectName('SettingsSectionTitle')
self.pluginSections = []
self._pluginDescriptorCards = []
self._pluginActionCards = []
self._pluginActions = []
self.tunModeAction = TUNModeAction(
checkable=True,
@@ -417,6 +517,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.networkTestCard,
self.forceLocalhostCard,
self.connectionProgressCard,
self.metricsCollectionCard,
self.clearTrafficUsageCard,
self.editorWhitespaceCard,
) = (
@@ -442,6 +543,11 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
'ShowProgressBarWhenConnecting',
SettingsController.setConnectionProgressVisible,
),
_ToggleSettingsCard(
'speedometer2.svg',
METRICS_COLLECTION_SETTING,
SettingsController.setMetricsCollectionEnabled,
),
_ToggleSettingsCard(
'arrow-repeat.svg',
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
@@ -461,6 +567,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.connectionSection.addCard(self.networkTestCard)
self.connectionSection.addCard(self.forceLocalhostCard)
self.connectionSection.addCard(self.connectionProgressCard)
self.connectionSection.addCard(self.metricsCollectionCard)
self.connectionSection.addCard(self.clearTrafficUsageCard)
self.connectionSection.addCard(self.editorWhitespaceCard)
@@ -474,31 +581,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
else:
self.autoAssetsCard = None
(
self.hysteriaURLCard,
self.hysteriaClientIdCard,
self.hysteriaSecretCard,
) = (
_LineEditSettingsCard(
'activity.svg',
HYSTERIA2_STATS_URL_SETTING,
placeholder='http://server:9999',
),
_LineEditSettingsCard(
'person-badge.svg',
HYSTERIA2_STATS_CLIENT_ID_SETTING,
),
_LineEditSettingsCard(
'key.svg',
HYSTERIA2_STATS_SECRET_SETTING,
secret=True,
strip=False,
),
)
self.hysteriaSection.addCard(self.hysteriaURLCard)
self.hysteriaSection.addCard(self.hysteriaClientIdCard)
self.hysteriaSection.addCard(self.hysteriaSecretCard)
self._buildPluginSections()
(
self.updateCard,
@@ -544,7 +627,13 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
contentLayout.addWidget(self.pageTitleLabel)
contentLayout.addWidget(self.generalSection)
contentLayout.addWidget(self.connectionSection)
contentLayout.addWidget(self.hysteriaSection)
if self.pluginSections:
contentLayout.addWidget(self.pluginSettingsTitleLabel)
for section in self.pluginSections:
contentLayout.addWidget(section)
contentLayout.addWidget(self.applicationSection)
contentLayout.addStretch(1)
@@ -557,6 +646,162 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
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
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,
)
elif control in (PluginSettingControl.Text, PluginSettingControl.Password):
if not descriptor.settingName:
raise ValueError('text plugin settings require settingName')
card = _LineEditSettingsCard(
descriptor.iconFileName,
descriptor.settingName,
placeholder=descriptor.placeholder,
secret=control == PluginSettingControl.Password,
strip=descriptor.strip,
)
else:
if not callable(descriptor.callback):
raise ValueError('action plugin settings require a callback')
card = _ActionSettingsCard(
descriptor.iconFileName,
descriptor.callback,
)
self._pluginDescriptorCards.append((card, descriptor))
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()
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))
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()
section._pluginActionMetadata = metadata
for action in actions:
if isinstance(action, AppQSeperator):
continue
if not isinstance(action, AppQAction):
continue
self._pluginActions.append(action)
iconFileName = action.iconFileName or 'plugin.svg'
if action.isCheckable():
card = _ActionToggleSettingsCard(iconFileName, action)
else:
card = _ActionSettingsCard(
iconFileName,
lambda _checked=False, target=action: target.trigger(),
)
self._pluginActionCards.append((card, action, metadata))
action.changed.connect(
lambda target=card, source=action, owner=metadata: target.setTexts(
source.text(),
owner.description or owner.displayName,
)
)
section.addCard(card)
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:
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:
logger.error(
f'failed to create management settings from plugin '
f'{metadata.id!r}: {ex}'
)
def _setTUNMode(self, enabled: bool):
"""Trigger the compatibility action used by connection state."""
if self.tunModeAction.isChecked() != enabled:
@@ -565,13 +810,11 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
@QtCore.Slot()
def _syncTUNModeAction(self, *_args):
"""Mirror compatibility-action state into the settings control."""
blocker = QtCore.QSignalBlocker(self.tunModeCard.checkBox)
if self.tunModeCard.checkBox.isChecked() != self.tunModeAction.isChecked():
self.tunModeCard.checkBox.syncChecked(self.tunModeAction.isChecked())
self.tunModeCard.checkBox.setChecked(self.tunModeAction.isChecked())
self.tunModeCard.checkBox.setEnabled(self.tunModeAction.isEnabled())
del blocker
def _openTUNSettings(self):
"""Create and retain the existing Tun2socks settings dialog."""
self._tunSettingsDialogFactory(parent=self).open()
@@ -595,6 +838,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.generalSection,
self.connectionSection,
self.applicationSection,
*self.pluginSections,
):
for card in section.cards:
sync = getattr(card, 'sync', None)
@@ -609,7 +853,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.pageTitleLabel.setText(_('Settings'))
self.generalSection.titleLabel.setText(_('General'))
self.connectionSection.titleLabel.setText(_('Connection and Interface'))
self.hysteriaSection.titleLabel.setText(_('Hysteria2 Traffic Statistics'))
self.pluginSettingsTitleLabel.setText(_('Plugin Settings'))
self.applicationSection.titleLabel.setText(_('Application'))
tunTitle = _('TUN Mode')
@@ -677,6 +921,10 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
_('Show Progress Bar When Connecting'),
_('Show connection progress while proxy services are starting.'),
)
self.metricsCollectionCard.setTexts(
_('Enable Metrics Collection'),
_('Collect network speed and traffic history while connected.'),
)
self.clearTrafficUsageCard.setTexts(
_('Clear Traffic Usage Statistics On Reconnect'),
_(
@@ -694,18 +942,44 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
_('Keep supported proxy-core data files up to date.'),
)
self.hysteriaURLCard.setTexts(
_('Traffic Stats API URL'),
_('Hysteria2 server API address; /traffic is appended automatically.'),
)
self.hysteriaClientIdCard.setTexts(
_('Traffic Stats Client ID'),
_('Authentication ID reported by the Hysteria2 server.'),
)
self.hysteriaSecretCard.setTexts(
_('Traffic Stats API Secret'),
_('Authorization value configured by the Hysteria2 server.'),
)
for section in self.pluginSections:
descriptor = getattr(section, '_pluginDescriptor', None)
metadata = getattr(section, '_pluginActionMetadata', None)
if descriptor is not None:
section.titleLabel.setText(
self._translatedPluginText(
descriptor.title,
descriptor.translatable,
)
)
elif metadata is not None:
section.titleLabel.setText(metadata.displayName)
for card, descriptor in self._pluginDescriptorCards:
title = self._translatedPluginText(
descriptor.title,
descriptor.translatable,
)
description = self._translatedPluginText(
descriptor.description,
descriptor.translatable,
)
card.setTexts(title, description)
if isinstance(card, _ActionSettingsCard):
card.button.setText(
self._translatedPluginText(
descriptor.buttonText,
descriptor.translatable,
)
)
for card, action, metadata in self._pluginActionCards:
card.setTexts(action.text(), metadata.description or metadata.displayName)
if isinstance(card, _ActionSettingsCard):
card.button.setText(_('Open'))
self._setActionCardText(
self.updateCard,