From 3d67bfdcaf440629c1e60187fc0bd9450d84ea09 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 9 Aug 2026 10:42:44 +0800 Subject: [PATCH] Refactor plugin capability architecture Signed-off-by: Loren Eteval --- Furious/Actions/Import.py | 9 +- Furious/Application/DesktopApplication.py | 8 +- Furious/Backends/Configuration.py | 8 +- Furious/Backends/Hysteria1/Plugin.py | 80 +-- Furious/Backends/Hysteria1/Protocols.py | 96 +++ Furious/Backends/Hysteria2/Plugin.py | 86 +-- Furious/Backends/Hysteria2/Protocols.py | 101 +++ Furious/Backends/Xray/Plugin.py | 133 +--- Furious/Backends/Xray/Protocols.py | 161 +++++ Furious/Domain/Configuration.py | 121 +--- Furious/Domain/__init__.py | 18 +- Furious/Extensions/StandardSubscriptions.py | 89 +++ Furious/Extensions/__init__.py | 26 + Furious/Plugins/API.py | 160 +++-- Furious/Plugins/Profile.py | 95 +++ Furious/Plugins/README.md | 151 ++--- Furious/Plugins/Registry.py | 677 ++++++++++++++------ Furious/Plugins/__init__.py | 33 +- Furious/Repository/Servers.py | 5 +- Furious/Service/ConnectionManager.py | 8 +- Furious/Widget/ServerTableView.py | 86 +-- Furious/Window/MainWindow.py | 2 +- Furious/Window/QRCodeWindow.py | 3 +- Furious/Window/TextEditorWindow.py | 3 +- 24 files changed, 1410 insertions(+), 749 deletions(-) create mode 100644 Furious/Backends/Hysteria1/Protocols.py create mode 100644 Furious/Backends/Hysteria2/Protocols.py create mode 100644 Furious/Backends/Xray/Protocols.py create mode 100644 Furious/Extensions/StandardSubscriptions.py create mode 100644 Furious/Extensions/__init__.py create mode 100644 Furious/Plugins/Profile.py diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index bbb4405..59c0965 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Domain import * +from Furious.Plugins import configurationFromAny from Furious.Repository import * from Furious.Qt import * from Furious.Qt import gettext as _ @@ -69,7 +70,7 @@ def showMBoxImportError(clipboard: str): def importURIFromClipboard(clipboard: str): """Import URI from clipboard.""" - factory = configFactoryFromAny(clipboard) + factory = configurationFromAny(clipboard) if not factory.isValid(): showMBoxImportError(clipboard) @@ -100,7 +101,7 @@ def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None): rowIndex = len(Storage.UserServers()) for uri in uris: - factory = configFactoryFromAny(uri.strip()) + factory = configurationFromAny(uri.strip()) if factory.isValid(): APP().mainWindow.appendNewItemByFactory(factory) @@ -244,7 +245,7 @@ class ImportURIsProgressDialog(AppQDialog): uri = self.uris[self.currentIndex] self.currentIndex += 1 - factory = configFactoryFromAny(uri.strip()) + factory = configurationFromAny(uri.strip()) if factory.isValid(): remark = factory.getExtras('remark') @@ -428,7 +429,7 @@ class ImportFromFileAction(AppQAction): # Show the MessageBox asynchronously mbox.open() else: - factory = configFactoryFromAny( + factory = configurationFromAny( plainText, remark=os.path.basename(filename) ) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 6c3bad0..88908eb 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -23,7 +23,8 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Core import Tun2socks from Furious.Backends import OFFICIAL_PLUGIN_TYPES -from Furious.Plugins import initializePluginRegistry +from Furious.Extensions import BUNDLED_EXTENSION_TYPES +from Furious.Plugins import getPluginRegistry, initializePluginRegistry from Furious.Qt import AppStyleSheet from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ @@ -299,7 +300,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): @staticmethod def addEnviron(): """Add environ.""" - pluginRegistry = initializePluginRegistry(OFFICIAL_PLUGIN_TYPES) + pluginRegistry = initializePluginRegistry( + (*OFFICIAL_PLUGIN_TYPES, *BUNDLED_EXTENSION_TYPES) + ) configureEditorLogMetadata( lambda: (*pluginRegistry.coreVersions(), Tun2socks.version()), pluginRegistry.logTimestampPatterns, @@ -400,6 +403,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): @QtCore.Slot() def cleanup(): """Release resources owned by the application.""" + getPluginRegistry().shutdown() Mixins.CleanupOnExit.cleanupAll() if AppSettings.get('SystemProxyMode') == AppBuiltinProxyMode.Auto.value: diff --git a/Furious/Backends/Configuration.py b/Furious/Backends/Configuration.py index d86e2ce..6a2c8fe 100644 --- a/Furious/Backends/Configuration.py +++ b/Furious/Backends/Configuration.py @@ -1366,7 +1366,9 @@ class ConfigXray(ConfigFactory): @property def itemProtocol(self) -> str: """Return the item protocol value.""" - return Protocol.toEnum(self.proxyProtocol).value + protocol = Protocol.toEnum(self.proxyProtocol) + + return self.proxyProtocol if protocol == Protocol.Unknown else protocol.value @property def itemAddress(self) -> str: @@ -2310,9 +2312,9 @@ class ConfigHysteria2(ConfigFactory): return False -def configXrayEmptyProxyOutboundObject(protocol: Protocol) -> dict: +def configXrayEmptyProxyOutboundObject(protocol) -> dict: """Return the config Xray empty proxy outbound object value used by the application.""" - value = protocol.value.casefold() + value = str(getattr(protocol, 'value', protocol)).casefold() if value == 'vless' or value == 'vmess': return { diff --git a/Furious/Backends/Hysteria1/Plugin.py b/Furious/Backends/Hysteria1/Plugin.py index b9903b4..91d3a0c 100644 --- a/Furious/Backends/Hysteria1/Plugin.py +++ b/Furious/Backends/Hysteria1/Plugin.py @@ -20,88 +20,30 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Plugins.API import * from Furious.Backends.Configuration import * from .Process import * +from .Protocols import HYSTERIA1_PROTOCOL_HANDLERS -import copy import logging __all__ = ['Hysteria1Plugin'] logger = logging.getLogger(__name__) -_TRANSLATABLE_ACTION_TEXT = [ - _('Add Hysteria1 Server...'), -] +class Hysteria1Backend(CoreBackend): + """Run Hysteria 1 independently of profile parsing and editor creation.""" -class Hysteria1Plugin(FuriousPlugin): - """Provide official Hysteria 1 support.""" - - pluginId = 'official.hysteria1' - displayName = 'Hysteria1' - protocols = ( - PluginProtocol( - 'hysteria1', - 'hysteria1', - 'Add Hysteria1 Server...', - 50, - True, - ), - ) + backendId = 'official.hysteria1' configurationTypes = (ConfigHysteria1,) coreTypes = (Hysteria1,) - def configFromString(self, config: str, **kwargs): - """Parse a Hysteria 1 share URI.""" - if config.startswith('hysteria://'): - return ConfigHysteria1(config, **kwargs) - - return None - - def configFromDict(self, config: dict, **kwargs): - """Recognize Hysteria 1 configuration mappings.""" - if config.get('server') is None: - return None - - fields = ( - 'protocol', - 'up_mbps', - 'down_mbps', - 'auth_str', - 'alpn', - 'server_name', - 'insecure', - 'recv_window_conn', - 'recv_window', - 'fast_open', - 'lazy_start', - ) - if any(config.get(field) is not None for field in fields) or isinstance( - config.get('obfs'), str - ): - return ConfigHysteria1(config, **kwargs) - - return None - - def blankConfig(self, protocol, **kwargs): - """Construct a blank Hysteria 1 configuration.""" - return ConfigHysteria1(copy.deepcopy(BLANK_CONFIG_HYSTERIA1), **kwargs) - - def createEditorForProtocol(self, protocol, parent=None, **kwargs): - """Create the Hysteria 1 editor.""" - # Plugin discovery can occur while the Furious.Qt package is initializing. - from .Editor import Hysteria1Editor - - return Hysteria1Editor(parent=parent, **kwargs) - def routingOptions(self, config=None): """Return the routing modes supported by Hysteria 1.""" return tuple( - PluginRouting(routing.value, routing.value, translatable=True) + RoutingOption(routing.value, routing.value, translatable=True) for routing in AppBuiltinRouting ) @@ -174,3 +116,15 @@ class Hysteria1Plugin(FuriousPlugin): return 'Connection to server has been lost' return None + + +class Hysteria1Plugin(FuriousPlugin): + """Bundle official Hysteria 1 protocol and runtime capabilities.""" + + pluginId = 'official.hysteria1' + displayName = 'Hysteria1' + protocolHandlers = HYSTERIA1_PROTOCOL_HANDLERS + + def __init__(self): + """Create an isolated Hysteria 1 backend instance for this plugin.""" + self.coreBackends = (Hysteria1Backend(),) diff --git a/Furious/Backends/Hysteria1/Protocols.py b/Furious/Backends/Hysteria1/Protocols.py new file mode 100644 index 0000000..f703647 --- /dev/null +++ b/Furious/Backends/Hysteria1/Protocols.py @@ -0,0 +1,96 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Contribute the Hysteria 1 profile and editor capability.""" + +from __future__ import annotations + +from Furious.Backends.Configuration import ( + BLANK_CONFIG_HYSTERIA1, + ConfigHysteria1, +) +from Furious.Plugins.API import ProtocolDescriptor, ProtocolHandler + +import copy + +__all__ = ['HYSTERIA1_PROTOCOL_HANDLERS'] + + +class Hysteria1ProtocolHandler(ProtocolHandler): + """Own Hysteria 1 URI, mapping, blank-profile, and editor behavior.""" + + descriptor = ProtocolDescriptor( + 'hysteria1', + 'Hysteria1', + 'Add Hysteria1 Server...', + 50, + True, + ) + schemes = ('hysteria',) + + def supports(self, configuration) -> bool: + """Return whether *configuration* is a Hysteria 1 profile.""" + return isinstance(configuration, ConfigHysteria1) + + def parse(self, uri: str, **kwargs): + """Parse a Hysteria 1 share URI.""" + factory = ConfigHysteria1(uri, **kwargs) + + return factory if factory.isValid() else None + + def fromMapping(self, configuration, **kwargs): + """Recognize a Hysteria 1 client configuration mapping.""" + if configuration.get('server') is None: + return None + + fields = ( + 'protocol', + 'up_mbps', + 'down_mbps', + 'auth_str', + 'alpn', + 'server_name', + 'insecure', + 'recv_window_conn', + 'recv_window', + 'fast_open', + 'lazy_start', + ) + + if any(configuration.get(field) is not None for field in fields) or isinstance( + configuration.get('obfs'), str + ): + return ConfigHysteria1(configuration, **kwargs) + + return None + + def blank(self, **kwargs): + """Create a blank Hysteria 1 client profile.""" + return ConfigHysteria1(copy.deepcopy(BLANK_CONFIG_HYSTERIA1), **kwargs) + + def export(self, configuration, remark: str = '') -> str: + """Export a Hysteria 1 profile.""" + return configuration.toURI(remark) if self.supports(configuration) else '' + + def createEditor(self, parent=None, **kwargs): + """Create the Hysteria 1 editor on demand.""" + from .Editor import Hysteria1Editor + + return Hysteria1Editor(parent=parent, **kwargs) + + +HYSTERIA1_PROTOCOL_HANDLERS = (Hysteria1ProtocolHandler(),) diff --git a/Furious/Backends/Hysteria2/Plugin.py b/Furious/Backends/Hysteria2/Plugin.py index 4282bfb..ef0c84e 100644 --- a/Furious/Backends/Hysteria2/Plugin.py +++ b/Furious/Backends/Hysteria2/Plugin.py @@ -20,14 +20,13 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Plugins.API import * from Furious.Backends.Configuration import * from .Process import Hysteria2 +from .Protocols import HYSTERIA2_PROTOCOL_HANDLERS from .TUN import * -import copy import logging __all__ = ['Hysteria2Plugin'] @@ -35,78 +34,13 @@ __all__ = ['Hysteria2Plugin'] logger = logging.getLogger(__name__) -_TRANSLATABLE_ACTION_TEXT = [ - _('Add Hysteria2 Server...'), -] +class Hysteria2Backend(CoreBackend): + """Run Hysteria 2 independently of profile parsing and editor creation.""" - -class Hysteria2Plugin(FuriousPlugin): - """Provide official Hysteria 2 support.""" - - pluginId = 'official.hysteria2' - displayName = 'Hysteria2' - protocols = ( - PluginProtocol( - 'hysteria2', - 'hysteria2', - 'Add Hysteria2 Server...', - 60, - ), - ) + backendId = 'official.hysteria2' configurationTypes = (ConfigHysteria2,) coreTypes = (Hysteria2,) - def configFromString(self, config: str, **kwargs): - """Parse a Hysteria 2 share URI.""" - if config.startswith( - ( - 'hy2://', - 'hysteria2://', - 'hysteria2+realm://', - 'hysteria2+realm+http://', - ) - ): - return ConfigHysteria2(config, **kwargs) - - return None - - def configFromDict(self, config: dict, **kwargs): - """Recognize Hysteria 2 configuration mappings.""" - if config.get('server') is None: - return None - - fields = ( - 'auth', - 'tls', - 'transport', - 'quic', - 'bandwidth', - 'tcpForwarding', - 'udpForwarding', - 'tcpTProxy', - 'udpTProxy', - 'tun', - 'fastOpen', - 'lazy', - ) - if any(config.get(field) is not None for field in fields) or isinstance( - config.get('obfs'), dict - ): - return ConfigHysteria2(config, **kwargs) - - return None - - def blankConfig(self, protocol, **kwargs): - """Construct a blank Hysteria 2 configuration.""" - return ConfigHysteria2(copy.deepcopy(BLANK_CONFIG_HYSTERIA2), **kwargs) - - def createEditorForProtocol(self, protocol, parent=None, **kwargs): - """Create the Hysteria 2 editor.""" - # Plugin discovery can occur while the Furious.Qt package is initializing. - from .Editor import Hysteria2Editor - - return Hysteria2Editor(parent=parent, **kwargs) - def createManagementActions(self, parent=None, **kwargs): """Create Hysteria 2 native TUN management actions.""" isCoreActive = kwargs.pop('isCoreActive', lambda coreType: False) @@ -223,3 +157,15 @@ class Hysteria2Plugin(FuriousPlugin): def logTimestampPatterns(self): """Return the timestamp format emitted by Hysteria 2.""" return (r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})',) + + +class Hysteria2Plugin(FuriousPlugin): + """Bundle official Hysteria 2 protocol and runtime capabilities.""" + + pluginId = 'official.hysteria2' + displayName = 'Hysteria2' + protocolHandlers = HYSTERIA2_PROTOCOL_HANDLERS + + def __init__(self): + """Create an isolated Hysteria 2 backend instance for this plugin.""" + self.coreBackends = (Hysteria2Backend(),) diff --git a/Furious/Backends/Hysteria2/Protocols.py b/Furious/Backends/Hysteria2/Protocols.py new file mode 100644 index 0000000..5971ece --- /dev/null +++ b/Furious/Backends/Hysteria2/Protocols.py @@ -0,0 +1,101 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Contribute the Hysteria 2 profile and editor capability.""" + +from __future__ import annotations + +from Furious.Backends.Configuration import ( + BLANK_CONFIG_HYSTERIA2, + ConfigHysteria2, +) +from Furious.Plugins.API import ProtocolDescriptor, ProtocolHandler + +import copy + +__all__ = ['HYSTERIA2_PROTOCOL_HANDLERS'] + + +class Hysteria2ProtocolHandler(ProtocolHandler): + """Own Hysteria 2 URI, mapping, blank-profile, and editor behavior.""" + + descriptor = ProtocolDescriptor( + 'hysteria2', + 'Hysteria2', + 'Add Hysteria2 Server...', + 60, + ) + schemes = ( + 'hy2', + 'hysteria2', + 'hysteria2+realm', + 'hysteria2+realm+http', + ) + + def supports(self, configuration) -> bool: + """Return whether *configuration* is a Hysteria 2 profile.""" + return isinstance(configuration, ConfigHysteria2) + + def parse(self, uri: str, **kwargs): + """Parse a Hysteria 2 share URI, including Realm mode.""" + factory = ConfigHysteria2(uri, **kwargs) + + return factory if factory.isValid() else None + + def fromMapping(self, configuration, **kwargs): + """Recognize a Hysteria 2 client configuration mapping.""" + if configuration.get('server') is None: + return None + + fields = ( + 'auth', + 'tls', + 'transport', + 'quic', + 'bandwidth', + 'tcpForwarding', + 'udpForwarding', + 'tcpTProxy', + 'udpTProxy', + 'tun', + 'fastOpen', + 'lazy', + ) + + if any(configuration.get(field) is not None for field in fields) or isinstance( + configuration.get('obfs'), dict + ): + return ConfigHysteria2(configuration, **kwargs) + + return None + + def blank(self, **kwargs): + """Create a blank Hysteria 2 client profile.""" + return ConfigHysteria2(copy.deepcopy(BLANK_CONFIG_HYSTERIA2), **kwargs) + + def export(self, configuration, remark: str = '') -> str: + """Export a Hysteria 2 profile.""" + return configuration.toURI(remark) if self.supports(configuration) else '' + + def createEditor(self, parent=None, **kwargs): + """Create the Hysteria 2 editor on demand.""" + from .Editor import Hysteria2Editor + + return Hysteria2Editor(parent=parent, **kwargs) + + +HYSTERIA2_PROTOCOL_HANDLERS = (Hysteria2ProtocolHandler(),) diff --git a/Furious/Backends/Xray/Plugin.py b/Furious/Backends/Xray/Plugin.py index ebe99fb..e73a442 100644 --- a/Furious/Backends/Xray/Plugin.py +++ b/Furious/Backends/Xray/Plugin.py @@ -20,19 +20,18 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Core import * from Furious.Repository import * from Furious.Plugins.API import * from Furious.Backends.Configuration import * from .Process import * +from .Protocols import XRAY_PROTOCOL_HANDLERS from .Routing import * from .TUN import * import os import uuid -import copy import logging __all__ = ['XrayPlugin'] @@ -40,11 +39,6 @@ __all__ = ['XrayPlugin'] logger = logging.getLogger(__name__) -def _protocolId(protocol) -> str: - """Return a normalized protocol identifier.""" - return str(getattr(protocol, 'value', protocol)).strip().casefold() - - def fixLogObjectPath(config, attr: str, value: str, log=True): """Resolve and normalize one log-file path in an Xray configuration.""" try: @@ -78,110 +72,23 @@ def fixLogObjectPath(config, attr: str, value: str, log=True): ) -_TRANSLATABLE_ACTION_TEXT = [ - _('Add VMess Server...'), - _('Add VLESS Server...'), - _('Add Shadowsocks Server...'), - _('Add Trojan Server...'), - _('Add SOCKS Server...'), -] +class XrayBackend(CoreBackend): + """Run Xray configurations independently of protocol codecs and editors.""" - -class XrayPlugin(FuriousPlugin): - """Provide official Xray-core support.""" - - pluginId = 'official.xray' - displayName = 'Xray-core' - protocols = ( - PluginProtocol('VMess', 'VMess', 'Add VMess Server...', 10), - PluginProtocol('VLESS', 'VLESS', 'Add VLESS Server...', 20), - PluginProtocol('Shadowsocks', 'Shadowsocks', 'Add Shadowsocks Server...', 30), - PluginProtocol('Trojan', 'Trojan', 'Add Trojan Server...', 40), - PluginProtocol('SOCKS', 'SOCKS', 'Add SOCKS Server...', 70, True), - ) + backendId = 'official.xray' configurationTypes = (ConfigXray,) coreTypes = (XrayCore,) - def configFromString(self, config: str, **kwargs): - """Parse an Xray share URI when its scheme is supported.""" - if config.startswith( - ( - 'vmess://', - 'vless://', - 'ss://', - 'trojan://', - 'socks://', - 'socks5://', - 'socks5h://', - ) + def fromMapping(self, configuration, **kwargs): + """Recognize a complete Xray configuration without a proxy profile.""" + if ( + configuration.get('inbounds') is not None + or configuration.get('outbounds') is not None ): - return ConfigXray(config, **kwargs) + return ConfigXray(configuration, **kwargs) return None - def configFromDict(self, config: dict, **kwargs): - """Recognize Xray configuration mappings by their inbound/outbound fields.""" - if config.get('inbounds') is not None or config.get('outbounds') is not None: - return ConfigXray(config, **kwargs) - - return None - - def blankConfig(self, protocol, **kwargs): - """Construct a blank Xray configuration for one supported protocol.""" - protocolId = _protocolId(protocol) - factory = ConfigXray(copy.deepcopy(BLANK_CONFIG_XRAY), **kwargs) - outbound = factory['outbounds'][0] - - if protocolId in ('vmess', 'vless'): - outbound['protocol'] = protocolId - outbound['settings']['vnext'] = [ - { - 'address': '', - 'port': 0, - 'users': [{'email': PROXY_OUTBOUND_USER_EMAIL}], - }, - ] - elif protocolId == 'socks': - outbound['protocol'] = protocolId - outbound['settings'] = {'address': '', 'port': 0} - elif protocolId in ('shadowsocks', 'trojan'): - outbound['protocol'] = protocolId - outbound['settings']['servers'] = [ - { - 'address': '', - 'port': 0, - 'email': PROXY_OUTBOUND_USER_EMAIL, - }, - ] - else: - return None - - return factory - - def createEditorForProtocol(self, protocol, parent=None, **kwargs): - """Create the Xray editor matching a protocol identifier.""" - # Plugin discovery can occur while the Furious.Qt package is initializing. - from .ShadowsocksEditor import ShadowsocksEditor - from .SocksEditor import SocksEditor - from .TrojanEditor import TrojanEditor - from .VlessEditor import VlessEditor - from .VmessEditor import VmessEditor - - editors = { - 'vmess': VmessEditor, - 'vless': VlessEditor, - 'shadowsocks': ShadowsocksEditor, - 'socks': SocksEditor, - 'trojan': TrojanEditor, - } - editorType = editors.get(_protocolId(protocol)) - - return editorType(parent=parent, **kwargs) if editorType is not None else None - - def createEditorForConfig(self, config, parent=None, **kwargs): - """Create the editor matching an Xray outbound protocol.""" - return self.createEditorForProtocol(config.proxyProtocol, parent, **kwargs) - def createManagementActions(self, parent=None, **kwargs): """Create Xray routing, TUN, and asset-management actions.""" isCoreActive = kwargs.pop('isCoreActive', lambda coreType: False) @@ -274,17 +181,17 @@ class XrayPlugin(FuriousPlugin): def routingOptions(self, config=None): """Return built-in and named routing modes supported by Xray.""" options = [ - PluginRouting( + RoutingOption( AppBuiltinRouting.BypassMainlandChina.value, AppBuiltinRouting.BypassMainlandChina.value, translatable=True, ), - PluginRouting( + RoutingOption( AppBuiltinRouting.Global.value, AppBuiltinRouting.Global.value, translatable=True, ), - PluginRouting( + RoutingOption( AppBuiltinRouting.Custom.value, AppBuiltinRouting.Custom.value, translatable=True, @@ -296,7 +203,7 @@ class XrayPlugin(FuriousPlugin): if routing.get('enabled', True) ) options.extend( - PluginRouting( + RoutingOption( f'Custom:{unique}', routing.get('remark', ''), separatorBefore=index == 0, @@ -454,3 +361,15 @@ class XrayPlugin(FuriousPlugin): assetDownloadManager.configureHttpProxy(httpProxy) assetDownloadManager.download() + + +class XrayPlugin(FuriousPlugin): + """Bundle official Xray protocol handlers and its runtime backend.""" + + pluginId = 'official.xray' + displayName = 'Xray-core' + protocolHandlers = XRAY_PROTOCOL_HANDLERS + + def __init__(self): + """Create an isolated Xray backend instance for this plugin.""" + self.coreBackends = (XrayBackend(),) diff --git a/Furious/Backends/Xray/Protocols.py b/Furious/Backends/Xray/Protocols.py new file mode 100644 index 0000000..1bbea8a --- /dev/null +++ b/Furious/Backends/Xray/Protocols.py @@ -0,0 +1,161 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Contribute isolated Xray protocol profile and editor capabilities.""" + +from __future__ import annotations + +from Furious.Backends.Configuration import ( + BLANK_CONFIG_XRAY, + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Plugins.API import ProtocolDescriptor, ProtocolHandler + +from importlib import import_module + +import copy + +__all__ = ['XRAY_PROTOCOL_HANDLERS'] + + +class XrayProtocolHandler(ProtocolHandler): + """Adapt one Xray outbound protocol to the host protocol contract.""" + + def __init__( + self, + descriptor, + schemes, + parserName, + editorModule, + editorType, + ): + """Store immutable dispatch metadata for one Xray protocol.""" + self.descriptor = descriptor + self.schemes = tuple(schemes) + self._parserName = parserName + self._editorModule = editorModule + self._editorType = editorType + + @property + def protocolId(self) -> str: + """Return the normalized Xray outbound protocol identifier.""" + return self.descriptor.id.casefold() + + def supports(self, configuration) -> bool: + """Return whether *configuration* uses this Xray outbound protocol.""" + return ( + isinstance(configuration, ConfigXray) + and configuration.proxyProtocol.casefold() == self.protocolId + ) + + def parse(self, uri: str, **kwargs): + """Parse this handler's URI directly into an Xray configuration.""" + parser = getattr(ConfigXray, self._parserName) + remark, proxyOutbound = parser(uri) + + if ( + not proxyOutbound + or proxyOutbound.get('protocol', '').casefold() != self.protocolId + ): + return None + + config = copy.deepcopy(BLANK_CONFIG_XRAY) + config['outbounds'][0] = proxyOutbound + factory = ConfigXray(config, **kwargs) + factory.setExtras('remark', remark) + + return factory + + def fromMapping(self, configuration, **kwargs): + """Recognize a full Xray mapping by its tagged proxy outbound.""" + outbounds = configuration.get('outbounds') + + if not isinstance(outbounds, list): + return None + + for outbound in outbounds: + if ( + isinstance(outbound, dict) + and outbound.get('tag') == 'proxy' + and str(outbound.get('protocol', '')).casefold() == self.protocolId + ): + return ConfigXray(configuration, **kwargs) + + return None + + def blank(self, **kwargs): + """Create a blank full Xray configuration for this protocol.""" + config = copy.deepcopy(BLANK_CONFIG_XRAY) + config['outbounds'][0] = configXrayEmptyProxyOutboundObject(self.descriptor.id) + + return ConfigXray(config, **kwargs) + + def export(self, configuration, remark: str = '') -> str: + """Export an owned Xray configuration to its share-link format.""" + return configuration.toURI(remark) if self.supports(configuration) else '' + + def createEditor(self, parent=None, **kwargs): + """Load and create the protocol editor only when the GUI asks for it.""" + module = import_module(self._editorModule) + editorType = getattr(module, self._editorType) + + return editorType(parent=parent, **kwargs) + + +XRAY_PROTOCOL_HANDLERS = ( + XrayProtocolHandler( + ProtocolDescriptor('VMess', 'VMess', 'Add VMess Server...', 10), + ('vmess',), + 'URI2ProxyOutboundObjectVMess', + 'Furious.Backends.Xray.VmessEditor', + 'VmessEditor', + ), + XrayProtocolHandler( + ProtocolDescriptor('VLESS', 'VLESS', 'Add VLESS Server...', 20), + ('vless',), + 'URI2ProxyOutboundObjectVLESS', + 'Furious.Backends.Xray.VlessEditor', + 'VlessEditor', + ), + XrayProtocolHandler( + ProtocolDescriptor( + 'Shadowsocks', + 'Shadowsocks', + 'Add Shadowsocks Server...', + 30, + ), + ('ss',), + 'URI2ProxyOutboundObjectSS', + 'Furious.Backends.Xray.ShadowsocksEditor', + 'ShadowsocksEditor', + ), + XrayProtocolHandler( + ProtocolDescriptor('Trojan', 'Trojan', 'Add Trojan Server...', 40), + ('trojan',), + 'URI2ProxyOutboundObjectTrojan', + 'Furious.Backends.Xray.TrojanEditor', + 'TrojanEditor', + ), + XrayProtocolHandler( + ProtocolDescriptor('SOCKS', 'SOCKS', 'Add SOCKS Server...', 70, True), + ('socks', 'socks5', 'socks5h'), + 'URI2ProxyOutboundObjectSocks', + 'Furious.Backends.Xray.SocksEditor', + 'SocksEditor', + ), +) diff --git a/Furious/Domain/Configuration.py b/Furious/Domain/Configuration.py index 08c1313..d13dbcd 100644 --- a/Furious/Domain/Configuration.py +++ b/Furious/Domain/Configuration.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define configuration data and the registry used to construct it.""" +"""Define the core-neutral configuration profile model.""" from __future__ import annotations @@ -25,17 +25,10 @@ from typing import Union import copy import functools -import threading import ujson __all__ = [ 'ConfigFactory', - 'ConfigurationRegistry', - 'configurationRegistry', - 'registerConfigurationProvider', - 'configFactoryFromDict', - 'configFactoryFromAny', - 'configFactoryBlank', ] @@ -259,115 +252,3 @@ class ConfigFactory(ServerTableItem, dict): """ return False - - -class ConfigurationRegistry: - """Construct configuration objects through registered providers. - - Providers are deliberately defined by behavior instead of by a plugin base - class. This keeps configuration and persistence independent from the - optional plugin system while allowing plugins to contribute parsers. - """ - - def __init__(self): - """Initialize an empty provider registry.""" - self._providers = [] - self._lock = threading.RLock() - - def register(self, provider): - """Register *provider* once and return it.""" - requiredMethods = ('configFromString', 'configFromDict', 'blankConfig') - - if not all(callable(getattr(provider, name, None)) for name in requiredMethods): - raise TypeError( - 'configuration providers must implement configFromString, ' - 'configFromDict, and blankConfig' - ) - - with self._lock: - if any(item is provider for item in self._providers): - raise ValueError('configuration provider is already registered') - - self._providers.append(provider) - - return provider - - def providers(self): - """Return registered providers in deterministic registration order.""" - with self._lock: - return tuple(self._providers) - - def fromString(self, config: str, **kwargs): - """Return the first provider result for textual *config*.""" - for provider in self.providers(): - factory = provider.configFromString(config, **kwargs) - - if factory is not None: - return factory - - return None - - def fromDict(self, config: dict, **kwargs): - """Return the first provider result for mapping *config*.""" - for provider in self.providers(): - factory = provider.configFromDict(config, **kwargs) - - if factory is not None: - return factory - - return None - - def blank(self, protocol, **kwargs): - """Return a blank configuration from the first matching provider.""" - for provider in self.providers(): - factory = provider.blankConfig(protocol, **kwargs) - - if factory is not None: - return factory - - return None - - -configurationRegistry = ConfigurationRegistry() - - -def registerConfigurationProvider(provider): - """Register a configuration provider with the process-wide registry.""" - return configurationRegistry.register(provider) - - -def configFactoryFromDict(config: dict, **kwargs) -> ConfigFactory: - """Construct a configuration mapping through registered providers.""" - if not isinstance(config, dict): - return ConfigFactory(**kwargs) - - factory = configurationRegistry.fromDict(config, **kwargs) - - return factory if factory is not None else ConfigFactory(config, **kwargs) - - -def configFactoryFromAny(config: Union[str, dict], **kwargs) -> ConfigFactory: - """Construct configuration data from text or a mapping.""" - if isinstance(config, str): - factory = configurationRegistry.fromString(config, **kwargs) - - if factory is not None: - return factory - - try: - return configFactoryFromDict(ujson.loads(config), **kwargs) - except Exception: - # Any non-exit exceptions - return ConfigFactory(**kwargs) - - if isinstance(config, dict): - return configFactoryFromDict(config, **kwargs) - - return ConfigFactory(**kwargs) - - -def configFactoryBlank(protocol, **kwargs) -> ConfigFactory: - """Construct a blank configuration through a registered provider.""" - factory = configurationRegistry.blank(protocol, **kwargs) - - return factory if factory is not None else ConfigFactory(**kwargs) diff --git a/Furious/Domain/__init__.py b/Furious/Domain/__init__.py index b5f66d3..d3c587c 100644 --- a/Furious/Domain/__init__.py +++ b/Furious/Domain/__init__.py @@ -15,31 +15,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose configuration models, provider registration, and data encoding.""" +"""Expose core-neutral configuration models and data encoding.""" from __future__ import annotations -from .Configuration import ( - ConfigFactory, - ConfigurationRegistry, - configFactoryBlank, - configFactoryFromAny, - configFactoryFromDict, - configurationRegistry, - registerConfigurationProvider, -) +from .Configuration import ConfigFactory from .Encoding import Base64Encoder, JSONEncoder, PyBase64Encoder, UJSONEncoder __all__ = [ 'Base64Encoder', 'ConfigFactory', - 'ConfigurationRegistry', 'JSONEncoder', 'PyBase64Encoder', 'UJSONEncoder', - 'configFactoryBlank', - 'configFactoryFromAny', - 'configFactoryFromDict', - 'configurationRegistry', - 'registerConfigurationProvider', ] diff --git a/Furious/Extensions/StandardSubscriptions.py b/Furious/Extensions/StandardSubscriptions.py new file mode 100644 index 0000000..e10902c --- /dev/null +++ b/Furious/Extensions/StandardSubscriptions.py @@ -0,0 +1,89 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Decode the plain-text and base64 share-link subscription formats.""" + +from __future__ import annotations + +from Furious.Plugins.API import ( + FuriousPlugin, + SubscriptionDecoder, + SubscriptionItem, + SubscriptionResult, +) + +import base64 +import binascii + +__all__ = ['StandardSubscriptionPlugin'] + + +def _shareLinks(text: str): + """Return validated non-comment share links from subscription text.""" + lines = tuple( + line.strip() + for line in text.splitlines() + if line.strip() and not line.lstrip().startswith(('#', '//')) + ) + + if not lines or any('://' not in line for line in lines): + return None + + return lines + + +class ShareLinkSubscriptionDecoder(SubscriptionDecoder): + """Decode standard newline-delimited plain or base64 share links.""" + + decoderId = 'share-links' + displayName = 'Share Links (plain text or base64)' + priority = 100 + + def decode(self, data: bytes): + """Decode a validated plain-text or base64 share-link payload.""" + try: + text = data.decode('utf-8-sig') + except UnicodeDecodeError: + text = '' + + links = _shareLinks(text) + + if links is None: + compact = b''.join(data.split()) + compact += b'=' * (-len(compact) % 4) + + try: + decoded = base64.b64decode(compact, altchars=b'-_', validate=True) + links = _shareLinks(decoded.decode('utf-8-sig')) + except (binascii.Error, UnicodeDecodeError, ValueError): + return None + + if links is None: + return None + + return SubscriptionResult( + self.decoderId, + tuple(SubscriptionItem(uri=link) for link in links), + ) + + +class StandardSubscriptionPlugin(FuriousPlugin): + """Contribute Furious's built-in share-link subscription decoder.""" + + pluginId = 'official.standard-subscriptions' + displayName = 'Standard Subscriptions' + subscriptionDecoders = (ShareLinkSubscriptionDecoder(),) diff --git a/Furious/Extensions/__init__.py b/Furious/Extensions/__init__.py new file mode 100644 index 0000000..942f983 --- /dev/null +++ b/Furious/Extensions/__init__.py @@ -0,0 +1,26 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Expose bundled non-core plugins shipped with Furious.""" + +from __future__ import annotations + +from .StandardSubscriptions import StandardSubscriptionPlugin + +__all__ = ['BUNDLED_EXTENSION_TYPES', 'StandardSubscriptionPlugin'] + +BUNDLED_EXTENSION_TYPES = (StandardSubscriptionPlugin,) diff --git a/Furious/Plugins/API.py b/Furious/Plugins/API.py index 9e3170d..3bb2c6a 100644 --- a/Furious/Plugins/API.py +++ b/Furious/Plugins/API.py @@ -15,25 +15,32 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the public contract implemented by Furious core plugins.""" +"""Define the capability contracts implemented by Furious plugins.""" from __future__ import annotations from dataclasses import dataclass +from typing import Any, Mapping, Optional, Tuple __all__ = [ 'PLUGIN_API_VERSION', - 'PluginProtocol', - 'PluginRouting', + 'CoreBackend', 'FuriousPlugin', + 'PluginContext', + 'ProtocolDescriptor', + 'ProtocolHandler', + 'RoutingOption', + 'SubscriptionDecoder', + 'SubscriptionItem', + 'SubscriptionResult', ] -PLUGIN_API_VERSION = 1 +PLUGIN_API_VERSION = 2 @dataclass(frozen=True) -class PluginProtocol: - """Describe one server protocol contributed by a plugin.""" +class ProtocolDescriptor: + """Describe one user-visible proxy protocol.""" id: str displayName: str @@ -43,8 +50,8 @@ class PluginProtocol: @dataclass(frozen=True) -class PluginRouting: - """Describe one routing mode supported by a core plugin.""" +class RoutingOption: + """Describe one routing mode supported by a core backend.""" id: str displayName: str @@ -52,50 +59,106 @@ class PluginRouting: translatable: bool = False -class FuriousPlugin: - """Provide configuration, UI, and process hooks for one proxy core family.""" +@dataclass(frozen=True) +class PluginContext: + """Provide host services to a plugin during initialization.""" - apiVersion = PLUGIN_API_VERSION - pluginId = '' + pluginId: str + registry: Any + + +@dataclass(frozen=True) +class SubscriptionItem: + """Represent one profile emitted by a subscription decoder.""" + + uri: Optional[str] = None + configuration: Optional[Mapping[str, Any]] = None + name: str = '' + + def __post_init__(self): + """Require exactly one serialized or normalized profile value.""" + if (self.uri is None) == (self.configuration is None): + raise ValueError( + 'a subscription item must contain exactly one URI or configuration' + ) + + +@dataclass(frozen=True) +class SubscriptionResult: + """Return normalized entries produced from one subscription payload.""" + + decoderId: str + items: Tuple[SubscriptionItem, ...] + + +class ProtocolHandler: + """Own one protocol's profile conversion and editor capability.""" + + descriptor = ProtocolDescriptor('', '', '') + schemes = tuple() + + def supports(self, configuration) -> bool: + """Return whether this handler owns *configuration*.""" + return False + + def parse(self, uri: str, **kwargs): + """Parse one supported URI or return ``None``.""" + return None + + def fromMapping(self, configuration: Mapping[str, Any], **kwargs): + """Recognize one normalized configuration mapping or return ``None``.""" + return None + + def blank(self, **kwargs): + """Create a blank configuration for this protocol.""" + return None + + def export(self, configuration, remark: str = '') -> str: + """Serialize one owned configuration to a share URI.""" + return '' + + def createEditor(self, parent=None, **kwargs): + """Create this protocol's editor, if it provides one.""" + return None + + +class SubscriptionDecoder: + """Decode one subscription representation without importing profiles.""" + + decoderId = '' displayName = '' - protocols = tuple() + priority = 0 + + def decode(self, data: bytes) -> Optional[SubscriptionResult]: + """Decode *data* or return ``None`` when the format does not match.""" + return None + + +class CoreBackend: + """Run configurations for one proxy core independently of URI protocols.""" + + backendId = '' configurationTypes = tuple() coreTypes = tuple() - def configFromString(self, config: str, **kwargs): - """Construct a supported configuration from text or return ``None``.""" + def fromMapping(self, configuration: Mapping[str, Any], **kwargs): + """Recognize a full backend configuration not owned by one protocol.""" return None - def configFromDict(self, config: dict, **kwargs): - """Construct a supported configuration mapping or return ``None``.""" - return None - - def blankConfig(self, protocol, **kwargs): - """Construct a blank configuration for a contributed protocol.""" - return None - - def createEditorForProtocol(self, protocol, parent=None, **kwargs): - """Create the configuration editor for a contributed protocol.""" - return None - - def createEditorForConfig(self, config, parent=None, **kwargs): - """Create the configuration editor for a plugin configuration.""" - return self.createEditorForProtocol(config.itemProtocol, parent, **kwargs) - def createManagementActions(self, parent=None, **kwargs): - """Return optional actions for this plugin's management submenu.""" + """Return optional actions for this backend's management submenu.""" return tuple() def prepareTUN(self, config) -> bool: - """Prepare plugin-native TUN and return whether the plugin handles it.""" + """Prepare native TUN and return whether the backend handles it.""" return False def routingOptions(self, config=None): - """Return routing modes supported for a plugin configuration.""" + """Return routing modes supported for a backend configuration.""" return tuple() def configureEnvironment(self): - """Set optional process environment required by the plugin core.""" + """Set optional environment required by this backend's process.""" def startCore( self, @@ -107,7 +170,7 @@ class FuriousPlugin: log=True, **kwargs, ): - """Start the plugin core and return ``(process, success)``.""" + """Start the backend and return ``(process, success)``.""" return None, False def prepareDownloadTest(self, config, port: int): @@ -115,16 +178,33 @@ class FuriousPlugin: return None def coreVersions(self): - """Return core version strings that should be treated as versions in logs.""" + """Return version strings reported by this backend.""" return tuple() def logTimestampPatterns(self): - """Return regular expressions for timestamps emitted by the plugin core.""" + """Return timestamp expressions emitted by this backend.""" return tuple() def coreExitMessage(self, core, exitcode: int): - """Return a user-facing message key for a plugin-specific exit code.""" + """Return a user-facing message key for a special exit code.""" return None def afterConnected(self, httpProxy=None): - """Perform optional plugin maintenance after a connection succeeds.""" + """Perform optional maintenance after a connection succeeds.""" + + +class FuriousPlugin: + """Group independently discoverable Furious capabilities.""" + + apiVersion = PLUGIN_API_VERSION + pluginId = '' + displayName = '' + protocolHandlers = tuple() + coreBackends = tuple() + subscriptionDecoders = tuple() + + def initialize(self, context: PluginContext): + """Initialize the plugin after all of its capabilities are registered.""" + + def shutdown(self): + """Release resources owned by the plugin before application shutdown.""" diff --git a/Furious/Plugins/Profile.py b/Furious/Plugins/Profile.py new file mode 100644 index 0000000..b0e38eb --- /dev/null +++ b/Furious/Plugins/Profile.py @@ -0,0 +1,95 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Construct and export profiles through registered protocol capabilities.""" + +from __future__ import annotations + +from Furious.Domain.Configuration import ConfigFactory + +from typing import Mapping, Union + +import logging +import ujson + +from .Registry import getPluginRegistry + +__all__ = [ + 'blankConfiguration', + 'configurationFromAny', + 'configurationFromMapping', + 'exportConfiguration', +] + +logger = logging.getLogger(__name__) + + +def configurationFromMapping(config: Mapping, **kwargs) -> ConfigFactory: + """Construct a profile from a normalized mapping.""" + if not isinstance(config, dict): + return ConfigFactory(**kwargs) + + try: + factory = getPluginRegistry().configFromDict(config, **kwargs) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'failed to recognize configuration mapping: {ex}') + + factory = None + + return factory if factory is not None else ConfigFactory(config, **kwargs) + + +def configurationFromAny(config: Union[str, Mapping], **kwargs) -> ConfigFactory: + """Construct a profile from a share URI, JSON text, or mapping.""" + if isinstance(config, str): + factory = getPluginRegistry().configFromString(config, **kwargs) + + if factory is not None: + return factory + + try: + return configurationFromMapping(ujson.loads(config), **kwargs) + except Exception: + # Any non-exit exceptions + + return ConfigFactory(**kwargs) + + if isinstance(config, dict): + return configurationFromMapping(config, **kwargs) + + return ConfigFactory(**kwargs) + + +def blankConfiguration(protocol, **kwargs) -> ConfigFactory: + """Create a blank profile through an exact protocol capability.""" + factory = getPluginRegistry().blankConfig(protocol, **kwargs) + + return factory if factory is not None else ConfigFactory(**kwargs) + + +def exportConfiguration(config, remark: str = '') -> str: + """Export a profile through its owning protocol capability.""" + try: + return getPluginRegistry().exportConfig(config, remark) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'failed to export configuration: {ex}') + + return '' diff --git a/Furious/Plugins/README.md b/Furious/Plugins/README.md index c66e81d..3bd32d7 100644 --- a/Furious/Plugins/README.md +++ b/Furious/Plugins/README.md @@ -1,93 +1,98 @@ # Furious plugins -Furious core support is provided through `FuriousPlugin` implementations. The -host GUI owns application lifecycle, storage, subscriptions, routing setup, and -the `tun2socks` sidecar. A plugin owns everything specific to a proxy core: +`Furious.Plugins` is the extension host. It contains contracts, lifecycle, and +capability indexes, but no proxy-core implementation. Bundled proxy cores live +under `Furious.Backends`; bundled non-core extensions live under +`Furious.Extensions`. -- configuration recognition, URI import/export, and blank configurations; -- server protocols and their Add Server menu metadata; -- configuration editor construction; -- optional management actions and routing choices; -- core process construction and startup; -- download-test configuration adaptation; -- optional environment setup and post-connection maintenance, such as core - asset updates; -- optional native TUN configuration, replacing the host's `tun2socks` sidecar; -- plugin-specific process exit messages and version strings; -- plugin-specific log timestamp highlighting. +The API separates three capabilities that can evolve independently: -The bundled implementations live under `Furious.Backends`. `Furious.Plugins` -contains only the extension contract and registry; the desktop composition root -registers the bundled backend types explicitly during startup. +- `ProtocolHandler` owns one protocol's URI schemes, profile recognition, + blank profile, URI export, and optional editor. +- `CoreBackend` owns process startup, routing, TUN, tests, versions, logs, and + backend maintenance for one or more configuration types. +- `SubscriptionDecoder` converts one subscription representation into URI or + normalized mapping entries. Importing those entries remains a host concern. -Registered core plugins appear under `Plugins` > `Core` in the main window. -Plugins may return actions from `createManagementActions` to populate their own -submenu. Plugins without management actions remain visible as supported cores. +A plugin is a small container for any combination of these capabilities. A +subscription-format plugin therefore does not appear as a proxy core, and a +protocol handler can target an existing backend without inheriting its process +implementation. -## Third-party discovery +## Dispatch and ownership -Third-party packages register an entry point in `pyproject.toml`: +Protocol IDs, declared URI schemes, backend IDs, decoder IDs, configuration +types, and core types are validated at registration. A mapping/editor-only +protocol may declare no URI scheme. Declared URI schemes are indexed directly; +the importer does not scan plugins until one happens to accept a string. +Duplicate schemes fail during registration, avoiding order-dependent imports. + +Mapping recognition is capability-driven because a mapping has no required URI +scheme. The host rejects an ambiguous mapping when multiple handlers claim it. +Configuration export and editor creation use the handler that explicitly owns +the profile. Runtime operations use the independently resolved core backend. + +## Lifecycle and discovery + +Third-party packages expose a `FuriousPlugin` through the +`furious.plugins` Python entry-point group: ```toml [project.entry-points."furious.plugins"] -example-core = "furious_example.plugin:ExamplePlugin" +example = "furious_example.plugin:ExamplePlugin" ``` -The entry point may expose a `FuriousPlugin` instance, a `FuriousPlugin` -subclass, a zero-argument callable returning one plugin, or a list/tuple of -plugins. Plugins execute inside the Furious process and must therefore be -treated as trusted Python code. +The host validates and indexes all capabilities, then calls +`plugin.initialize(context)`. If initialization fails, registration is rolled +back. During application cleanup, initialized plugins receive `shutdown()` in +reverse order. Plugins execute in the Furious process and must be treated as +trusted Python code. ```python -from Furious.Plugins import FuriousPlugin, PluginProtocol, PluginRouting +from Furious.Plugins import ( + FuriousPlugin, + ProtocolDescriptor, + ProtocolHandler, +) + + +class ExampleProtocol(ProtocolHandler): + descriptor = ProtocolDescriptor( + id='example', + displayName='Example', + addActionText='Add Example Server...', + menuOrder=100, + ) + schemes = ('example',) + + def supports(self, configuration): + return isinstance(configuration, ExampleConfig) + + def parse(self, uri, **kwargs): + return ExampleConfig.fromUri(uri, **kwargs) + + def fromMapping(self, configuration, **kwargs): + if configuration.get('type') == 'example': + return ExampleConfig(configuration, **kwargs) + + def blank(self, **kwargs): + return ExampleConfig.blank(**kwargs) + + def export(self, configuration, remark=''): + return configuration.toUri(remark) class ExamplePlugin(FuriousPlugin): - apiVersion = 1 - pluginId = 'example.core' - displayName = 'Example Core' - protocols = ( - PluginProtocol( - id='example', - displayName='Example', - addActionText='Add Example Server...', - menuOrder=100, - separatorBefore=True, - ), - ) - configurationTypes = (ExampleConfig,) - coreTypes = (ExampleCore,) - - # Implement configFromString/configFromDict, blankConfig, - # createEditorForProtocol, startCore, and prepareDownloadTest as needed. - # createManagementActions, routingOptions, - # prepareTUN, configureEnvironment, afterConnected, and - # logTimestampPatterns are optional. + pluginId = 'example.protocol' + displayName = 'Example Protocol' + protocolHandlers = (ExampleProtocol(),) ``` -`routingOptions(config)` may return `PluginRouting` values for routing modes -supported by the given configuration. The tray Routing submenu follows the -active server's plugin and is hidden when that plugin returns no routing modes. -The host validates the current setting and falls back to the plugin's first -option before starting its core. Routing display names are treated as literal -text by default; set `translatable=True` only when the display name is an -application translation key. User-provided labels should remain literal. +Qt-dependent editors and actions should be imported only inside capability +methods. Discovery may happen while the host's Qt package is still being +initialized; metadata, protocol parsing, and backend configuration must remain +usable without importing Qt widgets. -Configuration classes should subclass `ConfigFactory` and implement its table -display, proxy endpoint, validity, serialization, and URI methods. Core process -classes normally subclass `CoreProcessWorker`; editor dialogs normally subclass -`GuiEditorWidgetQDialog`. - -Plugin modules can be discovered while the `Furious.Qt` package is still being -initialized. Keep metadata, configuration, routing, and process imports at -module scope, but defer imports of Qt-dependent editors, windows, actions, and -managers until their plugin hook is called. - -`prepareTUN(config)` is called only for a normal connection while the host is -in TUN mode. It may modify the copied runtime configuration and return `True` -to indicate that the plugin handles TUN itself. Returning `False` keeps the -host-provided `tun2socks` implementation. - -Protocol IDs are case-insensitive. Plugin IDs, protocol IDs, configuration -types, and core types must be unique. Conflicting or incompatible third-party -plugins are rejected without preventing other entry points from loading. +Routing labels are literal by default. A `RoutingOption` should set +`translatable=True` only when its display name is an application translation +key. User-provided labels must remain literal. diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index 01e6623..0c72ce1 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -15,13 +15,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Register official plugins and discover third-party Furious plugins.""" +"""Discover plugins and index their independently usable capabilities.""" from __future__ import annotations -from Furious.Domain.Configuration import configurationRegistry - from importlib import metadata +from urllib.parse import urlsplit import logging import threading @@ -41,30 +40,42 @@ PLUGIN_ENTRY_POINT_GROUP = 'furious.plugins' logger = logging.getLogger(__name__) -def _normalizeProtocol(protocol) -> str: - """Return a case-insensitive protocol identifier.""" - value = getattr(protocol, 'value', protocol) +def _normalizeIdentifier(value) -> str: + """Return a case-insensitive capability identifier.""" + return str(getattr(value, 'value', value)).strip().casefold() - return str(value).strip().casefold() + +def _normalizeScheme(value) -> str: + """Return a URI scheme without punctuation.""" + return str(value).strip().rstrip(':').casefold() + + +def _schemeFromURI(uri: str) -> str: + """Extract a normalized scheme from *uri*.""" + try: + return _normalizeScheme(urlsplit(uri.strip()).scheme) + except Exception: + return '' class PluginRegistry: - """Store plugins and route host operations to the owning plugin.""" + """Own plugin lifecycle and dispatch through indexed capabilities.""" - def __init__(self, configRegistry=None): - """Initialize an empty plugin registry. - - ``configRegistry`` is supplied only to the process-wide registry. This - keeps independently constructed registries isolated for tools and tests. - """ + def __init__(self): + """Initialize an empty capability registry.""" self._plugins = {} self._protocols = {} - self._configurationTypes = {} - self._coreTypes = {} - self._configRegistry = configRegistry + self._schemes = {} + self._protocolEntries = [] + self._backends = {} + self._configurationBackends = {} + self._coreBackends = {} + self._decoders = {} + self._initializedPlugins = [] + self._closed = False - def register(self, plugin: FuriousPlugin): - """Register a plugin after validating all of its public identifiers.""" + def _validatePlugin(self, plugin): + """Validate *plugin* and return its normalized capability metadata.""" if isinstance(plugin, type) and issubclass(plugin, FuriousPlugin): plugin = plugin() @@ -85,249 +96,491 @@ class PluginRegistry: if pluginId in self._plugins: raise ValueError(f'plugin {pluginId!r} is already registered') - protocolKeys = [] + protocols = [] + localProtocolIds = set() + localSchemes = set() - for descriptor in plugin.protocols: - if not isinstance(descriptor, PluginProtocol): - raise TypeError('plugin protocols must contain PluginProtocol values') + for handler in plugin.protocolHandlers: + if not isinstance(handler, ProtocolHandler): + raise TypeError( + 'plugin protocolHandlers must contain ProtocolHandler values' + ) - key = _normalizeProtocol(descriptor.id) + descriptor = handler.descriptor - if not key: - raise ValueError('plugin protocol ID cannot be empty') + if not isinstance(descriptor, ProtocolDescriptor): + raise TypeError( + 'protocol handlers must expose a ProtocolDescriptor value' + ) - if key in self._protocols or key in protocolKeys: + protocolId = _normalizeIdentifier(descriptor.id) + + if not protocolId: + raise ValueError('protocol ID cannot be empty') + + if protocolId in self._protocols or protocolId in localProtocolIds: raise ValueError(f'protocol {descriptor.id!r} is already registered') - protocolKeys.append(key) + schemes = tuple(_normalizeScheme(scheme) for scheme in handler.schemes) - configurationTypes = [] + if any(not scheme for scheme in schemes): + raise ValueError(f'protocol {descriptor.id!r} has an empty URI scheme') - for configType in plugin.configurationTypes: - if not isinstance(configType, type): - raise TypeError('plugin configuration types must be classes') + for scheme in schemes: + if scheme in self._schemes or scheme in localSchemes: + raise ValueError(f'URI scheme {scheme!r} is already registered') - if any( - issubclass(configType, registeredType) - or issubclass(registeredType, configType) - for registeredType in ( - *self._configurationTypes, - *configurationTypes, - ) + localProtocolIds.add(protocolId) + localSchemes.update(schemes) + protocols.append((protocolId, schemes, handler)) + + backends = [] + localBackendIds = set() + localConfigurationTypes = [] + localCoreTypes = [] + + for backend in plugin.coreBackends: + if not isinstance(backend, CoreBackend): + raise TypeError('plugin coreBackends must contain CoreBackend values') + + backendId = _normalizeIdentifier(backend.backendId) + + if not backendId: + raise ValueError('backend ID cannot be empty') + + if backendId in self._backends or backendId in localBackendIds: + raise ValueError(f'backend {backend.backendId!r} is already registered') + + configurationTypes = tuple(backend.configurationTypes) + coreTypes = tuple(backend.coreTypes) + + for value, label, existing, local in ( + ( + configurationTypes, + 'configuration', + tuple(self._configurationBackends), + localConfigurationTypes, + ), + (coreTypes, 'core', tuple(self._coreBackends), localCoreTypes), ): + for itemType in value: + if not isinstance(itemType, type): + raise TypeError(f'backend {label} types must be classes') + + if any( + issubclass(itemType, registeredType) + or issubclass(registeredType, itemType) + for registeredType in (*existing, *local) + ): + raise ValueError( + f'{label} type {itemType.__name__!r} overlaps a ' + f'registered type' + ) + + local.append(itemType) + + localBackendIds.add(backendId) + backends.append((backendId, backend)) + + decoders = [] + localDecoderIds = set() + + for decoder in plugin.subscriptionDecoders: + if not isinstance(decoder, SubscriptionDecoder): + raise TypeError( + 'plugin subscriptionDecoders must contain SubscriptionDecoder values' + ) + + decoderId = _normalizeIdentifier(decoder.decoderId) + + if not decoderId: + raise ValueError('subscription decoder ID cannot be empty') + + if decoderId in self._decoders or decoderId in localDecoderIds: raise ValueError( - f'configuration type {configType.__name__!r} overlaps a registered type' + f'subscription decoder {decoder.decoderId!r} is already registered' ) - configurationTypes.append(configType) + if not isinstance(decoder.priority, int): + raise TypeError('subscription decoder priority must be an integer') - coreTypes = [] + localDecoderIds.add(decoderId) + decoders.append((decoderId, decoder)) - for coreType in plugin.coreTypes: - if not isinstance(coreType, type): - raise TypeError('plugin core types must be classes') + return plugin, pluginId, protocols, backends, decoders - if any( - issubclass(coreType, registeredType) - or issubclass(registeredType, coreType) - for registeredType in ( - *self._coreTypes, - *coreTypes, - ) - ): - raise ValueError( - f'core type {coreType.__name__!r} overlaps a registered type' - ) + def register(self, plugin: FuriousPlugin): + """Register, index, and initialize one plugin atomically.""" + if self._closed: + raise RuntimeError('plugin registry has already been shut down') - coreTypes.append(coreType) - - if self._configRegistry is not None: - self._configRegistry.register(plugin) + plugin, pluginId, protocols, backends, decoders = self._validatePlugin(plugin) self._plugins[pluginId] = plugin - for key, descriptor in zip(protocolKeys, plugin.protocols): - self._protocols[key] = (plugin, descriptor) - for configType in plugin.configurationTypes: - self._configurationTypes[configType] = plugin - for coreType in plugin.coreTypes: - self._coreTypes[coreType] = plugin + for protocolId, schemes, handler in protocols: + entry = (plugin, handler) + self._protocols[protocolId] = entry + self._protocolEntries.append(entry) + for scheme in schemes: + self._schemes[scheme] = entry + + for backendId, backend in backends: + self._backends[backendId] = (plugin, backend) + + for configType in backend.configurationTypes: + self._configurationBackends[configType] = (plugin, backend) + for coreType in backend.coreTypes: + self._coreBackends[coreType] = (plugin, backend) + + for decoderId, decoder in decoders: + self._decoders[decoderId] = (plugin, decoder) + + try: + plugin.initialize(PluginContext(pluginId, self)) + except Exception: + try: + plugin.shutdown() + except Exception as ex: + logger.error(f'plugin rollback failed for {pluginId!r}: {ex}') + + self._removePlugin(pluginId) + raise + + self._initializedPlugins.append(plugin) logger.info(f'registered plugin {pluginId!r}') return plugin + def _removePlugin(self, pluginId: str): + """Remove a partially registered plugin after initialization failure.""" + plugin = self._plugins.pop(pluginId, None) + + if plugin is None: + return + + self._protocolEntries = [ + entry for entry in self._protocolEntries if entry[0] is not plugin + ] + self._protocols = { + key: entry + for key, entry in self._protocols.items() + if entry[0] is not plugin + } + self._schemes = { + key: entry for key, entry in self._schemes.items() if entry[0] is not plugin + } + self._backends = { + key: entry + for key, entry in self._backends.items() + if entry[0] is not plugin + } + self._configurationBackends = { + key: entry + for key, entry in self._configurationBackends.items() + if entry[0] is not plugin + } + self._coreBackends = { + key: entry + for key, entry in self._coreBackends.items() + if entry[0] is not plugin + } + self._decoders = { + key: entry + for key, entry in self._decoders.items() + if entry[0] is not plugin + } + def plugins(self): - """Return registered plugins in registration order.""" + """Return initialized plugins in registration order.""" return tuple(self._plugins.values()) + def corePlugins(self): + """Return plugins that contribute at least one core backend.""" + return tuple(plugin for plugin in self.plugins() if plugin.coreBackends) + def plugin(self, pluginId: str): - """Return the plugin registered with ``pluginId``, if any.""" + """Return the plugin registered with *pluginId*, if any.""" return self._plugins.get(pluginId) def protocolDescriptors(self): - """Return contributed protocols in their requested menu order.""" - descriptors = list( - descriptor for _plugin, descriptor in self._protocols.values() + """Return protocol descriptors in their requested menu order.""" + descriptors = [handler.descriptor for _plugin, handler in self._protocolEntries] + + return tuple(sorted(descriptors, key=lambda value: value.menuOrder)) + + def protocolHandlers(self): + """Return registered protocol handlers in registration order.""" + return tuple(handler for _plugin, handler in self._protocolEntries) + + def coreBackends(self): + """Return registered core backends in registration order.""" + return tuple(backend for _plugin, backend in self._backends.values()) + + def subscriptionDecoders(self): + """Return subscription decoders in auto-detection priority order.""" + return tuple( + decoder + for _plugin, decoder in sorted( + self._decoders.values(), + key=lambda value: value[1].priority, + reverse=True, + ) ) - return tuple(sorted(descriptors, key=lambda descriptor: descriptor.menuOrder)) + def handlerForProtocol(self, protocol): + """Return the handler registered for a protocol identifier.""" + entry = self._protocols.get(_normalizeIdentifier(protocol)) + + return entry[1] if entry is not None else None + + def handlerForConfig(self, config): + """Return the unique protocol handler that owns *config*.""" + matches = [] + + for _plugin, handler in self._protocolEntries: + try: + if handler.supports(config): + matches.append(handler) + except Exception as ex: + logger.error( + f'protocol ownership check failed for ' + f'{handler.descriptor.id!r}: {ex}' + ) + + if len(matches) > 1: + names = ', '.join(repr(handler.descriptor.id) for handler in matches) + raise ValueError(f'configuration is claimed by multiple protocols: {names}') + + return matches[0] if matches else None def pluginForProtocol(self, protocol): - """Return the plugin that owns a protocol identifier.""" - entry = self._protocols.get(_normalizeProtocol(protocol)) + """Return the plugin that contributes *protocol*.""" + entry = self._protocols.get(_normalizeIdentifier(protocol)) return entry[0] if entry is not None else None - def pluginForConfig(self, config): - """Return the plugin that owns a configuration instance.""" - for configType, plugin in self._configurationTypes.items(): + def backendForConfig(self, config): + """Return the backend whose configuration type matches *config*.""" + for configType, (_plugin, backend) in self._configurationBackends.items(): if isinstance(config, configType): - return plugin + return backend return None + def backendForCore(self, core): + """Return the backend that owns a running core object.""" + for coreType, (_plugin, backend) in self._coreBackends.items(): + if isinstance(core, coreType): + return backend + + return None + + def pluginForConfig(self, config): + """Return the plugin that contributes the owning backend or protocol.""" + for configType, (plugin, _backend) in self._configurationBackends.items(): + if isinstance(config, configType): + return plugin + + handler = self.handlerForConfig(config) + + if handler is None: + return None + + return self.pluginForProtocol(handler.descriptor.id) + def pluginForCore(self, core): - """Return the plugin that owns a running core instance.""" - for coreType, plugin in self._coreTypes.items(): + """Return the plugin that contributes the core's backend.""" + for coreType, (plugin, _backend) in self._coreBackends.items(): if isinstance(core, coreType): return plugin return None def configFromString(self, config: str, **kwargs): - """Ask plugins to parse textual configuration data.""" - for plugin in self.plugins(): - factory = plugin.configFromString(config, **kwargs) + """Parse a URI through its directly indexed scheme handler.""" + entry = self._schemes.get(_schemeFromURI(config)) - if factory is not None: - return factory + if entry is None: + return None - return None + _plugin, handler = entry + + try: + result = handler.parse(config, **kwargs) + except Exception as ex: + logger.error( + f'failed to parse {handler.descriptor.id!r} configuration: {ex}' + ) + + return None + + if result is not None and not handler.supports(result): + logger.error( + f'protocol handler {handler.descriptor.id!r} returned a ' + f'configuration it does not own' + ) + + return None + + return result def configFromDict(self, config: dict, **kwargs): - """Ask plugins to parse configuration mapping data.""" - for plugin in self.plugins(): - factory = plugin.configFromDict(config, **kwargs) + """Recognize a normalized configuration through protocol handlers.""" + matches = [] - if factory is not None: - return factory + for _plugin, handler in self._protocolEntries: + try: + result = handler.fromMapping(config, **kwargs) + except Exception as ex: + logger.error( + f'failed to recognize {handler.descriptor.id!r} mapping: {ex}' + ) + continue - return None + if result is not None: + matches.append((handler, result)) + + if len(matches) > 1: + names = ', '.join(repr(item[0].descriptor.id) for item in matches) + raise ValueError(f'configuration mapping is ambiguous: {names}') + + if matches: + return matches[0][1] + + backendMatches = [] + + for _plugin, backend in self._backends.values(): + try: + result = backend.fromMapping(config, **kwargs) + except Exception as ex: + logger.error(f'failed to recognize {backend.backendId!r} mapping: {ex}') + continue + + if result is not None: + backendMatches.append((backend, result)) + + if len(backendMatches) > 1: + names = ', '.join(repr(item[0].backendId) for item in backendMatches) + raise ValueError(f'backend configuration mapping is ambiguous: {names}') + + return backendMatches[0][1] if backendMatches else None def blankConfig(self, protocol, **kwargs): - """Construct a blank configuration through the owning plugin.""" - plugin = self.pluginForProtocol(protocol) + """Create a blank configuration through an exact protocol handler.""" + handler = self.handlerForProtocol(protocol) - return plugin.blankConfig(protocol, **kwargs) if plugin is not None else None + return handler.blank(**kwargs) if handler is not None else None + + def exportConfig(self, config, remark: str = '') -> str: + """Export a configuration through its owning protocol handler.""" + handler = self.handlerForConfig(config) + + return handler.export(config, remark) if handler is not None else '' def createEditorForProtocol(self, protocol, parent=None, **kwargs): - """Construct a protocol editor through the owning plugin.""" - plugin = self.pluginForProtocol(protocol) + """Create an editor through an exact protocol handler.""" + handler = self.handlerForProtocol(protocol) - if plugin is None: - return None - - return plugin.createEditorForProtocol(protocol, parent=parent, **kwargs) + return ( + handler.createEditor(parent=parent, **kwargs) + if handler is not None + else None + ) def createEditorForConfig(self, config, parent=None, **kwargs): - """Construct a configuration editor through the owning plugin.""" - plugin = self.pluginForConfig(config) + """Create an editor through the configuration's protocol handler.""" + handler = self.handlerForConfig(config) - if plugin is None: - return None - - return plugin.createEditorForConfig(config, parent=parent, **kwargs) + return ( + handler.createEditor(parent=parent, **kwargs) + if handler is not None + else None + ) def managementActions(self, plugin, parent=None, **kwargs): - """Return management actions contributed by one registered plugin.""" + """Aggregate management actions from one plugin's core backends.""" if self._plugins.get(plugin.pluginId) is not plugin: raise ValueError(f'plugin {plugin.pluginId!r} is not registered') - try: - return tuple(plugin.createManagementActions(parent=parent, **kwargs)) - except Exception as ex: - logger.error( - f'failed to create management actions for {plugin.pluginId!r}: {ex}' - ) + actions = [] - return tuple() + for backend in plugin.coreBackends: + try: + actions.extend(backend.createManagementActions(parent=parent, **kwargs)) + except Exception as ex: + logger.error( + f'failed to create management actions for ' + f'{backend.backendId!r}: {ex}' + ) + + return tuple(actions) def prepareTUN(self, config) -> bool: - """Ask a configuration's plugin to prepare its native TUN support.""" - plugin = self.pluginForConfig(config) + """Ask a configuration's backend to prepare native TUN support.""" + backend = self.backendForConfig(config) - if plugin is None: + if backend is None: return False try: - handled = plugin.prepareTUN(config) + handled = backend.prepareTUN(config) if not isinstance(handled, bool): - raise TypeError('plugin TUN preparation result must be a boolean') + raise TypeError('backend TUN preparation result must be a boolean') return handled except Exception as ex: - # Any non-exit exceptions - - logger.error(f'TUN preparation failed for {plugin.pluginId!r}: {ex}') + logger.error(f'TUN preparation failed for {backend.backendId!r}: {ex}') return False def routingOptions(self, config): - """Return validated routing modes supported by a configuration's plugin.""" - plugin = self.pluginForConfig(config) + """Return validated routing modes from a configuration's backend.""" + backend = self.backendForConfig(config) - if plugin is None: + if backend is None: return tuple() try: - options = tuple(plugin.routingOptions(config)) + options = tuple(backend.routingOptions(config)) optionIds = set() for option in options: - if not isinstance(option, PluginRouting): + if not isinstance(option, RoutingOption): raise TypeError( - 'plugin routing options must be PluginRouting values' + 'backend routing options must be RoutingOption values' ) - if not isinstance(option.id, str): - raise TypeError('plugin routing option ID must be a string') - - if not option.id.strip(): - raise ValueError('plugin routing option ID cannot be empty') + if not isinstance(option.id, str) or not option.id.strip(): + raise ValueError('routing option ID must be a non-empty string') if not isinstance(option.displayName, str): - raise TypeError( - 'plugin routing option display name must be a string' - ) + raise TypeError('routing option display name must be a string') if not isinstance(option.translatable, bool): raise TypeError( - 'plugin routing option translatable flag must be a boolean' + 'routing option translatable flag must be a boolean' ) - optionId = option.id - - if optionId in optionIds: + if option.id in optionIds: raise ValueError( f'routing option {option.id!r} is already registered' ) - optionIds.add(optionId) + optionIds.add(option.id) return options except Exception as ex: - # Any non-exit exceptions - logger.error( - f'failed to obtain routing options for {plugin.pluginId!r}: {ex}' + f'failed to obtain routing options for {backend.backendId!r}: {ex}' ) return tuple() def normalizeRouting(self, config, routing): - """Return a supported routing value or the plugin's first option.""" + """Return a supported routing value or the backend's first option.""" options = self.routingOptions(config) if not options: @@ -337,78 +590,117 @@ class PluginRegistry: return routing if routing in optionIds else optionIds[0] - def configureEnvironment(self): - """Allow every plugin to configure its core process environment.""" - for plugin in self.plugins(): - try: - plugin.configureEnvironment() - except Exception as ex: - # Any non-exit exceptions + def decodeSubscription(self, data: bytes, decoderId=None): + """Decode subscription bytes using an explicit or auto-detected decoder.""" + if not isinstance(data, bytes): + raise TypeError('subscription payload must be bytes') - logger.error(f'environment hook failed for {plugin.pluginId!r}: {ex}') + if decoderId: + entry = self._decoders.get(_normalizeIdentifier(decoderId)) + candidates = (entry,) if entry is not None else tuple() + else: + candidates = tuple( + sorted( + self._decoders.values(), + key=lambda value: value[1].priority, + reverse=True, + ) + ) + + for _plugin, decoder in candidates: + try: + result = decoder.decode(data) + except Exception as ex: + logger.error(f'subscription decoder {decoder.decoderId!r} failed: {ex}') + continue + + if result is None: + continue + + if not isinstance(result, SubscriptionResult): + logger.error( + f'subscription decoder {decoder.decoderId!r} returned an ' + f'invalid result' + ) + continue + + if _normalizeIdentifier(result.decoderId) != _normalizeIdentifier( + decoder.decoderId + ) or any(not isinstance(item, SubscriptionItem) for item in result.items): + logger.error( + f'subscription decoder {decoder.decoderId!r} returned ' + f'inconsistent metadata' + ) + continue + + return result + + return None + + def configureEnvironment(self): + """Allow every backend to configure its process environment.""" + for _plugin, backend in self._backends.values(): + try: + backend.configureEnvironment() + except Exception as ex: + logger.error(f'environment hook failed for {backend.backendId!r}: {ex}') def coreVersions(self): - """Return version strings reported by every registered plugin core.""" + """Return version strings reported by every registered backend.""" versions = [] - for plugin in self.plugins(): + for _plugin, backend in self._backends.values(): try: - versions.extend(plugin.coreVersions()) + versions.extend(backend.coreVersions()) except Exception as ex: - # Any non-exit exceptions - logger.error( - f'failed to obtain core versions for {plugin.pluginId!r}: {ex}' + f'failed to obtain core versions for {backend.backendId!r}: {ex}' ) return tuple(filter(None, versions)) def logTimestampPatterns(self): - """Return timestamp expressions contributed by registered plugins.""" + """Return timestamp expressions contributed by all backends.""" patterns = [] - for plugin in self.plugins(): + for _plugin, backend in self._backends.values(): try: - patterns.extend(plugin.logTimestampPatterns()) + patterns.extend(backend.logTimestampPatterns()) except Exception as ex: - # Any non-exit exceptions - logger.error( - f'failed to obtain log patterns for {plugin.pluginId!r}: {ex}' + f'failed to obtain log patterns for {backend.backendId!r}: {ex}' ) return tuple(filter(None, patterns)) def coreExitMessage(self, core, exitcode: int): - """Return the owning plugin's special exit message, if any.""" - plugin = self.pluginForCore(core) + """Return the owning backend's special exit message, if any.""" + backend = self.backendForCore(core) - if plugin is None: + if backend is None: return None try: - return plugin.coreExitMessage(core, exitcode) + return backend.coreExitMessage(core, exitcode) except Exception as ex: - # Any non-exit exceptions - - logger.error(f'failed to interpret core exit for {plugin.pluginId!r}: {ex}') + logger.error( + f'failed to interpret core exit for {backend.backendId!r}: {ex}' + ) return None def afterConnected(self, httpProxy=None): - """Notify every registered plugin after a connection succeeds.""" - for plugin in self.plugins(): + """Notify every backend after a connection succeeds.""" + for _plugin, backend in self._backends.values(): try: - plugin.afterConnected(httpProxy) + backend.afterConnected(httpProxy) except Exception as ex: - # Any non-exit exceptions - logger.error( - f'post-connection hook failed for {plugin.pluginId!r}: {ex}' + f'post-connection hook failed for {backend.backendId!r}: {ex}' ) def discover(self): - """Load third-party plugins exposed through Python entry points.""" + """Load trusted third-party plugins exposed through entry points.""" try: entryPoints = metadata.entry_points() @@ -417,8 +709,6 @@ class PluginRegistry: else: entryPoints = entryPoints.get(PLUGIN_ENTRY_POINT_GROUP, tuple()) except Exception as ex: - # Any non-exit exceptions - logger.error(f'failed to enumerate Furious plugins: {ex}') return @@ -438,23 +728,36 @@ class PluginRegistry: else: self.register(plugin) except Exception as ex: - # Any non-exit exceptions - logger.error(f'failed to load plugin {entryPoint.name!r}: {ex}') + def shutdown(self): + """Shut down initialized plugins in reverse registration order once.""" + if self._closed: + return -_registry = PluginRegistry(configurationRegistry) + self._closed = True + + for plugin in reversed(self._initializedPlugins): + try: + plugin.shutdown() + except Exception as ex: + logger.error(f'plugin shutdown failed for {plugin.pluginId!r}: {ex}') + + self._initializedPlugins.clear() + + +_registry = PluginRegistry() _registryLock = threading.RLock() _registryInitialized = False def initializePluginRegistry(pluginTypes=()) -> PluginRegistry: - """Initialize discovery and register host-provided plugin implementations.""" + """Discover third-party plugins and register host-provided plugin types.""" global _registry, _registryInitialized with _registryLock: if not _registryInitialized: - registry = PluginRegistry(configurationRegistry) + registry = PluginRegistry() for pluginType in pluginTypes: registry.register(pluginType()) diff --git a/Furious/Plugins/__init__.py b/Furious/Plugins/__init__.py index 9a6591b..b3247b5 100644 --- a/Furious/Plugins/__init__.py +++ b/Furious/Plugins/__init__.py @@ -19,7 +19,24 @@ from __future__ import annotations -from .API import PLUGIN_API_VERSION, FuriousPlugin, PluginProtocol, PluginRouting +from .API import ( + PLUGIN_API_VERSION, + CoreBackend, + FuriousPlugin, + PluginContext, + ProtocolDescriptor, + ProtocolHandler, + RoutingOption, + SubscriptionDecoder, + SubscriptionItem, + SubscriptionResult, +) +from .Profile import ( + blankConfiguration, + configurationFromAny, + configurationFromMapping, + exportConfiguration, +) from .Registry import ( PLUGIN_ENTRY_POINT_GROUP, PluginRegistry, @@ -31,10 +48,20 @@ from .Registry import ( __all__ = [ 'PLUGIN_API_VERSION', 'PLUGIN_ENTRY_POINT_GROUP', + 'CoreBackend', 'FuriousPlugin', - 'PluginProtocol', + 'PluginContext', 'PluginRegistry', - 'PluginRouting', + 'ProtocolDescriptor', + 'ProtocolHandler', + 'RoutingOption', + 'SubscriptionDecoder', + 'SubscriptionItem', + 'SubscriptionResult', + 'blankConfiguration', + 'configurationFromAny', + 'configurationFromMapping', + 'exportConfiguration', 'getPluginRegistry', 'initializePluginRegistry', 'registerPlugin', diff --git a/Furious/Repository/Servers.py b/Furious/Repository/Servers.py index aa4fdfc..ea6e1b3 100644 --- a/Furious/Repository/Servers.py +++ b/Furious/Repository/Servers.py @@ -21,8 +21,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Domain.Configuration import ConfigFactory, configFactoryFromAny +from Furious.Domain.Configuration import ConfigFactory from Furious.Domain.Encoding import * +from Furious.Plugins import configurationFromAny __all__ = ['UserServers'] @@ -58,7 +59,7 @@ class UserServers(Mixins.CleanupOnExit, StorageBackend): self._data = restore() self._list = list( - configFactoryFromAny(model.pop('config', ''), index=index, **model) + configurationFromAny(model.pop('config', ''), index=index, **model) for index, model in enumerate(self._data['model']) ) diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index b34912d..5b03eb0 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -89,15 +89,15 @@ class ConnectionManager(Mixins.CleanupOnExit): log=True, **kwargs, ) -> Tuple[Union[CoreProcessWorker, None], bool]: - """Start a configuration through the plugin that owns it.""" + """Start a configuration through the backend that owns it.""" pluginRegistry = getPluginRegistry() - plugin = pluginRegistry.pluginForConfig(config) - if plugin is None: + backend = pluginRegistry.backendForConfig(config) + if backend is None: return None, False routing = pluginRegistry.normalizeRouting(config, routing) - return plugin.startCore( + return backend.startCore( config, routing, exitCallback=exitCallback, diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index fcf0efe..31ee158 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -23,7 +23,12 @@ from Furious.Frozenlib import * from Furious.Interface import * from Furious.Domain import * from Furious.Repository import * -from Furious.Plugins import getPluginRegistry +from Furious.Plugins import ( + blankConfiguration, + configurationFromAny, + exportConfiguration, + getPluginRegistry, +) from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Service import ConnectionManager @@ -154,7 +159,7 @@ class SubscriptionManager(WebGETManager): showMessageBox = kwargs.pop('showMessageBox', True) for param in successArgs: - uris, unique = param['uris'], param['unique'] + items, unique = param['items'], param['unique'] parent = self.parent() @@ -186,8 +191,18 @@ class SubscriptionManager(WebGETManager): remaining = len(Storage.UserServers()) - for uri in uris: - parent.appendNewItem(config=uri, subsId=unique) + for item in items: + profile = ( + item.configuration + if item.configuration is not None + else item.uri + ) + itemArgs = {'remark': item.name} if item.name else {} + parent.appendNewItem( + config=profile, + subsId=unique, + **itemArgs, + ) if subsGroupIndex >= 0: newIndex = remaining + subsGroupIndex @@ -232,52 +247,19 @@ class SubscriptionManager(WebGETManager): successArgs = kwargs.get('successArgs', list()) failureArgs = kwargs.get('failureArgs', list()) - data = networkReply.readAll().data() + data = bytes(networkReply.readAll().data()) + decoderId = kwargs.get('decoderId') + result = getPluginRegistry().decodeSubscription(data, decoderId) - uris = None - lastException = None - - try: - decoded = PyBase64Encoder.decode(data).decode() - except Exception as ex: - # Any non-exit exceptions - - lastException = ex - - logger.error( - f'parse base64 share link from \'{webURL}\' failed: {ex}. ' - f'Try to fall back to plain text' - ) - else: - # pybase64 decodes leniently and happily turns plain text into - # garbage bytes, so only accept the base64 result when it actually - # looks like share links. - if '://' in decoded: - uris = list(filter(lambda x: x != '', decoded.split('\n'))) - - if uris is None: - try: - uris = list( - filter( - lambda x: x != '', - data.decode().split('\n'), - ) - ) - except Exception as ex: - # Any non-exit exceptions - - lastException = ex - - logger.error(f'parse share link from \'{webURL}\' failed: {ex}') - - if uris is None: - failureArgs.append({'error': classname(lastException), **kwargs}) + if result is None: + failureArgs.append({'error': 'UnsupportedSubscriptionFormat', **kwargs}) else: logger.info( - f'update subs ({remark}, {webURL}) success. Got {len(uris)} share link' + f'update subs ({remark}, {webURL}) success. ' + f'Got {len(result.items)} profiles from {result.decoderId!r}' ) - successArgs.append({'uris': uris, **kwargs}) + successArgs.append({'items': result.items, **kwargs}) def failureCallback(self, networkReply, **kwargs): """Handle a failed network operation.""" @@ -537,15 +519,15 @@ class TestDownloadSpeedWorker(WebGETManager): pass def _startCore(self, config) -> bool: - """Prepare and start a download test through the owning plugin.""" - plugin = getPluginRegistry().pluginForConfig(config) - if plugin is None: + """Prepare and start a download test through the owning backend.""" + backend = getPluginRegistry().backendForConfig(config) + if backend is None: self.factory.setExtras('speedResult', 'Invalid') self.sync() return False - configcopy = plugin.prepareDownloadTest(config, self.port) + configcopy = backend.prepareDownloadTest(config, self.port) if configcopy is None: self.factory.setExtras('speedResult', 'Invalid') self.sync() @@ -1991,7 +1973,7 @@ class ServerTableView( **kwargs, ): """Add server via GUI.""" - factory = configFactoryBlank(protocol) + factory = blankConfiguration(protocol) guiEditor = self.getGuiEditorByFactory(factory, **kwargs) @@ -2531,7 +2513,7 @@ class ServerTableView( } tostr = f'{model}' - factory = configFactoryFromAny(model.pop('config', ''), **model) + factory = configurationFromAny(model.pop('config', ''), **model) if factory.isValid(): self.appendNewItemByFactory(factory) @@ -2554,7 +2536,7 @@ class ServerTableView( assert isinstance(factory, ConfigFactory) try: - return factory.toURI() + return exportConfiguration(factory) except Exception: # Any non-exit exceptions diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 0177c7e..48a33f3 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -487,7 +487,7 @@ class MainWindow(AppQMainWindow): toolsActions.extend([AppQSeperator(), *systemTools]) corePluginActions = [] - for plugin in pluginRegistry.plugins(): + for plugin in pluginRegistry.corePlugins(): managementActions = pluginRegistry.managementActions( plugin, parent=self, diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index 63d150c..ec4d57b 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Repository import * +from Furious.Plugins import exportConfiguration from Furious.Qt import * from Furious.Qt import gettext as _ @@ -89,7 +90,7 @@ class QRCodeWindow(AppQMainWindow): config = Storage.UserServers()[index] try: - uri = config.toURI() + uri = exportConfiguration(config) except Exception: # Any non-exit exceptions diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index 0a8b4ec..9ec8bbe 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Domain import * +from Furious.Plugins import configurationFromMapping from Furious.Repository import * from Furious.Qt import * from Furious.Qt import gettext as _ @@ -295,7 +296,7 @@ class TextEditorWindow(AppQMainWindow): return False else: old = Storage.UserServers()[index] - new = configFactoryFromDict(jsonObject, **old.kwargs) + new = configurationFromMapping(jsonObject, **old.kwargs) old.deleted = True