From 8e1f3defafc7b15bbbd57f78561af29aa8f1149d Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 9 Aug 2026 14:35:29 +0800 Subject: [PATCH] Expand capability-based plugin architecture Signed-off-by: Loren Eteval --- Furious/Actions/Connection.py | 2 +- Furious/Actions/Import.py | 20 +- Furious/Backends/Configuration.py | 109 +-- Furious/Backends/Hysteria1/Editor.py | 2 +- Furious/Backends/Hysteria1/Plugin.py | 69 +- .../Hysteria1/ProtocolEditors.py} | 29 +- Furious/Backends/Hysteria1/Protocols.py | 33 +- Furious/Backends/Hysteria2/Editor.py | 2 +- Furious/Backends/Hysteria2/Plugin.py | 64 +- Furious/Backends/Hysteria2/ProtocolEditors.py | 40 + Furious/Backends/Hysteria2/Protocols.py | 33 +- Furious/Backends/Xray/Plugin.py | 88 +- Furious/Backends/Xray/ProtocolEditors.py | 70 ++ Furious/Backends/Xray/Protocols.py | 111 ++- Furious/Backends/__init__.py | 20 +- Furious/Domain/Configuration.py | 75 +- Furious/Domain/Encoding.py | 10 +- Furious/Domain/Profile.py | 286 +++++++ Furious/Domain/Protocol.py | 51 ++ Furious/Domain/__init__.py | 19 +- Furious/Extensions/StandardSubscriptions.py | 59 +- Furious/Externals/GenTranslation.py | 68 +- Furious/Frozenlib/Constants.py | 5 +- Furious/Frozenlib/Utility.py | 39 - Furious/Frozenlib/__init__.py | 8 +- Furious/Interface/Process.py | 16 +- Furious/Interface/Server.py | 75 -- Furious/Interface/__init__.py | 7 +- Furious/Plugins/API.py | 214 ++++- Furious/Plugins/Profile.py | 111 ++- Furious/Plugins/Registry.py | 809 ++++++++++++------ Furious/Plugins/__init__.py | 26 +- Furious/Qt/EditorWidgets.py | 12 +- Furious/Repository/Servers.py | 55 +- Furious/Repository/Storage.py | 5 +- Furious/Service/ConnectionManager.py | 19 +- Furious/Service/SubscriptionImporter.py | 97 +++ Furious/Service/__init__.py | 8 + Furious/Widget/ServerTableView.py | 170 ++-- Furious/Window/MainWindow.py | 74 +- Furious/Window/QRCodeWindow.py | 6 +- Furious/Window/TextEditorWindow.py | 3 +- Furious/__init__.py | 16 +- 43 files changed, 2060 insertions(+), 975 deletions(-) rename Furious/{Interface/Codec.py => Backends/Hysteria1/ProtocolEditors.py} (56%) create mode 100644 Furious/Backends/Hysteria2/ProtocolEditors.py create mode 100644 Furious/Backends/Xray/ProtocolEditors.py create mode 100644 Furious/Domain/Profile.py create mode 100644 Furious/Domain/Protocol.py delete mode 100644 Furious/Interface/Server.py create mode 100644 Furious/Service/SubscriptionImporter.py diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py index 373147f..6e3ebcd 100644 --- a/Furious/Actions/Connection.py +++ b/Furious/Actions/Connection.py @@ -227,7 +227,7 @@ class ConnectAction(AppQAction): self.setChecked(False) else: - assert isinstance(config, ConfigFactory) + assert isinstance(config, ServerProfile) @forceToLocalhostIfPossible() def getHttpProxy() -> str: diff --git a/Furious/Actions/Import.py b/Furious/Actions/Import.py index 59c0965..caf86f3 100644 --- a/Furious/Actions/Import.py +++ b/Furious/Actions/Import.py @@ -21,7 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Domain import * -from Furious.Plugins import configurationFromAny +from Furious.Plugins import profileFromAny from Furious.Repository import * from Furious.Qt import * from Furious.Qt import gettext as _ @@ -70,7 +70,7 @@ def showMBoxImportError(clipboard: str): def importURIFromClipboard(clipboard: str): """Import URI from clipboard.""" - factory = configurationFromAny(clipboard) + factory = profileFromAny(clipboard) if not factory.isValid(): showMBoxImportError(clipboard) @@ -78,7 +78,7 @@ def importURIFromClipboard(clipboard: str): APP().mainWindow.appendNewItemByFactory(factory) mbox = MBoxImportSuccess(icon=AppQMessageBox.Icon.Information) - mbox.remark = factory.getExtras('remark') + mbox.remark = factory.itemRemark mbox.setText(mbox.customText()) # Show the MessageBox asynchronously @@ -101,12 +101,12 @@ def importURIs(*uris, failureCallback: Union[Callable[[], None], None] = None): rowIndex = len(Storage.UserServers()) for uri in uris: - factory = configurationFromAny(uri.strip()) + factory = profileFromAny(uri.strip()) if factory.isValid(): APP().mainWindow.appendNewItemByFactory(factory) - imported.append(factory.getExtras('remark')) + imported.append(factory.itemRemark) if len(imported) == 0: if callable(failureCallback): @@ -245,10 +245,10 @@ class ImportURIsProgressDialog(AppQDialog): uri = self.uris[self.currentIndex] self.currentIndex += 1 - factory = configurationFromAny(uri.strip()) + factory = profileFromAny(uri.strip()) if factory.isValid(): - remark = factory.getExtras('remark') + remark = factory.itemRemark self.currentRemark = self.limitedRemark(remark) @@ -429,15 +429,13 @@ class ImportFromFileAction(AppQAction): # Show the MessageBox asynchronously mbox.open() else: - factory = configurationFromAny( - plainText, remark=os.path.basename(filename) - ) + factory = profileFromAny(plainText, remark=os.path.basename(filename)) if factory.isValid(): APP().mainWindow.appendNewItemByFactory(factory) mbox = MBoxImportSuccess(icon=AppQMessageBox.Icon.Information) - mbox.remark = factory.getExtras('remark') + mbox.remark = factory.itemRemark mbox.setText(mbox.customText()) # Show the MessageBox asynchronously diff --git a/Furious/Backends/Configuration.py b/Furious/Backends/Configuration.py index 6a2c8fe..17db349 100644 --- a/Furious/Backends/Configuration.py +++ b/Furious/Backends/Configuration.py @@ -19,10 +19,9 @@ from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * from Furious.Domain.Configuration import ConfigFactory from Furious.Domain.Encoding import * +from Furious.Domain.Protocol import Protocol from typing import Union, Tuple @@ -36,6 +35,15 @@ parse_qsl = functools.partial(urllib.parse.parse_qsl) urlparse = functools.partial(urllib.parse.urlparse) urlunparse = functools.partial(urllib.parse.urlunparse) +XRAY_PROXY_USER_EMAIL = 'user@Furious.GUI' + + +def _parseHostPort(address: str) -> Tuple[str | None, str | None]: + """Split a URI-style endpoint into normalized host and port strings.""" + result = urllib.parse.urlsplit(address if '//' in address else f'//{address}') + + return result.hostname, str(result.port) if result.port is not None else None + def queryStringFromItems(items): """Encode non-empty query items as a URI query string.""" @@ -165,7 +173,7 @@ class ConfigXrayProxyOutboundObjectSS(dict): 'port': int(port), 'method': method, 'password': password, - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, 'ota': False, }, ] @@ -248,7 +256,7 @@ class ConfigXrayProxyOutboundObjectTrojan(dict): 'address': address, 'port': int(port), 'password': password, - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, }, ] }, @@ -352,9 +360,9 @@ BLANK_CONFIG_XRAY = { class ConfigXray(ConfigFactory): """Represent Xray configuration and supported share-link formats.""" - def __init__(self, config: Union[str, dict] = '', **kwargs): + def __init__(self, config: Union[str, dict] = ''): """Initialize the ConfigXray.""" - super().__init__(config, **kwargs) + super().__init__(config) def coreName(self) -> str: """Return the core implementation name.""" @@ -1136,7 +1144,7 @@ class ConfigXray(ConfigFactory): UserObject = { 'id': uuid_, 'security': encryption, - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, } # For VMess(v2rayN share standard) only. @@ -1150,7 +1158,7 @@ class ConfigXray(ConfigFactory): UserObject = { 'id': uuid_, 'encryption': encryption, - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, } if kwargs.get('flow'): @@ -1223,7 +1231,7 @@ class ConfigXray(ConfigFactory): uuid_, server = result.netloc.split('@') - remote_host, remote_port = parseHostPort(server) + remote_host, remote_port = _parseHostPort(server) encryption = queryObject.pop('encryption', 'none') type_ = queryObject.pop('type', 'tcp') @@ -1266,7 +1274,7 @@ class ConfigXray(ConfigFactory): # in base64 encoding. Add padding to userinfo return [ *PyBase64Encoder.decode(userinfo + '===').decode().split(':', 1), - *parseHostPort(server), + *_parseHostPort(server), ] except Exception: # Any non-exit exceptions @@ -1277,7 +1285,7 @@ class ConfigXray(ConfigFactory): # Try pack with 4 element userinfo, server = result.netloc.split('@') - return [*userinfo.split(':', 1), *parseHostPort(server)] + return [*userinfo.split(':', 1), *_parseHostPort(server)] except Exception: # Any non-exit exceptions @@ -1289,7 +1297,7 @@ class ConfigXray(ConfigFactory): PyBase64Encoder.decode(result.netloc).decode().split('@') ) - return [*userinfo.split(':', 1), *parseHostPort(server)] + return [*userinfo.split(':', 1), *_parseHostPort(server)] except Exception: # Any non-exit exceptions @@ -1330,7 +1338,7 @@ class ConfigXray(ConfigFactory): password, server = result.netloc.split('@') - address, port = parseHostPort(server) + address, port = _parseHostPort(server) type_ = queryObject.pop('type', 'tcp') # For Trojan: Assign tls by default @@ -1394,18 +1402,6 @@ class ConfigXray(ConfigFactory): """Return the item TLS value.""" return self.proxyStreamSettingsTLS - @property - def itemLatency(self) -> str: - # Backward compatibility - """Return the item latency value.""" - return self.getExtras('delayResult') - - @property - def itemSpeed(self) -> str: - # Backward compatibility - """Return the item speed value.""" - return self.getExtras('speedResult') - def toJSONString(self, **kwargs) -> str: """Serialize the configuration as JSON text.""" indent = kwargs.pop('indent', 2) @@ -1414,10 +1410,7 @@ class ConfigXray(ConfigFactory): def toURI(self, remark: str = '') -> str: """Export the configuration as a share URI.""" - if remark == '': - override = self.itemRemark - else: - override = remark + override = remark protocol = self.proxyProtocol.casefold() @@ -1531,7 +1524,7 @@ class ConfigXray(ConfigFactory): def fromURI(self, URI: str) -> bool: """Populate the configuration from a share URI.""" try: - remark, proxyOutboundObject = ConfigXray.URI2ProxyOutboundObject(URI) + _remark, proxyOutboundObject = ConfigXray.URI2ProxyOutboundObject(URI) factory = copy.deepcopy(BLANK_CONFIG_XRAY) factory['outbounds'] = [ @@ -1557,8 +1550,6 @@ class ConfigXray(ConfigFactory): dict.__init__(self, **factory) - self.setExtras('remark', remark) - return True except Exception: # Any non-exit exceptions @@ -1615,7 +1606,7 @@ class ConfigXray(ConfigFactory): # None satisfied. Return success return True - listen, port = parseHostPort(endpoint) + listen, port = _parseHostPort(endpoint) for inbound in self['inbounds']: if inbound['protocol'] == 'http': @@ -1673,7 +1664,7 @@ class ConfigXray(ConfigFactory): # None satisfied. Return success return True - listen, port = parseHostPort(endpoint) + listen, port = _parseHostPort(endpoint) for inbound in self['inbounds']: if inbound['protocol'] == 'socks': @@ -1727,9 +1718,9 @@ BLANK_CONFIG_HYSTERIA1 = { class ConfigHysteria1(ConfigFactory): """Represent Hysteria 1 client configuration and share links.""" - def __init__(self, config: Union[str, dict] = '', **kwargs): + def __init__(self, config: Union[str, dict] = ''): """Initialize the ConfigHysteria1.""" - super().__init__(config, **kwargs) + super().__init__(config) def coreName(self) -> str: """Return the core implementation name.""" @@ -1774,16 +1765,6 @@ class ConfigHysteria1(ConfigFactory): """Return the item TLS value.""" return '' - @property - def itemLatency(self) -> str: - """Return the item latency value.""" - return self.getExtras('delayResult') - - @property - def itemSpeed(self) -> str: - """Return the item speed value.""" - return self.getExtras('speedResult') - def toJSONString(self, **kwargs) -> str: """Serialize the configuration as JSON text.""" indent = kwargs.pop('indent', 4) @@ -1792,10 +1773,7 @@ class ConfigHysteria1(ConfigFactory): def toURI(self, remark: str = '') -> str: """Export the configuration as a share URI.""" - if remark == '': - override = self.itemRemark - else: - override = remark + override = remark try: netloc, mport = self['server'].split(',') @@ -1860,7 +1838,6 @@ class ConfigHysteria1(ConfigFactory): """Populate the configuration from a share URI.""" try: result = urlparse(URI) - remark = unquote(result.fragment) queryObject = {key: value for key, value in parse_qsl(result.query)} if result.scheme != 'hysteria': @@ -1931,8 +1908,6 @@ class ConfigHysteria1(ConfigFactory): }, ) - self.setExtras('remark', remark) - return True except Exception: # Any non-exit exceptions @@ -2013,9 +1988,9 @@ BLANK_CONFIG_HYSTERIA2 = { class ConfigHysteria2(ConfigFactory): """Represent Hysteria 2 client configuration and share links.""" - def __init__(self, config: Union[str, dict] = '', **kwargs): + def __init__(self, config: Union[str, dict] = ''): """Initialize the ConfigHysteria2.""" - super().__init__(config, **kwargs) + super().__init__(config) def coreName(self) -> str: """Return the core implementation name.""" @@ -2070,16 +2045,6 @@ class ConfigHysteria2(ConfigFactory): """Return the item TLS value.""" return '' - @property - def itemLatency(self) -> str: - """Return the item latency value.""" - return self.getExtras('delayResult') - - @property - def itemSpeed(self) -> str: - """Return the item speed value.""" - return self.getExtras('speedResult') - def toJSONString(self, **kwargs) -> str: """Serialize the configuration as JSON text.""" indent = kwargs.pop('indent', 4) @@ -2102,10 +2067,7 @@ class ConfigHysteria2(ConfigFactory): def toURI(self, remark: str = '') -> str: """Export the configuration as a share URI.""" - if remark == '': - override = self.itemRemark - else: - override = remark + override = remark TLSArg, obfsArg = {}, {} @@ -2162,7 +2124,6 @@ class ConfigHysteria2(ConfigFactory): """Populate the configuration from a share URI.""" try: result = urlparse(URI) - remark = unquote(result.fragment) queryItems = parse_qsl(result.query) queryObject = {key: value for key, value in queryItems} @@ -2247,8 +2208,6 @@ class ConfigHysteria2(ConfigFactory): }, ) - self.setExtras('remark', remark) - return True except Exception: # Any non-exit exceptions @@ -2327,7 +2286,7 @@ def configXrayEmptyProxyOutboundObject(protocol) -> dict: 'port': 0, 'users': [ { - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, }, ], }, @@ -2352,7 +2311,7 @@ def configXrayEmptyProxyOutboundObject(protocol) -> dict: 'port': 0, 'method': '', 'password': '', - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, 'ota': False, } ] @@ -2391,7 +2350,7 @@ def configXrayEmptyProxyOutboundObject(protocol) -> dict: 'address': '', 'port': 0, 'password': '', - 'email': PROXY_OUTBOUND_USER_EMAIL, + 'email': XRAY_PROXY_USER_EMAIL, } ] }, diff --git a/Furious/Backends/Hysteria1/Editor.py b/Furious/Backends/Hysteria1/Editor.py index cc8fb0b..6a7184f 100644 --- a/Furious/Backends/Hysteria1/Editor.py +++ b/Furious/Backends/Hysteria1/Editor.py @@ -21,7 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Domain import ConfigFactory +from Furious.Domain import ConfigFactory, Protocol from Furious.Qt import * from Furious.Qt import gettext as _ diff --git a/Furious/Backends/Hysteria1/Plugin.py b/Furious/Backends/Hysteria1/Plugin.py index 91d3a0c..8d3903b 100644 --- a/Furious/Backends/Hysteria1/Plugin.py +++ b/Furious/Backends/Hysteria1/Plugin.py @@ -24,6 +24,7 @@ from Furious.Plugins.API import * from Furious.Backends.Configuration import * from .Process import * +from .ProtocolEditors import HYSTERIA1_PROTOCOL_EDITORS from .Protocols import HYSTERIA1_PROTOCOL_HANDLERS import logging @@ -33,12 +34,12 @@ __all__ = ['Hysteria1Plugin'] logger = logging.getLogger(__name__) -class Hysteria1Backend(CoreBackend): - """Run Hysteria 1 independently of profile parsing and editor creation.""" +class Hysteria1KernelFactory(KernelFactory): + """Construct Hysteria 1 kernels independently of protocol handling.""" - backendId = 'official.hysteria1' + factoryId = 'official.hysteria1' configurationTypes = (ConfigHysteria1,) - coreTypes = (Hysteria1,) + kernelTypes = (Hysteria1,) def routingOptions(self, config=None): """Return the routing modes supported by Hysteria 1.""" @@ -47,25 +48,21 @@ class Hysteria1Backend(CoreBackend): for routing in AppBuiltinRouting ) - def startCore( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Configure Hysteria 1 routing files and start its core.""" + def create(self, request: KernelRequest): + """Configure routing and create a Hysteria 1 kernel launch.""" + config, routing = ( + request.configuration, + request.routing, + ) + if routing == AppBuiltinRouting.BypassMainlandChina.value: - if not proxyModeOnly and SystemRuntime.isTUNMode(): + if not request.proxyModeOnly and SystemRuntime.isTUNMode(): # Defer Qt access until the application has finished importing it. from Furious.Qt.QtWidgets import showMBoxDirectRulesNotAllowed showMBoxDirectRulesNotAllowed() - return None, False + return None routingObject = { 'rule': DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl', @@ -79,20 +76,25 @@ class Hysteria1Backend(CoreBackend): else: routingObject = {'rule': '', 'mmdb': ''} - if log: + if request.log: logger.info(f'core {Hysteria1.name()} configured') logger.info(f'routing is {routing}') logger.info(f'RoutingObject: {routingObject}') - process = Hysteria1(exitCallback=exitCallback, msgCallback=msgCallback) - success = process.start( - config, - Hysteria1.rule(routingObject.get('rule', '')), - Hysteria1.mmdb(routingObject.get('mmdb', '')), - **kwargs, + process = Hysteria1( + exitCallback=request.exitCallback, + msgCallback=request.messageCallback, ) - return process, success + return KernelLaunch( + process, + config, + arguments=( + Hysteria1.rule(routingObject.get('rule', '')), + Hysteria1.mmdb(routingObject.get('mmdb', '')), + ), + options=request.options, + ) def prepareDownloadTest(self, config, port: int): """Create a Hysteria 1 configuration with one local HTTP proxy.""" @@ -121,10 +123,17 @@ class Hysteria1Backend(CoreBackend): class Hysteria1Plugin(FuriousPlugin): """Bundle official Hysteria 1 protocol and runtime capabilities.""" - pluginId = 'official.hysteria1' - displayName = 'Hysteria1' - protocolHandlers = HYSTERIA1_PROTOCOL_HANDLERS + metadata = PluginMetadata( + 'official.hysteria1', + 'Hysteria1', + description='Official Hysteria 1 protocol, editor, and runtime support.', + provider='Furious', + ) def __init__(self): - """Create an isolated Hysteria 1 backend instance for this plugin.""" - self.coreBackends = (Hysteria1Backend(),) + """Create an isolated Hysteria 1 runtime factory.""" + self.capabilities = ( + *HYSTERIA1_PROTOCOL_HANDLERS, + *HYSTERIA1_PROTOCOL_EDITORS, + Hysteria1KernelFactory(), + ) diff --git a/Furious/Interface/Codec.py b/Furious/Backends/Hysteria1/ProtocolEditors.py similarity index 56% rename from Furious/Interface/Codec.py rename to Furious/Backends/Hysteria1/ProtocolEditors.py index 488d42f..9594d60 100644 --- a/Furious/Interface/Codec.py +++ b/Furious/Backends/Hysteria1/ProtocolEditors.py @@ -15,25 +15,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the common data encoder interface.""" +"""Provide the lazily imported Hysteria 1 protocol editor.""" from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Any +from Furious.Plugins.API import ProtocolEditorProvider -__all__ = ['Codec'] +__all__ = ['HYSTERIA1_PROTOCOL_EDITORS'] -class Codec(ABC): - """Define the interface and shared behavior for encoder objects.""" +class Hysteria1ProtocolEditor(ProtocolEditorProvider): + """Create the Hysteria 1 editor on demand.""" - @abstractmethod - def encode(self, data: Any, **kwargs) -> Any: - """Encode data with the encoder factory.""" - raise NotImplementedError + editorId = 'official.hysteria1.editor' + protocolIds = ('hysteria1',) - @abstractmethod - def decode(self, data: Any, **kwargs) -> Any: - """Decode data with the encoder factory.""" - raise NotImplementedError + def createEditor(self, protocolId: str, parent=None, **kwargs): + """Create the Hysteria 1 editor.""" + from .Editor import Hysteria1Editor + + return Hysteria1Editor(parent=parent, **kwargs) + + +HYSTERIA1_PROTOCOL_EDITORS = (Hysteria1ProtocolEditor(),) diff --git a/Furious/Backends/Hysteria1/Protocols.py b/Furious/Backends/Hysteria1/Protocols.py index f703647..d530603 100644 --- a/Furious/Backends/Hysteria1/Protocols.py +++ b/Furious/Backends/Hysteria1/Protocols.py @@ -23,7 +23,13 @@ from Furious.Backends.Configuration import ( BLANK_CONFIG_HYSTERIA1, ConfigHysteria1, ) -from Furious.Plugins.API import ProtocolDescriptor, ProtocolHandler +from Furious.Plugins.API import ( + ProtocolDescriptor, + ProtocolHandler, + ProtocolParseResult, +) + +from urllib.parse import unquote, urlsplit import copy @@ -31,7 +37,7 @@ __all__ = ['HYSTERIA1_PROTOCOL_HANDLERS'] class Hysteria1ProtocolHandler(ProtocolHandler): - """Own Hysteria 1 URI, mapping, blank-profile, and editor behavior.""" + """Own Hysteria 1 URI, mapping, validation, and export behavior.""" descriptor = ProtocolDescriptor( 'hysteria1', @@ -39,6 +45,8 @@ class Hysteria1ProtocolHandler(ProtocolHandler): 'Add Hysteria1 Server...', 50, True, + {'type': 'object', 'required': ('server',)}, + True, ) schemes = ('hysteria',) @@ -48,9 +56,16 @@ class Hysteria1ProtocolHandler(ProtocolHandler): def parse(self, uri: str, **kwargs): """Parse a Hysteria 1 share URI.""" - factory = ConfigHysteria1(uri, **kwargs) + factory = ConfigHysteria1(uri) - return factory if factory.isValid() else None + return ( + ProtocolParseResult( + factory, + {'displayName': unquote(urlsplit(uri).fragment)}, + ) + if factory.isValid() + else None + ) def fromMapping(self, configuration, **kwargs): """Recognize a Hysteria 1 client configuration mapping.""" @@ -74,23 +89,17 @@ class Hysteria1ProtocolHandler(ProtocolHandler): if any(configuration.get(field) is not None for field in fields) or isinstance( configuration.get('obfs'), str ): - return ConfigHysteria1(configuration, **kwargs) + return ConfigHysteria1(configuration) return None def blank(self, **kwargs): """Create a blank Hysteria 1 client profile.""" - return ConfigHysteria1(copy.deepcopy(BLANK_CONFIG_HYSTERIA1), **kwargs) + return ConfigHysteria1(copy.deepcopy(BLANK_CONFIG_HYSTERIA1)) def export(self, configuration, remark: str = '') -> str: """Export a Hysteria 1 profile.""" return configuration.toURI(remark) if self.supports(configuration) else '' - def createEditor(self, parent=None, **kwargs): - """Create the Hysteria 1 editor on demand.""" - from .Editor import Hysteria1Editor - - return Hysteria1Editor(parent=parent, **kwargs) - HYSTERIA1_PROTOCOL_HANDLERS = (Hysteria1ProtocolHandler(),) diff --git a/Furious/Backends/Hysteria2/Editor.py b/Furious/Backends/Hysteria2/Editor.py index 2e654e0..4476504 100644 --- a/Furious/Backends/Hysteria2/Editor.py +++ b/Furious/Backends/Hysteria2/Editor.py @@ -21,7 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Domain import ConfigFactory +from Furious.Domain import ConfigFactory, Protocol from Furious.Qt import * from Furious.Qt import gettext as _ diff --git a/Furious/Backends/Hysteria2/Plugin.py b/Furious/Backends/Hysteria2/Plugin.py index ef0c84e..52f7628 100644 --- a/Furious/Backends/Hysteria2/Plugin.py +++ b/Furious/Backends/Hysteria2/Plugin.py @@ -24,6 +24,7 @@ from Furious.Plugins.API import * from Furious.Backends.Configuration import * from .Process import Hysteria2 +from .ProtocolEditors import HYSTERIA2_PROTOCOL_EDITORS from .Protocols import HYSTERIA2_PROTOCOL_HANDLERS from .TUN import * @@ -34,14 +35,13 @@ __all__ = ['Hysteria2Plugin'] logger = logging.getLogger(__name__) -class Hysteria2Backend(CoreBackend): - """Run Hysteria 2 independently of profile parsing and editor creation.""" +class Hysteria2ActionProvider(ActionProvider): + """Provide optional Hysteria 2 management UI independently of its runtime.""" - backendId = 'official.hysteria2' - configurationTypes = (ConfigHysteria2,) - coreTypes = (Hysteria2,) + providerId = 'official.hysteria2.management' + category = 'core' - def createManagementActions(self, parent=None, **kwargs): + def createActions(self, parent=None, **kwargs): """Create Hysteria 2 native TUN management actions.""" isCoreActive = kwargs.pop('isCoreActive', lambda coreType: False) @@ -77,6 +77,14 @@ class Hysteria2Backend(CoreBackend): ), ) + +class Hysteria2KernelFactory(KernelFactory): + """Construct Hysteria 2 kernels independently of protocol handling.""" + + factoryId = 'official.hysteria2' + configurationTypes = (ConfigHysteria2,) + kernelTypes = (Hysteria2,) + def prepareTUN(self, config) -> bool: """Add Hysteria 2 native TUN mode when enabled and safe to route.""" if not isHysteria2TUNEnabled(): @@ -119,23 +127,21 @@ class Hysteria2Backend(CoreBackend): return True - def startCore( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Start the Hysteria 2 core.""" - if log: + def create(self, request: KernelRequest): + """Create a prepared Hysteria 2 kernel launch.""" + if request.log: logger.info(f'core {Hysteria2.name()} configured') - process = Hysteria2(exitCallback=exitCallback, msgCallback=msgCallback) + process = Hysteria2( + exitCallback=request.exitCallback, + msgCallback=request.messageCallback, + ) - return process, process.start(config, **kwargs) + return KernelLaunch( + process, + request.configuration, + options=request.options, + ) def prepareDownloadTest(self, config, port: int): """Create a Hysteria 2 configuration with one local HTTP proxy.""" @@ -162,10 +168,18 @@ class Hysteria2Backend(CoreBackend): class Hysteria2Plugin(FuriousPlugin): """Bundle official Hysteria 2 protocol and runtime capabilities.""" - pluginId = 'official.hysteria2' - displayName = 'Hysteria2' - protocolHandlers = HYSTERIA2_PROTOCOL_HANDLERS + metadata = PluginMetadata( + 'official.hysteria2', + 'Hysteria2', + description='Official Hysteria 2 protocol, editor, and runtime support.', + provider='Furious', + ) def __init__(self): - """Create an isolated Hysteria 2 backend instance for this plugin.""" - self.coreBackends = (Hysteria2Backend(),) + """Create an isolated Hysteria 2 runtime factory.""" + self.capabilities = ( + *HYSTERIA2_PROTOCOL_HANDLERS, + *HYSTERIA2_PROTOCOL_EDITORS, + Hysteria2KernelFactory(), + Hysteria2ActionProvider(), + ) diff --git a/Furious/Backends/Hysteria2/ProtocolEditors.py b/Furious/Backends/Hysteria2/ProtocolEditors.py new file mode 100644 index 0000000..b5d6995 --- /dev/null +++ b/Furious/Backends/Hysteria2/ProtocolEditors.py @@ -0,0 +1,40 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide the lazily imported Hysteria 2 protocol editor.""" + +from __future__ import annotations + +from Furious.Plugins.API import ProtocolEditorProvider + +__all__ = ['HYSTERIA2_PROTOCOL_EDITORS'] + + +class Hysteria2ProtocolEditor(ProtocolEditorProvider): + """Create the Hysteria 2 editor on demand.""" + + editorId = 'official.hysteria2.editor' + protocolIds = ('hysteria2',) + + def createEditor(self, protocolId: str, parent=None, **kwargs): + """Create the Hysteria 2 editor.""" + from .Editor import Hysteria2Editor + + return Hysteria2Editor(parent=parent, **kwargs) + + +HYSTERIA2_PROTOCOL_EDITORS = (Hysteria2ProtocolEditor(),) diff --git a/Furious/Backends/Hysteria2/Protocols.py b/Furious/Backends/Hysteria2/Protocols.py index 5971ece..a878745 100644 --- a/Furious/Backends/Hysteria2/Protocols.py +++ b/Furious/Backends/Hysteria2/Protocols.py @@ -23,7 +23,13 @@ from Furious.Backends.Configuration import ( BLANK_CONFIG_HYSTERIA2, ConfigHysteria2, ) -from Furious.Plugins.API import ProtocolDescriptor, ProtocolHandler +from Furious.Plugins.API import ( + ProtocolDescriptor, + ProtocolHandler, + ProtocolParseResult, +) + +from urllib.parse import unquote, urlsplit import copy @@ -31,13 +37,15 @@ __all__ = ['HYSTERIA2_PROTOCOL_HANDLERS'] class Hysteria2ProtocolHandler(ProtocolHandler): - """Own Hysteria 2 URI, mapping, blank-profile, and editor behavior.""" + """Own Hysteria 2 URI, mapping, validation, and export behavior.""" descriptor = ProtocolDescriptor( 'hysteria2', 'Hysteria2', 'Add Hysteria2 Server...', 60, + configurationSchema={'type': 'object', 'required': ('server', 'auth')}, + translatable=True, ) schemes = ( 'hy2', @@ -52,9 +60,16 @@ class Hysteria2ProtocolHandler(ProtocolHandler): def parse(self, uri: str, **kwargs): """Parse a Hysteria 2 share URI, including Realm mode.""" - factory = ConfigHysteria2(uri, **kwargs) + factory = ConfigHysteria2(uri) - return factory if factory.isValid() else None + return ( + ProtocolParseResult( + factory, + {'displayName': unquote(urlsplit(uri).fragment)}, + ) + if factory.isValid() + else None + ) def fromMapping(self, configuration, **kwargs): """Recognize a Hysteria 2 client configuration mapping.""" @@ -79,23 +94,17 @@ class Hysteria2ProtocolHandler(ProtocolHandler): if any(configuration.get(field) is not None for field in fields) or isinstance( configuration.get('obfs'), dict ): - return ConfigHysteria2(configuration, **kwargs) + return ConfigHysteria2(configuration) return None def blank(self, **kwargs): """Create a blank Hysteria 2 client profile.""" - return ConfigHysteria2(copy.deepcopy(BLANK_CONFIG_HYSTERIA2), **kwargs) + return ConfigHysteria2(copy.deepcopy(BLANK_CONFIG_HYSTERIA2)) def export(self, configuration, remark: str = '') -> str: """Export a Hysteria 2 profile.""" return configuration.toURI(remark) if self.supports(configuration) else '' - def createEditor(self, parent=None, **kwargs): - """Create the Hysteria 2 editor on demand.""" - from .Editor import Hysteria2Editor - - return Hysteria2Editor(parent=parent, **kwargs) - HYSTERIA2_PROTOCOL_HANDLERS = (Hysteria2ProtocolHandler(),) diff --git a/Furious/Backends/Xray/Plugin.py b/Furious/Backends/Xray/Plugin.py index e73a442..093db80 100644 --- a/Furious/Backends/Xray/Plugin.py +++ b/Furious/Backends/Xray/Plugin.py @@ -26,6 +26,7 @@ from Furious.Plugins.API import * from Furious.Backends.Configuration import * from .Process import * +from .ProtocolEditors import XRAY_PROTOCOL_EDITORS from .Protocols import XRAY_PROTOCOL_HANDLERS from .Routing import * from .TUN import * @@ -72,24 +73,13 @@ def fixLogObjectPath(config, attr: str, value: str, log=True): ) -class XrayBackend(CoreBackend): - """Run Xray configurations independently of protocol codecs and editors.""" +class XrayActionProvider(ActionProvider): + """Provide optional Xray management UI independently of its runtime.""" - backendId = 'official.xray' - configurationTypes = (ConfigXray,) - coreTypes = (XrayCore,) + providerId = 'official.xray.management' + category = 'core' - 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(configuration, **kwargs) - - return None - - def createManagementActions(self, parent=None, **kwargs): + def createActions(self, parent=None, **kwargs): """Create Xray routing, TUN, and asset-management actions.""" isCoreActive = kwargs.pop('isCoreActive', lambda coreType: False) @@ -156,6 +146,24 @@ class XrayBackend(CoreBackend): ), ) + +class XrayKernelFactory(KernelFactory): + """Construct Xray kernels independently of protocols and editors.""" + + factoryId = 'official.xray' + configurationTypes = (ConfigXray,) + kernelTypes = (XrayCore,) + + 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(configuration) + + return None + def prepareTUN(self, config) -> bool: """Add the configured Xray native TUN inbound when enabled.""" if not isXrayTUNEnabled(): @@ -217,17 +225,15 @@ class XrayBackend(CoreBackend): """Point Xray-core at Furious's bundled geo-asset directory.""" os.environ['XRAY_LOCATION_ASSET'] = str(XRAY_ASSET_DIR) - def startCore( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Configure routing and start Xray-core.""" + def create(self, request: KernelRequest): + """Configure routing and create an Xray-core launch.""" + config, routing, proxyModeOnly, log = ( + request.configuration, + request.routing, + request.proxyModeOnly, + request.log, + ) + if config.get('log') is None or not isinstance(config['log'], dict): config['log'] = {'access': '', 'error': '', 'loglevel': 'warning'} @@ -247,7 +253,7 @@ class XrayBackend(CoreBackend): showMBoxDirectRulesNotAllowed() - return None, False + return None routingObject = { 'domainStrategy': 'IPIfNonMatch', @@ -290,9 +296,13 @@ class XrayBackend(CoreBackend): logger.info(f'RoutingObject: {routingObject}') config['routing'] = routingObject - process = XrayCore(exitCallback=exitCallback, msgCallback=msgCallback) - return process, process.start(config, **kwargs) + process = XrayCore( + exitCallback=request.exitCallback, + msgCallback=request.messageCallback, + ) + + return KernelLaunch(process, config, options=request.options) def prepareDownloadTest(self, config, port: int): """Create an Xray configuration with one local HTTP test inbound.""" @@ -366,10 +376,18 @@ class XrayBackend(CoreBackend): class XrayPlugin(FuriousPlugin): """Bundle official Xray protocol handlers and its runtime backend.""" - pluginId = 'official.xray' - displayName = 'Xray-core' - protocolHandlers = XRAY_PROTOCOL_HANDLERS + metadata = PluginMetadata( + 'official.xray', + 'Xray-core', + description='Official Xray protocol, editor, and runtime support.', + provider='Furious', + ) def __init__(self): - """Create an isolated Xray backend instance for this plugin.""" - self.coreBackends = (XrayBackend(),) + """Create an isolated Xray runtime factory for this plugin.""" + self.capabilities = ( + *XRAY_PROTOCOL_HANDLERS, + *XRAY_PROTOCOL_EDITORS, + XrayKernelFactory(), + XrayActionProvider(), + ) diff --git a/Furious/Backends/Xray/ProtocolEditors.py b/Furious/Backends/Xray/ProtocolEditors.py new file mode 100644 index 0000000..d73c16d --- /dev/null +++ b/Furious/Backends/Xray/ProtocolEditors.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide lazily imported Xray protocol editors.""" + +from __future__ import annotations + +from Furious.Plugins.API import ProtocolEditorProvider + +from importlib import import_module + +__all__ = ['XRAY_PROTOCOL_EDITORS'] + + +class XrayProtocolEditors(ProtocolEditorProvider): + """Create the editor registered for each supported Xray protocol.""" + + editorId = 'official.xray.editors' + protocolIds = ('vmess', 'vless', 'shadowsocks', 'trojan', 'socks') + _editors = { + 'vmess': ( + 'Furious.Backends.Xray.VmessEditor', + 'VmessEditor', + ), + 'vless': ( + 'Furious.Backends.Xray.VlessEditor', + 'VlessEditor', + ), + 'shadowsocks': ( + 'Furious.Backends.Xray.ShadowsocksEditor', + 'ShadowsocksEditor', + ), + 'trojan': ( + 'Furious.Backends.Xray.TrojanEditor', + 'TrojanEditor', + ), + 'socks': ( + 'Furious.Backends.Xray.SocksEditor', + 'SocksEditor', + ), + } + + def createEditor(self, protocolId: str, parent=None, **kwargs): + """Load and create the requested Xray editor on demand.""" + editor = self._editors.get(protocolId.casefold()) + + if editor is None: + return None + + moduleName, typeName = editor + editorType = getattr(import_module(moduleName), typeName) + + return editorType(parent=parent, **kwargs) + + +XRAY_PROTOCOL_EDITORS = (XrayProtocolEditors(),) diff --git a/Furious/Backends/Xray/Protocols.py b/Furious/Backends/Xray/Protocols.py index 1bbea8a..792f7f1 100644 --- a/Furious/Backends/Xray/Protocols.py +++ b/Furious/Backends/Xray/Protocols.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Contribute isolated Xray protocol profile and editor capabilities.""" +"""Contribute isolated Xray protocol parsing capabilities.""" from __future__ import annotations @@ -24,9 +24,11 @@ from Furious.Backends.Configuration import ( ConfigXray, configXrayEmptyProxyOutboundObject, ) -from Furious.Plugins.API import ProtocolDescriptor, ProtocolHandler - -from importlib import import_module +from Furious.Plugins.API import ( + ProtocolDescriptor, + ProtocolHandler, + ProtocolParseResult, +) import copy @@ -41,15 +43,11 @@ class XrayProtocolHandler(ProtocolHandler): 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: @@ -76,10 +74,11 @@ class XrayProtocolHandler(ProtocolHandler): config = copy.deepcopy(BLANK_CONFIG_XRAY) config['outbounds'][0] = proxyOutbound - factory = ConfigXray(config, **kwargs) - factory.setExtras('remark', remark) - return factory + return ProtocolParseResult( + ConfigXray(config), + {'displayName': remark}, + ) def fromMapping(self, configuration, **kwargs): """Recognize a full Xray mapping by its tagged proxy outbound.""" @@ -94,7 +93,7 @@ class XrayProtocolHandler(ProtocolHandler): and outbound.get('tag') == 'proxy' and str(outbound.get('protocol', '')).casefold() == self.protocolId ): - return ConfigXray(configuration, **kwargs) + return ConfigXray(configuration) return None @@ -103,34 +102,59 @@ class XrayProtocolHandler(ProtocolHandler): config = copy.deepcopy(BLANK_CONFIG_XRAY) config['outbounds'][0] = configXrayEmptyProxyOutboundObject(self.descriptor.id) - return ConfigXray(config, **kwargs) + return ConfigXray(config) 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) +def _placeholder(x): + return x +_ = _placeholder + +_TRANSLATABLE = ( + _('Add VMess Server...'), + _('Add VLESS Server...'), + _('Add Shadowsocks Server...'), + _('Add Trojan Server...'), + _('Add SOCKS Server...'), +) + XRAY_PROTOCOL_HANDLERS = ( XrayProtocolHandler( - ProtocolDescriptor('VMess', 'VMess', 'Add VMess Server...', 10), + ProtocolDescriptor( + 'VMess', + 'VMess', + 'Add VMess Server...', + 10, + configurationSchema={ + 'type': 'object', + 'required': ('outbounds',), + 'proxyProtocol': 'vmess', + }, + translatable=True, + ), ('vmess',), 'URI2ProxyOutboundObjectVMess', - 'Furious.Backends.Xray.VmessEditor', - 'VmessEditor', ), XrayProtocolHandler( - ProtocolDescriptor('VLESS', 'VLESS', 'Add VLESS Server...', 20), + ProtocolDescriptor( + 'VLESS', + 'VLESS', + 'Add VLESS Server...', + 20, + configurationSchema={ + 'type': 'object', + 'required': ('outbounds',), + 'proxyProtocol': 'vless', + }, + translatable=True, + ), ('vless',), 'URI2ProxyOutboundObjectVLESS', - 'Furious.Backends.Xray.VlessEditor', - 'VlessEditor', ), XrayProtocolHandler( ProtocolDescriptor( @@ -138,24 +162,47 @@ XRAY_PROTOCOL_HANDLERS = ( 'Shadowsocks', 'Add Shadowsocks Server...', 30, + configurationSchema={ + 'type': 'object', + 'required': ('outbounds',), + 'proxyProtocol': 'shadowsocks', + }, + translatable=True, ), ('ss',), 'URI2ProxyOutboundObjectSS', - 'Furious.Backends.Xray.ShadowsocksEditor', - 'ShadowsocksEditor', ), XrayProtocolHandler( - ProtocolDescriptor('Trojan', 'Trojan', 'Add Trojan Server...', 40), + ProtocolDescriptor( + 'Trojan', + 'Trojan', + 'Add Trojan Server...', + 40, + configurationSchema={ + 'type': 'object', + 'required': ('outbounds',), + 'proxyProtocol': 'trojan', + }, + translatable=True, + ), ('trojan',), 'URI2ProxyOutboundObjectTrojan', - 'Furious.Backends.Xray.TrojanEditor', - 'TrojanEditor', ), XrayProtocolHandler( - ProtocolDescriptor('SOCKS', 'SOCKS', 'Add SOCKS Server...', 70, True), + ProtocolDescriptor( + 'SOCKS', + 'SOCKS', + 'Add SOCKS Server...', + 70, + True, + { + 'type': 'object', + 'required': ('outbounds',), + 'proxyProtocol': 'socks', + }, + True, + ), ('socks', 'socks5', 'socks5h'), 'URI2ProxyOutboundObjectSocks', - 'Furious.Backends.Xray.SocksEditor', - 'SocksEditor', ), ) diff --git a/Furious/Backends/__init__.py b/Furious/Backends/__init__.py index 7bc3fd3..14c4c85 100644 --- a/Furious/Backends/__init__.py +++ b/Furious/Backends/__init__.py @@ -15,15 +15,23 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose the official proxy backend implementations bundled with Furious.""" +"""Expose official backend plugin types without importing runtimes eagerly.""" from __future__ import annotations -from .Hysteria1.Plugin import Hysteria1Plugin -from .Hysteria2.Plugin import Hysteria2Plugin -from .Xray.Plugin import XrayPlugin - __all__ = ['OFFICIAL_PLUGIN_TYPES'] -OFFICIAL_PLUGIN_TYPES = (XrayPlugin, Hysteria1Plugin, Hysteria2Plugin) +def __getattr__(name: str): + """Load official plugin types only when the application requests them.""" + if name != 'OFFICIAL_PLUGIN_TYPES': + raise AttributeError(name) + + from .Hysteria1.Plugin import Hysteria1Plugin + from .Hysteria2.Plugin import Hysteria2Plugin + from .Xray.Plugin import XrayPlugin + + pluginTypes = (XrayPlugin, Hysteria1Plugin, Hysteria2Plugin) + globals()[name] = pluginTypes + + return pluginTypes diff --git a/Furious/Domain/Configuration.py b/Furious/Domain/Configuration.py index d13dbcd..5a1fb20 100644 --- a/Furious/Domain/Configuration.py +++ b/Furious/Domain/Configuration.py @@ -15,12 +15,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the core-neutral configuration profile model.""" +"""Define the core-neutral connection-document model.""" from __future__ import annotations -from Furious.Interface.Server import ServerTableItem - from typing import Union import copy @@ -32,7 +30,7 @@ __all__ = [ ] -class ConfigFactory(ServerTableItem, dict): +class ConfigFactory(dict): """ ConfigurationFactory is how Furious sees the core config. @@ -41,7 +39,7 @@ class ConfigFactory(ServerTableItem, dict): 2. string -- from URI or (valid) JSON string """ - def __init__(self, config: Union[str, dict] = '', **kwargs): + def __init__(self, config: Union[str, dict] = ''): """ Constructs a ConfigurationFactory. The constructor never throws exception @@ -49,12 +47,6 @@ class ConfigFactory(ServerTableItem, dict): :param config: The input configuration. Can be a string or dict """ - self._index = kwargs.pop('index', 0) - self._deleted = kwargs.pop('deleted', False) - - # Extra attributes - self.kwargs = kwargs - self._init_dispatch(config) @functools.singledispatchmethod @@ -110,58 +102,6 @@ class ConfigFactory(ServerTableItem, dict): """Return whether valid.""" return bool(self) - def getExtras(self, item): - """Return non-core metadata associated with the configuration.""" - return self.kwargs.get(item, '') - - def setExtras(self, item, value): - """Store non-core metadata associated with the configuration.""" - self.kwargs[item] = value - - @property - def index(self) -> int: - """Return the index value.""" - return self._index - - @index.setter - def index(self, value: int): - """Set the index value.""" - assert isinstance(value, int) - - self._index = value - - @property - def deleted(self) -> bool: - """Return the deleted value.""" - return self._deleted - - @deleted.setter - def deleted(self, value: bool): - """Set the deleted value.""" - assert isinstance(value, bool) - - self._deleted = value - - @property - def itemRemark(self) -> str: - """Return the item remark value.""" - return self.getExtras('remark') - - @property - def itemSubscription(self) -> str: - """Return the persisted subscription identifier.""" - return self.getExtras('subsId') or '' - - @property - def itemLatency(self) -> str: - """Return the item latency value.""" - return self.getExtras('delayResult') - - @property - def itemSpeed(self) -> str: - """Return the item speed value.""" - return self.getExtras('speedResult') - def toJSONString(self, **kwargs) -> str: """ Converts self to a JSON string @@ -188,15 +128,6 @@ class ConfigFactory(ServerTableItem, dict): # '' is invalid return '' - def toStorageObject(self) -> dict: - """Build the persisted representation of the configuration.""" - if self.kwargs.get('remark') is None: - # compatibility: remark field is mandatory in previous application version - self.kwargs['remark'] = '' - - # self.toJSONString() is used to maintain backward compatibility - return {'config': self.toJSONString(), **self.kwargs} - def toURI(self, remark: str = '') -> str: """ Converts self to a URI string diff --git a/Furious/Domain/Encoding.py b/Furious/Domain/Encoding.py index 971e5c3..5a84e59 100644 --- a/Furious/Domain/Encoding.py +++ b/Furious/Domain/Encoding.py @@ -19,8 +19,6 @@ from __future__ import annotations -from Furious.Interface import * - from typing import Any, AnyStr import json @@ -36,7 +34,7 @@ __all__ = [ ] -class JSONEncoder(Codec): +class JSONEncoder: """Encode and decode data using JSON.""" @staticmethod @@ -52,7 +50,7 @@ class JSONEncoder(Codec): return json.loads(data, **kwargs) -class UJSONEncoder(Codec): +class UJSONEncoder: """Encode and decode data using ujson.""" @staticmethod @@ -74,7 +72,7 @@ class UJSONEncoder(Codec): return ujson.loads(data, **kwargs) -class Base64Encoder(Codec): +class Base64Encoder: """Encode and decode data using base64.""" @staticmethod @@ -90,7 +88,7 @@ class Base64Encoder(Codec): return base64.b64decode(data, validate=validate, **kwargs) -class PyBase64Encoder(Codec): +class PyBase64Encoder: """Encode and decode data using py base64.""" @staticmethod diff --git a/Furious/Domain/Profile.py b/Furious/Domain/Profile.py new file mode 100644 index 0000000..08923c5 --- /dev/null +++ b/Furious/Domain/Profile.py @@ -0,0 +1,286 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Define server profiles as metadata composed with connection documents.""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping, MutableMapping +from dataclasses import dataclass, field +from typing import Any + +import copy + +from .Configuration import ConfigFactory + +__all__ = [ + 'ProfileMetadata', + 'ServerProfile', + 'connectionOf', + 'ensureProfile', +] + + +@dataclass +class ProfileMetadata: + """Store user and subscription metadata outside a connection document.""" + + displayName: str = '' + group: str = '' + tags: tuple[str, ...] = tuple() + subscriptionSource: str = '' + updatedAt: str = '' + annotations: str = '' + favorite: bool = False + latency: str = '' + speed: str = '' + extras: dict[str, Any] = field(default_factory=dict) + + @classmethod + def fromMapping(cls, value: Mapping[str, Any] | None = None, **kwargs): + """Construct metadata from current or legacy persisted field names.""" + data = dict(value or {}) + data.update(kwargs) + nestedExtras = data.pop('extras', {}) + tags = data.pop('tags', tuple()) or tuple() + + if isinstance(tags, str): + tags = tuple(value.strip() for value in tags.split(',') if value.strip()) + else: + tags = tuple(tags) + + favorite = data.pop('favorite', False) + + if isinstance(favorite, str): + favorite = favorite.strip().casefold() in ('1', 'true', 'yes', 'on') + + known = { + 'displayName': data.pop('displayName', data.pop('remark', '')), + 'group': data.pop('group', ''), + 'tags': tags, + 'subscriptionSource': data.pop( + 'subscriptionSource', data.pop('subsId', '') + ), + 'updatedAt': data.pop('updatedAt', ''), + 'annotations': data.pop('annotations', ''), + 'favorite': bool(favorite), + 'latency': data.pop('latency', data.pop('delayResult', '')), + 'speed': data.pop('speed', data.pop('speedResult', '')), + } + extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {} + extras.update(data) + + return cls(**known, extras=extras) + + def toMapping(self) -> dict[str, Any]: + """Return the normalized persisted metadata mapping.""" + return { + 'displayName': self.displayName, + 'group': self.group, + 'tags': list(self.tags), + 'subscriptionSource': self.subscriptionSource, + 'updatedAt': self.updatedAt, + 'annotations': self.annotations, + 'favorite': self.favorite, + 'latency': self.latency, + 'speed': self.speed, + 'extras': dict(self.extras), + } + + def set(self, name: str, value): + """Set a current or legacy metadata field.""" + aliases = { + 'remark': 'displayName', + 'subsId': 'subscriptionSource', + 'delayResult': 'latency', + 'speedResult': 'speed', + } + attribute = aliases.get(name, name) + + if attribute == 'tags': + self.tags = ( + tuple(item.strip() for item in value.split(',') if item.strip()) + if isinstance(value, str) + else tuple(value or tuple()) + ) + elif attribute == 'favorite' and isinstance(value, str): + self.favorite = value.strip().casefold() in ('1', 'true', 'yes', 'on') + elif attribute in self.__dataclass_fields__ and attribute != 'extras': + setattr(self, attribute, value) + else: + self.extras[name] = value + + +@dataclass +class ServerProfile(MutableMapping[str, Any]): + """Compose profile metadata with a core-neutral connection document.""" + + connection: ConfigFactory + metadata: ProfileMetadata = field(default_factory=ProfileMetadata) + index: int = 0 + deleted: bool = False + + @classmethod + def fromConfiguration( + cls, + configuration: ConfigFactory, + metadata: ProfileMetadata | Mapping[str, Any] | None = None, + *, + index: int = 0, + deleted: bool = False, + ): + """Move transient parser metadata into a separate profile object.""" + if isinstance(configuration, ServerProfile): + return configuration + + if not isinstance(configuration, ConfigFactory): + raise TypeError('profile connection must be a ConfigFactory') + + if isinstance(metadata, ProfileMetadata): + profileMetadata = copy.deepcopy(metadata) + else: + profileMetadata = ProfileMetadata.fromMapping(metadata) + + connection = configuration.deepcopy() + + return cls(connection, profileMetadata, index, deleted) + + def __getitem__(self, key: str): + """Return a connection-document value.""" + return self.connection[key] + + def __setitem__(self, key: str, value): + """Set a connection-document value.""" + self.connection[key] = value + + def __delitem__(self, key: str): + """Delete a connection-document value.""" + del self.connection[key] + + def __iter__(self) -> Iterator[str]: + """Iterate over connection-document keys.""" + return iter(self.connection) + + def __len__(self) -> int: + """Return the connection-document size.""" + return len(self.connection) + + def deepcopy(self): + """Return an independent profile copy.""" + return copy.deepcopy(self) + + def replaceConnection(self, connection: ConfigFactory): + """Return this profile's metadata composed with a new connection.""" + return ServerProfile.fromConfiguration( + connection, + self.metadata, + index=self.index, + deleted=self.deleted, + ) + + def coreName(self) -> str: + """Return the runtime implementation name.""" + return self.connection.coreName() + + def isValid(self) -> bool: + """Return whether the connection document is valid.""" + return self.connection.isValid() + + def toJSONString(self, **kwargs) -> str: + """Serialize only the connection document as JSON.""" + return self.connection.toJSONString(**kwargs) + + def toURI(self, remark: str = '') -> str: + """Serialize the connection document as a share URI.""" + return self.connection.toURI(remark or self.metadata.displayName) + + def httpProxy(self) -> str: + """Return the connection's HTTP proxy endpoint.""" + return self.connection.httpProxy() + + def socksProxy(self) -> str: + """Return the connection's SOCKS proxy endpoint.""" + return self.connection.socksProxy() + + def setHttpProxy(self, endpoint: str) -> bool: + """Set the connection's HTTP proxy endpoint.""" + return self.connection.setHttpProxy(endpoint) + + def setSocksProxy(self, endpoint: str) -> bool: + """Set the connection's SOCKS proxy endpoint.""" + return self.connection.setSocksProxy(endpoint) + + @property + def itemRemark(self) -> str: + """Return the profile display name.""" + return self.metadata.displayName + + @property + def itemProtocol(self) -> str: + """Return the connection protocol display value.""" + return str(getattr(self.connection, 'itemProtocol', '')) + + @property + def itemAddress(self) -> str: + """Return the connection address display value.""" + return str(getattr(self.connection, 'itemAddress', '')) + + @property + def itemPort(self) -> str: + """Return the connection port display value.""" + return str(getattr(self.connection, 'itemPort', '')) + + @property + def itemTransport(self) -> str: + """Return the connection transport display value.""" + return str(getattr(self.connection, 'itemTransport', '')) + + @property + def itemTLS(self) -> str: + """Return the connection TLS display value.""" + return str(getattr(self.connection, 'itemTLS', '')) + + @property + def itemSubscription(self) -> str: + """Return the subscription source identifier.""" + return self.metadata.subscriptionSource + + @property + def itemLatency(self) -> str: + """Return the last latency result.""" + return self.metadata.latency + + @property + def itemSpeed(self) -> str: + """Return the last speed result.""" + return self.metadata.speed + + +def connectionOf(value): + """Return a server profile's connection document or *value* itself.""" + return value.connection if isinstance(value, ServerProfile) else value + + +def ensureProfile(value, **metadata) -> ServerProfile: + """Return *value* as a profile, merging optional metadata fields.""" + if isinstance(value, ServerProfile): + for name, item in metadata.items(): + value.metadata.set(name, item) + + return value + + return ServerProfile.fromConfiguration(value, metadata) diff --git a/Furious/Domain/Protocol.py b/Furious/Domain/Protocol.py new file mode 100644 index 0000000..0e7708a --- /dev/null +++ b/Furious/Domain/Protocol.py @@ -0,0 +1,51 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Define core-neutral proxy protocol identifiers.""" + +from __future__ import annotations + +from enum import Enum + +__all__ = ['Protocol'] + + +class Protocol(Enum): + """Enumerate protocol names used by built-in configuration models.""" + + Unknown = 'Unknown' + VMess = 'VMess' + VLESS = 'VLESS' + Shadowsocks = 'Shadowsocks' + Socks = 'SOCKS' + Trojan = 'Trojan' + Hysteria1 = 'hysteria1' + Hysteria2 = 'hysteria2' + + @staticmethod + def toEnum(protocol: str): + """Return the built-in identifier matching *protocol*.""" + if not isinstance(protocol, str): + return Protocol.Unknown + + normalized = protocol.casefold() + + for value in Protocol: + if value is not Protocol.Unknown and value.value.casefold() == normalized: + return value + + return Protocol.Unknown diff --git a/Furious/Domain/__init__.py b/Furious/Domain/__init__.py index d3c587c..30a734e 100644 --- a/Furious/Domain/__init__.py +++ b/Furious/Domain/__init__.py @@ -20,12 +20,29 @@ from __future__ import annotations from .Configuration import ConfigFactory -from .Encoding import Base64Encoder, JSONEncoder, PyBase64Encoder, UJSONEncoder +from .Encoding import ( + Base64Encoder, + JSONEncoder, + PyBase64Encoder, + UJSONEncoder, +) +from .Profile import ( + ProfileMetadata, + ServerProfile, + connectionOf, + ensureProfile, +) +from .Protocol import Protocol __all__ = [ 'Base64Encoder', 'ConfigFactory', 'JSONEncoder', + 'ProfileMetadata', 'PyBase64Encoder', + 'Protocol', + 'ServerProfile', 'UJSONEncoder', + 'connectionOf', + 'ensureProfile', ] diff --git a/Furious/Extensions/StandardSubscriptions.py b/Furious/Extensions/StandardSubscriptions.py index e10902c..e7ff0ae 100644 --- a/Furious/Extensions/StandardSubscriptions.py +++ b/Furious/Extensions/StandardSubscriptions.py @@ -15,12 +15,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Decode the plain-text and base64 share-link subscription formats.""" +"""Decode standard subscription representations independently.""" from __future__ import annotations from Furious.Plugins.API import ( FuriousPlugin, + PluginMetadata, SubscriptionDecoder, SubscriptionItem, SubscriptionResult, @@ -46,31 +47,49 @@ def _shareLinks(text: str): return lines -class ShareLinkSubscriptionDecoder(SubscriptionDecoder): - """Decode standard newline-delimited plain or base64 share links.""" +class PlainShareLinkDecoder(SubscriptionDecoder): + """Decode newline-delimited plain-text share links.""" - decoderId = 'share-links' - displayName = 'Share Links (plain text or base64)' + decoderId = 'plain-share-links' + displayName = 'Share Links (plain text)' priority = 100 def decode(self, data: bytes): - """Decode a validated plain-text or base64 share-link payload.""" + """Decode a validated plain-text share-link payload.""" try: text = data.decode('utf-8-sig') except UnicodeDecodeError: - text = '' + return None links = _shareLinks(text) - if links is None: - compact = b''.join(data.split()) - compact += b'=' * (-len(compact) % 4) + return ( + SubscriptionResult( + self.decoderId, + tuple(SubscriptionItem(uri=link) for link in links), + ) + if links is not None + else None + ) - try: - decoded = base64.b64decode(compact, altchars=b'-_', validate=True) - links = _shareLinks(decoded.decode('utf-8-sig')) - except (binascii.Error, UnicodeDecodeError, ValueError): - return None + +class Base64ShareLinkDecoder(SubscriptionDecoder): + """Decode a Base64 envelope containing plain share links.""" + + decoderId = 'base64-share-links' + displayName = 'Share Links (Base64)' + priority = 90 + + def decode(self, data: bytes): + """Decode Base64 bytes before validating contained share links.""" + 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 @@ -84,6 +103,10 @@ class ShareLinkSubscriptionDecoder(SubscriptionDecoder): class StandardSubscriptionPlugin(FuriousPlugin): """Contribute Furious's built-in share-link subscription decoder.""" - pluginId = 'official.standard-subscriptions' - displayName = 'Standard Subscriptions' - subscriptionDecoders = (ShareLinkSubscriptionDecoder(),) + metadata = PluginMetadata( + 'official.standard-subscriptions', + 'Standard Subscriptions', + description='Plain-text and Base64 share-link subscription decoders.', + provider='Furious', + ) + capabilities = (PlainShareLinkDecoder(), Base64ShareLinkDecoder()) diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 816d8ae..9fe43fd 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -1,8 +1,8 @@ TRANSLATION = { "Delete": { "source": [ - "Furious.Backends.Xray.RoutingWindow", "Furious.Backends.Xray.AssetListWidget", + "Furious.Backends.Xray.RoutingWindow", "Furious.Qt.QtWidgets", "Furious.Widget.ServerTableView", "Furious.Widget.SubscriptionTableView", @@ -102,8 +102,8 @@ TRANSLATION = { }, "Import": { "source": [ - "Furious.Backends.Xray.AssetListWidget", - "Furious.Actions.Import" + "Furious.Actions.Import", + "Furious.Backends.Xray.AssetListWidget" ], "RU": "Импорт", "ZH": "导入", @@ -135,8 +135,8 @@ TRANSLATION = { }, "Import From File...": { "source": [ - "Furious.Backends.Xray.AssetWindow", - "Furious.Actions.Import" + "Furious.Actions.Import", + "Furious.Backends.Xray.AssetWindow" ], "RU": "Импорт из файла...", "ZH": "从文件导入...", @@ -144,8 +144,8 @@ TRANSLATION = { }, "Import File": { "source": [ - "Furious.Backends.Xray.AssetWindow", - "Furious.Actions.Import" + "Furious.Actions.Import", + "Furious.Backends.Xray.AssetWindow" ], "RU": "Импорт файла", "ZH": "导入文件", @@ -218,15 +218,15 @@ TRANSLATION = { }, "Cancel": { "source": [ + "Furious.Actions.Import", "Furious.Backends.Xray.RoutingWindow", "Furious.Qt.EditorWidgets", - "Furious.Actions.Import", + "Furious.Widget.ServerTableView", + "Furious.Window.IndentDialog", "Furious.Window.NetworkTestDialog", "Furious.Window.ProxyBypassDialog", - "Furious.Window.IndentDialog", - "Furious.Widget.ServerTableView", - "Furious.Window.TextEditorWindow", - "Furious.Window.SubscriptionWindow" + "Furious.Window.SubscriptionWindow", + "Furious.Window.TextEditorWindow" ], "RU": "Отмена", "ZH": "取消", @@ -440,12 +440,12 @@ TRANSLATION = { "source": [ "Furious.Backends.Hysteria1.Editor", "Furious.Backends.Hysteria2.Editor", + "Furious.Backends.Xray.RoutingWindow", "Furious.Backends.Xray.ShadowsocksEditor", "Furious.Backends.Xray.SocksEditor", "Furious.Backends.Xray.TrojanEditor", "Furious.Backends.Xray.VlessEditor", "Furious.Backends.Xray.VmessEditor", - "Furious.Backends.Xray.RoutingWindow", "Furious.Widget.ServerTableView", "Furious.Widget.SubscriptionTableView" ], @@ -712,8 +712,8 @@ TRANSLATION = { }, "Exit": { "source": [ - "Furious.Backends.Xray.AssetWindow", "Furious.Actions.Exit", + "Furious.Backends.Xray.AssetWindow", "Furious.Window.LogWindow" ], "RU": "Выход", @@ -983,8 +983,8 @@ TRANSLATION = { }, "Unable to connect": { "source": [ - "Furious.Qt.QtWidgets", - "Furious.Actions.Connection" + "Furious.Actions.Connection", + "Furious.Qt.QtWidgets" ], "RU": "Не удается подключиться", "ZH": "无法连接", @@ -1397,9 +1397,9 @@ TRANSLATION = { "Furious.__main__", "Furious.Backends.Xray.RoutingWindow", "Furious.Qt.EditorWidgets", + "Furious.Window.IndentDialog", "Furious.Window.NetworkTestDialog", "Furious.Window.ProxyBypassDialog", - "Furious.Window.IndentDialog", "Furious.Window.SubscriptionWindow" ], "RU": "OK", @@ -2157,7 +2157,7 @@ TRANSLATION = { }, "Add VMess Server...": { "source": [ - "Furious.Backends.Xray.Plugin" + "Furious.Backends.Xray.Protocols" ], "RU": "Добавить сервер VMess...", "ZH": "添加VMess服务器...", @@ -2165,7 +2165,7 @@ TRANSLATION = { }, "Add VLESS Server...": { "source": [ - "Furious.Backends.Xray.Plugin" + "Furious.Backends.Xray.Protocols" ], "RU": "Добавить сервер VLESS...", "ZH": "添加VLESS服务器...", @@ -2173,7 +2173,7 @@ TRANSLATION = { }, "Add Shadowsocks Server...": { "source": [ - "Furious.Backends.Xray.Plugin" + "Furious.Backends.Xray.Protocols" ], "RU": "Добавить сервер Shadowsocks...", "ZH": "添加Shadowsocks服务器...", @@ -2181,31 +2181,15 @@ TRANSLATION = { }, "Add Trojan Server...": { "source": [ - "Furious.Backends.Xray.Plugin" + "Furious.Backends.Xray.Protocols" ], "RU": "Добавить сервер Trojan...", "ZH": "添加Trojan服务器...", "isReviewed": "True" }, - "Add Hysteria1 Server...": { - "source": [ - "Furious.Backends.Hysteria1.Plugin" - ], - "RU": "Добавить сервер Hysteria 1...", - "ZH": "添加Hysteria1服务器...", - "isReviewed": "True" - }, - "Add Hysteria2 Server...": { - "source": [ - "Furious.Backends.Hysteria2.Plugin" - ], - "RU": "Добавить сервер Hysteria 2...", - "ZH": "添加Hysteria2服务器...", - "isReviewed": "True" - }, "Add SOCKS Server...": { "source": [ - "Furious.Backends.Xray.Plugin" + "Furious.Backends.Xray.Protocols" ], "RU": "Добавить сервер SOCKS...", "ZH": "添加SOCKS服务器...", @@ -2309,5 +2293,13 @@ TRANSLATION = { "RU": "Настроить параметры TUN Hysteria2...", "ZH": "自定义Hysteria2 TUN设置...", "isReviewed": "True" + }, + "Extensions": { + "source": [ + "Furious.Window.MainWindow" + ], + "RU": "Расширения", + "ZH": "扩展功能", + "isReviewed": "True" } } diff --git a/Furious/Frozenlib/Constants.py b/Furious/Frozenlib/Constants.py index 65bef82..5d126f7 100644 --- a/Furious/Frozenlib/Constants.py +++ b/Furious/Frozenlib/Constants.py @@ -39,7 +39,9 @@ __all__ = [ 'APPLICATION_NAME', 'APPLICATION_REPO_NAME', 'APPLICATION_REPO_OWNER_NAME', + 'APPLICATION_TUN2SOCKS_DEVICE_NAME', 'APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS', + 'APPLICATION_TUN2SOCKS_IP_ADDRESS', 'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS', 'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME', 'APPLICATION_VERSION', @@ -59,7 +61,6 @@ __all__ = [ 'PLATFORM_MACHINE', 'PLATFORM_PYTHON_VERSION', 'PLATFORM_RELEASE', - 'PROXY_OUTBOUND_USER_EMAIL', 'PROXY_SERVER_BYPASS', 'PYSIDE6_VERSION', 'ROOT_DIR', @@ -107,8 +108,6 @@ SYSTEM_LANGUAGE = QtCore.QLocale().name()[:2].upper() GOLDEN_RATIO = (math.sqrt(5) + 1) / 2 -PROXY_OUTBOUND_USER_EMAIL = f'user@{ORGANIZATION_DOMAIN}' - ROOT_DIR = pathlib.Path(__file__).resolve().parent.parent.parent PACKAGE_DIR = ROOT_DIR / APPLICATION_NAME DATA_DIR = PACKAGE_DIR / 'Data' diff --git a/Furious/Frozenlib/Utility.py b/Furious/Frozenlib/Utility.py index 3b37ed5..4e4a0cb 100644 --- a/Furious/Frozenlib/Utility.py +++ b/Furious/Frozenlib/Utility.py @@ -22,7 +22,6 @@ from __future__ import annotations from Furious.Frozenlib.Constants import * from Furious.Frozenlib.AppSettings import * -from enum import Enum from typing import AnyStr, Tuple import os @@ -35,7 +34,6 @@ import subprocess import urllib.parse __all__ = [ - 'Protocol', 'callRateLimited', 'forceToLocalhostIfPossible', 'callOnceOnly', @@ -48,43 +46,6 @@ __all__ = [ ] -class Protocol(Enum): - """Enumerate proxy protocols recognized by Furious.""" - - Unknown = 'Unknown' - VMess = 'VMess' - VLESS = 'VLESS' - Shadowsocks = 'Shadowsocks' - Socks = 'SOCKS' - Trojan = 'Trojan' - Hysteria1 = 'hysteria1' - Hysteria2 = 'hysteria2' - - @staticmethod - @functools.lru_cache(None) - def toEnum(protocol: str): - """Return the to enum value used by the protocol.""" - if not isinstance(protocol, str): - return Protocol.Unknown - - if protocol.casefold() == 'vmess': - return Protocol.VMess - - if protocol.casefold() == 'vless': - return Protocol.VLESS - - if protocol.casefold() == 'shadowsocks': - return Protocol.Shadowsocks - - if protocol.casefold() == 'socks': - return Protocol.Socks - - if protocol.casefold() == 'trojan': - return Protocol.Trojan - - return Protocol.Unknown - - def callRateLimited(maxCallPerSecond): """ Decorator function that limits the rate at which a function can be called. diff --git a/Furious/Frozenlib/__init__.py b/Furious/Frozenlib/__init__.py index 9092e83..0087721 100644 --- a/Furious/Frozenlib/__init__.py +++ b/Furious/Frozenlib/__init__.py @@ -32,7 +32,9 @@ from .Constants import ( APPLICATION_NAME, APPLICATION_REPO_NAME, APPLICATION_REPO_OWNER_NAME, + APPLICATION_TUN2SOCKS_DEVICE_NAME, APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS, + APPLICATION_TUN2SOCKS_IP_ADDRESS, APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS, APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME, APPLICATION_VERSION, @@ -52,7 +54,6 @@ from .Constants import ( PLATFORM_MACHINE, PLATFORM_PYTHON_VERSION, PLATFORM_RELEASE, - PROXY_OUTBOUND_USER_EMAIL, PROXY_SERVER_BYPASS, PYSIDE6_VERSION, ROOT_DIR, @@ -75,7 +76,6 @@ from .SystemRoutingTable import SystemRoutingTable from .SystemRuntime import SystemRuntime from .Tcping import tcping from .Utility import ( - Protocol, absolutePath, callOnceOnly, callRateLimited, @@ -100,7 +100,9 @@ __all__ = [ 'APPLICATION_NAME', 'APPLICATION_REPO_NAME', 'APPLICATION_REPO_OWNER_NAME', + 'APPLICATION_TUN2SOCKS_DEVICE_NAME', 'APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS', + 'APPLICATION_TUN2SOCKS_IP_ADDRESS', 'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS', 'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME', 'APPLICATION_VERSION', @@ -130,10 +132,8 @@ __all__ = [ 'PLATFORM_MACHINE', 'PLATFORM_PYTHON_VERSION', 'PLATFORM_RELEASE', - 'PROXY_OUTBOUND_USER_EMAIL', 'PROXY_SERVER_BYPASS', 'PYSIDE6_VERSION', - 'Protocol', 'PySide6Legacy', 'ROOT_DIR', 'SYSTEM_LANGUAGE', diff --git a/Furious/Interface/Process.py b/Furious/Interface/Process.py index 6afe7d2..cf799c8 100644 --- a/Furious/Interface/Process.py +++ b/Furious/Interface/Process.py @@ -28,11 +28,11 @@ from typing import Callable, Union import ujson import functools -__all__ = ['CoreProcess'] +__all__ = ['CoreProcess', 'RuntimeKernel'] -class CoreProcess(ABC): - """Define the interface and shared behavior for core process objects.""" +class RuntimeKernel(ABC): + """Define the lifecycle shared by plugin-created runtime kernels.""" class ExitCode(Enum): """Enumerate process exit codes.""" @@ -45,7 +45,7 @@ class CoreProcess(ABC): def __init__( self, - exitCallback: Union[Callable[[CoreProcess, int], None], None] = None, + exitCallback: Union[Callable[[RuntimeKernel, int], None], None] = None, ): """Initialize the core process.""" super().__init__() @@ -104,10 +104,14 @@ class CoreProcess(ABC): @abstractmethod def start(self, *args, **kwargs) -> bool: - """Start the core process factory.""" + """Start the runtime kernel.""" raise NotImplementedError @abstractmethod def stop(self): - """Stop the core process factory.""" + """Stop the runtime kernel.""" raise NotImplementedError + + +# Compatibility name retained for existing process implementations. +CoreProcess = RuntimeKernel diff --git a/Furious/Interface/Server.py b/Furious/Interface/Server.py deleted file mode 100644 index 3d93c75..0000000 --- a/Furious/Interface/Server.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Define display fields for server-table rows.""" - -from __future__ import annotations - -__all__ = ['ServerTableItem'] - - -class ServerTableItem: - """Define the display fields required by a server-table row.""" - - def __init__(self, *args, **kwargs): - """Initialize the server table item.""" - super().__init__(*args, **kwargs) - - @property - def itemRemark(self) -> str: - """Return the item remark value.""" - return '' - - @property - def itemProtocol(self) -> str: - """Return the item protocol value.""" - return '' - - @property - def itemAddress(self) -> str: - """Return the item address value.""" - return '' - - @property - def itemPort(self) -> str: - """Return the item port value.""" - return '' - - @property - def itemTransport(self) -> str: - """Return the item transport value.""" - return '' - - @property - def itemTLS(self) -> str: - """Return the item TLS value.""" - return '' - - @property - def itemSubscription(self) -> str: - """Return the item subscription value.""" - return '' - - @property - def itemLatency(self) -> str: - """Return the item latency value.""" - return '' - - @property - def itemSpeed(self) -> str: - """Return the item speed value.""" - return '' diff --git a/Furious/Interface/__init__.py b/Furious/Interface/__init__.py index c79525e..d80f04d 100644 --- a/Furious/Interface/__init__.py +++ b/Furious/Interface/__init__.py @@ -20,18 +20,15 @@ from __future__ import annotations from .Application import ApplicationRunner -from .Codec import Codec from .Editor import EditorBinding, EditorWidgetBinding -from .Process import CoreProcess -from .Server import ServerTableItem +from .Process import CoreProcess, RuntimeKernel from .Storage import StorageBackend __all__ = [ 'ApplicationRunner', - 'Codec', 'CoreProcess', 'EditorBinding', 'EditorWidgetBinding', - 'ServerTableItem', 'StorageBackend', + 'RuntimeKernel', ] diff --git a/Furious/Plugins/API.py b/Furious/Plugins/API.py index 3bb2c6a..eb91609 100644 --- a/Furious/Plugins/API.py +++ b/Furious/Plugins/API.py @@ -15,27 +15,86 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the capability contracts implemented by Furious plugins.""" +"""Define capability contracts implemented by Furious plugins.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from enum import Enum from typing import Any, Mapping, Optional, Tuple __all__ = [ 'PLUGIN_API_VERSION', - 'CoreBackend', + 'ActionProvider', + 'CapabilityKind', 'FuriousPlugin', + 'KernelFactory', + 'KernelLaunch', + 'KernelRequest', + 'PluginCapability', 'PluginContext', + 'PluginMetadata', 'ProtocolDescriptor', + 'ProtocolEditorProvider', 'ProtocolHandler', + 'ProtocolParseResult', 'RoutingOption', 'SubscriptionDecoder', 'SubscriptionItem', 'SubscriptionResult', ] -PLUGIN_API_VERSION = 2 +PLUGIN_API_VERSION = 3 + + +class CapabilityKind(str, Enum): + """Identify independently discoverable plugin extension points.""" + + ActionProvider = 'action-provider' + Protocol = 'protocol' + ProtocolEditor = 'protocol-editor' + SubscriptionDecoder = 'subscription-decoder' + KernelFactory = 'kernel-factory' + Utility = 'utility' + + +@dataclass(frozen=True) +class PluginMetadata: + """Describe a plugin independently from the capabilities it provides.""" + + id: str + displayName: str + version: str = '1' + description: str = '' + provider: str = '' + + +class PluginCapability: + """Define one independently queryable plugin capability.""" + + capabilityKind = CapabilityKind.Utility + + @property + def capabilityId(self) -> str: + """Return the identifier unique within this capability kind.""" + return '' + + +class ActionProvider(PluginCapability): + """Create optional host UI actions without implying a runtime capability.""" + + capabilityKind = CapabilityKind.ActionProvider + providerId = '' + category = 'plugin' + + @property + def capabilityId(self) -> str: + """Return the action-provider identifier.""" + return self.providerId + + def createActions(self, parent=None, **kwargs): + """Return actions contributed to the plugin management UI.""" + return tuple() @dataclass(frozen=True) @@ -47,6 +106,21 @@ class ProtocolDescriptor: addActionText: str menuOrder: int = 0 separatorBefore: bool = False + configurationSchema: Mapping[str, Any] = field(default_factory=dict) + translatable: bool = False + + +@dataclass(frozen=True) +class ProtocolParseResult: + """Return a connection document and its URI-derived profile metadata.""" + + configuration: Any + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self): + """Validate the metadata boundary exposed by a protocol parser.""" + if not isinstance(self.metadata, Mapping): + raise TypeError('protocol parse metadata must be a mapping') @dataclass(frozen=True) @@ -65,6 +139,7 @@ class PluginContext: pluginId: str registry: Any + metadata: PluginMetadata @dataclass(frozen=True) @@ -74,6 +149,7 @@ class SubscriptionItem: uri: Optional[str] = None configuration: Optional[Mapping[str, Any]] = None name: str = '' + metadata: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self): """Require exactly one serialized or normalized profile value.""" @@ -82,6 +158,9 @@ class SubscriptionItem: 'a subscription item must contain exactly one URI or configuration' ) + if not isinstance(self.metadata, Mapping): + raise TypeError('subscription item metadata must be a mapping') + @dataclass(frozen=True) class SubscriptionResult: @@ -91,18 +170,24 @@ class SubscriptionResult: items: Tuple[SubscriptionItem, ...] -class ProtocolHandler: - """Own one protocol's profile conversion and editor capability.""" +class ProtocolHandler(PluginCapability): + """Own one protocol's validation and serialization behavior.""" + capabilityKind = CapabilityKind.Protocol descriptor = ProtocolDescriptor('', '', '') schemes = tuple() + @property + def capabilityId(self) -> str: + """Return the protocol identifier.""" + return self.descriptor.id + 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 a `ProtocolParseResult` or ``None`` for *uri*.""" return None def fromMapping(self, configuration: Mapping[str, Any], **kwargs): @@ -117,38 +202,101 @@ class ProtocolHandler: """Serialize one owned configuration to a share URI.""" return '' - def createEditor(self, parent=None, **kwargs): - """Create this protocol's editor, if it provides one.""" + def validate(self, configuration) -> Tuple[str, ...]: + """Return validation errors for one owned configuration.""" + if not self.supports(configuration): + return ('Unsupported protocol',) + + validator = getattr(configuration, 'isValid', None) + + return tuple() if not callable(validator) or validator() else ('Invalid data',) + + +class ProtocolEditorProvider(PluginCapability): + """Create Qt editors for one or more protocol identifiers.""" + + capabilityKind = CapabilityKind.ProtocolEditor + editorId = '' + protocolIds = tuple() + + @property + def capabilityId(self) -> str: + """Return the editor-provider identifier.""" + return self.editorId + + def createEditor(self, protocolId: str, parent=None, **kwargs): + """Create an editor for *protocolId* or return ``None``.""" return None -class SubscriptionDecoder: +class SubscriptionDecoder(PluginCapability): """Decode one subscription representation without importing profiles.""" + capabilityKind = CapabilityKind.SubscriptionDecoder decoderId = '' displayName = '' priority = 0 + @property + def capabilityId(self) -> str: + """Return the subscription decoder identifier.""" + return self.decoderId + 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.""" +@dataclass(frozen=True) +class KernelRequest: + """Describe one runtime-kernel construction request.""" - backendId = '' + configuration: Any + routing: str + exitCallback: Any = None + messageCallback: Any = None + proxyModeOnly: bool = False + log: bool = True + options: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class KernelLaunch: + """Bind a constructed kernel to its prepared start arguments.""" + + kernel: Any + configuration: Any + arguments: Tuple[Any, ...] = tuple() + options: Mapping[str, Any] = field(default_factory=dict) + + def start(self) -> bool: + """Start the prepared kernel.""" + return bool( + self.kernel.start( + self.configuration, + *self.arguments, + **dict(self.options), + ) + ) + + +class KernelFactory(PluginCapability): + """Construct runtime kernels independently from protocol handling.""" + + capabilityKind = CapabilityKind.KernelFactory + factoryId = '' configurationTypes = tuple() - coreTypes = tuple() + kernelTypes = tuple() + + @property + def capabilityId(self) -> str: + """Return the runtime factory identifier.""" + return self.factoryId def fromMapping(self, configuration: Mapping[str, Any], **kwargs): """Recognize a full backend configuration not owned by one protocol.""" return None - def createManagementActions(self, parent=None, **kwargs): - """Return optional actions for this backend's management submenu.""" - return tuple() - def prepareTUN(self, config) -> bool: """Prepare native TUN and return whether the backend handles it.""" return False @@ -160,18 +308,9 @@ class CoreBackend: def configureEnvironment(self): """Set optional environment required by this backend's process.""" - def startCore( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Start the backend and return ``(process, success)``.""" - return None, False + def create(self, request: KernelRequest) -> Optional[KernelLaunch]: + """Create a prepared runtime kernel launch.""" + return None def prepareDownloadTest(self, config, port: int): """Return a proxy-only configuration for a download-speed test.""" @@ -197,11 +336,16 @@ class FuriousPlugin: """Group independently discoverable Furious capabilities.""" apiVersion = PLUGIN_API_VERSION - pluginId = '' - displayName = '' - protocolHandlers = tuple() - coreBackends = tuple() - subscriptionDecoders = tuple() + metadata = PluginMetadata('', '') + capabilities = tuple() + + def pluginMetadata(self) -> PluginMetadata: + """Return this plugin's declarative metadata.""" + return self.metadata + + def declaredCapabilities(self) -> Tuple[PluginCapability, ...]: + """Return the independently discoverable capabilities of this plugin.""" + return tuple(self.capabilities) def initialize(self, context: PluginContext): """Initialize the plugin after all of its capabilities are registered.""" diff --git a/Furious/Plugins/Profile.py b/Furious/Plugins/Profile.py index b0e38eb..f837496 100644 --- a/Furious/Plugins/Profile.py +++ b/Furious/Plugins/Profile.py @@ -19,7 +19,7 @@ from __future__ import annotations -from Furious.Domain.Configuration import ConfigFactory +from Furious.Domain import ConfigFactory, ServerProfile, ensureProfile from typing import Mapping, Union @@ -33,18 +33,23 @@ __all__ = [ 'configurationFromAny', 'configurationFromMapping', 'exportConfiguration', + 'blankProfile', + 'profileFromAny', + 'profileFromMapping', ] 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) +def configurationFromMapping(config: Mapping, registry=None, **kwargs) -> ConfigFactory: + """Construct a connection document from a normalized mapping.""" + if not isinstance(config, Mapping): + return ConfigFactory() try: - factory = getPluginRegistry().configFromDict(config, **kwargs) + factory = (registry or getPluginRegistry()).configFromDict( + dict(config), **kwargs + ) except Exception as ex: # Any non-exit exceptions @@ -52,44 +57,104 @@ def configurationFromMapping(config: Mapping, **kwargs) -> ConfigFactory: factory = None - return factory if factory is not None else ConfigFactory(config, **kwargs) + return factory if factory is not None else ConfigFactory(dict(config)) -def configurationFromAny(config: Union[str, Mapping], **kwargs) -> ConfigFactory: - """Construct a profile from a share URI, JSON text, or mapping.""" +def configurationFromAny( + config: Union[str, Mapping, ConfigFactory, ServerProfile], registry=None, **kwargs +) -> ConfigFactory: + """Construct a connection document from supported input data.""" + if isinstance(config, ServerProfile): + return config.connection.deepcopy() + + if isinstance(config, ConfigFactory): + return config.deepcopy() + if isinstance(config, str): - factory = getPluginRegistry().configFromString(config, **kwargs) + registry = registry or getPluginRegistry() + result = registry.parseURI(config, **kwargs) - if factory is not None: - return factory + if result is not None: + return result.configuration try: - return configurationFromMapping(ujson.loads(config), **kwargs) + return configurationFromMapping( + ujson.loads(config), registry=registry, **kwargs + ) except Exception: # Any non-exit exceptions - return ConfigFactory(**kwargs) + return ConfigFactory() - if isinstance(config, dict): - return configurationFromMapping(config, **kwargs) + if isinstance(config, Mapping): + return configurationFromMapping(config, registry=registry, **kwargs) - return ConfigFactory(**kwargs) + return ConfigFactory() -def blankConfiguration(protocol, **kwargs) -> ConfigFactory: - """Create a blank profile through an exact protocol capability.""" - factory = getPluginRegistry().blankConfig(protocol, **kwargs) +def blankConfiguration(protocol, registry=None, **kwargs) -> ConfigFactory: + """Create a blank connection through an exact protocol capability.""" + factory = (registry or getPluginRegistry()).blankConfig(protocol, **kwargs) - return factory if factory is not None else ConfigFactory(**kwargs) + return factory if factory is not None else ConfigFactory() -def exportConfiguration(config, remark: str = '') -> str: +def exportConfiguration(config, remark: str = '', registry=None) -> str: """Export a profile through its owning protocol capability.""" try: - return getPluginRegistry().exportConfig(config, remark) + return (registry or getPluginRegistry()).exportConfig(config, remark) except Exception as ex: # Any non-exit exceptions logger.error(f'failed to export configuration: {ex}') return '' + + +def profileFromMapping(config: Mapping, registry=None, **metadata) -> ServerProfile: + """Construct a metadata-separated profile from a connection mapping.""" + return ensureProfile( + configurationFromMapping(config, registry=registry), + **metadata, + ) + + +def profileFromAny( + config: Union[str, Mapping, ConfigFactory, ServerProfile], + registry=None, + **metadata, +) -> ServerProfile: + """Construct a metadata-separated profile from supported input data.""" + if isinstance(config, ServerProfile): + profile = config.deepcopy() + + for name, value in metadata.items(): + profile.metadata.set(name, value) + + return profile + + if isinstance(config, str): + registry = registry or getPluginRegistry() + result = registry.parseURI(config) + + if result is not None: + parsedMetadata = dict(result.metadata) + parsedMetadata.update(metadata) + + return ServerProfile.fromConfiguration( + result.configuration, + parsedMetadata, + ) + + return ensureProfile( + configurationFromAny(config, registry=registry), + **metadata, + ) + + +def blankProfile(protocol, registry=None, **metadata) -> ServerProfile: + """Create a metadata-separated blank profile for *protocol*.""" + return ensureProfile( + blankConfiguration(protocol, registry=registry), + **metadata, + ) diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index 0c72ce1..94401d7 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -15,11 +15,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Discover plugins and index their independently usable capabilities.""" +"""Discover plugins and index independently usable capabilities.""" from __future__ import annotations +from collections.abc import Mapping from importlib import metadata +from typing import Optional from urllib.parse import urlsplit import logging @@ -36,6 +38,7 @@ __all__ = [ ] PLUGIN_ENTRY_POINT_GROUP = 'furious.plugins' +SUPPORTED_PLUGIN_API_VERSIONS = (PLUGIN_API_VERSION,) logger = logging.getLogger(__name__) @@ -58,187 +61,272 @@ def _schemeFromURI(uri: str) -> str: return '' +def _connectionOf(value): + """Return a profile's connection document or *value* itself.""" + return getattr(value, 'connection', value) + + class PluginRegistry: - """Own plugin lifecycle and dispatch through indexed capabilities.""" + """Own plugin lifecycle and dispatch through capability indexes.""" def __init__(self): """Initialize an empty capability registry.""" self._plugins = {} + self._metadata = {} + self._capabilities = {kind: {} for kind in CapabilityKind} + self._capabilityEntries = [] self._protocols = {} self._schemes = {} self._protocolEntries = [] - self._backends = {} - self._configurationBackends = {} - self._coreBackends = {} + self._editors = {} + self._protocolEditors = {} + self._factories = {} + self._configurationFactories = {} + self._kernelFactories = {} self._decoders = {} self._initializedPlugins = [] self._closed = False + @staticmethod + def _kind(capability) -> CapabilityKind: + """Return a validated capability kind.""" + try: + return CapabilityKind(capability.capabilityKind) + except Exception as ex: + raise TypeError('capability has an invalid capability kind') from ex + + @staticmethod + def _id(capability) -> str: + """Return a normalized non-empty capability identifier.""" + identifier = _normalizeIdentifier(capability.capabilityId) + + if not identifier: + raise ValueError('capability ID cannot be empty') + + return identifier + def _validatePlugin(self, plugin): - """Validate *plugin* and return its normalized capability metadata.""" + """Validate *plugin* and return normalized registration data.""" if isinstance(plugin, type) and issubclass(plugin, FuriousPlugin): plugin = plugin() if not isinstance(plugin, FuriousPlugin): raise TypeError('plugin must be a FuriousPlugin instance') - if plugin.apiVersion != PLUGIN_API_VERSION: + if plugin.apiVersion not in SUPPORTED_PLUGIN_API_VERSIONS: raise ValueError( f'plugin API {plugin.apiVersion!r} is not supported; ' - f'expected {PLUGIN_API_VERSION}' + f'expected one of {SUPPORTED_PLUGIN_API_VERSIONS!r}' ) - pluginId = str(plugin.pluginId).strip() + pluginMetadata = plugin.pluginMetadata() + + if not isinstance(pluginMetadata, PluginMetadata): + raise TypeError('plugin metadata must be a PluginMetadata value') + + pluginId = _normalizeIdentifier(pluginMetadata.id) if not pluginId: raise ValueError('plugin ID cannot be empty') - if pluginId in self._plugins: - raise ValueError(f'plugin {pluginId!r} is already registered') + if not str(pluginMetadata.displayName).strip(): + raise ValueError('plugin display name cannot be empty') - protocols = [] + for fieldName in ('version', 'description', 'provider'): + if not isinstance(getattr(pluginMetadata, fieldName), str): + raise TypeError(f'plugin metadata {fieldName} must be a string') + + if pluginId in self._plugins: + raise ValueError(f'plugin {pluginMetadata.id!r} is already registered') + + capabilities = tuple(plugin.declaredCapabilities()) + localCapabilityIds = set() localProtocolIds = set() localSchemes = set() - - for handler in plugin.protocolHandlers: - if not isinstance(handler, ProtocolHandler): - raise TypeError( - 'plugin protocolHandlers must contain ProtocolHandler values' - ) - - descriptor = handler.descriptor - - if not isinstance(descriptor, ProtocolDescriptor): - raise TypeError( - 'protocol handlers must expose a ProtocolDescriptor value' - ) - - 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') - - schemes = tuple(_normalizeScheme(scheme) for scheme in handler.schemes) - - if any(not scheme for scheme in schemes): - raise ValueError(f'protocol {descriptor.id!r} has an empty URI scheme') - - for scheme in schemes: - if scheme in self._schemes or scheme in localSchemes: - raise ValueError(f'URI scheme {scheme!r} is already registered') - - localProtocolIds.add(protocolId) - localSchemes.update(schemes) - protocols.append((protocolId, schemes, handler)) - - backends = [] - localBackendIds = set() + localEditorProtocols = set() localConfigurationTypes = [] - localCoreTypes = [] + localKernelTypes = [] + entries = [] - for backend in plugin.coreBackends: - if not isinstance(backend, CoreBackend): - raise TypeError('plugin coreBackends must contain CoreBackend values') + for capability in capabilities: + if not isinstance(capability, PluginCapability): + raise TypeError( + 'plugin capabilities must contain PluginCapability values' + ) - backendId = _normalizeIdentifier(backend.backendId) + kind = self._kind(capability) + capabilityId = self._id(capability) + key = (kind, capabilityId) - if not backendId: - raise ValueError('backend ID cannot be empty') + if capabilityId in self._capabilities[kind] or key in localCapabilityIds: + raise ValueError( + f'{kind.value} capability {capability.capabilityId!r} ' + f'is already registered' + ) - if backendId in self._backends or backendId in localBackendIds: - raise ValueError(f'backend {backend.backendId!r} is already registered') + localCapabilityIds.add(key) - configurationTypes = tuple(backend.configurationTypes) - coreTypes = tuple(backend.coreTypes) + if isinstance(capability, ProtocolHandler): + descriptor = capability.descriptor - 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 not isinstance(descriptor, ProtocolDescriptor): + raise TypeError( + 'protocol handlers must expose a ProtocolDescriptor value' + ) - if any( - issubclass(itemType, registeredType) - or issubclass(registeredType, itemType) - for registeredType in (*existing, *local) + protocolId = _normalizeIdentifier(descriptor.id) + + if not protocolId: + raise ValueError('protocol ID cannot be empty') + + if not isinstance(descriptor.displayName, str): + raise TypeError('protocol display name must be a string') + + if not isinstance(descriptor.addActionText, str): + raise TypeError('protocol add-action text must be a string') + + if not isinstance(descriptor.configurationSchema, Mapping): + raise TypeError('protocol configuration schema must be a mapping') + + if not isinstance(descriptor.translatable, bool): + raise TypeError('protocol translatable flag must be a boolean') + + if protocolId in self._protocols or protocolId in localProtocolIds: + raise ValueError( + f'protocol {descriptor.id!r} is already registered' + ) + + schemes = tuple( + _normalizeScheme(scheme) for scheme in capability.schemes + ) + + if any(not scheme for scheme in schemes): + raise ValueError( + f'protocol {descriptor.id!r} has an empty URI scheme' + ) + + for scheme in schemes: + if scheme in self._schemes or scheme in localSchemes: + raise ValueError(f'URI scheme {scheme!r} is already registered') + + localProtocolIds.add(protocolId) + localSchemes.update(schemes) + detail = (protocolId, schemes) + elif isinstance(capability, ProtocolEditorProvider): + protocolIds = tuple( + _normalizeIdentifier(value) for value in capability.protocolIds + ) + + if not protocolIds or any(not value for value in protocolIds): + raise ValueError( + 'protocol editor providers must declare protocol IDs' + ) + + for protocolId in protocolIds: + if ( + protocolId in self._protocolEditors + or protocolId in localEditorProtocols ): raise ValueError( - f'{label} type {itemType.__name__!r} overlaps a ' - f'registered type' + f'protocol {protocolId!r} already has an editor provider' ) - local.append(itemType) + localEditorProtocols.update(protocolIds) + detail = protocolIds + elif isinstance(capability, KernelFactory): + configurationTypes = tuple(capability.configurationTypes) + kernelTypes = tuple(capability.kernelTypes) - localBackendIds.add(backendId) - backends.append((backendId, backend)) + if not configurationTypes: + raise ValueError( + f'kernel factory {capability.factoryId!r} must declare ' + f'configuration types' + ) - decoders = [] - localDecoderIds = set() + for values, label, existing, local in ( + ( + configurationTypes, + 'configuration', + tuple(self._configurationFactories), + localConfigurationTypes, + ), + ( + kernelTypes, + 'kernel', + tuple(self._kernelFactories), + localKernelTypes, + ), + ): + for itemType in values: + if not isinstance(itemType, type): + raise TypeError( + f'kernel factory {label} types must be classes' + ) - for decoder in plugin.subscriptionDecoders: - if not isinstance(decoder, SubscriptionDecoder): - raise TypeError( - 'plugin subscriptionDecoders must contain SubscriptionDecoder values' - ) + 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' + ) - decoderId = _normalizeIdentifier(decoder.decoderId) + local.append(itemType) - if not decoderId: - raise ValueError('subscription decoder ID cannot be empty') + detail = (configurationTypes, kernelTypes) + elif isinstance(capability, SubscriptionDecoder): + if not isinstance(capability.priority, int): + raise TypeError('subscription decoder priority must be an integer') - if decoderId in self._decoders or decoderId in localDecoderIds: - raise ValueError( - f'subscription decoder {decoder.decoderId!r} is already registered' - ) + detail = None + else: + detail = None - if not isinstance(decoder.priority, int): - raise TypeError('subscription decoder priority must be an integer') + entries.append((kind, capabilityId, capability, detail)) - localDecoderIds.add(decoderId) - decoders.append((decoderId, decoder)) - - return plugin, pluginId, protocols, backends, decoders + return plugin, pluginId, pluginMetadata, tuple(entries) def register(self, plugin: FuriousPlugin): """Register, index, and initialize one plugin atomically.""" if self._closed: raise RuntimeError('plugin registry has already been shut down') - plugin, pluginId, protocols, backends, decoders = self._validatePlugin(plugin) - + plugin, pluginId, pluginMetadata, entries = self._validatePlugin(plugin) self._plugins[pluginId] = plugin + self._metadata[pluginId] = pluginMetadata - for protocolId, schemes, handler in protocols: - entry = (plugin, handler) - self._protocols[protocolId] = entry - self._protocolEntries.append(entry) + for kind, capabilityId, capability, detail in entries: + entry = (plugin, capability) + self._capabilities[kind][capabilityId] = entry + self._capabilityEntries.append((kind, entry)) - for scheme in schemes: - self._schemes[scheme] = entry + if isinstance(capability, ProtocolHandler): + protocolId, schemes = detail + self._protocols[protocolId] = entry + self._protocolEntries.append(entry) - for backendId, backend in backends: - self._backends[backendId] = (plugin, backend) + for scheme in schemes: + self._schemes[scheme] = entry + elif isinstance(capability, ProtocolEditorProvider): + self._editors[capabilityId] = entry - for configType in backend.configurationTypes: - self._configurationBackends[configType] = (plugin, backend) - for coreType in backend.coreTypes: - self._coreBackends[coreType] = (plugin, backend) + for protocolId in detail: + self._protocolEditors[protocolId] = entry + elif isinstance(capability, KernelFactory): + configurationTypes, kernelTypes = detail + self._factories[capabilityId] = entry - for decoderId, decoder in decoders: - self._decoders[decoderId] = (plugin, decoder) + for configType in configurationTypes: + self._configurationFactories[configType] = entry + for kernelType in kernelTypes: + self._kernelFactories[kernelType] = entry + elif isinstance(capability, SubscriptionDecoder): + self._decoders[capabilityId] = entry try: - plugin.initialize(PluginContext(pluginId, self)) + plugin.initialize(PluginContext(pluginId, self, pluginMetadata)) except Exception: try: plugin.shutdown() @@ -249,60 +337,100 @@ class PluginRegistry: raise self._initializedPlugins.append(plugin) - logger.info(f'registered plugin {pluginId!r}') + logger.info(f'registered plugin {pluginMetadata.id!r}') return plugin def _removePlugin(self, pluginId: str): """Remove a partially registered plugin after initialization failure.""" + pluginId = _normalizeIdentifier(pluginId) plugin = self._plugins.pop(pluginId, None) + self._metadata.pop(pluginId, None) if plugin is None: return + self._capabilityEntries = [ + item for item in self._capabilityEntries if item[1][0] is not plugin + ] + + for kind in CapabilityKind: + self._capabilities[kind] = { + key: entry + for key, entry in self._capabilities[kind].items() + if entry[0] is not plugin + } + 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 - } + + for name in ( + '_protocols', + '_schemes', + '_editors', + '_protocolEditors', + '_factories', + '_configurationFactories', + '_kernelFactories', + '_decoders', + ): + setattr( + self, + name, + { + key: entry + for key, entry in getattr(self, name).items() + if entry[0] is not plugin + }, + ) def plugins(self): """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 self._plugins.get(pluginId) + return self._plugins.get(_normalizeIdentifier(pluginId)) + + def metadataFor(self, plugin) -> Optional[PluginMetadata]: + """Return normalized metadata for a registered plugin.""" + if isinstance(plugin, FuriousPlugin): + plugin = plugin.pluginMetadata().id + + return self._metadata.get(_normalizeIdentifier(plugin)) + + def capabilities(self, kind=None, plugin=None): + """Return capabilities, optionally filtered by kind and plugin.""" + normalizedKind = CapabilityKind(kind) if kind is not None else None + + if plugin is not None and not isinstance(plugin, FuriousPlugin): + plugin = self.plugin(plugin) + + if plugin is None: + return tuple() + + return tuple( + capability + for entryKind, (owner, capability) in self._capabilityEntries + if (normalizedKind is None or entryKind == normalizedKind) + and (plugin is None or owner is plugin) + ) + + def capability(self, kind, capabilityId): + """Return one capability by kind and identifier.""" + entry = self._capabilities[CapabilityKind(kind)].get( + _normalizeIdentifier(capabilityId) + ) + + return entry[1] if entry is not None else None + + def pluginsWithCapability(self, kind): + """Return plugins contributing at least one capability of *kind*.""" + kind = CapabilityKind(kind) + owners = {id(owner) for owner, _capability in self._capabilities[kind].values()} + + return tuple(plugin for plugin in self.plugins() if id(plugin) in owners) def protocolDescriptors(self): """Return protocol descriptors in their requested menu order.""" @@ -312,19 +440,26 @@ class PluginRegistry: def protocolHandlers(self): """Return registered protocol handlers in registration order.""" - return tuple(handler for _plugin, handler in self._protocolEntries) + return self.capabilities(CapabilityKind.Protocol) - def coreBackends(self): - """Return registered core backends in registration order.""" - return tuple(backend for _plugin, backend in self._backends.values()) + def actionProviders(self): + """Return registered plugin action providers.""" + return self.capabilities(CapabilityKind.ActionProvider) + + def protocolEditors(self): + """Return registered protocol editor providers.""" + return self.capabilities(CapabilityKind.ProtocolEditor) + + def kernelFactories(self): + """Return registered runtime kernel factories.""" + return self.capabilities(CapabilityKind.KernelFactory) 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, + sorted( + self.capabilities(CapabilityKind.SubscriptionDecoder), + key=lambda decoder: decoder.priority, reverse=True, ) ) @@ -337,6 +472,7 @@ class PluginRegistry: def handlerForConfig(self, config): """Return the unique protocol handler that owns *config*.""" + config = _connectionOf(config) matches = [] for _plugin, handler in self._protocolEntries: @@ -355,52 +491,63 @@ class PluginRegistry: return matches[0] if matches else None + def editorForProtocol(self, protocol): + """Return the editor provider registered for *protocol*.""" + entry = self._protocolEditors.get(_normalizeIdentifier(protocol)) + + return entry[1] if entry is not None else None + + def factoryForConfig(self, config): + """Return the runtime factory whose configuration type matches *config*.""" + config = _connectionOf(config) + + for configType, (_plugin, factory) in self._configurationFactories.items(): + if isinstance(config, configType): + return factory + + return None + + def factoryForKernel(self, kernel): + """Return the runtime factory that owns *kernel*.""" + for kernelType, (_plugin, factory) in self._kernelFactories.items(): + if isinstance(kernel, kernelType): + return factory + + return None + def pluginForProtocol(self, protocol): """Return the plugin that contributes *protocol*.""" entry = self._protocols.get(_normalizeIdentifier(protocol)) return entry[0] if entry is not None else None - 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 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(): + """Return the plugin contributing the owning factory or protocol.""" + config = _connectionOf(config) + + for configType, (plugin, _factory) in self._configurationFactories.items(): if isinstance(config, configType): return plugin handler = self.handlerForConfig(config) - if handler is None: - return None + return ( + self.pluginForProtocol(handler.descriptor.id) + if handler is not None + else None + ) - return self.pluginForProtocol(handler.descriptor.id) - - def pluginForCore(self, core): - """Return the plugin that contributes the core's backend.""" - for coreType, (plugin, _backend) in self._coreBackends.items(): - if isinstance(core, coreType): + def pluginForKernel(self, kernel): + """Return the plugin that contributes a kernel's factory.""" + for kernelType, (plugin, _factory) in self._kernelFactories.items(): + if isinstance(kernel, kernelType): return plugin return None - def configFromString(self, config: str, **kwargs): - """Parse a URI through its directly indexed scheme handler.""" - entry = self._schemes.get(_schemeFromURI(config)) + def parseURI(self, uri: str, **kwargs): + """Parse a URI and keep connection data separate from profile metadata.""" + entry = self._schemes.get(_schemeFromURI(uri)) if entry is None: return None @@ -408,7 +555,7 @@ class PluginRegistry: _plugin, handler = entry try: - result = handler.parse(config, **kwargs) + result = handler.parse(uri, **kwargs) except Exception as ex: logger.error( f'failed to parse {handler.descriptor.id!r} configuration: {ex}' @@ -416,7 +563,28 @@ class PluginRegistry: return None - if result is not None and not handler.supports(result): + if result is None: + return None + + if not isinstance(result, ProtocolParseResult): + logger.error( + f'protocol handler {handler.descriptor.id!r} returned an ' + f'invalid parse result' + ) + + return None + + try: + owned = handler.supports(result.configuration) + except Exception as ex: + logger.error( + f'protocol ownership check failed for ' + f'{handler.descriptor.id!r}: {ex}' + ) + + return None + + if not owned: logger.error( f'protocol handler {handler.descriptor.id!r} returned a ' f'configuration it does not own' @@ -427,7 +595,7 @@ class PluginRegistry: return result def configFromDict(self, config: dict, **kwargs): - """Recognize a normalized configuration through protocol handlers.""" + """Recognize a normalized mapping through registered capabilities.""" matches = [] for _plugin, handler in self._protocolEntries: @@ -440,6 +608,22 @@ class PluginRegistry: continue if result is not None: + try: + owned = handler.supports(result) + except Exception as ex: + logger.error( + f'protocol ownership check failed for ' + f'{handler.descriptor.id!r}: {ex}' + ) + continue + + if not owned: + logger.error( + f'protocol handler {handler.descriptor.id!r} returned ' + f'an unowned mapping result' + ) + continue + matches.append((handler, result)) if len(matches) > 1: @@ -449,108 +633,165 @@ class PluginRegistry: if matches: return matches[0][1] - backendMatches = [] + factoryMatches = [] - for _plugin, backend in self._backends.values(): + for _plugin, factory in self._factories.values(): try: - result = backend.fromMapping(config, **kwargs) + result = factory.fromMapping(config, **kwargs) except Exception as ex: - logger.error(f'failed to recognize {backend.backendId!r} mapping: {ex}') + logger.error(f'failed to recognize {factory.factoryId!r} mapping: {ex}') continue if result is not None: - backendMatches.append((backend, result)) + if not isinstance(result, factory.configurationTypes): + logger.error( + f'kernel factory {factory.factoryId!r} returned an ' + f'unowned mapping result' + ) + continue - if len(backendMatches) > 1: - names = ', '.join(repr(item[0].backendId) for item in backendMatches) - raise ValueError(f'backend configuration mapping is ambiguous: {names}') + factoryMatches.append((factory, result)) - return backendMatches[0][1] if backendMatches else None + if len(factoryMatches) > 1: + names = ', '.join(repr(item[0].factoryId) for item in factoryMatches) + raise ValueError(f'kernel configuration mapping is ambiguous: {names}') + + return factoryMatches[0][1] if factoryMatches else None def blankConfig(self, protocol, **kwargs): """Create a blank configuration through an exact protocol handler.""" handler = self.handlerForProtocol(protocol) - return handler.blank(**kwargs) if handler is not None else None + if handler is None: + return None + + try: + result = handler.blank(**kwargs) + except Exception as ex: + logger.error( + f'failed to create blank {handler.descriptor.id!r} configuration: ' + f'{ex}' + ) + + return None + + if result is not None: + try: + if handler.supports(result): + return result + except Exception as ex: + logger.error( + f'protocol ownership check failed for ' + f'{handler.descriptor.id!r}: {ex}' + ) + + return None + + logger.error( + f'protocol handler {handler.descriptor.id!r} returned an unowned ' + f'blank configuration' + ) + + return 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 '' + if handler is None: + return '' - def createEditorForProtocol(self, protocol, parent=None, **kwargs): - """Create an editor through an exact protocol handler.""" - handler = self.handlerForProtocol(protocol) + if not remark: + remark = str(getattr(config, 'itemRemark', '')) + + return handler.export(_connectionOf(config), remark) + + def validateConfig(self, config): + """Validate a configuration through its protocol capability.""" + handler = self.handlerForConfig(config) return ( - handler.createEditor(parent=parent, **kwargs) + tuple(handler.validate(_connectionOf(config))) if handler is not None + else ('Unsupported protocol',) + ) + + def createEditorForProtocol(self, protocol, parent=None, **kwargs): + """Create an editor through an exact editor-provider capability.""" + protocolId = _normalizeIdentifier(protocol) + provider = self.editorForProtocol(protocolId) + + return ( + provider.createEditor(protocolId, parent=parent, **kwargs) + if provider is not None else None ) def createEditorForConfig(self, config, parent=None, **kwargs): - """Create an editor through the configuration's protocol handler.""" + """Create an editor for a configuration through capability discovery.""" handler = self.handlerForConfig(config) return ( - handler.createEditor(parent=parent, **kwargs) + self.createEditorForProtocol(handler.descriptor.id, parent, **kwargs) if handler is not None else None ) def managementActions(self, plugin, parent=None, **kwargs): - """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') + """Aggregate management actions from one plugin's action providers.""" + if not isinstance(plugin, FuriousPlugin): + plugin = self.plugin(plugin) + + if plugin is None or self.plugin(plugin.pluginMetadata().id) is not plugin: + raise ValueError('plugin is not registered') actions = [] - for backend in plugin.coreBackends: + for provider in self.capabilities(CapabilityKind.ActionProvider, plugin): try: - actions.extend(backend.createManagementActions(parent=parent, **kwargs)) + actions.extend(provider.createActions(parent=parent, **kwargs)) except Exception as ex: logger.error( f'failed to create management actions for ' - f'{backend.backendId!r}: {ex}' + f'{provider.providerId!r}: {ex}' ) return tuple(actions) def prepareTUN(self, config) -> bool: - """Ask a configuration's backend to prepare native TUN support.""" - backend = self.backendForConfig(config) + """Ask a configuration's factory to prepare native TUN support.""" + factory = self.factoryForConfig(config) - if backend is None: + if factory is None: return False try: - handled = backend.prepareTUN(config) + handled = factory.prepareTUN(_connectionOf(config)) if not isinstance(handled, bool): - raise TypeError('backend TUN preparation result must be a boolean') + raise TypeError('kernel TUN preparation result must be a boolean') return handled except Exception as ex: - logger.error(f'TUN preparation failed for {backend.backendId!r}: {ex}') + logger.error(f'TUN preparation failed for {factory.factoryId!r}: {ex}') return False def routingOptions(self, config): - """Return validated routing modes from a configuration's backend.""" - backend = self.backendForConfig(config) + """Return validated routing modes from a configuration's factory.""" + factory = self.factoryForConfig(config) - if backend is None: + if factory is None: return tuple() try: - options = tuple(backend.routingOptions(config)) + options = tuple(factory.routingOptions(_connectionOf(config))) optionIds = set() for option in options: if not isinstance(option, RoutingOption): raise TypeError( - 'backend routing options must be RoutingOption values' + 'kernel routing options must be RoutingOption values' ) if not isinstance(option.id, str) or not option.id.strip(): @@ -560,9 +801,7 @@ class PluginRegistry: raise TypeError('routing option display name must be a string') if not isinstance(option.translatable, bool): - raise TypeError( - 'routing option translatable flag must be a boolean' - ) + raise TypeError('routing translatable flag must be a boolean') if option.id in optionIds: raise ValueError( @@ -574,13 +813,13 @@ class PluginRegistry: return options except Exception as ex: logger.error( - f'failed to obtain routing options for {backend.backendId!r}: {ex}' + f'failed to obtain routing options for {factory.factoryId!r}: {ex}' ) return tuple() def normalizeRouting(self, config, routing): - """Return a supported routing value or the backend's first option.""" + """Return a supported routing value or the factory's first option.""" options = self.routingOptions(config) if not options: @@ -590,8 +829,64 @@ class PluginRegistry: return routing if routing in optionIds else optionIds[0] + def createKernel(self, config, routing, **kwargs): + """Create a prepared kernel launch for *config*.""" + factory = self.factoryForConfig(config) + + if factory is None: + return None + + request = KernelRequest( + configuration=_connectionOf(config), + routing=self.normalizeRouting(config, routing), + exitCallback=kwargs.pop('exitCallback', None), + messageCallback=kwargs.pop('messageCallback', None), + proxyModeOnly=kwargs.pop('proxyModeOnly', False), + log=kwargs.pop('log', True), + options=kwargs, + ) + launch = factory.create(request) + + if launch is None: + return None + + if not isinstance(launch, KernelLaunch): + raise TypeError('kernel factory must return a KernelLaunch value') + + if factory.kernelTypes and not isinstance(launch.kernel, factory.kernelTypes): + raise TypeError( + f'kernel factory {factory.factoryId!r} returned an unowned kernel' + ) + + return launch + + def startKernel(self, config, routing, **kwargs): + """Create and start the runtime kernel selected for *config*.""" + try: + launch = self.createKernel(config, routing, **kwargs) + + return ( + (launch.kernel, launch.start()) if launch is not None else (None, False) + ) + except Exception as ex: + factory = self.factoryForConfig(config) + factoryId = factory.factoryId if factory is not None else 'unknown' + logger.error(f'kernel start failed for {factoryId!r}: {ex}') + + return None, False + + def prepareDownloadTest(self, config, port: int): + """Create a proxy-only test configuration through its kernel factory.""" + factory = self.factoryForConfig(config) + + return ( + factory.prepareDownloadTest(_connectionOf(config), port) + if factory is not None + else None + ) + def decodeSubscription(self, data: bytes, decoderId=None): - """Decode subscription bytes using an explicit or auto-detected decoder.""" + """Decode subscription bytes using an explicit or detected decoder.""" if not isinstance(data, bytes): raise TypeError('subscription payload must be bytes') @@ -638,65 +933,65 @@ class PluginRegistry: return None def configureEnvironment(self): - """Allow every backend to configure its process environment.""" - for _plugin, backend in self._backends.values(): + """Allow every kernel factory to configure its process environment.""" + for factory in self.kernelFactories(): try: - backend.configureEnvironment() + factory.configureEnvironment() except Exception as ex: - logger.error(f'environment hook failed for {backend.backendId!r}: {ex}') + logger.error(f'environment hook failed for {factory.factoryId!r}: {ex}') def coreVersions(self): - """Return version strings reported by every registered backend.""" + """Return version strings reported by every kernel factory.""" versions = [] - for _plugin, backend in self._backends.values(): + for factory in self.kernelFactories(): try: - versions.extend(backend.coreVersions()) + versions.extend(factory.coreVersions()) except Exception as ex: logger.error( - f'failed to obtain core versions for {backend.backendId!r}: {ex}' + f'failed to obtain core versions for {factory.factoryId!r}: {ex}' ) return tuple(filter(None, versions)) def logTimestampPatterns(self): - """Return timestamp expressions contributed by all backends.""" + """Return timestamp expressions contributed by all kernel factories.""" patterns = [] - for _plugin, backend in self._backends.values(): + for factory in self.kernelFactories(): try: - patterns.extend(backend.logTimestampPatterns()) + patterns.extend(factory.logTimestampPatterns()) except Exception as ex: logger.error( - f'failed to obtain log patterns for {backend.backendId!r}: {ex}' + f'failed to obtain log patterns for {factory.factoryId!r}: {ex}' ) return tuple(filter(None, patterns)) def coreExitMessage(self, core, exitcode: int): - """Return the owning backend's special exit message, if any.""" - backend = self.backendForCore(core) + """Return the owning factory's special exit message, if any.""" + factory = self.factoryForKernel(core) - if backend is None: + if factory is None: return None try: - return backend.coreExitMessage(core, exitcode) + return factory.coreExitMessage(core, exitcode) except Exception as ex: logger.error( - f'failed to interpret core exit for {backend.backendId!r}: {ex}' + f'failed to interpret core exit for {factory.factoryId!r}: {ex}' ) return None def afterConnected(self, httpProxy=None): - """Notify every backend after a connection succeeds.""" - for _plugin, backend in self._backends.values(): + """Notify every kernel factory after a connection succeeds.""" + for factory in self.kernelFactories(): try: - backend.afterConnected(httpProxy) + factory.afterConnected(httpProxy) except Exception as ex: logger.error( - f'post-connection hook failed for {backend.backendId!r}: {ex}' + f'post-connection hook failed for {factory.factoryId!r}: {ex}' ) def discover(self): @@ -741,7 +1036,8 @@ class PluginRegistry: try: plugin.shutdown() except Exception as ex: - logger.error(f'plugin shutdown failed for {plugin.pluginId!r}: {ex}') + pluginMetadata = plugin.pluginMetadata() + logger.error(f'plugin shutdown failed for {pluginMetadata.id!r}: {ex}') self._initializedPlugins.clear() @@ -768,13 +1064,14 @@ def initializePluginRegistry(pluginTypes=()) -> PluginRegistry: else: for pluginType in pluginTypes: plugin = pluginType() - registered = _registry.plugin(plugin.pluginId) + pluginMetadata = plugin.pluginMetadata() + registered = _registry.plugin(pluginMetadata.id) if registered is None: _registry.register(plugin) elif not isinstance(registered, pluginType): raise ValueError( - f'plugin {plugin.pluginId!r} is already registered by ' + f'plugin {pluginMetadata.id!r} is already registered by ' f'{type(registered).__name__}' ) diff --git a/Furious/Plugins/__init__.py b/Furious/Plugins/__init__.py index b3247b5..0b24f97 100644 --- a/Furious/Plugins/__init__.py +++ b/Furious/Plugins/__init__.py @@ -21,11 +21,19 @@ from __future__ import annotations from .API import ( PLUGIN_API_VERSION, - CoreBackend, + ActionProvider, + CapabilityKind, FuriousPlugin, + KernelFactory, + KernelLaunch, + KernelRequest, + PluginCapability, PluginContext, + PluginMetadata, ProtocolDescriptor, + ProtocolEditorProvider, ProtocolHandler, + ProtocolParseResult, RoutingOption, SubscriptionDecoder, SubscriptionItem, @@ -33,9 +41,12 @@ from .API import ( ) from .Profile import ( blankConfiguration, + blankProfile, configurationFromAny, configurationFromMapping, exportConfiguration, + profileFromAny, + profileFromMapping, ) from .Registry import ( PLUGIN_ENTRY_POINT_GROUP, @@ -48,21 +59,32 @@ from .Registry import ( __all__ = [ 'PLUGIN_API_VERSION', 'PLUGIN_ENTRY_POINT_GROUP', - 'CoreBackend', + 'ActionProvider', + 'CapabilityKind', 'FuriousPlugin', + 'KernelFactory', + 'KernelLaunch', + 'KernelRequest', + 'PluginCapability', 'PluginContext', 'PluginRegistry', + 'PluginMetadata', 'ProtocolDescriptor', + 'ProtocolEditorProvider', 'ProtocolHandler', + 'ProtocolParseResult', 'RoutingOption', 'SubscriptionDecoder', 'SubscriptionItem', 'SubscriptionResult', 'blankConfiguration', + 'blankProfile', 'configurationFromAny', 'configurationFromMapping', 'exportConfiguration', 'getPluginRegistry', 'initializePluginRegistry', + 'profileFromAny', + 'profileFromMapping', 'registerPlugin', ] diff --git a/Furious/Qt/EditorWidgets.py b/Furious/Qt/EditorWidgets.py index c475011..398427b 100644 --- a/Furious/Qt/EditorWidgets.py +++ b/Furious/Qt/EditorWidgets.py @@ -21,7 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Domain import ConfigFactory +from Furious.Domain import ConfigFactory, ServerProfile from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Qt.QtWidgets import * @@ -181,13 +181,13 @@ class GuiEditorItemBasicRemark(GuiEditorItemTextInput): """Initialize the GuiEditorItemBasicRemark.""" super().__init__(*args, **kwargs) - def inputToFactory(self, config: ConfigFactory) -> bool: + def inputToFactory(self, config: ServerProfile) -> bool: """Apply the current editor value to the configuration.""" - oldRemark = config.getExtras('remark') + oldRemark = config.metadata.displayName newRemark = self.text() if newRemark != oldRemark: - config.setExtras('remark', newRemark) + config.metadata.displayName = newRemark # Value modified, but not return as a modified behavior return False @@ -195,9 +195,9 @@ class GuiEditorItemBasicRemark(GuiEditorItemTextInput): # Not modified return False - def factoryToInput(self, config: ConfigFactory): + def factoryToInput(self, config: ServerProfile): """Load the configuration value into the editor.""" - self.setText(config.getExtras('remark')) + self.setText(config.metadata.displayName) class GuiEditorItemProxyHttp(GuiEditorItemTextInput): diff --git a/Furious/Repository/Servers.py b/Furious/Repository/Servers.py index f065412..21a4a0b 100644 --- a/Furious/Repository/Servers.py +++ b/Furious/Repository/Servers.py @@ -21,13 +21,13 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Domain.Configuration import ConfigFactory +from Furious.Domain import ProfileMetadata, ServerProfile from Furious.Domain.Encoding import * from Furious.Plugins import configurationFromAny -from dataclasses import dataclass +from dataclasses import asdict, dataclass -__all__ = ['UserServers'] +__all__ = ['UserServer', 'UserServers'] registerAppSettings('Configuration') @@ -36,13 +36,20 @@ registerAppSettings('Configuration') class UserServer: """Represent one serialized user-server storage record.""" - remark: str - config: str - subsId: str + metadata: dict + connection: str + + @classmethod + def fromProfile(cls, profile: ServerProfile): + """Build a storage record from a server profile.""" + return cls(profile.metadata.toMapping(), profile.connection.toJSONString()) + + def toMapping(self) -> dict: + """Return the record as a JSON-compatible mapping.""" + return asdict(self) class UserServers(Mixins.CleanupOnExit, StorageBackend): - # remark, config, subsId. (subsId corresponds to unique in user subscription) """Manage the persisted list of server configurations.""" def __init__(self, *args, **kwargs): @@ -61,10 +68,26 @@ class UserServers(Mixins.CleanupOnExit, StorageBackend): return {'model': []} self._data = restore() - self._list = list( - configurationFromAny(model.pop('config', ''), index=index, **model) - for index, model in enumerate(self._data['model']) - ) + records = self._data.get('profiles', self._data.get('model', [])) + self._list = [] + + for index, value in enumerate(records): + record = dict(value) + + if 'connection' in record: + connection = configurationFromAny(record.get('connection', '')) + metadata = ProfileMetadata.fromMapping(record.get('metadata', {})) + else: + connection = configurationFromAny(record.pop('config', '')) + metadata = ProfileMetadata.fromMapping(record) + + self._list.append( + ServerProfile.fromConfiguration( + connection, + metadata, + index=index, + ) + ) def sync(self): """Persist the current user servers data.""" @@ -72,12 +95,18 @@ class UserServers(Mixins.CleanupOnExit, StorageBackend): 'Configuration', PyBase64Encoder.encode( UJSONEncoder.encode( - {'model': list(factory.toStorageObject() for factory in self._list)} + { + 'schemaVersion': 2, + 'profiles': [ + UserServer.fromProfile(profile).toMapping() + for profile in self._list + ], + } ).encode() ), ) - def data(self) -> list[ConfigFactory]: + def data(self) -> list[ServerProfile]: # Shallow copy """Return the data managed by the user servers.""" return self._list diff --git a/Furious/Repository/Storage.py b/Furious/Repository/Storage.py index 7c95f99..fab5f72 100644 --- a/Furious/Repository/Storage.py +++ b/Furious/Repository/Storage.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Domain import ServerProfile from Furious.Repository.Routings import UserRoutings from Furious.Repository.Servers import UserServers from Furious.Repository.Subscriptions import UserSubs @@ -79,7 +80,7 @@ class Storage: return -1 @staticmethod - def UserServers() -> list[ConfigFactory]: + def UserServers() -> list[ServerProfile]: """Return the user servers value.""" return Storage._UserServersStorage().data() @@ -135,7 +136,7 @@ class Storage: ) if index >= 0: - return f'{index + 1} - ' + servers[index].getExtras('remark') + return f'{index + 1} - ' + servers[index].itemRemark else: # Should not reach here return '' diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index 5b03eb0..3d85374 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -79,7 +79,7 @@ class ConnectionManager(Mixins.CleanupOnExit): self.uniqueCleanup = False self.processesPool = list() - def _startCore( + def _startKernel( self, config, routing, @@ -89,19 +89,12 @@ class ConnectionManager(Mixins.CleanupOnExit): log=True, **kwargs, ) -> Tuple[Union[CoreProcessWorker, None], bool]: - """Start a configuration through the backend that owns it.""" - pluginRegistry = getPluginRegistry() - backend = pluginRegistry.backendForConfig(config) - if backend is None: - return None, False - - routing = pluginRegistry.normalizeRouting(config, routing) - - return backend.startCore( + """Construct and start the runtime kernel selected for a configuration.""" + return getPluginRegistry().startKernel( config, routing, exitCallback=exitCallback, - msgCallback=msgCallback, + messageCallback=msgCallback, proxyModeOnly=proxyModeOnly, log=log, **kwargs, @@ -126,7 +119,7 @@ class ConnectionManager(Mixins.CleanupOnExit): def start( self, - config: ConfigFactory, + config: ConfigFactory | ServerProfile, routing: str, exitCallback=None, msgCallbackCore=None, @@ -155,7 +148,7 @@ class ConnectionManager(Mixins.CleanupOnExit): if not proxyModeOnly and SystemRuntime.isTUNMode(): pluginTUN = getPluginRegistry().prepareTUN(configcopy) - process, success = self._startCore( + process, success = self._startKernel( configcopy, routing, exitCallback, diff --git a/Furious/Service/SubscriptionImporter.py b/Furious/Service/SubscriptionImporter.py new file mode 100644 index 0000000..a6ecb87 --- /dev/null +++ b/Furious/Service/SubscriptionImporter.py @@ -0,0 +1,97 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Convert decoded subscription payloads into metadata-separated profiles.""" + +from __future__ import annotations + +from Furious.Domain import ServerProfile +from Furious.Plugins import getPluginRegistry, profileFromAny + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional, Tuple + +__all__ = [ + 'SubscriptionImportResult', + 'SubscriptionImportService', + 'SubscriptionSource', +] + + +@dataclass(frozen=True) +class SubscriptionSource: + """Describe where a subscription payload came from.""" + + id: str + location: str = '' + displayName: str = '' + decoderId: Optional[str] = None + updatedAt: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + +@dataclass(frozen=True) +class SubscriptionImportResult: + """Return profiles generated from one decoded subscription payload.""" + + decoderId: str + profiles: Tuple[ServerProfile, ...] + rejectedItems: int = 0 + + +class SubscriptionImportService: + """Separate payload decoding from protocol profile construction.""" + + def __init__(self, registry=None): + """Use the supplied capability registry or the application registry.""" + self.registry = registry or getPluginRegistry() + + def importPayload(self, data: bytes, source: SubscriptionSource): + """Decode *data* and construct supported profiles for *source*.""" + result = self.registry.decodeSubscription(data, source.decoderId) + + if result is None: + return None + + profiles = [] + rejected = 0 + + for item in result.items: + value = item.configuration if item.configuration is not None else item.uri + metadata = { + **dict(item.metadata), + 'subscriptionSource': source.id, + 'updatedAt': source.updatedAt, + } + + if item.name: + metadata['displayName'] = item.name + + profile = profileFromAny( + value, + registry=self.registry, + **metadata, + ) + + if not self.registry.validateConfig(profile): + profiles.append(profile) + else: + rejected += 1 + + return SubscriptionImportResult(result.decoderId, tuple(profiles), rejected) diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py index 5cdd549..ee9c1e2 100644 --- a/Furious/Service/__init__.py +++ b/Furious/Service/__init__.py @@ -22,11 +22,19 @@ from __future__ import annotations from .ConnectionManager import ConnectionManager from .ConnectivityManager import ConnectivityManager from .DnsResolver import DnsResolver +from .SubscriptionImporter import ( + SubscriptionImportResult, + SubscriptionImportService, + SubscriptionSource, +) from .UpdateManager import UpdateManager __all__ = [ 'ConnectionManager', 'ConnectivityManager', 'DnsResolver', + 'SubscriptionImportResult', + 'SubscriptionImportService', + 'SubscriptionSource', 'UpdateManager', ] diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index c972d99..37da6e2 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -24,14 +24,18 @@ from Furious.Interface import * from Furious.Domain import * from Furious.Repository import * from Furious.Plugins import ( - blankConfiguration, - configurationFromAny, + blankProfile, exportConfiguration, getPluginRegistry, + profileFromAny, ) from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Service import ConnectionManager +from Furious.Service import ( + ConnectionManager, + SubscriptionImportService, + SubscriptionSource, +) from Furious.Widget.WaitingSpinner import * from PySide6 import QtCore @@ -152,6 +156,8 @@ class SubscriptionManager(WebGETManager): super().__init__(parent, actionMessage=actionMessage, mustCallOnce=False) + self.importer = SubscriptionImportService() + def handleItemDeletionAndInsertion(self, **kwargs): """Handle item deletion and insertion.""" successArgs = kwargs.pop('successArgs', list()) @@ -159,7 +165,7 @@ class SubscriptionManager(WebGETManager): showMessageBox = kwargs.pop('showMessageBox', True) for param in successArgs: - items, unique = param['items'], param['unique'] + profiles, unique = param['profiles'], param['unique'] parent = self.parent() @@ -169,7 +175,7 @@ class SubscriptionManager(WebGETManager): subsIndexes = list( index for index, server in enumerate(Storage.UserServers()) - if server.getExtras('subsId') == unique + if server.itemSubscription == unique ) subsGroupIndex = -1 @@ -178,7 +184,7 @@ class SubscriptionManager(WebGETManager): if activatedIndex in subsIndexes: for index, server in enumerate(Storage.UserServers()): if index <= activatedIndex: - if server.getExtras('subsId') == unique: + if server.itemSubscription == unique: subsGroupIndex += 1 else: break @@ -191,18 +197,8 @@ class SubscriptionManager(WebGETManager): remaining = len(Storage.UserServers()) - 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, - ) + for profile in profiles: + parent.appendNewItemByFactory(profile) if subsGroupIndex >= 0: newIndex = remaining + subsGroupIndex @@ -248,18 +244,24 @@ class SubscriptionManager(WebGETManager): failureArgs = kwargs.get('failureArgs', list()) data = bytes(networkReply.readAll().data()) - decoderId = kwargs.get('decoderId') - result = getPluginRegistry().decodeSubscription(data, decoderId) + source = SubscriptionSource( + kwargs.get('unique', ''), + webURL, + remark, + kwargs.get('decoderId'), + ) + result = self.importer.importPayload(data, source) - if result is None: + if result is None or not result.profiles: failureArgs.append({'error': 'UnsupportedSubscriptionFormat', **kwargs}) else: logger.info( f'update subs ({remark}, {webURL}) success. ' - f'Got {len(result.items)} profiles from {result.decoderId!r}' + f'Got {len(result.profiles)} profiles from {result.decoderId!r}; ' + f'rejected {result.rejectedItems}' ) - successArgs.append({'items': result.items, **kwargs}) + successArgs.append({'profiles': result.profiles, **kwargs}) def failureCallback(self, networkReply, **kwargs): """Handle a failed network operation.""" @@ -332,7 +334,7 @@ class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): finished = QtCore.Signal() - def __init__(self, factory: ConfigFactory): + def __init__(self, factory: ServerProfile): # Explictly called __init__ """Initialize the TestPingLatencyWorker.""" QtCore.QObject.__init__(self) @@ -348,7 +350,7 @@ class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): # Invalid item. Do nothing return - assert isinstance(self.factory, ConfigFactory) + assert isinstance(self.factory, ServerProfile) try: result = icmplib.ping( @@ -360,16 +362,16 @@ class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): except Exception as ex: # Any non-exit exceptions - self.factory.setExtras('delayResult', classname(ex)) + self.factory.metadata.latency = classname(ex) else: # Result address should not be empty if result.address and result.is_alive: - self.factory.setExtras('delayResult', f'{round(result.avg_rtt)}ms') + self.factory.metadata.latency = f'{round(result.avg_rtt)}ms' else: if result.packet_loss == 1: - self.factory.setExtras('delayResult', 'Timeout') + self.factory.metadata.latency = 'Timeout' else: - self.factory.setExtras('delayResult', 'Error') + self.factory.metadata.latency = 'Error' finally: # Extra guard if not appIsExiting(): @@ -381,7 +383,7 @@ class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): finished = QtCore.Signal() - def __init__(self, factory: ConfigFactory): + def __init__(self, factory: ServerProfile): # Explictly called __init__ """Initialize the TestTcpingLatencyWorker.""" QtCore.QObject.__init__(self) @@ -397,7 +399,7 @@ class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): # Invalid item. Do nothing return - assert isinstance(self.factory, ConfigFactory) + assert isinstance(self.factory, ServerProfile) try: sent, rtts = tcping( @@ -410,12 +412,12 @@ class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): except Exception as ex: # Any non-exit exceptions - self.factory.setExtras('delayResult', classname(ex)) + self.factory.metadata.latency = classname(ex) else: if rtts: - self.factory.setExtras('delayResult', f'{round(rtts[0] * 1000)}ms') + self.factory.metadata.latency = f'{round(rtts[0] * 1000)}ms' else: - self.factory.setExtras('delayResult', 'Timeout') + self.factory.metadata.latency = 'Timeout' finally: # Extra guard if not appIsExiting(): @@ -430,7 +432,7 @@ class TestDownloadSpeedWorker(WebGETManager): def __init__( self, - factory: ConfigFactory, + factory: ServerProfile, port: int, timeout: int, parent=None, @@ -495,15 +497,15 @@ class TestDownloadSpeedWorker(WebGETManager): """Handle the core exit callback.""" try: if exitcode == CoreProcess.ExitCode.ConfigurationError.value: - self.factory.setExtras('speedResult', f'Invalid') + self.factory.metadata.speed = 'Invalid' self.sync() elif exitcode == CoreProcess.ExitCode.ServerStartFailure.value: - self.factory.setExtras('speedResult', f'Core start failed') + self.factory.metadata.speed = 'Core start failed' self.sync() elif exitcode == CoreProcess.ExitCode.SystemShuttingDown.value: pass else: - self.factory.setExtras('speedResult', f'Core exited {exitcode}') + self.factory.metadata.speed = f'Core exited {exitcode}' self.sync() finally: self.must() @@ -518,23 +520,16 @@ class TestDownloadSpeedWorker(WebGETManager): pass - def _startCore(self, config) -> bool: - """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 = backend.prepareDownloadTest(config, self.port) + def _startKernel(self, config) -> bool: + """Prepare and start a download test through its runtime factory.""" + configcopy = getPluginRegistry().prepareDownloadTest(config, self.port) if configcopy is None: - self.factory.setExtras('speedResult', 'Invalid') + self.factory.metadata.speed = 'Invalid' self.sync() return False - self.factory.setExtras('speedResult', 'Starting') + self.factory.metadata.speed = 'Starting' self.sync() return self.coreManager.start( @@ -559,14 +554,14 @@ class TestDownloadSpeedWorker(WebGETManager): # Invalid item. Do nothing return - assert isinstance(self.factory, ConfigFactory) + assert isinstance(self.factory, ServerProfile) if not self.factory.isValid(): # Configuration is invalid - self.factory.setExtras('speedResult', 'Invalid') + self.factory.metadata.speed = 'Invalid' self.sync() else: - if not self._startCore(self.factory) or appIsExiting(): + if not self._startKernel(self.factory) or appIsExiting(): return self.configureHttpProxy(f'127.0.0.1:{self.port}') @@ -596,9 +591,9 @@ class TestDownloadSpeedWorker(WebGETManager): elapsedSecond = self.elapsedTimer.elapsed() / 1000 downloadSpeed = self.totalBytesRead / elapsedSecond / 1024 / 1024 - self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s') + self.factory.metadata.speed = f'{downloadSpeed:.2f} MiB/s' else: - self.factory.setExtras('speedResult', f'Core start failed') + self.factory.metadata.speed = 'Core start failed' self.coreManager.stopAll() self.sync() @@ -616,7 +611,7 @@ class TestDownloadSpeedWorker(WebGETManager): # Has speed test result self.hasSpeedResult = True - self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s') + self.factory.metadata.speed = f'{downloadSpeed:.2f} MiB/s' # Limited to save CPU resources if self.hasDataCounter % 25 == 0: @@ -634,7 +629,7 @@ class TestDownloadSpeedWorker(WebGETManager): == QNetworkReply.NetworkError.OperationCanceledError ): # Canceled by application - self.factory.setExtras('speedResult', 'Canceled') + self.factory.metadata.speed = 'Canceled' else: try: error = networkReply.error().name @@ -652,9 +647,9 @@ class TestDownloadSpeedWorker(WebGETManager): error = 'UnknownError' if error != 'UnknownError' and error.endswith('Error'): - self.factory.setExtras('speedResult', error[:-5]) + self.factory.metadata.speed = error[:-5] else: - self.factory.setExtras('speedResult', error) + self.factory.metadata.speed = error self.coreManager.stopAll() self.sync() @@ -666,7 +661,7 @@ class DownloadSpeedTestJob: def __init__( self, index: int, - factory: ConfigFactory, + factory: ServerProfile, timeout: int, logActionMessage=False, ): @@ -703,7 +698,7 @@ class DownloadSpeedTestScheduler(QtCore.QObject): def enqueue( self, index: int, - factory: ConfigFactory, + factory: ServerProfile, timeout: int, logActionMessage=False, ): @@ -753,7 +748,7 @@ class DownloadSpeedTestScheduler(QtCore.QObject): while self.queue and len(self.activeJobs) < self.maxConcurrency: job = self.queue.popleft() - assert isinstance(job.factory, ConfigFactory) + assert isinstance(job.factory, ServerProfile) if job.factory.deleted: continue @@ -947,7 +942,7 @@ class DeleteServersProgressDialog(AppQDialog): factory = Storage.UserServers()[deleteIndex] - self.currentRemark = self.limitedRemark(factory.getExtras('remark')) + self.currentRemark = self.limitedRemark(factory.itemRemark) if originalIndex == Storage.UserActivatedItemIndex(): self.deletedActivated = True @@ -1030,7 +1025,7 @@ class ServerTableColumn: self.name = name self.func = func - def __call__(self, item: ConfigFactory) -> str: + def __call__(self, item: ServerProfile) -> str: """Invoke the user servers Qt table view headers as a callable.""" if callable(self.func): return self.func(item) @@ -1046,7 +1041,7 @@ class ServerTableColumn: return self.name -def _subscriptionRemark(item: ConfigFactory) -> str: +def _subscriptionRemark(item: ServerProfile) -> str: """Resolve a persisted subscription ID to its user-visible remark.""" subscription = Storage.UserSubs().get(item.itemSubscription, {}) @@ -1223,7 +1218,7 @@ class UserServersTableModel(QtCore.QAbstractTableModel): header = self.headers[column] - def keyFn(factory: ConfigFactory): + def keyFn(factory: ServerProfile): """Return the key fn value used by the user servers table model.""" data = header(factory) @@ -1827,7 +1822,7 @@ class ServerTableView( return - guiEditor.setWindowTitle(f'{index + 1} - ' + factory.getExtras('remark')) + guiEditor.setWindowTitle(f'{index + 1} - ' + factory.itemRemark) try: guiEditor.factoryToInput(factory) @@ -1856,7 +1851,7 @@ class ServerTableView( self, editor: GuiEditorWidgetQDialog, index: int, - factory: ConfigFactory, + factory: ServerProfile, ): """Handle GUI editor accepted.""" logger.debug(f'guiEditor accepted with index {index}') @@ -1907,7 +1902,7 @@ class ServerTableView( self.sourceModel.emitRowChanged(oldIndex) self.sourceModel.emitRowChanged(index) - def flushItem(self, row: int, column: int, item: ConfigFactory): + def flushItem(self, row: int, column: int, item: ServerProfile): """Refresh item.""" itemIndex = item.index @@ -1972,7 +1967,7 @@ class ServerTableView( **kwargs, ): """Add server via GUI.""" - factory = blankConfiguration(protocol) + factory = blankProfile(protocol) guiEditor = self.getGuiEditorByFactory(factory, **kwargs) @@ -2007,7 +2002,7 @@ class ServerTableView( def handleAddServerViaGuiAccepted( self, editor: GuiEditorWidgetQDialog, - factory: ConfigFactory, + factory: ServerProfile, ): """Handle add server via GUI accepted.""" editor.inputToFactory(factory) @@ -2022,7 +2017,7 @@ class ServerTableView( editor.accepted.disconnect() editor.rejected.disconnect() - def flushRow(self, row: int, item: ConfigFactory): + def flushRow(self, row: int, item: ServerProfile): """Refresh row.""" itemIndex = item.index @@ -2135,7 +2130,7 @@ class ServerTableView( # Do not clone subsId self.appendNewItem( - remark=deepcopy.getExtras('remark'), + remark=deepcopy.itemRemark, config=deepcopy, ) @@ -2231,9 +2226,9 @@ class ServerTableView( mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) mbox.isMulti = bool(len(indexes) > 1) - mbox.possibleRemark = f'{indexes[0] + 1} - ' + Storage.UserServers()[ - indexes[0] - ].getExtras('remark') + mbox.possibleRemark = ( + f'{indexes[0] + 1} - ' + Storage.UserServers()[indexes[0]].itemRemark + ) mbox.setText(mbox.customText()) mbox.finished.connect(functools.partial(handleResultCode, indexes)) @@ -2253,7 +2248,7 @@ class ServerTableView( return index = indexes[0] - title = f'{index + 1} - ' + Storage.UserServers()[index].getExtras('remark') + title = f'{index + 1} - ' + Storage.UserServers()[index].itemRemark self.configurationEditor.currentIndex = index self.configurationEditor.customWindowTitle = title @@ -2288,7 +2283,7 @@ class ServerTableView( self.setCurrentIndex(activatedItem) self.scrollTo(activatedItem) - def rowFromFactory(self, fallbackIndex: int, factory: ConfigFactory) -> int: + def rowFromFactory(self, fallbackIndex: int, factory: ServerProfile) -> int: """Return the row from factory value.""" if ( 0 <= factory.index < len(Storage.UserServers()) @@ -2308,7 +2303,7 @@ class ServerTableView( return -1 - def flushDownloadSpeedItem(self, fallbackIndex: int, factory: ConfigFactory): + def flushDownloadSpeedItem(self, fallbackIndex: int, factory: ServerProfile): """Refresh download speed item.""" index = self.rowFromFactory(fallbackIndex, factory) @@ -2332,7 +2327,7 @@ class ServerTableView( if appIsExiting(): break - assert isinstance(reference, ConfigFactory) + assert isinstance(reference, ServerProfile) if reference.deleted: continue @@ -2365,7 +2360,7 @@ class ServerTableView( if appIsExiting(): break - assert isinstance(reference, ConfigFactory) + assert isinstance(reference, ServerProfile) if reference.deleted: continue @@ -2386,7 +2381,7 @@ class ServerTableView( def testDownloadSpeedByFactory( self, index: int, - factory: ConfigFactory, + factory: ServerProfile, port: int, timeout: int, isMulti: bool, @@ -2453,8 +2448,8 @@ class ServerTableView( for index in indexes: factory = Storage.UserServers()[index] - factory.setExtras('delayResult', '') - factory.setExtras('speedResult', '') + factory.metadata.latency = '' + factory.metadata.speed = '' self.flushItem(index, self.Headers.index('Latency'), factory) self.flushItem(index, self.Headers.index('Speed'), factory) @@ -2476,8 +2471,9 @@ class ServerTableView( self.subsManager.configureHttpProxy(httpProxy) self.subsManager.updateSubs(**kwargs) - def appendNewItemByFactory(self, factory: ConfigFactory): + def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile): """Append new item by factory.""" + factory = ensureProfile(factory) index = len(Storage.UserServers()) # Set index @@ -2512,7 +2508,7 @@ class ServerTableView( } tostr = f'{model}' - factory = configurationFromAny(model.pop('config', ''), **model) + factory = profileFromAny(model.pop('config', ''), **model) if factory.isValid(): self.appendNewItemByFactory(factory) @@ -2532,7 +2528,7 @@ class ServerTableView( def toURI(factory) -> str: """Export the configuration as a share URI.""" - assert isinstance(factory, ConfigFactory) + assert isinstance(factory, ServerProfile) try: return exportConfiguration(factory) diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 48a33f3..d82f929 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -23,7 +23,7 @@ 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 CapabilityKind, getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Service import ConnectivityManager, UpdateManager @@ -329,7 +329,7 @@ class MainWindow(AppQMainWindow): list( index for index, server in enumerate(Storage.UserServers()) - if server.getExtras('subsId') == unique + if server.itemSubscription == unique ), showProgress=False, ), @@ -372,11 +372,22 @@ class MainWindow(AppQMainWindow): ] serverActions = [] + for descriptor in pluginRegistry.protocolDescriptors(): + if ( + not descriptor.addActionText + or pluginRegistry.editorForProtocol(descriptor.id) is None + ): + continue + if descriptor.separatorBefore and serverActions: serverActions.append(AppQSeperator()) - actionText = _(descriptor.addActionText) + actionText = ( + _(descriptor.addActionText) + if descriptor.translatable + else descriptor.addActionText + ) serverActions.append( AppQAction( actionText, @@ -483,11 +494,16 @@ class MainWindow(AppQMainWindow): ), ] systemTools = [*restartAsAdminAction, *openAppFolderAction] + if systemTools: toolsActions.extend([AppQSeperator(), *systemTools]) corePluginActions = [] - for plugin in pluginRegistry.corePlugins(): + + for plugin in pluginRegistry.pluginsWithCapability( + CapabilityKind.KernelFactory + ): + pluginMetadata = pluginRegistry.metadataFor(plugin) managementActions = pluginRegistry.managementActions( plugin, parent=self, @@ -496,7 +512,7 @@ class MainWindow(AppQMainWindow): if managementActions: corePluginActions.append( AppQAction( - _(plugin.displayName), + pluginMetadata.displayName, menu=AppQMenu(*managementActions), useActionGroup=False, checkable=False, @@ -505,7 +521,36 @@ class MainWindow(AppQMainWindow): else: corePluginActions.append( AppQAction( - _(plugin.displayName), + pluginMetadata.displayName, + checkable=False, + ) + ) + + extensionPluginActions = [] + + corePlugins = set( + pluginRegistry.pluginsWithCapability(CapabilityKind.KernelFactory) + ) + + for plugin in pluginRegistry.pluginsWithCapability( + CapabilityKind.ActionProvider + ): + if plugin in corePlugins: + continue + + managementActions = pluginRegistry.managementActions( + plugin, + parent=self, + isCoreActive=_isCoreActive, + ) + + if managementActions: + pluginMetadata = pluginRegistry.metadataFor(plugin) + extensionPluginActions.append( + AppQAction( + pluginMetadata.displayName, + menu=AppQMenu(*managementActions), + useActionGroup=False, checkable=False, ) ) @@ -516,8 +561,19 @@ class MainWindow(AppQMainWindow): menu=AppQMenu(*corePluginActions), useActionGroup=False, checkable=False, - ) + ), ] + + if extensionPluginActions: + pluginActions.append( + AppQAction( + _('Extensions'), + menu=AppQMenu(*extensionPluginActions), + useActionGroup=False, + checkable=False, + ) + ) + pluginsToolbarActions = [ AppQSeperator(), AppQAction( @@ -714,11 +770,11 @@ class MainWindow(AppQMainWindow): """Update subs by unique.""" self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs) - def appendNewItemByFactory(self, factory: ConfigFactory): + def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile): """Append new item by factory.""" self.userServersQTableWidget.appendNewItemByFactory(factory) - def flushRow(self, row: int, item: ConfigFactory): + def flushRow(self, row: int, item: ServerProfile): """Refresh row.""" self.userServersQTableWidget.flushRow(row, item) diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index 76536e0..15185a8 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -56,7 +56,7 @@ class QRCodeWindow(AppQMainWindow): """Return the tab count value used by the QR code window.""" return self.tabWidget.count() - def initTabByIndex(self, indexes): + def initTabByIndex(self, indexes: list[int]): """Handle init tab by index for the QR code window.""" self.tabWidget.clear() @@ -82,9 +82,7 @@ class QRCodeWindow(AppQMainWindow): widget = QLabel(parent=self.tabWidget) widget.setPixmap(pixmap) - self.tabWidget.addTab( - widget, f'{index + 1} - ' + config.getExtras('remark') - ) + self.tabWidget.addTab(widget, f'{index + 1} - ' + config.itemRemark) @QtCore.Slot(int) def handleTabCloseRequested(self, index): diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index 9ec8bbe..26af044 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -296,7 +296,8 @@ class TextEditorWindow(AppQMainWindow): return False else: old = Storage.UserServers()[index] - new = configurationFromMapping(jsonObject, **old.kwargs) + connection = configurationFromMapping(jsonObject) + new = old.replaceConnection(connection) old.deleted = True diff --git a/Furious/__init__.py b/Furious/__init__.py index 0edf7ab..ec2bdfb 100644 --- a/Furious/__init__.py +++ b/Furious/__init__.py @@ -15,10 +15,22 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Initialize the Furious application package.""" +"""Initialize Furious without eagerly loading its Qt resource module.""" from __future__ import annotations -from .Frozenlib import AppResources +from importlib import import_module __all__ = ['AppResources'] + + +def __getattr__(name: str): + """Load generated Qt resources only when requested by the application.""" + if name != 'AppResources': + raise AttributeError(name) + + resources = import_module('.Frozenlib.AppResources', __name__) + + globals()[name] = resources + + return resources