diff --git a/Furious/Backends/Hysteria2/Plugin.py b/Furious/Backends/Hysteria2/Plugin.py index a04af0c..e851801 100644 --- a/Furious/Backends/Hysteria2/Plugin.py +++ b/Furious/Backends/Hysteria2/Plugin.py @@ -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(), ) diff --git a/Furious/Plugins/API.py b/Furious/Plugins/API.py index a1f08a1..c3f4492 100644 --- a/Furious/Plugins/API.py +++ b/Furious/Plugins/API.py @@ -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.""" diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index cd18861..d06f2c5 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -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)) diff --git a/Furious/Plugins/__init__.py b/Furious/Plugins/__init__.py index 6936cce..e089898 100644 --- a/Furious/Plugins/__init__.py +++ b/Furious/Plugins/__init__.py @@ -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', diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index 0f54490..59577d6 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -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']}; }} diff --git a/Furious/Service/PluginUIManager.py b/Furious/Service/PluginUIManager.py new file mode 100644 index 0000000..b0a10b0 --- /dev/null +++ b/Furious/Service/PluginUIManager.py @@ -0,0 +1,121 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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) diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py index 620cee9..4cea47d 100644 --- a/Furious/Service/__init__.py +++ b/Furious/Service/__init__.py @@ -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', ] diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py index e6f98c6..852955d 100644 --- a/Furious/Window/SettingsPage.py +++ b/Furious/Window/SettingsPage.py @@ -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,