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