mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-07 06:18:05 +03:00
Refactor plugin capability architecture
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),)
|
||||
|
||||
@@ -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(),)
|
||||
@@ -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(),)
|
||||
|
||||
@@ -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
@@ -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(),)
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),)
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'])
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user