Expand capability-based plugin architecture

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:49 +08:00
parent 8d36749530
commit 8e1f3defaf
43 changed files with 2060 additions and 975 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ class ConnectAction(AppQAction):
self.setChecked(False)
else:
assert isinstance(config, ConfigFactory)
assert isinstance(config, ServerProfile)
@forceToLocalhostIfPossible()
def getHttpProxy() -> str:
+9 -11
View File
@@ -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
+34 -75
View File
@@ -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,
}
]
},
+1 -1
View File
@@ -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 _
+39 -30
View File
@@ -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(),
)
@@ -15,25 +15,26 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Define the 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(),)
+21 -12
View File
@@ -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(),)
+1 -1
View File
@@ -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 _
+39 -25
View File
@@ -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(),
)
@@ -0,0 +1,40 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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(),)
+21 -12
View File
@@ -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(),)
+53 -35
View File
@@ -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(),
)
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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(),)
+79 -32
View File
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
),
)
+14 -6
View File
@@ -15,15 +15,23 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Expose 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
+3 -72
View File
@@ -15,12 +15,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Define the 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
+4 -6
View File
@@ -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
+286
View File
@@ -0,0 +1,286 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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)
+51
View File
@@ -0,0 +1,51 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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
+18 -1
View File
@@ -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',
]
+41 -18
View File
@@ -15,12 +15,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Decode the plain-text and base64 share-link subscription formats."""
"""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())
+30 -38
View File
@@ -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"
}
}
+2 -3
View File
@@ -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'
-39
View File
@@ -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.
+4 -4
View File
@@ -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',
+10 -6
View File
@@ -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
-75
View File
@@ -1,75 +0,0 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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 ''
+2 -5
View File
@@ -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',
]
+179 -35
View File
@@ -15,27 +15,86 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Define the 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."""
+88 -23
View File
@@ -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,
)
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -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',
]
+6 -6
View File
@@ -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):
+42 -13
View File
@@ -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
+3 -2
View File
@@ -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 ''
+6 -13
View File
@@ -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,
+97
View File
@@ -0,0 +1,97 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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)
+8
View File
@@ -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',
]
+83 -87
View File
@@ -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)
+65 -9
View File
@@ -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)
+2 -4
View File
@@ -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):
+2 -1
View File
@@ -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
+14 -2
View File
@@ -15,10 +15,22 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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