mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-05 21:37:56 +03:00
Add plugin-contributed settings and navigation
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']};
|
||||
}}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user