mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Expand capability-based plugin architecture
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -227,7 +227,7 @@ class ConnectAction(AppQAction):
|
||||
|
||||
self.setChecked(False)
|
||||
else:
|
||||
assert isinstance(config, ConfigFactory)
|
||||
assert isinstance(config, ServerProfile)
|
||||
|
||||
@forceToLocalhostIfPossible()
|
||||
def getHttpProxy() -> str:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 _
|
||||
|
||||
|
||||
@@ -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(),)
|
||||
@@ -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(),)
|
||||
|
||||
@@ -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 _
|
||||
|
||||
|
||||
@@ -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) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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(),)
|
||||
@@ -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(),)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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(),)
|
||||
@@ -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',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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())
|
||||
|
||||
Vendored
+30
-38
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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 ''
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
+553
-256
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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)
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user