Refactor plugin capability architecture

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:47 +08:00
parent 96e9a640a8
commit 3d67bfdcaf
24 changed files with 1410 additions and 749 deletions
+5 -4
View File
@@ -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)
)
+6 -2
View File
@@ -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:
+5 -3
View File
@@ -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 {
+17 -63
View File
@@ -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(),)
+96
View File
@@ -0,0 +1,96 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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(),)
+16 -70
View File
@@ -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(),)
+101
View File
@@ -0,0 +1,101 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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(),)
+26 -107
View File
@@ -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(),)
+161
View File
@@ -0,0 +1,161 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
),
)
+1 -120
View File
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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)
+2 -16
View File
@@ -15,31 +15,17 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
]
@@ -0,0 +1,89 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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(),)
+26
View File
@@ -0,0 +1,26 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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,)
+120 -40
View File
@@ -15,25 +15,32 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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."""
+95
View File
@@ -0,0 +1,95 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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 ''
+78 -73
View File
@@ -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.
+490 -187
View File
@@ -15,13 +15,12 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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())
+30 -3
View File
@@ -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',
+3 -2
View File
@@ -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'])
)
+4 -4
View File
@@ -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,
+34 -52
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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